Volver a las guías
Solución de problemas·17 de julio de 2026·6 min de lectura

Errores de streaming de LLM: cortes SSE, streams bloqueados y uso no informado

Los fallos de streaming rara vez los causa el modelo: suelen estar en la infraestructura entre tú y él. Los proxies con tiempo de espera por inactividad terminan las conexiones silenciosas, el buffering de nginx se traga los eventos y los streams consumidos a medias parecen indicar que «la API dejó de responder». Revisa primero la infraestructura.

Última revisión: .

Los fallos de streaming rara vez los causa el modelo: suelen estar en la infraestructura entre tú y él. Los proxies con tiempo de espera por inactividad terminan las conexiones silenciosas, el buffering de nginx se traga los eventos y los streams consumidos a medias parecen indicar que «la API dejó de responder». Revisa primero la infraestructura.

El error

symptoms
- 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 proxy

Causas y soluciones de un vistazo

CausaSolución
Tiempo de espera por inactividad del proxy o equilibrador de carga (60 s de forma predeterminada en muchas configuraciones)Aumenta los tiempos de espera de lectura para la ruta de la API; las pausas largas de razonamiento parecen inactividad para un proxy.
Buffering delante de SSE (nginx proxy_buffering, algunas CDN)Desactiva el buffering para la ruta de streaming (X-Accel-Buffering: no / proxy_buffering off).
El cliente deja de consumir (falta await, se descarta el iterador)Consume hasta el final o cierra explícitamente: un iterador recogido por el GC a mitad del stream no se distingue de un corte.
Esperar uso sin solicitarloOpenAI-wire: pasa stream_options: {"include_usage": true}; el uso llega en el fragmento final.

Reproduce el problema con curl -N directamente contra la API

Evita todos los proxies. Si el SSE sin intermediarios fluye correctamente durante toda la generación, el problema está en la ruta de tu aplicación; vuelve a introducir los saltos uno a uno:

raw-stream.sh
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."}]}'

Corrige el salto que lo rompe

nginx: proxy_buffering off + proxy_read_timeout 300s para la ruta. Serverless: comprueba los límites de streaming de respuestas de la plataforma. Proxies corporativos: algunos no admiten SSE; en ese caso, usa el modo sin streaming.

Gestiona correctamente la cola final

Los datos de facturación del streaming llegan al final: el fragmento final contiene el uso (cuando se solicita) antes de [DONE]. Agrega los deltas, lee el uso del último fragmento y trata un cierre prematuro (sin finish_reason) como reintentable.

«El stream SSE terminó sin [DONE]»: ¿la respuesta estaba completa?

Ese mensaje es la comprobación del propio cliente, no un error enviado por la API: la conexión se cerró antes de la línea data: [DONE] con la que termina un stream compatible con OpenAI. Lo causan tres cosas: un salto intermedio cerró la conexión (el tiempo de espera del proxy y el buffering descritos arriba); el servidor falló a mitad de la respuesta y cerró sin un frame terminal; o la respuesta estaba completa y solo se perdió el marcador. El último fragmento recibido determina cuál fue el caso: un finish_reason allí significa que el texto está completo, mientras que la ausencia de finish_reason significa que se cortó y debe reintentarse. No dejes esa comprobación al SDK: el SDK de Python de OpenAI termina silenciosamente su bucle cuando la conexión se cierra sin [DONE] y solo lanza una excepción cuando un fragmento contiene un objeto de error. Haz la comprobación en tu propio código:

stream_check.py
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))

Un stream vacío: 200 y después nada

A veces el stream se abre con HTTP 200 y termina sin un solo fragmento de contenido: sin texto, sin llamada a herramienta y, en ocasiones, ni siquiera el fragmento de rol. Casi siempre se trata de un upstream que falló después de enviar las cabeceras: un proveedor sobrecargado, una pasarela cuyo propio upstream rechazó la solicitud o un proxy que descartó el cuerpo. Trátalo como un stream cortado y reintenta con backoff; además, registra el cuerpo sin procesar de una respuesta fallida, porque a menudo la causa es un evento de error dentro del stream que tu SDK omitió. Claude Code reacciona ante la misma condición reintentando la solicitud sin streaming.

Si llamas a través de Kunavo

Kunavo transmite SSE estándar OpenAI-wire (con compatibilidad con stream_options.include_usage) y eventos Anthropic-wire en /v1/messages, por lo que la reproducción con curl anterior también es la prueba de compatibilidad. Para los modelos Claude y GPT en /v1/chat/completions, un stream de Kunavo termina con data: [DONE] independientemente de que la respuesta haya terminado: después del fragmento con finish_reason cuando terminó, y después de un fragmento de error —type upstream_error, code upstream_disconnect o upstream_timeout— cuando el upstream se desconectó a mitad de la respuesta; así, los SDK de OpenAI lanzan un APIError en lugar de entregarte el fragmento como si fuera la respuesta completa. Un stream upstream que termina antes de cualquier contenido o falla antes del primer token nunca te llega como un 200 vacío: el intento se reintenta en otro canal cuando el modelo dispone de uno y, de lo contrario, se devuelve como error HTTP. Una solicitud transmitida que falla antes de que te llegue cualquier salida no se factura; una que se interrumpe a mitad de la respuesta no tiene esa garantía: vuelve a enviarla en lugar de asumir que fue gratuita.

Preguntas frecuentes

¿Por qué usage es null en mis respuestas transmitidas?

En las API OpenAI-wire, usage no se incluye en los streams a menos que pases stream_options: {"include_usage": true}; entonces llega en el fragmento final. El stream nativo de Anthropic informa del uso en los eventos message_start/message_delta.

¿Cómo detecto un stream cortado frente a uno que terminó?

Un stream terminado finaliza con un finish_reason (o message_stop de Anthropic) y después [DONE]. Una conexión que se cierra sin esos marcadores se cortó; trátala como un fallo reintentable, no como una respuesta corta.

¿Qué significa «El stream SSE terminó sin [DONE]»?

Tu cliente leyó el stream hasta el final de la conexión y nunca vio la línea data: [DONE] que cierra un stream compatible con OpenAI. La API no envió ese mensaje; lo escribió tu cliente o agente. Si el último fragmento llevaba un finish_reason, la respuesta está completa y solo se perdió el cierre. Si no lo llevaba, la respuesta fue cortada por un proxy, un tiempo de espera o un fallo del servidor, y la solicitud debe reintentarse.

¿Qué significa «el stream terminó sin finish_reason»?

El cliente leyó el stream hasta el final y ningún fragmento llevaba un finish_reason, el campo que usa un stream compatible con OpenAI para indicar que la respuesta está completa (stop, length, tool_calls). Sin él, el texto que tienes es un fragmento, por natural que parezca su última frase. Reintenta la solicitud; si se repite, busca un tiempo de espera del proxy o buffering entre tú y la API.

¿Por qué una API de LLM devolvería un stream vacío?

Porque el fallo ocurrió después de enviar las cabeceras HTTP: el proveedor estaba sobrecargado, el upstream de una pasarela rechazó la solicitud o un proxy descartó el cuerpo. La línea de estado sigue indicando 200, por lo que las comprobaciones de estado no lo detectan. Trata un stream sin fragmentos de contenido como una solicitud fallida, reinténtala con backoff y registra una respuesta sin procesar para encontrar el evento de error dentro de ella.

¿Es seguro ignorar un [DONE] ausente si ya tengo texto?

Solo cuando el último fragmento llevaba un finish_reason. Sin él, el texto que tienes es un fragmento que puede terminar a mitad de una frase o de una llamada a herramienta, con sus argumentos JSON incompletos. Reinténtalo en lugar de guardarlo como respuesta.

Guías relacionadas

Encontrarás más detalles sobre el significado de los errores en referencia de errores; obtener una clave lleva un minuto mediante registro y la guía de autenticación.