Todos os artigos
streamingsseapinode

Streaming SSE em produção: medir tokens chunk a chunk

Aprenda a consumir o stream de uma API compatível com OpenAI via SSE, medir tokens no fim da resposta e tratar desconexões sem quebrar a aplicação.

3 min de leitura

Por que usar streaming em produção?

Quando um modelo precisa gerar respostas longas, aguardar a resposta completa antes de exibir qualquer caractere causa latência percebida alta. Com streaming, cada trecho ("chunk") é enviado assim que fica pronto, permitindo que a interface reaja em tempo real — como digitação progressiva em um chat.

Em APIs compatíveis com o padrão da OpenAI, para ativar o streaming basta enviar "stream": true no corpo da requisição. A resposta chega como uma sequência de eventos SSE (text/event-stream), com cada linha data: contendo um objeto JSON parcial.

Neste tutorial, você verá como consumir esse stream com JavaScript puro (Node 18+), medir o total de tokens ao final e implementar um tratamento básico para desconexões.

Pré-requisitos

  • O endereço da API e uma chave de acesso, fornecidos pela sua equipe de acesso (neste tutorial, BASE_URL e API_KEY em variáveis de ambiente).
  • Node.js 18 ou superior (para usar fetch nativo).
  • Conhecimento básico de promessas e ReadableStream.

Consumindo o stream chunk a chunk

O primeiro passo é fazer uma requisição POST para /chat/completions com stream: true. No Node, o fetch retorna um Response cujo body é um ReadableStream. Para processar eventos SSE, você lê o corpo em fatias e divide por quebras de linha duplas (\n\n), que separam cada evento.

Exemplo mínimo:

js
const API_URL = `${process.env.BASE_URL}/chat/completions`;
const API_KEY = process.env.API_KEY;

async function streamChat(messages) {
  const response = await fetch(API_URL, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${API_KEY}`,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      model: 'gpt-4o-mini',
      messages,
      stream: true
    })
  });

  if (!response.ok) {
    throw new Error(`Erro ${response.status}: ${await response.text()}`);
  }

  const reader = response.body.getReader();
  const decoder = new TextDecoder();
  let buffer = '';

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

    buffer += decoder.decode(value, { stream: true });

    // Eventos SSE são separados por linha em branco
    const parts = buffer.split('\n\n');
    buffer = parts.pop() ?? '';

    for (const part of parts) {
      for (const line of part.split('\n')) {
        if (line.startsWith('data:')) {
          const data = line.slice(5).trim();
          if (data === '[DONE]') continue;
          const chunk = JSON.parse(data);
          const delta = chunk.choices?.[0]?.delta?.content;
          if (delta) {
            process.stdout.write(delta); // ou envie para o cliente
          }
        }
      }
    }
  }
}

Pontos importantes:

  • O buffer guarda a parte final que ainda não formou um evento completo.
  • O evento [DONE] indica o fim do stream.
  • Cada chunk.choices[0].delta.content contém um pedaço do texto. Em modelos que retornam outros campos, o delta pode trazer tool_calls ou reasoning_content; adapte conforme necessário.

Medindo tokens no fim do stream

Em produção, você frequentemente precisa saber quantos tokens foram consumidos naquela chamada — para controle de uso, telemetria ou rate limiting interno. APIs que seguem o padrão OpenAI podem incluir o campo usage no último chunk se você solicitar explicitamente.

Para isso, adicione stream_options ao corpo da requisição:

json
{
  "model": "gpt-4o-mini",
  "messages": [{"role": "user", "content": "Olá"}],
  "stream": true,
  "stream_options": {"include_usage": true}
}

No loop do stream, trate o chunk final (aquele sem choices ou com usage presente):

js
let usage = null;

for (const part of parts) {
  for (const line of part.split('\n')) {
    if (!line.startsWith('data:')) continue;
    const data = line.slice(5).trim();
    if (data === '[DONE]') continue;
    const chunk = JSON.parse(data);
    if (chunk.usage) {
      usage = chunk.usage; // { prompt_tokens, completion_tokens, total_tokens }
    }
    const delta = chunk.choices?.[0]?.delta?.content;
    if (delta) process.stdout.write(delta);
  }
}

console.log('\nTokens:', usage);

Se por algum motivo o usage não vier no último chunk, você pode estimar localmente com uma biblioteca como tiktoken, mas isso só aproxima o valor e não substitui o dado oficial. A recomendação é sempre usar o stream_options.include_usage.

Lidando com desconexões no meio da resposta

Streams longos podem ser interrompidos por timeouts de rede, reinicialização do servidor ou cancelamento do cliente. Em produção, você precisa pelo menos:

  1. Detectar o erro.
  2. Cancelar a requisição ativa para liberar recursos.
  3. Registrar o ocorrido e decidir se tenta novamente.

O AbortController permite cancelar o fetch do lado do cliente. Você também pode definir um timeout para evitar esperas infinitas:

js
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 120_000); // 2 minutos

try {
  const response = await fetch(API_URL, {
    method: 'POST',
    headers: { /* ... */ },
    body: JSON.stringify({ /* ... */ }),
    signal: controller.signal
  });
  // ... processa o stream
} catch (err) {
  if (err.name === 'AbortError') {
    console.error('Timeout ou cancelamento do stream.');
  } else {
    console.error('Falha na conexão:', err);
  }
} finally {
  clearTimeout(timeout);
}

Ao tentar novamente, cuidado com duplicação: se o modelo já gerou parte da resposta e a conexão caiu, simplesmente reenviar a mesma conversa pode produzir tokens adicionais e inconsistência no histórico. Uma prática comum é não reexecutar automaticamente streams com efeitos colaterais; em chats, pode-se apresentar ao usuário uma mensagem de erro e permitir que ele reenvie. Para casos idempotentes, você pode registrar a mensagem parcial e retomar com um prompt de continuação, mas isso adiciona complexidade.

Outro ponto: use keepalive no fetch se precisar que a requisição sobreviva a um unload da página no navegador, mas lembre-se de que keepalive tem limites de tamanho e não é adequado para streams longos.

Exemplo completo (Node.js)

Juntando as partes:

js
const API_URL = `${process.env.BASE_URL}/chat/completions`;
const API_KEY = process.env.API_KEY;

async function streamCompletion(messages, { timeoutMs = 120_000 } = {}) {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), timeoutMs);

  let fullContent = '';
  let usage = null;

  try {
    const response = await fetch(API_URL, {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${API_KEY}`,
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({
        model: 'gpt-4o-mini',
        messages,
        stream: true,
        stream_options: { include_usage: true }
      }),
      signal: controller.signal
    });

    if (!response.ok) {
      throw new Error(`Erro ${response.status}: ${await response.text()}`);
    }

    const reader = response.body.getReader();
    const decoder = new TextDecoder();
    let buffer = '';

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

      const events = buffer.split('\n\n');
      buffer = events.pop() ?? '';

      for (const event of events) {
        for (const line of event.split('\n')) {
          if (!line.startsWith('data:')) continue;
          const payload = line.slice(5).trim();
          if (payload === '[DONE]') continue;

          const chunk = JSON.parse(payload);
          if (chunk.usage) usage = chunk.usage;
          const delta = chunk.choices?.[0]?.delta?.content;
          if (delta) {
            fullContent += delta;
            process.stdout.write(delta);
          }
        }
      }
    }

    return { content: fullContent, usage };
  } finally {
    clearTimeout(timer);
  }
}

// Uso
streamCompletion([{ role: 'user', content: 'Explique stream SSE em 3 frases.' }])
  .then(({ content, usage }) => {
    console.log('\n---\nTokens usados:', usage);
  })
  .catch(console.error);

Considerações finais

Streaming por SSE é o padrão de fato para respostas de LLMs em produção. Com uma API compatível com OpenAI, você consegue integrar em poucas linhas e manter controle sobre tokens e falhas de rede.

Teste o fluxo em ambiente de homologação antes de subir, monitore o usage para entender o consumo e trate AbortError e erros HTTP de forma explícita.

Quer entender como a sua empresa pode usar modelos de IA com atendimento e faturamento no Brasil? Fale com a gente — a equipe da TokenRecarga responde em português.

Quer usar isso na sua empresa?

Conte o que sua equipe precisa e preparamos uma proposta.

Continue lendo