Exercice 4 : Streaming
Exercice 4 : Mise en Place du Streaming avec Server-Sent Events
Dans ce chapitre, nous allons ajouter une fonctionnalité de streaming à nos réponses de LLM, c'est-à-dire que l'utilisateur va voir la réponse se construire en temps réel, un peu comme sur ChatGPT. Pour cela, nous allons utiliser les Server-Sent Events (SSE) et le nouveau package @laravel/stream-vue de Laravel.
1. Concepts : Streaming en Temps Réel
1.1 Autre Approche : Broadcasting avec Pusher
Il existe d'autres solutions pour implémenter du streaming en temps réel, notamment Pusher avec Laravel Echo. Le principe est :
- Pusher : service externe qui gère les connexions WebSocket
- Laravel Echo : package JavaScript qui se connecte aux canaux Pusher
- Broadcasting : Laravel diffuse des événements vers Pusher, qui les redistribue aux clients connectés
Cette approche fonctionne bien mais nécessite une configuration plus complexe, un service externe payant, et introduit de la latence. Nous allons utiliser une approche plus simple et directe.
1.2 Notre Approche : Server-Sent Events
Les Server-Sent Events sont une technologie web native qui permet au serveur d'envoyer des données au client en temps réel via une connexion HTTP persistante. Plus simple et plus direct :
- ✅ Connexion HTTP standard (pas de WebSocket)
- ✅ Aucun service externe requis
- ✅ Aucune configuration serveur supplémentaire
- ✅ Latence réduite
- ✅ Configuration minimale
1.3 Comment Fonctionne le Streaming ?
Analogie : Le Robinet vs. Le Seau
Pour comprendre le streaming, imagine deux façons de remplir un verre d'eau :
🪣 Approche Classique (Sans Streaming) :
- Tu demandes un verre d'eau
- On va chercher un seau entier d'eau
- On attend que le seau soit complètement rempli
- Seulement après, on te verse le verre
- ⏱️ Temps d'attente : long (tu attends que tout soit prêt)
🚰 Approche Streaming :
- Tu demandes un verre d'eau
- On ouvre le robinet directement dans ton verre
- L'eau coule en continu, goutte par goutte
- Tu peux commencer à boire pendant que ça coule
- ⏱️ Temps d'attente : court (tu vois immédiatement les premières gouttes)
C'est exactement ce qui se passe avec les réponses de l'IA :
- Sans streaming : tu attends 30 secondes avant de voir la réponse complète
- Avec streaming : tu vois les mots apparaître en temps réel, comme sur ChatGPT
Les Événements SSE en Action
Le serveur envoie la réponse chunk par chunk (morceau par morceau). Voici deux façons de structurer ces chunks :
Approche 1 : Texte Brut avec Marqueurs
Le serveur envoie du texte simple avec des marqueurs spéciaux pour séparer le contenu du reasoning :

[REASONING]L'utilisateur me demande[/REASONING][REASONING] de raconter[/REASONING]
Je peux tout[/REASONING][REASONING] à fait raconter une histoire
- ✅ Avantage : Simple à générer côté serveur
- ❌ Inconvénient : Parsing plus complexe côté client (regex)
Approche 2 : Événements JSON Structurés
Le serveur envoie des événements SSE au format JSON :

{"type":"reasoning","data":" mais tra"}
{"type":"reasoning","data":"itons l'aspect"}
{"type":"content","data":"#"}
{"type":"content","data":" L'Aff"}
{"type":"content","data":"aire Pét"}
- ✅ Avantage : Structure claire, facile à parser
- ❌ Inconvénient : Overhead JSON à chaque chunk
Notre Implémentation
Nous utilisons l'Approche 1 (texte brut avec marqueurs) car :
- Plus simple à implémenter côté serveur (juste des
echo) - Compatible nativement avec
useStreamde Laravel - Moins de données transférées (pas de JSON à chaque chunk)
- Le parsing côté client est géré une seule fois à la fin
2. Configuration Préalable : Nginx sous Windows
2.1 Configuration de Laragon avec Nginx
Si vous utilisez Laragon sous Windows, vous devez configurer Nginx au lieu d'Apache pour que les Server-Sent Events fonctionnent correctement.
Étapes dans Laragon :
- Ouvrir les préférences de Laragon (clic droit sur l'icône dans la barre des tâches)
- Onglet "Services & Ports"
- Décocher "Apache"
- Cocher "Nginx"
- Configurer le port sur 80 (port par défaut)
- Redémarrer Laragon


3. Installation des Dépendances
3.1 Installation du package JavaScript
La seule dépendance nécessaire côté front-end :
npm install @laravel/stream-vue
4. Mise en Place côté Backend
4.1 Service de Streaming - SimpleAskStreamService
Créez un nouveau service dédié au streaming. Ce service gère la communication avec l'API OpenRouter et parse les chunks SSE en temps réel.
Fichier : app/Services/SimpleAskStreamService.php
<?php
declare(strict_types=1);
namespace App\Services;
use Generator;
use Illuminate\Support\Facades\Http;
use Psr\Http\Message\StreamInterface;
/**
* Service simplifié pour le streaming avec l'API OpenRouter.
*
* Exemple pédagogique utilisant le client HTTP de Laravel.
*
* @see https://openrouter.ai/docs/api/reference/streaming
*/
class SimpleAskStreamService
{
public const DEFAULT_MODEL = 'openai/gpt-4o-mini';
private string $apiKey;
private string $baseUrl;
public function __construct()
{
$this->apiKey = config('services.openrouter.api_key');
$this->baseUrl = rtrim(config('services.openrouter.base_url', 'https://openrouter.ai/api/v1'), '/');
}
/**
* Récupère la liste des modèles disponibles (avec cache).
*/
public function getModels(): array
{
return cache()->remember('openrouter.models', now()->addHour(), function (): array {
$response = Http::withToken($this->apiKey)->get("{$this->baseUrl}/models");
return collect($response->json('data', []))
->sortBy('name')
->map(fn(array $model): array => [
'id' => $model['id'],
'name' => $model['name'],
'description' => $model['description'] ?? '',
'context_length' => $model['context_length'] ?? 0,
'max_completion_tokens' => $model['top_provider']['max_completion_tokens'] ?? 0,
'input_modalities' => $model['architecture']['input_modalities'] ?? [],
'output_modalities' => $model['architecture']['output_modalities'] ?? [],
'supported_parameters' => $model['supported_parameters'] ?? [],
])
->values()
->toArray();
});
}
/**
* Récupère la liste légère des modèles.
*/
public function getModelsLight(): array
{
return collect($this->getModels())
->map(fn(array $m): array => ['id' => $m['id'], 'name' => $m['name']])
->values()
->toArray();
}
/**
* Récupère les détails d'un modèle.
*/
public function getModelDetails(string $id): ?array
{
return collect($this->getModels())->firstWhere('id', $id);
}
/**
* Stream un message en temps réel vers la sortie.
* Output le contenu texte directement (compatible avec useStream de Laravel).
*/
public function streamToOutput(
array $messages,
?string $model = null,
float $temperature = 1.0,
?string $reasoningEffort = null
): void {
$response = $this->sendStreamRequest($messages, $model, $temperature, $reasoningEffort);
if ($response->failed()) {
echo "[ERROR] " . $response->json('error.message', 'HTTP Error');
$this->flush();
return;
}
foreach ($this->parseSSEStream($response->toPsrResponse()->getBody()) as $event) {
if ($event['type'] === 'error') {
echo "[ERROR] " . $event['data'];
$this->flush();
return;
}
if ($event['type'] === 'content' && $event['data']) {
echo $event['data'];
$this->flush();
}
// Pour le reasoning, on utilise un préfixe spécial
if ($event['type'] === 'reasoning' && $event['data']) {
echo "[REASONING]" . $event['data'] . "[/REASONING]";
$this->flush();
}
}
}
/**
* Flush la sortie immédiatement.
*/
private function flush(): void
{
if (ob_get_level() > 0) {
ob_flush();
}
flush();
}
/**
* Envoie la requête streaming à l'API.
*/
private function sendStreamRequest(
array $messages,
?string $model,
float $temperature,
?string $reasoningEffort
): \Illuminate\Http\Client\Response {
$payload = [
'model' => $model ?? self::DEFAULT_MODEL,
'messages' => [$this->getSystemPrompt(), ...$messages],
'temperature' => $temperature,
'stream' => true,
];
if ($reasoningEffort !== null) {
$payload['reasoning'] = ['effort' => $reasoningEffort];
}
return Http::withToken($this->apiKey)
->withHeaders([
'HTTP-Referer' => config('app.url'),
'X-Title' => config('app.name'),
])
->withOptions(['stream' => true])
->timeout(120)
->post("{$this->baseUrl}/chat/completions", $payload);
}
/**
* Parse un stream SSE et yield les événements.
*
* @return Generator<array{type: string, data: string|null}>
*/
private function parseSSEStream(StreamInterface $body): Generator
{
$buffer = '';
while (!$body->eof()) {
$buffer .= $body->read(1024);
while (($pos = strpos($buffer, "\n")) !== false) {
$line = trim(substr($buffer, 0, $pos));
$buffer = substr($buffer, $pos + 1);
if ($event = $this->parseSSELine($line)) {
yield $event;
}
}
}
}
/**
* Parse une ligne SSE.
*/
private function parseSSELine(string $line): ?array
{
if ($line === '' || str_starts_with($line, ':')) {
return null;
}
if (!str_starts_with($line, 'data: ')) {
return null;
}
$data = substr($line, 6);
if ($data === '[DONE]') {
return ['type' => 'done', 'data' => null];
}
return $this->parseJSON($data);
}
/**
* Parse le JSON d'un chunk SSE.
*/
private function parseJSON(string $json): ?array
{
try {
$parsed = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
if (isset($parsed['error'])) {
return ['type' => 'error', 'data' => $parsed['error']['message'] ?? 'Unknown error'];
}
$delta = $parsed['choices'][0]['delta'] ?? [];
if (!empty($delta['content'])) {
return ['type' => 'content', 'data' => $delta['content']];
}
if (!empty($delta['reasoning'])) {
return ['type' => 'reasoning', 'data' => $delta['reasoning']];
}
if (!empty($delta['reasoning_content'])) {
return ['type' => 'reasoning', 'data' => $delta['reasoning_content']];
}
return null;
} catch (\JsonException) {
return null;
}
}
/**
* Retourne le prompt système.
*/
private function getSystemPrompt(): array
{
return [
'role' => 'system',
'content' => view('prompts.system', [
'now' => now()->locale('fr')->format('l d F Y H:i'),
'user' => auth()->user()?->name ?? 'l\'utilisateur',
])->render(),
];
}
}
Points clés du service :
streamToOutput(): envoie les chunks directement à la sortie HTTP viaechoetflush()- Marqueurs spéciaux : le reasoning est encapsulé dans
[REASONING]...[/REASONING]pour être parsé côté frontend - Parsing SSE : lit le stream ligne par ligne et extrait les chunks de contenu
- Support du reasoning : paramètre optionnel
reasoning_effort(low/medium/high)
4.2 Contrôleur - AskStreamController
Créez un nouveau contrôleur dédié à cette fonctionnalité de streaming.
Fichier : app/Http/Controllers/AskStreamController.php
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Services\SimpleAskStreamService;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* Controller pour la démonstration du streaming SSE.
*
* Exemple pédagogique : streaming temps réel avec Laravel + Vue.
*/
class AskStreamController extends Controller
{
public function __construct(
private SimpleAskStreamService $streamService
) {}
/**
* Affiche la page de streaming.
*/
public function index(Request $request): Response
{
$modelId = $request->input('model')
?? auth()->user()?->preferred_model
?? SimpleAskStreamService::DEFAULT_MODEL;
return Inertia::render('AskStream/Index', [
'models' => $this->streamService->getModelsLight(),
'selectedModel' => $modelId,
'selectedModelDetails' => fn() => $this->streamService->getModelDetails($modelId),
]);
}
/**
* Endpoint de streaming.
*/
public function stream(Request $request): StreamedResponse
{
$validated = $request->validate([
'message' => 'required|string|max:100000',
'model' => 'required|string',
'temperature' => 'nullable|numeric|min:0|max:2',
'reasoning_effort' => 'nullable|string|in:low,medium,high',
]);
// Update user's preferred model
$user = auth()->user();
if ($user && $user->preferred_model !== $validated['model']) {
$user->update(['preferred_model' => $validated['model']]);
}
$messages = [['role' => 'user', 'content' => $validated['message']]];
$model = $validated['model'];
$temperature = (float) ($validated['temperature'] ?? 1.0);
$reasoningEffort = $validated['reasoning_effort'] ?? null;
return response()->stream(
function () use ($messages, $model, $temperature, $reasoningEffort): void {
$this->streamService->streamToOutput($messages, $model, $temperature, $reasoningEffort);
},
headers: [
'Content-Type' => 'text/plain; charset=utf-8',
'Cache-Control' => 'no-cache, no-store',
'X-Accel-Buffering' => 'no',
]
);
}
}
Points clés :
response()->stream()crée automatiquement une réponse SSE- Le service
streamToOutput()écrit directement dans la sortie HTTP - Headers HTTP spécifiques pour désactiver le buffering
- Support du
reasoning_effortpour les modèles compatibles
4.3 Routes
Ajoutez les routes dans routes/web.php :
Route::get('/ask-stream', [\App\Http\Controllers\AskStreamController::class, 'index'])
->name('stream.index');
Route::post('/ask-stream', [\App\Http\Controllers\AskStreamController::class, 'stream'])
->name('stream.post');
5. Implémentation côté Frontend
5.1 Hook useStream - Code Essentiel
Voici le code essentiel pour implémenter le streaming côté frontend. Le hook useStream de Laravel gère automatiquement la connexion SSE et la réception des chunks.
<script setup lang="ts">
import { ref } from 'vue';
import { useStream } from '@laravel/stream-vue';
// State
const message = ref('');
const model = ref('openai/gpt-4o-mini');
const temperature = ref(1.0);
const reasoningEffort = ref<'low' | 'medium' | 'high' | null>(null);
/**
* useStream hook - Le hook concatène automatiquement dans `data`
* Le backend envoie du texte avec marqueurs [REASONING]...[/REASONING]
*/
const { data, isFetching, isStreaming, send, cancel } = useStream(
'/ask-stream',
{
onData: () => {
// Callback appelé pour chaque chunk reçu
// Le contenu est automatiquement concaténé dans `data`
},
onFinish: () => {
// Appelé quand le stream se termine
message.value = '';
},
onError: (err: Error) => {
console.error('Erreur streaming:', err);
},
},
);
/**
* Submit handler - Envoie la requête de streaming
*/
const submit = () => {
if (!message.value.trim()) return;
send({
message: message.value,
model: model.value,
temperature: temperature.value,
reasoning_effort: reasoningEffort.value,
});
};
</script>
Points clés du hook useStream :
data: contient la réponse complète, automatiquement concaténéeisStreaming: booléen indiquant si un stream est en coursisFetching: booléen pour la phase de connexionsend(payload): fonction pour démarrer le streamcancel(): fonction pour annuler le stream en cours- Callbacks :
onData: appelé pour chaque chunk reçuonFinish: appelé quand le stream se termineonError: appelé en cas d'erreur
5.2 Parsing des Marqueurs Reasoning
Le backend envoie le reasoning encapsulé dans des marqueurs spéciaux. Voici comment les extraire :
<script setup lang="ts">
import { computed } from 'vue';
/**
* Extrait le contenu principal (sans le reasoning)
*/
const streamedContent = computed(() => {
if (!data.value) return '';
// Enlever les blocs [REASONING]...[/REASONING]
return data.value
.replace(/\[REASONING\][\s\S]*?\[\/REASONING\]/g, '')
.trim();
});
/**
* Extrait le reasoning des marqueurs
*/
const streamedReasoning = computed(() => {
if (!data.value) return '';
const matches = data.value.match(/\[REASONING\]([\s\S]*?)\[\/REASONING\]/g);
if (!matches) return '';
return matches
.map((m) =>
m.replace(/\[REASONING\]/g, '').replace(/\[\/REASONING\]/g, ''),
)
.join('');
});
</script>
<template>
<!-- Affichage du reasoning (optionnel) -->
<div v-if="streamedReasoning" class="reasoning-trace">
<h4>Trace de raisonnement</h4>
<pre>{{ streamedReasoning }}</pre>
</div>
<!-- Affichage du contenu principal -->
<div class="main-content">
<MarkdownRenderer :content="streamedContent" />
</div>
</template>
Explication du parsing :
- Les modèles de raisonnement (comme les modèles o1) génèrent une trace de leur raisonnement
- Le backend encapsule cette trace dans
[REASONING]...[/REASONING] - On utilise des regex pour extraire séparément le contenu principal et le reasoning
- Le reasoning peut être affiché ou caché selon les besoins
6. Configuration CSRF et Authentification
6.1 Token CSRF dans le Template de Base
Le package @laravel/stream-vue cherche automatiquement le token CSRF dans une balise meta du <head>. Ajoutez cette ligne dans votre template de base Inertia :
Fichier : resources/views/app.blade.php
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title inertia>{{ config('app.name', 'Laravel') }}</title>
<!-- ... autres balises meta et scripts -->
</head>
Sans cette balise, vous obtiendrez une erreur 419 lors des requêtes de streaming.
7. Diagramme de Séquence

8. Avantages des Server-Sent Events
| Aspect | Server-Sent Events |
|---|---|
| Configuration | Aucune configuration serveur |
| Dépendances | 1 package npm uniquement |
| Coût | Gratuit |
| Performance | Direct HTTP, faible latence |
| Complexité | Simple et élégant |
Conclusion
Les Server-Sent Events avec @laravel/stream-vue offrent une solution élégante et simple pour implémenter le streaming en temps réel. Cette approche native élimine la complexité des solutions externes (comme Pusher) tout en offrant d'excellentes performances, sans nécessiter aucune configuration serveur supplémentaire.
