Diffuser les réponses de LLM avec Server-Sent Events
Comment fonctionne le streaming de jetons LLM via SSE : le format text/event-stream, l'analyse des lignes data et du marqueur [DONE], pourquoi fetch surpasse EventSource, et l'annulation avec AbortController.
Les API de LLM diffusent leur sortie jeton par jeton grâce aux Server-Sent Events (SSE), un protocole texte unidirectionnel où le serveur envoie des données text/event-stream que le client lit au fur et à mesure de leur arrivée. Lorsque vous définissez stream: true, la réponse n'est plus un unique corps JSON : c'est une séquence de petits événements, chacun étant une ligne data: portant un fragment JSON, séparés par des lignes vides, et (pour Chat Completions) se terminant par un data: [DONE] littéral. Pour afficher les jetons à mesure qu'ils sont générés, vous analysez ce flux de façon incrémentale plutôt que d'attendre une réponse entière. Cet article explique le format sur le fil, pourquoi vous devriez utiliser fetch plutôt que le EventSource du navigateur pour les appels LLM, et comment annuler proprement un flux avec AbortController.
Le streaming vaut bien l'effort d'analyse supplémentaire, car il fait s'effondrer la latence perçue. Le temps jusqu'au premier jeton (time-to-first-token) est ce que l'utilisateur ressent ; une réponse diffusée commence à apparaître en quelques centaines de millisecondes au lieu d'attendre la génération complète. En contrepartie, votre client possède désormais un analyseur et un chemin d'annulation.
Le format SSE sur le fil, précisément
SSE est défini par un petit ensemble de règles. Le serveur répond avec Content-Type: text/event-stream, le corps est du texte UTF-8, et les messages sont séparés par une paire de sauts de ligne (\n\n). Chaque message est composé d'une ou plusieurs lignes field: value. Seuls quatre champs sont définis :
| Champ | Signification |
|---|---|
data | La charge utile. Des lignes data: consécutives dans un même message sont concaténées avec un saut de ligne entre elles. |
event | Un nom pour le type d'événement ; distribué à un écouteur correspondant. S'il est omis, il s'agit d'un message par défaut. |
id | Définit le « dernier ID d'événement » du client, renvoyé lors de la reconnexion via l'en-tête Last-Event-ID. |
retry | Délai de reconnexion en millisecondes. |
Une ligne commençant par deux-points est un commentaire et est ignorée : les fournisseurs s'en servent comme pings de maintien pour empêcher les proxys inactifs de fermer la connexion. Un flux Chat Completions brut ressemble à ceci sur le fil :
data: {"id":"chatcmpl-x","choices":[{"delta":{"role":"assistant"}}]}
data: {"id":"chatcmpl-x","choices":[{"delta":{"content":"Hello"}}]}
data: {"id":"chatcmpl-x","choices":[{"delta":{"content":" world"}}]}
data: [DONE]
Chaque fragment est un objet chat.completion.chunk ; le texte se trouve dans choices[0].delta.content, et vous accumulez les deltas pour reconstruire le message. Le flux se termine par le marqueur data: [DONE], qui n'est pas du JSON : vous devez le vérifier en tant que chaîne littérale avant de tenter l'analyse, sinon votre décodeur JSON lèvera une exception sur la dernière ligne.
Deux vocabulaires d'événements : Chat Completions vs Responses
Le transport est identique, mais les événements diffèrent selon le point de terminaison. Chat Completions émet des deltas chat.completion.chunk et le marqueur [DONE]. L'API Responses émet des événements sémantiques aux noms explicites — response.created, response.output_text.delta, response.completed, et d'autres — où chaque événement annonce ce qu'il est. Les événements sémantiques sont plus faciles à router : au lieu d'inspecter la forme d'un fragment, vous branchez sur le nom de l'événement. Si vous hésitez entre les points de terminaison, voyez Responses API vs Chat Completions ; pour migrer du code de streaming existant, voyez migrer de Chat Completions vers l'API Responses.
Pour obtenir un décompte final d'usage sur un flux Chat Completions, envoyez stream_options: {"include_usage": true} ; le fournisseur ajoute un fragment final dont l'objet usage contient le nombre de jetons, que vous pouvez transmettre aux journaux d'usage de votre passerelle pour la comptabilité.
Pourquoi pas EventSource ?
Le navigateur embarque un client SSE intégré, EventSource, et c'est le mauvais outil pour les appels LLM pour deux raisons concrètes. Premièrement, EventSource n'émet que des requêtes GET, alors que les appels LLM sont des POST avec un corps JSON. Deuxièmement, EventSource ne peut pas définir d'en-têtes personnalisés, vous ne pouvez donc pas envoyer Authorization: Bearer .... Ces deux limites sont structurelles, ce ne sont pas des réglages que vous pouvez basculer.
La bonne approche est fetch avec un flux lisible, en analysant SSE vous-même. Cela vous donne POST, les en-têtes et un contrôle total sur l'annulation :
const res = await fetch("https://your-gateway.example.com/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer AVE_GATEWAY_API_KEY",
},
body: JSON.stringify({ model: "gpt-5.6", stream: true,
messages: [{ role: "user", content: "Stream a haiku about latency." }] }),
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
// Normalize CRLF so the blank-line separator matches regardless of line endings.
buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, "\n");
const events = buffer.split("\n\n"); // messages end at a blank line
buffer = events.pop(); // keep the incomplete trailing chunk
for (const evt of events) {
// One event can hold several data: lines; join them per the SSE spec.
const data = evt
.split("\n")
.filter((l) => l.startsWith("data:"))
.map((l) => l.slice(5).replace(/^ /, ""))
.join("\n");
if (!data) continue;
if (data === "[DONE]") return; // sentinel is not JSON
const json = JSON.parse(data);
const token = json.choices?.[0]?.delta?.content;
if (token) appendToken(token); // browser: write to a DOM text node
}
}
appendToken est votre point de rendu : dans le navigateur, ajoutez le jeton à un nœud texte ou à l'état React ; dans Node, écrivez-le plutôt dans process.stdout. Deux détails d'analyse sont essentiels et faciles à omettre. Normalisez d'abord \r\n en \n, car certains intermédiaires émettent des fins de ligne CRLF et un découpage naïf sur \n\n manquerait alors la frontière \r\n\r\n. Ensuite, joignez toutes les lignes data: d'un événement au lieu de ne lire que la première : la spécification SSE concatène les lignes data: consécutives avec un saut de ligne, si bien qu'une charge utile multiligne est corrompue si vous n'en gardez qu'une.
La mise en tampon est tout aussi cruciale : TCP ne respecte pas les frontières de message, donc un seul read() peut livrer la moitié d'un événement ou plusieurs événements à la fois. Découpez sur le séparateur de ligne vide et reportez la queue incomplète à la lecture suivante. Ignorer cela est le bug de streaming le plus courant : ça marche en test et se corrompt sous charge.
Annuler un flux avec AbortController
Les utilisateurs abandonnent des requêtes : ils quittent la page, modifient l'invite ou stoppent une génération emballée. Vous devriez cesser de facturer les jetons dès qu'ils le font. AbortController est le mécanisme. Créez un contrôleur, passez son signal à fetch, et appelez abort() pour démonter la connexion :
const controller = new AbortController();
const res = await fetch(url, { method: "POST", signal: controller.signal, /* ... */ });
// stopButton.onclick:
controller.abort(); // fetch promise rejects with a DOMException named "AbortError"
Lorsque vous appelez abort(), le fetch en cours est rejeté avec une DOMException dont le name est AbortError ; distinguez donc une annulation délibérée d'un véritable échec en vérifiant err.name === "AbortError". Pour un délai d'expiration strict sans câbler votre propre contrôleur, AbortSignal.timeout(ms) renvoie un signal qui s'interrompt automatiquement après le nombre de millisecondes donné — un délai qui se déclenche rejette avec un TimeoutError plutôt qu'un AbortError. Interrompre rapidement est un contrôle de coût direct, ce qui rejoint la réduction des coûts des API LLM.
La reconnexion et ses limites pour les flux LLM
SSE dispose d'une reconnexion intégrée : les champs id et retry permettent à un navigateur de reprendre après une connexion coupée en renvoyant le Last-Event-ID. C'est puissant pour les flux d'événements, mais seulement partiellement applicable à la génération LLM, car une connexion reprise ne peut pas « défacturer » des jetons déjà produits, et la plupart des flux de modèles ne sont pas conçus pour rejouer depuis un décalage arbitraire. Pour le trafic LLM, traitez un flux interrompu comme un résultat incomplet et régénérez — en coordonnant cela avec l'idempotence afin de ne pas être facturé deux fois, comme couvert dans reprises et idempotence des API LLM. Une note d'infrastructure : SSE sur HTTP/1.1 est plafonné à six connexions concurrentes par domaine, donc diffuser de nombreuses générations parallèles depuis une seule origine de navigateur nécessite HTTP/2, qu'une passerelle termine généralement pour vous.
Relayer un flux SSE via un serveur
La plupart des configurations de production ne diffusent pas directement du navigateur vers le fournisseur : elles diffusent via leur propre backend ou passerelle, qui détient la clé du fournisseur et applique l'authentification, la limitation de débit et la journalisation. Relayer du SSE comporte quelques exigences faciles à mal gérer et qui produisent un flux qui « se fige » jusqu'à ce que toute la réponse soit prête.
- Désactivez la mise en tampon des réponses. Les proxys inverses et les frameworks tamponnent souvent les réponses par défaut, ce qui anéantit le streaming. Envoyez les fragments immédiatement et, le cas échéant, désactivez la mise en tampon du proxy afin que rien n'accumule le corps avant de le transmettre.
- Définissez les bons en-têtes. Répondez avec
Content-Type: text/event-streametCache-Control: no-cachepour que les intermédiaires ne mettent pas en cache ni ne remodèlent le flux. - Transmettez, ne re-sérialisez pas. Faites passer les fragments du fournisseur tels quels quand vous le pouvez ; ré-analyser et ré-émettre chaque jeton ajoute de la latence et invite les bugs de frontière.
- Propagez l'annulation. Lorsque le client se déconnecte, interrompez aussi la requête amont, sinon vous continuez à payer des jetons que personne ne lira. C'est le miroir côté serveur d'
AbortController.
Une passerelle est le foyer naturel de tout cela, car elle termine déjà les connexions clientes et détient les identifiants. Enregistrer chaque fournisseur comme un canal derrière elle permet de diffuser depuis l'amont qui est sain tout en présentant une forme SSE cohérente à chaque client, et de journaliser l'usage de jetons d'une requête diffusée exactement comme celui d'une requête non diffusée. Si vous faites tourner de nombreux fournisseurs derrière un seul endpoint, la traduction du streaming entre leurs formats d'événements fait partie de ce qu'une passerelle d'API compatible OpenAI réalise pour vous.
FAQ
Pourquoi mon analyseur JSON lève-t-il une exception sur la dernière ligne du flux ?
Parce que la dernière ligne d'un flux Chat Completions est data: [DONE], et [DONE] est un marqueur littéral, pas du JSON. Vérifiez la chaîne [DONE] et arrêtez-vous avant de passer la charge utile à JSON.parse. C'est la toute première cause d'erreur d'analyse en streaming.
Puis-je utiliser le EventSource du navigateur pour du streaming à la OpenAI ?
Non. EventSource est limité à GET et ne peut pas définir d'en-tête Authorization, alors que les appels LLM sont des requêtes POST authentifiées. Utilisez fetch avec res.body.getReader() et analysez vous-même le text/event-stream, comme le montre l'exemple ci-dessus.
Comment arrêter un flux et cesser de payer des jetons ?
Attachez un signal AbortController au fetch et appelez controller.abort(). La connexion se démonte et la promesse fetch est rejetée avec un AbortError. L'interruption n'arrête la facturation que si l'annulation se propage jusqu'au fournisseur ; dans une configuration avec proxy, le serveur doit aussi interrompre son propre appel amont. Reliez-la donc à tout contrôle « stop » de l'interface et aux déconnexions client, puis propagez-la de bout en bout.
Les flux Chat Completions et Responses se ressemblent-ils sur le fil ?
Ils partagent le transport SSE mais pas les événements. Chat Completions envoie des deltas chat.completion.chunk se terminant par [DONE] ; l'API Responses envoie des événements sémantiques nommés comme response.output_text.delta et response.completed. Un analyseur écrit pour l'un doit être adapté pour l'autre.
Câblez un flux annulable
Prenez l'extrait fetch + reader ci-dessus, pointez-le vers votre passerelle avec stream: true, et affichez choices[0].delta.content dans un nœud texte à mesure que les jetons arrivent. Ajoutez ensuite un AbortController et un bouton stop. L'annulation reste une opération au mieux : le nombre de jetons de sortie enregistré localement devrait baisser sur une requête annulée, mais ce n'est qu'un signal, pas une preuve. L'amont peut continuer brièvement à générer ou facturer après la déconnexion, et les décomptes locaux peuvent être estimés si l'usage final de l'amont n'arrive jamais. Pour confirmer que l'annulation a réellement atteint le fournisseur, recoupez le stream_status de la passerelle — un marqueur client_gone — avec l'usage ou la facture du fournisseur, et non avec le seul décompte local de jetons.