Naar inhoud springen
14/30Hoofdstuk 14 van 30

Je eerste LLM-call in productie: streaming, retries, timeouts

Bouw een provider die tegen je liegt — 429s, hangende sockets, halve streams — en meet je client. Full jitter: 2,2 s versus 226.

Op deze pagina

Hoofdstuk 13 eindigde met een stopwatch op een model dat je kon aanraken. De gewichten stonden in je geheugen, de KV cache kon je zelf aan- of uitzetten, en het getal dat eruit kwam — time to first token — was een eigenschap van je hardware.

Zet dat model nu achter een poort, wat elk product doet, en lees hetzelfde getal opnieuw. Het is nog steeds time to first token, maar het is niet langer een eigenschap van iets waar jij controle over hebt. Het omvat nu een TLS-handshake, een wachtrij bij de provider, een rate limiter en de mogelijkheid dat er helemaal nooit een token aankomt.

Die laatste bijzin is dit hoofdstuk. De code die je gaat schrijven berekent niets. Hij opent een verbinding, wacht, parset wat binnenkomt, beslist wat hij doet als er niets binnenkomt, beslist opnieuw wanneer wat binnenkomt een fout is, en annuleert zichzelf wanneer de gebruiker van gedachten verandert. Elk daarvan is een beslissing over state over time, en elk heeft een verkeerd antwoord dat naar productie gaat en geld kost.

Dit is de vorm van het probleem, gemeten, allemaal in dit hoofdstuk:

wat er gebeurdewat een slordige client doetwat het kost
de server accepteerde de socket en antwoordde nooitwacht300,8 s voordat Node uit zichzelf opgeeft
de sleutel was verkeerd (401)probeert vijf keer opnieuw6.325 ms vertraging, en daarna dezelfde 401
honderd clients raken tegelijk de rate limitallemaal retryn op hetzelfde schema226 s om leeg te lopen, versus 2,2 s
de request kreeg een timeout en werd opnieuw verzondenverzendt hem opnieuwde provider genereert — en factureert — het antwoord twee keer
de verbinding viel halverwege het antwoord wegtoont de gedeeltelijke tekstniet te onderscheiden van een correct kort antwoord

Geen van deze dingen is een modelleerprobleem. Ze zitten allemaal in de eerste honderd regels van elk LLM-product dat ooit is geschreven.

Lees die tabel nog eens en vraag je af wat voor programma hij beschrijft. Het houdt veertig seconden lang een verbinding open. Het moet met een knop te annuleren zijn. Het verzamelt een gedeeltelijk antwoord dat geldig is om te tonen en ongeldig om op te slaan. En het draait in een serverproces of op een edge worker, naast het ding dat het antwoord rendert, met een socket vast.

Dat is geen notebook. Het is niet dat Python dit niet kan — dat kan het, en mensen doen het — maar alles wat de vorige dertien hoofdstukken hebben opgebouwd was van een ander soort. Hoofdstukken 1 tot en met 13 hielden gewichten, gradients, logits en tokenizer-bytes vast. Vanaf hier houdt de code een verbinding, een retry, een annulering, opgebouwde state en, later, een permission prompt vast. De cursus verandert precies op de naad waar het object verandert van taal.

Dus de regel, één keer opgeschreven:

Als de code gewichten, gradients, logits of tokenizer-bytes in handen heeft, is het Python. Als hij een verbinding vasthoudt, retries doet, annuleert, state opbouwt en om toestemming vraagt, is het TypeScript.

De naad is enkelvoudig en valt hier, tussen Hoofdstuk 13 en Hoofdstuk 14. Drie onafhankelijke criteria plaatsen hem hier.

Eén: het ecosysteem, geteld. Alles wat de linkerhelft van deze cursus citeert is Python, en in de twaalf cursussen die voor deze syllabus zijn geaudit is er geen enkel precedent waarin backpropagation in een andere taal wordt onderwezen: micrograd (17,4K sterren), nanoGPT (62,8K), nanochat (57,8K), minbpe (10,7K), PyTorch (102,8K), transformers (164,9K). Hoofdstuk 5 in TypeScript schrijven zou de link met die bronnen breken, en die links zijn de helft van de waarde van een hoofdstuk dat bestaat om naar te verwijzen in plaats van om te ranken. Aan deze kant draait de rekenkunde om: Vercels ai-pakket zit op 89,4M downloads per maand en levert het ding zelf — een tool-calling agent-loop, geëxporteerd als ToolLoopAgent — dus het concept dat deze cursus bereikt in Hoofdstuk 23 heeft zijn referentie-implementatie in TypeScript, ook al is, zoals dat hoofdstuk meet, niemand het eens geworden over een naam ervoor; Mastra zit op 27,7K sterren; en Anthropic's SDK's, gegenereerd uit één specificatie, declareren 202 endpoints in TypeScript tegenover 201 in Python — pariteit, geen beleefdheids-port.

Twee: de normatieve bron van MCP. Het schema van de Model Context Protocol-specificatie is een schema.ts-bestand. Het protocol van Hoofdstuk 26 in een andere taal onderwijzen betekent een vertaling van het oprichtingsdocument onderwijzen.

Drie: zoekvraag, met een correctie op de voor de hand liggende gok. machine learning python is de meest verzadigde zin op internet; ai agent typescript heeft zijn eigen gezonde long tail. Maar "het MCP-ecosysteem is grotendeels TypeScript" is alleen waar afhankelijk van hoe je telt: het officiële register vermeldt 8.275 servers op npm tegenover 3.603 op PyPI, terwijl Python wint op downloads — 287M per maand voor mcp plus 72M voor fastmcp tegenover 195M voor @modelcontextprotocol/sdk. MCP is hier het enige echt tweetalige gebied, en daarom schrijft Hoofdstuk 27 dezelfde server twee keer in plaats van te doen alsof.

Details tonen

De vijf verklaarde uitzonderingen, zodat de regel een regel is en geen slogan.

Hoofdstukken 17, 20 en 29 krijgen een tweede paneel in Python: top-p-sampling implementeren vereist dat je de probability vector in handen hebt en een HTTP API geeft je die nooit; een fine-tune eerlijk prijzen betekent er één draaien, en een LoRA-adapter is een dozijn regels nn.Module; en lm-eval-harness, HELM, SWE-bench en τ-bench zijn Python, dus een evaluation harness in TypeScript zou het spiegelbeeld zijn van de backpropagation-fout. Hoofdstuk 27 is tweetalig, om de gemeten reden hierboven. Hoofdstuk 28 is Markdown, omdat een agent skill een SKILL.md-bestand is en er een programmeertaal aan geven zou betekenen dat je het formaat niet hebt begrepen.

De dertien Python-hoofdstukken worden niet weggegooid. Wat aan de andere kant van de poort staat, is wat zij hebben gebouwd, en het laatste gedeelte hier verbindt er een client mee.

Je kunt dit niet leren tegen een echte provider. Je kunt er niet om een 429 op een gekozen moment vragen, of om een socket die je verbinding accepteert en nooit antwoordt, of om een stream die midden in een woord stopt — en je zou voor elk experiment betalen, terwijl de interessante experimenten juist degene zijn die je honderd keer uitvoert.

Daarom is het eerste programma in deze helft van de cursus geen client. Het is een vijandige server: veertig regels plain Node die hetzelfde wire protocol spreken als een chat-completions-endpoint en zich op commando misdragen. Elk getal in dit hoofdstuk kwam eruit.

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

Vier vijandige gedragingen, elk één regel: /hang accepteert de socket en schrijft er nooit naar; /401 weigert de sleutel; de capaciteitscheck produceert een echte 429 met een echte Retry-After-header zodra er al drie requests in flight zijn; en ?cut=N laat het antwoord halverwege vallen, ofwel door de socket te resetten of — met &how=close — door hem netjes te sluiten, wat enorm blijkt uit te maken. De rest is een echte Server-Sent Events-stream: één JSON-object per data:-regel, een lege regel tussen events, de string [DONE] aan het einde.1

Draai hem, en de rest van het hoofdstuk is meting.

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]

De request body, en de sleutel die de server nooit verlaat

Link naar de sectie: De request body, en de sleutel die de server nooit verlaat

Een chat-request is een lijst berichten, elk met een rol. Die lijst is de volledige state van het model: er is geen geheugen tussen calls, en alles wat je wilt dat het model weet, moet in de array zitten die je deze keer verstuurt. Hoofdstuk 15 gaat over wat je erin stopt en Hoofdstuk 16 over wat het kost, dus hier gaat het alleen om de vorm.

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

Die rollen zijn geen decoratie. Ze worden gerenderd in de chat template van Hoofdstuk 11 voordat het model ook maar één token ziet, en daarom degradeert een verkeerde rol het antwoord stilletjes in plaats van een error te geven.

Eén regel zonder uitzonderingen: de API-sleutel reist nooit naar de client. Niet in een environment variable met een browserprefix, niet in een build-time constante, niet "tijdelijk". Een sleutel in een bundle is binnen een paar dagen een sleutel op andermans rekening. De browser praat met je server, je server bewaart de sleutel en praat met de provider — en omdat je server ertussen zit, is dat ook de enige plek die kan meten wat elke gebruiker uitgeeft, en daar moet de boekhouding van Hoofdstuk 16 leven.

Nu het experiment waarop dit hoofdstuk is gebouwd. Eén vraag, één mock provider die dertien tokens produceert met elk 60 ms, drie manieren om hem te stellen.

Eerst, zonder streaming. De client verstuurt de request en wacht op de volledige JSON-body.

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

De twee getallen zijn hetzelfde, en dat is het hele probleem. 791 ms lang heeft de gebruiker een spinner, en geen enkel woord was eerder beschikbaar — de server had het antwoord, byte voor byte, en koos ervoor niets te zeggen.

Ten tweede, met streaming. Dezelfde server, hetzelfde antwoord, hetzelfde totale werk. Het verschil is een 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);
      }
    }
  }
}

Drie details daar zijn dragend en de meeste eerste pogingen slaan ze alle drie over. De buffer bestaat omdat een network chunk geen relatie heeft met een event: één read() kan een half event teruggeven, of tweeënhalf. De { stream: true }-flag bestaat omdat een multi-byte UTF-8-teken over twee chunks gesplitst kan worden, en zonder die flag wordt een letter met accent willekeurig een replacement character. En events worden gescheiden door een lege regel, niet door een newline, wat de reden is dat de loop naar \n\n zoekt.

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

Twaalf keer sneller naar het eerste woord, en twee milliseconden langzamer naar het laatste. Streaming maakt niets sneller. Het verandert wat de gebruiker doet tijdens dezelfde 790 ms: lezen in plaats van wachten. Dat is het hele voordeel, het is enorm, en het is de reden dat elk chatproduct streamt.

Ten derde, met twintig clients tegelijk. De mock provider bedient drie requests tegelijk. Vuur er twintig af:

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

Twintig antwoorden, vierenzeventig requests, vierenvijftig afwijzingen. Niemand verloor iets, elke client kreeg dezelfde tekst, en de enige zichtbare kost was tijd. Dat is een retry-beleid dat werkt. De rest van dit hoofdstuk gaat over de drie manieren waarop het in plaats daarvan kan falen.

finish_reason, en twee eindes die er hetzelfde uitzien

Link naar de sectie: finish_reason, en twee eindes die er hetzelfde uitzien

Vóór de failures: het veld dat bijna iedereen bij de eerste poging negeert. Elke stream eindigt met een event dat finish_reason bevat. stop betekent dat het model besloot dat het klaar was. length betekent dat het de token-limiet raakte, dus het antwoord is midden in een zin afgekapt en dat is niet de schuld van het model. Latere hoofdstukken voegen tool_calls (Hoofdstuk 18) en contentfilters toe.

Bekijk nu twee eindes die een naïeve client niet uit elkaar kan houden. Dezelfde server, dezelfde vertraging, één afgekapt door max_tokens en één waarbij de verbinding na vijf tokens netjes wordt gesloten:

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"

Lees de eerste twee rijen aandachtig. Identieke tekst. Identiek aantal chunks. In geen van beide gevallen een exception. De for await-loop eindigde beide keren normaal, want vanuit het perspectief van de reader eindigde de body en dat is alles wat een body kan doen. Het enige verschil in de hele observatie is dat de ene finish_reason: "length" bevat en de andere helemaal niets.

De regel is dus niet "vang errors tijdens streaming". Hij is:

Een stream die eindigt zonder finish_reason is niet geëindigd. Hij is gestopt.

Behandel een ontbrekende finish_reason altijd als een failure, en sla die tekst nooit op als voltooid antwoord. De derde rij toont het eenvoudigere geval — een vernietigde socket gooit wel een exception, en verliest ook de chunk die onderweg was, waardoor de tekst één woord korter is dan de twee erboven.

Vijf statuscodes die vijf verschillende problemen zijn

Link naar de sectie: Vijf statuscodes die vijf verschillende problemen zijn

De duurste gewoonte van een nieuw product is één catch-blok voor alles wat de provider teruggeeft. Deze codes zijn geen variaties op "het is mislukt". Het zijn vijf instructies, en vier ervan spreken elkaar tegen.

statuswat het betekentwat je doetwachten?
400je request is misvormd — slechte JSON, onbekend veld, context te langfix de codenooit
401de sleutel is verkeerd, ontbreekt of is ingetrokkenfix de deploymentnooit
429rate limit: te veel requests, of te veel tokens, per minuutretryRetry-After, daarna backoff
500de provider is stukretrybackoff
503de provider is overbelast — hij is up, hij zit volretrybackoff, en shed load

De lijn die ertoe doet loopt tussen 4xx en de rest. Een 400 of 401 geeft exact hetzelfde antwoord als je hem duizend keer verstuurt, omdat er tussen pogingen aan geen van beide kanten iets verandert. Hem retryn is geen voorzichtigheid, het is vertraging met extra stappen. Gemeten: één client die zes pogingen doet — vijf retries met exponential backoff — en één die eerst de code leest.

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

Zes seconden spinner om een antwoord te bereiken dat in vier milliseconden beschikbaar was. En dat is de milde versie: retries in een product zijn meestal genest — een retryende HTTP-client binnen een retryende job runner binnen een queue met eigen redelivery — dus zes seconden wordt zes minuten waarin een permanent kapotte deployment eruitziet als een trage.

De triage is negen regels en hoort op één plek:

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
}

Nog twee voor je lijst: 402, dat sommige providers gebruiken voor "je credits zijn op" en dat een scherm met een link om meer te kopen nodig heeft in plaats van een retry, en 529 of vendor-specifieke equivalenten, die zich gedragen als 503.

Retrien is makkelijk. Wanneer retrien is het deel met een meetbaar juist antwoord.

Exponential backoff is de standaard: wacht een basisvertraging, verdubbel die na elke failure, stop bij een plafond. Het bestaat omdat een overbelaste server slechter wordt als de clients die net faalden meteen terugkomen.

Het probleem is dat iedereen vanaf hetzelfde startpunt verdubbelt. Als honderd clients op hetzelfde moment een limiet raken — en dat doen ze, want dat is wat een traffic spike is — dan wachten alle honderd 200 ms, proberen alle honderd tegelijk opnieuw, falen alle honderd tegelijk, en wachten alle honderd 400 ms. Het retry-schema heeft ze gesynchroniseerd. Dat is een thundering herd, en willekeur is de oplossing.2

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

Die ene verandering — uniform kiezen uit het interval in plaats van het bovenste uiteinde te nemen — heet full jitter. Het is één call naar Math.random(), en het is de moeite waard om te meten in plaats van te geloven:

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

Honderd clients, één server die er drie tegelijk bedient, verder alles identiek, elk drie runs:

HTTP requestsafwijzingenslechtste clientdrukste 50 ms-vensterwall clock
geen jitter, run 149139110 pogingen46 aankomsten65,6 s
geen jitter, run 278068019 pogingen72 aankomsten245,7 s
geen jitter, run 377067018 pogingen97 aankomsten225,6 s
full jitter, run 13242245 pogingen32 aankomsten2,2 s
full jitter, run 23132136 pogingen31 aankomsten2,3 s
full jitter, run 33182186 pogingen25 aankomsten1,8 s

Twee dingen in die tabel, en het tweede is het belangrijke.

Het eerste is de mediaan: 226 seconden tegen 2,2, een factor van ongeveer honderd, met minder dan de helft van de requests. Het drukste retry-venster laat zien waarom. Zonder jitter kwamen tot 97 van de honderd clients binnen hetzelfde slot van 50 milliseconden aan; de server had er drie, dus 94 werden afgewezen en gingen samen slapen, nog steeds gesynchroniseerd, om het opnieuw te doen met een langere wachttijd. Met jitter spreidden dezelfde honderd zich over dezelfde vensters in groepen van rond de dertig en liepen ze vrijwel meteen leeg.

Het tweede is de variantie. Zonder jitter: 65,6 s, 245,7 s, 225,6 s. Met jitter: 2,2, 2,3, 1,8. Een systeem zonder jitter presteert niet alleen slecht, het presteert onvoorspelbaar, omdat de uitkomst wordt bepaald door microscopische scheduling-toevalligheden die bepalen welke drie van de honderd gesynchroniseerde clients als eerste aankomen. Dat is het signaal van deze bug in productie: een endpoint dat prima is, prima, prima, en dan vier minuten duurt, zonder dat een verandering van jou het verklaart.

En de goedkoopste retry is degene die nooit gebeurt. Zet een concurrency gate vóór de provider — een teller die nooit meer dan N requests in flight laat zijn — en dezelfde twintig clients die 74 requests en 7,1 seconden nodig hadden gedragen zich zo:

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

Twintig requests voor twintig antwoorden, nul afwijzingen, acht keer sneller. Een retry is de verontschuldiging; de gate zorgt dat je er geen nodig hebt.

Wanneer een provider 429 teruggeeft, vertelt hij je meestal hoe lang je moet wachten, in de Retry-After-header.3 Dat getal is geen advies: de provider is de enige partij in de uitwisseling die weet wanneer zijn venster reset.

De wachttijd is dus de grootste van de twee: nooit minder dan Retry-After, en ook nooit minder dan je eigen backoff, omdat de header vertelt wanneer de limiter je vergeeft en niet wanneer de server ruimte heeft.

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

De trace van de client met de meeste pech in de run met twintig clients laat zien dat de header zijn werk doet. Zijn eerste vier backoff-trekkingen lagen allemaal onder één seconde, en alle vier werden overschreven:

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

Twee praktische opmerkingen. Retry-After kan een HTTP-datum zijn in plaats van een aantal seconden, dus parse allebei. En providers passen rate limiting tegelijk toe op twee assen — requests per minuut en tokens per minuut — waardoor lange prompts ver onder de gedocumenteerde request-limiet worden afgewezen. De header ziet er in beide gevallen hetzelfde uit; de fix niet.

Vraag de mock provider om /hang. Hij accepteert de verbinding en doet daarna helemaal niets: geen headers, geen body, geen close. Dit is niet exotisch — dit is wat een load balancer doet wanneer het proces erachter is gestorven zonder zijn sockets te sluiten.

Twee clients, één verschil:

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

Driehonderd seconden. Vijf minuten lang een socket opengehouden, een request-slot bezet en een gebruiker die naar een spinner staart, eindigend in een generieke TypeError die niets zegt over wat er gebeurde. Dat getal is geen bug: het is Node's standaard headers-timeout, redelijk voor een generieke HTTP-client en catastrofaal voor een user-facing request. Elke runtime heeft zo'n default, de meeste mensen zoeken hem nooit op, en de enige manier om de jouwe te vinden is bewust een socket te laten hangen zoals we net deden.

Dus: elke uitgaande request krijgt een expliciete deadline, door jou gekozen.

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

Voor een streaming-call is één deadline niet genoeg, omdat er twee verschillende failures zijn. De eerste is de stream opent nooit: er komt helemaal geen event binnen, en tien tot dertig seconden is juist. De tweede is de stream opent en valt daarna stil: tokens stroomden en stopten toen, voorgoed, terwijl de socket nog gezond is. Een timeout op totale duur kan een vastgelopen stream niet onderscheiden van een lang correct antwoord, dus wat je wilt is een idle timeout — een timer die door elk event wordt gereset en alleen afgaat wanneer er bijvoorbeeld vijftien seconden niets is aangekomen.

Cancellation is dezelfde machinery gericht op een persoon. AbortSignal.timeout en een gebruiker die op Stop drukt komen allebei binnen als een AbortError, dus combineer ze en leg vast welke afging:

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

Aborten is om meer redenen belangrijk dan netheid: de tokens worden gegenereerd en gefactureerd terwijl jij niet luistert. Hoofdstuk 16 zet daar een prijs op.

Nu de failure die geld kost in plaats van tijd. Een request krijgt een timeout op de client, en de voor de hand liggende stap is hem opnieuw te sturen — maar een timeout zegt niets over of de server hem heeft ontvangen. Heel vaak is dat wel zo, en is hij nog bezig.

Gemeten. De mock provider heeft 780 ms nodig voor het antwoord. De client geeft na 300 ms op en probeert opnieuw. De server telt hoeveel antwoorden hij daadwerkelijk heeft gegenereerd, wat hij zou factureren:

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

Zonder sleutel: twee volledige generaties, twee keer betaald, en de client ontving geen van beide. Met sleutel: de server herkende de tweede request als dezelfde request en antwoordde direct met het antwoord dat hij al had geproduceerd, dus de retry voorkwam zowel de dubbele kosten als werd de poging die uiteindelijk slaagde.

Een idempotency key is een unieke string die je genereert per logische operatie — niet per poging — en onveranderd meestuurt bij elke retry ervan. De server slaat de uitkomst op bij de sleutel en speelt die opnieuw af. Het is het mechanisme dat payment-API's gebruiken, om dezelfde reden.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");
}

Twee eerlijke beperkingen. Niet elke provider ondersteunt idempotency keys op completions, en waar het endpoint niet idempotent is, is het correcte aantal retries voor een POST die al uitgevoerd kan zijn nul. En een stream die halverwege faalde is in het algemene geval niet replayable: je start hem opnieuw en betaalt opnieuw, of je houdt de gedeeltelijke tekst en markeert hem als onvolledig. Welke van die twee je product doet is een productbeslissing, geen netwerkbeslissing, en het is de moeite waard om die bewust te nemen.

De client die in dit hoofdstuk is geschreven heeft geen idee wat er achter de poort zit. Richt zijn base URL op een commerciële provider en hij streamt tokens uit een model met een biljoen parameters. Richt hem op een server gebouwd op de rekenkunde van Hoofdstuk 13 — die het model serveert dat je in Hoofdstuk 10 hebt voorgetraind, met zijn KV cache en zijn gequantiseerde gewichten — en dezelfde code, onveranderd, streamt tokens uit een model dat je zelf hebt gebouwd.

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

Die ene regel is de naad van deze cursus. Aan de ene kant staat wat de eerste dertien hoofdstukken hebben gebouwd; aan de andere kant wat de volgende zestien bouwen. De grens is schoon omdat het contract HTTP en SSE is, en geen van beide kanten iets anders over de andere weet.

Het is de moeite waard om op te merken wat je verloor door over te steken. Achter een commercieel endpoint controleer je noch de gewichten, noch de sampling-implementatie, noch de versie waarmee je praat, noch of die vanmorgen is veranderd. Wat je controleert is het contract: de berichten die je verstuurt, de deadline die je zet, de codes die je onderscheidt, en wat je doet wanneer er niets terugkomt. Dat is een kleiner oppervlak dan je in Hoofdstuk 5 had, en elk resterend hoofdstuk gaat erover hoe je dat goed gebruikt.

Je hebt nu een client die streamt, op tijd opgeeft, de juiste dingen retriet en de verkeerde nooit retriet. Wat hij verstuurt is nog steeds wat jij typte.

Hoofdstuk 15 gaat over die inhoud, en komt met een discipline. Het internet staat vol prompting-advies — bied het model een fooi, bedreig het, zeg dat het diep adem moet halen — en bijna niets daarvan komt met een meting. Sommige van die technieken bewegen de output flink, sommige helemaal niet, en minstens één maakt een classificatietaak slechter terwijl hij meer tokens kost. Welke welke is, is niet duidelijk door ze te lezen, en het wordt niet beslist door discussie.

Dus het volgende hoofdstuk bouwt een bench: zestig cases met bekende antwoorden, vier varianten van dezelfde prompt, parallel uitgevoerd door precies de client die je net schreef, getabelleerd met de betrouwbaarheidsintervallen uit Hoofdstuk 4 — omdat vier varianten over twintig cases helemaal niets onderscheiden. Eén zin regeert het hele hoofdstuk: een prompt wordt gemeten, niet bediscussieerd.


Elk getal hierboven kwam van de mock provider, op Node 22 over een loopback-interface, dus de latencies zijn schoner dan welk echt netwerk je ook zal geven. Dat is opzettelijk: geen van de gemeten failures wordt veroorzaakt door het netwerk, en een vijandige server die je kunt herstarten leert beter dan een echte waarvoor je moet betalen en die je niet kunt breken.

  1. Server-Sent Events, WHATWG HTML Living Standard, sectie 9.2. Het wire format — data:-velden, events gescheiden door lege regels, id: en retry: — wordt daar gedefinieerd, samen met de EventSource-interface. EventSource kan geen request body of custom headers versturen, en daarom parset elke LLM-client het formaat met de hand bovenop fetch in plaats van die interface te gebruiken.

  2. Brooker, M. Exponential Backoff and Jitter. AWS Architecture Blog (2015). De bron van de hierboven gebruikte "full jitter"-formulering, met de simulaties die laten zien waarom de naïeve versie clients synchroniseert. Het bijbehorende argument voor load shedding in plaats van queueing is het hoofdstuk Handling Overload van Beyer, Jones, Petoff en Murphy (eds.), Site Reliability Engineering (O'Reilly, 2016).

  3. Fielding, R., Nottingham, M. en Reschke, J. (eds.), HTTP Semantics, RFC 9110, sectie 15, definieert de statuscodeklassen; Nottingham, M. en Fielding, R., Additional HTTP Status Codes, RFC 6585 (2012), sectie 4, definieert 429 Too Many Requests. Retry-After is RFC 9110 sectie 10.2.3, en accepteert ofwel een aantal seconden ofwel een HTTP-datum.

  4. Stripe, Idempotent requests, docs.stripe.com/api/idempotent_requests, gelezen op 7 september 2026 — de helderste formulering van het contract: één sleutel per logische operatie, opgeslagen resultaten worden opnieuw afgespeeld, een conflict wordt teruggegeven terwijl de eerste poging nog in flight is — en het patroon is provider-onafhankelijk. De normatieve referenties voor de request- en event-vormen die hier worden gebruikt zijn developers.openai.com/api/reference/resources/chat voor streaming, error codes en rate limits, en platform.claude.com/docs/en/api/messages voor de Messages API; ai-sdk.dev/docs is het beste uitgewerkte voorbeeld van dezelfde zorgen verpakt in een library. Allemaal op dezelfde dag gelezen.


Gemaakt door

David Vicente Campos

Oprichter van NeuraLIA Labs en medeoprichter van MyRealFood

Ik ben informatica-ingenieur, afgestudeerd aan de Universiteit van León. Ik was medeoprichter van MyRealFood, waar ik als CTO de app bouwde die miljoenen mensen hebben gebruikt om beter te eten, en ik heb NeuraLIA Labs opgericht, waar ik AI-producten bouw. Hier schrijf ik over wat ik onderweg heb moeten begrijpen, zoals ik wou dat iemand het mij had uitgelegd.

Meer over de auteur

Gepubliceerd door NeuraLIA Labs.

Ontvang nieuwe posts in je inbox

AI-nieuws, gidsen en productupdates — een korte mail wanneer we iets publiceren dat je tijd waard is.

Cursusindex

Abstract software decision engine with branching paths, probability nodes, and glowing gates.
jev12 min leestijd

Jev AI-model is gebouwd voor beslissingen, niet voor proza

TypeSafe AI’s Jev trekt aandacht omdat het software-intelligentie benadert als een waarschijnlijkheidsprobleem: kies de juiste vertakking, voeg vertrouwen toe en betaal geen LLM om tekst te schrijven wanneer code een beslissing nodig heeft.

Abstract agent runtime sorting documents, memory blocks and pointer nodes inside a bounded context frame.
context-engineering11 min leestijd

Context-engineering voor langlopende AI-agenten

Langlopende agenten falen niet alleen omdat het venster klein is. Ze falen wanneer bestanden, tool-outputs en verouderde geschiedenis de taak verdringen die de agent moest afronden.

Klaar om LIA te laten kiezen?

Bouw met elk AI-model op één plek — begin vandaag nog gratis.