Saltar ao contido
14/30Capítulo 14 de 30

A túa primeira chamada LLM en produción: streaming, reintentos e timeouts

Crea un provedor que che mente — 429, sockets colgados, streams cortados — e mide que fai o teu cliente. Full jitter: 2,2 s fronte a 226.

Nesta páxina

O capítulo 13 rematou cun cronómetro sobre un modelo que podías tocar. Os pesos estaban na túa memoria, o KV cache podíalo activar ou desactivar ti, e o número que saía — tempo ata o primeiro token — era unha propiedade do teu hardware.

Agora pon ese modelo detrás dun porto, que é o que fai calquera produto, e le de novo o mesmo número. Segue sendo tempo ata o primeiro token, pero xa non é unha propiedade de nada que controles. Agora inclúe un handshake TLS, unha cola no provedor, un limitador de taxa e a posibilidade de que non chegue ningún token en absoluto.

Esa última cláusula é o capítulo. O código que estás a piques de escribir non calcula nada. Abre unha conexión, agarda, analiza o que chega, decide que facer cando non chega nada, volve decidir cando o que chega é un erro e cancélase a si mesmo cando o usuario cambia de idea. Cada unha desas cousas é unha decisión sobre estado ao longo do tempo, e cada unha ten unha resposta incorrecta que se envía a produción e custa cartos.

Esta é a forma do problema, medida, toda ela neste capítulo:

que pasouque fai un cliente descoidadoque custa
o servidor aceptou o socket e nunca respondeuagarda300,8 s antes de que Node se renda pola súa conta
a chave era incorrecta (401)reintenta cinco veces6.325 ms de atraso, e logo o mesmo 401
cen clientes bateron co límite de taxa á veztodos reintentan co mesmo horario226 s para baleirar, fronte a 2,2 s
a solicitude esgotou o tempo e reenviousereenvíaao provedor xera — e factura — a resposta dúas veces
a conexión caeu a media respostamostra o texto parcialindistinguible dunha resposta curta correcta

Ningunha destas cousas é un problema de modelaxe. Todas están nas primeiras cen liñas de calquera produto LLM que se escribise nunca.

Le de novo esa táboa e pregúntate que tipo de programa describe. Mantén unha conexión aberta durante corenta segundos. Debe poder cancelarse desde un botón. Acumula unha resposta parcial que é válida para mostrar e inválida para gardar. E execútase nun proceso de servidor ou nun edge worker, ao lado do que renderiza a resposta, sostendo un socket.

Iso non é un notebook. Non é que Python non poida facelo — pode, e hai xente que o fai —, senón que todo o que construíron os trece capítulos anteriores era doutra clase. Os capítulos 1 a 13 tiñan pesos, gradients, logits e bytes do tokenizer. A partir de aquí, o código ten unha conexión, un reintento, unha cancelación, estado acumulado e, máis adiante, un prompt de permiso. O curso cambia de linguaxe exactamente na costura onde cambia o obxecto.

Así que a regra, escrita unha vez:

Se o código ten pesos, gradients, logits ou bytes do tokenizer nas mans, é Python. Se sostén unha conexión, reintenta, cancela, acumula estado e pide permiso, é TypeScript.

A costura é única e cae aquí, entre o capítulo 13 e o capítulo 14. Tres criterios independentes póñena aquí.

Un: o ecosistema, contado. Todo o que cita a metade esquerda deste curso é Python, e entre os doce cursos auditados para este temario non hai nin un precedente de backpropagation ensinada noutra linguaxe: micrograd (17,4K estrelas), nanoGPT (62,8K), nanochat (57,8K), minbpe (10,7K), PyTorch (102,8K), transformers (164,9K). Escribir o capítulo 5 en TypeScript rompería a ligazón con esas fontes, e as ligazóns son a metade do valor dun capítulo que existe para ser referenciado máis que para posicionar. Neste lado a aritmética invértese: o paquete ai de Vercel está en 89,4M descargas ao mes e entrega a cousa en si — un loop de agent con tool calling, exportado como ToolLoopAgent — así que o concepto ao que chega este curso no capítulo 23 ten a súa implementación de referencia en TypeScript, aínda que, como mide ese capítulo, ninguén se puxo de acordo nun nome para el; Mastra está en 27,7K estrelas; e os SDK de Anthropic, xerados desde unha soa especificación, declaran 202 endpoints en TypeScript fronte a 201 en Python — paridade, non un port de cortesía.

Dous: a fonte normativa de MCP. O esquema da especificación do Model Context Protocol é un ficheiro schema.ts. Ensinar o protocolo do capítulo 26 noutra linguaxe significa ensinar unha tradución do seu documento fundacional.

Tres: demanda de busca, cunha corrección á suposición obvia. machine learning python é a frase máis saturada de internet; ai agent typescript ten a súa propia cola saudable. Pero «o ecosistema MCP é principalmente TypeScript» só é certo dependendo de como contes: o rexistro oficial lista 8.275 servidores en npm fronte a 3.603 en PyPI, mentres que por descargas gaña Python — 287M ao mes para mcp máis 72M para fastmcp fronte a 195M para @modelcontextprotocol/sdk. MCP é o único territorio realmente bilingüe aquí, e por iso o capítulo 27 escribe o mesmo servidor dúas veces en vez de finxir.

Mostrar detalles

As cinco excepcións declaradas, para que a regra sexa unha regra e non un slogan.

Os capítulos 17, 20 e 29 levan un segundo panel en Python: implementar mostraxe top-p necesita que teñas o vector de probabilidades na man e unha API HTTP nunca cho dá; poñer prezo a un fine-tune con honestidade significa executar un, e un adaptador LoRA son unha ducia de liñas de nn.Module; e lm-eval-harness, HELM, SWE-bench e τ-bench son Python, así que un harness de avaliación en TypeScript sería a imaxe especular do erro de backpropagation. O capítulo 27 é bilingüe, polo motivo medido de arriba. O capítulo 28 é Markdown, porque unha skill de agent é un ficheiro SKILL.md e darlle unha linguaxe de programación significaría non ter entendido o formato.

Os trece capítulos en Python non se descartan. O que hai ao outro lado do porto é o que eles construíron, e a última sección de aquí conecta un cliente con iso.

Non podes aprender nada disto contra un provedor real. Non podes pedirlle un 429 nun momento escollido, nin un socket que acepte a túa conexión e nunca responda, nin un stream que pare no medio dunha palabra — e estarías pagando por cada experimento, cando os experimentos interesantes son os que executas cen veces.

Así que o primeiro programa desta metade do curso non é un cliente. É un servidor hostil: corenta liñas de Node simple que falan o mesmo protocolo de rede ca un endpoint de chat completions e se portan mal baixo demanda. Todos os números deste capítulo saíron del.

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);

Catro comportamentos hostís, unha liña cada un: /hang acepta o socket e nunca escribe nel; /401 rexeita a chave; a comprobación de capacidade produce un 429 auténtico cun header Retry-After auténtico cando xa hai tres solicitudes en voo; e ?cut=N abandona a resposta pola metade, ben reiniciando o socket ou — con &how=close — pechándoo de forma ordenada, o que resulta importar moitísimo. O resto é un stream Server-Sent Events real: un obxecto JSON por liña data:, unha liña en branco entre eventos, a cadea [DONE] ao final.1

Execútao, e o resto do capítulo é medición.

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 solicitude, e a chave que nunca sae do servidor

Ligazón á sección: O corpo da solicitude, e a chave que nunca sae do servidor

Unha solicitude de chat é unha lista de mensaxes, cada unha cun rol. Esa lista é o estado enteiro do modelo: non hai memoria entre chamadas, e todo o que queiras que o modelo saiba ten que estar dentro do array que envías esta vez. O capítulo 15 trata sobre que poñer nel e o capítulo 16 sobre canto custa, así que aquí só está a forma.

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,
};

Eses roles non son decoración. Renderízanse no template de chat do capítulo 11 antes de que o modelo vexa un só token, e por iso enviar o rol incorrecto degrada a resposta en silencio no canto de lanzar un erro.

Unha regra sen excepcións: a chave da API nunca viaxa ao cliente. Nin nunha variábel de contorno prefixada para o navegador, nin nunha constante de build-time, nin «temporalmente». Unha chave nun bundle é unha chave na factura doutra persoa en cuestión de días. O navegador fala co teu servidor, o teu servidor garda a chave e fala co provedor — e, como o teu servidor está no medio, tamén é o único lugar que pode medir o que gasta cada usuario, que é onde ten que vivir a contabilidade do capítulo 16.

Agora o experimento sobre o que está construído o capítulo. Unha pregunta, un provedor simulado que produce trece tokens a 60 ms cada un, tres formas de preguntar.

Primeiro, sen streaming. O cliente envía a solicitude e agarda polo corpo JSON completo.

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

Os dous números son iguais, e ese é todo o problema. Durante 791 ms o usuario ten un spinner, e ningunha palabra estivo dispoñible antes — o servidor tiña a resposta, byte a byte, e decidiu non dicir nada.

Segundo, con streaming. Mesmo servidor, mesma resposta, mesmo traballo total. A diferenza é un 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);
      }
    }
  }
}

Hai tres detalles aí que soportan carga e a maioría dos primeiros intentos saltan os tres. O buffer existe porque un chunk de rede non ten relación cun evento: un read() pode devolver medio evento, ou dous e medio. O flag { stream: true } existe porque un carácter UTF-8 multibyte pode partirse entre dous chunks, e sen el unha letra acentuada convértese nun carácter de substitución ao azar. E os eventos sepáranse por unha liña en branco, non por un salto de liña, que é por que o loop busca \n\n.

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

Doce veces máis rápido ata a primeira palabra, e dous milisegundos máis lento ata a última. O streaming non fai nada máis rápido. Cambia o que está a facer o usuario durante os mesmos 790 ms: ler en vez de agardar. Ese é todo o beneficio, é enorme, e é a razón pola que todos os produtos de chat fan streaming.

Terceiro, con vinte clientes á vez. O provedor simulado atende tres solicitudes á vez. Dispara 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 catro solicitudes, cincuenta e catro rexeitamentos. Ninguén perdeu nada, cada cliente recibiu o mesmo texto, e o único custo visíbel foi o tempo. Iso é unha política de reintentos funcionando. O resto deste capítulo trata das tres formas en que pode fallar no canto diso.

finish_reason, e dous finais que parecen iguais

Ligazón á sección: finish_reason, e dous finais que parecen iguais

Antes dos fallos, o campo que case todo o mundo ignora no primeiro pase. Todo stream remata cun evento que leva finish_reason. stop significa que o modelo decidiu que xa acabara. length significa que bateu co teito de tokens, así que a resposta está truncada a media frase e non é culpa do modelo. Capítulos posteriores engaden tool_calls (capítulo 18) e filtros de contido.

Agora mira dous finais que un cliente inxenuo non pode distinguir. Mesmo servidor, mesmo atraso, un truncado por max_tokens e outro no que a conexión se pecha limpamente despois 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"

Le con coidado as dúas primeiras filas. Texto idéntico. Conta de chunks idéntica. Sen excepción en ningún dos dous casos. O loop for await rematou normalmente as dúas veces, porque desde o punto de vista do reader o corpo rematou e iso é todo o que pode facer un corpo. A única diferenza en toda a observación é que un leva finish_reason: "length" e o outro non leva nada.

Así que a regra non é «captura erros mentres fas streaming». É:

Un stream que remata sen un finish_reason non rematou. Parou.

Trata un finish_reason ausente como un fallo, sempre, e nunca persistas ese texto como unha resposta completada. A terceira fila mostra o caso máis doado: un socket destruído si lanza, e tamén perde o chunk que estaba en voo, por iso o texto é unha palabra máis curto ca os dous de arriba.

Cinco códigos de estado que son cinco problemas distintos

Ligazón á sección: Cinco códigos de estado que son cinco problemas distintos

O hábito máis caro que ten un produto novo é un único bloque catch para todo o que devolve o provedor. Estes códigos non son variacións de «fallou». Son cinco instrucións, e catro delas contradínse.

statusque significaque faceragardar?
400a túa solicitude está mal formada — JSON incorrecto, campo descoñecido, context demasiado longoarranxa o códigonunca
401a chave é incorrecta, falta ou foi revogadaarranxa o deploymentnunca
429límite de taxa: demasiadas solicitudes, ou demasiados tokens, por minutoreintentaRetry-After, logo backoff
500o provedor rompeureintentabackoff
503o provedor está sobrecargado — está en pé, está cheoreintentabackoff, e reduce carga

A liña que importa vai entre 4xx e o resto. Un 400 ou un 401 devolve exactamente a mesma resposta se o envías mil veces, porque nada cambia en ningún dos extremos entre intentos. Reintentalo non é prudencia, é un atraso con pasos extra. Medido: un cliente que fai seis intentos — cinco reintentos con backoff exponencial —, e outro que le primeiro o código.

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 unha resposta que estaba dispoñible en catro milisegundos. E esa é a versión suave: os reintentos nun produto adoitan estar aniñados — un cliente HTTP que reintenta dentro dun job runner que reintenta dentro dunha cola coa súa propia reentrega —, así que seis segundos convértense en seis minutos dun deployment permanentemente roto parecendo un lento.

A triage son nove liñas e pertence a un só 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
}

Dúas máis para a túa lista: 402, que algúns provedores usan para «quedaches sen crédito» e que necesita unha pantalla cunha ligazón para mercar máis en vez dun reintento, e 529 ou os seus equivalentes específicos de provedor, que se comportan como 503.

Reintentar é doado. Reintentar cando é a parte cunha resposta correcta medíbel.

O backoff exponencial é o estándar: agardar un atraso base, duplicalo despois de cada fallo, parar nun teito. Existe porque un servidor sobrecargado empeora se os clientes que acaban de fallar volven de inmediato.

O problema é que todo o mundo duplica desde o mesmo punto de partida. Se cen clientes baten cun límite no mesmo momento — e vano facer, porque iso é o que é un pico de tráfico — entón os cen agardan 200 ms, os cen reintentan xuntos, os cen fallan xuntos e os cen agardan 400 ms. O calendario de reintentos sincronizounos. Iso é unha thundering herd, e a aleatoriedade é a solución.2

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

Ese único cambio — escoller uniformemente dentro do intervalo en vez de tomar o seu extremo superior — chámase full jitter. É unha chamada a Math.random(), e paga a pena medilo en vez de crelo:

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);      

Cen clientes, un servidor que atende tres á vez, todo o demais idéntico, tres execucións cada un:

solicitudes HTTPrexeitamentospeor clientexanela de 50 ms máis ocupadatempo total
sen jitter, execución 149139110 intentos46 chegadas65,6 s
sen jitter, execución 278068019 intentos72 chegadas245,7 s
sen jitter, execución 377067018 intentos97 chegadas225,6 s
full jitter, execución 13242245 intentos32 chegadas2,2 s
full jitter, execución 23132136 intentos31 chegadas2,3 s
full jitter, execución 33182186 intentos25 chegadas1,8 s

Hai dúas cousas nesa táboa, e a segunda é a importante.

A primeira é a mediana: 226 segundos fronte a 2,2, un factor duns cen, con menos da metade de solicitudes. A xanela de reintento máis ocupada di por que. Sen jitter, ata 97 dos cen clientes chegaron dentro do mesmo slot de 50 milisegundos; o servidor tiña tres, así que 94 foron rexeitados e durmiron xuntos, aínda sincronizados, para facelo de novo cunha espera máis longa. Con jitter, os mesmos cen espalláronse polas mesmas xanelas en grupos duns trinta e baleiráronse case de inmediato.

A segunda é a varianza. Sen jitter: 65,6 s, 245,7 s, 225,6 s. Con el: 2,2, 2,3, 1,8. Un sistema sen jitter non só rende mal: rende de forma imprevisíbel, porque o resultado decídeno accidentes microscópicos de programación que escollen que tres de cen clientes sincronizados chegan primeiro. Esa é a sinatura deste bug en produción: un endpoint que vai ben, ben, ben, e logo tarda catro minutos, sen que ningún cambio teu o explique.

E o reintento máis barato é o que nunca ocorre. Pon unha porta de concorrencia diante do provedor — un contador que nunca deixa que haxa máis de N solicitudes en voo — e os mesmos vinte clientes que necesitaron 74 solicitudes e 7,1 segundos compórtanse así:

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

Vinte solicitudes para vinte respostas, cero rexeitamentos, oito veces máis rápido. Un reintento é a desculpa; a porta é non necesitala.

Cando un provedor devolve 429 normalmente diche canto agardar, no header Retry-After.3 Ese número non é un consello: o provedor é a única parte do intercambio que sabe cando se reinicia a súa xanela.

Así que a espera é a maior das dúas: nunca menos ca Retry-After, e nunca menos ca o teu propio backoff tampouco, porque o header che di cando o limitador che perdoa e non cando o servidor ten sitio.

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));  

A traza do cliente con peor sorte na execución de vinte clientes mostra o header facendo o seu traballo. As súas catro primeiras tiradas de backoff estiveron todas por debaixo dun segundo, e as catro foron sobrescritas:

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

Dúas notas prácticas. Retry-After pode ser unha data HTTP en vez dun número de segundos, así que analiza as dúas. E os provedores aplican rate-limit en dous eixes á vez — solicitudes por minuto e tokens por minuto —, que é por que os prompts longos son rexeitados moi por debaixo do límite de solicitudes documentado. O header ten o mesmo aspecto nos dous casos; a solución non.

Pídelle ao provedor simulado /hang. Acepta a conexión e logo non fai nada en absoluto: sen headers, sen corpo, sen peche. Isto non é exótico: é o que fai un balanceador de carga cando o proceso que hai detrás morreu sen pechar os seus sockets.

Dous clientes, unha diferenza:

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

Trescentos segundos. Cinco minutos cun socket aberto, un slot de solicitude ocupado e un usuario mirando para un spinner, rematando nun TypeError xenérico que non di nada sobre o que pasou. Ese número non é un bug: é o timeout de headers predeterminado de Node, razoábel para un cliente HTTP xenérico e catastrófico para unha solicitude de cara ao usuario. Cada runtime ten un predeterminado así, a maioría da xente nunca o consulta, e a única forma de atopar o teu é colgar un socket a propósito como acabamos de facer.

Así que: cada solicitude saínte recibe un deadline explícito, escollido por ti.

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 unha chamada con streaming un deadline non abonda, porque hai dous fallos distintos. O primeiro é o stream nunca abre: non chega ningún evento, e entre dez e trinta segundos é correcto. O segundo é o stream abre e logo queda parado: fluíron tokens e logo pararon, para sempre, co socket aínda san. Un timeout de duración total non pode distinguir un stream parado dunha resposta longa correcta, así que o que queres é un idle timeout — un temporizador que se reinicia con cada evento e salta só cando non chegou nada durante, digamos, quince segundos.

A cancelación é a mesma maquinaria apuntada a unha persoa. AbortSignal.timeout e un usuario premendo Deter chegan ambos como un AbortError, así que combínaos e rexistra cal foi o que saltou:

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

Abortar importa por unha razón alén da orde: os tokens estanse xerando e facturando mentres ti non estás escoitando. O capítulo 16 ponlle prezo a iso.

Agora o fallo que custa cartos en vez de tempo. Unha solicitude esgota o tempo no cliente, e o movemento obvio é enviala de novo — pero un timeout non che di nada sobre se o servidor a recibiu. Moi a miúdo recibiuna, e segue traballando.

Medido. O provedor simulado necesita 780 ms para a resposta. O cliente rende aos 300 ms e reintenta. O servidor conta cantas respostas xerou realmente, que é o que facturaría:

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

Sen chave: dúas xeracións completas, pagadas dúas veces, e o cliente non recibiu ningunha delas. Con chave: o servidor recoñeceu a segunda solicitude como a mesma solicitude e respondeu ao instante coa resposta que xa producira, así que o reintento evitou o dobre cargo e foi o intento que finalmente tivo éxito.

Unha idempotency key é unha cadea única que xeras por operación lóxica — non por intento — e envías sen cambiar en cada reintento dela. O servidor garda o resultado contra a chave e reprodúceo. É o mecanismo que usan as API de pagos, pola mesma razón.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");
}

Dous límites honestos. Non todos os provedores soportan idempotency keys en completions, e onde o endpoint non é idempotente, o número correcto de reintentos para un POST que pode xa terse executado é cero. E un stream que fallou pola metade non é reproducíbel no caso xeral: ou o reinicias e pagas de novo, ou gardas o texto parcial e márcalo como incompleto. Cal desas dúas cousas fai o teu produto é unha decisión de produto, non de rede, e paga a pena tomala a propósito.

O cliente escrito neste capítulo non ten nin idea do que hai detrás do porto. Apunta o seu URL base a un provedor comercial e fai streaming de tokens desde un modelo dun billón de parámetros. Apúntao a un servidor construído sobre a aritmética do capítulo 13 — servindo o modelo que preadestraches no capítulo 10, co seu KV cache e os seus pesos cuantizados — e o mesmo código, sen cambios, fai streaming de tokens desde un modelo que construíches ti.

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

Esa única liña é a costura deste curso. Dun lado está o que construíron os primeiros trece capítulos; do outro, o que constrúen os dezaseis seguintes. A fronteira é limpa porque o contrato é HTTP e SSE, e ningún lado sabe nada máis do outro.

Paga a pena reparar no que perdiches ao cruzar. Detrás dun endpoint comercial non controlas nin os pesos, nin a implementación de mostraxe, nin a versión coa que estás a falar, nin se cambiou esta mañá. O que controlas é o contrato: as mensaxes que envías, o deadline que marcas, os códigos que distingues e o que fas cando non volve nada. É unha superficie máis pequena ca a que tiñas no capítulo 5, e todos os capítulos que quedan tratan de usala ben.

Agora tes un cliente que fai streaming, rende a tempo, reintenta as cousas correctas e nunca reintenta as incorrectas. O que envía segue sendo o que escribiches.

O capítulo 15 trata sobre ese contido, e vén cunha disciplina. Internet está chea de consellos de prompting — ofrecerlle unha propina ao modelo, ameazalo, dicirlle que respire fondo — e case ningún chega cunha medición. Algunhas desas técnicas moven moito a saída, outras non a moven nada, e polo menos unha empeora unha tarefa de clasificación mentres custa máis tokens. Cal é cal non é obvio ao lelas, e non se resolve discutindo.

Así que o seguinte capítulo constrúe un banco: sesenta casos con respostas coñecidas, catro variantes do mesmo prompt, executadas en paralelo exactamente a través do cliente que acabas de escribir, tabuladas cos intervalos de confianza do capítulo 4 — porque catro variantes sobre vinte casos non distinguen nada en absoluto. Unha frase goberna todo o capítulo: un prompt mídese, non se debate.


Todos os números de arriba saíron do provedor simulado, en Node 22 sobre unha interface loopback, así que as latencias son máis limpas do que che dará calquera rede real. É deliberado: ningún dos fallos medidos é causado pola rede, e un servidor hostil que podes reiniciar ensina mellor ca un real polo que tes que pagar e que non podes romper.

  1. Server-Sent Events, WHATWG HTML Living Standard, sección 9.2. O formato de rede — campos data:, eventos separados por liñas en branco, id: e retry: — defínese alí, xunto coa interface EventSource. EventSource non pode enviar un corpo de solicitude nin headers personalizados, que é por que todo cliente LLM analiza o formato á man sobre fetch en vez de usalo.

  2. Brooker, M. Exponential Backoff and Jitter. AWS Architecture Blog (2015). A fonte da formulación «full jitter» usada arriba, coas simulacións que mostran por que a versión inxenua sincroniza clientes. O argumento compañeiro para reducir carga no canto de poñela en cola é 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, sección 15, define as clases de códigos de estado; Nottingham, M. e Fielding, R., Additional HTTP Status Codes, RFC 6585 (2012), sección 4, define 429 Too Many Requests. Retry-After é RFC 9110 sección 10.2.3, e acepta tanto un número de segundos como unha data HTTP.

  4. Stripe, Idempotent requests, docs.stripe.com/api/idempotent_requests, lido o 7 de setembro de 2026 — a declaración máis clara do contrato: unha chave por operación lóxica, resultados gardados que se reproducen, un conflito devolto mentres o primeiro intento segue en voo — e o patrón é independente do provedor. As referencias normativas para as formas de solicitude e evento usadas aquí son developers.openai.com/api/reference/resources/chat para streaming, códigos de erro e límites de taxa, e platform.claude.com/docs/en/api/messages para a Messages API; ai-sdk.dev/docs é o mellor exemplo traballado das mesmas preocupacións envoltas nunha biblioteca. Todas lidas o mesmo día.

Listo para deixar que LIA escolla por ti?

Crea con todos os modelos de IA nun só sitio: empeza gratis hoxe mesmo.