Ves al contingut
14/30Capítol 14 de 30

La teva primera crida LLM en producció: streaming, reintents i timeouts

Construeix un proveïdor que et menteixi: 429, sockets penjats i streams tallats. Full jitter: 2,2 segons contra 226.

En aquesta pàgina

El capítol 13 acabava amb un cronòmetre sobre un model que podies tocar. Els pesos eren a la teva memòria, la KV cache era teva per activar-la o desactivar-la, i el número que en sortia —temps fins al primer token— era una propietat del teu hardware.

Ara posa aquest model darrere d’un port, que és el que fa qualsevol producte, i torna a llegir el mateix número. Continua sent el temps fins al primer token, però ja no és una propietat de res que controlis. Ara inclou un handshake TLS, una cua al proveïdor, un limitador de taxa i la possibilitat que no arribi mai cap token.

Aquesta última clàusula és el capítol. El codi que estàs a punt d’escriure no calcula res. Obre una connexió, espera, interpreta el que arriba, decideix què fer quan no arriba res, torna a decidir quan el que arriba és un error, i es cancel·la quan l’usuari canvia d’opinió. Cadascuna d’aquestes coses és una decisió sobre estat al llarg del temps, i cadascuna té una resposta equivocada que arriba a producció i costa diners.

Aquesta és la forma del problema, mesurada, tota ella en aquest capítol:

què ha passatquè fa un client descuidatquè costa
el servidor ha acceptat el socket i no ha respost maiespera300,8 s abans que Node es rendeixi pel seu compte
la clau era incorrecta (401)ho reintenta cinc vegades6.325 ms de retard, i després el mateix 401
cent clients arriben al límit de taxa alhoratots reintenten amb el mateix calendari226 s per buidar-se, contra 2,2 s
la petició ha fet timeout i s’ha reenviatla reenviael proveïdor genera —i factura— la resposta dues vegades
la connexió cau a mitja respostamostra el text parcialindistingible d’una resposta curta correcta

Cap d’aquests és un problema de modelatge. Tots són a les primeres cent línies de qualsevol producte LLM mai escrit.

Torna a llegir aquella taula i pregunta’t quin tipus de programa descriu. Manté una connexió oberta durant quaranta segons. Ha de ser cancel·lable des d’un botó. Acumula una resposta parcial que és vàlida per mostrar i invàlida per desar. I s’executa en un procés de servidor o en un edge worker, al costat de la cosa que renderitza la resposta, mantenint un socket.

Això no és un notebook. No és que Python no ho pugui fer —pot, i la gent ho fa—, és que tot el que havien construït els tretze capítols anteriors era d’un altre tipus. Els capítols 1 a 13 tenien pesos, gradients, logits i bytes de tokenizer. A partir d’aquí el codi té una connexió, un reintent, una cancel·lació, estat acumulat i, més endavant, un prompt de permís. El curs canvia de llenguatge exactament a la costura on canvia l’objecte.

Així doncs, la regla, escrita una vegada:

Si el codi té pesos, gradients, logits o bytes de tokenizer a les mans, és Python. Si té una connexió, reintents, cancel·la, acumula estat i demana permís, és TypeScript.

La costura és única i cau aquí, entre el capítol 13 i el capítol 14. Tres criteris independents la situen aquí.

Un: l’ecosistema, comptat. Tot el que cita la meitat esquerra d’aquest curs és Python, i entre els dotze cursos auditats per a aquest temari no hi ha ni un precedent de backpropagation ensenyada en un altre llenguatge: micrograd (17,4K estrelles), nanoGPT (62,8K), nanochat (57,8K), minbpe (10,7K), PyTorch (102,8K), transformers (164,9K). Escriure el capítol 5 en TypeScript trencaria el vincle amb aquestes fonts, i els vincles són la meitat del valor d’un capítol que existeix per ser referenciat més que no pas per posicionar. En aquest costat, l’aritmètica s’inverteix: el paquet ai de Vercel té 89,4M de descàrregues al mes i inclou la cosa mateixa —un bucle d’agent amb tool calling, exportat com a ToolLoopAgent—, de manera que el concepte al qual arriba aquest curs al capítol 23 té la seva implementació de referència en TypeScript, tot i que, com mesura aquell capítol, ningú no n’ha acordat el nom; Mastra té 27,7K estrelles; i els SDK d’Anthropic, generats a partir d’una sola especificació, declaren 202 endpoints en TypeScript contra 201 en Python: paritat, no un port de cortesia.

Dos: la font normativa de MCP. L’esquema de l’especificació del Model Context Protocol és un fitxer schema.ts. Ensenyar el protocol del capítol 26 en un altre llenguatge vol dir ensenyar una traducció del seu document fundacional.

Tres: demanda de cerca, amb una correcció a la suposició òbvia. machine learning python és la frase més saturada d’internet; ai agent typescript té la seva pròpia cua saludable. Però «l’ecosistema MCP és sobretot TypeScript» només és cert segons com ho comptis: el registre oficial llista 8.275 servidors a npm contra 3.603 a PyPI, mentre que per descàrregues guanya Python —287M al mes per mcp més 72M per fastmcp contra 195M per @modelcontextprotocol/sdk. MCP és l’únic territori genuïnament bilingüe aquí, i per això el capítol 27 escriu el mateix servidor dues vegades en lloc de fingir.

Mostra els detalls

Les cinc excepcions declarades, perquè la regla sigui una regla i no un eslògan.

Els capítols 17, 20 i 29 porten un segon panell en Python: implementar mostreig top-p requereix tenir el vector de probabilitats a la mà i una API HTTP mai te’n dona cap; posar preu a un fine-tune amb honestedat vol dir executar-ne un, i un adaptador LoRA són una dotzena de línies de nn.Module; i lm-eval-harness, HELM, SWE-bench i τ-bench són Python, així que un harness d’avaluació en TypeScript seria la imatge especular de l’error de backpropagation. El capítol 27 és bilingüe, pel motiu mesurat de més amunt. El capítol 28 és Markdown, perquè una skill d’agent és un fitxer SKILL.md i donar-li un llenguatge de programació voldria dir no haver entès el format.

Els tretze capítols en Python no es descarten. El que hi ha a l’altra banda del port és el que han construït, i l’última secció d’aquí hi connecta un client.

No pots aprendre res d’això contra un proveïdor real. No pots demanar-li un 429 en un moment triat, ni un socket que accepti la connexió i no respongui mai, ni un stream que s’aturi a mitja paraula; i pagaries per cada experiment, quan els experiments interessants són els que executes cent vegades.

Així que el primer programa d’aquesta meitat del curs no és un client. És un servidor hostil: quaranta línies de Node pla que parlen el mateix protocol de cable que un endpoint de chat completions i es comporten malament a demanda. Tots els números d’aquest capítol en surten.

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

Quatre comportaments hostils, una línia cadascun: /hang accepta el socket i no hi escriu mai; /401 rebutja la clau; la comprovació de capacitat produeix un 429 genuí amb una capçalera Retry-After genuïna quan ja hi ha tres peticions en curs; i ?cut=N abandona la resposta a mig camí, sigui reiniciant el socket o —amb &how=close— tancant-lo de manera ordenada, cosa que resulta importar molt. La resta és un stream Server-Sent Events real: un objecte JSON per línia data:, una línia en blanc entre esdeveniments, la cadena [DONE] al final.1

Executa’l, i la resta del capítol és mesura.

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]

El cos de la petició, i la clau que mai no surt del servidor

Enllaç a la secció: El cos de la petició, i la clau que mai no surt del servidor

Una petició de xat és una llista de missatges, cadascun amb un rol. Aquesta llista és tot l’estat del model: no hi ha memòria entre crides, i tot el que vols que el model sàpiga ha de ser dins de l’array que envies aquesta vegada. El capítol 15 tracta de què posar-hi i el capítol 16 de què costa, així que aquí només n’importa la 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,
};

Aquests rols no són decoració. Es renderitzen dins de la plantilla de xat del capítol 11 abans que el model vegi ni un sol token, i per això enviar el rol incorrecte degrada silenciosament la resposta en lloc de generar un error.

Una regla sense excepcions: la clau API no viatja mai al client. Ni en una variable d’entorn amb prefix per al navegador, ni en una constant de build-time, ni «temporalment». Una clau dins d’un bundle és una clau en la factura d’algú altre en qüestió de dies. El navegador parla amb el teu servidor, el teu servidor guarda la clau i parla amb el proveïdor; i com que el teu servidor és al mig, també és l’únic lloc que pot mesurar què gasta cada usuari, que és on ha de viure la comptabilitat del capítol 16.

Ara l’experiment sobre el qual es construeix el capítol. Una pregunta, un proveïdor simulat que produeix tretze tokens a 60 ms cadascun, tres maneres de preguntar.

Primer, sense streaming. El client envia la petició i espera el cos JSON sencer.

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

Els dos números són iguals, i aquest és tot el problema. Durant 791 ms l’usuari té un spinner, i no hi havia cap paraula disponible abans: el servidor tenia la resposta, byte a byte, i va triar no dir res.

Segon, amb streaming. El mateix servidor, la mateixa resposta, la mateixa feina total. La diferència és 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);
      }
    }
  }
}

Hi ha tres detalls que suporten la càrrega i la majoria de primers intents se salten tots tres. El buffer existeix perquè un chunk de xarxa no té cap relació amb un esdeveniment: un read() pot retornar mig esdeveniment, o dos i mig. El flag { stream: true } existeix perquè un caràcter UTF-8 multibyte es pot partir entre dos chunks, i sense ell una lletra accentuada es converteix aleatòriament en un caràcter de reemplaçament. I els esdeveniments se separen per una línia en blanc, no per un salt de línia, que és per això que el bucle busca \n\n.

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

Dotze vegades més ràpid fins a la primera paraula, i dos mil·lisegons més lent fins a l’última. El streaming no fa res més ràpid. Canvia el que fa l’usuari durant els mateixos 790 ms: llegir en lloc d’esperar. Aquest és tot el benefici, és enorme, i és el motiu pel qual tots els productes de xat fan streaming.

Tercer, amb vint clients alhora. El proveïdor simulat serveix tres peticions alhora. Dispara’n vint:

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

Vint respostes, setanta-quatre peticions, cinquanta-quatre rebuigs. Ningú no va perdre res, cada client va obtenir el mateix text, i l’únic cost visible va ser temps. Això és una política de reintents que funciona. La resta d’aquest capítol tracta de les tres maneres en què pot fallar.

finish_reason, i dos finals que semblen iguals

Enllaç a la secció: finish_reason, i dos finals que semblen iguals

Abans de les fallades, el camp que gairebé tothom ignora a la primera passada. Cada stream acaba amb un esdeveniment que porta finish_reason. stop vol dir que el model ha decidit que havia acabat. length vol dir que ha arribat al sostre de tokens, així que la resposta queda truncada a mitja frase i no és culpa del model. Capítols posteriors afegeixen tool_calls (capítol 18) i filtres de contingut.

Ara mira dos finals que un client ingenu no pot distingir. El mateix servidor, el mateix retard, un truncat per max_tokens i un en què la connexió es tanca netament després de cinc 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"

Llegeix les dues primeres files amb atenció. Text idèntic. Nombre de chunks idèntic. Cap excepció en cap dels dos casos. El bucle for await ha acabat normalment totes dues vegades, perquè des del punt de vista del reader el cos ha acabat i això és tot el que pot fer un cos. L’única diferència en tota l’observació és que un porta finish_reason: "length" i l’altre no porta res.

Així que la regla no és «captura errors mentre fas streaming». És:

Un stream que acaba sense un finish_reason no ha acabat. S’ha aturat.

Tracta un finish_reason absent com una fallada, sempre, i no persisteixis mai aquest text com una resposta completada. La tercera fila mostra el cas més fàcil: un socket destruït sí que llença una excepció, i també perd el chunk que estava en vol, que és per això que el text és una paraula més curt que els dos de dalt.

Cinc codis d’estat que són cinc problemes diferents

Enllaç a la secció: Cinc codis d’estat que són cinc problemes diferents

L’hàbit més car d’un producte nou és tenir un sol bloc catch per a tot el que retorna el proveïdor. Aquests codis no són variacions de «ha fallat». Són cinc instruccions, i quatre es contradiuen entre si.

estatquè vol dirquè feresperar?
400la teva petició està mal formada: JSON dolent, camp desconegut, context massa llargarregla el codimai
401la clau és incorrecta, falta o ha estat revocadaarregla el deploymentmai
429límit de taxa: massa peticions, o massa tokens, per minutreintentaRetry-After, després backoff
500el proveïdor s’ha trencatreintentabackoff
503el proveïdor està sobrecarregat: està actiu, està plereintentabackoff, i redueix càrrega

La línia important passa entre els 4xx i la resta. Un 400 o un 401 retorna exactament la mateixa resposta si l’envies mil vegades, perquè res no canvia a cap dels dos extrems entre intents. Reintentar-ho no és prudència, és un retard amb passos extres. Mesurat: un client que fa sis intents —cinc reintents amb backoff exponencial—, i un que llegeix el codi primer.

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

Sis segons de spinner per arribar a una resposta que estava disponible en quatre mil·lisegons. I aquesta és la versió suau: els reintents en un producte solen estar imbricats —un client HTTP que reintenta dins d’un job runner que reintenta dins d’una cua amb la seva pròpia redelivery—, de manera que sis segons es converteixen en sis minuts d’un deployment permanentment trencat que sembla lent.

El triatge són nou línies i ha de viure en un sol lloc:

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
}

Dos més per a la llista: 402, que alguns proveïdors fan servir per dir «t’has quedat sense crèdit» i que necessita una pantalla amb un enllaç per comprar-ne més en lloc d’un reintent, i 529 o els seus equivalents específics de venedor, que es comporten com 503.

Reintentar és fàcil. Reintentar quan és la part amb una resposta correcta mesurable.

El backoff exponencial és l’estàndard: espera un retard base, duplica’l després de cada fallada, atura’t en un sostre. Existeix perquè un servidor sobrecarregat empitjora si els clients que acaben de fallar tornen immediatament.

El problema és que tothom duplica des del mateix punt de partida. Si cent clients topen amb un límit en el mateix moment —i ho faran, perquè això és un pic de trànsit—, aleshores tots cent esperen 200 ms, tots cent reintenten junts, tots cent fallen junts, i tots cent esperen 400 ms. El calendari de reintents els ha sincronitzat. Això és una thundering herd, i l’atzar és la solució.2

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

Aquest únic canvi —triar uniformement dins de l’interval en lloc d’agafar-ne l’extrem superior— s’anomena full jitter. És una crida a Math.random(), i val la pena mesurar-ho en lloc de creure-s’ho:

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

Cent clients, un servidor que en serveix tres alhora, tota la resta idèntica, tres execucions cadascuna:

peticions HTTPrebuigspitjor clientfinestra de 50 ms més ocupadatemps de paret
sense jitter, execució 149139110 intents46 arribades65,6 s
sense jitter, execució 278068019 intents72 arribades245,7 s
sense jitter, execució 377067018 intents97 arribades225,6 s
full jitter, execució 13242245 intents32 arribades2,2 s
full jitter, execució 23132136 intents31 arribades2,3 s
full jitter, execució 33182186 intents25 arribades1,8 s

Hi ha dues coses en aquesta taula, i la segona és la important.

La primera és la mediana: 226 segons contra 2,2, un factor d’aproximadament cent, amb menys de la meitat de peticions. La finestra de reintents més ocupada explica per què. Sense jitter, fins a 97 dels cent clients arribaven dins del mateix slot de 50 mil·lisegons; el servidor en tenia tres, així que 94 eren rebutjats i anaven a dormir junts, encara sincronitzats, per tornar-hi amb una espera més llarga. Amb jitter, els mateixos cent es reparteixen per les mateixes finestres en grups d’uns trenta i es buiden gairebé immediatament.

La segona és la variància. Sense jitter: 65,6 s, 245,7 s, 225,6 s. Amb ell: 2,2, 2,3, 1,8. Un sistema sense jitter no només rendeix malament, rendeix de manera imprevisible, perquè el resultat el decideixen accidents microscòpics de planificació que trien quins tres de cent clients sincronitzats arriben primer. Aquesta és la signatura d’aquest bug en producció: un endpoint que va bé, bé, bé, i de cop triga quatre minuts, sense cap canvi teu que ho expliqui.

I el reintent més barat és el que no passa mai. Posa una porta de concurrència davant del proveïdor —un comptador que mai deixa que hi hagi més de N peticions en curs— i els mateixos vint clients que necessitaven 74 peticions i 7,1 segons es comporten així:

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

Vint peticions per a vint respostes, zero rebuigs, vuit vegades més ràpid. Un reintent és la disculpa; la porta és no necessitar-ne cap.

Quan un proveïdor retorna 429, normalment et diu quant has d’esperar, a la capçalera Retry-After.3 Aquest número no és un consell: el proveïdor és l’única part de l’intercanvi que sap quan es reinicia la seva finestra.

Així que l’espera és la més gran de les dues: mai menys de Retry-After, i tampoc mai menys que el teu propi backoff, perquè la capçalera et diu quan el limitador et perdona, no quan el servidor té espai.

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

La traça del client amb més mala sort de l’execució de vint clients mostra la capçalera fent la seva feina. Els seus primers quatre sorteigs de backoff eren tots per sota d’un segon, i tots quatre van ser sobreescrits:

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

Dues notes pràctiques. Retry-After pot ser una data HTTP en lloc d’un nombre de segons, així que analitza totes dues formes. I els proveïdors limiten la taxa en dos eixos alhora —peticions per minut i tokens per minut—, que és per això que els prompts llargs es rebutgen molt per sota del límit de peticions documentat. La capçalera sembla igual en tots dos casos; la solució no.

Demana al proveïdor simulat /hang. Accepta la connexió i després no fa absolutament res: capçalera, cos ni tancament. Això no és exòtic: és el que fa un balancejador de càrrega quan el procés que hi ha al darrere ha mort sense tancar els seus sockets.

Dos clients, una diferència:

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

Tres-cents segons. Cinc minuts amb un socket obert, un slot de petició ocupat i un usuari mirant un spinner, que acaben en un TypeError genèric que no diu res sobre què ha passat. Aquest número no és un bug: és el timeout de capçaleres per defecte de Node, raonable per a un client HTTP genèric i catastròfic per a una petició de cara a l’usuari. Tots els runtimes tenen un valor per defecte així, la majoria de gent no el consulta mai, i l’única manera de trobar el teu és penjar un socket expressament com acabem de fer.

Així doncs: cada petició sortint té una data límit explícita, triada per tu.

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

Per a una crida en streaming, una sola data límit no n’hi ha prou, perquè hi ha dues fallades diferents. La primera és el stream no s’obre mai: no arriba cap esdeveniment, i de deu a trenta segons és correcte. La segona és el stream s’obre i després queda encallat: els tokens fluïen i després s’aturen, per sempre, amb el socket encara sa. Un timeout de durada total no pot distingir un stream encallat d’una resposta llarga correcta, així que el que vols és un idle timeout: un temporitzador reiniciat per cada esdeveniment, que només dispara quan no ha arribat res durant, posem, quinze segons.

La cancel·lació és la mateixa maquinària apuntada a una persona. AbortSignal.timeout i un usuari prement Atura arriben tots dos com un AbortError, així que combina’ls i registra quin s’ha activat:

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

Avortar importa per una raó més enllà de l’ordre: els tokens s’estan generant i facturant mentre tu ja no escoltes. El capítol 16 hi posa un preu.

Ara la fallada que costa diners en lloc de temps. Una petició fa timeout al client, i el moviment obvi és enviar-la de nou; però un timeout no et diu res sobre si el servidor l’ha rebut. Molt sovint sí que l’ha rebut, i encara hi està treballant.

Mesurat. El proveïdor simulat necessita 780 ms per a la resposta. El client es rendeix als 300 ms i reintenta. El servidor compta quantes respostes ha generat realment, que és el que facturaria:

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

Sense clau: dues generacions completes, pagades dues vegades, i el client no en va rebre cap. Amb clau: el servidor va reconèixer la segona petició com la mateixa petició i va respondre instantàniament amb la resposta que ja havia produït, de manera que el reintent va evitar el doble càrrec i també va ser l’intent que finalment va tenir èxit.

Una clau d’idempotència és una cadena única que generes per operació lògica —no per intent— i envies sense canviar en cada reintent d’aquella operació. El servidor desa el resultat contra la clau i el reprodueix. És el mecanisme que fan servir les API de pagament, pel mateix motiu.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");
}

Dos límits honestos. No tots els proveïdors admeten claus d’idempotència en completions, i quan l’endpoint no és idempotent, el nombre correcte de reintents per a un POST que potser ja s’ha executat és zero. I un stream que ha fallat a mig camí no és reproduïble en el cas general: o bé el reinicies i pagues de nou, o bé conserves el text parcial i el marques com a incomplet. Quina d’aquestes coses fa el teu producte és una decisió de producte, no de xarxa, i val la pena prendre-la expressament.

El client escrit en aquest capítol no té ni idea de què hi ha darrere del port. Apunta la seva URL base a un proveïdor comercial i farà streaming de tokens d’un model d’un bilió de paràmetres. Apunta-la a un servidor construït sobre l’aritmètica del capítol 13 —servint el model que vas preentrenar al capítol 10, amb la seva KV cache i els seus pesos quantitzats— i el mateix codi, sense canvis, farà streaming de tokens d’un model que has construït tu.

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

Aquesta sola línia és la costura d’aquest curs. A un costat hi ha el que han construït els primers tretze capítols; a l’altre, el que construiran els setze següents. La frontera és neta perquè el contracte és HTTP i SSE, i cap costat no sap res més de l’altre.

Val la pena adonar-se del que has perdut en creuar-la. Darrere d’un endpoint comercial no controles ni els pesos, ni la implementació de mostreig, ni la versió amb què estàs parlant, ni si ha canviat aquest matí. El que controles és el contracte: els missatges que envies, la data límit que estableixes, els codis que distingeixes, i què fas quan no torna res. És una superfície més petita que la que tenies al capítol 5, i tots els capítols restants tracten d’utilitzar-la bé.

Ara tens un client que fa streaming, es rendeix a temps, reintenta les coses correctes i no reintenta mai les equivocades. El que envia continua sent el que hagis escrit.

El capítol 15 tracta d’aquest contingut, i ve amb una disciplina. Internet és ple de consells de prompting —ofereix una propina al model, amenaça’l, digues-li que respiri fondo— i gairebé cap no arriba amb una mesura. Algunes d’aquestes tècniques mouen molt l’output, altres no el mouen gens, i almenys una empitjora una tasca de classificació mentre costa més tokens. Quina és quina no és obvi llegint-les, i no es resol discutint.

Així que el capítol següent construeix un banc de proves: seixanta casos amb respostes conegudes, quatre variants del mateix prompt, executades en paral·lel exactament pel client que acabes d’escriure, tabulades amb els intervals de confiança del capítol 4, perquè quatre variants sobre vint casos no distingeixen absolutament res. Una frase governa tot el capítol: un prompt es mesura, no es debat.


Tots els números de més amunt provenen del proveïdor simulat, en Node 22 sobre una interfície loopback, així que les latències són més netes del que et donarà qualsevol xarxa real. És deliberat: cap de les fallades que es mesuren no és causada per la xarxa, i un servidor hostil que pots reiniciar ensenya millor que un de real que has de pagar i no pots trencar.

  1. Server-Sent Events, WHATWG HTML Living Standard, secció 9.2. El format de cable —camps data:, esdeveniments separats per línies en blanc, id: i retry:— s’hi defineix, juntament amb la interfície EventSource. EventSource no pot enviar un cos de petició ni capçaleres personalitzades, que és per això que tots els clients LLM analitzen el format a mà sobre fetch en lloc de fer-lo servir.

  2. Brooker, M. Exponential Backoff and Jitter. AWS Architecture Blog (2015). La font de la formulació de «full jitter» utilitzada més amunt, amb les simulacions que mostren per què la versió ingènua sincronitza els clients. L’argument complementari per reduir càrrega en lloc de posar-la en cua és el capítol Handling Overload de Beyer, Jones, Petoff i Murphy (eds.), Site Reliability Engineering (O’Reilly, 2016).

  3. Fielding, R., Nottingham, M. i Reschke, J. (eds.), HTTP Semantics, RFC 9110, secció 15, defineix les classes de codis d’estat; Nottingham, M. i Fielding, R., Additional HTTP Status Codes, RFC 6585 (2012), secció 4, defineix 429 Too Many Requests. Retry-After és la secció 10.2.3 de l’RFC 9110, i accepta tant un nombre de segons com una data HTTP.

  4. Stripe, Idempotent requests, docs.stripe.com/api/idempotent_requests, llegit el 7 de setembre de 2026: l’enunciat més clar del contracte: una clau per operació lògica, resultats desats reproduïts, un conflicte retornat mentre el primer intent encara està en curs; i el patró és independent del proveïdor. Les referències normatives per a les formes de petició i d’esdeveniment utilitzades aquí són developers.openai.com/api/reference/resources/chat per a streaming, codis d’error i límits de taxa, i platform.claude.com/docs/en/api/messages per a l’API Messages; ai-sdk.dev/docs és el millor exemple desenvolupat de les mateixes preocupacions embolcallades en una llibreria. Tots llegits el mateix dia.

A punt per deixar que triï LIA?

Crea amb tots els models d'IA en un sol lloc — comença gratis avui mateix.