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
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).
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:
data: Hello
event: done
data: {}
Os campos que importam:
data:é o conteúdo. Linhasdata: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 eventomessagepadrão.id:define o ID do último evento, que o navegador devolve comoLast-Event-IDao 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):
npm install express @anthropic-ai/sdkCrie o server.mjs. A chave da API e o modelo vêm de variáveis de ambiente, nunca do código:
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.stringifyem cada trecho impede que quebras de linha dentro do texto estraguem o formato da linhadata:.no-transformimpede que intermediários comprimam ou reescrevam o stream.- O handler de
closeaborta 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.messagese 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:
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:
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):
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:
- Buffering no proxy. O nginx faz buffer das respostas por padrão. Use
proxy_buffering off;na rota, ou mantenha o cabeçalhoX-Accel-Buffering: nodo exemplo. Veja o que é um proxy reverso. - Compressão. Um middleware de gzip pode segurar os trechos até ter o bastante para comprimir. Tire
text/event-streamda compressão. - Cabeçalhos ausentes. Sem
Content-Type: text/event-stream, alguns clientes e proxies esperam o corpo inteiro. - 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
: pingseguida de linha em branco a cada 15 segundos enquanto o modelo pensa. - 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:
- Envie o projeto com o
package.json. Não há etapa de build: no start, a plataforma rodanpm installquando não existenode_modules. - Escolha
server.mjscomo arquivo principal. O arquivosquarecloud.appé opcional: o painel o gera no deploy, ou você o escreve na raiz do projeto. - Cadastre
ANTHROPIC_API_KEYeANTHROPIC_MODELnas 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
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.
Mais em IA
Ver todos os 13 artigos- Quanto custa criar e manter um chatbot com IAManter um chatbot com IA custa tokens do modelo, hospedagem, banco e, com RAG, contexto extra por mensagem. Veja como fazer a conta por conversa.
- Quanto custa um sistema RAG: embeddings, banco e LLMUm RAG tem três custos: embeddings, banco vetorial e LLM. Veja a conta para mil perguntas por dia, com preços hipotéticos, e onde a fatura cresce.
- Como criar um bot do Discord com IACrie um bot do Discord com IA em discord.js: comando /ask, chave de API em variável de ambiente, memória por canal, limites do Discord e como colocar no ar.
- Como hospedar um chatbot com IAUm chatbot com IA é uma API que recebe mensagens, chama um modelo de linguagem e devolve a resposta em streaming. Veja o código e como colocá-lo no ar.