Back

Guía Completa: Streaming de Respuestas LLM en Aplicaciones Web

Si has usado ChatGPT o Claude, ya lo has visto: el texto aparece palabra por palabra, creando ese efecto de "máquina de escribir" tan satisfactorio. Pero cuando intentas implementar esto en tu propia app, descubres rápidamente que no es tan simple como llamar a una API y mostrar la respuesta.

El streaming de respuestas LLM es uno de los desafíos más comunes para desarrolladores que construyen aplicaciones de IA en 2024-2025. La diferencia entre una app lenta que muestra un spinner durante 10+ segundos y una interfaz responsiva que muestra contenido de inmediato puede determinar si los usuarios la adoptan o la abandonan.

En esta guía cubriremos todo sobre streaming de respuestas LLM, desde los protocolos base hasta implementaciones listas para producción. Ya sea que uses OpenAI, Anthropic, Ollama o cualquier otro proveedor, los patrones que aprenderás son universalmente aplicables.

¿Por qué importa el streaming?

Antes de entrar en la implementación, veamos por qué es tan crítico.

Los datos reales

Cuando un usuario envía un prompt a un LLM, el tiempo hasta el primer token (TTFT) y el tiempo total de generación varían bastante:

ModeloTTFT PromedioTiempo Total (500 tokens)
GPT-4 Turbo0.5-1.5s8-15s
Claude 3 Opus0.8-2.0s10-20s
GPT-3.5 Turbo0.2-0.5s3-6s
Llama 3 70B (local)0.1-0.3s15-45s

Sin streaming, tus usuarios ven un spinner durante todo ese tiempo. Con streaming, empiezan a ver contenido dentro del TTFT—una mejora de 10-20x en la percepción de velocidad.

Psicología del usuario

Los estudios de UX muestran que:

  • Los usuarios perciben las interfaces con streaming como 40% más rápidas, aunque el tiempo total sea idéntico
  • Ver el contenido aparecer gradualmente reduce la ansiedad de espera
  • El efecto de "escribiendo" da la sensación de que la IA está pensando, lo que aumenta la confianza

Arquitectura del pipeline de streaming

Veamos qué pasa cuando transmitimos una respuesta LLM:

┌─────────────┐    ┌─────────────┐    ┌─────────────┐    ┌─────────────┐
│   LLM API   │───▶│   Backend   │───▶│  Protocolo  │───▶│   Frontend  │
│  (OpenAI)   │    │   Server    │    │  Transporte │    │    (React)  │
└─────────────┘    └─────────────┘    └─────────────┘    └─────────────┘
     Tokens           Chunks              SSE/WS         Updates DOM

Cada etapa tiene sus propias consideraciones:

  1. LLM API: El proveedor envía tokens conforme se generan
  2. Backend Server: Transforma la respuesta de la API al formato adecuado
  3. Protocolo de Transporte: Cómo envías los datos al navegador (SSE, WebSockets, HTTP streaming)
  4. Frontend: Actualizar el DOM eficientemente sin causar lag

Parte 1: Server-Sent Events (SSE) — El estándar

SSE es el estándar de facto para streaming LLM. OpenAI, Anthropic y la mayoría de APIs LLM lo usan de forma nativa.

¿Qué es SSE?

SSE es un estándar web que permite a los servidores enviar datos a clientes web sobre HTTP. Comparado con WebSockets:

  • Unidireccional: Solo Servidor → Cliente
  • Basado en HTTP: Atraviesa proxies, CDNs y load balancers sin problema
  • Reconexión automática: Se reconecta solo si se pierde la conexión
  • Basado en texto: Cada mensaje es un evento de texto

Formato del protocolo SSE

Un stream SSE sigue este formato:

event: message
data: {"content": "Hola"}

event: message
data: {"content": " mundo"}

event: done
data: [DONE]

Reglas clave:

  • Cada campo en su propia línea: field: value
  • Los mensajes se separan con doble salto de línea (\n\n)
  • El campo data: contiene el payload (normalmente JSON)
  • Los campos event:, id: y retry: son opcionales

Implementación Backend con Node.js

Construyamos un endpoint SSE que hace proxy de la API de OpenAI:

// server.js - Express + OpenAI Streaming import express from 'express'; import OpenAI from 'openai'; import cors from 'cors'; const app = express(); app.use(cors()); app.use(express.json()); const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY, }); app.post('/api/chat/stream', async (req, res) => { const { messages, model = 'gpt-4-turbo-preview' } = req.body; // Configurar headers SSE res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); res.setHeader('X-Accel-Buffering', 'no'); // Desactivar buffering de nginx res.flushHeaders(); try { const stream = await openai.chat.completions.create({ model, messages, stream: true, stream_options: { include_usage: true }, }); let totalTokens = 0; for await (const chunk of stream) { const content = chunk.choices[0]?.delta?.content; const finishReason = chunk.choices[0]?.finish_reason; if (chunk.usage) { totalTokens = chunk.usage.total_tokens; } if (content) { res.write(`data: ${JSON.stringify({ type: 'content', content })}\n\n`); } if (finishReason) { res.write(`data: ${JSON.stringify({ type: 'done', finishReason, usage: { totalTokens } })}\n\n`); } } } catch (error) { console.error('Error de stream:', error); res.write(`data: ${JSON.stringify({ type: 'error', message: error.message })}\n\n`); } finally { res.end(); } }); app.listen(3001, () => { console.log('Servidor en http://localhost:3001'); });

Errores comunes que debes evitar

Hay varias trampas que pueden romper tu implementación:

1. Buffering de proxies

Nginx, Cloudflare y muchos reverse proxies tienen buffering por defecto. Esto mata el streaming. Añade estos headers:

res.setHeader('X-Accel-Buffering', 'no'); // Nginx res.setHeader('Cache-Control', 'no-cache, no-transform'); // CDNs

En Cloudflare, puede que necesites desactivar "Auto Minify" y activar "Chunked Transfer Encoding" desde el dashboard.

2. Timeouts de conexión

Los streams largos pueden alcanzar el límite de timeout. Implementa heartbeats:

// Enviar heartbeat cada 15 segundos const heartbeat = setInterval(() => { res.write(': heartbeat\n\n'); // Comentario SSE, el cliente lo ignora }, 15000); req.on('close', () => { clearInterval(heartbeat); });

3. Manejo de backpressure

Si el cliente no puede consumir los datos tan rápido como llegan:

for await (const chunk of stream) { const content = chunk.choices[0]?.delta?.content; if (content) { const data = `data: ${JSON.stringify({ type: 'content', content })}\n\n`; const canContinue = res.write(data); if (!canContinue) { // Esperar a que el buffer se vacíe await new Promise(resolve => res.once('drain', resolve)); } } }

Parte 2: Implementación Frontend con React

Ahora construyamos el frontend que consume nuestro stream SSE.

Hook de Streaming

// hooks/useStreamingChat.ts import { useState, useCallback, useRef } from 'react'; interface Message { role: 'user' | 'assistant'; content: string; } interface StreamState { isStreaming: boolean; error: string | null; usage: { totalTokens: number } | null; } export function useStreamingChat() { const [messages, setMessages] = useState<Message[]>([]); const [streamState, setStreamState] = useState<StreamState>({ isStreaming: false, error: null, usage: null, }); const abortControllerRef = useRef<AbortController | null>(null); const sendMessage = useCallback(async (userMessage: string) => { abortControllerRef.current?.abort(); abortControllerRef.current = new AbortController(); const newMessages: Message[] = [ ...messages, { role: 'user', content: userMessage }, ]; setMessages([...newMessages, { role: 'assistant', content: '' }]); setStreamState({ isStreaming: true, error: null, usage: null }); try { const response = await fetch('/api/chat/stream', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ messages: newMessages }), signal: abortControllerRef.current.signal, }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const reader = response.body?.getReader(); if (!reader) throw new Error('No response body'); const decoder = new TextDecoder(); let buffer = ''; let assistantContent = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n\n'); buffer = lines.pop() || ''; for (const line of lines) { if (!line.startsWith('data: ')) continue; const jsonStr = line.slice(6); if (jsonStr === '[DONE]') continue; try { const data = JSON.parse(jsonStr); if (data.type === 'content') { assistantContent += data.content; setMessages(prev => { const updated = [...prev]; updated[updated.length - 1] = { role: 'assistant', content: assistantContent, }; return updated; }); } else if (data.type === 'done') { setStreamState(prev => ({ ...prev, usage: data.usage, })); } else if (data.type === 'error') { throw new Error(data.message); } } catch (parseError) { console.warn('Error parsing SSE:', line); } } } } catch (error) { if ((error as Error).name === 'AbortError') { return; } setStreamState(prev => ({ ...prev, error: (error as Error).message, })); setMessages(prev => prev.slice(0, -1)); } finally { setStreamState(prev => ({ ...prev, isStreaming: false })); } }, [messages]); const cancelStream = useCallback(() => { abortControllerRef.current?.abort(); setStreamState(prev => ({ ...prev, isStreaming: false })); }, []); const clearMessages = useCallback(() => { setMessages([]); setStreamState({ isStreaming: false, error: null, usage: null }); }, []); return { messages, streamState, sendMessage, cancelStream, clearMessages, }; }

Renderizado eficiente

Durante el streaming, el contenido se actualiza 10-50 veces por segundo. Hay que optimizar:

// components/MessageContent.tsx import { memo, useMemo } from 'react'; import { marked } from 'marked'; import DOMPurify from 'dompurify'; interface MessageContentProps { content: string; isStreaming: boolean; } export const MessageContent = memo(function MessageContent({ content, isStreaming, }: MessageContentProps) { const renderedContent = useMemo(() => { if (isStreaming) { // Durante streaming, no parseamos markdown (es costoso) return content.split('\n').map((line, i) => ( <span key={i}> {line} {i < content.split('\n').length - 1 && <br />} </span> )); } // Cuando termina, renderizamos markdown completo const html = marked(content, { breaks: true, gfm: true }); const sanitized = DOMPurify.sanitize(html); return <div dangerouslySetInnerHTML={{ __html: sanitized }} />; }, [content, isStreaming]); return ( <div className="message-content"> {renderedContent} {isStreaming && <span className="cursor-blink"></span>} </div> ); });

Componente de Chat completo

// components/StreamingChat.tsx import { useState, useRef, useEffect, FormEvent } from 'react'; import { useStreamingChat } from '../hooks/useStreamingChat'; import { MessageContent } from './MessageContent'; export function StreamingChat() { const [input, setInput] = useState(''); const messagesEndRef = useRef<HTMLDivElement>(null); const inputRef = useRef<HTMLTextAreaElement>(null); const { messages, streamState, sendMessage, cancelStream, clearMessages, } = useStreamingChat(); useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages]); useEffect(() => { if (inputRef.current) { inputRef.current.style.height = 'auto'; inputRef.current.style.height = `${inputRef.current.scrollHeight}px`; } }, [input]); const handleSubmit = async (e: FormEvent) => { e.preventDefault(); if (!input.trim() || streamState.isStreaming) return; const message = input.trim(); setInput(''); await sendMessage(message); }; const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSubmit(e); } }; return ( <div className="chat-container"> <div className="messages-area"> {messages.length === 0 ? ( <div className="empty-state"> <h2>Inicia una conversación</h2> <p>Envía un mensaje para empezar</p> </div> ) : ( messages.map((message, index) => ( <div key={index} className={`message message-${message.role}`} > <div className="message-avatar"> {message.role === 'user' ? '👤' : '🤖'} </div> <MessageContent content={message.content} isStreaming={ streamState.isStreaming && index === messages.length - 1 && message.role === 'assistant' } /> </div> )) )} <div ref={messagesEndRef} /> </div> {streamState.error && ( <div className="error-banner"> <span>⚠️ {streamState.error}</span> <button onClick={clearMessages}>Cerrar</button> </div> )} {streamState.usage && ( <div className="usage-info"> Tokens usados: {streamState.usage.totalTokens} </div> )} <form onSubmit={handleSubmit} className="input-area"> <textarea ref={inputRef} value={input} onChange={(e) => setInput(e.target.value)} onKeyDown={handleKeyDown} placeholder="Escribe un mensaje... (Shift+Enter para nueva línea)" disabled={streamState.isStreaming} rows={1} /> {streamState.isStreaming ? ( <button type="button" onClick={cancelStream} className="cancel-btn"> ⏹ Detener </button> ) : ( <button type="submit" disabled={!input.trim()}> Enviar → </button> )} </form> </div> ); }

Parte 3: Alternativas a SSE

SSE cubre el 90% de los casos, pero a veces necesitas algo diferente.

Cuándo usar WebSocket

Usa WebSocket si necesitas:

  • Cancelación en tiempo real: Parar la generación a mitad del stream
  • Múltiples streams: Varias conversaciones en una conexión
  • Comunicación bidireccional: El servidor envía mensajes por iniciativa propia
class StreamingWebSocket { private ws: WebSocket; private messageHandlers = new Map<string, (data: any) => void>(); constructor(url: string) { this.ws = new WebSocket(url); this.ws.onmessage = (event) => { const data = JSON.parse(event.data); const handler = this.messageHandlers.get(data.streamId); if (handler) handler(data); }; } async streamChat( messages: Message[], onChunk: (content: string) => void ): Promise<{ streamId: string; cancel: () => void }> { const streamId = crypto.randomUUID(); this.messageHandlers.set(streamId, (data) => { if (data.type === 'content') { onChunk(data.content); } }); this.ws.send(JSON.stringify({ type: 'start_stream', streamId, messages, })); return { streamId, cancel: () => { this.ws.send(JSON.stringify({ type: 'cancel', streamId })); this.messageHandlers.delete(streamId); }, }; } }

Vercel AI SDK

Para producción rápida, el Vercel AI SDK simplifica todo:

import { useChat } from 'ai/react'; export function Chat() { const { messages, input, handleInputChange, handleSubmit, isLoading } = useChat({ api: '/api/chat', }); return ( <div> {messages.map(m => ( <div key={m.id}>{m.role}: {m.content}</div> ))} <form onSubmit={handleSubmit}> <input value={input} onChange={handleInputChange} /> <button type="submit" disabled={isLoading}>Enviar</button> </form> </div> ); }

Parte 4: Consideraciones para producción

Reintentos con backoff exponencial

async function streamWithRetry( messages: Message[], maxRetries = 3 ): AsyncGenerator<string> { let attempt = 0; while (attempt < maxRetries) { try { yield* streamFromAPI(messages); return; } catch (error) { attempt++; if (attempt >= maxRetries) throw error; const delay = Math.pow(2, attempt - 1) * 1000; await new Promise(resolve => setTimeout(resolve, delay)); } } }

Rate limiting

class RateLimiter { private tokens: number; private lastRefill: number; constructor( private maxTokens: number, private refillRate: number ) { this.tokens = maxTokens; this.lastRefill = Date.now(); } async acquire(): Promise<boolean> { this.refill(); if (this.tokens >= 1) { this.tokens -= 1; return true; } return false; } private refill() { const now = Date.now(); const elapsed = (now - this.lastRefill) / 1000; this.tokens = Math.min( this.maxTokens, this.tokens + elapsed * this.refillRate ); this.lastRefill = now; } }

Métricas clave

Monitorea estas métricas:

  1. TTFT: Tiempo hasta el primer token
  2. TPS: Tokens por segundo
  3. Completion rate: Porcentaje de streams que terminan sin error
  4. Connection duration: Cuánto tiempo están abiertos los streams
import { Counter, Histogram } from 'prom-client'; const streamDuration = new Histogram({ name: 'llm_stream_duration_seconds', help: 'Duración de requests de streaming LLM', buckets: [0.5, 1, 2, 5, 10, 30, 60], }); const tokensGenerated = new Counter({ name: 'llm_tokens_generated_total', help: 'Tokens generados en total', }); const streamErrors = new Counter({ name: 'llm_stream_errors_total', help: 'Errores de streaming', labelNames: ['error_type'], });

Parte 5: Casos extremos

Respuestas muy largas

import { FixedSizeList as List } from 'react-window'; function VirtualizedMessage({ content }: { content: string }) { const lines = content.split('\n'); return ( <List height={400} itemCount={lines.length} itemSize={24} width="100%" > {({ index, style }) => ( <div style={style}>{lines[index]}</div> )} </List> ); }

Evitar parpadeo en bloques de código

function isCompleteCodeBlock(content: string): boolean { const openBlocks = (content.match(/```/g) || []).length; return openBlocks % 2 === 0; } function MessageContent({ content, isStreaming }: Props) { const shouldHighlight = !isStreaming || isCompleteCodeBlock(content); const processed = shouldHighlight ? highlightCode(content) : escapeHtml(content); return <div dangerouslySetInnerHTML={{ __html: processed }} />; }

Conexiones lentas

function getChunkingStrategy(): 'immediate' | 'batched' { if ('connection' in navigator) { const connection = (navigator as any).connection; if (connection.effectiveType === '2g' || connection.effectiveType === 'slow-2g') { return 'batched'; } } return 'immediate'; }

Conclusión

El streaming de respuestas LLM ya es una expectativa básica para usuarios acostumbrados a ChatGPT y Claude.

Puntos clave:

  1. SSE es la opción por defecto: Simple, basado en HTTP, funciona en todas partes
  2. Cuidado con backpressure y timeouts: Son problemas frecuentes en producción
  3. Optimiza el renderizado: El parsing de markdown y las actualizaciones de DOM son costosas
  4. Monitorea todo: TTFT, TPS y error rates son métricas críticas
  5. Planifica los edge cases: Respuestas largas, bloques de código y usuarios móviles

El código de esta guía está probado en producción. Úsalo como base y construye experiencias de IA increíbles. 🚀

llmstreamingssereactnodejsopenaiai-engineeringweb-development

Explora herramientas relacionadas

Prueba estas herramientas gratuitas de Pockit