Ensimmäinen tuotannon LLM-kutsusi: streaming, retries ja timeouts
Rakenna provider, joka valehtelee: 429:t, jumittuneet socketit ja katkeavat streamit. Full jitter: 2,2 sekuntia vs 226.
Tällä sivulla
Luku 13 päättyi sekuntikelloon mallissa, johon voit koskea. Painot olivat muistissasi, KV cache oli sinun otettavissasi käyttöön tai pois, ja ulos tullut luku — aika ensimmäiseen tokeniin — oli laitteistosi ominaisuus.
Laita nyt tuo malli portin taakse, kuten jokainen tuote tekee, ja lue sama luku uudelleen. Se on edelleen aika ensimmäiseen tokeniin, mutta se ei ole enää minkään hallitsemasi ominaisuus. Siihen sisältyy nyt TLS-kättely, providerin jono, rate limiter ja mahdollisuus, ettei yhtäkään tokenia koskaan saavu.
Tuo viimeinen lauseke on tämä luku. Koodi, jonka aiot kirjoittaa, ei laske mitään. Se avaa yhteyden, odottaa, parsii saapuvan datan, päättää mitä tehdä, kun mitään ei saavu, päättää uudelleen, kun saapuva asia on virhe, ja peruu itsensä, kun käyttäjä muuttaa mielensä. Jokainen näistä on päätös tilasta ajan yli, ja jokaisella on väärä vastaus, joka päätyy tuotantoon ja maksaa rahaa.
Tässä on ongelman muoto mitattuna, kokonaan tässä luvussa:
| mitä tapahtui | mitä huolimaton client tekee | mitä se maksaa |
|---|---|---|
| server hyväksyi socketin eikä koskaan vastannut | odottaa | 300,8 s ennen kuin Node luovuttaa itse |
| avain oli väärä (401) | yrittää viisi kertaa uudelleen | 6 325 ms viivettä, sitten sama 401 |
| sata clientiä osuu rate limitiin yhtä aikaa | kaikki retry samalla aikataululla | 226 s purkaa, verrattuna 2,2 s |
| request aikakatkaistiin ja lähetettiin uudelleen | lähettää sen uudelleen | provider generoi — ja laskuttaa — vastauksen kahdesti |
| yhteys katkesi kesken vastauksen | näyttää osittaisen tekstin | erottamaton oikeasta lyhyestä vastauksesta |
Mikään näistä ei ole mallinnusongelma. Kaikki ne ovat jokaisen koskaan kirjoitetun LLM-tuotteen ensimmäisessä sadassa rivissä.
Miksi tämä luku vaihtaa kieltä
Linkki osioon: Miksi tämä luku vaihtaa kieltäLue tuo taulukko uudelleen ja kysy, millaista ohjelmaa se kuvaa. Se pitää yhteyden auki neljäkymmentä sekuntia. Sen pitää olla peruttavissa painikkeesta. Se kerää osittaista vastausta, joka on kelvollinen näytettäväksi ja kelvoton tallennettavaksi. Ja se pyörii server-prosessissa tai edge workerissa, vastauksen renderöivän asian vieressä, socketia pitäen.
Se ei ole notebook. Ei siksi, etteikö Python voisi tehdä sitä — voi, ja ihmiset tekevät — vaan siksi, että kaikki, mitä edelliset kolmetoista lukua rakensivat, oli eri tyyppistä. Luvut 1–13 pitivät käsissään painoja, gradientteja, logit-arvoja ja tokenizer-tavuja. Tästä eteenpäin koodi pitää käsissään yhteyttä, retryä, peruutusta, kertynyttä tilaa ja myöhemmin lupapromptia. Kurssi vaihtaa kieltä täsmälleen siinä saumassa, jossa objekti vaihtuu.
Sääntö siis kirjoitettuna kerran:
Jos koodilla on käsissään painoja, gradientteja, logit-arvoja tai tokenizer-tavuja, se on Pythonia. Jos se pitää yhteyttä, tekee retries, peruu, kerää tilaa ja kysyy lupaa, se on TypeScriptiä.
Sauma on yksi, ja se osuu tähän, luvun 13 ja luvun 14 väliin. Kolme riippumatonta kriteeriä asettaa sen tähän.
Yksi: ekosysteemi, laskettuna. Kaikki, mihin tämän kurssin vasen puolisko viittaa, on Pythonia, eikä kahdestatoista tätä oppimäärää varten auditoidusta kurssista löydy yhtäkään ennakkotapausta, jossa backpropagation opetetaan toisella kielellä: micrograd (17,4K tähteä), nanoGPT (62,8K), nanochat (57,8K), minbpe (10,7K), PyTorch (102,8K), transformers (164,9K). Luvun 5 kirjoittaminen TypeScriptillä katkaisisi yhteyden noihin lähteisiin, ja yhteydet ovat puolet sellaisen luvun arvosta, joka on olemassa viitattavaksi eikä sijoittuakseen hakutuloksissa. Tällä puolella aritmetiikka kääntyy: Vercelin ai-paketilla on 89,4M latausta kuukaudessa ja se toimittaa itse asian — tool-calling agent loopin, vietynä nimellä ToolLoopAgent — joten konseptilla, johon tämä kurssi pääsee luvussa 23, on referenssitoteutus TypeScriptissä, vaikka, kuten tuo luku mittaa, kukaan ei ole sopinut sille nimeä; Mastralla on 27,7K tähteä; ja Anthropic SDK:t, jotka generoidaan yhdestä spesifikaatiosta, ilmoittavat 202 endpointia TypeScriptissä ja 201 Pythonissa — pariteetti, ei kohteliaisuusporttaus.
Kaksi: MCP:n normatiivinen lähde. Model Context Protocol -spesifikaation schema on schema.ts-tiedosto. Luvun 26 protokollan opettaminen toisella kielellä tarkoittaa sen perustamisasiakirjan käännöksen opettamista.
Kolme: hakukysyntä, korjauksella ilmeiseen arvaukseen. machine learning python on internetin kyllästynein fraasi; ai agent typescript:lla on oma terve long tailinsa. Mutta väite ”MCP-ekosysteemi on enimmäkseen TypeScriptiä” on totta vain laskutavasta riippuen: virallinen rekisteri listaa npm:ssä 8 275 serveriä ja PyPI:ssä 3 603, kun taas latauksissa Python voittaa — 287M kuukaudessa paketille mcp plus 72M paketille fastmcp vastaan 195M paketille @modelcontextprotocol/sdk. MCP on täällä ainoa aidosti kaksikielinen alue, minkä vuoksi luku 27 kirjoittaa saman serverin kahdesti eikä teeskentele.
Näytä lisätiedot
Viisi ilmoitettua poikkeusta, jotta sääntö on sääntö eikä slogan.
Luvuissa 17, 20 ja 29 on toinen paneeli Pythonilla: top-p samplingin toteuttaminen tarvitsee todennäköisyysvektorin käteen, eikä HTTP API koskaan anna sellaista; fine-tune-hinnoittelu rehellisesti tarkoittaa yhden ajamista, ja LoRA-adapteri on tusina riviä nn.Module; ja lm-eval-harness, HELM, SWE-bench ja τ-bench ovat Pythonia, joten evaluation harness TypeScriptissä olisi backpropagation-virheen peilikuva. Luku 27 on kaksikielinen, yllä mitatusta syystä. Luku 28 on Markdownia, koska agent skill on SKILL.md-tiedosto, ja ohjelmointikielen antaminen sille tarkoittaisi, ettei formaattia ole ymmärretty.
Kolmeatoista Python-lukua ei heitetä pois. Portin toisella puolella on se, mitä ne rakensivat, ja viimeinen osio täällä yhdistää clientin siihen.
Provider, jonka voit rikkoa
Linkki osioon: Provider, jonka voit rikkoaEt voi oppia mitään tästä oikeaa provideria vasten. Et voi pyytää siltä 429:ää valitsemallasi hetkellä, tai socketia, joka hyväksyy yhteytesi eikä koskaan vastaa, tai streamia, joka pysähtyy sanan keskelle — ja maksaisit jokaisesta kokeesta, vaikka kiinnostavat kokeet ovat niitä, joita ajat sata kertaa.
Siksi tämän kurssipuoliskon ensimmäinen ohjelma ei ole client. Se on vihamielinen server: neljäkymmentä riviä tavallista Nodea, joka puhuu samaa wire protocolia kuin chat completions endpoint ja käyttäytyy pyydettäessä huonosti. Jokainen tämän luvun luku tuli siitä.
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);Neljä vihamielistä käytöstä, rivi kutakin: /hang hyväksyy socketin eikä koskaan kirjoita siihen; /401 hylkää avaimen; kapasiteettitarkistus tuottaa aidon 429:n aidolla Retry-After-headerilla, kun kolme requestia on jo käynnissä; ja ?cut=N hylkää vastauksen puolivälissä joko resetoimalla socketin tai — parametrilla &how=close — sulkemalla sen järjestelmällisesti, mikä osoittautuu erittäin tärkeäksi. Loput on oikea Server-Sent Events -stream: yksi JSON-objekti per data:-rivi, tyhjä rivi eventtien välissä, merkkijono [DONE] lopussa.1
Aja se, ja loput luvusta on mittausta.
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 ja avain, joka ei koskaan lähde serveriltä
Linkki osioon: Request body ja avain, joka ei koskaan lähde serveriltäChat request on lista viestejä, joista jokaisella on rooli. Tuo lista on mallin koko tila: kutsujen välillä ei ole muistia, ja kaiken, mitä haluat mallin tietävän, pitää olla siinä arrayssa, jonka lähetät tällä kertaa. Luku 15 käsittelee, mitä siihen laitetaan, ja luku 16, mitä se maksaa, joten tässä se on vain muoto.
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,
};Nuo roolit eivät ole koristeita. Ne renderöidään luvun 11 chat templateen ennen kuin malli näkee yhtäkään tokenia, minkä vuoksi väärän roolin lähettäminen heikentää vastausta hiljaisesti virheen nostamisen sijaan.
Yksi sääntö ilman poikkeuksia: API-avain ei koskaan kulje clientille. Ei selaimelle prefiksatussa ympäristömuuttujassa, ei build-time-vakiossa, ei ”väliaikaisesti”. Bundlessa oleva avain on muutamassa päivässä avain jonkun muun laskulle. Selain puhuu serverillesi, serverisi pitää avaimen ja puhuu providerille — ja koska serverisi on välissä, se on myös ainoa paikka, joka voi mitata, paljonko kukin käyttäjä kuluttaa, eli paikka, jossa luvun 16 kirjanpidon on elettävä.
Sama kysymys, kolme kertaa
Linkki osioon: Sama kysymys, kolme kertaaNyt koe, jonka varaan luku on rakennettu. Yksi kysymys, yksi mock provider, joka tuottaa kolmetoista tokenia 60 ms välein, kolme tapaa kysyä.
Ensin ilman streamingia. Client lähettää requestin ja odottaa koko JSON bodyn.
blocking first visible = 791 ms complete = 791 ms finish_reason = stopKaksi lukua ovat samat, ja se on koko ongelma. 791 ms ajan käyttäjällä on spinner, eikä yksikään sana ollut saatavilla aiemmin — serverillä oli vastaus, tavu tavulta, ja se päätti olla sanomatta mitään.
Toiseksi streamingin kanssa. Sama server, sama vastaus, sama kokonaistyö. Ero on parseri.
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);
}
}
}
}Kolme yksityiskohtaa siinä kantaa kuormaa, ja useimmat ensimmäiset yritykset ohittavat kaikki kolme. buffer on olemassa, koska network chunkilla ei ole suhdetta eventtiin: yksi read() voi palauttaa puolikkaan eventin tai kaksi ja puoli. { stream: true }-flag on olemassa, koska monen tavun UTF-8-merkki voi jakaantua kahden chunkin yli, ja ilman sitä aksentoitu kirjain muuttuu satunnaisesti korvausmerkiksi. Ja eventit erotetaan tyhjällä rivillä, ei rivinvaihdolla, minkä vuoksi loop etsii kohtaa \n\n.
streaming first visible = 65 ms complete = 793 ms finish_reason = stopKaksitoista kertaa nopeampi ensimmäiseen sanaan, ja kaksi millisekuntia hitaampi viimeiseen. Streaming ei tee mitään nopeammaksi. Se muuttaa sen, mitä käyttäjä tekee saman 790 ms aikana: lukee odottamisen sijaan. Se on koko hyöty, se on valtava, ja se on syy, miksi jokainen chat-tuote streamaa.
Kolmanneksi kahdellakymmenellä clientillä yhtä aikaa. Mock provider palvelee kolmea requestia kerrallaan. Laukaise kaksikymmentä:
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: trueKaksikymmentä vastausta, seitsemänkymmentäneljä requestia, viisikymmentäneljä hylkäystä. Kukaan ei menettänyt mitään, jokainen client sai saman tekstin, ja ainoa näkyvä kustannus oli aika. Se on retry policy toiminnassa. Loput tästä luvusta käsittelee kolmea tapaa, joilla se voi sen sijaan epäonnistua.
finish_reason ja kaksi loppua, jotka näyttävät samalta
Linkki osioon: finish_reason ja kaksi loppua, jotka näyttävät samaltaEnnen virheitä kenttä, jonka lähes kaikki ohittavat ensimmäisellä kierroksella. Jokainen stream päättyy eventtiin, jossa on finish_reason. stop tarkoittaa, että malli päätti olevansa valmis. length tarkoittaa, että se osui token-kattoon, joten vastaus katkeaa kesken virkkeen eikä se ole mallin vika. Myöhemmät luvut lisäävät tool_calls (luku 18) ja sisältösuodattimet.
Katso nyt kahta loppua, joita naiivi client ei pysty erottamaan. Sama server, sama viive, toinen typistetty parametrilla max_tokens ja toinen, jossa yhteys suljetaan siististi viiden tokenin jälkeen:
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"Lue kaksi ensimmäistä riviä huolellisesti. Identtinen teksti. Identtinen chunk-määrä. Ei poikkeusta kummassakaan tapauksessa. for await-loop päättyi normaalisti molemmilla kerroilla, koska readerin näkökulmasta body päättyi, ja siinä kaikki mitä body voi tehdä. Ainoa ero koko havainnossa on, että toisessa on finish_reason: "length" ja toisessa ei ole yhtään mitään.
Sääntö ei siis ole ”ota virheet kiinni streamingin aikana”. Se on:
Stream, joka päättyy ilman
finish_reason, ei päättynyt. Se pysähtyi.
Käsittele puuttuvaa finish_reason-arvoa aina epäonnistumisena, äläkä koskaan tallenna tuota tekstiä valmiina vastauksena. Kolmas rivi näyttää helpomman tapauksen — tuhottu socket heittää poikkeuksen, ja se myös menettää lennossa olleen chunkin, minkä vuoksi teksti on yhtä sanaa lyhyempi kuin kaksi yllä.
Viisi status codea, jotka ovat viisi eri ongelmaa
Linkki osioon: Viisi status codea, jotka ovat viisi eri ongelmaaKallein tapa, joka uudella tuotteella on, on yksi catch-lohko kaikelle, mitä provider palauttaa. Nämä koodit eivät ole muunnelmia asiasta ”se epäonnistui”. Ne ovat viisi ohjetta, ja neljä niistä on keskenään ristiriidassa.
| status | mitä se tarkoittaa | mitä tehdä | odota? |
|---|---|---|---|
| 400 | request on virheellinen — huono JSON, tuntematon kenttä, context liian pitkä | korjaa koodi | ei koskaan |
| 401 | avain on väärä, puuttuu tai peruttu | korjaa deployment | ei koskaan |
| 429 | rate limit: liikaa requesteja tai liikaa tokeneita minuutissa | retry | Retry-After, sitten backoff |
| 500 | provider hajosi | retry | backoff |
| 503 | provider on ylikuormittunut — se on pystyssä, se on täynnä | retry | backoff, ja pudota kuormaa |
Tärkeä raja kulkee 4xx-koodien ja muiden välillä. 400 tai 401 palauttaa täsmälleen saman vastauksen, vaikka lähettäisit sen tuhat kertaa, koska kummassakaan päässä mikään ei muutu yritysten välillä. Sen retry ei ole varovaisuutta, se on viive lisävaiheilla. Mitattuna: yksi client tekee kuusi yritystä — viisi retryä exponential backoffilla — ja toinen lukee koodin ensin.
retry everything -> 6 requests, gave up after 6,325 ms, still HTTP 401
triage first -> 1 request, gave up after 4 ms, still HTTP 401Kuusi sekuntia spinneriä päästäkseen vastaukseen, joka oli saatavilla neljässä millisekunnissa. Ja tämä on lievä versio: retries ovat tuotteessa yleensä sisäkkäisiä — retryävä HTTP client retryävän job runnerin sisällä jonon sisällä, jolla on oma redelivery — joten kuusi sekuntia muuttuu kuudeksi minuutiksi pysyvästi rikkinäistä deploymentia, joka näyttää hitaalta.
Triage on yhdeksän riviä ja kuuluu yhteen paikkaan:
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
}Kaksi lisää listaasi: 402, jota jotkut providerit käyttävät merkityksessä ”creditit ovat loppu” ja joka tarvitsee näkymän, jossa on linkki ostaa lisää, ei retryä, sekä 529 tai sen vendor-kohtaiset vastineet, jotka käyttäytyvät kuin 503.
Backoff ja mitä jitter oikeasti ostaa
Linkki osioon: Backoff ja mitä jitter oikeasti ostaaRetry on helppoa. Retry milloin on osa, jolla on mitattava oikea vastaus.
Exponential backoff on standardi: odota perusviive, tuplaa se jokaisen epäonnistumisen jälkeen, pysähdy kattoon. Se on olemassa, koska ylikuormittunut server pahenee, jos juuri epäonnistuneet clientit tulevat suoraan takaisin.
Ongelma on, että kaikki tuplaavat samasta lähtöpisteestä. Jos sata clientiä osuu limittiin samalla hetkellä — ja ne osuvat, koska juuri sitä liikennepiikki on — kaikki sata odottavat 200 ms, kaikki sata yrittävät uudelleen yhdessä, kaikki sata epäonnistuvat yhdessä ja kaikki sata odottavat 400 ms. Retry-aikataulu on synkronoinut ne. Se on thundering herd, ja satunnaisuus on korjaus.2
Tuo yksi muutos — valitaan tasaisesti intervallista sen ylärajan ottamisen sijaan — on nimeltään full jitter. Se on yksi kutsu funktioon Math.random(), ja se kannattaa mitata eikä uskoa:
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); Sata clientiä, yksi server, joka palvelee kolmea kerrallaan, kaikki muu identtistä, kolme ajoa kutakin:
| HTTP requests | hylkäykset | huonoin client | vilkkain 50 ms ikkuna | wall clock | |
|---|---|---|---|---|---|
| ei jitteriä, ajo 1 | 491 | 391 | 10 yritystä | 46 saapumista | 65,6 s |
| ei jitteriä, ajo 2 | 780 | 680 | 19 yritystä | 72 saapumista | 245,7 s |
| ei jitteriä, ajo 3 | 770 | 670 | 18 yritystä | 97 saapumista | 225,6 s |
| full jitter, ajo 1 | 324 | 224 | 5 yritystä | 32 saapumista | 2,2 s |
| full jitter, ajo 2 | 313 | 213 | 6 yritystä | 31 saapumista | 2,3 s |
| full jitter, ajo 3 | 318 | 218 | 6 yritystä | 25 saapumista | 1,8 s |
Kaksi asiaa tuossa taulukossa, ja toinen on tärkeä.
Ensimmäinen on mediaani: 226 sekuntia vastaan 2,2, noin sadan kerroin, alle puolella request-määrällä. Vilkkain retry-ikkuna kertoo miksi. Ilman jitteriä jopa 97 sadasta clientistä saapui saman 50 millisekunnin slotin sisällä; serverillä oli kolme, joten 94 hylättiin ja meni nukkumaan yhdessä, yhä synkronoituina, tehdäkseen sen uudelleen pidemmällä odotuksella. Jitterin kanssa samat sata jakautuivat samoihin ikkunoihin noin kolmenkymmenen ryhmissä ja purkautuivat lähes heti.
Toinen on varianssi. Ilman jitteriä: 65,6 s, 245,7 s, 225,6 s. Sen kanssa: 2,2, 2,3, 1,8. Järjestelmä ilman jitteriä ei pelkästään suoriudu huonosti, se suoriutuu ennustamattomasti, koska lopputuloksen ratkaisevat mikroskooppiset ajoitusvahingot, jotka valitsevat, mitkä kolme sadasta synkronoidusta clientistä saapuvat ensin. Se on tämän bugin allekirjoitus tuotannossa: endpoint, joka on hyvä, hyvä, hyvä, ja sitten kestää neljä minuuttia, eikä mikään oma muutos selitä sitä.
Ja halvin retry on se, jota ei koskaan tapahdu. Laita providerin eteen concurrency gate — laskuri, joka ei koskaan salli enempää kuin N requestia yhtä aikaa lennossa — ja samat kaksikymmentä clientiä, jotka tarvitsivat 74 requestia ja 7,1 sekuntia, käyttäytyvät näin:
client-side gate of 3: 20 HTTP requests, 0 429s, wall 883 msKaksikymmentä requestia kahteenkymmeneen vastaukseen, nolla hylkäystä, kahdeksan kertaa nopeampi. Retry on anteeksipyyntö; gate on sitä, ettei sellaista tarvita.
Retry-After on lattia, ei ehdotus
Linkki osioon: Retry-After on lattia, ei ehdotusKun provider palauttaa 429:n, se yleensä kertoo, kuinka kauan odottaa, Retry-After-headerissa.3 Tuo numero ei ole neuvo: provider on vaihdon ainoa osapuoli, joka tietää, milloin sen ikkuna nollautuu.
Odotus on siis näistä kahdesta suurempi: ei koskaan vähemmän kuin Retry-After eikä koskaan vähemmän kuin oma backoffisikaan, koska header kertoo, milloin limiter antaa anteeksi, ei milloin serverillä on tilaa.
const header = res.headers.get("retry-after");
const floor = header ? Number(header) * 1000 : 0; // seconds -> ms
const wait = Math.max(floor, backoffFull(attempt)); Kahdenkymmenen clientin ajon epäonnisimman clientin trace näyttää headerin tekevän työnsä. Sen neljä ensimmäistä backoff-arvontaa olivat kaikki alle sekunnin, ja kaikki neljä ohitettiin:
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 msKaksi käytännön huomiota. Retry-After voi olla HTTP date eikä sekuntimäärä, joten parsi molemmat. Ja providerit rate-limit kahdella akselilla yhtä aikaa — requestit minuutissa ja tokenit minuutissa — minkä vuoksi pitkät promptit hylätään paljon dokumentoitua request-limittiä alempana. Header näyttää samalta molemmissa tapauksissa; korjaus ei.
Timeout, jota kukaan ei valinnut
Linkki osioon: Timeout, jota kukaan ei valinnutPyydä mock providerilta /hang. Se hyväksyy yhteyden eikä tee sitten mitään: ei headereita, ei bodya, ei sulkemista. Tämä ei ole eksoottista — näin load balancer tekee, kun sen takana oleva prosessi on kuollut sulkematta socketejaan.
Kaksi clientiä, yksi ero:
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_TIMEOUTKolmesataa sekuntia. Viisi minuuttia auki pidettyä socketia, varattu request-slot ja spinneriä tuijottava käyttäjä, päättyen geneeriseen TypeError-virheeseen, joka ei kerro mitään tapahtuneesta. Tuo luku ei ole bugi: se on Noden oletus headers timeout, järkevä geneeriselle HTTP clientille ja katastrofaalinen käyttäjälle näkyvälle requestille. Jokaisella runtimella on tällainen oletus, useimmat eivät koskaan tarkista sitä, ja ainoa tapa löytää omasi on ripustaa socket tarkoituksella niin kuin juuri teimme.
Siis: jokainen lähtevä request saa eksplisiittisen deadlinen, jonka sinä valitset.
const res = await fetch(url, {
method: "POST",
headers: { "content-type": "application/json", authorization: `Bearer ${key}` },
body: JSON.stringify(payload),
signal: AbortSignal.timeout(20_000),
});Streaming-kutsulle yksi deadline ei riitä, koska on kaksi eri epäonnistumista. Ensimmäinen on stream ei koskaan avaudu: yhtään eventtiä ei saavu, ja kymmenestä kolmeenkymmeneen sekuntia on oikein. Toinen on stream avautuu ja sitten stallaa: tokenit virtasivat ja sitten pysähtyivät ikuisiksi ajoiksi, socket yhä terveenä. Kokonaiskeston timeout ei erota stallaavaa streamia pitkästä oikeasta vastauksesta, joten haluat idle timeoutin — ajastimen, jonka jokainen event resetoi ja joka laukeaa vain, kun mitään ei ole saapunut vaikkapa viiteentoista sekuntiin.
Cancellation on sama koneisto ihmiseen suunnattuna. AbortSignal.timeout ja Stop-painiketta painava käyttäjä saapuvat molemmat muodossa AbortError, joten yhdistä ne ja kirjaa kumpi laukesi:
const user = new AbortController();
const signal = AbortSignal.any([user.signal, AbortSignal.timeout(20_000)]);
// stopButton.onclick = () => user.abort();Keskeyttämisellä on merkitystä siisteyttä suuremmasta syystä: tokenit generoidaan ja laskutetaan samalla, kun et kuuntele. Luku 16 antaa sille hinnan.
Mitä on turvallista retry
Linkki osioon: Mitä on turvallista retryNyt epäonnistuminen, joka maksaa rahaa ajan sijaan. Request aikakatkaistaan clientillä, ja ilmeinen liike on lähettää se uudelleen — mutta timeout ei kerro mitään siitä, vastaanottiko server sen. Hyvin usein vastaanotti ja tekee yhä töitä.
Mitattuna. Mock provider tarvitsee vastaukseen 780 ms. Client luovuttaa 300 ms kohdalla ja yrittää uudelleen. Server laskee, montako vastausta se todella generoi, eli mistä se laskuttaisi:
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): 1Ilman avainta: kaksi täyttä generointia, maksettu kahdesti, eikä client saanut kumpaakaan. Avaimen kanssa: server tunnisti toisen requestin samaksi requestiksi ja vastasi heti jo tuottamallaan vastauksella, joten retry sekä vältti tuplalaskun että oli yritys, joka lopulta onnistui.
Idempotency key on yksilöllinen merkkijono, jonka generoit per looginen operaatio — et per yritys — ja lähetät muuttumattomana jokaisella sen retryllä. Server tallentaa lopputuloksen avainta vasten ja toistaa sen. Se on mekanismi, jota payment API:t käyttävät samasta syystä.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");
}Kaksi rehellistä rajaa. Kaikki providerit eivät tue idempotency keys completions-endpointeissa, ja kun endpoint ei ole idempotentti, oikea retries-määrä POSTille, joka on saattanut jo ajaa, on nolla. Ja stream, joka epäonnistui puolivälissä, ei ole yleisessä tapauksessa toistettavissa: joko käynnistät sen uudelleen ja maksat taas, tai pidät osittaisen tekstin ja merkitset sen keskeneräiseksi. Kumman tuotteesi tekee, on tuotepäätös, ei verkkopäätös, ja se kannattaa tehdä tarkoituksella.
Sauman sulkeminen
Linkki osioon: Sauman sulkeminenTässä luvussa kirjoitettu client ei tiedä, mitä portin takana on. Osoita sen base URL kaupalliseen provideriin, ja se streamaa tokeneita biljoonan parametrin mallista. Osoita se serveriin, joka on rakennettu luvun 13 aritmetiikan päälle — palvelemaan mallia, jonka pretrained luvussa 10, sen KV cachella ja kvantisoiduilla painoilla — ja sama koodi, muuttumattomana, streamaa tokeneita rakentamastasi mallista.
const BASE = process.env.LLM_BASE_URL ?? "http://127.0.0.1:8000/v1"; Tuo yksi rivi on tämän kurssin sauma. Sen toisella puolella on se, mitä ensimmäiset kolmetoista lukua rakensivat; toisella se, mitä seuraavat kuusitoista rakentavat. Raja on siisti, koska sopimus on HTTP ja SSE, eikä kumpikaan puoli tiedä toisesta mitään muuta.
Kannattaa huomata, mitä menetit ylittäessäsi sen. Kaupallisen endpointin takana et hallitse painoja, sampling-toteutusta, versiota, jolle puhut, etkä sitä, muuttuiko se tänä aamuna. Hallitset sopimusta: viestejä, jotka lähetät, deadlinea, jonka asetat, koodeja, jotka erotat, ja sitä, mitä teet, kun mitään ei tule takaisin. Se on pienempi pinta kuin sinulla oli luvussa 5, ja jokainen jäljellä oleva luku käsittelee sen käyttämistä hyvin.
Mihin tämä menee seuraavaksi
Linkki osioon: Mihin tämä menee seuraavaksiSinulla on nyt client, joka streamaa, luovuttaa ajoissa, retry oikeat asiat eikä koskaan retry vääriä. Se, mitä se lähettää, on yhä mitä ikinä kirjoitit.
Luku 15 käsittelee tuota sisältöä, ja siihen tulee kuri. Internet on täynnä prompting-neuvoja — tarjoa mallille tippiä, uhkaa sitä, käske sitä hengittämään syvään — eikä juuri mikään niistä saavu mittauksen kanssa. Jotkut näistä tekniikoista liikuttavat outputia paljon, jotkut eivät lainkaan, ja ainakin yksi tekee classification-tehtävästä huonomman samalla kun se maksaa enemmän tokeneita. Mikä on mikä ei ole ilmeistä niitä lukemalla, eikä se ratkea väittelemällä.
Seuraava luku rakentaa siis benchin: kuusikymmentä tapausta tunnetuilla vastauksilla, saman promptin neljä varianttia, ajettuna rinnakkain täsmälleen juuri kirjoittamasi clientin läpi, taulukoituna luvun 4 luottamusväleillä — koska neljä varianttia kahdessakymmenessä tapauksessa ei erota yhtään mitään. Yksi lause hallitsee koko lukua: prompt mitataan, siitä ei väitellä.
Lähteet ja menetelmä
Linkki osioon: Lähteet ja menetelmäJokainen yllä oleva luku tuli mock providerista, Node 22:lla loopback-rajapinnan yli, joten latenssit ovat puhtaampia kuin mikään oikea verkko antaa. Se on tarkoituksellista: mikään mitatuista epäonnistumisista ei johdu verkosta, ja vihamielinen server, jonka voit käynnistää uudelleen, opettaa paremmin kuin oikea, josta sinun täytyy maksaa ja jota et voi rikkoa.
Viitteet
Linkki osioon: Viitteet-
Server-Sent Events, WHATWG HTML Living Standard, kohta 9.2. Wire format —
data:-kentät, tyhjillä riveillä erotetut eventit,id:jaretry:— määritellään siellä yhdessäEventSource-rajapinnan kanssa.EventSourceei voi lähettää request bodya tai custom headereita, minkä vuoksi jokainen LLM client parsii formaatin käsinfetch:n päällä sen käyttämisen sijaan. ↩ -
Brooker, M. Exponential Backoff and Jitter. AWS Architecture Blog (2015). Yllä käytetyn ”full jitter” -muotoilun lähde, simulaatioineen, jotka näyttävät miksi naiivi versio synkronoi clientit. Rinnakkaisargumentti kuorman pudottamisesta jonottamisen sijaan on luvussa Handling Overload teoksessa Beyer, Jones, Petoff ja Murphy (toim.), Site Reliability Engineering (O’Reilly, 2016). ↩
-
Fielding, R., Nottingham, M. ja Reschke, J. (toim.), HTTP Semantics, RFC 9110, kohta 15, määrittelee status code -luokat; Nottingham, M. ja Fielding, R., Additional HTTP Status Codes, RFC 6585 (2012), kohta 4, määrittelee 429 Too Many Requests.
Retry-Afteron RFC 9110 kohta 10.2.3 ja hyväksyy joko sekuntimäärän tai HTTP date -arvon. ↩ -
Stripe, Idempotent requests,
docs.stripe.com/api/idempotent_requests, luettu 7. syyskuuta 2026 — selkein lausuma sopimuksesta: yksi avain per looginen operaatio, tallennetut tulokset toistetaan, konflikti palautetaan, kun ensimmäinen yritys on yhä lennossa — ja malli on provider-riippumaton. Normatiiviset viitteet täällä käytettyihin request- ja event-muotoihin ovatdevelopers.openai.com/api/reference/resources/chatstreamingille, error codeille ja rate limiteille sekäplatform.claude.com/docs/en/api/messagesMessages API:lle;ai-sdk.dev/docson paras läpityöstetty esimerkki samoista huolista kirjastoon käärittynä. Kaikki luettu samana päivänä. ↩