Streamer les réponses d’un LLM : le proxy qui retenait les mots
Le streaming ne rend pas un modèle plus rapide, il le rend plus vite lisible. Encore faut-il que les mots arrivent : buffering, compression, connexions coupées, et ce que l’interface doit soigner. Avec une simulation du trajet.
Un modèle met quelques secondes à écrire une réponse de cent mots. Afficher ces mots au fur et à mesure ne change rien à cette durée, mais tout au ressenti : le premier mot apparaît en moins d’une seconde, et de toute façon, lire prend plus de temps qu’écrire. C’est l’optimisation de performance la plus rentable d’une interface conversationnelle, et elle ne touche pas au modèle.
Sauf qu’entre le modèle et l’écran, il y a votre serveur, parfois un middleware de compression, souvent un proxy, et chacun peut décider de garder les mots pour lui. Le symptôme est toujours le même. En local, tout s’affiche progressivement. En production, la réponse arrive d’un bloc après plusieurs secondes d’écran vide.
Le trajet d’un mot
La plupart des API de modèles envoient leur réponse en Server-Sent Events : une connexion HTTP qui reste ouverte, et des événements de texte envoyés au fil de la génération, chacun terminé par une ligne vide. Le format est minuscule. Une ligne qui commence par deux-points est un commentaire, ignoré par le client : on y reviendra.
event: delta
data: {"text": "Code E-07"}
event: delta
data: {"text": " means the speed sensor"}
: ping
event: done
data: {}La simulation suit une réponse du modèle jusqu’au navigateur. Activez les intermédiaires un par un, puis rejouez : la durée totale ne bouge presque pas, le moment où le premier mot s’affiche, si.
Le proxy qui bufferise
nginx, par défaut, lit la réponse du serveur dans ses propres tampons avant de la transmettre : c’est proxy_buffering on. Pour une page HTML, c’est une bonne idée, le serveur d’application est libéré plus vite. Pour un flux, c’est la panne décrite plus haut : les mots attendent dans le proxy jusqu’à la fin de la réponse.
La correction la plus propre se fait côté application : renvoyer l’en-tête X-Accel-Buffering: no sur les réponses en streaming. nginx le comprend et désactive le buffering pour cette réponse seulement, sans toucher au reste du site. Si on préfère la configuration, on peut aussi isoler la route :
location /api/chat {
proxy_pass http://app;
proxy_http_version 1.1;
proxy_buffering off; # or send X-Accel-Buffering: no from the app
proxy_read_timeout 300s; # the default closes after 60 s of silence
gzip off;
}La compression qui attend
Un middleware de compression a besoin de matière pour compresser. Celui d’Express, compression, garde les données jusqu’à remplir son tampon, sauf si l’application appelle res.flush() après chaque événement. Le plus simple est souvent d’exclure text/event-stream de la compression : quelques kilo-octets de texte ne valent pas la latence ajoutée.
Le silence qui coupe
Un agent ne parle pas en continu. Quand il appelle un outil lent, une recherche ou une requête en base, le flux peut rester muet de longues secondes. Or les intermédiaires coupent les connexions silencieuses : 60 secondes par défaut pour proxy_read_timeout chez nginx, 60 secondes aussi pour le délai d’inactivité d’un load balancer AWS. Activez l’appel d’outil dans la simulation : sans signe de vie, la connexion tombe et la fin de la réponse n’arrive jamais.
La parade est un heartbeat : une ligne de commentaire SSE, envoyée toutes les quinze secondes. Le client l’ignore, les proxys y voient du trafic. C’est aussi l’occasion d’envoyer un événement d’état, « recherche dans la documentation », qui dit à l’utilisateur ce qui se passe pendant l’attente au lieu de le laisser devant un curseur figé.
Côté navigateur
EventSource, l’API native, ne sait faire que des requêtes GET, sans corps ni en-têtes personnalisés. Pour envoyer une conversation en POST avec un jeton, on lit le flux avec fetch et un ReadableStream. On perd au passage la reconnexion automatique, qu’il faut alors gérer soi-même.
const res = await fetch('/api/chat', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ messages }),
signal: controller.signal // the Stop button aborts the whole chain
})
const reader = res.body!.pipeThrough(new TextDecoderStream()).getReader()
let buffer = ''
for (;;) {
const { value, done } = await reader.read()
if (done) break
buffer += value
// events end with a blank line; keep the unfinished tail for the next chunk
const events = buffer.split('\n\n')
buffer = events.pop() ?? ''
for (const event of events) {
const line = event.split('\n').find((l) => l.startsWith('data: '))
if (line) onEvent(JSON.parse(line.slice(6)))
}
}Le découpage mérite attention : un morceau reçu du réseau peut contenir plusieurs événements, ou la moitié d’un seul. On accumule, on découpe sur les lignes vides, et on garde la fin incomplète pour le morceau suivant.
Ce que l’interface doit soigner
Une fois que les mots arrivent vraiment, le reste se joue dans l’interface. Ce sont de petits détails, et ce sont eux qui donnent l’impression d’une réponse fluide plutôt que d’un texte qui se débat.
- Montrer un premier signe en moins d’une seconde, même si ce n’est qu’un état comme « je cherche ».
- Ne pas faire défiler la page de force. Si le lecteur remonte pour relire, le défilement automatique s’arrête jusqu’à ce qu’il redescende.
- Rendre le Markdown sans clignotement. Un bloc de code ouvert mais pas encore fermé ne doit pas transformer toute la suite en code : on ferme provisoirement les blocs inachevés au moment du rendu.
- Prévoir l’arrêt. Un bouton Stop qui annule la requête et propage l’annulation jusqu’à l’appel au modèle, pour ne pas payer des tokens que personne ne lira.
- Gérer la coupure. Garder le texte déjà reçu et proposer de reprendre, plutôt qu’effacer et afficher une erreur.
En local, le streaming marche tout seul. En production, il dépend de chaque intermédiaire entre le modèle et l’écran, et c’est la première chose que je vérifie sur un environnement qui ressemble à la prod.