Siirry sisältöön
14/30Luku 14/30

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ä tapahtuimitä huolimaton client tekeemitä se maksaa
server hyväksyi socketin eikä koskaan vastannutodottaa300,8 s ennen kuin Node luovuttaa itse
avain oli väärä (401)yrittää viisi kertaa uudelleen6 325 ms viivettä, sitten sama 401
sata clientiä osuu rate limitiin yhtä aikaakaikki retry samalla aikataululla226 s purkaa, verrattuna 2,2 s
request aikakatkaistiin ja lähetettiin uudelleenlähettää sen uudelleenprovider generoi — ja laskuttaa — vastauksen kahdesti
yhteys katkesi kesken vastauksennäyttää osittaisen tekstinerottamaton 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.

Et 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ä.

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

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.

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 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.

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

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ä.

Nyt 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.

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

Kaksi 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.

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

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.

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

Kaksitoista 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ä:

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

Kaksikymmentä 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 samalta

Ennen 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:

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"

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 ongelmaa

Kallein 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.

statusmitä se tarkoittaamitä tehdäodota?
400request on virheellinen — huono JSON, tuntematon kenttä, context liian pitkäkorjaa koodiei koskaan
401avain on väärä, puuttuu tai peruttukorjaa deploymentei koskaan
429rate limit: liikaa requesteja tai liikaa tokeneita minuutissaretryRetry-After, sitten backoff
500provider hajosiretrybackoff
503provider on ylikuormittunut — se on pystyssä, se on täynnäretrybackoff, 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.

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

Kuusi 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:

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
}

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 ostaa

Retry 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

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

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:

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

Sata clientiä, yksi server, joka palvelee kolmea kerrallaan, kaikki muu identtistä, kolme ajoa kutakin:

HTTP requestshylkäyksethuonoin clientvilkkain 50 ms ikkunawall clock
ei jitteriä, ajo 149139110 yritystä46 saapumista65,6 s
ei jitteriä, ajo 278068019 yritystä72 saapumista245,7 s
ei jitteriä, ajo 377067018 yritystä97 saapumista225,6 s
full jitter, ajo 13242245 yritystä32 saapumista2,2 s
full jitter, ajo 23132136 yritystä31 saapumista2,3 s
full jitter, ajo 33182186 yritystä25 saapumista1,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:

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

Kaksikymmentä 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 ehdotus

Kun 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.

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

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:

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

Kaksi 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 valinnut

Pyydä 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:

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

Kolmesataa 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.

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

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:

cancel.tsTS
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.

Nyt 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:

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

Ilman 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

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

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.

Tä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.

switch.tsTS
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.

Sinulla 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ä.


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.

  1. Server-Sent Events, WHATWG HTML Living Standard, kohta 9.2. Wire format — data:-kentät, tyhjillä riveillä erotetut eventit, id: ja retry: — määritellään siellä yhdessä EventSource-rajapinnan kanssa. EventSource ei voi lähettää request bodya tai custom headereita, minkä vuoksi jokainen LLM client parsii formaatin käsin fetch:n päällä sen käyttämisen sijaan.

  2. 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).

  3. 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-After on RFC 9110 kohta 10.2.3 ja hyväksyy joko sekuntimäärän tai HTTP date -arvon.

  4. 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 ovat developers.openai.com/api/reference/resources/chat streamingille, error codeille ja rate limiteille sekä platform.claude.com/docs/en/api/messages Messages API:lle; ai-sdk.dev/docs on paras läpityöstetty esimerkki samoista huolista kirjastoon käärittynä. Kaikki luettu samana päivänä.


Tekijä

David Vicente Campos

NeuraLIA Labsin perustaja ja MyRealFoodin toinen perustaja

Olen valmistunut tietotekniikan insinööriksi Leónin yliopistosta. Olin mukana perustamassa MyRealFoodia, jossa teknologiajohtajana rakensin sovelluksen, jota miljoonat ihmiset ovat käyttäneet syödäkseen paremmin, ja perustin NeuraLIA Labsin, jossa rakennan tekoälytuotteita. Täällä kirjoitan siitä, mitä minun on pitänyt ymmärtää matkan varrella, niin kuin olisin toivonut jonkun selittävän asiat minulle.

Lisää kirjoittajasta

Julkaisija: NeuraLIA Labs.

Uudet julkaisut suoraan sähköpostiisi

AI-uutisia, oppaita ja tuoteuutisia — lyhyt sähköposti, kun julkaisemme jotain aikasi arvoista.

Kurssin hakemisto

Abstract software decision engine with branching paths, probability nodes, and glowing gates.
jev9 min lukuaikaa

Jev AI -malli on rakennettu päätöksiä, ei proosaa varten

TypeSafe AI:n Jev herättää huomiota, koska se käsittelee ohjelmistojen älykkyyttä todennäköisyysongelmana: valitse oikea haara, liitä mukaan varmuus ja vältä maksamasta LLM:lle tekstin kirjoittamisesta, kun koodi tarvitsee päätöksen.

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

Kontekstisuunnittelu pitkän aikavälin AI-agenteille

Pitkäkestoiset agentit eivät epäonnistu vain siksi, että ikkuna on pieni. Ne epäonnistuvat, kun tiedostot, työkalujen tulosteet ja vanhentunut historia syrjäyttävät tehtävän, joka agentin piti saada valmiiksi.

Valmis antamaan LIA:n valita puolestasi?

Rakenna kaikilla tekoälymalleilla yhdessä paikassa — aloita ilmaiseksi jo tänään.