Attendre 8 secondes qu'un LLM finisse avant d'afficher quoi que ce soit, c'est une UX morte. Le streaming token par token change tout. Et côté serveur, pas besoin de WebSocket : Server-Sent Events suffit, en PHP natif.

Le principe

SSE est un flux HTTP texte avec un Content-Type: text/event-stream. Le serveur écrit des lignes data: ...\n\n et le navigateur les reçoit via EventSource. Unidirectionnel serveur→client, exactement ce qu'il faut pour du token streaming.

Le code serveur

<?php
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
header('X-Accel-Buffering: no'); // désactive le buffer nginx

while (ob_get_level() > 0) { ob_end_flush(); }

foreach (streamLlm($prompt) as $chunk) {
    echo 'data: ' . json_encode(['t' => $chunk]) . "\n\n";
    flush();
}
echo "event: done\ndata: {}\n\n";
flush();

Côté client

const es = new EventSource('/stream?q=' + encodeURIComponent(q));
es.onmessage = (e) => { output.textContent += JSON.parse(e.data).t; };
es.addEventListener('done', () => es.close());

Les trois pièges

  • Buffering nginx/Apache : le header X-Accel-Buffering: no (nginx) et désactiver gzip sur cette route, sinon tout arrive d'un bloc.
  • Buffer PHP : vider tous les niveaux d'ob_* et flush() après chaque chunk.
  • Timeout FPM : un stream long tient un worker FPM. Limiter la durée, prévoir un max_execution_time adapté, et idéalement isoler ces requêtes d'un pool FPM dédié.

Pas de dépendance, ~15 lignes serveur. Pour un chat IA, c'est le strict nécessaire et ça suffit en production tant que la concurrence reste raisonnable.