← Blog

Cómo mantener vivo un stream SSE mientras el modelo piensa: headers de entrada y un comentario cada 15 s

CognitionDesk Team··4 min de lectura

El stream anda perfecto hasta que el modelo piensa un poco más de la cuenta.

En nuestra API el endpoint de chat por streaming (/chat-apiKeyAuth/stream) tiene un guard: si a los 60 s el request todavía no mandó headers, contesta 504. Un día de septiembre, 4 de 44 turnos salieron con 504. El caso que medimos tardó unos 95 s hasta el primer chunk del modelo. El turno seguía vivo; lo cortó el guard.

La causa estaba en nuestro controller: fijaba los headers SSE con setHeader y nada más, así que no salían hasta el primer res.write, y el primer write llegaba con el primer token.

0153045607590scorte a los 60 s sin headersAntessin headers504 a los 60 sAhoraheaders · ping cada 15 schunks
Antes, los headers esperaban al primer chunk y el guard cortaba a los 60 s. Ahora salen al abrir y un comentario cada 15 s mantiene la conexión con tráfico.

1. Mandá los headers al abrir

export function abrirSSE(res, { pingMs = 15_000 } = {}) {
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('Connection', 'keep-alive');
  res.flushHeaders(); // el 200 sale ahora, no con el primer token
  // ...
}

flushHeaders() manda el 200 y los headers en el momento. Desde ahí, cualquier timeout que mire "¿ya respondió algo?" da el request por atendido.

2. Un comentario SSE cada 15 s

  const vivo = () => !res.writableEnded && !res.destroyed;
  const timer = setInterval(() => {
    if (vivo()) res.write(': ping\n\n');
    else clearInterval(timer);
  }, pingMs);
  res.on('close', () => clearInterval(timer));
  res.on('finish', () => clearInterval(timer));

Una línea que empieza con : es un comentario en la especificación de SSE: EventSource y cualquier cliente que la respete la ignoran. Al cliente no le llega ningún evento, pero la conexión tiene tráfico, y un proxy intermedio no la da por muerta.

Que se apague solo importa tanto como que se prenda. Si el cliente se va o el turno termina, el intervalo se limpia; si no, quedaría escribiendo en un socket cerrado.

3. El timeout del SDK no mide lo que creés

Al ver un turno de 95 s, lo natural es pensar que el SDK del modelo tiene que tener un timeout más largo. Mirando el código, en el SDK de OpenAI (v4) y en el de Anthropic el timeout se arma así:

const timeout = setTimeout(() => controller.abort(), ms);
// ...
return this.fetch(url, fetchOptions).finally(() => clearTimeout(timeout));

El clearTimeout corre cuando fetch resuelve, y fetch resuelve cuando llegan los headers de la respuesta. En streaming, eso es al principio. El timeout del SDK es un tope de conexión, no del turno entero.

Lo que sí ajustamos, sólo en streaming: el cliente del modelo queda con timeout de 45 s y 1 reintento. LangChain reintentaba 6 veces por defecto, y con un stream abierto del lado del usuario, seis reintentos son mucho tiempo mirando una pantalla vacía. Sin streaming no lo tocamos, porque ahí los headers llegan con la respuesta entera.

Cómo lo probamos

Un test con timers falsos levanta un LLM que tarda 70 s en dar su primer chunk:

  • La respuesta es 200 con Content-Type: text/event-stream, nunca el 504.
  • Antes del primer chunk salen 4 pings (70 / 15).
  • El stream termina con data: [DONE] y, una vez terminado, no queda ningún ping escribiendo.

Contra la rama anterior al arreglo, el mismo test daba 504. Hay otro test para el caso en que el cliente se desconecta: el ping se apaga.

En CognitionDesk

Así corre hoy /chat-apiKeyAuth/stream, el endpoint que usan el widget y las integraciones por API key. Si tu stream se corta mientras el modelo piensa, escribinos por el chat de esta página.

Comentarios y correcciones

Creá tu primer agente IA hoy

RAG sobre tus documentos, tools, workflows y widget web — con plan gratuito.

Empezar gratis