Como fazer streaming de respostas de IA (SSE)

Para fazer streaming de uma resposta de IA, chame o modelo com streaming ligado e repasse cada trecho de texto ao navegador como um server-sent event. O navegador mostra os trechos conforme chegam, sem esperar a resposta inteira.

Atualizado em 6 min de leitura

Objetivos de aprendizado

Depois de ler este artigo, você vai conseguir:

  • Repassar os tokens do modelo ao navegador como server-sent events
  • Ler o stream no cliente com fetch e mostrar o texto ao vivo
  • Corrigir problemas de buffering no proxy, cancelamento e reconexão
Nesta página

Um modelo de linguagem gera texto um token por vez, e uma resposta longa pode levar dez segundos ou mais para terminar. Se o seu backend espera a resposta inteira antes de responder, o usuário fica olhando um spinner esse tempo todo. Com streaming, as primeiras palavras aparecem numa fração de segundo e o resto chega conforme é gerado.

O jeito padrão de fazer isso sobre HTTP é com server-sent events (SSE). A seguir você monta um endpoint pequeno em Node.js que transmite a resposta do modelo e um cliente no navegador que mostra o texto ao vivo. Depois, vê os problemas que costumam quebrar o streaming em produção. Para entender o lado do modelo, veja o que é um modelo de linguagem (LLM).

NavegadorServidorLLMPOST + conversastreaming ligadotrechosflushOládata: "Olá"Olámundata: " mun"Olá mundodata: "do"Olá mundoevent: done
Cada trecho do modelo vira uma linha data: com flush, e o navegador mostra o texto enquanto ele chega.

O que são server-sent events?#

SSE é uma resposta HTTP comum que fica aberta até o servidor terminar. O servidor envia Content-Type: text/event-stream e vai escrevendo mensagens de texto. Cada mensagem é uma ou mais linhas campo: valor, seguidas de uma linha em branco:

text
data: Hello

event: done
data: {}

Os campos que importam:

  • data: é o conteúdo. Linhas data: seguidas na mesma mensagem são unidas com uma quebra de linha.
  • event: dá nome ao evento. Sem ele, o navegador trata a mensagem como um evento message padrão.
  • id: define o ID do último evento, que o navegador devolve como Last-Event-ID ao reconectar.
  • retry: define o intervalo de reconexão em milissegundos.
  • Uma linha que começa com : é um comentário, útil como keep-alive.

Por ser HTTP comum, o SSE funciona com as ferramentas de sempre para autenticação, logs e TLS. A maioria dos provedores de LLM usa SSE nas próprias APIs de streaming, por isso os SDKs já entregam um stream pronto para você repassar.

SSE ou WebSockets para respostas de IA?#

Para a resposta de um chat, SSE costuma bastar. Os dados vão num sentido só, do servidor para o navegador, e a próxima mensagem do usuário é só mais uma requisição HTTP. WebSockets abrem um canal bidirecional, mas aí o upgrade de conexão, os heartbeats e a lógica de reconexão ficam por sua conta. Escolha WebSockets quando o cliente precisa enviar dados o tempo todo na mesma conexão, como entrada de voz. Leia o que é WebSocket e como funciona para esse caso.

Como transmitir os tokens a partir do servidor?#

Instale o Express e o SDK da Anthropic (o mesmo padrão vale para qualquer provedor com streaming):

bash
npm install express @anthropic-ai/sdk

Crie o server.mjs. A chave da API e o modelo vêm de variáveis de ambiente, nunca do código:

js
import express from "express";
import Anthropic from "@anthropic-ai/sdk";

const client = new Anthropic(); // lê ANTHROPIC_API_KEY
const app = express();
app.use(express.json());

app.post("/api/chat", async (req, res) => {
  res.writeHead(200, {
    "Content-Type": "text/event-stream",
    "Cache-Control": "no-cache, no-transform",
    Connection: "keep-alive",
    "X-Accel-Buffering": "no", // pede ao nginx para não fazer buffer desta resposta
  });

  const send = (event, data) =>
    res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);

  const stream = client.messages.stream({
    model: process.env.ANTHROPIC_MODEL, // um id de modelo da documentação do provedor
    max_tokens: 1024,
    messages: req.body.messages,
  });

  // Pare de pagar por tokens que ninguém vai ler.
  res.on("close", () => {
    if (!res.writableEnded) stream.abort();
  });

  stream.on("text", (text) => send("token", text));

  try {
    await stream.finalMessage();
    send("done", {});
  } catch (err) {
    if (!res.destroyed) send("error", { message: "Falha na geração" });
  }
  res.end();
});

app.listen(process.env.PORT || 3000, "0.0.0.0");

Rode com ANTHROPIC_API_KEY=... ANTHROPIC_MODEL=... node server.mjs. Alguns detalhes passam batido:

  • JSON.stringify em cada trecho impede que quebras de linha dentro do texto estraguem o formato da linha data:.
  • no-transform impede que intermediários comprimam ou reescrevam o stream.
  • O handler de close aborta a requisição ao provedor quando o usuário fecha a aba ou aperta parar. Sem ele, você continua gerando (e pagando) uma resposta que ninguém vê.
  • Num app de verdade, valide e limite req.body.messages e autentique a requisição antes de chamar o modelo.

Como ler o stream no navegador?#

O EventSource nativo só envia requisições GET, sem corpo e sem cabeçalhos personalizados, então não consegue levar a conversa nem um token de autenticação. Use fetch e leia o corpo como stream. O parser abaixo separa as mensagens nas linhas em branco, que é onde cada mensagem SSE termina:

js
async function streamChat(messages, onToken, signal) {
  const res = await fetch("/api/chat", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ messages }),
    signal,
  });
  if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`);

  const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
  let buffer = "";

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

    let end;
    while ((end = buffer.indexOf("\n\n")) !== -1) {
      const raw = buffer.slice(0, end);
      buffer = buffer.slice(end + 2);

      let event = "message";
      let data = "";
      for (const line of raw.split("\n")) {
        if (line.startsWith("event:")) event = line.slice(6).trim();
        else if (line.startsWith("data:")) data += line.slice(5).trimStart();
      }
      if (event === "token") onToken(JSON.parse(data));
      if (event === "error") throw new Error(JSON.parse(data).message);
      if (event === "done") return;
    }
  }
}

Use a função com um AbortController. Assim, um botão Parar cancela a requisição e, pelo handler de close do servidor, também a chamada ao modelo:

js
const controller = new AbortController();
const out = document.querySelector("#answer");

streamChat(
  [{ role: "user", content: "Explique SSE em duas frases." }],
  (token) => (out.textContent += token),
  controller.signal,
);

document.querySelector("#stop").onclick = () => controller.abort();

Usar textContent em vez de innerHTML faz diferença: a saída do modelo é texto não confiável. Se você renderiza Markdown, sanitize o HTML antes.

Como verificar se o streaming funciona de verdade?#

Teste o endpoint sem navegador, com curl -N (a flag -N desliga o buffer do próprio curl):

bash
curl -N -X POST http://localhost:3000/api/chat \
  -H "Content-Type: application/json" \
  -d '{"messages":[{"role":"user","content":"Conte até dez."}]}'

Você deve ver as linhas event: token aparecendo uma depois da outra, e no fim event: done. Se tudo chega de uma vez no final, o seu código transmite certo, mas algo no caminho está fazendo buffer. Repita o teste na URL publicada: um stream que funciona local e não em produção quase sempre aponta para um proxy.

Por que o stream chega todo de uma vez?#

Estas são as causas usuais, da mais à menos comum:

  1. Buffering no proxy. O nginx faz buffer das respostas por padrão. Use proxy_buffering off; na rota, ou mantenha o cabeçalho X-Accel-Buffering: no do exemplo. Veja o que é um proxy reverso.
  2. Compressão. Um middleware de gzip pode segurar os trechos até ter o bastante para comprimir. Tire text/event-stream da compressão.
  3. Cabeçalhos ausentes. Sem Content-Type: text/event-stream, alguns clientes e proxies esperam o corpo inteiro.
  4. Timeout por inatividade. Um proxy pode fechar uma conexão que passa dezenas de segundos sem enviar nada. Mande uma linha de comentário como : ping seguida de linha em branco a cada 15 segundos enquanto o modelo pensa.
  5. Limite de conexões do navegador. Em HTTP/1.1, os navegadores permitem cerca de seis conexões SSE abertas por domínio, somando todas as abas. O HTTP/2 sobe esse número para o limite de streams negociado, 100 por padrão.

Se você usa EventSource num stream só de GET, ele reconecta sozinho depois de uma queda e envia o último id: que viu como Last-Event-ID. Uma chamada ao modelo não retoma no meio da geração, então, para chat, o caminho mais seguro é mostrar um erro e deixar o usuário tentar de novo.

Como fazer isso na Square Cloud#

Um endpoint de streaming segura cada conexão aberta por vários segundos, e é aí que muita hospedagem falha: um proxy com buffer entrega tudo no fim, e um processo que adormece perde a requisição. Na Square Cloud, o proxy na frente das aplicações deixa o SSE passar sem buffer e não corta conexões longas, e as aplicações rodam em containers sempre ligados, sem cold start nem modo de suspensão.

Para publicar o server.mjs no runtime de Node.js:

  1. Envie o projeto com o package.json. Não há etapa de build: no start, a plataforma roda npm install quando não existe node_modules.
  2. Escolha server.mjs como arquivo principal. O arquivo squarecloud.app é opcional: o painel o gera no deploy, ou você o escreve na raiz do projeto.
  3. Cadastre ANTHROPIC_API_KEY e ANTHROPIC_MODEL nas variáveis de ambiente da aplicação, pelo painel, API, CLI ou VS Code. A mudança vale no próximo restart, sem rebuild.

A aplicação recebe um subdomínio *.squareweb.app com HTTPS e escuta em 0.0.0.0, na porta da variável PORT, como o exemplo já faz. Uma aplicação web usa no mínimo 512 MB do pool de RAM do plano; o Hobby custa a partir de R$ 12,49 por mês. Depois do deploy, repita o curl -N no subdomínio enquanto acompanha os logs em tempo real: se os eventos token chegam um a um, o caminho inteiro funciona.

Uma ressalva sobre o AI Gateway, a API compatível com a OpenAI para o modelo cubic, em beta a partir do plano Standard: ele ainda não faz streaming, e uma requisição com stream: true volta com erro 400. Para mostrar a resposta token por token, como aqui, continue com um provedor que transmite. O gateway atende chamadas em que a resposta pode chegar inteira, como resumos e classificações em segundo plano. Para o app completo em volta do endpoint, veja como hospedar um chatbot de IA e a página de hospedagem de APIs.

IA

Hospede apps com IA em containers sempre ligados

Saiba mais

Perguntas frequentes

Qual a diferença entre SSE e WebSockets para streaming de IA?

SSE é de mão única (do servidor para o navegador) sobre HTTP comum, e é tudo de que uma resposta de chat precisa. WebSockets são bidirecionais e exigem tratamento próprio da conexão, então só compensam quando o cliente precisa enviar dados o tempo todo na mesma conexão.

Por que não dá para usar EventSource num endpoint de chat?

O EventSource só faz requisições GET, sem corpo e sem cabeçalhos personalizados. Um chat precisa enviar a conversa, em geral com POST e um cabeçalho de autenticação, então use fetch e leia o corpo da resposta como stream.

Por que o meu stream chega todo de uma vez em vez de token por token?

Algo entre o seu código e o navegador está segurando a resposta em buffer. As causas comuns são um proxy reverso com buffering ligado, compressão gzip do stream ou falta de flush. Desligue o buffering nessa rota e teste com curl -N.

Preciso de streaming se o modelo for rápido?

Respostas longas ainda levam vários segundos para terminar. O streaming mostra as primeiras palavras quase na hora, o que faz o app parecer mais rápido mesmo com o mesmo tempo total.

Próximo artigoO que é RAG (geração aumentada por recuperação)RAG (geração aumentada por recuperação) busca trechos relevantes dos seus dados e os entrega ao LLM junto com a pergunta. Veja como funciona e quando usar.