← Voltar para o Blog

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: no

Esse ú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:

CamadaOnde ajustar
Balanceador do provedorPainel, timeout de idle
nginxproxy_read_timeout
Servidor da aplicaçãotimeout do framework
Cliente HTTP para a API do modelotimeout 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 off e X-Accel-Buffering: no no location do 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.

Suba um servidor em minutos

Preço em real, suporte em português e dados no Brasil.

Criar conta