volver al blog

Streaming de respuestas de un LLM: la UX que cambia todo

iallmux

La misma respuesta, la mitad de espera

Dos interfaces sobre el mismo modelo. La primera se queda en blanco ocho segundos y luego suelta el párrafo entero de golpe. La segunda empieza a escribir a los 300 milisegundos y va llenando la pantalla palabra a palabra. El modelo ha tardado exactamente lo mismo en las dos. La percepción del usuario no tiene nada que ver.

Eso es el streaming: no acelera el modelo, acelera la sensación. Y en productos con LLM, la sensación de velocidad manda casi tanto como la latencia real. Es la diferencia entre una herramienta que parece pensar contigo y una que parece colgada.

Por qué un LLM se presta a esto

Un modelo autoregresivo genera la respuesta token a token: predice el siguiente, lo añade al contexto y vuelve a predecir. Cuando pides la respuesta completa, el servidor espera a que termine toda esa cadena antes de contestarte. Pero los tokens existen mucho antes de que el último llegue.

El streaming solo consiste en abrir el grifo antes: en lugar de acumular la respuesta y enviarla al final, el servidor reenvía cada token en cuanto el modelo lo produce. La métrica que importa deja de ser la latencia total y pasa a ser el time to first token (TTFT): cuánto tarda en aparecer la primera palabra. Un TTFT de 300 ms con generación fluida se percibe como instantáneo aunque la respuesta completa tarde diez segundos.

Cómo se transmite: SSE frente a WebSockets

El canal habitual son los Server-Sent Events (SSE): una conexión HTTP que se mantiene abierta y por la que el servidor va empujando fragmentos. Es lo que usan las APIs de OpenAI y Anthropic, y encaja con el problema porque el flujo es en un solo sentido, del servidor al cliente.

SSEWebSockets
DirecciónSolo servidor → clienteBidireccional
ProtocoloHTTP normalProtocolo propio (upgrade)
ReconexiónAutomática, integradaManual
Encaje con LLMEl estándar de factoSobra potencia para el caso

Para servir la respuesta de un modelo, SSE es la opción sensata: menos piezas móviles, reconexión gratis y funciona con cualquier proxy que hable HTTP. Deja los WebSockets para cuando de verdad necesites un canal de ida y vuelta, como una conversación de voz en tiempo real.

Del servidor al navegador, con FastAPI

En el backend, streamear es devolver un generador en lugar de un objeto. Con FastAPI y el cliente de OpenAI, el patrón es directo:

from fastapi import FastAPI
from fastapi.responses import StreamingResponse
from openai import OpenAI

app = FastAPI()
client = OpenAI()

@app.post("/chat")
def chat(pregunta: str):
    def generar():
        # stream=True hace que el cliente itere token a token
        stream = client.chat.completions.create(
            model="gpt-4o-mini",
            messages=[{"role": "user", "content": pregunta}],
            stream=True,
        )
        for chunk in stream:
            delta = chunk.choices[0].delta.content
            if delta:
                # formato SSE: cada evento empieza por "data: " y acaba en \n\n
                yield f"data: {delta}\n\n"
        yield "data: [DONE]\n\n"

    return StreamingResponse(generar(), media_type="text/event-stream")

La clave es que generar() no retorna, sino que cede (yield) cada fragmento. FastAPI mantiene la conexión abierta y va enviando lo que el generador produce, sin esperar al final. El [DONE] al cierre es una convención para que el cliente sepa que el flujo ha terminado.

En el navegador, se consume leyendo el cuerpo de la respuesta como un stream:

const res = await fetch("/chat", {
  method: "POST",
  body: JSON.stringify({ pregunta }),
});

const reader = res.body.getReader();
const decoder = new TextDecoder();

while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  const texto = decoder.decode(value);
  // aquí llega cada fragmento: lo pintas en cuanto entra
  render(texto.replace("data: ", ""));
}

No hay magia: lees, pintas, repites. El efecto de “está escribiendo” sale solo del hecho de renderizar cada fragmento nada más recibirlo.

Lo que nadie te cuenta hasta que lo despliegas

El código de ejemplo funciona en tu portátil. En producción aparecen cuatro detalles que deciden si la experiencia es buena o frustrante.

Los proxies bufferizan. Nginx, muchos balanceadores y algunas capas de CDN acumulan la respuesta antes de reenviarla, y eso mata el streaming: el usuario vuelve a ver el párrafo de golpe. Hay que desactivar el buffering explícitamente (X-Accel-Buffering: no en Nginx) y comprobarlo en el entorno real, no en local.

El usuario se va a mitad. Si cierra la pestaña, tu backend debería dejar de consumir tokens: cada uno que sigues pidiendo al modelo lo estás pagando para nadie. Escucha la desconexión del cliente y corta la generación.

Los errores llegan tarde. Con una respuesta normal, un fallo es un código de estado limpio. En streaming, la conexión ya está abierta y devolviendo 200 cuando el modelo peta a mitad. Necesitas un canal dentro del propio flujo para señalar el error y una UI que sepa recuperarse sin dejar media frase colgada.

Markdown a medias se ve roto. Si el modelo devuelve una tabla o un bloque de código, los fragmentos intermedios son sintaxis incompleta. Renderizar Markdown en crudo token a token produce parpadeos feos. La solución habitual es pintar texto plano mientras llega y aplicar el formato solo cuando el bloque se cierra.

Cuándo no merece la pena

El streaming brilla en respuestas largas que un humano va a leer según aparecen: chat, redacción, explicaciones. No aporta nada, y complica el código, cuando la respuesta es corta o cuando otro programa la va a consumir entera.

Si pides al modelo un JSON que tu backend va a parsear, streamearlo no sirve de nada: no puedes procesar medio JSON, así que esperas igual al final y encima añades complejidad. Lo mismo con clasificaciones de una palabra o respuestas de sí o no. Ahí, la llamada normal es más simple y más robusta.

En resumen

El streaming no hace que el modelo piense más rápido; hace que el usuario deje de mirar una pantalla vacía. Es de las mejoras con mejor relación esfuerzo-percepción que puedes meter en un producto con LLM: un generador en el backend, un lector en el frontend y cuidado con el buffering de los proxies. Optimiza el time to first token antes que la latencia total, streamea solo lo que un humano va a leer sobre la marcha, y prueba siempre en el entorno real, donde los proxies enseñan los dientes.