Streaming de respostas com LLMs: SSE, chunked transfer e UX em tempo real
1. Fundamentos do Streaming com LLMs
1.1. Por que streaming é essencial para LLMs: latência percebida vs. latência real
Modelos de linguagem de grande escala (LLMs) como GPT-4, Claude ou Llama podem levar de 2 a 30 segundos para gerar uma resposta completa, dependendo do tamanho do prompt e da complexidade da tarefa. Em uma abordagem batch, o usuário enfrenta uma espera silenciosa até que todo o texto seja produzido — uma experiência frustrante que aumenta a taxa de abandono em até 40%.
O streaming resolve esse problema ao entregar tokens (palavras ou subpalavras) assim que são gerados, reduzindo o time-to-first-token (TTFT) para milissegundos. A latência percebida cai drasticamente porque o usuário vê o texto sendo "digitado" em tempo real, criando uma sensação de resposta imediata.
1.2. Diferença entre resposta completa (batch) e resposta incremental (streaming)
Na resposta batch, o servidor coleta todos os tokens, monta a string final e a envia em uma única resposta HTTP. No streaming, cada token é enviado individualmente ou em pequenos lotes:
// Resposta batch (JSON completo)
{
"response": "Olá! Como posso ajudar você hoje com suas dúvidas sobre programação?"
}
// Resposta streaming (eventos individuais)
data: "Olá"
data: "!"
data: " Como"
data: " posso"
data: " ajudar"
data: " você"
...
1.3. Protocolos envolvidos: HTTP, WebSockets e a escolha do SSE
Para streaming unidirecional (servidor → cliente), o Server-Sent Events (SSE) é a escolha natural. Ele opera sobre HTTP padrão, não requer bibliotecas especiais no cliente (basta EventSource no navegador) e lida automaticamente com reconexão. WebSockets são mais adequados para comunicação bidirecional, mas introduzem complexidade desnecessária quando o fluxo é apenas do servidor para o cliente.
2. Server-Sent Events (SSE): O Padrão para Streaming Unidirecional
2.1. Estrutura do protocolo SSE
O SSE utiliza o content-type text/event-stream e segue um formato simples:
Content-Type: text/event-stream
data: {"token": "Olá"}
data: {"token": "!"}
data: [DONE]
Cada mensagem começa com data: seguido do conteúdo. Opcionalmente, pode-se incluir id:, event: e retry: para controle de fluxo.
2.2. Implementação básica de um endpoint SSE para LLM (exemplo com Node.js/Express)
const express = require('express');
const app = express();
app.get('/stream', async (req, res) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
});
// Simula streaming de tokens de um LLM
const tokens = ["Olá", "!", " Como", " posso", " ajudar", "?"];
for (const token of tokens) {
res.write(`data: ${JSON.stringify({ token })}\n\n`);
await new Promise(resolve => setTimeout(resolve, 200)); // Simula latência
}
res.write('data: [DONE]\n\n');
res.end();
});
app.listen(3000);
2.3. Tratamento de reconexão e IDs de eventos para resiliência
Para garantir que o cliente não perca mensagens em caso de queda de rede:
let eventId = 0;
// No servidor
res.write(`id: ${eventId}\n`);
res.write(`data: ${token}\n\n`);
eventId++;
// No cliente (JavaScript)
const eventSource = new EventSource('/stream');
eventSource.lastEventId = localStorage.getItem('lastEventId') || '';
3. Chunked Transfer Encoding: Streaming em Baixo Nível
3.1. Como o HTTP chunked transfer funciona
O chunked transfer encoding permite que o servidor envie dados em pedaços sem conhecer o tamanho total da resposta. O cabeçalho Transfer-Encoding: chunked sinaliza isso ao cliente:
Transfer-Encoding: chunked
5\r\n
Olá!\r\n
A\r\n
Como vai?\r\n
0\r\n
\r\n
Cada chunk tem: tamanho em hexa, dados, CRLF. O chunk de tamanho 0 indica o fim.
3.2. Comparação prática: SSE vs. chunked para streaming de tokens de LLM
| Característica | SSE | Chunked Transfer |
|---|---|---|
| Abstração | Alta (formato padronizado) | Baixa (bytes crus) |
| Reconexão automática | Sim (via EventSource) | Não (implementação manual) |
| Compatibilidade | Navegadores modernos | Todo cliente HTTP |
| Controle de eventos | Sim (tipos de evento) | Não |
Para LLMs, SSE é quase sempre superior devido à reconexão automática e ao formato estruturado.
3.3. Casos de uso: quando usar chunked diretamente
Use chunked transfer quando:
- O cliente não é um navegador (CLI, IoT, servidores)
- Precisa de controle binário de baixo nível
- O middleware (proxy reverso) não suporta SSE corretamente
4. Arquitetura Backend para Streaming de LLMs
4.1. Integração com provedores de LLM e suporte a streaming
const { OpenAI } = require('openai');
app.post('/chat', async (req, res) => {
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache'
});
const stream = await openai.chat.completions.create({
model: 'gpt-4',
messages: [{ role: 'user', content: req.body.message }],
stream: true
});
for await (const chunk of stream) {
const token = chunk.choices[0]?.delta?.content || '';
if (token) {
res.write(`data: ${JSON.stringify({ token })}\n\n`);
}
}
res.write('data: [DONE]\n\n');
res.end();
});
4.2. Gerenciamento de backpressure e buffers
Quando o cliente é mais lento que o LLM, use buffers controlados:
const BUFFER_SIZE = 100; // tokens
let buffer = [];
for await (const chunk of stream) {
buffer.push(chunk.choices[0]?.delta?.content || '');
if (buffer.length >= BUFFER_SIZE) {
res.write(`data: ${JSON.stringify({ tokens: buffer })}\n\n`);
buffer = [];
// Aguarda o cliente processar (backpressure)
await new Promise(resolve => res.once('drain', resolve));
}
}
// Envia buffer restante
if (buffer.length > 0) {
res.write(`data: ${JSON.stringify({ tokens: buffer })}\n\n`);
}
4.3. Estratégias de timeout, cancelamento e controle de fluxo
// Timeout de 30 segundos
const timeout = setTimeout(() => {
res.write('data: {"error": "timeout"}\n\n');
res.end();
}, 30000);
// Cancelamento pelo cliente
req.on('close', () => {
clearTimeout(timeout);
stream.controller.abort(); // Cancela a requisição ao LLM
});
5. Experiência do Usuário (UX) em Tempo Real
5.1. Feedback visual: cursor piscante, animação de digitação
<div id="response-container">
<span id="response-text"></span>
<span id="cursor" class="blinking">|</span>
</div>
<style>
.blinking {
animation: blink 1s step-end infinite;
}
@keyframes blink {
50% { opacity: 0; }
}
</style>
<script>
const eventSource = new EventSource('/stream');
const responseText = document.getElementById('response-text');
eventSource.onmessage = (event) => {
if (event.data === '[DONE]') {
document.getElementById('cursor').style.display = 'none';
eventSource.close();
return;
}
const data = JSON.parse(event.data);
responseText.textContent += data.token;
};
</script>
5.2. Tratamento de interrupções: pausa, cancelamento e retomada
let isPaused = false;
let buffer = [];
document.getElementById('pause-btn').onclick = () => {
isPaused = !isPaused;
if (!isPaused) {
// Envia buffer acumulado
responseText.textContent += buffer.join('');
buffer = [];
}
};
eventSource.onmessage = (event) => {
if (isPaused) {
buffer.push(JSON.parse(event.data).token);
return;
}
// Processa normalmente
};
5.3. Acessibilidade: leitores de tela e conteúdo incremental
Para garantir que usuários de leitores de tela recebam o conteúdo incremental:
<div
id="response-container"
role="log"
aria-live="polite"
aria-atomic="false"
>
<span id="response-text"></span>
</div>
Use aria-live="polite" para que o leitor de tela anuncie mudanças sem interromper outras leituras.
6. Desafios Técnicos e Boas Práticas
6.1. Problemas comuns: buffer de proxy reverso, compressão e latência
Proxies como Nginx podem bufferizar respostas SSE. Configure:
# nginx.conf
proxy_buffering off;
proxy_cache off;
chunked_transfer_encoding on;
Evite compressão gzip em SSE, pois ela atrasa a entrega dos chunks. Use Content-Encoding: identity ou desative compressão para endpoints SSE.
6.2. Segurança: proteção contra injeção de eventos e validação
// Validação de tokens no servidor
function sanitizeToken(token) {
return token.replace(/[\n\r]/g, ''); // Remove quebras de linha
}
// Autenticação via token JWT
app.get('/stream', authenticate, (req, res) => {
// Verifica se o token é válido
if (!req.user.isPremium) {
res.status(403).end();
return;
}
// Inicia streaming
});
6.3. Monitoramento e métricas
let ttft = null;
let tokenCount = 0;
for await (const chunk of stream) {
if (!ttft) {
ttft = Date.now();
metrics.timeToFirstToken = ttft - startTime;
}
tokenCount++;
}
metrics.totalTokens = tokenCount;
metrics.throughput = tokenCount / ((Date.now() - startTime) / 1000);
7. Comparação com Alternativas e Tendências Futuras
7.1. WebSockets vs. SSE para streaming bidirecional em LLMs
WebSockets são necessários quando o cliente precisa enviar dados durante o streaming (ex.: comandos de interrupção, feedback). Para 90% dos casos de LLMs, SSE é suficiente. Use WebSockets apenas se precisar de comunicação bidirecional frequente.
7.2. HTTP/3 e QUIC: impacto no desempenho de streaming
HTTP/3 (baseado em QUIC) elimina o head-of-line blocking do TCP, reduzindo a latência em redes instáveis. Para streaming de LLMs, isso pode melhorar o TTFT em até 30% em conexões móveis.
7.3. O futuro: streaming nativo em frameworks
React Server Components e HTMX estão incorporando streaming como parte nativa do framework. O HTMX, por exemplo, permite:
<div hx-get="/stream" hx-trigger="load" hx-swap="innerHTML">
Carregando...
</div>
Isso simplifica drasticamente a implementação de streaming em aplicações web modernas.
Referências
- MDN Web Docs: Server-Sent Events — Documentação completa do protocolo SSE, incluindo eventos, reconexão e exemplos práticos
- OpenAI API: Streaming completions — Guia oficial da OpenAI para streaming de respostas com modelos GPT
- HTTP Chunked Transfer Encoding (RFC 7230) — Especificação técnica do HTTP/1.1 chunked transfer encoding
- Nginx: Proxy buffering configuration for SSE — Documentação oficial sobre configuração de buffering para streaming em proxies Nginx
- Web Performance: Time to First Token (TTFT) measurement — Artigo do Google sobre métricas de performance para streaming e primeira resposta
- HTMX: Server-Sent Events integration — Documentação oficial do HTMX sobre integração nativa com SSE para streaming
- React Server Components: Streaming and Suspense — Documentação do React sobre streaming nativo em Server Components