Guia Completo: Streaming de Respostas LLM em Aplicações Web
Se você já usou o ChatGPT ou o Claude, já viu aquilo: o texto aparece palavra por palavra, criando aquele efeito de "máquina de escrever" satisfatório. Mas quando você tenta implementar isso na sua própria aplicação, descobre rápido que não é só chamar a API e mostrar a resposta.
O streaming de respostas LLM é um dos desafios mais comuns para desenvolvedores construindo aplicações de IA em 2024-2025. A diferença entre uma app lenta mostrando spinner por 10+ segundos e uma interface responsiva que mostra conteúdo imediatamente pode definir se os usuários adotam ou abandonam.
Neste guia vamos cobrir tudo sobre streaming de respostas LLM, desde os protocolos base até implementações prontas para produção. Seja usando OpenAI, Anthropic, Ollama ou qualquer outro provider, os padrões que você vai aprender são universalmente aplicáveis.
Por que streaming importa?
Antes de entrar na implementação, vamos ver por que isso é tão crítico.
Os números reais
Quando um usuário manda um prompt para um LLM, o tempo até o primeiro token (TTFT) e o tempo total de geração variam bastante:
| Modelo | TTFT Médio | Tempo 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 |
Sem streaming, seus usuários ficam olhando um spinner durante todo esse tempo. Com streaming, eles começam a ver conteúdo dentro do TTFT—uma melhoria de 10-20x na percepção de velocidade.
Psicologia do usuário
Estudos de UX mostram que:
- Usuários percebem interfaces com streaming como 40% mais rápidas, mesmo quando o tempo total é idêntico
- Ver o conteúdo aparecer gradualmente reduz a ansiedade de espera
- O efeito de "digitando" dá a sensação de que a IA está pensando, o que aumenta a confiança
Arquitetura do pipeline de streaming
Vamos ver o que acontece quando transmitimos uma resposta LLM:
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ LLM API │───▶│ Backend │───▶│ Protocolo │───▶│ Frontend │
│ (OpenAI) │ │ Server │ │ Transporte │ │ (React) │
└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘
Tokens Chunks SSE/WS Updates DOM
Cada etapa tem suas próprias considerações:
- LLM API: O provider envia tokens conforme são gerados
- Backend Server: Transforma a resposta da API no formato adequado
- Protocolo de Transporte: Como você envia os dados pro navegador (SSE, WebSockets, HTTP streaming)
- Frontend: Atualizar o DOM eficientemente sem causar lag
Parte 1: Server-Sent Events (SSE) — O padrão
SSE é o padrão de fato para streaming LLM. OpenAI, Anthropic e a maioria das APIs LLM usam nativamente.
O que é SSE?
SSE é um padrão web que permite servidores enviarem dados para clientes web sobre HTTP. Comparado com WebSockets:
- Unidirecional: Só Servidor → Cliente
- Baseado em HTTP: Atravessa proxies, CDNs e load balancers sem problema
- Reconexão automática: Se reconecta sozinho se perder a conexão
- Baseado em texto: Cada mensagem é um evento de texto
Formato do protocolo SSE
Um stream SSE segue este formato:
event: message
data: {"content": "Olá"}
event: message
data: {"content": " mundo"}
event: done
data: [DONE]
Regras principais:
- Cada campo na sua própria linha:
field: value - Mensagens separadas por quebra de linha dupla (
\n\n) - O campo
data:contém o payload (geralmente JSON) - Os campos
event:,id:eretry:são opcionais
Implementação Backend com Node.js
Vamos construir um endpoint SSE que faz proxy da API da 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'); // Desativar buffering do 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('Erro de stream:', error); res.write(`data: ${JSON.stringify({ type: 'error', message: error.message })}\n\n`); } finally { res.end(); } }); app.listen(3001, () => { console.log('Servidor rodando em http://localhost:3001'); });
Armadilhas comuns
Tem várias ciladas que podem quebrar sua implementação:
1. Buffering de proxies
Nginx, Cloudflare e muitos reverse proxies fazem buffering por padrão. Isso mata o streaming. Adicione estes headers:
res.setHeader('X-Accel-Buffering', 'no'); // Nginx res.setHeader('Cache-Control', 'no-cache, no-transform'); // CDNs
No Cloudflare, pode precisar desativar "Auto Minify" e ativar "Chunked Transfer Encoding" pelo dashboard.
2. Timeouts de conexão
Streams longos podem bater no limite de timeout. Implemente heartbeats:
// Enviar heartbeat a cada 15 segundos const heartbeat = setInterval(() => { res.write(': heartbeat\n\n'); // Comentário SSE, cliente ignora }, 15000); req.on('close', () => { clearInterval(heartbeat); });
3. Tratando backpressure
Se o cliente não consegue consumir os dados tão rápido quanto chegam:
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 o buffer esvaziar await new Promise(resolve => res.once('drain', resolve)); } } }
Parte 2: Implementação Frontend com React
Agora vamos construir o frontend que consome nosso 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('Sem corpo na resposta'); 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('Erro ao parsear 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, }; }
Renderização eficiente
Durante o streaming, o conteúdo atualiza 10-50 vezes por segundo. Precisa otimizar:
// 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, não parseamos markdown (é pesado) return content.split('\n').map((line, i) => ( <span key={i}> {line} {i < content.split('\n').length - 1 && <br />} </span> )); } // Quando 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>Inicie uma conversa</h2> <p>Mande uma mensagem para começar</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}>Fechar</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="Digite uma mensagem... (Shift+Enter para nova linha)" disabled={streamState.isStreaming} rows={1} /> {streamState.isStreaming ? ( <button type="button" onClick={cancelStream} className="cancel-btn"> ⏹ Parar </button> ) : ( <button type="submit" disabled={!input.trim()}> Enviar → </button> )} </form> </div> ); }
Parte 3: Alternativas ao SSE
SSE cobre 90% dos casos, mas às vezes você precisa de algo diferente.
Quando usar WebSocket
Use WebSocket se precisar de:
- Cancelamento em tempo real: Parar a geração no meio do stream
- Múltiplos streams: Várias conversas em uma conexão
- Comunicação bidirecional: Servidor envia mensagens por iniciativa própria
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 produção rápida, o Vercel AI SDK simplifica tudo:
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: Considerações para produção
Retries com 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 importantes
Monitore essas métricas:
- TTFT: Tempo até o primeiro token
- TPS: Tokens por segundo
- Completion rate: Porcentagem de streams que terminam sem erro
- Connection duration: Quanto tempo os streams ficam abertos
import { Counter, Histogram } from 'prom-client'; const streamDuration = new Histogram({ name: 'llm_stream_duration_seconds', help: 'Duração 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 gerados no total', }); const streamErrors = new Counter({ name: 'llm_stream_errors_total', help: 'Erros de streaming', labelNames: ['error_type'], });
Parte 5: Casos extremos
Respostas muito longas
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 piscar em blocos 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 }} />; }
Conexões 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'; }
Conclusão
O streaming de respostas LLM já é uma expectativa básica para usuários acostumados com ChatGPT e Claude.
Pontos-chave:
- SSE é a opção padrão: Simples, baseado em HTTP, funciona em qualquer lugar
- Cuidado com backpressure e timeouts: São problemas frequentes em produção
- Otimize a renderização: Parsing de markdown e atualizações de DOM são pesadas
- Monitore tudo: TTFT, TPS e error rates são métricas críticas
- Planeje os edge cases: Respostas longas, blocos de código e usuários mobile
O código deste guia está testado em produção. Use como base e construa experiências de IA incríveis. 🚀
Explore ferramentas relacionadas
Experimente estas ferramentas gratuitas do Pockit