Réponses de LLM en streaming : leur importance pour l’UX et les coûts
Les 900 ms qui donnaient l’impression que notre interface de chat était « cassée »
Il y a quelques mois, je déboguais un assistant de support client dont la latence de bout en bout était parfaitement acceptable sur le papier : la plupart des réponses étaient terminées en 4 à 7 secondes. Le modèle sélectionnait les outils, récupérait le contexte du compte et générait une réponse utile.
Mais les utilisateurs le détestaient.
La raison était simple : l’interface restait vide pendant les 3.8 premières secondes.
Aucun texte accompagnant un indicateur de chargement, aucune réponse partielle, aucun signe que le modèle avait compris la question. Juste une bulle de réponse vide. Lorsque nous avons configuré ce même endpoint pour transmettre les tokens en streaming via Server-Sent Events, le temps moyen de génération complète a à peine changé. Mais le délai avant le premier contenu visible est tombé à environ 700 à 900 ms pour de nombreuses requêtes, et le produit a immédiatement paru plus rapide.
Voilà la véritable raison pour laquelle le streaming compte.
Les réponses LLM en streaming n’ont rien de magique. Le streaming ne fait pas réfléchir le modèle plus vite. Il ne réduit pas le nombre total de tokens. Il ne réduit pas automatiquement votre facture. Mais il modifie la boucle d’interaction d’une manière perceptible par les utilisateurs et donne à votre application davantage de contrôle sur l’annulation, le rendu, la modération, la comptabilisation de l’usage et la contre-pression.
Cet article explique comment fonctionne réellement le streaming SSE, ce qu’il faut mesurer, les subtilités possibles de la facturation et les pratiques côté client que j’utilise dans les systèmes en production.
Quel est le principal avantage des réponses en streaming ?
Le principal avantage des réponses en streaming est la latence perçue : l’utilisateur voit les premiers tokens en quelques centaines de millisecondes, au lieu de fixer un indicateur de chargement pendant toute la génération. Le temps total de génération change à peine : c’est le délai avant le premier token qui change, et c’est cette mesure que les utilisateurs ressentent réellement. Une réponse qui prend 9 secondes à se terminer, mais commence à apparaître après 400 ms, semble rapide ; la même réponse affichée d’un seul coup après 9 secondes donne l’impression d’un dysfonctionnement.
Il existe aussi des avantages secondaires : le streaming permet d’afficher progressivement une sortie partielle, d’annuler une génération dès que l’utilisateur en a vu assez, ce qui économise des tokens de sortie, et d’éviter les délais d’attente de lecture des proxys sur les réponses longues. Mais s’il faut retenir une chose : le streaming ne rend pas le modèle plus rapide, il fait disparaître l’attente de l’expérience utilisateur.
Ce que signifie réellement le streaming
Lorsque vous envoyez une requête LLM sans streaming, le serveur attend généralement que le modèle ait fini de générer la réponse, puis renvoie une seule charge utile JSON :
{
"id": "resp_123",
"model": "some-model",
"output_text": "Sure — here is the answer...",
"usage": {
"input_tokens": 812,
"output_tokens": 436
}
}
Avec le streaming, le serveur envoie des événements successifs à mesure que la sortie devient disponible. Le mécanisme de transport le plus courant est Server-Sent Events, généralement abrégé en SSE.
SSE est simplement une réponse HTTP avec une connexion de longue durée et un type de contenu particulier :
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
Au lieu d’un seul objet JSON, le serveur envoie une séquence de petits événements :
event: message.delta
data: {"delta":"Sure"}
event: message.delta
data: {"delta":" — here"}
event: message.delta
data: {"delta":" is"}
event: message.delta
data: {"delta":" the answer..."}
event: message.done
data: {"finish_reason":"stop","usage":{"input_tokens":812,"output_tokens":436}}
Les différentes API utilisent des noms d’événements et des schémas différents, mais le déroulement est généralement le même :
- Ouvrir une connexion HTTP.
- Envoyer les métadonnées de la requête et le prompt.
- Recevoir des fragments successifs.
- Ajouter les fragments au message actuel de l’assistant.
- Recevoir un événement final avec le motif d’arrêt et, souvent, les métadonnées d’usage.
- Fermer le flux.
En pratique, la plupart des flux LLM ne transmettent pas un token par événement. Les fournisseurs peuvent regrouper les tokens en fragments pour gagner en efficacité. Vous pouvez recevoir un seul caractère, un fragment de mot, plusieurs mots ou un delta JSON structuré. Ne concevez jamais un client qui suppose que chaque fragment est un mot entier ou une phrase entière.
Latence : temps total et délai avant le premier token
La distinction la plus importante est celle entre la latence totale et le délai avant le premier token.
| Mesure | Ce qu’elle mesure | Pourquoi elle compte |
|---|---|---|
| Délai avant le premier octet, TTFB | Premier octet reçu du serveur | Inclut le routage, l’authentification, la mise en file d’attente et l’ordonnancement du modèle |
| Délai avant le premier token, TTFT | Premier token ou fragment généré visible par l’utilisateur | Principal facteur de réactivité perçue |
| Tokens par seconde | Vitesse de génération de la sortie après le premier token | Détermine la vitesse à laquelle les réponses longues se terminent |
| Temps total de génération | Réponse complète reçue | Compte pour l’automatisation, les appels d’outils et les tâches par lots |
| Délai avant une réponse exploitable | L’utilisateur dispose d’assez d’informations pour agir | Souvent atteint avant la fin de la génération complète |
Pour l’UX d’un chat, TTFT est généralement plus important que le temps total. Une réponse qui commence en 800 ms et se termine en 8 secondes semble souvent plus agréable qu’une réponse qui apparaît d’un seul coup après 4 secondes.
Pour les workflows entre machines, le streaming compte souvent moins. Si l’étape suivante ne peut pas s’exécuter tant que l’objet JSON n’est pas complet, le streaming risque simplement de complexifier l’implémentation.
Mesurer TTFT en Python
Voici une méthode simple pour mesurer la latence du premier fragment et la durée totale du flux. La structure de l’API est volontairement générique : adaptez l’URL et la charge utile à votre fournisseur.
import json
import time
import requests
url = "https://api.example.com/v1/chat/completions"
payload = {
"model": "sonnet-4.6",
"stream": True,
"messages": [
{"role": "user", "content": "Explain SSE streaming in 5 bullet points."}
]
}
headers = {
"Authorization": "Bearer $API_KEY",
"Content-Type": "application/json",
"Accept": "text/event-stream",
}
start = time.perf_counter()
first_chunk_at = None
chars = 0
with requests.post(url, headers=headers, json=payload, stream=True) as r:
r.raise_for_status()
for raw_line in r.iter_lines(decode_unicode=True):
if not raw_line:
continue
if not raw_line.startswith("data: "):
continue
data = raw_line[len("data: "):]
if data == "[DONE]":
break
event = json.loads(data)
# Provider-specific: this may be event["delta"], event["choices"][0]["delta"], etc.
delta = event.get("delta", "")
if delta and first_chunk_at is None:
first_chunk_at = time.perf_counter()
chars += len(delta)
print(delta, end="", flush=True)
end = time.perf_counter()
print("\n\n--- metrics ---")
print(f"ttft_ms={((first_chunk_at or end) - start) * 1000:.0f}")
print(f"total_ms={(end - start) * 1000:.0f}")
print(f"chars={chars}")
Un piège courant : si vous testez le streaming avec un proxy local, une fonction serverless ou un proxy inverse qui met les réponses en mémoire tampon, vous pouvez croire que le modèle ne diffuse pas en streaming. Il est possible que le modèle le fasse correctement, mais que votre infrastructure retienne les fragments jusqu’à la fin de la réponse.
J’ai rencontré ce problème avec :
- La mise en mémoire tampon activée dans Nginx.
- Des fonctions cloud qui ne prennent pas en charge les réponses en streaming.
- Des middlewares de compression qui attendent des tampons plus volumineux.
- Des frameworks web qui construisent toute la réponse avant de la renvoyer.
- Des proxys d’entreprise qui regroupent les petits fragments.
Pour Nginx, ce paramètre est souvent important :
location /api/llm/stream {
proxy_pass http://app_backend;
proxy_http_version 1.1;
proxy_buffering off;
proxy_cache off;
chunked_transfer_encoding on;
add_header X-Accel-Buffering no;
}
SSE et WebSockets
SSE n’est pas le seul moyen de diffuser en streaming. Les WebSockets sont également courants, notamment pour les applications bidirectionnelles. Pour la plupart des interfaces de chat LLM, SSE reste toutefois le choix par défaut le plus simple.
| Fonctionnalité | SSE | WebSocket |
|---|---|---|
| Direction | Du serveur vers le client | Bidirectionnelle |
| Protocole | Réponse HTTP standard | Passage au protocole WebSocket |
| Prise en charge par les navigateurs | Natif EventSource; fonctionne aussi avec fetch des flux | Natif WebSocket |
| Comportement de reconnexion | Mécanisme intégré, mais une logique applicative reste nécessaire | À implémenter vous-même |
| Compatibilité avec les équilibreurs de charge | Généralement plus simple | Nécessite parfois une configuration supplémentaire |
| Idéal pour | Streaming de tokens, événements de progression, chat simple | Collaboration en temps réel, audio, sessions interactives à plusieurs tours |
| Prise en charge du corps de la requête | EventSource ne prend en charge que GET ; utilisez fetch pour le streaming POST | Messages bidirectionnels complets |
Le EventSource L’API du navigateur est pratique, mais elle ne prend en charge que les requêtes GET. La plupart des appels aux LLM nécessitent des corps POST et des en-têtes d’authentification ; j’utilise donc généralement pour le streaming fetch() et ReadableStream.
Modèle d’implémentation côté navigateur : Fetch + ReadableStream
Voici une implémentation minimale côté navigateur :
async function streamChat({ messages, signal, onDelta, onDone }) {
const res = await fetch("/api/chat/stream", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
model: "gpt-5.5",
stream: true,
messages
}),
signal
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
const reader = res.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed.startsWith("data:")) continue;
const data = trimmed.slice(5).trim();
if (data === "[DONE]") {
onDone?.();
return;
}
const event = JSON.parse(data);
if (event.delta) {
onDelta(event.delta);
}
}
}
onDone?.();
}
Et dans une interface utilisateur :
const controller = new AbortController();
let assistantText = "";
streamChat({
messages,
signal: controller.signal,
onDelta(delta) {
assistantText += delta;
renderAssistantMessage(assistantText);
},
onDone() {
markMessageComplete();
}
});
// Cancel button
document.querySelector("#stop").onclick = () => {
controller.abort();
};
Ne faites pas un rendu naïf de chaque fragment
Si vous mettez à jour l’état React à chaque petit fragment, vous risquez de créer un problème de performances côté client. Certains flux produisent des dizaines de fragments par seconde. Refaire le rendu d’une grande arborescence de conversation à chaque fois est un gaspillage de ressources.
En pratique, je regroupe généralement les mises à jour de l’interface à chaque trame d’animation :
let pending = "";
let scheduled = false;
function onDelta(delta) {
pending += delta;
if (!scheduled) {
scheduled = true;
requestAnimationFrame(() => {
assistantText += pending;
pending = "";
scheduled = false;
setAssistantText(assistantText);
});
}
}
Cela permet de conserver une interface fluide tout en donnant l’impression d’une sortie en direct.
Gérer correctement les sorties partielles
Avec le streaming, votre application reçoit du texte incomplet. Cela paraît évident, mais de nombreux bugs viennent du fait que l’on traite une sortie partielle comme une sortie définitive.
Le Markdown est temporairement invalide
Pendant le streaming, le Markdown peut être mal formé :
Here is the query:
```sql
SELECT *
FROM users
WHERE
Le bloc de code n’est peut-être pas encore fermé. Un tableau peut ne contenir que sa ligne d’en-tête. Un lien peut n’être émis qu’à moitié. Votre moteur de rendu doit tolérer ces situations.
Pour les interfaces de conversation, je privilégie les pratiques suivantes :
- Effectuer le rendu du Markdown progressivement, mais de manière défensive.
- Désactiver les éléments HTML dangereux.
- Éviter la coloration syntaxique coûteuse tant que le message n’est pas complet.
- Afficher discrètement un état « génération en cours ».
- Appliquer à nouveau la mise en forme finale une fois le flux terminé.
Le streaming de JSON est plus difficile
Si vous demandez au modèle de produire du JSON et que vous le diffusez en streaming, l’état intermédiaire est généralement du JSON invalide. C’est un problème pour les applications qui souhaitent mettre à jour des interfaces structurées en temps réel.
Par exemple :
{
"title": "Incident Summary",
"severity": "high",
"steps": [
"Restart
Vous ne pouvez pas encore analyser ce contenu.
Il existe trois approches courantes :
- Attendre la fin, puis analyser le contenu une seule fois.
- Utiliser du JSON délimité par des sauts de ligne, où chaque ligne complète peut être analysée indépendamment.
- Diffuser des événements sémantiques, plutôt que du JSON brut.
Pour le streaming structuré, j’apprécie les sorties sous forme d’événements :
event: section
data: {"title":"Summary"}
event: bullet
data: {"text":"Database failover started at 09:42 UTC."}
event: bullet
data: {"text":"Read latency remained elevated for 6 minutes."}
Ce modèle nécessite davantage d’orchestration, mais il facilite grandement un rendu fiable.
Ce qui se passe réellement lorsque les utilisateurs cliquent sur « Arrêter »
L’annulation est l’un des avantages sous-estimés du streaming.
Avec une requête sans streaming, l’utilisateur peut quitter la page alors que le modèle continue de générer du contenu sur le serveur. Selon le fournisseur et votre backend, cette génération peut encore consommer des tokens de sortie.
Avec le streaming, le client peut interrompre la requête HTTP :
const controller = new AbortController();
fetch("/api/chat/stream", {
method: "POST",
body: JSON.stringify(payload),
signal: controller.signal
});
// Later
controller.abort();
Mais l’annulation n’est pas toujours instantanée de bout en bout.
Plusieurs étapes interviennent :
- Le navigateur interrompt l’appel fetch.
- Votre backend détecte la fermeture de la connexion.
- Votre backend annule la requête envoyée au fournisseur en amont.
- Le fournisseur arrête la génération.
- L’utilisation est calculée pour les tokens déjà traités ou générés.
Un piège courant : si votre backend relaie le flux sans propager l’annulation en amont, le navigateur de l’utilisateur se déconnecte, mais votre serveur continue de lire la sortie du modèle jusqu’à la fin.
En pseudocode de style Node :
app.post("/api/chat/stream", async (req, res) => {
const upstreamController = new AbortController();
req.on("close", () => {
upstreamController.abort();
});
const upstream = await fetch(PROVIDER_URL, {
method: "POST",
headers: providerHeaders,
body: JSON.stringify({
...req.body,
stream: true
}),
signal: upstreamController.signal
});
res.setHeader("Content-Type", "text/event-stream");
for await (const chunk of upstream.body) {
res.write(chunk);
}
res.end();
});
C’est la différence entre « l’interface s’est arrêtée » et « le modèle s’est arrêté ».
Streaming et coût : ce qui change et ce qui ne change pas
Le streaming change le moment où vous observez l’utilisation. Il ne change généralement pas l’unité de base de la facturation.
La plupart des API de LLM facturent selon des critères tels que :
- Les tokens d’entrée.
- Tokens de sortie.
- Les tokens d’entrée mis en cache, si la mise en cache des prompts s’applique.
- Les appels d’outils ou les modalités particulières, selon l’API.
- Parfois, les tokens de raisonnement ou les ressources de calcul internes allouées à certaines familles de modèles.
Le streaming en lui-même ne donne généralement pas droit à une réduction. Si le modèle génère 700 tokens de sortie, vous devez vous attendre à comptabiliser 700 tokens de sortie, qu’ils arrivent progressivement ou dans une seule charge utile finale.
Le streaming influe sur les coûts sur le plan opérationnel :
| Scénario | Impact sur les coûts |
|---|---|
| L’utilisateur annule après 80 tokens | Vous pouvez éviter de générer le reste de la sortie si l’annulation se propage |
| L’interface détecte suffisamment tôt que la réponse est suffisante | L’application peut arrêter délibérément la génération |
| Le backend n’interrompt pas la requête en amont | Vous pouvez payer la génération complète malgré la déconnexion du client |
| Les données d’utilisation n’arrivent que dans l’événement final | Les flux interrompus peuvent nécessiter des journaux côté fournisseur ou des estimations |
| Les réponses longues sont diffusées en streaming par défaut | Les utilisateurs peuvent consommer davantage parce que l’expérience paraît plus fluide |
| Appels d’outils avant la diffusion du texte | TTFT peut augmenter, mais le coût des tokens de sortie peut rester inchangé |
Piège de la comptabilisation de l’utilisation
De nombreuses API de streaming envoient les métadonnées d’utilisation à la fin du flux :
{
"type": "message.done",
"usage": {
"input_tokens": 1240,
"output_tokens": 389
},
"finish_reason": "stop"
}
Si le flux est interrompu avant l’événement final, votre application peut ne pas recevoir les données d’utilisation. Cela ne signifie pas nécessairement que l’utilisation est nulle.
Pour la comptabilisation interne, je stocke généralement trois champs :
{
"request_id": "chat_01j...",
"status": "client_aborted",
"usage": null,
"estimated_output_chars": 1842,
"final_usage_received": false
}
Je rapproche ensuite ces données si le fournisseur donne accès à des journaux de requêtes ou à des événements d’utilisation. Sinon, vous pouvez au moins établir une estimation approximative pour l’analyse du produit, tout en évitant une fausse précision dans la facturation.
Ne facturez pas les utilisateurs finaux à partir d’une estimation fondée sur le nombre de caractères, sauf si vos conditions le prévoient explicitement. La tokenisation varie selon le modèle et la langue.
Modèle d’architecture backend
En production, le cheminement d’un flux ressemble généralement à ceci :
Browser
|
| POST /api/chat/stream
v
App Backend
|
| provider streaming request
v
LLM API
J’expose rarement les clés des fournisseurs directement aux navigateurs. Le backend vous apporte :
- L’authentification et l’autorisation.
- La limitation du débit par utilisateur ou par espace de travail.
- L’assemblage du prompt et la récupération d’informations.
- L’exécution des outils.
- Des contrôles de masquage des données et de journalisation.
- Le basculement vers un autre fournisseur.
- La propagation de l’annulation.
- L’enregistrement de l’utilisation.
Une réponse minimale en streaming depuis un backend Python FastAPI :
import json
from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse
import httpx
app = FastAPI()
PROVIDER_URL = "https://api.example.com/v1/chat/completions"
@app.post("/api/chat/stream")
async def chat_stream(request: Request):
body = await request.json()
async def event_generator():
async with httpx.AsyncClient(timeout=None) as client:
async with client.stream(
"POST",
PROVIDER_URL,
headers={
"Authorization": "Bearer YOUR_PROVIDER_KEY",
"Content-Type": "application/json",
"Accept": "text/event-stream",
},
json={
**body,
"stream": True,
},
) as upstream:
async for line in upstream.aiter_lines():
if await request.is_disconnected():
break
if not line:
continue
# Optionally transform provider-specific events here.
yield line + "\n\n"
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
},
)
En pratique, je normalise généralement les événements des fournisseurs dans un schéma interne unique :
{
"type": "delta",
"text": "partial text here"
}
puis :
{
"type": "done",
"finish_reason": "stop",
"usage": {
"input_tokens": 812,
"output_tokens": 436
}
}
Cela compte si vous prenez en charge plusieurs modèles tels que Claude, GPT, Gemini, Llama, Qwen ou Kimi derrière une même interface. Chaque fournisseur a une sémantique de flux légèrement différente. Une normalisation précoce permet de garder un frontend sans surprises, ce qui est exactement ce que vous souhaitez.
TTFT ne dépend pas uniquement du modèle
Lorsque TTFT est mauvais, on accuse souvent le modèle en premier. C’est parfois justifié : des modèles plus volumineux, des contextes plus longs et des modes de raisonnement plus intensifs peuvent augmenter la latence du premier token. Mais de nombreux mécanismes interviennent avant l’arrivée de ce premier token.
Les facteurs qui y contribuent comprennent :
- L’établissement de la connexion TLS.
- Les démarrages à froid de votre backend.
- La construction du prompt et la récupération d’informations.
- La latence de la base de données vectorielle.
- Les appels d’outils avant la génération.
- La mise en file d’attente chez le fournisseur.
- L’ingestion d’un long contexte d’entrée.
- Les contrôles de sécurité ou les couches d’application des règles.
- Mise en mémoire tampon par le proxy.
- Délai de rendu côté client.
Une trace exploitable doit distinguer ces éléments :
{
"trace_id": "req_abc",
"timings_ms": {
"auth": 12,
"retrieval": 84,
"prompt_build": 9,
"provider_connect": 141,
"ttfb": 612,
"first_delta": 804,
"stream_complete": 5820
}
}
Si la récupération prend 600 ms et que le premier delta du modèle arrive 250 ms plus tard, changer de modèle ne résoudra peut-être pas votre problème d’expérience utilisateur. Si l’établissement de la connexion au fournisseur prend l’essentiel du temps, réutiliser les connexions peut aider. Si le rendu côté frontend attend un paragraphe complet, votre streaming fonctionne, mais votre politique d’affichage le masque.
Quand éviter le streaming
Le streaming n’en vaut pas toujours la peine.
J’évite le streaming lorsque :
- La réponse est très courte, par exemple pour une classification ou un routage.
- L’appelant est un autre service qui a besoin de JSON complet.
- La sortie doit être entièrement modérée avant son affichage.
- La requête s’exécute dans une file de tâches ou un pipeline de traitement par lots.
- Le framework ou la couche d’hébergement rend le streaming peu fiable.
- Le produit a besoin de réponses complètes livrées en une fois, et non d’un texte affiché progressivement.
Il existe aussi un inconvénient pour l’expérience utilisateur : le streaming peut laisser voir le modèle changer de direction au milieu de sa réponse. Les utilisateurs le tolèrent dans une certaine mesure dans un chat, mais cela peut paraître peu soigné dans des workflows à forts enjeux. Pour les interfaces juridiques, médicales, financières ou soumises à de fortes exigences de conformité, vous pouvez choisir de ne diffuser le contenu qu’après l’application de garde-fous, ou de le diffuser dans un tampon interne avant d’afficher les fragments jugés sûrs.
Pratiques utiles côté client
Voici les pratiques que je recommande pour la plupart des produits de chat :
1. Afficher un état avant l’arrivée du texte
Ne laissez pas la bulle de l’assistant vide. Utilisez des états explicites :
- « Lecture du contexte… »
- « Recherche dans les documents… »
- « Rédaction de la réponse… »
- « Traitement en cours… »
C’est particulièrement utile lorsque des appels d’outils précèdent la génération de texte.
2. Conserver un véritable bouton d’arrêt
Un bouton d’arrêt n’est pas un simple élément décoratif de l’interface. Correctement implémenté, il peut économiser des tokens de sortie.
Assurez-vous que :
- Le navigateur annule la requête.
- Votre backend détecte les déconnexions.
- Votre backend annule la requête auprès du fournisseur.
- Vous enregistrez la requête comme annulée.
3. Afficher progressivement, finaliser la mise en forme à la fin
Pendant le streaming :
- Ajoutez le texte au fur et à mesure.
- Effectuez un rendu Markdown léger.
- Évitez une coloration syntaxique coûteuse.
- Gardez un comportement de défilement prévisible.
Une fois la génération terminée :
- Analysez à nouveau le Markdown.
- Appliquez la coloration syntaxique au code.
- Activez les boutons de copie.
- Ajoutez des citations ou des références si votre système les prend en charge.
4. Stocker les messages partiels avec précaution
Si un flux échoue à mi-parcours, décidez de ce que doit représenter l’historique de conversation.
Options :
- Conserver le message partiel de l’assistant avec la mention « interrompu ».
- Le supprimer entièrement.
- Permettre à l’utilisateur de réessayer avec le même prompt.
- Demander au modèle de poursuivre à partir du contexte partiel.
Je conserve généralement le message partiel en le signalant clairement. Une suppression silencieuse déroute les utilisateurs, et faire comme si le message était complet est encore pire.
5. Normaliser les événements
Votre frontend ne devrait pas avoir à connaître cinq formats de streaming propres à différents fournisseurs. Normalisez-les dans le backend :
{"type":"delta","text":"Hello"}
{"type":"tool_start","name":"search_docs"}
{"type":"tool_result","name":"search_docs","status":"ok"}
{"type":"done","finish_reason":"stop","usage":{"input_tokens":100,"output_tokens":50}}
{"type":"error","message":"upstream timeout"}
Cela facilite également le passage d’une famille de modèles à une autre ou le routage dynamique des requêtes. Si vous utilisez une couche API multimodèle, cette normalisation fait peut-être déjà partie de votre passerelle ; sinon, il vaut la peine de la développer vous-même.
Conseils pratiques
- Le streaming améliore la latence perçue en réduisant le délai avant la première sortie visible, sans pour autant rendre la génération complète gratuite ou instantanée.
- Mesurez séparément TTFT, le temps total de génération et le nombre de tokens par seconde.
- SSE est généralement le transport le plus simple pour diffuser du texte de LLM en streaming ; utilisez
fetch()des flux lorsque vous avez besoin de corps POST. - Surveillez la mise en mémoire tampon par les proxies et les frameworks. De nombreux « bugs de streaming » sont en réalité des problèmes de mise en mémoire tampon dans l’infrastructure.
- Traitez la sortie partielle comme non valide : le Markdown peut être incomplet, JSON peut être impossible à analyser et les délimiteurs de blocs de code peuvent encore être ouverts.
- L’annulation ne réduit les coûts que si elle se propage du navigateur au backend, puis au fournisseur.
- Les métadonnées d’utilisation arrivent souvent à la fin du flux ; les flux interrompus nécessitent un comptage rigoureux.
- Ne refaites pas le rendu de toute votre interface à chaque minuscule fragment. Regroupez les mises à jour à l’aide des frames d’animation.
- Normalisez les événements de flux propres aux fournisseurs dans votre backend.
- Évitez le streaming pour les classifications courtes, les tâches par lots, les sorties strictement structurées ou les workflows nécessitant une modération complète avant affichage.
Une seule clé API pour Claude Opus 5.5, Sonnet 5, Haiku 4.5 et Fable 5.1, ainsi que GPT-6 modèles. Paiement à l’usage, sans abonnement.
Obtenir votre clé API →