Hoppa till innehållet
14/30Kapitel 14 av 30

Ditt första LLM-anrop i produktion: streaming, retries, timeouts

Bygg en provider som ljuger för dig: 429:or, hängande sockets och streams som kapas mitt i. Full jitter: 2,2 sekunder mot 226.

På den här sidan

Kapitel 13 slutade med ett stoppur på en modell du kunde röra vid. Vikterna låg i ditt minne, KV cache var din att slå på eller av, och siffran som kom ut — time to first token — var en egenskap hos din hårdvara.

Sätt nu den modellen bakom en port, vilket är vad varje produkt gör, och läs samma siffra igen. Det är fortfarande time to first token, men det är inte längre en egenskap hos något du kontrollerar. Nu ingår en TLS-handshake, en kö hos leverantören, en rate limiter och möjligheten att ingen token någonsin kommer alls.

Den sista satsen är kapitlet. Koden du snart ska skriva beräknar ingenting. Den öppnar en anslutning, väntar, tolkar det som kommer, bestämmer vad den ska göra när inget kommer, bestämmer igen när det som kommer är ett fel, och avbryter sig själv när användaren ändrar sig. Var och en av dessa saker är ett beslut om tillstånd över tid, och var och en har ett fel svar som kan skeppas och kosta pengar.

Här är problemets form, uppmätt, alltihop i det här kapitlet:

vad som händevad en slarvig klient görvad det kostar
servern accepterade socketen och svarade aldrigväntar300,8 s innan Node ger upp på egen hand
nyckeln var fel (401)försöker igen fem gånger6 325 ms fördröjning, sedan samma 401
hundra klienter träffar rate limit samtidigtalla försöker igen enligt samma schema226 s att tömma, jämfört med 2,2 s
begäran gick ut på timeout och skickades igenskickar om denleverantören genererar — och fakturerar — svaret två gånger
anslutningen bröts mitt i svaretvisar den partiella textengår inte att skilja från ett korrekt kort svar

Inget av detta är ett modelleringsproblem. Allt finns i de första hundra raderna i varje LLM-produkt som någonsin skrivits.

Läs tabellen igen och fråga vilken sorts program den beskriver. Det håller en anslutning öppen i fyrtio sekunder. Det måste gå att avbryta från en knapp. Det ackumulerar ett partiellt svar som är giltigt att visa och ogiltigt att spara. Och det körs i en serverprocess eller i en edge worker, bredvid det som renderar svaret, medan det håller en socket.

Det är inte en notebook. Det är inte så att Python inte kan göra det — det kan det, och folk gör det — utan att allt de föregående tretton kapitlen byggde var av ett annat slag. Kapitel 1 till 13 höll vikter, gradients, logits och tokenizer-bytes. Härifrån håller koden en anslutning, ett retry, en cancellation, ackumulerat tillstånd och, senare, en permission prompt. Kursen byter språk precis vid skarven där objektet förändras.

Så regeln, skriven en gång:

Om koden har vikter, gradients, logits eller tokenizer-bytes i händerna är det Python. Om den håller en anslutning, gör retries, avbryter, ackumulerar tillstånd och ber om tillåtelse är det TypeScript.

Det finns bara en skarv, och den hamnar här, mellan kapitel 13 och kapitel 14. Tre oberoende kriterier placerar den här.

Ett: ekosystemet, räknat. Allt som den vänstra halvan av den här kursen hänvisar till är Python, och bland de tolv kurser som granskats för den här kursplanen finns inte ett enda prejudikat där backpropagation lärs ut på ett annat språk: micrograd (17,4K stjärnor), nanoGPT (62,8K), nanochat (57,8K), minbpe (10,7K), PyTorch (102,8K), transformers (164,9K). Att skriva kapitel 5 i TypeScript skulle bryta länken till dessa källor, och länkarna är halva värdet i ett kapitel som finns för att refereras snarare än för att ranka. På den här sidan vänds aritmetiken: Vercels ai-paket ligger på 89,4M nedladdningar i månaden och skeppar själva saken — en tool-calling agent-loop, exporterad som ToolLoopAgent — så konceptet kursen når i kapitel 23 har sin referensimplementation i TypeScript, även om, som det kapitlet mäter, ingen har enats om ett namn för den; Mastra ligger på 27,7K stjärnor; och Anthropics SDK:er, genererade från en specifikation, deklarerar 202 endpoints i TypeScript mot 201 i Python — paritet, inte en artighetsport.

Två: MCP:s normativa källa. Schemat i Model Context Protocol-specifikationen är en schema.ts-fil. Att lära ut protokollet i kapitel 26 på ett annat språk betyder att lära ut en översättning av dess grunddokument.

Tre: sökefterfrågan, med en korrigering av den uppenbara gissningen. machine learning python är den mest mättade frasen på internet; ai agent typescript har sin egen friska svans. Men "MCP-ekosystemet är mest TypeScript" är bara sant beroende på hur du räknar: det officiella registret listar 8 275 servrar på npm mot 3 603 på PyPI, medan Python vinner på nedladdningar — 287M i månaden för mcp plus 72M för fastmcp mot 195M för @modelcontextprotocol/sdk. MCP är det enda verkligt tvåspråkiga territoriet här, vilket är varför kapitel 27 skriver samma server två gånger i stället för att låtsas.

Visa detaljer

De fem uttalade undantagen, så att regeln är en regel och inte en slogan.

Kapitel 17, 20 och 29 har en andra panel i Python: att implementera top-p sampling kräver att du har sannolikhetsvektorn i handen, och ett HTTP API ger dig aldrig en sådan; att prissätta en fine-tune ärligt innebär att köra en, och en LoRA-adapter är ett dussin rader nn.Module; och lm-eval-harness, HELM, SWE-bench och τ-bench är Python, så en evaluation harness i TypeScript vore spegelbilden av backpropagation-misstaget. Kapitel 27 är tvåspråkigt, av den uppmätta orsaken ovan. Kapitel 28 är Markdown, eftersom en agent skill är en SKILL.md-fil och att ge den ett programmeringsspråk skulle betyda att man inte förstått formatet.

De tretton Python-kapitlen kastas inte bort. Det som finns på andra sidan porten är vad de byggde, och det sista avsnittet här kopplar en klient till det.

Du kan inte lära dig något av detta mot en riktig provider. Du kan inte be den om en 429 vid en vald tidpunkt, eller om en socket som accepterar din anslutning och aldrig svarar, eller om en stream som slutar mitt i ett ord — och du skulle betala för varje experiment, när de intressanta experimenten är de du kör hundra gånger.

Så det första programmet i den här halvan av kursen är inte en klient. Det är en fientlig server: fyrtio rader ren Node som talar samma wire protocol som en chat completions-endpoint och beter sig illa på kommando. Varje siffra i det här kapitlet kom från den.

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

Fyra fientliga beteenden, en rad vardera: /hang accepterar socketen och skriver aldrig till den; /401 vägrar nyckeln; kapacitetskontrollen producerar en äkta 429 med en äkta Retry-After-header när tre begäranden redan är pågående; och ?cut=N överger svaret halvvägs, antingen genom att resetta socketen eller — med &how=close — genom att stänga den ordnat, vilket visar sig spela mycket stor roll. Resten är en riktig Server-Sent Events-stream: ett JSON-objekt per data:-rad, en tom rad mellan events, strängen [DONE] i slutet.1

Kör den, och resten av kapitlet är mätning.

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]

Request body, och nyckeln som aldrig lämnar servern

Länk till avsnittet: Request body, och nyckeln som aldrig lämnar servern

En chat request är en lista med meddelanden, vart och ett med en roll. Den listan är modellens hela tillstånd: det finns inget minne mellan anrop, och vad du än vill att modellen ska veta måste ligga i arrayen du skickar den här gången. Kapitel 15 handlar om vad du ska lägga i den och kapitel 16 handlar om vad det kostar, så här är det bara formen.

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

De rollerna är inte dekoration. De renderas in i chat template från kapitel 11 innan modellen ser en enda token, vilket är varför fel roll tyst försämrar svaret i stället för att ge ett fel.

En regel utan undantag: API-nyckeln går aldrig till klienten. Inte i en environment variable med browser-prefix, inte i en build-time constant, inte "tillfälligt". En nyckel i en bundle är en nyckel på någon annans faktura inom några dagar. Webbläsaren pratar med din server, din server håller nyckeln och pratar med leverantören — och eftersom din server står i mitten är den också den enda plats som kan mäta vad varje användare spenderar, vilket är där bokföringen från kapitel 16 måste bo.

Nu experimentet som kapitlet bygger på. En fråga, en mock provider som producerar tretton tokens vid 60 ms styck, tre sätt att fråga.

Först, utan streaming. Klienten skickar begäran och väntar på hela JSON-kroppen.

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

De två siffrorna är samma, och det är hela problemet. I 791 ms har användaren en spinner, och inte ett enda ord fanns tillgängligt tidigare — servern hade svaret, byte för byte, och valde att inte säga något.

Sedan, med streaming. Samma server, samma svar, samma totala arbete. Skillnaden är en 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);
      }
    }
  }
}

Tre detaljer där är bärande, och de flesta första försök missar alla tre. buffer finns eftersom ett nätverks-chunk inte har någon relation till ett event: en read() kan returnera ett halvt event, eller två och ett halvt. Flaggan { stream: true } finns eftersom ett UTF-8-tecken med flera bytes kan delas över två chunks, och utan den blir en accentbokstav slumpmässigt ett ersättningstecken. Och events separeras av en tom rad, inte en radbrytning, vilket är varför loopen letar efter \n\n.

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

Tolv gånger snabbare till första ordet, och två millisekunder långsammare till det sista. Streaming gör ingenting snabbare. Det ändrar vad användaren gör under samma 790 ms: läser i stället för att vänta. Det är hela nyttan, den är enorm, och det är skälet till att varje chatprodukt streamar.

För det tredje, med tjugo klienter samtidigt. Mock provider hanterar tre begäranden åt gången. Skjut iväg tjugo:

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

Tjugo svar, sjuttiofyra begäranden, femtiofyra avvisningar. Ingen förlorade något, varje klient fick samma text, och den enda synliga kostnaden var tid. Det är en retry policy som fungerar. Resten av kapitlet handlar om de tre sätt den i stället kan misslyckas på.

finish_reason, och två slut som ser likadana ut

Länk till avsnittet: finish_reason, och två slut som ser likadana ut

Före felen: fältet nästan alla ignorerar första gången. Varje stream slutar med ett event som bär finish_reason. stop betyder att modellen bestämde att den var klar. length betyder att den nådde token-taket, så svaret är avklippt mitt i en mening och det är inte modellens fel. Senare kapitel lägger till tool_calls (kapitel 18) och content filters.

Se nu två slut som en naiv klient inte kan skilja åt. Samma server, samma fördröjning, ett trunkerat av max_tokens och ett där anslutningen stängs rent efter fem tokens:

TEXT
max_tokens=5           loop ended NORMALLY   chunks=5  finish_reason=length  text="A tide gauge is a"
socket closed cleanly  loop ended NORMALLY   chunks=5  finish_reason=null    text="A tide gauge is a"
socket destroyed       threw TypeError: terminated (UND_ERR_SOCKET)
                                             chunks=4  finish_reason=null    text="A tide gauge is"

Läs de två första raderna noga. Identisk text. Identiskt chunk count. Inget exception i något av fallen. for await-loopen avslutades normalt båda gångerna, eftersom body från läsarens perspektiv tog slut och det är allt en body kan göra. Den enda skillnaden i hela observationen är att den ena bär finish_reason: "length" och den andra inte bär något alls.

Så regeln är inte "fånga fel under streaming". Den är:

En stream som slutar utan en finish_reason slutade inte. Den stannade.

Behandla en saknad finish_reason som ett fel, alltid, och persistera aldrig den texten som ett färdigt svar. Den tredje raden visar det enklare fallet — en förstörd socket kastar faktiskt, och den förlorar också det chunk som var på väg, vilket är varför texten är ett ord kortare än de två ovan.

Den dyraste vanan en ny produkt har är ett enda catch-block för allt leverantören returnerar. De här koderna är inte variationer av "det misslyckades". De är fem instruktioner, och fyra av dem motsäger varandra.

statusvad det betydervad du ska göravänta?
400din begäran är felformad — dålig JSON, okänt fält, context för långfixa kodenaldrig
401nyckeln är fel, saknas eller har återkallatsfixa deploymentenaldrig
429rate limit: för många begäranden, eller för många tokens, per minutretryRetry-After, sedan backoff
500leverantören gick sönderretrybackoff
503leverantören är överbelastad — den är uppe, den är fullretrybackoff, och shed load

Linjen som spelar roll går mellan 4xx och resten. En 400 eller en 401 returnerar exakt samma svar om du skickar den tusen gånger, eftersom ingenting på någon sida ändras mellan försöken. Att retry den är inte försiktighet, det är en fördröjning med extra steg. Uppmätt: en klient som gör sex försök — fem retries med exponential backoff — och en som läser koden först.

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

Sex sekunders spinner för att nå ett svar som fanns tillgängligt på fyra millisekunder. Och det är den milda versionen: retries i en produkt är oftast nästlade — en HTTP-klient som retry:ar inuti en job runner som retry:ar inuti en kö med egen redelivery — så sex sekunder blir sex minuter av en permanent trasig deployment som ser långsam ut.

Triage är nio rader och hör hemma på ett ställe:

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
}

Två till på din lista: 402, som vissa providers använder för "du har slut på credits" och som behöver en skärm med en länk för att köpa mer snarare än ett retry, och 529 eller dess leverantörsspecifika motsvarigheter, som beter sig som 503.

Att retry är enkelt. Att retry när är delen med ett mätbart rätt svar.

Exponential backoff är standarden: vänta en basfördröjning, dubbla den efter varje fel, stanna vid ett tak. Den finns eftersom en överbelastad server blir sämre om klienterna som just misslyckades kommer raka vägen tillbaka.

Problemet är att alla dubblar från samma startpunkt. Om hundra klienter träffar en limit vid samma ögonblick — och det kommer de, eftersom det är vad en trafikspik är — då väntar alla hundra 200 ms, alla hundra retry:ar tillsammans, alla hundra misslyckas tillsammans, och alla hundra väntar 400 ms. Retry-schemat har synkroniserat dem. Det är en thundering herd, och slumpmässighet är fixen.2

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

Den enda ändringen — att välja jämnt ur intervallet i stället för att ta dess övre gräns — kallas full jitter. Det är ett anrop till Math.random(), och det är värt att mäta i stället för att tro på:

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

Hundra klienter, en server som hanterar tre åt gången, allt annat identiskt, tre körningar vardera:

HTTP-begärandenavvisningarsämsta klientmest belastade 50 ms-fönsterväggklocka
ingen jitter, körning 149139110 försök46 ankomster65,6 s
ingen jitter, körning 278068019 försök72 ankomster245,7 s
ingen jitter, körning 377067018 försök97 ankomster225,6 s
full jitter, körning 13242245 försök32 ankomster2,2 s
full jitter, körning 23132136 försök31 ankomster2,3 s
full jitter, körning 33182186 försök25 ankomster1,8 s

Två saker i den tabellen, och den andra är den viktiga.

Den första är medianen: 226 sekunder mot 2,2, en faktor på ungefär hundra, med mindre än hälften så många begäranden. Det mest belastade retry-fönstret visar varför. Utan jitter kom upp till 97 av de hundra klienterna inom samma 50-millisekunderslucka; servern hade tre, så 94 avvisades och somnade tillsammans, fortfarande synkroniserade, för att göra om det med längre väntan. Med jitter spreds samma hundra över samma fönster i grupper på runt trettio och tömdes nästan omedelbart.

Den andra är variansen. Utan jitter: 65,6 s, 245,7 s, 225,6 s. Med det: 2,2, 2,3, 1,8. Ett system utan jitter presterar inte bara dåligt, det presterar oförutsägbart, eftersom utfallet avgörs av mikroskopiska schemaläggningsolyckor som väljer vilka tre av hundra synkroniserade klienter som kommer först. Det är signaturen för den här buggen i produktion: en endpoint som är okej, okej, okej, och sedan tar fyra minuter, utan att någon ändring från dig förklarar det.

Och det billigaste retry är det som aldrig händer. Sätt en concurrency gate framför leverantören — en räknare som aldrig låter fler än N begäranden vara pågående — och samma tjugo klienter som behövde 74 begäranden och 7,1 sekunder beter sig så här:

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

Tjugo begäranden för tjugo svar, noll avvisningar, åtta gånger snabbare. Ett retry är ursäkten; gaten är att inte behöva en.

När en provider returnerar 429 säger den oftast hur länge du ska vänta, i Retry-After-headern.3 Den siffran är inte ett råd: providern är den enda parten i utbytet som vet när dess fönster återställs.

Så väntan är den större av de två: aldrig mindre än Retry-After, och aldrig mindre än din egen backoff heller, eftersom headern säger när limitern förlåter dig och inte när servern har plats.

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

Spåret från den mest otursamma klienten i tjugo-klienterskörningen visar hur headern gör sitt jobb. Dess första fyra backoff-dragningar var alla under en sekund, och alla fyra åsidosattes:

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

Två praktiska anteckningar. Retry-After kan vara ett HTTP-datum snarare än ett antal sekunder, så tolka båda. Och providers rate-limit:artvå axlar samtidigt — begäranden per minut och tokens per minut — vilket är varför långa prompts avvisas långt under den dokumenterade request limit. Headern ser likadan ut i båda fallen; fixen gör det inte.

Be mock provider om /hang. Den accepterar anslutningen och gör sedan ingenting alls: inga headers, ingen body, ingen close. Det här är inte exotiskt — det är vad en load balancer gör när processen bakom den har dött utan att stänga sina sockets.

Två klienter, en skillnad:

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

Trehundra sekunder. Fem minuter med en socket öppen, en request slot upptagen och en användare som stirrar på en spinner, vilket slutar i en generisk TypeError som inte säger något om vad som hände. Den siffran är inte en bugg: den är Nodes standard-timeout för headers, rimlig för en generisk HTTP-klient och katastrofal för en användarnära begäran. Varje runtime har en sådan standard, de flesta slår aldrig upp den, och det enda sättet att hitta din är att hänga en socket med avsikt som vi just gjorde.

Alltså: varje utgående begäran får en explicit deadline, vald av dig.

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

För ett streaming-anrop räcker inte en deadline, eftersom det finns två olika fel. Det första är streamen öppnas aldrig: inget event kommer alls, och tio till trettio sekunder är rätt. Det andra är streamen öppnas och stannar sedan: tokens flödade och slutade sedan, för alltid, medan socketen fortfarande är frisk. En total-duration timeout kan inte skilja en stannad stream från ett långt korrekt svar, så det du vill ha är en idle timeout — en timer som reset:as av varje event och bara löser ut när inget har kommit på, säg, femton sekunder.

Cancellation är samma mekanik riktad mot en person. AbortSignal.timeout och en användare som trycker på Stopp kommer båda som en AbortError, så kombinera dem och registrera vilken som löste ut:

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

Att abort:a spelar roll av en anledning bortom prydlighet: tokens genereras och faktureras medan du inte lyssnar. Kapitel 16 sätter ett pris på det.

Nu felet som kostar pengar snarare än tid. En begäran går ut på timeout hos klienten, och det uppenbara draget är att skicka den igen — men en timeout säger ingenting om huruvida servern tog emot den. Väldigt ofta gjorde den det, och arbetar fortfarande.

Uppmätt. Mock provider behöver 780 ms för svaret. Klienten ger upp vid 300 ms och retry:ar. Servern räknar hur många svar den faktiskt genererade, vilket är vad den skulle fakturera:

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

Utan en nyckel: två fulla generationer, betalda två gånger, och klienten tog emot ingen av dem. Med en nyckel: servern kände igen den andra begäran som samma begäran och svarade direkt med svaret den redan hade producerat, så retry:t undvek både dubbeldebiteringen och blev försöket som till slut lyckades.

En idempotency key är en unik sträng du genererar per logisk operation — inte per försök — och skickar oförändrad på varje retry av den. Servern lagrar utfallet mot nyckeln och spelar upp det igen. Det är mekanismen betalnings-API:er använder, av samma skäl.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");
}

Två ärliga begränsningar. Alla providers stöder inte idempotency keys på completions, och där endpointen inte är idempotent är det korrekta antalet retries för en POST som redan kan ha körts noll. Och en stream som misslyckades halvvägs går i allmänhet inte att spela upp igen: du startar antingen om den och betalar igen, eller behåller den partiella texten och markerar den ofullständig. Vilket av dem din produkt gör är ett produktbeslut, inte ett nätverksbeslut, och det är värt att fatta med avsikt.

Klienten som skrivits i det här kapitlet har ingen aning om vad som finns bakom porten. Peka dess base URL mot en kommersiell provider och den streamar tokens från en modell med en biljon parametrar. Peka den mot en server byggd på aritmetiken från kapitel 13 — som serverar modellen du pretrained i kapitel 10, med dess KV cache och dess kvantiserade vikter — och samma kod, oförändrad, streamar tokens från en modell du byggde.

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

Den enda raden är skarven i den här kursen. På ena sidan av den finns det de första tretton kapitlen byggde; på den andra finns det de nästa sexton bygger. Gränsen är ren eftersom kontraktet är HTTP och SSE, och ingen sida vet något mer om den andra.

Det är värt att lägga märke till vad du förlorade genom att korsa den. Bakom en kommersiell endpoint kontrollerar du varken vikterna, sampling-implementationen, versionen du pratar med eller om den ändrades i morse. Det du kontrollerar är kontraktet: meddelandena du skickar, deadlinen du sätter, koderna du skiljer på och vad du gör när inget kommer tillbaka. Det är en mindre yta än du hade i kapitel 5, och varje återstående kapitel handlar om att använda den väl.

Du har nu en klient som streamar, ger upp i tid, retry:ar rätt saker och aldrig retry:ar fel saker. Det den skickar är fortfarande vad du än skrev.

Kapitel 15 handlar om det innehållet, och det kommer med en disciplin. Internet är fullt av prompting-råd — erbjud modellen dricks, hota den, säg åt den att ta ett djupt andetag — och nästan inget av det kommer med en mätning. Vissa av dessa tekniker flyttar output ganska mycket, vissa flyttar den inte alls, och minst en gör en klassificeringsuppgift sämre samtidigt som den kostar fler tokens. Vilken som är vilken går inte att se genom att läsa dem, och det avgörs inte genom argument.

Så nästa kapitel bygger en bench: sextio fall med kända svar, fyra varianter av samma prompt, körda parallellt genom exakt klienten du just skrev, tabellerade med confidence intervals från kapitel 4 — eftersom fyra varianter över tjugo fall inte skiljer någonting alls. En mening styr hela kapitlet: en prompt mäts, den debatteras inte.


Varje siffra ovan kom från mock provider, på Node 22 över ett loopback-interface, så latencies är renare än något verkligt nätverk kommer att ge dig. Det är avsiktligt: inget av felen som mäts orsakas av nätverket, och en fientlig server du kan starta om lär bättre än en riktig som du måste betala för och inte kan ha sönder.

  1. Server-Sent Events, WHATWG HTML Living Standard, avsnitt 9.2. Wire format — data:-fält, events separerade av tomma rader, id: och retry: — definieras där, tillsammans med EventSource-gränssnittet. EventSource kan inte skicka en request body eller custom headers, vilket är varför varje LLM-klient tolkar formatet för hand över fetch i stället för att använda det.

  2. Brooker, M. Exponential Backoff and Jitter. AWS Architecture Blog (2015). Källan till "full jitter"-formuleringen som används ovan, med simuleringarna som visar varför den naiva versionen synkroniserar klienter. Det kompletterande argumentet för shedding load snarare än att köa den finns i kapitlet Handling Overload i Beyer, Jones, Petoff och Murphy (red.), Site Reliability Engineering (O'Reilly, 2016).

  3. Fielding, R., Nottingham, M. och Reschke, J. (red.), HTTP Semantics, RFC 9110, avsnitt 15, definierar statuskodsklasserna; Nottingham, M. och Fielding, R., Additional HTTP Status Codes, RFC 6585 (2012), avsnitt 4, definierar 429 Too Many Requests. Retry-After är RFC 9110 avsnitt 10.2.3 och accepterar antingen ett antal sekunder eller ett HTTP-datum.

  4. Stripe, Idempotent requests, docs.stripe.com/api/idempotent_requests, läst 7 september 2026 — den tydligaste beskrivningen av kontraktet: en nyckel per logisk operation, lagrade resultat spelas upp igen, en konflikt returneras medan det första försöket fortfarande pågår — och mönstret är provider-oberoende. De normativa referenserna för request- och event-formerna som används här är developers.openai.com/api/reference/resources/chat för streaming, error codes och rate limits, och platform.claude.com/docs/en/api/messages för Messages API; ai-sdk.dev/docs är det bästa genomarbetade exemplet på samma frågor insvepta i ett bibliotek. Alla lästes samma dag.


Skapad av

David Vicente Campos

Grundare av NeuraLIA Labs och medgrundare av MyRealFood

Jag är dataingenjör från Universitetet i León. Jag var med och grundade MyRealFood, där jag som CTO byggde appen som miljontals människor har använt för att äta bättre, och jag grundade NeuraLIA Labs, där jag bygger AI-produkter. Här skriver jag om det jag har behövt förstå längs vägen, så som jag önskar att någon hade förklarat det för mig.

Mer om författaren

Publicerad av NeuraLIA Labs.

Få nya inlägg i din inkorg

AI-nyheter, guider och produktuppdateringar — ett kort mejl när vi publicerar något som är värt din tid.

Kursindex

Abstract software decision engine with branching paths, probability nodes, and glowing gates.
jevLästid 11 min

Jevs AI-modell är byggd för beslut, inte prosa

TypeSafe AI:s Jev väcker uppmärksamhet eftersom den behandlar mjukvaruintelligens som ett sannolikhetsproblem: välj rätt gren, lägg till konfidens och undvik att betala en LLM för att skriva text när koden behöver ett beslut.

Abstract agent runtime sorting documents, memory blocks and pointer nodes inside a bounded context frame.
context-engineeringLästid 11 min

Kontextteknik för AI-agenter med lång horisont

Långkörande agenter misslyckas inte bara för att fönstret är litet. De misslyckas när filer, verktygsutdata och gammal historik tränger undan uppgiften agenten skulle slutföra.

Redo att låta LIA välja åt dig?

Bygg med alla AI-modeller på ett ställe – kom igång gratis i dag.