A sua primeira chamada LLM em produção: streaming, retries e timeouts
Crie um provider que lhe mente — 429s, sockets presos, streams cortados — e meça o que o cliente faz. Full jitter: 2,2 s contra 226.
Nesta página
Capítulo 13 terminou com um cronómetro num modelo em que podia tocar. Os weights estavam na sua memória, a KV cache era sua para ativar ou desativar, e o número que saía — tempo até ao primeiro token — era uma propriedade do seu hardware.
Agora ponha esse modelo atrás de uma porta, que é o que todos os produtos fazem, e leia o mesmo número outra vez. Continua a ser tempo até ao primeiro token, mas já não é uma propriedade de nada que controle. Agora inclui um handshake TLS, uma fila no provider, um limitador de ritmo e a possibilidade de nenhum token chegar de todo.
Essa última frase é o capítulo. O código que vai escrever não calcula nada. Abre uma ligação, espera, analisa o que chega, decide o que fazer quando nada chega, decide outra vez quando o que chega é um erro e cancela-se a si próprio quando o utilizador muda de ideias. Cada uma dessas coisas é uma decisão sobre estado ao longo do tempo, e cada uma tem uma resposta errada que chega a produção e custa dinheiro.
Eis a forma do problema, medido, tudo neste capítulo:
| o que aconteceu | o que um cliente descuidado faz | quanto custa |
|---|---|---|
| o servidor aceitou o socket e nunca respondeu | espera | 300,8 s antes de o Node desistir sozinho |
| a chave estava errada (401) | tenta novamente cinco vezes | 6.325 ms de atraso, e depois o mesmo 401 |
| cem clientes atingiram o rate limit em simultâneo | todos fazem retry no mesmo calendário | 226 s para escoar, contra 2,2 s |
| o pedido expirou e foi reenviado | reenvia-o | o provider gera — e cobra — a resposta duas vezes |
| a ligação caiu a meio da resposta | mostra o texto parcial | indistinguível de uma resposta curta correta |
Nenhum destes é um problema de modelação. Todos estão nas primeiras cem linhas de todos os produtos LLM alguma vez escritos.
Porque este capítulo muda de linguagem
Ligação para a secção: Porque este capítulo muda de linguagemLeia essa tabela outra vez e pergunte que tipo de programa ela descreve. Mantém uma ligação aberta durante quarenta segundos. Tem de ser cancelável a partir de um botão. Acumula uma resposta parcial que é válida para apresentar e inválida para guardar. E corre num processo de servidor ou num edge worker, ao lado daquilo que renderiza a resposta, segurando um socket.
Isso não é um notebook. Não é que Python não consiga fazê-lo — consegue, e há quem o faça — é que tudo o que os treze capítulos anteriores construíram era de outra natureza. Os Capítulos 1 a 13 seguravam weights, gradients, logits e bytes de tokenizer. A partir daqui o código segura uma ligação, um retry, um cancelamento, estado acumulado e, mais tarde, um prompt de permissão. O curso muda de linguagem exatamente na costura onde o objeto muda.
Portanto, a regra, escrita uma vez:
Se o código tem weights, gradients, logits ou bytes de tokenizer nas mãos, é Python. Se segura uma ligação, faz retries, cancela, acumula estado e pede permissão, é TypeScript.
A costura é única e fica aqui, entre o Capítulo 13 e o Capítulo 14. Três critérios independentes colocam-na aqui.
Um: o ecossistema, contado. Tudo o que a metade esquerda deste curso cita é Python e, nos doze cursos auditados para este programa, não há um único precedente de backpropagation ensinada noutra linguagem: micrograd (17,4K estrelas), nanoGPT (62,8K), nanochat (57,8K), minbpe (10,7K), PyTorch (102,8K), transformers (164,9K). Escrever o Capítulo 5 em TypeScript quebraria a ligação com essas fontes, e as ligações são metade do valor de um capítulo que existe para ser referenciado, não para ranquear. Deste lado, a aritmética inverte-se: o pacote ai da Vercel está nos 89,4M downloads por mês e entrega a coisa em si — um ciclo de agent com tool calling, exportado como ToolLoopAgent — por isso o conceito a que este curso chega no Capítulo 23 tem a sua implementação de referência em TypeScript, embora, como esse capítulo mede, ninguém tenha concordado num nome para ele; Mastra está nas 27,7K estrelas; e os SDKs da Anthropic, gerados a partir de uma especificação, declaram 202 endpoints em TypeScript contra 201 em Python — paridade, não um port de cortesia.
Dois: a fonte normativa do MCP. O schema da especificação do Model Context Protocol é um ficheiro schema.ts. Ensinar o protocolo do Capítulo 26 noutra linguagem significa ensinar uma tradução do seu documento fundador.
Três: procura de pesquisa, com uma correção à suposição óbvia. machine learning python é a expressão mais saturada da internet; ai agent typescript tem a sua própria cauda saudável. Mas "o ecossistema MCP é sobretudo TypeScript" só é verdade consoante a forma como se conta: o registo oficial lista 8.275 servidores em npm contra 3.603 em PyPI, enquanto por downloads Python ganha — 287M por mês para mcp mais 72M para fastmcp contra 195M para @modelcontextprotocol/sdk. MCP é o único território verdadeiramente bilingue aqui, razão pela qual o Capítulo 27 escreve o mesmo servidor duas vezes em vez de fingir.
Mostrar detalhes
As cinco exceções declaradas, para que a regra seja uma regra e não um slogan.
Os Capítulos 17, 20 e 29 trazem um segundo painel em Python: implementar amostragem top-p exige ter o vetor de probabilidade na mão e uma HTTP API nunca lhe dá um; calcular honestamente o preço de um fine-tune significa correr um, e um adaptador LoRA são uma dúzia de linhas de nn.Module; e lm-eval-harness, HELM, SWE-bench e τ-bench são Python, por isso um harness de avaliação em TypeScript seria a imagem espelhada do erro da backpropagation. O Capítulo 27 é bilingue, pelo motivo medido acima. O Capítulo 28 é Markdown, porque uma agent skill é um ficheiro SKILL.md e dar-lhe uma linguagem de programação significaria não ter compreendido o formato.
Os treze capítulos em Python não são descartados. O que está do outro lado da porta é aquilo que eles construíram, e a última secção aqui liga um cliente a isso.
Um provider que pode partir
Ligação para a secção: Um provider que pode partirNão consegue aprender nada disto contra um provider real. Não pode pedir-lhe um 429 num momento escolhido, nem um socket que aceita a sua ligação e nunca responde, nem um stream que para a meio de uma palavra — e estaria a pagar por cada experiência, quando as experiências interessantes são as que corre cem vezes.
Por isso o primeiro programa nesta metade do curso não é um cliente. É um servidor hostil: quarenta linhas de Node simples que falam o mesmo protocolo no fio que um endpoint de chat completions e se comportam mal a pedido. Todos os números neste capítulo saíram dele.
import { createServer } from "node:http";
const WORDS = "A tide gauge is a device that measures sea level over time .".split(" ");
const CAPACITY = 3; // how many requests it will serve at once
let inflight = 0;
const sse = (res, obj) => res.write(`data: ${JSON.stringify(obj)}\n\n`);
createServer(async (req, res) => {
const url = new URL(req.url, "http://x");
if (url.pathname === "/hang") return;
if (url.pathname === "/401") { res.writeHead(401); return res.end("{}"); }
if (inflight >= CAPACITY) {
res.writeHead(429, { "retry-after": "1" });
return res.end(JSON.stringify({ error: { type: "rate_limit_error" } }));
}
inflight++;
const cut = Number(url.searchParams.get("cut") ?? -1); // abandon after N chunks
const how = url.searchParams.get("how"); // "close" = orderly, else reset
const max = Number(url.searchParams.get("max_tokens") ?? 999);
const delay = Number(url.searchParams.get("delay") ?? 60); // ms per token
res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache" });
for (let i = 0; i < Math.min(WORDS.length, max); i++) {
if (i === cut) {
how === "close" ? res.end() : res.destroy();
inflight--; return;
}
await new Promise((r) => setTimeout(r, delay));
sse(res, { choices: [{ delta: { content: (i ? " " : "") + WORDS[i] }, finish_reason: null }] });
}
sse(res, { choices: [{ delta: {}, finish_reason: max < WORDS.length ? "length" : "stop" }] });
res.write("data: [DONE]\n\n");
inflight--;
res.end();
}).listen(8787);Quatro comportamentos hostis, uma linha cada: /hang aceita o socket e nunca escreve nele; /401 recusa a chave; a verificação de capacidade produz um 429 genuíno com um header Retry-After genuíno assim que já há três pedidos em curso; e ?cut=N abandona a resposta a meio, seja reiniciando o socket ou — com &how=close — fechando-o de forma ordenada, o que acaba por importar muito. O resto é um stream Server-Sent Events real: um objeto JSON por linha data:, uma linha em branco entre eventos, a string [DONE] no fim.1
Corra-o, e o resto do capítulo é medição.
node mock-provider.mjs &
curl -N "http://127.0.0.1:8787/v1/chat?max_tokens=3"data: {"choices":[{"delta":{"content":"A"},"finish_reason":null}]}
data: {"choices":[{"delta":{"content":" tide"},"finish_reason":null}]}
data: {"choices":[{"delta":{"content":" gauge"},"finish_reason":null}]}
data: {"choices":[{"delta":{},"finish_reason":"length"}]}
data: [DONE]O corpo do pedido, e a chave que nunca sai do servidor
Ligação para a secção: O corpo do pedido, e a chave que nunca sai do servidorUm pedido de chat é uma lista de mensagens, cada uma com um papel. Essa lista é o estado inteiro do modelo: não há memória entre chamadas, e tudo o que quer que o modelo saiba tem de estar dentro do array que envia desta vez. O Capítulo 15 é sobre o que pôr lá dentro e o Capítulo 16 é sobre quanto custa, por isso aqui fica apenas a forma.
const body = {
model: "gpt-4.1-mini",
messages: [
{ role: "system", content: "You explain instruments in one sentence." },
{ role: "user", content: "What is a tide gauge?" },
],
stream: true,
max_tokens: 200,
};Esses papéis não são decoração. São renderizados no template de chat do Capítulo 11 antes de o modelo ver um único token, que é a razão pela qual enviar o papel errado degrada silenciosamente a resposta em vez de lançar um erro.
Uma regra sem exceções: a chave API nunca viaja para o cliente. Nem numa variável de ambiente prefixada para o browser, nem numa constante de build-time, nem "temporariamente". Uma chave num bundle é uma chave na fatura de outra pessoa em poucos dias. O browser fala com o seu servidor, o seu servidor guarda a chave e fala com o provider — e, porque o seu servidor está no meio, também é o único sítio que consegue medir quanto cada utilizador gasta, que é onde a contabilidade do Capítulo 16 tem de viver.
A mesma pergunta, três vezes
Ligação para a secção: A mesma pergunta, três vezesAgora a experiência em que o capítulo assenta. Uma pergunta, um provider simulado que produz treze tokens a 60 ms cada, três formas de perguntar.
Primeiro, sem streaming. O cliente envia o pedido e espera pelo corpo JSON completo.
blocking first visible = 791 ms complete = 791 ms finish_reason = stopOs dois números são iguais, e esse é todo o problema. Durante 791 ms, o utilizador teve um spinner, e nem uma palavra esteve disponível mais cedo — o servidor tinha a resposta, byte a byte, e escolheu não dizer nada.
Segundo, com streaming. Mesmo servidor, mesma resposta, mesmo trabalho total. A diferença é um parser.
export async function* readSSE(res: Response) {
const reader = res.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 });
let sep: number;
while ((sep = buffer.indexOf("\n\n")) !== -1) {
const event = buffer.slice(0, sep);
buffer = buffer.slice(sep + 2);
for (const line of event.split("\n")) {
if (!line.startsWith("data:")) continue;
const payload = line.slice(5).trim();
if (payload === "[DONE]") return;
yield JSON.parse(payload);
}
}
}
}Há três detalhes aí que suportam carga e a maioria das primeiras tentativas salta todos os três. O buffer existe porque um chunk de rede não tem relação com um evento: um read() pode devolver meio evento, ou dois e meio. A flag { stream: true } existe porque um carácter UTF-8 multi-byte pode ser dividido entre dois chunks e, sem ela, uma letra acentuada torna-se aleatoriamente um carácter de substituição. E os eventos são separados por uma linha em branco, não por uma quebra de linha, que é a razão pela qual o ciclo procura \n\n.
streaming first visible = 65 ms complete = 793 ms finish_reason = stopDoze vezes mais rápido até à primeira palavra, e dois milissegundos mais lento até à última. O streaming não torna nada mais rápido. Muda o que o utilizador está a fazer durante os mesmos 790 ms: a ler em vez de esperar. Esse é todo o benefício, é enorme, e é a razão pela qual todos os produtos de chat fazem streaming.
Terceiro, com vinte clientes ao mesmo tempo. O provider simulado serve três pedidos de cada vez. Dispare vinte:
jitter=true clients=20 server capacity=3
HTTP requests made: 74 429s received: 54 200s: 20
wall clock: 7,100 ms
retries per client: 0 0 0 1 1 1 2 2 2 3 4 3 3 5 4 5 4 5 4 5
every answer identical: trueVinte respostas, setenta e quatro pedidos, cinquenta e quatro rejeições. Ninguém perdeu nada, todos os clientes receberam o mesmo texto, e o único custo visível foi tempo. Isso é uma política de retry a funcionar. O resto deste capítulo é sobre as três formas como pode falhar.
finish_reason, e dois fins que parecem iguais
Ligação para a secção: finish_reason, e dois fins que parecem iguaisAntes das falhas, o campo que quase toda a gente ignora na primeira passagem. Todos os streams terminam com um evento que transporta finish_reason. stop significa que o modelo decidiu que tinha terminado. length significa que atingiu o teto de tokens, por isso a resposta foi truncada a meio de uma frase e não é culpa do modelo. Capítulos posteriores acrescentam tool_calls (Capítulo 18) e filtros de conteúdo.
Agora veja dois fins que um cliente ingénuo não consegue distinguir. Mesmo servidor, mesmo atraso, um truncado por max_tokens e outro em que a ligação é fechada de forma limpa depois de cinco tokens:
max_tokens=5 loop ended NORMALLY chunks=5 finish_reason=length text="A tide gauge is a"
socket closed cleanly loop ended NORMALLY chunks=5 finish_reason=null text="A tide gauge is a"
socket destroyed threw TypeError: terminated (UND_ERR_SOCKET)
chunks=4 finish_reason=null text="A tide gauge is"Leia as duas primeiras linhas com atenção. Texto idêntico. Contagem de chunks idêntica. Sem exceção em qualquer dos casos. O ciclo for await terminou normalmente nas duas vezes, porque, do ponto de vista do reader, o corpo acabou e isso é tudo o que um corpo pode fazer. A única diferença em toda a observação é que um transporta finish_reason: "length" e o outro não transporta nada.
Portanto, a regra não é "apanhar erros durante o streaming". É:
Um stream que termina sem um
finish_reasonnão terminou. Parou.
Trate sempre um finish_reason em falta como uma falha, e nunca persista esse texto como uma resposta concluída. A terceira linha mostra o caso mais fácil — um socket destruído lança de facto uma exceção, e também perde o chunk que estava em trânsito, razão pela qual o texto é uma palavra mais curto do que os dois acima.
Cinco códigos de estado que são cinco problemas diferentes
Ligação para a secção: Cinco códigos de estado que são cinco problemas diferentesO hábito mais caro de um produto novo é um bloco catch para tudo o que o provider devolve. Estes códigos não são variações de "falhou". São cinco instruções, e quatro delas contradizem-se.
| estado | o que significa | o que fazer | esperar? |
|---|---|---|---|
| 400 | o seu pedido está malformado — JSON inválido, campo desconhecido, context demasiado longo | corrigir o código | nunca |
| 401 | a chave está errada, em falta ou revogada | corrigir o deployment | nunca |
| 429 | rate limit: demasiados pedidos, ou demasiados tokens, por minuto | retry | Retry-After, depois backoff |
| 500 | o provider avariou | retry | backoff |
| 503 | o provider está sobrecarregado — está de pé, está cheio | retry | backoff, e reduzir carga |
A linha que importa passa entre 4xx e o resto. Um 400 ou um 401 devolve exatamente a mesma resposta se o enviar mil vezes, porque nada em qualquer uma das pontas muda entre tentativas. Fazer retry não é cautela, é um atraso com passos extra. Medido: um cliente que faz seis tentativas — cinco retries com exponential backoff —, e outro que lê primeiro o código.
retry everything -> 6 requests, gave up after 6,325 ms, still HTTP 401
triage first -> 1 request, gave up after 4 ms, still HTTP 401Seis segundos de spinner para chegar a uma resposta que estava disponível em quatro milissegundos. E essa é a versão suave: os retries num produto costumam estar aninhados — um cliente HTTP com retry dentro de um job runner com retry dentro de uma fila com a sua própria reentrega — por isso seis segundos tornam-se seis minutos de um deployment permanentemente estragado a parecer apenas lento.
A triagem tem nove linhas e pertence a um único sítio:
export type Verdict = "retry" | "retry-after" | "fatal";
export function classify(status: number): Verdict {
if (status === 429) return "retry-after";
if (status === 408 || status >= 500) return "retry";
return "fatal"; // 400, 401, 403, 404, 422 — nothing changes by waiting
}Mais dois para a sua lista: 402, que alguns providers usam para "ficou sem crédito" e que precisa de um ecrã com uma ligação para comprar mais, não de um retry, e 529 ou equivalentes específicos de fornecedor, que se comportam como 503.
Backoff, e o que o jitter compra realmente
Ligação para a secção: Backoff, e o que o jitter compra realmenteFazer retry é fácil. Fazer retry quando é a parte com uma resposta certa mensurável.
Exponential backoff é o standard: esperar um atraso base, duplicá-lo depois de cada falha, parar num teto. Existe porque um servidor sobrecarregado fica pior se os clientes que acabaram de falhar voltarem logo.
O problema é que toda a gente duplica a partir do mesmo ponto de partida. Se cem clientes atingirem um limite no mesmo momento — e vão atingir, porque é isso que é um pico de tráfego — então os cem esperam 200 ms, os cem tentam de novo juntos, os cem falham juntos e os cem esperam 400 ms. O calendário de retry sincronizou-os. Isso é uma thundering herd, e a aleatoriedade é a correção.2
Essa única alteração — escolher uniformemente dentro do intervalo em vez de tomar o seu limite superior — chama-se full jitter. É uma chamada a Math.random(), e vale a pena medi-la em vez de acreditar nela:
export const backoffNaive = (n: number, base = 200, cap = 20_000) =>
Math.min(cap, base * 2 ** n);
export const backoffFull = (n: number, base = 200, cap = 20_000) =>
Math.random() * Math.min(cap, base * 2 ** n); Cem clientes, um servidor que serve três de cada vez, tudo o resto idêntico, três execuções cada:
| pedidos HTTP | rejeições | pior cliente | janela de 50 ms mais ocupada | tempo total | |
|---|---|---|---|---|---|
| sem jitter, execução 1 | 491 | 391 | 10 tentativas | 46 chegadas | 65,6 s |
| sem jitter, execução 2 | 780 | 680 | 19 tentativas | 72 chegadas | 245,7 s |
| sem jitter, execução 3 | 770 | 670 | 18 tentativas | 97 chegadas | 225,6 s |
| full jitter, execução 1 | 324 | 224 | 5 tentativas | 32 chegadas | 2,2 s |
| full jitter, execução 2 | 313 | 213 | 6 tentativas | 31 chegadas | 2,3 s |
| full jitter, execução 3 | 318 | 218 | 6 tentativas | 25 chegadas | 1,8 s |
Há duas coisas nessa tabela, e a segunda é a importante.
A primeira é a mediana: 226 segundos contra 2,2, um fator de cerca de cem, com menos de metade dos pedidos. A janela de retry mais ocupada explica porquê. Sem jitter, até 97 dos cem clientes chegaram dentro da mesma janela de 50 milissegundos; o servidor tinha três, por isso 94 foram rejeitados e adormeceram juntos, ainda sincronizados, para o fazer outra vez com uma espera mais longa. Com jitter, os mesmos cem espalharam-se pelas mesmas janelas em grupos de cerca de trinta e escoaram quase imediatamente.
A segunda é a variância. Sem jitter: 65,6 s, 245,7 s, 225,6 s. Com jitter: 2,2, 2,3, 1,8. Um sistema sem jitter não tem apenas mau desempenho, tem desempenho imprevisível, porque o resultado é decidido por acidentes microscópicos de agendamento que escolhem quais três dos cem clientes sincronizados chegam primeiro. Essa é a assinatura deste bug em produção: um endpoint que está bem, bem, bem, e depois demora quatro minutos, sem que nenhuma alteração sua o explique.
E o retry mais barato é o que nunca acontece. Ponha um portão de concorrência à frente do provider — um contador que nunca deixa mais de N pedidos em curso — e os mesmos vinte clientes que precisaram de 74 pedidos e 7,1 segundos comportam-se assim:
client-side gate of 3: 20 HTTP requests, 0 429s, wall 883 msVinte pedidos para vinte respostas, zero rejeições, oito vezes mais rápido. Um retry é o pedido de desculpa; o portão é não precisar dele.
Retry-After é um piso, não uma sugestão
Ligação para a secção: Retry-After é um piso, não uma sugestãoQuando um provider devolve 429, costuma dizer-lhe quanto tempo deve esperar, no header Retry-After.3 Esse número não é um conselho: o provider é a única parte na troca que sabe quando a sua janela reinicia.
Portanto, a espera é o maior dos dois: nunca menos do que Retry-After, e nunca menos do que o seu próprio backoff também, porque o header diz-lhe quando o limitador o perdoa, não quando o servidor tem espaço.
const header = res.headers.get("retry-after");
const floor = header ? Number(header) * 1000 : 0; // seconds -> ms
const wait = Math.max(floor, backoffFull(attempt)); O trace do cliente mais azarado na execução com vinte clientes mostra o header a fazer o seu trabalho. Os seus primeiros quatro sorteios de backoff ficaram todos abaixo de um segundo, e os quatro foram substituídos:
t+ 26ms attempt 0 HTTP 429 -> sleep 1000 ms
t+ 1032ms attempt 1 HTTP 429 -> sleep 1000 ms
t+ 2034ms attempt 2 HTTP 429 -> sleep 1000 ms
t+ 3046ms attempt 3 HTTP 429 -> sleep 1000 ms
t+ 4047ms attempt 4 HTTP 429 -> sleep 2782 ms
t+ 6852ms attempt 5 HTTP 200 -> sleep 0 msDuas notas práticas. Retry-After pode ser uma data HTTP em vez de um número de segundos, por isso analise ambos. E os providers fazem rate-limit em dois eixos ao mesmo tempo — pedidos por minuto e tokens por minuto —, razão pela qual prompts longos são rejeitados muito abaixo do limite de pedidos documentado. O header tem o mesmo aspeto nos dois casos; a correção não.
O timeout que ninguém escolheu
Ligação para a secção: O timeout que ninguém escolheuPeça /hang ao provider simulado. Ele aceita a ligação e depois não faz nada: sem headers, sem corpo, sem fecho. Isto não é exótico — é o que um balanceador de carga faz quando o processo por trás morreu sem fechar os sockets.
Dois clientes, uma diferença:
AbortSignal.timeout(5s) gave up after 5.0 s (TimeoutError: The operation was aborted due to timeout)
no timeout gave up after 300.8 s (TypeError: fetch failed)
cause: HeadersTimeoutError UND_ERR_HEADERS_TIMEOUTTrezentos segundos. Cinco minutos com um socket aberto, uma vaga de pedido ocupada e um utilizador a olhar para um spinner, acabando num TypeError genérico que não diz nada sobre o que aconteceu. Esse número não é um bug: é o timeout de headers por defeito do Node, razoável para um cliente HTTP genérico e catastrófico para um pedido virado para o utilizador. Todos os runtimes têm um default destes, a maioria das pessoas nunca o procura, e a única forma de encontrar o seu é pendurar um socket de propósito, como acabámos de fazer.
Portanto: todos os pedidos de saída recebem um prazo explícito, escolhido por si.
const res = await fetch(url, {
method: "POST",
headers: { "content-type": "application/json", authorization: `Bearer ${key}` },
body: JSON.stringify(payload),
signal: AbortSignal.timeout(20_000),
});Para uma chamada com streaming, um prazo não chega, porque há duas falhas diferentes. A primeira é o stream nunca abre: nenhum evento chega de todo, e dez a trinta segundos está certo. A segunda é o stream abre e depois fica preso: os tokens fluíram e depois pararam, para sempre, com o socket ainda saudável. Um timeout de duração total não consegue distinguir um stream preso de uma resposta longa correta, por isso o que quer é um idle timeout — um temporizador reiniciado por cada evento, disparando apenas quando nada chegou durante, digamos, quinze segundos.
O cancelamento é a mesma maquinaria apontada para uma pessoa. AbortSignal.timeout e um utilizador a carregar em Parar chegam ambos como um AbortError, por isso combine-os e registe qual disparou:
const user = new AbortController();
const signal = AbortSignal.any([user.signal, AbortSignal.timeout(20_000)]);
// stopButton.onclick = () => user.abort();Abortar importa por uma razão além da arrumação: os tokens estão a ser gerados e cobrados enquanto não está a ouvir. O Capítulo 16 põe um preço nisso.
O que é seguro tentar novamente
Ligação para a secção: O que é seguro tentar novamenteAgora a falha que custa dinheiro em vez de tempo. Um pedido expira no cliente, e o movimento óbvio é enviá-lo outra vez — mas um timeout não lhe diz nada sobre se o servidor o recebeu. Muitas vezes recebeu, e continua a trabalhar.
Medido. O provider simulado precisa de 780 ms para a resposta. O cliente desiste aos 300 ms e tenta de novo. O servidor conta quantas respostas gerou de facto, que é aquilo que cobraria:
idempotency-key: no attempt 0: TimeoutError after 300 ms | attempt 1: TimeoutError after 300 ms
answers generated (and billed): 2
idempotency-key: yes attempt 0: TimeoutError after 300 ms | attempt 1: HTTP 200 (replay) id=cmpl_1
answers generated (and billed): 1Sem uma chave: duas gerações completas, pagas duas vezes, e o cliente não recebeu nenhuma delas. Com uma chave: o servidor reconheceu o segundo pedido como o mesmo pedido e respondeu instantaneamente com a resposta que já tinha produzido, por isso o retry evitou a cobrança dupla e foi a tentativa que finalmente teve sucesso.
Uma idempotency key é uma string única que gera por operação lógica — não por tentativa — e envia sem alterações em todos os retries dessa operação. O servidor guarda o resultado contra a chave e reproduz-o. É o mecanismo que as APIs de pagamento usam, pela mesma razão.4
async function send(url: string, payload: unknown) {
const key = crypto.randomUUID(); // once per turn, not per attempt
for (let attempt = 0; attempt < 5; attempt++) {
const res = await fetch(url, {
method: "POST",
body: JSON.stringify(payload),
headers: { "content-type": "application/json", "idempotency-key": key },
signal: AbortSignal.timeout(20_000),
});
if (res.ok) return res;
if (classify(res.status) === "fatal") throw new Error(`HTTP ${res.status}`);
await sleep(backoffFull(attempt));
}
throw new Error("out of attempts");
}Dois limites honestos. Nem todos os providers suportam idempotency keys em completions, e quando o endpoint não é idempotente, o número correto de retries para um POST que pode já ter corrido é zero. E um stream que falhou a meio não é reproduzível no caso geral: ou o reinicia e paga outra vez, ou mantém o texto parcial e marca-o como incompleto. Qual dessas opções o seu produto escolhe é uma decisão de produto, não de rede, e vale a pena tomá-la de propósito.
Fechar a costura
Ligação para a secção: Fechar a costuraO cliente escrito neste capítulo não faz ideia do que está atrás da porta. Aponte o seu base URL para um provider comercial e ele faz streaming de tokens a partir de um modelo de um bilião de parâmetros. Aponte-o para um servidor construído sobre a aritmética do Capítulo 13 — servindo o modelo que pré-treinou no Capítulo 10, com a sua KV cache e os seus weights quantizados — e o mesmo código, sem alterações, faz streaming de tokens a partir de um modelo que construiu.
const BASE = process.env.LLM_BASE_URL ?? "http://127.0.0.1:8000/v1"; Essa linha única é a costura deste curso. De um lado está o que os primeiros treze capítulos construíram; do outro, o que os próximos dezasseis constroem. A fronteira é limpa porque o contrato é HTTP e SSE, e nenhum dos lados sabe mais nada sobre o outro.
Vale a pena reparar no que perdeu ao atravessar. Atrás de um endpoint comercial, não controla os weights, nem a implementação de sampling, nem a versão com que está a falar, nem se ela mudou esta manhã. O que controla é o contrato: as mensagens que envia, o prazo que define, os códigos que distingue e o que faz quando nada volta. É uma superfície menor do que tinha no Capítulo 5, e todos os capítulos restantes são sobre usá-la bem.
Para onde isto segue
Ligação para a secção: Para onde isto segueAgora tem um cliente que faz streaming, desiste a tempo, faz retry das coisas certas e nunca faz retry das erradas. O que envia continua a ser aquilo que escreveu.
O Capítulo 15 é sobre esse conteúdo, e vem com uma disciplina. A internet está cheia de conselhos de prompting — oferecer uma gorjeta ao modelo, ameaçá-lo, dizer-lhe para respirar fundo — e quase nenhum vem acompanhado de uma medição. Algumas dessas técnicas mexem muito no output, outras não mexem de todo, e pelo menos uma torna uma tarefa de classificação pior enquanto custa mais tokens. Qual é qual não é óbvio ao lê-las, e não se resolve por discussão.
Por isso o próximo capítulo constrói uma bench: sessenta casos com respostas conhecidas, quatro variantes do mesmo prompt, corridas em paralelo exatamente pelo cliente que acabou de escrever, tabeladas com os intervalos de confiança do Capítulo 4 — porque quatro variantes em vinte casos não distinguem absolutamente nada. Uma frase governa o capítulo inteiro: um prompt mede-se, não se debate.
Fontes e método
Ligação para a secção: Fontes e métodoTodos os números acima vieram do provider simulado, em Node 22 sobre uma interface de loopback, por isso as latências são mais limpas do que qualquer rede real lhe dará. Isso é deliberado: nenhuma das falhas medidas é causada pela rede, e um servidor hostil que pode reiniciar ensina melhor do que um real pelo qual tem de pagar e que não pode partir.
Referências
Ligação para a secção: Referências-
Server-Sent Events, WHATWG HTML Living Standard, secção 9.2. O formato no fio — campos
data:, eventos separados por linhas em branco,id:eretry:— é definido aí, juntamente com a interfaceEventSource.EventSourcenão consegue enviar um corpo de pedido nem headers personalizados, razão pela qual todos os clientes LLM analisam o formato à mão sobrefetchem vez de o usar. ↩ -
Brooker, M. Exponential Backoff and Jitter. AWS Architecture Blog (2015). A fonte da formulação "full jitter" usada acima, com as simulações que mostram porque a versão ingénua sincroniza clientes. O argumento complementar para reduzir carga em vez de a pôr em fila é o capítulo Handling Overload de Beyer, Jones, Petoff e Murphy (eds.), Site Reliability Engineering (O'Reilly, 2016). ↩
-
Fielding, R., Nottingham, M. e Reschke, J. (eds.), HTTP Semantics, RFC 9110, secção 15, define as classes de códigos de estado; Nottingham, M. e Fielding, R., Additional HTTP Status Codes, RFC 6585 (2012), secção 4, define 429 Too Many Requests.
Retry-Afteré a secção 10.2.3 da RFC 9110, e aceita tanto um número de segundos como uma data HTTP. ↩ -
Stripe, Idempotent requests,
docs.stripe.com/api/idempotent_requests, lido a 7 de setembro de 2026 — a declaração mais clara do contrato: uma chave por operação lógica, resultados guardados reproduzidos, um conflito devolvido enquanto a primeira tentativa ainda está em curso — e o padrão é independente do provider. As referências normativas para as formas de pedido e evento usadas aqui sãodevelopers.openai.com/api/reference/resources/chatpara streaming, códigos de erro e rate limits, eplatform.claude.com/docs/en/api/messagespara a Messages API;ai-sdk.dev/docsé o melhor exemplo trabalhado das mesmas preocupações embrulhadas numa biblioteca. Todos lidos no mesmo dia. ↩