Blog

Streaming de resposta de LLM sem quebrar a UX

Uma resposta de LLM que demora oito segundos para aparecer inteira parece um sistema travado; a mesma resposta, começando a surgir em trezentos milissegundos e escorrendo token a token, parece rápida mesmo levando os mesmos oito segundos até o fim. A diferença não está no modelo, está na entrega. Streaming é a técnica de mostrar a resposta enquanto ela é gerada, em vez de esperar o texto completo, e é o que separa uma interface de IA que parece viva de uma que parece congelada. Mas streaming feito de forma ingênua troca um problema por outro: a tela pisca a cada token, o scroll salta, o botão de parar não para nada, e quando a conexão cai no meio o usuário fica com meia resposta e nenhuma forma de continuar. O buffer errado engole a fluidez; o cancelamento ausente vaza custo depois que o usuário já foi embora; a reconexão mal feita duplica texto. Este artigo mostra como transmitir uma resposta de LLM do modelo até a tela sem quebrar a experiência: o protocolo de transporte, o servidor que repassa tokens com heartbeat e cancelamento, o cliente que monta o texto de forma incremental e as regras de UX que fazem o streaming parecer suave em vez de nervoso.

2026-07-14 / IA Aplicada / 12 min

01

Por que streaming muda a percepção de velocidade

O tempo que importa numa interface de IA não é quanto a resposta leva para terminar, é quanto leva para começar. Um modelo que gera a resposta completa em oito segundos e a devolve de uma vez faz o usuário encarar uma tela parada por oito segundos, sem sinal de que algo está acontecendo; a intuição dele é que o sistema travou. O mesmo modelo, transmitindo os tokens conforme os gera, entrega a primeira palavra em algumas centenas de milissegundos e mantém o texto escorrendo até o fim. O tempo total é idêntico, mas a percepção é oposta: o segundo parece rápido porque a espera é preenchida com progresso visível.

A métrica que captura essa diferença é o tempo até o primeiro token (TTFT), e ela é o que o streaming otimiza. Enquanto a latência total mede o fim da geração, o TTFT mede o início da resposta visível, e é o TTFT que governa a sensação de responsividade. A tabela abaixo separa as duas experiências para deixar claro o que muda.

AspectoSem streaming (espera o texto todo)Com streaming (token a token)
Primeiro sinal na telaSó ao terminar tudo (segundos)Primeiro token em centenas de ms
Percepção do usuárioParece travado, tentado a recarregarParece vivo, acompanha a geração
Abandono na esperaAlto: silêncio longo sem feedbackBaixo: progresso visível segura o usuário
Cancelar no meioImpossível: resposta é atômicaNatural: pode parar a qualquer token

O ganho do streaming não é só estético. Poder cancelar no meio significa parar de pagar tokens de uma resposta que o usuário já viu que não serve; ver o texto surgir dá ao usuário a chance de reformular a pergunta antes do fim; e o feedback contínuo reduz o abandono na espera, que é onde se perde usuário numa interface de IA. Streaming é, ao mesmo tempo, uma otimização de percepção, de custo e de retenção, e o resto do artigo é sobre entregá-lo sem introduzir os defeitos que o tornam pior do que a espera silenciosa.

02

O transporte: SSE, WebSocket ou fetch com stream

Antes do primeiro token chegar à tela, é preciso escolher como o servidor empurra os pedaços para o cliente. HTTP tradicional é uma requisição e uma resposta fechada, o que não serve para um fluxo aberto que pinga tokens ao longo do tempo. Três transportes resolvem isso, e a escolha certa depende do formato da conversa. Server-Sent Events (SSE) é um canal unidirecional do servidor para o cliente sobre HTTP comum, feito exatamente para o caso de empurrar uma sequência de eventos, e é o mais simples e o mais adequado para streaming de uma resposta de LLM. WebSocket abre um canal bidirecional persistente, e só vale a pena quando o cliente também precisa mandar dados no meio do fluxo (uma conversa de voz ao vivo, por exemplo). O fetch com ReadableStream lê o corpo da resposta em pedaços no próprio navegador, sem protocolo de eventos, e serve quando você controla as duas pontas e quer algo enxuto.

TransporteDireçãoQuando usarCusto de complexidade
SSEServidor para clienteStreaming de resposta de LLM, o caso padrãoBaixo: reconexão automática, HTTP comum
WebSocketBidirecionalCliente também emite durante o fluxo (voz)Alto: gerenciar estado da conexão
fetch + ReadableStreamServidor para clienteControle das duas pontas, sem eventos nomeadosMédio: parsing manual do stream

Para a esmagadora maioria dos casos de streaming de resposta de LLM, SSE é a escolha certa: o fluxo é do servidor para o cliente, o transporte é HTTP comum que passa por qualquer proxy, e o próprio protocolo já traz reconexão automática e um formato de evento simples de emitir. O restante deste artigo usa SSE porque ele resolve o problema real com o mínimo de peças móveis, e reserva WebSocket para quando a bidirecionalidade for de fato necessária, não por reflexo.

03

O servidor: repassar tokens com heartbeat e cancelamento

O papel do servidor no streaming é ser um cano fino e confiável: ele abre a chamada ao modelo em modo stream, recebe cada pedaço de texto e o repassa imediatamente ao cliente como um evento SSE, sem bufferizar a resposta inteira. Duas responsabilidades tornam esse cano robusto. A primeira é o heartbeat: se o modelo demora entre tokens, um comentário SSE periódico mantém a conexão viva e evita que proxies a derrubem por inatividade. A segunda, e mais importante para o custo, é o cancelamento: quando o cliente fecha a conexão (fechou a aba, cancelou a pergunta), o servidor precisa abortar a chamada ao modelo, senão continua pagando tokens de uma resposta que ninguém vai ler.

// server/stream.js
// Repassa os tokens do modelo ao cliente via SSE, com heartbeat
// e cancelamento quando o cliente desconecta. Nao bufferiza a resposta.

export async function streamHandler(req, res) {
  res.writeHead(200, {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache',
    Connection: 'keep-alive',
  });

  // AbortController liga a desconexao do cliente ao aborto da chamada ao modelo.
  const controller = new AbortController();
  req.on('close', () => controller.abort()); // cliente saiu: para de gerar.

  // Heartbeat: comentario SSE periodico mantem proxies de derrubarem a conexao.
  const heartbeat = setInterval(() => res.write(': keep-alive\n\n'), 15000);

  try {
    const stream = await model.stream({ messages: req.body.messages }, {
      signal: controller.signal,
    });

    for await (const chunk of stream) {
      const token = chunk.delta ?? '';
      if (token) res.write(`data: ${JSON.stringify({ token })}\n\n`);
    }
    res.write('event: done\ndata: {}\n\n'); // sinaliza fim limpo ao cliente.
  } catch (err) {
    if (err.name !== 'AbortError') {
      res.write(`event: error\ndata: ${JSON.stringify({ message: 'stream falhou' })}\n\n`);
    }
  } finally {
    clearInterval(heartbeat);
    res.end();
  }
}

O detalhe que separa esse servidor de uma versão ingênua é o par AbortController e req.on(close). Sem ele, quando o usuário fecha a aba no meio de uma resposta longa, a chamada ao modelo continua rodando até o fim no backend, gastando tokens que ninguém vai ver: o custo vaza silenciosamente e só aparece na fatura. Ligar a desconexão do cliente ao sinal de aborto do modelo transforma o fechar da aba num cancelamento real. O heartbeat resolve o problema oposto: quando o modelo pensa por vários segundos antes do próximo token, a conexão fica em silêncio, e proxies e balanceadores costumam derrubar conexões ociosas; um comentário SSE a cada poucos segundos mantém o cano aberto sem poluir o fluxo de dados.

04

O cliente: montar a resposta de forma incremental

Do lado do cliente, o trabalho é receber os tokens, acumulá-los numa string e atualizar a tela de forma que o texto pareça escorrer, não piscar. O erro mais comum aqui é substituir o conteúdo a cada token (o que causa flicker e re-render caro) em vez de anexar; o segundo erro é atualizar o DOM a cada token individual, quando dezenas chegam por segundo, saturando o render. A solução é acumular o texto num buffer e reconciliar a tela num ritmo controlado, além de tratar os três estados que o stream pode terminar: fim limpo, erro e cancelamento pelo usuário.

// client/useStream.js
// Consome o SSE, acumula os tokens e expoe o texto crescente.
// Um AbortController local permite ao usuario cancelar a geracao.

export function startStream({ url, body, onToken, onDone, onError }) {
  const controller = new AbortController();
  let text = ''; // acumula: anexa token, nunca substitui a resposta inteira.

  fetch(url, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
    signal: controller.signal, // cancelar aqui aborta a chamada e libera o servidor.
  })
    .then(async (res) => {
      const reader = res.body.getReader();
      const decoder = new TextDecoder();
      let buffer = '';

      while (true) {
        const { value, done } = await reader.read();
        if (done) break;
        buffer += decoder.decode(value, { stream: true });

        // SSE separa eventos por linha em branco; processa os completos.
        const events = buffer.split('\n\n');
        buffer = events.pop() ?? ''; // guarda o fragmento incompleto.

        for (const evt of events) {
          if (evt.startsWith(': ')) continue; // heartbeat: ignora.
          if (evt.includes('event: done')) { onDone(text); return; }
          if (evt.includes('event: error')) { onError(new Error('stream falhou')); return; }
          const line = evt.split('\n').find((l) => l.startsWith('data: '));
          if (line) {
            const { token } = JSON.parse(line.slice(6));
            text += token; // anexa.
            onToken(text); // entrega o texto crescente para a UI reconciliar.
          }
        }
      }
    })
    .catch((err) => {
      if (err.name !== 'AbortError') onError(err); // aborto do usuario nao e erro.
    });

  return () => controller.abort(); // devolve a funcao de cancelar (botao parar).
}

Repare que o cliente devolve uma função de cancelamento: é ela que o botão de parar chama, e ao abortar o fetch o cliente fecha a conexão, o que dispara o req.on(close) do servidor e aborta a chamada ao modelo. Assim o botão de parar não é decorativo: ele efetivamente interrompe a geração e para de gastar tokens, ponta a ponta. O outro detalhe é o buffer de eventos: SSE separa eventos por uma linha em branco, mas um pedaço lido pela rede pode cortar um evento no meio, então o cliente guarda o fragmento incompleto e só processa eventos completos. Sem esse cuidado, um token chega partido e o JSON.parse quebra.

05

As regras de UX: fazer o streaming parecer suave

Ter os tokens chegando não garante uma boa experiência; um streaming tecnicamente correto ainda pode parecer nervoso se a UI reage a cada token de forma crua. Quatro regras transformam um fluxo de tokens numa resposta que parece digitada com fluidez, e todas tratam da tensão entre atualizar rápido e atualizar suave.

  1. Anexe, nunca substitua: renderize o texto acumulado, adicionando os tokens novos ao fim, para que a resposta cresça sem piscar. Substituir o conteúdo inteiro a cada token causa flicker e re-render caro.
  2. Agrupe atualizações no ritmo do frame: dezenas de tokens chegam por segundo, mas a tela só desenha a sessenta quadros; acumule os tokens e reconcilie a UI uma vez por frame (via requestAnimationFrame) em vez de a cada token, para render suave sem custo.
  3. Segure o auto-scroll quando o usuário rolar para cima: rolar a tela para o fim a cada token é útil enquanto o usuário acompanha, mas vira sequestro de scroll se ele subiu para reler algo; detecte a rolagem manual e pause o auto-scroll até ele voltar ao fim.
  4. Mostre estado de parada e de erro com clareza: um cursor pulsando enquanto gera, o botão de parar sempre visível durante o fluxo, e uma mensagem de erro que preserva o texto já recebido em vez de apagá-lo, para o usuário nunca ficar com a tela em branco sem saber o que houve.

A regra que mais melhora a percepção é agrupar as atualizações no ritmo do frame. Um modelo rápido pode emitir dezenas de tokens por segundo, e atualizar o DOM a cada um satura o render e paradoxalmente deixa a interface mais travada do que o streaming deveria ser. Acumular os tokens num buffer e desenhar a tela uma vez por frame entrega toda a fluidez sem o custo, e é a diferença entre um texto que escorre suave e um que treme. O auto-scroll respeitoso é a segunda: sequestrar o scroll do usuário que subiu para reler é uma das frustrações mais citadas em interfaces de chat, e resolvê-la é só detectar a intenção dele e recuar.

06

Reconexão, erro e cancelamento: os três finais do stream

Um stream não termina de uma só forma, e a robustez está em tratar os três finais possíveis sem deixar o usuário perdido. O primeiro é o fim limpo: o modelo terminou, o servidor enviou o evento de done, o cliente marca a resposta como completa e esconde o cursor. O segundo é o erro no meio: a rede caiu, o modelo falhou, o servidor mandou um evento de erro; aqui a regra de ouro é preservar o texto já recebido e sinalizar que a resposta ficou incompleta, nunca apagar o que o usuário já leu. O terceiro é o cancelamento pelo usuário: ele clicou em parar, e o esperado é congelar o texto no ponto onde estava, sem tratar isso como erro.

Final do streamO que fazer com o textoO que mostrar
Fim limpo (done)Manter completoEsconder cursor, resposta pronta
Erro no meioPreservar o parcial, marcar incompletoAviso de falha com opção de repetir
Cancelado pelo usuárioCongelar no ponto atualTexto parado, sem alarme de erro
Conexão caiu (reconectável)Manter o parcial, tentar retomarIndicador de reconexão discreto

A diferença crítica entre esses finais é como o texto parcial é tratado. Um erro que apaga a resposta pela metade é pior que a espera silenciosa, porque destrói trabalho que o usuário estava lendo; o correto é sempre preservar o parcial e deixar claro que ele ficou incompleto, com opção de repetir. O cancelamento nunca deve virar um alarme de erro: o usuário pediu para parar, então parar é o comportamento certo, e mostrar um aviso de falha ali confunde. E a reconexão, quando o transporte a suporta (SSE reconecta sozinho), deve retomar de forma discreta sem duplicar o texto já recebido, o que exige que o servidor saiba de onde continuar ou que o cliente descarte o que já tem antes de retomar. Tratar os três finais é o que separa um streaming de demonstração de um que aguenta produção.

07

Montar o streaming em produção sem quebrar a UX

Streaming é uma das melhorias de maior impacto na experiência de uma interface de IA, porque ataca diretamente a percepção de velocidade sem tocar no modelo. Mas o ganho só é real se a entrega for suave e os finais forem tratados; um streaming nervoso ou que perde texto é pior que a espera limpa. A ordem de implantação abaixo entrega o valor cedo e blinda os pontos frágeis antes de escalar.

  1. Comece pelo transporte certo: SSE resolve o caso padrão de streaming de resposta de LLM com o mínimo de complexidade; só use WebSocket se o cliente precisar emitir durante o fluxo.
  2. Ligue cancelamento ponta a ponta desde o dia um: o AbortController no cliente e o req.on(close) no servidor garantem que fechar a aba ou clicar em parar de fato aborta a chamada ao modelo, cortando custo vazado.
  3. Acumule e anexe, nunca substitua: renderize o texto crescente adicionando tokens ao fim e reconcilie a UI uma vez por frame, para que a resposta escorra suave em vez de piscar.
  4. Trate os três finais explicitamente: fim limpo esconde o cursor, erro preserva o parcial e oferece repetir, cancelamento congela sem alarme. Nunca apague texto que o usuário já leu.
  5. Adicione heartbeat e monitore o TTFT: o heartbeat mantém a conexão viva em modelos lentos, e o tempo até o primeiro token é a métrica que diz se o streaming está entregando a responsividade que promete.

A diferença entre um streaming que faz a interface parecer viva e um que a faz parecer nervosa está inteira no cliente: no anexar em vez de substituir, no reconciliar por frame em vez de por token, no auto-scroll que respeita o usuário e no tratamento dos três finais. O servidor é a parte fácil, um cano que repassa tokens com heartbeat e cancelamento. O que faz o streaming valer a pena é a disciplina de UX na ponta, mais o cancelamento ponta a ponta que impede o custo de vazar. Bem montado, o streaming transforma a espera pela resposta de um silêncio ansioso num progresso visível, e essa é a diferença entre uma interface de IA que o usuário confia e uma que ele recarrega achando que travou.

FAQ

Perguntas frequentes

Por que usar SSE e não WebSocket para streaming de LLM?

Porque o fluxo de uma resposta de LLM é unidirecional: o servidor empurra tokens para o cliente, e o cliente não precisa emitir dados no meio da geração. SSE foi feito exatamente para isso, roda sobre HTTP comum que atravessa qualquer proxy, e já traz reconexão automática e um formato de evento simples. WebSocket abre um canal bidirecional persistente e só compensa quando o cliente também manda dados durante o fluxo, como numa conversa de voz ao vivo; para o caso padrão de streaming de texto, ele adiciona complexidade de gerenciar estado de conexão sem entregar nada que o SSE não resolva. A regra é usar o transporte mais simples que atende, e para streaming de resposta de LLM esse transporte é o SSE.

O que acontece com o custo quando o usuário fecha a aba no meio da resposta?

Sem cancelamento ponta a ponta, a chamada ao modelo continua rodando até o fim no backend mesmo que ninguém vá ler a resposta, e você paga por todos os tokens gerados após o usuário sair; esse custo vaza silenciosamente e só aparece na fatura. A correção é ligar a desconexão do cliente ao aborto da chamada ao modelo: no servidor, um AbortController que é acionado pelo evento de fechar da conexão (req.on close) interrompe a geração assim que o cliente some. No cliente, o mesmo AbortController alimenta o botão de parar, que ao abortar o fetch fecha a conexão e dispara o cancelamento no servidor. Assim, tanto fechar a aba quanto clicar em parar de fato interrompem a geração e param de gastar tokens.

Como evitar que o streaming deixe a interface nervosa e piscando?

Três cuidados no cliente resolvem. Primeiro, anexe os tokens ao texto acumulado em vez de substituir o conteúdo inteiro a cada token, o que elimina o flicker e o re-render caro. Segundo, agrupe as atualizações no ritmo do frame: um modelo rápido emite dezenas de tokens por segundo, mas a tela só desenha a sessenta quadros, então acumule os tokens num buffer e reconcilie a UI uma vez por frame (via requestAnimationFrame) em vez de a cada token; isso entrega toda a fluidez sem saturar o render. Terceiro, segure o auto-scroll quando o usuário rolar para cima para reler, retomando só quando ele voltar ao fim, para não sequestrar o scroll. Essas três regras transformam um fluxo cru de tokens numa resposta que parece digitada com fluidez.

Streaming bem feito transforma a espera pela resposta de LLM em progresso visível

Mostrar a resposta enquanto ela é gerada muda a percepção de velocidade sem tocar no modelo, mas só funciona com transporte certo, cancelamento ponta a ponta, anexação por frame e tratamento dos três finais. Posso desenhar essa camada de streaming no seu produto de IA, do servidor SSE às regras de UX, para uma interface que parece viva em vez de travada.