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:
| Modelo | TTFT Promedio | Tiempo Total (500 tokens) |
|---|---|---|
| GPT-4 Turbo | 0.5-1.5s | 8-15s |
| Claude 3 Opus | 0.8-2.0s | 10-20s |
| GPT-3.5 Turbo | 0.2-0.5s | 3-6s |
| Llama 3 70B (local) | 0.1-0.3s | 15-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:
- LLM API: El proveedor envía tokens conforme se generan
- Backend Server: Transforma la respuesta de la API al formato adecuado
- Protocolo de Transporte: Cómo envías los datos al navegador (SSE, WebSockets, HTTP streaming)
- 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:yretry: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:
- TTFT: Tiempo hasta el primer token
- TPS: Tokens por segundo
- Completion rate: Porcentaje de streams que terminan sin error
- 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:
- SSE es la opción por defecto: Simple, basado en HTTP, funciona en todas partes
- Cuidado con backpressure y timeouts: Son problemas frecuentes en producción
- Optimiza el renderizado: El parsing de markdown y las actualizaciones de DOM son costosas
- Monitorea todo: TTFT, TPS y error rates son métricas críticas
- 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. 🚀
Explora herramientas relacionadas
Prueba estas herramientas gratuitas de Pockit