Pular para o conteúdo
14/30Capítulo 14 de 30

Sua primeira chamada LLM em produção: streaming, retentativas e timeouts

Crie um provider que mente para você — 429s, sockets travados, streams cortados — e meça seu client. Full jitter: 2,2 s contra 226.

Nesta página

Chapter 13 terminou com um cronômetro em um model que você podia tocar. Os pesos estavam na sua memória, o KV cache era seu para ativar ou desativar, e o número que saiu — tempo até o primeiro token — era uma propriedade do seu hardware.

Agora coloque esse model atrás de uma porta, que é o que todo produto faz, e leia o mesmo número de novo. Ainda é tempo até o primeiro token, mas já não é propriedade de nada que você controle. Agora ele inclui um handshake TLS, uma fila no provider, um limitador de taxa e a possibilidade de que nenhum token chegue jamais.

Essa última frase é o capítulo. O código que você está prestes a escrever não computa nada. Ele abre uma conexão, espera, faz parse do que chega, decide o que fazer quando nada chega, decide de novo quando o que chega é um erro e cancela a si mesmo quando o usuário muda de ideia. Cada uma dessas é uma decisão sobre estado ao longo do tempo, e cada uma tem uma resposta errada que vai para produção e custa dinheiro.

Aqui está o formato do problema, medido, tudo neste capítulo:

o que aconteceuo que um client descuidado fazquanto custa
o servidor aceitou o socket e nunca respondeuespera300,8 s antes de o Node desistir sozinho
a chave estava errada (401)tenta de novo cinco vezes6.325 ms de atraso, depois o mesmo 401
cem clients atingiram o rate limit juntostodos tentam de novo no mesmo cronograma226 s para escoar, contra 2,2 s
a requisição deu timeout e foi reenviadareenviao provider gera — e cobra — a resposta duas vezes
a conexão caiu no meio da respostamostra o texto parcialindistinguível de uma resposta curta correta

Nada disso é um problema de modelagem. Tudo isso está nas primeiras cem linhas de todo produto LLM já escrito.

Leia essa tabela de novo e pergunte que tipo de programa ela descreve. Ele mantém uma conexão aberta por quarenta segundos. Precisa ser cancelável por um botão. Acumula uma resposta parcial que é válida para exibir e inválida para salvar. E roda em um processo de servidor ou em um edge worker, ao lado da coisa que renderiza a resposta, segurando um socket.

Isso não é um notebook. Não é que Python não consiga fazer isso — consegue, e as pessoas fazem —, é que tudo o que os treze capítulos anteriores construíram era de outra natureza. Os capítulos 1 a 13 seguravam pesos, gradients, logits e bytes do tokenizer. Daqui em diante, o código segura uma conexão, uma retentativa, um cancelamento, estado acumulado e, mais tarde, um prompt de permissão. O curso muda de linguagem exatamente na emenda em que o objeto muda.

Então a regra, escrita uma vez:

Se o código tem pesos, gradients, logits ou bytes do tokenizer nas mãos, é Python. Se ele segura uma conexão, tenta novamente, cancela, acumula estado e pede permissão, é TypeScript.

A emenda é única e fica aqui, entre o Chapter 13 e o Chapter 14. Três critérios independentes a colocam aqui.

Um: o ecossistema, contado. Tudo 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 em outra linguagem: micrograd (17,4K stars), nanoGPT (62,8K), nanochat (57,8K), minbpe (10,7K), PyTorch (102,8K), transformers (164,9K). Escrever o Chapter 5 em TypeScript quebraria o vínculo com essas fontes, e os vínculos são metade do valor de um capítulo que existe para ser referenciado, não para ranquear. Deste lado, a aritmética se inverte: o pacote ai da Vercel está em 89,4M downloads por mês e entrega a própria coisa — um loop de agent com tool calling, exportado como ToolLoopAgent —, então o conceito a que este curso chega no Chapter 23 tem sua implementação de referência em TypeScript, embora, como esse capítulo mede, ninguém tenha concordado sobre um nome para ele; Mastra está em 27,7K stars; 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 arquivo schema.ts. Ensinar o protocolo do Chapter 26 em outra linguagem significa ensinar uma tradução do documento fundador dele.

Três: demanda de busca, com uma correção ao palpite óbvio. machine learning python é a frase mais saturada da internet; ai agent typescript tem sua própria cauda saudável. Mas “o ecossistema MCP é majoritariamente TypeScript” só é verdade dependendo de como você conta: o registro oficial lista 8.275 servidores no npm contra 3.603 no PyPI, enquanto em downloads Python vence — 287M por mês para mcp mais 72M para fastmcp contra 195M para @modelcontextprotocol/sdk. MCP é o único território genuinamente bilíngue aqui, por isso o Chapter 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 probabilidades na mão e uma HTTP API nunca entrega um; precificar um fine-tune honestamente significa rodar 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, então um harness de avaliação em TypeScript seria a imagem espelhada do erro de backpropagation. O Chapter 27 é bilíngue, pelo motivo medido acima. O Chapter 28 é Markdown, porque uma skill de agent é um arquivo SKILL.md, e dar a ela uma linguagem de programação significaria não ter entendido o formato.

Os treze capítulos em Python não são descartados. O que está do outro lado da porta é o que eles construíram, e a última seção aqui conecta um client a isso.

Você não consegue aprender nada disso contra um provider real. Você não pode pedir a ele um 429 em um momento escolhido, nem um socket que aceita sua conexão e nunca responde, nem um stream que para no meio de uma palavra — e você estaria pagando por cada experimento, quando os experimentos interessantes são os que você roda cem vezes.

Então o primeiro programa nesta metade do curso não é um client. É um servidor hostil: quarenta linhas de Node puro que falam o mesmo protocolo de fio de um endpoint de chat completions e se comportam mal sob demanda. Todos os números deste capítulo saíram dele.

mock-provider.mjsJS
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 quando três requisições já estão em andamento; e ?cut=N abandona a resposta pela metade, seja resetando o socket ou — com &how=close — fechando-o de forma ordenada, o que acaba importando muito. O resto é um stream real de Server-Sent Events: um objeto JSON por linha data:, uma linha em branco entre eventos, a string [DONE] no fim.1

Rode isso, e o resto do capítulo é medição.

terminalBASH
node mock-provider.mjs &
curl -N "http://127.0.0.1:8787/v1/chat?max_tokens=3"
TEXT
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 da requisição, e a chave que nunca sai do servidor

Link para a seção: O corpo da requisição, e a chave que nunca sai do servidor

Uma requisição de chat é uma lista de mensagens, cada uma com um papel. Essa lista é o estado inteiro do model: não há memória entre chamadas, e tudo que você quer que o model saiba precisa estar dentro do array que você envia desta vez. O Chapter 15 é sobre o que colocar nele e o Chapter 16 é sobre quanto isso custa, então aqui é só o formato.

call.tsTS
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. Eles são renderizados no template de chat do Chapter 11 antes de o model ver um único token, por isso enviar o papel errado degrada silenciosamente a resposta em vez de gerar um erro.

Uma regra sem exceções: a chave da API nunca viaja para o client. Não em uma variável de ambiente prefixada para o navegador, não em uma constante de build-time, não “temporariamente”. Uma chave em um bundle vira uma chave na conta de outra pessoa em poucos dias. O navegador fala com o seu servidor, seu servidor mantém a chave e fala com o provider — e, como seu servidor está no meio, ele também é o único lugar que pode medir quanto cada usuário gasta, que é onde a contabilidade do Chapter 16 precisa morar.

Agora o experimento sobre o qual o capítulo foi construído. Uma pergunta, um provider mock produzindo treze tokens a 60 ms cada, três formas de perguntar.

Primeiro, sem streaming. O client envia a requisição e espera pelo corpo JSON inteiro.

TEXT
blocking   first visible =  791 ms   complete =  791 ms   finish_reason = stop

Os dois números são iguais, e esse é todo o problema. Por 791 ms o usuário tem um spinner, e nem uma palavra estava disponível antes — 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.

sse.tsTS
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);
      }
    }
  }
}

Três detalhes ali sustentam a carga, e a maioria das primeiras tentativas pula os três. O buffer existe porque um chunk de rede não tem relação com um evento: um read() pode retornar metade de um evento, ou dois e meio. A flag { stream: true } existe porque um caractere UTF-8 multibyte pode ser dividido entre dois chunks, e sem ela uma letra acentuada vira um caractere de substituição aleatoriamente. E eventos são separados por uma linha em branco, não por uma quebra de linha, por isso o loop procura \n\n.

TEXT
streaming  first visible =   65 ms   complete =  793 ms   finish_reason = stop

Doze vezes mais rápido até a primeira palavra, e dois milissegundos mais lento até a última. Streaming não torna nada mais rápido. Ele muda o que o usuário faz durante os mesmos 790 ms: lê em vez de esperar. Esse é todo o benefício, ele é enorme, e é por isso que todo produto de chat usa streaming.

Terceiro, com vinte clients ao mesmo tempo. O provider mock atende três requisições por vez. Dispare vinte:

TEXT
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: true

Vinte respostas, setenta e quatro requisições, cinquenta e quatro rejeições. Ninguém perdeu nada, todo client recebeu o mesmo texto, e o único custo visível foi tempo. Isso é uma política de retentativas funcionando. O resto deste capítulo é sobre as três formas pelas quais ela pode falhar.

finish_reason, e dois finais que parecem iguais

Link para a seção: finish_reason, e dois finais que parecem iguais

Antes das falhas, o campo que quase todo mundo ignora na primeira passada. Todo stream termina com um evento carregando finish_reason. stop significa que o model decidiu que terminou. length significa que ele atingiu o teto de tokens, então a resposta foi truncada no meio da frase e a culpa não é do model. Capítulos posteriores adicionam tool_calls (Chapter 18) e filtros de conteúdo.

Agora veja dois finais que um client ingênuo não consegue diferenciar. Mesmo servidor, mesmo atraso, um truncado por max_tokens e outro em que a conexão é fechada limpidamente depois de cinco tokens:

TEXT
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. Nenhuma exceção em nenhum dos casos. O loop for await terminou normalmente nas duas vezes, porque do ponto de vista do reader o body acabou, e isso é tudo que um body pode fazer. A única diferença em toda a observação é que um carrega finish_reason: "length" e o outro não carrega nada.

Então a regra não é “capturar erros durante streaming”. É:

Um stream que termina sem um finish_reason não terminou. Ele parou.

Trate um finish_reason ausente como falha, sempre, e nunca persista esse texto como uma resposta concluída. A terceira linha mostra o caso mais fácil — um socket destruído gera exceção, e também perde o chunk que estava em trânsito, por isso o texto é uma palavra mais curto que os dois acima.

Cinco códigos de status que são cinco problemas diferentes

Link para a seção: Cinco códigos de status que são cinco problemas diferentes

O hábito mais caro de um produto novo é ter um bloco catch para tudo que o provider retorna. Esses códigos não são variações de “falhou”. São cinco instruções, e quatro delas se contradizem.

statuso que significao que fazeresperar?
400sua requisição está malformada — JSON inválido, campo desconhecido, context muito longocorrija o códigonunca
401a chave está errada, ausente ou revogadacorrija o deploymentnunca
429rate limit: requisições demais, ou tokens demais, por minutotentar de novoRetry-After, depois backoff
500o provider quebroutentar de novobackoff
503o provider está sobrecarregado — está de pé, está cheiotentar de novobackoff, e reduza carga

A linha que importa passa entre 4xx e o resto. Um 400 ou 401 retorna exatamente a mesma resposta se você o enviar mil vezes, porque nada muda em nenhuma das pontas entre as tentativas. Tentar de novo não é cautela, é um atraso com passos extras. Medido: um client que faz seis tentativas — cinco retentativas com backoff exponencial — e um que lê o código primeiro.

TEXT
retry everything  ->  6 requests, gave up after 6,325 ms, still HTTP 401
triage first      ->  1 request,  gave up after     4 ms, still HTTP 401

Seis segundos de spinner para chegar a uma resposta que estava disponível em quatro milissegundos. E essa é a versão leve: retentativas em um produto geralmente ficam aninhadas — um HTTP client que tenta de novo dentro de um executor de jobs que tenta de novo dentro de uma fila com sua própria reentrega —, então seis segundos viram seis minutos de um deployment permanentemente quebrado parecendo apenas lento.

A triagem tem nove linhas e pertence a um único lugar:

classify.tsTS
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 “você ficou sem crédito” e que precisa de uma tela com um link para comprar mais, não de uma retentativa, e 529 ou equivalentes específicos de fornecedor, que se comportam como 503.

Tentar de novo é fácil. Tentar de novo quando é a parte com uma resposta correta mensurável.

Backoff exponencial é o padrão: espere um atraso base, dobre-o depois de cada falha, pare em um teto. Ele existe porque um servidor sobrecarregado piora se os clients que acabaram de falhar voltam imediatamente.

O problema é que todo mundo dobra a partir do mesmo ponto inicial. Se cem clients atingem um limite no mesmo momento — e vão atingir, porque isso é um pico de tráfego —, então todos os cem esperam 200 ms, todos os cem tentam de novo juntos, todos os cem falham juntos, e todos os cem esperam 400 ms. O cronograma de retentativas os sincronizou. Isso é um thundering herd, e aleatoriedade é a correção.2

sleep=random(0, min(cap, base2n))\text{sleep} = \mathrm{random}\big(0,\ \min(\text{cap},\ \text{base} \cdot 2^{\,n})\big)

Essa única mudança — escolher uniformemente dentro do intervalo em vez de tomar sua extremidade superior — se chama full jitter. É uma chamada a Math.random(), e vale medir em vez de acreditar:

backoff.tsTS
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 clients, um servidor que atende três por vez, todo o resto idêntico, três execuções cada:

requisições HTTPrejeiçõespior clientjanela de 50 ms mais cheiawall clock
sem jitter, execução 149139110 tentativas46 chegadas65,6 s
sem jitter, execução 278068019 tentativas72 chegadas245,7 s
sem jitter, execução 377067018 tentativas97 chegadas225,6 s
full jitter, execução 13242245 tentativas32 chegadas2,2 s
full jitter, execução 23132136 tentativas31 chegadas2,3 s
full jitter, execução 33182186 tentativas25 chegadas1,8 s

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 da metade das requisições. A janela de retentativa mais cheia explica por quê. Sem jitter, até 97 dos cem clients chegaram dentro do mesmo slot de 50 milissegundos; o servidor tinha três, então 94 foram rejeitados e dormiram juntos, ainda sincronizados, para repetir com uma espera maior. Com jitter, os mesmos cem se espalharam 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 ele: 2,2, 2,3, 1,8. Um sistema sem jitter não apenas performa mal, ele performa de forma imprevisível, porque o resultado é decidido por acidentes microscópicos de agendamento que escolhem quais três de cem clients sincronizados chegam primeiro. Essa é a assinatura desse bug em produção: um endpoint que está bem, bem, bem, e então leva quatro minutos, sem nenhuma mudança sua que explique.

E a retentativa mais barata é a que nunca acontece. Coloque uma porta de concorrência na frente do provider — um contador que nunca deixa mais de N requisições em andamento — e os mesmos vinte clients que precisaram de 74 requisições e 7,1 segundos se comportam assim:

TEXT
client-side gate of 3: 20 HTTP requests, 0 429s, wall 883 ms

Vinte requisições para vinte respostas, zero rejeições, oito vezes mais rápido. Uma retentativa é o pedido de desculpas; a porta é não precisar pedir desculpas.

Quando um provider retorna 429, ele geralmente diz por quanto tempo esperar, no header Retry-After.3 Esse número não é conselho: o provider é a única parte na troca que sabe quando sua janela reinicia.

Então a espera é o maior dos dois: nunca menor que Retry-After, e nunca menor que o seu próprio backoff também, porque o header diz quando o limitador perdoa você, não quando o servidor tem espaço.

wait.tsTS
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 client mais azarado na execução com vinte clients mostra o header fazendo seu trabalho. Seus quatro primeiros sorteios de backoff ficaram todos abaixo de um segundo, e todos os quatro foram sobrescritos:

TEXT
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 ms

Duas notas práticas. Retry-After pode ser uma data HTTP em vez de um número de segundos, então faça parse dos dois. E providers aplicam rate limit em dois eixos ao mesmo tempo — requisições por minuto e tokens por minuto —, por isso prompts longos são rejeitados muito abaixo do limite documentado de requisições. O header parece igual nos dois casos; a correção não.

Peça ao provider mock por /hang. Ele aceita a conexão e então não faz absolutamente nada: sem headers, sem body, sem fechar. Isso não é exótico — é o que um load balancer faz quando o processo atrás dele morreu sem fechar seus sockets.

Dois clients, uma diferença:

TEXT
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_TIMEOUT

Trezentos segundos. Cinco minutos de um socket mantido aberto, um slot de requisição ocupado e um usuário olhando para um spinner, terminando em um TypeError genérico que não diz nada sobre o que aconteceu. Esse número não é um bug: é o timeout padrão de headers do Node, razoável para um HTTP client genérico e catastrófico para uma requisição voltada ao usuário. Todo runtime tem um padrão assim, a maioria das pessoas nunca procura, e o único jeito de encontrar o seu é pendurar um socket de propósito como acabamos de fazer.

Então: toda requisição de saída recebe um prazo explícito, escolhido por você.

deadline.tsTS
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 basta, porque há duas falhas diferentes. A primeira é o stream nunca abre: nenhum evento chega, e dez a trinta segundos está certo. A segunda é o stream abre e depois trava: tokens fluíram e então pararam, para sempre, com o socket ainda saudável. Um timeout de duração total não consegue diferenciar um stream travado de uma resposta longa correta, então o que você quer é um timeout ocioso — um timer redefinido por cada evento, disparando apenas quando nada chegou por, digamos, quinze segundos.

Cancelamento é o mesmo mecanismo apontado para uma pessoa. AbortSignal.timeout e um usuário apertando Stop chegam ambos como um AbortError, então combine-os e registre qual deles disparou:

cancel.tsTS
const user = new AbortController();
const signal = AbortSignal.any([user.signal, AbortSignal.timeout(20_000)]);
// stopButton.onclick = () => user.abort();

Abortar importa por um motivo além de organização: os tokens estão sendo gerados e cobrados enquanto você não está ouvindo. O Chapter 16 coloca um preço nisso.

Agora a falha que custa dinheiro, não tempo. Uma requisição dá timeout no client, e o movimento óbvio é enviá-la de novo — mas um timeout não diz nada sobre se o servidor a recebeu. Com muita frequência recebeu, e ainda está trabalhando.

Medido. O provider mock precisa de 780 ms para a resposta. O client desiste em 300 ms e tenta de novo. O servidor conta quantas respostas ele realmente gerou, que é o que ele cobraria:

TEXT
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): 1

Sem uma chave: duas gerações completas, pagas duas vezes, e o client não recebeu nenhuma delas. Com uma chave: o servidor reconheceu a segunda requisição como a mesma requisição e respondeu instantaneamente com a resposta que já havia produzido, então a retentativa evitou a cobrança dupla e foi a tentativa que finalmente deu certo.

Uma idempotency key é uma string única que você gera por operação lógica — não por tentativa — e envia sem mudar em toda retentativa dela. O servidor armazena o resultado associado à chave e o reproduz. É o mecanismo que APIs de pagamento usam, pelo mesmo motivo.4

idempotent.tsTS
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 todo provider dá suporte a idempotency keys em completions, e quando o endpoint não é idempotente, o número correto de retentativas para um POST que talvez já tenha rodado é zero. E um stream que falhou pela metade não é reproduzível no caso geral: você o reinicia e paga de novo, ou mantém o texto parcial e o marca como incompleto. Qual dessas opções seu produto escolhe é uma decisão de produto, não de rede, e vale a pena tomá-la de propósito.

O client escrito neste capítulo não tem ideia do que está atrás da porta. Aponte sua base URL para um provider comercial e ele faz streaming de tokens de um model de um trilhão de parâmetros. Aponte-o para um servidor construído sobre a aritmética do Chapter 13 — servindo o model que você pré-treinou no Chapter 10, com seu KV cache e seus pesos quantizados — e o mesmo código, inalterado, faz streaming de tokens de um model que você construiu.

switch.tsTS
const BASE = process.env.LLM_BASE_URL ?? "http://127.0.0.1:8000/v1";  

Essa única linha é a emenda deste curso. De um lado dela está o que os treze primeiros capítulos construíram; do outro, o que os próximos dezesseis constroem. A fronteira é limpa porque o contrato é HTTP e SSE, e nenhum lado sabe mais nada sobre o outro.

Vale notar o que você perdeu ao atravessar. Atrás de um endpoint comercial, você não controla os pesos, nem a implementação de amostragem, nem a versão com que está falando, nem se ela mudou esta manhã. O que você controla é o contrato: as mensagens que envia, o prazo que define, os códigos que distingue e o que faz quando nada volta. Essa é uma superfície menor do que você tinha no Chapter 5, e todo capítulo restante é sobre usá-la bem.

Agora você tem um client que faz streaming, desiste no prazo, tenta de novo as coisas certas e nunca tenta de novo as erradas. O que ele envia ainda é simplesmente o que você digitou.

O Chapter 15 é sobre esse conteúdo, e vem com uma disciplina. A internet está cheia de conselhos de prompting — ofereça uma gorjeta ao model, ameace-o, diga para respirar fundo — e quase nenhum vem com uma medição. Algumas dessas técnicas mexem muito no output, algumas não mexem em nada, 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 debate.

Então o próximo capítulo constrói uma bancada: sessenta casos com respostas conhecidas, quatro variantes do mesmo prompt, rodadas em paralelo exatamente pelo client que você acabou de escrever, tabuladas com os intervalos de confiança do Chapter 4 — porque quatro variantes em vinte casos não distinguem absolutamente nada. Uma frase governa o capítulo inteiro: um prompt é medido, não debatido.


Todos os números acima vieram do provider mock, em Node 22 sobre uma interface de loopback, então as latências são mais limpas do que qualquer rede real vai oferecer. Isso é deliberado: nenhuma das falhas medidas é causada pela rede, e um servidor hostil que você pode reiniciar ensina melhor do que um real pelo qual você precisa pagar e que não pode quebrar.

  1. Server-Sent Events, WHATWG HTML Living Standard, seção 9.2. O formato no fio — campos data:, eventos separados por linha em branco, id: e retry: — é definido ali, junto com a interface EventSource. EventSource não consegue enviar um corpo de requisição nem headers customizados, por isso todo client LLM faz parse do formato à mão sobre fetch em vez de usá-lo.

  2. Brooker, M. Exponential Backoff and Jitter. AWS Architecture Blog (2015). A fonte da formulação de “full jitter” usada acima, com as simulações que mostram por que a versão ingênua sincroniza clients. O argumento complementar para descartar carga em vez de enfileirá-la é o capítulo Handling Overload de Beyer, Jones, Petoff e Murphy (eds.), Site Reliability Engineering (O'Reilly, 2016).

  3. Fielding, R., Nottingham, M. e Reschke, J. (eds.), HTTP Semantics, RFC 9110, seção 15, define as classes de códigos de status; Nottingham, M. e Fielding, R., Additional HTTP Status Codes, RFC 6585 (2012), seção 4, define 429 Too Many Requests. Retry-After é a seção 10.2.3 da RFC 9110, e aceita tanto um número de segundos quanto uma data HTTP.

  4. Stripe, Idempotent requests, docs.stripe.com/api/idempotent_requests, lido em 7 de setembro de 2026 — a declaração mais clara do contrato: uma chave por operação lógica, resultados armazenados reproduzidos, um conflito retornado enquanto a primeira tentativa ainda está em andamento — e o padrão é independente de provider. As referências normativas para os formatos de requisição e evento usados aqui são developers.openai.com/api/reference/resources/chat para streaming, códigos de erro e rate limits, e platform.claude.com/docs/en/api/messages para a Messages API; ai-sdk.dev/docs é o melhor exemplo trabalhado das mesmas preocupações encapsuladas em uma biblioteca. Todos lidos no mesmo dia.

Pronto para deixar a LIA escolher por você?

Crie com todos os modelos de IA em um só lugar — comece grátis hoje.