Back

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:

ModeloTTFT MédioTempo 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

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:

  1. LLM API: O provider envia tokens conforme são gerados
  2. Backend Server: Transforma a resposta da API no formato adequado
  3. Protocolo de Transporte: Como você envia os dados pro navegador (SSE, WebSockets, HTTP streaming)
  4. 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: e retry: 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:

  1. TTFT: Tempo até o primeiro token
  2. TPS: Tokens por segundo
  3. Completion rate: Porcentagem de streams que terminam sem erro
  4. 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:

  1. SSE é a opção padrão: Simples, baseado em HTTP, funciona em qualquer lugar
  2. Cuidado com backpressure e timeouts: São problemas frequentes em produção
  3. Otimize a renderização: Parsing de markdown e atualizações de DOM são pesadas
  4. Monitore tudo: TTFT, TPS e error rates são métricas críticas
  5. 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. 🚀

llmstreamingssereactnodejsopenaiai-engineeringweb-development

Explore ferramentas relacionadas

Experimente estas ferramentas gratuitas do Pockit