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 passat | què fa un client descuidat | què costa |
|---|---|---|
| el servidor ha acceptat el socket i no ha respost mai | espera | 300,8 s abans que Node es rendeixi pel seu compte |
| la clau era incorrecta (401) | ho reintenta cinc vegades | 6.325 ms de retard, i després el mateix 401 |
| cent clients arriben al límit de taxa alhora | tots reintenten amb el mateix calendari | 226 s per buidar-se, contra 2,2 s |
| la petició ha fet timeout i s’ha reenviat | la reenvia | el proveïdor genera —i factura— la resposta dues vegades |
| la connexió cau a mitja resposta | mostra el text parcial | indistingible 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.
Per què aquest capítol canvia de llenguatge
Enllaç a la secció: Per què aquest capítol canvia de llenguatgeTorna 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.
Un proveïdor que pots trencar
Enllaç a la secció: Un proveïdor que pots trencarNo 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.
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.
node mock-provider.mjs &
curl -N "http://127.0.0.1:8787/v1/chat?max_tokens=3"data: {"choices":[{"delta":{"content":"A"},"finish_reason":null}]}
data: {"choices":[{"delta":{"content":" tide"},"finish_reason":null}]}
data: {"choices":[{"delta":{"content":" gauge"},"finish_reason":null}]}
data: {"choices":[{"delta":{},"finish_reason":"length"}]}
data: [DONE]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 servidorUna 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.
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.
La mateixa pregunta, tres vegades
Enllaç a la secció: La mateixa pregunta, tres vegadesAra 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.
blocking first visible = 791 ms complete = 791 ms finish_reason = stopEls 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.
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.
streaming first visible = 65 ms complete = 793 ms finish_reason = stopDotze 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:
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: trueVint 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 igualsAbans 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:
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_reasonno 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 diferentsL’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.
| estat | què vol dir | què fer | esperar? |
|---|---|---|---|
| 400 | la teva petició està mal formada: JSON dolent, camp desconegut, context massa llarg | arregla el codi | mai |
| 401 | la clau és incorrecta, falta o ha estat revocada | arregla el deployment | mai |
| 429 | límit de taxa: massa peticions, o massa tokens, per minut | reintenta | Retry-After, després backoff |
| 500 | el proveïdor s’ha trencat | reintenta | backoff |
| 503 | el proveïdor està sobrecarregat: està actiu, està ple | reintenta | backoff, 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.
retry everything -> 6 requests, gave up after 6,325 ms, still HTTP 401
triage first -> 1 request, gave up after 4 ms, still HTTP 401Sis 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:
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.
Backoff, i què compra realment el jitter
Enllaç a la secció: Backoff, i què compra realment el jitterReintentar é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
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:
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 HTTP | rebuigs | pitjor client | finestra de 50 ms més ocupada | temps de paret | |
|---|---|---|---|---|---|
| sense jitter, execució 1 | 491 | 391 | 10 intents | 46 arribades | 65,6 s |
| sense jitter, execució 2 | 780 | 680 | 19 intents | 72 arribades | 245,7 s |
| sense jitter, execució 3 | 770 | 670 | 18 intents | 97 arribades | 225,6 s |
| full jitter, execució 1 | 324 | 224 | 5 intents | 32 arribades | 2,2 s |
| full jitter, execució 2 | 313 | 213 | 6 intents | 31 arribades | 2,3 s |
| full jitter, execució 3 | 318 | 218 | 6 intents | 25 arribades | 1,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í:
client-side gate of 3: 20 HTTP requests, 0 429s, wall 883 msVint 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.
Retry-After és un mínim, no un suggeriment
Enllaç a la secció: Retry-After és un mínim, no un suggerimentQuan 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.
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:
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 msDues 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.
El timeout que ningú va triar
Enllaç a la secció: El timeout que ningú va triarDemana 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:
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_TIMEOUTTres-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.
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:
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.
Què és segur reintentar
Enllaç a la secció: Què és segur reintentarAra 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:
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): 1Sense 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
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.
Tancar la costura
Enllaç a la secció: Tancar la costuraEl 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.
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é.
Cap a on va això ara
Enllaç a la secció: Cap a on va això araAra 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.
Fonts i mètode
Enllaç a la secció: Fonts i mètodeTots 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.
Referències
Enllaç a la secció: Referències-
Server-Sent Events, WHATWG HTML Living Standard, secció 9.2. El format de cable —camps
data:, esdeveniments separats per línies en blanc,id:iretry:— s’hi defineix, juntament amb la interfícieEventSource.EventSourceno pot enviar un cos de petició ni capçaleres personalitzades, que és per això que tots els clients LLM analitzen el format a mà sobrefetchen lloc de fer-lo servir. ↩ -
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). ↩
-
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. ↩ -
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óndevelopers.openai.com/api/reference/resources/chatper a streaming, codis d’error i límits de taxa, iplatform.claude.com/docs/en/api/messagesper 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. ↩