Cómo hacer streaming de respuestas de IA (SSE)

Para hacer streaming de una respuesta de IA, llama al modelo con streaming activado y reenvía cada fragmento de texto al navegador como un server-sent event. El navegador muestra los fragmentos según llegan, sin esperar la respuesta completa.

Actualizado el 6 min de lectura

Objetivos de aprendizaje

Después de leer este artículo podrás:

  • Reenviar los tokens del modelo al navegador como server-sent events
  • Leer el stream en el cliente con fetch y mostrar el texto en vivo
  • Corregir problemas de buffering en el proxy, cancelación y reconexión
En esta página

Un modelo de lenguaje genera texto un token a la vez, y una respuesta larga puede tardar diez segundos o más en terminar. Si tu backend espera la respuesta completa antes de contestar, el usuario se queda mirando un spinner todo ese tiempo. Con streaming, las primeras palabras aparecen en una fracción de segundo y el resto llega a medida que se genera.

La forma estándar de hacerlo sobre HTTP es con server-sent events (SSE). A continuación armas un endpoint pequeño en Node.js que transmite la respuesta del modelo y un cliente en el navegador que la muestra en vivo. Después, resuelves los problemas que suelen romper el streaming en producción. Para entender el lado del modelo, mira qué es un modelo de lenguaje (LLM).

NavegadorServidorLLMPOST + conversaciónstreaming activadofragmentosflushHoladata: "Hola"Holamundata: " mun"Hola mundodata: "do"Hola mundoevent: done
Cada fragmento del modelo se vuelve una línea data: con flush, y el navegador muestra el texto mientras llega.

¿Qué son los server-sent events?#

SSE es una respuesta HTTP común que queda abierta hasta que el servidor termina. El servidor envía Content-Type: text/event-stream y va escribiendo mensajes de texto. Cada mensaje es una o más líneas campo: valor, seguidas de una línea en blanco:

text
data: Hello

event: done
data: {}

Los campos que importan:

  • data: es el contenido. Las líneas data: seguidas en un mismo mensaje se unen con un salto de línea.
  • event: da nombre al evento. Sin él, el navegador trata el mensaje como un evento message por defecto.
  • id: define el ID del último evento, que el navegador devuelve como Last-Event-ID al reconectarse.
  • retry: define el tiempo de reconexión en milisegundos.
  • Una línea que empieza con : es un comentario, útil como keep-alive.

Como es HTTP común, SSE funciona con las herramientas de siempre para autenticación, logs y TLS. La mayoría de los proveedores de LLM usan SSE en sus propias APIs de streaming, por eso sus SDK ya te dan un stream listo para reenviar.

¿SSE o WebSockets para respuestas de IA?#

Para la respuesta de un chat, SSE suele bastar. Los datos van en un solo sentido, del servidor al navegador, y el siguiente mensaje del usuario es solo otra petición HTTP. Los WebSockets abren un canal bidireccional, pero el upgrade de conexión, los heartbeats y la lógica de reconexión quedan por tu cuenta. Elige WebSockets cuando el cliente tenga que enviar datos todo el tiempo por la misma conexión, como entrada de voz. Lee qué es un WebSocket y cómo funciona para ese caso.

¿Cómo transmitir los tokens desde el servidor?#

Instala Express y el SDK de Anthropic (el mismo patrón sirve para cualquier proveedor con streaming):

bash
npm install express @anthropic-ai/sdk

Crea server.mjs. La clave de la API y el modelo vienen de variables de entorno, nunca del código:

js
import express from "express";
import Anthropic from "@anthropic-ai/sdk";

const client = new Anthropic(); // lee ANTHROPIC_API_KEY
const app = express();
app.use(express.json());

app.post("/api/chat", async (req, res) => {
  res.writeHead(200, {
    "Content-Type": "text/event-stream",
    "Cache-Control": "no-cache, no-transform",
    Connection: "keep-alive",
    "X-Accel-Buffering": "no", // le pide a nginx que no haga buffer de esta respuesta
  });

  const send = (event, data) =>
    res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);

  const stream = client.messages.stream({
    model: process.env.ANTHROPIC_MODEL, // un id de modelo de la documentación del proveedor
    max_tokens: 1024,
    messages: req.body.messages,
  });

  // Deja de pagar por tokens que nadie va a leer.
  res.on("close", () => {
    if (!res.writableEnded) stream.abort();
  });

  stream.on("text", (text) => send("token", text));

  try {
    await stream.finalMessage();
    send("done", {});
  } catch (err) {
    if (!res.destroyed) send("error", { message: "Falló la generación" });
  }
  res.end();
});

app.listen(process.env.PORT || 3000, "0.0.0.0");

Ejecútalo con ANTHROPIC_API_KEY=... ANTHROPIC_MODEL=... node server.mjs. Algunos detalles se pasan por alto fácilmente:

  • JSON.stringify en cada fragmento evita que los saltos de línea dentro del texto rompan el formato de la línea data:.
  • no-transform impide que los intermediarios compriman o reescriban el stream.
  • El handler de close aborta la petición al proveedor cuando el usuario cierra la pestaña o pulsa detener. Sin él, sigues generando (y pagando) una respuesta que nadie ve.
  • En una app real, valida y limita req.body.messages y autentica la petición antes de llamar al modelo.

¿Cómo leer el stream en el navegador?#

El EventSource nativo solo envía peticiones GET, sin cuerpo y sin cabeceras personalizadas, así que no puede llevar la conversación ni un token de autenticación. Usa fetch y lee el cuerpo como stream. El parser de abajo separa los mensajes en las líneas en blanco, que es donde termina cada mensaje SSE:

js
async function streamChat(messages, onToken, signal) {
  const res = await fetch("/api/chat", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ messages }),
    signal,
  });
  if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`);

  const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
  let buffer = "";

  while (true) {
    const { value, done } = await reader.read();
    if (done) break;
    buffer += value;

    let end;
    while ((end = buffer.indexOf("\n\n")) !== -1) {
      const raw = buffer.slice(0, end);
      buffer = buffer.slice(end + 2);

      let event = "message";
      let data = "";
      for (const line of raw.split("\n")) {
        if (line.startsWith("event:")) event = line.slice(6).trim();
        else if (line.startsWith("data:")) data += line.slice(5).trimStart();
      }
      if (event === "token") onToken(JSON.parse(data));
      if (event === "error") throw new Error(JSON.parse(data).message);
      if (event === "done") return;
    }
  }
}

Llámala con un AbortController. Así, un botón Detener cancela la petición y, a través del handler de close del servidor, también la llamada al modelo:

js
const controller = new AbortController();
const out = document.querySelector("#answer");

streamChat(
  [{ role: "user", content: "Explica SSE en dos frases." }],
  (token) => (out.textContent += token),
  controller.signal,
);

document.querySelector("#stop").onclick = () => controller.abort();

Usar textContent en lugar de innerHTML importa: la salida del modelo es texto no confiable. Si renderizas Markdown, sanitiza el HTML antes.

¿Cómo comprobar que el streaming funciona de verdad?#

Prueba el endpoint sin navegador, con curl -N (la opción -N desactiva el buffer del propio curl):

bash
curl -N -X POST http://localhost:3000/api/chat \
  -H "Content-Type: application/json" \
  -d '{"messages":[{"role":"user","content":"Cuenta hasta diez."}]}'

Deberías ver las líneas event: token apareciendo una tras otra, y al final event: done. Si todo llega de golpe al final, tu código transmite bien, pero algo en el camino está haciendo buffer. Repite la prueba contra la URL publicada: un stream que funciona en local y no en producción casi siempre apunta a un proxy.

¿Por qué el stream llega todo de golpe?#

Estas son las causas habituales, de la más a la menos común:

  1. Buffering en el proxy. nginx hace buffer de las respuestas por defecto. Usa proxy_buffering off; en la ruta, o mantén la cabecera X-Accel-Buffering: no del ejemplo. Mira qué es un proxy inverso.
  2. Compresión. Un middleware de gzip puede retener los fragmentos hasta tener suficiente para comprimir. Excluye text/event-stream de la compresión.
  3. Cabeceras ausentes. Sin Content-Type: text/event-stream, algunos clientes y proxies esperan el cuerpo completo.
  4. Timeout por inactividad. Un proxy puede cerrar una conexión que pasa decenas de segundos sin enviar nada. Envía una línea de comentario como : ping seguida de una línea en blanco cada 15 segundos mientras el modelo piensa.
  5. Límite de conexiones del navegador. En HTTP/1.1, los navegadores permiten unas seis conexiones SSE abiertas por dominio, sumando todas las pestañas. HTTP/2 sube ese número al límite de streams negociado, 100 por defecto.

Si usas EventSource para un stream solo de GET, se reconecta solo después de una caída y envía el último id: que vio como Last-Event-ID. Una llamada al modelo no se puede retomar a mitad de la generación, así que, para un chat, lo más seguro es mostrar un error y dejar que el usuario lo intente de nuevo.

Cómo hacerlo en Square Cloud#

Un endpoint de streaming mantiene cada conexión abierta durante varios segundos, y ahí es donde falla mucho hosting: un proxy con buffer entrega todo al final, y un proceso que se duerme pierde la petición. En Square Cloud, el proxy delante de las aplicaciones deja pasar el SSE sin buffer y no corta las conexiones largas, y las aplicaciones corren en contenedores siempre encendidos, sin cold start ni modo de suspensión.

Para publicar el server.mjs en el runtime de Node.js:

  1. Sube el proyecto con su package.json. No hay etapa de build: al iniciar, la plataforma ejecuta npm install cuando no existe node_modules.
  2. Elige server.mjs como archivo principal. El archivo squarecloud.app es opcional: el panel lo genera en el deploy, o lo escribes en la raíz del proyecto.
  3. Agrega ANTHROPIC_API_KEY y ANTHROPIC_MODEL a las variables de entorno de la aplicación, desde el panel, la API, la CLI o VS Code. El cambio se aplica en el siguiente reinicio, sin rebuild.

La aplicación recibe un subdominio *.squareweb.app con HTTPS y escucha en 0.0.0.0, en el puerto de la variable PORT, como ya hace el ejemplo. Una aplicación web usa al menos 512 MB del pool de RAM del plan; el Hobby cuesta desde R$12,49 al mes. Después del deploy, repite el curl -N contra el subdominio mientras sigues los logs en tiempo real: si los eventos token llegan uno a uno, todo el camino funciona.

Una salvedad sobre el AI Gateway, la API compatible con OpenAI para el modelo cubic, en beta desde el plan Standard: todavía no hace streaming, y una petición con stream: true vuelve con un error 400. Para mostrar la respuesta token por token, como aquí, sigue con un proveedor que transmita. El gateway sirve para llamadas en las que la respuesta puede llegar completa, como resúmenes y clasificaciones en segundo plano. Para la app completa alrededor del endpoint, mira cómo hospedar un chatbot de IA y la página de hosting de APIs.

IA

Aloja apps con IA en contenedores siempre encendidos

Más información

Preguntas frecuentes

¿Cuál es la diferencia entre SSE y WebSockets para streaming de IA?

SSE es de un solo sentido (del servidor al navegador) sobre HTTP común, y es todo lo que necesita la respuesta de un chat. Los WebSockets son bidireccionales y exigen manejar la conexión por tu cuenta, así que solo compensan cuando el cliente tiene que enviar datos todo el tiempo por la misma conexión.

¿Por qué no puedo usar EventSource en un endpoint de chat?

EventSource solo hace peticiones GET, sin cuerpo y sin cabeceras personalizadas. Un chat necesita enviar la conversación, normalmente con POST y una cabecera de autenticación, así que usa fetch y lee el cuerpo de la respuesta como stream.

¿Por qué mi stream llega todo de golpe en vez de token por token?

Algo entre tu código y el navegador está guardando la respuesta en buffer. Las causas comunes son un proxy inverso con buffering activado, compresión gzip del stream o falta de flush. Desactiva el buffering en esa ruta y prueba con curl -N.

¿Necesito streaming si el modelo es rápido?

Las respuestas largas siguen tardando varios segundos en terminar. El streaming muestra las primeras palabras casi al instante, lo que hace que la app se sienta más rápida aunque el tiempo total sea el mismo.

Siguiente artículoQué es RAG (generación aumentada por recuperación)RAG (generación aumentada por recuperación) busca fragmentos relevantes de tus datos y los entrega al LLM junto con la pregunta. Mira cómo funciona y cuándo usarlo.