Dit første produktionskald til en LLM: streaming, retries og timeouts
Byg en provider, der lyver for dig — 429s, hængende sockets og streams skåret over — og mål, hvad din client gør.
På denne side
Kapitel 13 sluttede med et stopur på en model, du kunne røre ved. Vægtene lå i din hukommelse, KV cache var din at slå til eller fra, og tallet, der kom ud — time to first token — var en egenskab ved din hardware.
Sæt nu den model bag en port, sådan som alle produkter gør, og læs det samme tal igen. Det er stadig time to first token, men det er ikke længere en egenskab ved noget, du kontrollerer. Det omfatter nu et TLS-handshake, en kø hos provideren, en rate limiter og muligheden for, at der aldrig kommer nogen token overhovedet.
Den sidste sætning er kapitlet. Den kode, du skal til at skrive, beregner ikke noget. Den åbner en forbindelse, venter, parser det, der kommer, beslutter hvad den skal gøre, når der ikke kommer noget, beslutter igen, når det, der kommer, er en fejl, og annullerer sig selv, når brugeren ombestemmer sig. Hver af de ting er en beslutning om tilstand over tid, og hver har et forkert svar, der bliver sendt i produktion og koster penge.
Her er problemets form, målt, alt sammen i dette kapitel:
| hvad skete der | hvad en skødesløs client gør | hvad det koster |
|---|---|---|
| serveren accepterede socketen og svarede aldrig | venter | 300,8 s før Node giver op af sig selv |
| nøglen var forkert (401) | prøver igen fem gange | 6.325 ms forsinkelse, derefter samme 401 |
| hundrede clients rammer rate limit samtidig | alle retry efter samme tidsplan | 226 s til at tømme, mod 2,2 s |
| requesten fik timeout og blev sendt igen | sender den igen | provideren genererer — og fakturerer — svaret to gange |
| forbindelsen faldt midt i svaret | viser den delvise tekst | kan ikke skelnes fra et korrekt kort svar |
Intet af dette er et modelleringsproblem. Alt sammen ligger i de første hundrede linjer af ethvert LLM-produkt, der nogensinde er skrevet.
Hvorfor dette kapitel skifter sprog
Link til afsnittet: Hvorfor dette kapitel skifter sprogLæs tabellen igen, og spørg hvilken slags program den beskriver. Det holder en forbindelse åben i fyrre sekunder. Det skal kunne annulleres fra en knap. Det akkumulerer et delvist svar, der er gyldigt at vise og ugyldigt at gemme. Og det kører i en serverproces eller på en edge worker, ved siden af den ting, der renderer svaret, mens det holder en socket.
Det er ikke en notebook. Det er ikke, at Python ikke kan gøre det — det kan det, og folk gør det — det er, at alt det, de foregående tretten kapitler byggede, var af en anden slags. Kapitel 1 til 13 holdt vægte, gradients, logits og tokenizer-bytes. Herfra holder koden en forbindelse, en retry, en annullering, akkumuleret tilstand og senere et permission prompt. Kurset skifter sprog præcis ved den søm, hvor objektet skifter.
Så reglen, skrevet én gang:
Hvis koden har vægte, gradients, logits eller tokenizer-bytes i hænderne, er det Python. Hvis den holder en forbindelse, retries, annullerer, akkumulerer tilstand og beder om tilladelse, er det TypeScript.
Sømmen er én, og den falder her, mellem Kapitel 13 og Kapitel 14. Tre uafhængige kriterier placerer den her.
Ét: økosystemet, optalt. Alt, hvad venstre halvdel af dette kursus citerer, er Python, og på tværs af de tolv kurser, der er gennemgået til denne pensumplan, er der ikke ét fortilfælde for backpropagation undervist i et andet sprog: micrograd (17,4K stjerner), nanoGPT (62,8K), nanochat (57,8K), minbpe (10,7K), PyTorch (102,8K), transformers (164,9K). At skrive Kapitel 5 i TypeScript ville bryde forbindelsen til de kilder, og forbindelserne er halvdelen af værdien i et kapitel, der findes for at blive refereret til snarere end for at ranke. På denne side vender regnestykket: Vercels ai-pakke har 89,4 mio. downloads om måneden og shipper selve tingen — en tool-calling agent-loop, eksporteret som ToolLoopAgent — så det koncept, kurset når frem til i Kapitel 23, har sin referenceimplementering i TypeScript, selv om ingen, som det kapitel måler, er blevet enige om et navn til det; Mastra har 27,7K stjerner; og Anthropics SDK’er, genereret fra én specifikation, deklarerer 202 endpoints i TypeScript mod 201 i Python — paritet, ikke en høflighedsport.
To: den normative kilde til MCP. Model Context Protocol-specifikationens schema er en schema.ts-fil. At undervise protokollen fra Kapitel 26 i et andet sprog betyder at undervise en oversættelse af dens grundlæggende dokument.
Tre: søgeefterspørgsel, med en korrektion af det oplagte gæt. machine learning python er den mest mættede frase på internettet; ai agent typescript har sin egen sunde long tail. Men „MCP-økosystemet er mest TypeScript“ er kun sandt afhængigt af, hvordan du tæller: det officielle registry viser 8.275 servers på npm mod 3.603 på PyPI, mens Python vinder på downloads — 287 mio. om måneden for mcp plus 72 mio. for fastmcp mod 195 mio. for @modelcontextprotocol/sdk. MCP er det ene reelt tosprogede område her, og derfor skriver Kapitel 27 den samme server to gange i stedet for at lade som om.
Vis detaljer
De fem erklærede undtagelser, så reglen er en regel og ikke et slogan.
Kapitel 17, 20 og 29 har et andet panel i Python: implementering af top-p sampling kræver probability vector i hånden, og en HTTP API giver dig aldrig en; at prissætte en fine-tune ærligt betyder at køre en, og en LoRA-adapter er et dusin linjer nn.Module; og lm-eval-harness, HELM, SWE-bench og τ-bench er Python, så en evaluation harness i TypeScript ville være spejlbilledet af backpropagation-fejlen. Kapitel 27 er tosproget af den målte grund ovenfor. Kapitel 28 er Markdown, fordi en agent skill er en SKILL.md-fil, og at give den et programmeringssprog ville betyde, at man ikke havde forstået formatet.
De tretten Python-kapitler bliver ikke kasseret. Det, der er på den anden side af porten, er det, de byggede, og sidste afsnit her forbinder en client til det.
En provider du kan ødelægge
Link til afsnittet: En provider du kan ødelæggeDu kan ikke lære noget af dette mod en rigtig provider. Du kan ikke bede den om en 429 på et valgt tidspunkt, eller om en socket, der accepterer din forbindelse og aldrig svarer, eller om en stream, der stopper midt i et ord — og du ville betale for hvert eksperiment, når de interessante eksperimenter er dem, du kører hundrede gange.
Så det første program i denne halvdel af kurset er ikke en client. Det er en fjendtlig server: fyrre linjer ren Node, der taler den samme wire protocol som et chat completions-endpoint og opfører sig dårligt på kommando. Hvert tal i dette kapitel kom fra den.
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);Fire fjendtlige adfærdsmønstre, én linje hver: /hang accepterer socketen og skriver aldrig til den; /401 afviser nøglen; kapacitetstjekket producerer en ægte 429 med en ægte Retry-After-header, når tre requests allerede er in flight; og ?cut=N forlader svaret halvvejs, enten ved at nulstille socketen eller — med &how=close — ved at lukke den ordentligt, hvilket viser sig at betyde rigtig meget. Resten er en rigtig Server-Sent Events-stream: ét JSON-objekt per data:-linje, en tom linje mellem events, strengen [DONE] til sidst.1
Kør den, og resten af kapitlet er måling.
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]Request body og nøglen, der aldrig forlader serveren
Link til afsnittet: Request body og nøglen, der aldrig forlader serverenEn chat-request er en liste af messages, hver med en role. Den liste er modellens hele tilstand: der er ingen hukommelse mellem kald, og hvad du end vil have modellen til at vide, skal ligge inde i det array, du sender denne gang. Kapitel 15 handler om, hvad du skal putte i det, og Kapitel 16 handler om, hvad det koster, så her er det kun formen.
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,
};De roles er ikke pynt. De bliver renderet ind i chat template fra Kapitel 11, før modellen ser en eneste token, og derfor forringer den forkerte role svaret lydløst i stedet for at udløse en fejl.
Én regel uden undtagelser: API-nøglen rejser aldrig til clienten. Ikke i en environment variable med browser-prefix, ikke i en build-time constant, ikke „midlertidigt“. En nøgle i et bundle er en nøgle på en andens regning inden for få dage. Browseren taler med din server, din server holder nøglen og taler med provideren — og fordi din server er i midten, er den også det eneste sted, der kan måle, hvad hver bruger bruger, hvilket er dér regnskabet fra Kapitel 16 skal bo.
Det samme spørgsmål, tre gange
Link til afsnittet: Det samme spørgsmål, tre gangeNu eksperimentet, kapitlet er bygget på. Ét spørgsmål, én mock provider, der producerer tretten tokens ved 60 ms hver, tre måder at spørge på.
Først, uden streaming. Clienten sender requesten og venter på hele JSON body.
blocking first visible = 791 ms complete = 791 ms finish_reason = stopDe to tal er ens, og det er hele problemet. I 791 ms har brugeren en spinner, og ikke ét ord var tilgængeligt tidligere — serveren havde svaret, byte for byte, og valgte ikke at sige noget.
For det andet, med streaming. Samme server, samme svar, samme totale arbejde. Forskellen er en 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);
}
}
}
}Tre detaljer dér er bærende, og de fleste første forsøg springer alle tre over. buffer findes, fordi en netværks-chunk ikke har nogen relation til et event: én read() kan returnere et halvt event eller to og et halvt. { stream: true }-flaget findes, fordi et multi-byte UTF-8-tegn kan blive delt over to chunks, og uden det bliver et accentbogstav tilfældigt til et replacement character. Og events adskilles af en tom linje, ikke en newline, og derfor leder loopet efter \n\n.
streaming first visible = 65 ms complete = 793 ms finish_reason = stopTolv gange hurtigere til det første ord og to millisekunder langsommere til det sidste. Streaming gør intet hurtigere. Det ændrer, hvad brugeren laver i de samme 790 ms: læser i stedet for at vente. Det er hele gevinsten, den er enorm, og det er grunden til, at alle chatprodukter streamer.
For det tredje, med tyve clients på én gang. Mock provideren betjener tre requests ad gangen. Fyr tyve af:
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: trueTyve svar, fireoghalvfjerds requests, fireoghalvtreds afvisninger. Ingen mistede noget, hver client fik den samme tekst, og den eneste synlige omkostning var tid. Det er en retry policy, der virker. Resten af dette kapitel handler om de tre måder, den i stedet kan fejle på.
finish_reason og to slutninger, der ser ens ud
Link til afsnittet: finish_reason og to slutninger, der ser ens udFør fejlene: feltet næsten alle ignorerer første gang. Hver stream slutter med et event, der bærer finish_reason. stop betyder, at modellen besluttede, at den var færdig. length betyder, at den ramte token-loftet, så svaret er afkortet midt i en sætning, og det er ikke modellens skyld. Senere kapitler tilføjer tool_calls (Kapitel 18) og content filters.
Se nu to slutninger, en naiv client ikke kan skelne fra hinanden. Samme server, samme forsinkelse, den ene afkortet af max_tokens og den anden, hvor forbindelsen lukkes rent efter fem 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"Læs de første to rækker omhyggeligt. Identisk tekst. Identisk chunk count. Ingen exception i nogen af tilfældene. for await-loopet sluttede normalt begge gange, fordi body fra readerens synspunkt sluttede, og det er alt, en body kan gøre. Den eneste forskel i hele observationen er, at den ene bærer finish_reason: "length", og den anden ikke bærer noget som helst.
Så reglen er ikke „catch errors under streaming“. Den er:
En stream, der slutter uden en
finish_reason, sluttede ikke. Den stoppede.
Behandl en manglende finish_reason som en fejl, altid, og gem aldrig den tekst som et færdigt svar. Tredje række viser det lettere tilfælde — en ødelagt socket kaster faktisk en exception, og den mister også den chunk, der var in flight, hvilket er grunden til, at teksten er ét ord kortere end de to ovenfor.
Fem statuskoder, der er fem forskellige problemer
Link til afsnittet: Fem statuskoder, der er fem forskellige problemerDen dyreste vane i et nyt produkt er én catch-blok til alt, hvad provideren returnerer. Disse koder er ikke variationer af „det fejlede“. De er fem instruktioner, og fire af dem modsiger hinanden.
| status | hvad det betyder | hvad du skal gøre | vente? |
|---|---|---|---|
| 400 | din request er malformed — dårlig JSON, ukendt felt, context for lang | ret koden | aldrig |
| 401 | nøglen er forkert, mangler eller er tilbagekaldt | ret deploymentet | aldrig |
| 429 | rate limit: for mange requests eller for mange tokens per minut | retry | Retry-After, derefter backoff |
| 500 | provideren gik i stykker | retry | backoff |
| 503 | provideren er overbelastet — den er oppe, den er fuld | retry | backoff, og shed load |
Linjen, der betyder noget, går mellem 4xx og resten. En 400 eller en 401 returnerer præcis det samme svar, hvis du sender den tusind gange, fordi intet i nogen ende ændrer sig mellem forsøgene. At retry den er ikke forsigtighed, det er en forsinkelse med ekstra trin. Målt: én client, der laver seks forsøg — fem retries med exponential backoff — og én, der læser koden først.
retry everything -> 6 requests, gave up after 6,325 ms, still HTTP 401
triage first -> 1 request, gave up after 4 ms, still HTTP 401Seks sekunders spinner for at nå et svar, der var tilgængeligt på fire millisekunder. Og det er den milde version: retries i et produkt er normalt indlejrede — en retryende HTTP client inde i en retryende job runner inde i en kø med sin egen redelivery — så seks sekunder bliver til seks minutter, hvor et permanent ødelagt deployment ligner et langsomt.
Triage er ni linjer og hører hjemme ét sted:
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
}To mere til din liste: 402, som nogle providers bruger for „du er løbet tør for credits“, og som kræver en skærm med et link til at købe flere snarere end en retry, og 529 eller dens vendor-specifikke ækvivalenter, der opfører sig som 503.
Backoff, og hvad jitter faktisk køber
Link til afsnittet: Backoff, og hvad jitter faktisk køberAt retry er let. Hvornår man retryer er den del, der har et målbart rigtigt svar.
Exponential backoff er standarden: vent en base delay, fordobl den efter hver fejl, stop ved et loft. Den findes, fordi en overbelastet server får det værre, hvis de clients, der lige fejlede, kommer direkte tilbage.
Problemet er, at alle fordobler fra det samme udgangspunkt. Hvis hundrede clients rammer en grænse på samme øjeblik — og det gør de, fordi det er, hvad en trafikspids er — så venter alle hundrede 200 ms, alle hundrede retryer sammen, alle hundrede fejler sammen, og alle hundrede venter 400 ms. Retry-planen har synkroniseret dem. Det er en thundering herd, og tilfældighed er rettelsen.2
Den ene ændring — at vælge uniformt fra intervallet i stedet for at tage dets øvre ende — kaldes full jitter. Det er ét kald til Math.random(), og det er værd at måle i stedet for at tro på:
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); Hundrede clients, én server, der betjener tre ad gangen, alt andet identisk, tre kørsler hver:
| HTTP requests | afvisninger | værste client | travleste 50 ms-vindue | wall clock | |
|---|---|---|---|---|---|
| ingen jitter, kørsel 1 | 491 | 391 | 10 forsøg | 46 ankomster | 65,6 s |
| ingen jitter, kørsel 2 | 780 | 680 | 19 forsøg | 72 ankomster | 245,7 s |
| ingen jitter, kørsel 3 | 770 | 670 | 18 forsøg | 97 ankomster | 225,6 s |
| full jitter, kørsel 1 | 324 | 224 | 5 forsøg | 32 ankomster | 2,2 s |
| full jitter, kørsel 2 | 313 | 213 | 6 forsøg | 31 ankomster | 2,3 s |
| full jitter, kørsel 3 | 318 | 218 | 6 forsøg | 25 ankomster | 1,8 s |
To ting i den tabel, og den anden er den vigtige.
Den første er medianen: 226 sekunder mod 2,2, en faktor omkring hundrede, med mindre end halvdelen af requestene. Det travleste retry-vindue siger hvorfor. Uden jitter ankom op til 97 af de hundrede clients i samme 50-millisekunders slot; serveren havde tre, så 94 blev afvist og gik i seng sammen, stadig synkroniserede, for at gøre det igen med længere ventetid. Med jitter blev de samme hundrede spredt over de samme vinduer i grupper på omkring tredive og blev drænet næsten med det samme.
Den anden er variansen. Uden jitter: 65,6 s, 245,7 s, 225,6 s. Med den: 2,2, 2,3, 1,8. Et system uden jitter præsterer ikke bare dårligt, det præsterer uforudsigeligt, fordi udfaldet afgøres af mikroskopiske scheduling-uheld, der vælger, hvilke tre af hundrede synkroniserede clients der ankommer først. Det er signaturen på denne bug i produktion: et endpoint, der er fint, fint, fint, og så tager fire minutter, uden at nogen ændring fra din side forklarer det.
Og den billigste retry er den, der aldrig sker. Sæt en concurrency gate foran provideren — en tæller, der aldrig lader mere end N requests være in flight — og de samme tyve clients, der havde brug for 74 requests og 7,1 sekunder, opfører sig sådan her:
client-side gate of 3: 20 HTTP requests, 0 429s, wall 883 msTyve requests for tyve svar, nul afvisninger, otte gange hurtigere. En retry er undskyldningen; gaten er ikke at have brug for en.
Retry-After er et gulv, ikke et forslag
Link til afsnittet: Retry-After er et gulv, ikke et forslagNår en provider returnerer 429, fortæller den normalt, hvor længe du skal vente, i Retry-After-headeren.3 Det tal er ikke et råd: provideren er den eneste part i udvekslingen, der ved, hvornår dens vindue nulstilles.
Så ventetiden er den største af de to: aldrig mindre end Retry-After og heller aldrig mindre end din egen backoff, fordi headeren fortæller, hvornår limiteren tilgiver dig, ikke hvornår serveren har plads.
const header = res.headers.get("retry-after");
const floor = header ? Number(header) * 1000 : 0; // seconds -> ms
const wait = Math.max(floor, backoffFull(attempt)); Tracen for den mest uheldige client i tyve-client-kørslen viser, at headeren gør sit arbejde. Dens første fire backoff-trækninger var alle under ét sekund, og alle fire blev tilsidesat:
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 msTo praktiske noter. Retry-After kan være en HTTP-dato snarere end et antal sekunder, så parse begge. Og providers rate-limiter på to akser på én gang — requests per minute og tokens per minute — hvilket er grunden til, at lange prompts bliver afvist langt under den dokumenterede request limit. Headeren ser ens ud i begge tilfælde; rettelsen gør ikke.
Timeouten ingen valgte
Link til afsnittet: Timeouten ingen valgteBed mock provideren om /hang. Den accepterer forbindelsen og gør derefter slet ingenting: ingen headers, ingen body, ingen close. Det er ikke eksotisk — det er, hvad en load balancer gør, når processen bag den er død uden at lukke sine sockets.
To clients, én forskel:
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_TIMEOUTTre hundrede sekunder. Fem minutter med en socket holdt åben, en request-slot optaget og en bruger, der stirrer på en spinner, og det hele ender i en generisk TypeError, der intet siger om, hvad der skete. Det tal er ikke en bug: det er Nodes standard headers timeout, rimelig for en generisk HTTP client og katastrofal for en brugerrettet request. Hver runtime har sådan en standard, de fleste slår den aldrig op, og den eneste måde at finde din på er at hænge en socket med vilje, sådan som vi lige gjorde.
Altså: hver udgående request får en eksplicit deadline, valgt af dig.
const res = await fetch(url, {
method: "POST",
headers: { "content-type": "application/json", authorization: `Bearer ${key}` },
body: JSON.stringify(payload),
signal: AbortSignal.timeout(20_000),
});For et streaming-kald er én deadline ikke nok, fordi der er to forskellige fejl. Den første er streamen åbner aldrig: intet event ankommer overhovedet, og ti til tredive sekunder er rigtigt. Den anden er streamen åbner og går så i stå: tokens flød og stoppede så, for evigt, mens socketen stadig er sund. En total-duration timeout kan ikke skelne en stalled stream fra et langt korrekt svar, så det, du vil have, er en idle timeout — en timer, der nulstilles af hvert event og kun udløses, når intet er ankommet i for eksempel femten sekunder.
Annullering er det samme maskineri rettet mod en person. AbortSignal.timeout og en bruger, der trykker Stop, ankommer begge som et AbortError, så kombiner dem og registrer, hvilken der blev udløst:
const user = new AbortController();
const signal = AbortSignal.any([user.signal, AbortSignal.timeout(20_000)]);
// stopButton.onclick = () => user.abort();Abort betyder noget af en grund ud over orden: tokens bliver genereret og faktureret, mens du ikke lytter. Kapitel 16 sætter en pris på det.
Hvad er sikkert at retry
Link til afsnittet: Hvad er sikkert at retryNu fejlen, der koster penge snarere end tid. En request får timeout på clienten, og det oplagte træk er at sende den igen — men en timeout fortæller dig intet om, hvorvidt serveren modtog den. Meget ofte gjorde den det og arbejder stadig.
Målt. Mock provideren skal bruge 780 ms på svaret. Clienten giver op efter 300 ms og retryer. Serveren tæller, hvor mange svar den faktisk genererede, hvilket er det, den ville fakturere:
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): 1Uden en nøgle: to fulde genereringer, betalt to gange, og clienten modtog ingen af dem. Med en nøgle: serveren genkendte den anden request som den samme request og svarede øjeblikkeligt med det svar, den allerede havde produceret, så retryen undgik både dobbeltbetaling og var det forsøg, der endelig lykkedes.
En idempotency key er en unik streng, du genererer per logisk operation — ikke per forsøg — og sender uændret på hver retry af den. Serveren gemmer udfaldet mod nøglen og afspiller det igen. Det er den mekanisme, betalings-API’er bruger, af samme grund.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");
}To ærlige begrænsninger. Ikke alle providers understøtter idempotency keys på completions, og hvor endpointet ikke er idempotent, er det korrekte antal retries for en POST, der måske allerede er kørt, nul. Og en stream, der fejlede halvvejs, kan generelt ikke afspilles igen: enten starter du den forfra og betaler igen, eller også beholder du den delvise tekst og markerer den som ufuldstændig. Hvilken af delene dit produkt gør, er en produktbeslutning, ikke en netværksbeslutning, og den er værd at træffe med vilje.
At lukke sømmen
Link til afsnittet: At lukke sømmenClienten skrevet i dette kapitel aner ikke, hvad der er bag porten. Peg dens base URL på en kommerciel provider, og den streamer tokens fra en model med en billion parameters. Peg den på en server bygget på aritmetikken fra Kapitel 13 — der serverer den model, du pretrainede i Kapitel 10, med dens KV cache og dens quantized weights — og den samme kode, uændret, streamer tokens fra en model, du byggede.
const BASE = process.env.LLM_BASE_URL ?? "http://127.0.0.1:8000/v1"; Den ene linje er sømmen i dette kursus. På den ene side af den er det, de første tretten kapitler byggede; på den anden, det de næste seksten bygger. Grænsen er ren, fordi kontrakten er HTTP og SSE, og ingen af siderne ved noget som helst andet om den anden.
Det er værd at lægge mærke til, hvad du mistede ved at krydse. Bag et kommercielt endpoint kontrollerer du hverken vægtene, sampling-implementeringen, versionen du taler med, eller om den ændrede sig i morges. Det, du kontrollerer, er kontrakten: de messages du sender, den deadline du sætter, de koder du skelner mellem, og hvad du gør, når intet kommer tilbage. Det er en mindre overflade, end du havde i Kapitel 5, og hvert tilbageværende kapitel handler om at bruge den godt.
Hvor det går hen nu
Link til afsnittet: Hvor det går hen nuDu har nu en client, der streamer, giver op til tiden, retryer de rigtige ting og aldrig retryer de forkerte. Det, den sender, er stadig bare det, du skrev.
Kapitel 15 handler om det indhold, og det kommer med en disciplin. Internettet er fuldt af prompting-råd — tilbyd modellen drikkepenge, tru den, bed den tage en dyb indånding — og næsten intet af det kommer med en måling. Nogle af de teknikker flytter outputtet rigtig meget, nogle flytter det slet ikke, og mindst én gør en classification task værre, mens den koster flere tokens. Hvad der er hvad, er ikke oplagt ved at læse dem, og det afgøres ikke af argumenter.
Så næste kapitel bygger en bench: tres cases med kendte svar, fire varianter af samme prompt, kørt parallelt gennem præcis den client, du lige skrev, tabelleret med confidence intervals fra Kapitel 4 — fordi fire varianter over tyve cases ikke skelner noget som helst. Én sætning styrer hele kapitlet: et prompt måles, det debatteres ikke.
Kilder og metode
Link til afsnittet: Kilder og metodeHvert tal ovenfor kom fra mock provideren, på Node 22 over et loopback-interface, så latenserne er renere, end noget rigtigt netværk vil give dig. Det er med vilje: ingen af de fejl, der måles, skyldes netværket, og en fjendtlig server, du kan genstarte, lærer bedre end en rigtig, du skal betale for og ikke kan ødelægge.
Referencer
Link til afsnittet: Referencer-
Server-Sent Events, WHATWG HTML Living Standard, afsnit 9.2. Wire format —
data:-felter, events adskilt af blanke linjer,id:ogretry:— er defineret dér sammen medEventSource-interfacet.EventSourcekan ikke sende en request body eller custom headers, hvilket er grunden til, at hver LLM client parser formatet i hånden overfetchi stedet for at bruge det. ↩ -
Brooker, M. Exponential Backoff and Jitter. AWS Architecture Blog (2015). Kilden til „full jitter“-formuleringen brugt ovenfor, med simuleringerne, der viser, hvorfor den naive version synkroniserer clients. Det tilhørende argument for shedding load i stedet for at sætte den i kø er kapitlet Handling Overload i Beyer, Jones, Petoff og Murphy (red.), Site Reliability Engineering (O’Reilly, 2016). ↩
-
Fielding, R., Nottingham, M. og Reschke, J. (red.), HTTP Semantics, RFC 9110, afsnit 15, definerer statuskodeklasserne; Nottingham, M. og Fielding, R., Additional HTTP Status Codes, RFC 6585 (2012), afsnit 4, definerer 429 Too Many Requests.
Retry-Afterer RFC 9110 afsnit 10.2.3 og accepterer enten et antal sekunder eller en HTTP-dato. ↩ -
Stripe, Idempotent requests,
docs.stripe.com/api/idempotent_requests, læst 7. september 2026 — den klareste formulering af kontrakten: én nøgle per logisk operation, gemte resultater afspilles igen, en konflikt returneres, mens første forsøg stadig er in flight — og mønstret er provider-uafhængigt. De normative referencer for request- og event-formerne brugt her erdevelopers.openai.com/api/reference/resources/chatfor streaming, error codes og rate limits, ogplatform.claude.com/docs/en/api/messagesfor Messages API;ai-sdk.dev/docser det bedste gennemarbejdede eksempel på de samme hensyn pakket ind i et library. Alle læst samme dag. ↩