Streaming de resposta: a infraestrutura que o token a token exige
Mostrar a resposta sendo escrita muda a percepção de velocidade mais que qualquer otimização. E quebra proxy, balanceador e timeout que funcionavam perfeitamente antes.
Equipe EasyOps Cloud · · 7 min de leitura
Uma resposta de modelo de linguagem leva alguns segundos para ficar pronta. Entregue de uma vez, esses segundos são espera pura, e a percepção é de lentidão. Entregue token a token, a pessoa começa a ler quase imediatamente — e a mesma duração passa a parecer rápida.
É a otimização de percepção com melhor retorno em produto de IA. E é também a que mais quebra infraestrutura que funcionava sem problema, porque tudo entre o modelo e o navegador foi desenhado assumindo requisição e resposta.
O que muda tecnicamente
Numa requisição comum, o servidor monta a resposta inteira e envia. Com streaming, a conexão fica aberta e os pedaços saem conforme são gerados.
Isso contraria premissas em várias camadas: proxies acumulam resposta antes de repassar, balanceadores encerram conexão ociosa, e o cliente precisa saber processar um fluxo em vez de esperar um corpo completo.
O sintoma de não ajustar é característico e confunde: funciona em desenvolvimento e falha em produção. Localmente não há proxy no caminho; em produção, há.
SSE ou WebSocket
Duas opções, e a escolha é mais simples do que parece.
SSE — eventos enviados pelo servidor — é HTTP comum, unidirecional, com reconexão automática pelo navegador. É a escolha certa para o caso típico: a pessoa envia a pergunta e recebe a resposta em fluxo.
WebSocket é bidirecional e faz sentido quando o cliente precisa enviar durante a resposta — interromper a geração, mandar contexto adicional — ou quando há mais de um participante na mesma sessão.
Para a maioria das aplicações de IA, SSE basta e evita uma camada de complexidade. Ele atravessa proxies com mais facilidade e não exige tratamento especial de reconexão.
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
X-Accel-Buffering: noEsse último cabeçalho é o atalho mais útil deste artigo: ele instrui o nginx a
não acumular a resposta, sem precisar mexer na configuração do servidor. Vale
enviá-lo sempre, mesmo com o proxy já configurado — funciona como rede de
segurança.
O proxy que engole o fluxo
O comportamento padrão do nginx é acumular a resposta do backend antes de
repassar ao cliente. É bom para conexão lenta e fatal para streaming: os tokens
ficam presos no buffer e chegam todos juntos no fim, anulando o benefício.
location /api/chat {
proxy_pass http://app;
proxy_http_version 1.1;
proxy_buffering off;
proxy_cache off;
chunked_transfer_encoding on;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_set_header Connection '';
}Cada linha resolve um problema específico. O proxy_buffering off é o principal.
O proxy_cache off evita que uma resposta em fluxo seja armazenada e servida
truncada depois. O chunked_transfer_encoding permite enviar sem saber o tamanho
total. E o Connection '' vazio impede que o cabeçalho seja repassado e encerre a
conexão.
Aplique isso apenas no location do streaming. Desligar buffering em todo o site
faz o servidor pagar o custo de cada cliente lento, como discutido no artigo
sobre erros de nginx como reverse proxy.
Os timeouts longos são necessários e merecem cuidado: eles valem para aquela rota, e uma rota com timeout de 300 segundos pode acumular conexões presas se o backend travar. Vale combinar com limite de conexões simultâneas.
Timeouts em cascata
Este é o ponto que mais consome tempo de diagnóstico. Uma conexão de streaming atravessa várias camadas, e a mais restritiva decide:
| Camada | Onde ajustar |
|---|---|
| Balanceador do provedor | Painel, timeout de idle |
| nginx | proxy_read_timeout |
| Servidor da aplicação | timeout do framework |
| Cliente HTTP para a API do modelo | timeout da biblioteca |
O sintoma de errar é a conexão cair sempre no mesmo tempo — 30, 60 ou 120 segundos — independentemente do conteúdo. Esse número é a pista: ele identifica qual camada desistiu.
Uma técnica que resolve boa parte dos casos sem aumentar timeout em lugar nenhum é enviar batimentos periódicos:
: keep-alive
data: {"tipo":"token","conteudo":"Olá"}
data: {"tipo":"fim"}A linha iniciada por dois-pontos é um comentário no protocolo SSE — o cliente ignora, e a conexão deixa de ser ociosa. Um comentário a cada quinze segundos mantém vivo qualquer intermediário que derrubaria por inatividade.
Do lado do cliente
const resp = await fetch('/api/chat', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({pergunta}),
signal: controlador.signal,
});
const leitor = resp.body.getReader();
const decodificador = new TextDecoder();
let buffer = '';
while (true) {
const {done, value} = await leitor.read();
if (done) break;
buffer += decodificador.decode(value, {stream: true});
const linhas = buffer.split('\n\n');
buffer = linhas.pop(); // pedaço incompleto fica
for (const linha of linhas) {
if (!linha.startsWith('data: ')) continue;
const evento = JSON.parse(linha.slice(6));
if (evento.tipo === 'token') mostrar(evento.conteudo);
}
}Dois detalhes que evitam bugs difíceis. O {stream: true} no decodificador impede
que um caractere multibyte partido entre dois pedaços vire lixo — em português,
com acentos, isso acontece o tempo todo. E guardar o pedaço incompleto no buffer
trata o caso de um evento chegar dividido, que é frequente e produz erro de JSON
intermitente.
O signal permite cancelar. Vale implementar: pessoa que vê a resposta indo pelo
caminho errado quer interromper, e cancelar também para de consumir tokens — o
que tem efeito direto no custo.
A métrica que importa
Para streaming, a métrica de latência muda. O tempo total importa menos que o tempo até o primeiro token, porque é ele que determina a percepção.
inicio = time.monotonic()
primeiro = None
for pedaco in stream:
if primeiro is None:
primeiro = time.monotonic() - inicio
...
total = time.monotonic() - inicio
registrar(ttft=primeiro, total=total, tokens=n)Um primeiro token em menos de um segundo produz sensação de resposta imediata, mesmo que a geração completa leve dez. Acima de três segundos, o benefício do streaming se perde — a pessoa já achou que travou.
Se o primeiro token demora, a causa raramente é o modelo. Costuma ser recuperação de contexto lenta, prompt grande demais, ou buffering em alguma camada. Vale medir separadamente o tempo da recuperação e o da chamada.
Erro no meio do fluxo
Um problema estrutural do streaming: quando algo falha depois do primeiro token,
o código de status HTTP já foi enviado como 200. Não há como devolver erro pelo
mecanismo normal.
A saída é ter um tipo de evento para isso:
data: {"tipo":"token","conteudo":"O prazo é"}
data: {"tipo":"erro","mensagem":"conexão com o modelo interrompida"}E o cliente precisa tratar: sinalizar visualmente que a resposta está incompleta, em vez de deixar o texto truncado parecendo terminado. Uma resposta cortada no meio que parece completa é pior que um erro explícito — especialmente quando ela informa um prazo ou um valor pela metade.
Vale sempre enviar um evento de fim explícito, para que o cliente distinga "terminou" de "a conexão caiu".
Um checklist
proxy_buffering offeX-Accel-Buffering: nonolocationdo streaming.- Timeouts alinhados em todas as camadas, incluindo o balanceador do provedor.
- Batimento periódico para conexão que pode ficar ociosa.
- Decodificação com `stream: true` e buffer para evento partido.
- Cancelamento implementado, que economiza custo e melhora a experiência.
- Evento de erro e de fim explícitos.
- Tempo até o primeiro token medido e acompanhado.
Sete itens que separam um streaming que funciona de um que funciona no notebook do desenvolvedor. O primeiro e o segundo respondem sozinhos pela maioria dos casos em que "o streaming não funciona em produção".
O que fazer com o texto que já saiu
Streaming cria uma situação que requisição comum não tem: parte da resposta já está na tela quando você descobre que ela é problemática.
Isso importa em dois cenários. O primeiro é a verificação de saída — se você valida o conteúdo depois de gerado, como no artigo sobre guardrails, essa validação acontece com o texto já visível.
O segundo é o erro de fundamentação: o modelo começa a responder e, no meio, cita um valor que não estava no material recuperado.
Há três abordagens, com trocas diferentes:
- Validar em janela, a cada bloco de tokens, interrompendo ao detectar problema. Responsivo e mais complexo de implementar.
- Segurar o primeiro trecho — alguns tokens de atraso — para validar antes de exibir. Custa latência percebida, e pouca.
- Aceitar o risco em fluxo de baixa criticidade, com validação apenas no fim e sinalização visual se algo for detectado.
Para a maioria das aplicações, a terceira é adequada. Para as que informam valor, prazo ou orientação com consequência, vale a segunda — o meio segundo a mais é um preço baixo por não exibir informação que será retirada.
O que não funciona é ignorar o problema. Texto retirado da tela depois de lido já foi lido, e a correção posterior raramente alcança quem viu.