Streaming de respostas com LLMs: SSE, chunked transfer e UX em tempo real

1. Fundamentos do Streaming com LLMs

1.1. Por que streaming é essencial para LLMs: latência percebida vs. latência real

Modelos de linguagem de grande escala (LLMs) como GPT-4, Claude ou Llama podem levar de 2 a 30 segundos para gerar uma resposta completa, dependendo do tamanho do prompt e da complexidade da tarefa. Em uma abordagem batch, o usuário enfrenta uma espera silenciosa até que todo o texto seja produzido — uma experiência frustrante que aumenta a taxa de abandono em até 40%.

O streaming resolve esse problema ao entregar tokens (palavras ou subpalavras) assim que são gerados, reduzindo o time-to-first-token (TTFT) para milissegundos. A latência percebida cai drasticamente porque o usuário vê o texto sendo "digitado" em tempo real, criando uma sensação de resposta imediata.

1.2. Diferença entre resposta completa (batch) e resposta incremental (streaming)

Na resposta batch, o servidor coleta todos os tokens, monta a string final e a envia em uma única resposta HTTP. No streaming, cada token é enviado individualmente ou em pequenos lotes:

// Resposta batch (JSON completo)
{
  "response": "Olá! Como posso ajudar você hoje com suas dúvidas sobre programação?"
}

// Resposta streaming (eventos individuais)
data: "Olá"
data: "!"
data: " Como"
data: " posso"
data: " ajudar"
data: " você"
...

1.3. Protocolos envolvidos: HTTP, WebSockets e a escolha do SSE

Para streaming unidirecional (servidor → cliente), o Server-Sent Events (SSE) é a escolha natural. Ele opera sobre HTTP padrão, não requer bibliotecas especiais no cliente (basta EventSource no navegador) e lida automaticamente com reconexão. WebSockets são mais adequados para comunicação bidirecional, mas introduzem complexidade desnecessária quando o fluxo é apenas do servidor para o cliente.

2. Server-Sent Events (SSE): O Padrão para Streaming Unidirecional

2.1. Estrutura do protocolo SSE

O SSE utiliza o content-type text/event-stream e segue um formato simples:

Content-Type: text/event-stream

data: {"token": "Olá"}
data: {"token": "!"}
data: [DONE]

Cada mensagem começa com data: seguido do conteúdo. Opcionalmente, pode-se incluir id:, event: e retry: para controle de fluxo.

2.2. Implementação básica de um endpoint SSE para LLM (exemplo com Node.js/Express)

const express = require('express');
const app = express();

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

  // Simula streaming de tokens de um LLM
  const tokens = ["Olá", "!", " Como", " posso", " ajudar", "?"];

  for (const token of tokens) {
    res.write(`data: ${JSON.stringify({ token })}\n\n`);
    await new Promise(resolve => setTimeout(resolve, 200)); // Simula latência
  }

  res.write('data: [DONE]\n\n');
  res.end();
});

app.listen(3000);

2.3. Tratamento de reconexão e IDs de eventos para resiliência

Para garantir que o cliente não perca mensagens em caso de queda de rede:

let eventId = 0;

// No servidor
res.write(`id: ${eventId}\n`);
res.write(`data: ${token}\n\n`);
eventId++;

// No cliente (JavaScript)
const eventSource = new EventSource('/stream');
eventSource.lastEventId = localStorage.getItem('lastEventId') || '';

3. Chunked Transfer Encoding: Streaming em Baixo Nível

3.1. Como o HTTP chunked transfer funciona

O chunked transfer encoding permite que o servidor envie dados em pedaços sem conhecer o tamanho total da resposta. O cabeçalho Transfer-Encoding: chunked sinaliza isso ao cliente:

Transfer-Encoding: chunked

5\r\n
Olá!\r\n
A\r\n
 Como vai?\r\n
0\r\n
\r\n

Cada chunk tem: tamanho em hexa, dados, CRLF. O chunk de tamanho 0 indica o fim.

3.2. Comparação prática: SSE vs. chunked para streaming de tokens de LLM

Característica SSE Chunked Transfer
Abstração Alta (formato padronizado) Baixa (bytes crus)
Reconexão automática Sim (via EventSource) Não (implementação manual)
Compatibilidade Navegadores modernos Todo cliente HTTP
Controle de eventos Sim (tipos de evento) Não

Para LLMs, SSE é quase sempre superior devido à reconexão automática e ao formato estruturado.

3.3. Casos de uso: quando usar chunked diretamente

Use chunked transfer quando:
- O cliente não é um navegador (CLI, IoT, servidores)
- Precisa de controle binário de baixo nível
- O middleware (proxy reverso) não suporta SSE corretamente

4. Arquitetura Backend para Streaming de LLMs

4.1. Integração com provedores de LLM e suporte a streaming

const { OpenAI } = require('openai');

app.post('/chat', async (req, res) => {
  const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });

  res.writeHead(200, {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache'
  });

  const stream = await openai.chat.completions.create({
    model: 'gpt-4',
    messages: [{ role: 'user', content: req.body.message }],
    stream: true
  });

  for await (const chunk of stream) {
    const token = chunk.choices[0]?.delta?.content || '';
    if (token) {
      res.write(`data: ${JSON.stringify({ token })}\n\n`);
    }
  }

  res.write('data: [DONE]\n\n');
  res.end();
});

4.2. Gerenciamento de backpressure e buffers

Quando o cliente é mais lento que o LLM, use buffers controlados:

const BUFFER_SIZE = 100; // tokens
let buffer = [];

for await (const chunk of stream) {
  buffer.push(chunk.choices[0]?.delta?.content || '');

  if (buffer.length >= BUFFER_SIZE) {
    res.write(`data: ${JSON.stringify({ tokens: buffer })}\n\n`);
    buffer = [];

    // Aguarda o cliente processar (backpressure)
    await new Promise(resolve => res.once('drain', resolve));
  }
}

// Envia buffer restante
if (buffer.length > 0) {
  res.write(`data: ${JSON.stringify({ tokens: buffer })}\n\n`);
}

4.3. Estratégias de timeout, cancelamento e controle de fluxo

// Timeout de 30 segundos
const timeout = setTimeout(() => {
  res.write('data: {"error": "timeout"}\n\n');
  res.end();
}, 30000);

// Cancelamento pelo cliente
req.on('close', () => {
  clearTimeout(timeout);
  stream.controller.abort(); // Cancela a requisição ao LLM
});

5. Experiência do Usuário (UX) em Tempo Real

5.1. Feedback visual: cursor piscante, animação de digitação

<div id="response-container">
  <span id="response-text"></span>
  <span id="cursor" class="blinking">|</span>
</div>

<style>
.blinking {
  animation: blink 1s step-end infinite;
}
@keyframes blink {
  50% { opacity: 0; }
}
</style>

<script>
const eventSource = new EventSource('/stream');
const responseText = document.getElementById('response-text');

eventSource.onmessage = (event) => {
  if (event.data === '[DONE]') {
    document.getElementById('cursor').style.display = 'none';
    eventSource.close();
    return;
  }
  const data = JSON.parse(event.data);
  responseText.textContent += data.token;
};
</script>

5.2. Tratamento de interrupções: pausa, cancelamento e retomada

let isPaused = false;
let buffer = [];

document.getElementById('pause-btn').onclick = () => {
  isPaused = !isPaused;
  if (!isPaused) {
    // Envia buffer acumulado
    responseText.textContent += buffer.join('');
    buffer = [];
  }
};

eventSource.onmessage = (event) => {
  if (isPaused) {
    buffer.push(JSON.parse(event.data).token);
    return;
  }
  // Processa normalmente
};

5.3. Acessibilidade: leitores de tela e conteúdo incremental

Para garantir que usuários de leitores de tela recebam o conteúdo incremental:

<div 
  id="response-container"
  role="log"
  aria-live="polite"
  aria-atomic="false"
>
  <span id="response-text"></span>
</div>

Use aria-live="polite" para que o leitor de tela anuncie mudanças sem interromper outras leituras.

6. Desafios Técnicos e Boas Práticas

6.1. Problemas comuns: buffer de proxy reverso, compressão e latência

Proxies como Nginx podem bufferizar respostas SSE. Configure:

# nginx.conf
proxy_buffering off;
proxy_cache off;
chunked_transfer_encoding on;

Evite compressão gzip em SSE, pois ela atrasa a entrega dos chunks. Use Content-Encoding: identity ou desative compressão para endpoints SSE.

6.2. Segurança: proteção contra injeção de eventos e validação

// Validação de tokens no servidor
function sanitizeToken(token) {
  return token.replace(/[\n\r]/g, ''); // Remove quebras de linha
}

// Autenticação via token JWT
app.get('/stream', authenticate, (req, res) => {
  // Verifica se o token é válido
  if (!req.user.isPremium) {
    res.status(403).end();
    return;
  }
  // Inicia streaming
});

6.3. Monitoramento e métricas

let ttft = null;
let tokenCount = 0;

for await (const chunk of stream) {
  if (!ttft) {
    ttft = Date.now();
    metrics.timeToFirstToken = ttft - startTime;
  }
  tokenCount++;
}

metrics.totalTokens = tokenCount;
metrics.throughput = tokenCount / ((Date.now() - startTime) / 1000);

7. Comparação com Alternativas e Tendências Futuras

7.1. WebSockets vs. SSE para streaming bidirecional em LLMs

WebSockets são necessários quando o cliente precisa enviar dados durante o streaming (ex.: comandos de interrupção, feedback). Para 90% dos casos de LLMs, SSE é suficiente. Use WebSockets apenas se precisar de comunicação bidirecional frequente.

7.2. HTTP/3 e QUIC: impacto no desempenho de streaming

HTTP/3 (baseado em QUIC) elimina o head-of-line blocking do TCP, reduzindo a latência em redes instáveis. Para streaming de LLMs, isso pode melhorar o TTFT em até 30% em conexões móveis.

7.3. O futuro: streaming nativo em frameworks

React Server Components e HTMX estão incorporando streaming como parte nativa do framework. O HTMX, por exemplo, permite:

<div hx-get="/stream" hx-trigger="load" hx-swap="innerHTML">
  Carregando...
</div>

Isso simplifica drasticamente a implementação de streaming em aplicações web modernas.

Referências