Les échecs du streaming viennent rarement du modèle — ils proviennent de l’infrastructure entre vous et lui. Les proxys à timeout d’inactivité tuent les connexions silencieuses, nginx met les événements en mémoire tampon, et les flux partiellement consommés ressemblent à « l’API ne répond plus ». Commencez par examiner l’infrastructure.
L’erreur
- Stream stops mid-sentence, connection closed (no error event)
- Client hangs after the last token, never sees [DONE]
- usage is null on streamed responses
- Works in curl, dies behind nginx / a corporate proxyCauses et solutions en bref
| Cause | Solution |
|---|---|
| Timeout d’inactivité du proxy ou du répartiteur de charge (60s par défaut dans de nombreuses configurations) | Augmentez les timeouts de lecture pour le chemin API ; les longues pauses de réflexion semblent être une inactivité pour un proxy. |
| Mise en mémoire tampon devant SSE (nginx proxy_buffering, certains CDN) | Désactivez la mise en mémoire tampon pour la route de streaming (X-Accel-Buffering: no / proxy_buffering off). |
| Le client cesse de consommer (await manquant, itérateur abandonné) | Consommez jusqu’à la fin ou fermez explicitement — un itérateur récupéré par le GC au milieu du flux est impossible à distinguer d’une coupure. |
| S’attendre à recevoir l’utilisation sans la demander | Protocole OpenAI : transmettez stream_options: {"include_usage": true} — l’utilisation arrive dans le dernier fragment. |
Reproduisez avec curl -N directement vers l’API
Contournez chaque proxy. Si le SSE brut fonctionne correctement pendant toute la génération, le problème vient du chemin de votre application — réintroduisez les intermédiaires un par un :
curl -N https://api.kunavo.com/v1/chat/completions \
-H "Authorization: Bearer $KUNAVO_API_KEY" \
-H "content-type: application/json" \
-d '{"model":"claude-sonnet-5","stream":true,
"stream_options":{"include_usage":true},
"max_tokens":300,
"messages":[{"role":"user","content":"Count slowly to 20 in words."}]}'Corrigez l’intermédiaire qui casse le flux
nginx : proxy_buffering off + proxy_read_timeout 300s pour la route. Serverless : vérifiez les limites de streaming des réponses de la plateforme. Proxys d’entreprise : certains ne prennent pas du tout en charge SSE — utilisez alors le mode sans streaming.
Gérez correctement la fin du flux
Les données de facturation du streaming arrivent en dernier : le dernier fragment contient l’utilisation (lorsqu’elle est demandée) avant [DONE]. Agrégez les deltas, lisez l’utilisation dans le dernier fragment et traitez une fermeture précoce (sans finish_reason) comme pouvant être relancée.
« Le flux SSE s’est terminé sans [DONE] » — la réponse était-elle complète ?
Ce message provient de la propre vérification de votre client, et non d’une erreur envoyée par l’API : la connexion s’est fermée avant la ligne data: [DONE] qui termine un flux compatible OpenAI. Trois causes sont possibles : un intermédiaire a fermé la connexion (timeout du proxy ou mise en mémoire tampon décrits ci-dessus) ; le serveur a échoué au milieu de la réponse et s’est fermé sans trame terminale ; ou la réponse était complète et seul le marqueur a été perdu. Le dernier fragment reçu permet de trancher : un finish_reason présent signifie que le texte est complet, tandis que son absence signifie qu’il a été coupé et doit être relancé. Ne laissez pas cette vérification au SDK — le SDK Python OpenAI termine discrètement sa boucle lorsque la connexion se ferme sans [DONE] et ne lève une exception que lorsqu’un fragment contient un objet d’erreur. Effectuez la vérification dans votre propre code :
from openai import OpenAI
client = OpenAI(base_url="https://api.kunavo.com/v1", api_key="sk-kn-...")
finish_reason, parts = None, []
stream = client.chat.completions.create(
model="claude-sonnet-5",
messages=[{"role": "user", "content": "Count slowly to 20 in words."}],
stream=True,
)
for chunk in stream: # raises openai.APIError on a chunk that carries "error"
for choice in chunk.choices:
parts.append(choice.delta.content or "")
finish_reason = choice.finish_reason or finish_reason
if finish_reason is None:
raise RuntimeError("stream closed without a finish_reason: cut off, retry it")
print("".join(parts))Flux vide : 200, puis plus rien
Parfois, le flux s’ouvre avec HTTP 200 puis se termine sans le moindre fragment de contenu — aucun texte, aucun appel d’outil, parfois même pas le fragment de rôle. Cela signifie presque toujours qu’un service en amont a échoué après l’envoi des en-têtes : fournisseur surchargé, passerelle dont le propre service en amont a refusé la requête, ou proxy qui a supprimé le corps. Traitez cela comme une coupure de flux et relancez avec un backoff, puis consignez le corps brut d’une réponse en échec, car la cause est souvent un événement d’erreur à l’intérieur du flux que votre SDK a ignoré. Claude Code réagit à la même condition en relançant la requête sans streaming.
Si vous appelez via Kunavo
Kunavo diffuse des SSE standard compatibles avec le protocole OpenAI (avec prise en charge de stream_options.include_usage) ainsi que des événements compatibles avec le protocole Anthropic sur /v1/messages ; la reproduction curl ci-dessus constitue donc également le test de compatibilité. Pour les modèles Claude et GPT sur /v1/chat/completions, un flux Kunavo se termine par data: [DONE], que la réponse soit terminée ou non : après le fragment contenant finish_reason lorsqu’elle l’est, et après un fragment d’erreur — type upstream_error, code upstream_disconnect ou upstream_timeout — lorsque le fournisseur en amont s’est déconnecté en cours de réponse, afin que les SDK OpenAI lèvent une APIError au lieu de vous remettre le fragment comme réponse complète. Un flux en amont qui se termine avant tout contenu, ou échoue avant le premier jeton, ne vous parvient jamais sous la forme d’un 200 vide : la tentative est relancée sur un autre canal lorsque le modèle en possède un, et renvoyée sinon sous forme d’erreur HTTP. Une requête en flux qui échoue avant de vous transmettre toute sortie n’est pas facturée ; celle qui s’interrompt en cours de réponse ne l’est pas nécessairement — renvoyez-la plutôt que de supposer qu’elle était gratuite.
Questions fréquentes
Pourquoi usage est-il null dans mes réponses en flux ?
Sur les API compatibles avec le protocole OpenAI, usage n’est pas inclus dans les flux sauf si vous transmettez stream_options: {"include_usage": true} ; il arrive alors dans le fragment final. Le flux natif d’Anthropic indique l’utilisation dans les événements message_start/message_delta.
Comment détecter un flux interrompu par rapport à un flux terminé ?
Un flux terminé se clôt par un finish_reason (ou le message_stop d’Anthropic), puis par [DONE]. Une connexion qui se ferme sans ces marqueurs a été interrompue — traitez-la comme un échec réessayable, et non comme une réponse courte.
Que signifie « SSE stream ended without [DONE] » ?
Votre client a lu le flux jusqu’à la fin de la connexion sans jamais voir la ligne data: [DONE] qui clôt un flux compatible avec OpenAI. L’API n’a pas envoyé ce message ; votre client ou votre agent l’a écrit. Si le dernier fragment contenait un finish_reason, la réponse est complète et seule la fermeture a été perdue. Sinon, la réponse a été interrompue par un proxy, un délai d’expiration ou une défaillance côté serveur, et la requête doit être relancée.
Que signifie « stream ended without finish_reason » ?
Le client a lu le flux jusqu’à sa fin et aucun fragment ne contenait de finish_reason — le champ qu’un flux compatible avec OpenAI utilise pour indiquer que la réponse est terminée (stop, length, tool_calls). Sans ce champ, le texte dont vous disposez est un fragment, aussi naturelle que paraisse sa dernière phrase. Relancez la requête ; si le problème se répète, recherchez un délai d’expiration de proxy ou une mise en tampon entre vous et l’API.
Pourquoi une API LLM renverrait-elle un flux vide ?
Parce que l’échec s’est produit après l’envoi des en-têtes HTTP : le fournisseur était surchargé, la passerelle en amont a refusé la requête ou un proxy a supprimé le corps. La ligne d’état indique tout de même 200, de sorte que les vérifications d’état ne le détectent pas. Traitez un flux sans fragment de contenu comme une requête échouée, relancez-la avec un backoff et consignez une réponse brute pour trouver l’événement d’erreur qu’elle contient.
Est-il prudent d’ignorer un [DONE] manquant si j’ai déjà du texte ?
Uniquement lorsque le dernier fragment contenait un finish_reason. Sans celui-ci, le texte dont vous disposez est un fragment qui peut se terminer au milieu d’une phrase ou d’un appel d’outil, avec des arguments JSON incomplets. Relancez la requête plutôt que de l’enregistrer comme réponse.
Guides associés
- Guide des API compatibles OpenAI — de l'endpoint local d'Ollama aux modèles avancés hébergés
- « Streaming interrupted. Waiting for the complete message » — signification et solution
- Claude Code « Response stalled mid-stream » et « Streaming response ended before any complete data was received » — ce qui interrompt le flux
La sémantique détaillée des erreurs est disponible dans référence des erreurs ; obtenir une clé prend une minute via inscription et la guide d’authentification.