Streaming e TTFT
Acompanhe o text/event-stream do primeiro byte até o fim e saiba onde o TTFT é medido. Você separa a latência do upstream da latência do proxy e reage certo quando o primeiro frame não chega.
Este guia cobre o caminho com stream: true. No caminho não-streaming a resposta chega inteira: não existe TTFT, e o proxy registra só o tempo total.
Pré-requisitos#
Para acompanhar o fluxo de ponta a ponta, você precisa de:
customUrl que o cliente aponta por ANTHROPIC_BASE_URL — o passo a passo está no Quickstart — proxy.ApiKeyGroup com chaves ativas. A strategy decide quem atende, e rated, hedge e race leem o TTFT para ordenar o pool.stream: true no body de /v1/messages.O que é TTFT#
TTFT (time to first token) é o tempo entre o recebimento do pedido no proxy e o primeiro frame SSE válido repassado ao cliente. É o número que descreve quanto tempo você espera até a resposta começar a aparecer.
O proxy guarda quatro medidas por request. Compare as colunas antes de atribuir a lentidão a uma chave:
| Métrica | O que mede | Por que importa |
|---|---|---|
TTFT | pedido recebido → primeiro frame SSE válido repassado ao cliente | é a latência percebida, e é o dado que alimenta o rating de velocidade das chaves |
responseTime | pedido recebido → fim da resposta | custo total da request; usa o mesmo relógio do TTFT, então as duas colunas se comparam linha a linha |
ttftEwmaMs | média exponencial do TTFT por chave, guardada no Redis | é o número que rated, hedge e race consomem na seleção |
primaryTtftMs e measuredTtftMs | TTFT esperado da primária contra o TTFT medido, no log do modo shadow | mostram se a corrida teria escolhido outra chave |
Como o stream passa#
O caminho tem quatro tempos, nesta ordem. Cada um fecha uma porta: depois dele, muda o que ainda é possível fazer com a resposta.
-
1 · Headers segurados
Depois do dial, o handler não confirma 200. Ele segura o head e lê o primeiro frame SSE do upstream com
readFirstFrame, sob o teto deFIRST_CHUNK_TIMEOUT_MS. Enquanto o head não sai, o failover para a próxima chave continua possível. -
2 · Primeiro frame
Se o primeiro frame é dado válido, os bytes voltam para a frente da stream com
unshift— replay byte-idêntico — e só então o proxy escreve o head SSE. Se o primeiro frame é erro, o corpo, até 64 KB, segue para o classificador e a chave seguinte é tentada em silêncio. -
3 · Corpo
O coletor repassa só os bytes que o
SseParserreconheceu como frame válido. Texto cru colado entre frames não chega ao cliente. É neste mesmo ponto que o TTFT é carimbado. -
4 · Fim
Quando o stream termina,
detectStreamErrorsvarre os eventos coletados e aplica a mesma política de erro do caminho HTTP. O cliente recebe o fechamento normal do stream.
O formato que atravessa o fio é o SSE canônico do Anthropic Messages. Cada frame carrega uma linha data: com um objeto JSON, e os frames são separados por uma linha em branco:
event: message_start
data: {"type":"message_start","message":{"id":"msg_01H8","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":24,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":1}}}
event: content_block_start
data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}
event: content_block_delta
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Olá"}}
event: content_block_delta
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"!"}}
event: content_block_stop
data: {"type":"content_block_stop","index":0}
event: message_delta
data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":12}}
event: message_stop
data: {"type":"message_stop"}
O sentinela [DONE] também conta como frame válido, e o parser o descarta em silêncio. Frame sem linha data: parseável não vale.
Antes do 200, o resultado da leitura do primeiro frame decide o caminho. Estes são os cinco desfechos possíveis:
Desfecho de readFirstFrame | Quando acontece | O que o proxy faz |
|---|---|---|
data | o primeiro frame é dado válido | devolve os bytes à frente da stream e confirma o 200 |
error | o primeiro frame já é erro do upstream | classifica o corpo, aplica a política de erro na chave e tenta a seguinte |
ended | o upstream fechou o stream sem mandar nada | mesmo failover do caso anterior |
timeout | estourou FIRST_CHUNK_TIMEOUT_MS sem chegar frame | conta como falha da chave e faz failover — o cliente nunca viu o 200 |
aborted | o cliente desconectou durante a leitura | nada é escrito e o dial é destruído, sem socket órfão |
É o teto de espera pelo primeiro frame SSE válido. Default 15000 ms, faixa de 1000 a 120000. Sem esse teto, uma chave presa só sairia da frente quando o UPSTREAM_TIMEOUT de 180 s estourasse; com ele, a chave lenta sai em segundos e a próxima é tentada. O caso fica registrado no log como [FIRST-CHUNK] timeout de primeiro chunk.
Onde o proxy mede#
O TTFT é carimbado no transform do coletor, no mesmo if (validBytes) que repassa os bytes reconhecidos: é o instante em que o primeiro byte útil atravessa para o cliente. O carimbo nunca reseta — do segundo frame em diante, o valor já não é TTFT.
O ponto não é o primeiro byte cru. Entre o upstream e o cliente passam o parse do SSE, os transforms de provider e a fila do socket. Medir no start do upstream, ou no primeiro byte cru, daria um número menor do que o cliente sente, e você compararia esse número com a experiência do usuário acreditando que eram a mesma coisa.
Três consequências seguem desse ponto de medição:
flush do coletor não carimba. Se nada foi reconhecido antes dele, não houve primeiro token para o cliente e getTtftMs() devolve null.firstByteAtMs - startTimeMs, no mesmo relógio de responseTime. Por isso as duas medidas se comparam linha a linha.recordKeyRating via logRequestToDatabase, e daí o ttftEwmaMs de cada chave no Redis.Erro no meio do stream#
Depois que o 200 sai, o status HTTP está congelado. O proxy age sobre a chave e sobre o corpo que emite, mas não muda o que o cliente já recebeu.
Um erro que chega depois de dados válidos já repassados não passa por failover. Esconder um erro no meio do stream é impossível em qualquer protocolo de streaming: o cliente já tem parte da resposta.
Nesse ponto, o proxy faz o seguinte:
event: error com corpo genérico e encerra o stream. A mensagem real do erro não é repassada: pode conter stack trace de TLS, hostname interno ou a string literal aborted.type: response.failed do OpenAI Responses, type: error do Anthropic e o objeto error sem type no topo, que Mistral, Z.AI e OpenRouter usam.errorClassifier do caminho HTTP, passando 200 como status de entrada. O status real sai do code do corpo, do tipo ou do texto da mensagem.increment429; temporary vira cooldown; request_error só registra; billing_exhausted entra em cooldown e nunca desativa; 401 em chave meta_device tenta o remint; o resto desativa a chave.O frame que o cliente recebe é este:
event: error
data: {"type":"error","error":{"type":"overloaded_error","message":"All configured providers are unavailable. Please try again shortly."}}
Erro que chega antes do primeiro frame válido é outro caso: o head ainda não saiu, então o proxy aplica a política na chave e faz failover em silêncio. Você recebe a mesma resposta que receberia se a primeira chave tivesse funcionado.
O que afeta a latência#
Cada fator abaixo age num ponto diferente do caminho. Use a terceira coluna para achar onde mexer:
| Fator | Onde age | O que controla |
|---|---|---|
| Geração do modelo no upstream | antes do primeiro frame | é a maior parte do TTFT; rated ordena as chaves por esse número |
| Chave presa sem primeiro frame | entre o dial e o 200 | FIRST_CHUNK_TIMEOUT_MS tira a chave da frente em 15 s, em vez de esperar 180 s |
| Parse do SSE e transforms de provider | no caminho até o cliente | entram na medição do TTFT; não são latência escondida |
| Backpressure do cliente | na fila do socket | o proxy faz push-through: um cliente lento atrasa o que chega depois dele |
| Cooldown e failover | na escolha da chave seguinte | chave em cooldown não entra no pool; cada fallback paga um novo dial e um corpo re-sanitizado |
hedge e race | a partir de hedgeDelayMs | a reserva dispara 2000 ms depois do início da request; com shadow: true o cliente não muda de caminho |
| Retenção de log do stream | só em memória | STREAM_LOG_LIMIT_BYTES (1 MB) limita o que fica guardado, não o que é repassado |
Próximos passos#
Com o stream e o TTFT claros, veja como essa medição vira escolha de chave:
Depois de dominar o básico, explore além:
responseTime e erros mid-stream em tempo real.FIRST_CHUNK_TIMEOUT_MS, HEDGE_DELAY_MS e STREAM_LOG_LIMIT_BYTES, com faixas e defaults.