Ugrás a tartalomra
14/3014/30. fejezet

Az első production LLM-hívásod: streaming, retryk, timeoutok

Építs egy providert, amely félrevezet: 429-ek, beragadt socketek, félbeszakadt streamek. Full jitter: 2,2 mp 226 ellen.

Ezen az oldalon

13. fejezet egy stopperórával ért véget egy model mellett, amelyet meg tudtál érinteni. A súlyok a memóriádban voltak, a KV cache engedélyezése vagy letiltása a te kezedben volt, és a kapott szám — az első tokenig eltelt idő — a hardvered tulajdonsága volt.

Most tedd ezt a modelt egy port mögé, ahogy minden product teszi, és olvasd le újra ugyanazt a számot. Még mindig az első tokenig eltelt idő, de már nem olyasmi tulajdonsága, amit te irányítasz. Most már benne van egy TLS handshake, egy sor a providernél, egy rate limiter, és annak lehetősége is, hogy soha egyetlen token sem érkezik meg.

Ez az utolsó tagmondat maga a fejezet. A kód, amelyet most megírsz, nem számol ki semmit. Kapcsolatot nyit, vár, feldolgozza, ami érkezik, eldönti, mit tegyen, ha semmi nem érkezik, újra dönt, amikor ami érkezik, hiba, és megszakítja saját magát, amikor a user meggondolja magát. Ezek mind döntések időben változó állapotról, és mindegyiknek van olyan rossz válasza, amely productionbe kerül és pénzbe kerül.

Íme a probléma formája, mérve, mindez ebben a fejezetben:

mi történtmit csinál egy figyelmetlen kliensmennyibe kerül
a szerver elfogadta a socketet, és soha nem válaszoltvár300,8 mp, mielőtt a Node magától feladja
rossz volt a kulcs (401)ötször újrapróbálja6.325 ms késleltetés, majd ugyanaz a 401
száz kliens egyszerre ütközik rate limitbemind ugyanazon ütemezés szerint próbálja újra226 mp a kiürülésig, szemben 2,2 mp-cel
a request timeoutolt, és újraküldtékújraküldia provider kétszer generálja — és számlázza — a választ
a kapcsolat félbeszakadt válasz közbenmegjeleníti a részleges szövegetmegkülönböztethetetlen egy helyes, rövid választól

Ezek közül egyik sem modelling probléma. Mind ott van minden valaha megírt LLM product első száz sorában.

Olvasd el újra azt a táblázatot, és kérdezd meg, milyen programot ír le. Negyven másodpercig nyitva tart egy kapcsolatot. Egy gombról megszakíthatónak kell lennie. Részleges választ gyűjt, amely megjelenítésre érvényes, mentésre érvénytelen. És egy szerverfolyamatban vagy edge workeren fut, a választ renderelő dolog mellett, socketet tartva.

Ez nem notebook. Nem arról van szó, hogy a Python ne tudná ezt — tudja, és sokan így is csinálják —, hanem arról, hogy mindaz, amit az előző tizenhárom fejezet felépített, más természetű volt. Az 1–13. fejezetek súlyokat, gradienseket, logits értékeket és tokenizer byte-okat tartottak kézben. Innentől a kód kapcsolatot, retryt, megszakítást, felhalmozott állapotot és később permission promptot tart kézben. A kurzus pontosan azon a varraton vált nyelvet, ahol az objektum megváltozik.

Tehát a szabály, egyszer leírva:

Ha a kód súlyokat, gradienseket, logits értékeket vagy tokenizer byte-okat tart a kezében, akkor Python. Ha kapcsolatot tart, retryket kezel, megszakít, állapotot gyűjt és engedélyt kér, akkor TypeScript.

A varrat egyetlen, és itt húzódik, a 13. és 14. fejezet között. Három független kritérium teszi ide.

Egy: az ökoszisztéma, megszámolva. Minden, amire a kurzus bal fele hivatkozik, Python, és a tananyaghoz auditált tizenkét kurzusban nincs egyetlen precedens sem arra, hogy a backpropagationt más nyelven tanítanák: micrograd (17,4K csillag), nanoGPT (62,8K), nanochat (57,8K), minbpe (10,7K), PyTorch (102,8K), transformers (164,9K). Az 5. fejezet TypeScriptben megszakítaná a kapcsolatot ezekkel a forrásokkal, márpedig a hivatkozások adják annak a fejezetnek az értékének felét, amely azért létezik, hogy hivatkozzanak rá, nem azért, hogy rangsoroljon. Ezen az oldalon az aritmetika megfordul: a Vercel ai csomagja havi 89,4M letöltésnél jár, és magát a dolgot szállítja — egy tool-calling agent loopot, ToolLoopAgent néven exportálva —, így annak a fogalomnak, amelyhez a kurzus a 23. fejezetben ér el, TypeScriptben van a referencia-implementációja, még akkor is, ha, ahogy az a fejezet méri, senki nem állapodott meg a nevében; a Mastra 27,7K csillagnál jár; az Anthropic SDK-i pedig, amelyek egyetlen specifikációból generálódnak, TypeScriptben 202 endpointot deklarálnak a Python 201-ével szemben — paritás, nem udvariassági port.

Kettő: az MCP normatív forrása. A Model Context Protocol specifikációjának sémája egy schema.ts fájl. A 26. fejezet protokollját más nyelven tanítani azt jelenti, hogy az alapító dokumentum fordítását tanítjuk.

Három: keresési kereslet, az egyértelmű tipp korrekciójával. A machine learning python a legtelítettebb kifejezés az interneten; a ai agent typescript saját, egészséges hosszú farokkal rendelkezik. De az, hogy „az MCP ökoszisztéma többnyire TypeScript”, csak attól függően igaz, hogyan számolsz: a hivatalos registry 8.275 szervert listáz npm-en, szemben 3.603-mal PyPI-on, miközben letöltésben a Python nyer — havi 287M a mcp plusz 72M a fastmcp esetében, szemben a @modelcontextprotocol/sdk 195M-jával. Az MCP itt az egyetlen valóban kétnyelvű terület, ezért írja meg a 27. fejezet ugyanazt a szervert kétszer, ahelyett hogy úgy tenne, mintha nem így lenne.

Részletek megjelenítése

Az öt deklarált kivétel, hogy a szabály szabály legyen, ne szlogen.

A 17., 20. és 29. fejezet második panelt kap Pythonban: a top-p sampling implementálásához a valószínűségi vektornak a kezedben kell lennie, és egy HTTP API soha nem ad ilyet; egy fine-tune őszinte árazásához futtatni kell egyet, egy LoRA adapter pedig egy tucat sor nn.Module; és a lm-eval-harness, HELM, SWE-bench és τ-bench Python, így egy evaluation harness TypeScriptben a backpropagation hiba tükörképe lenne. A 27. fejezet kétnyelvű, a fent mért okból. A 28. fejezet Markdown, mert egy agent skill maga egy SKILL.md fájl, és programozási nyelvet adni neki azt jelentené, hogy nem értettük meg a formátumot.

A tizenhárom Python-fejezet nincs kidobva. A port másik oldalán az van, amit felépítettek, és az utolsó szakasz itt ehhez kapcsol egy klienst.

Ezt nem lehet valódi provideren megtanulni. Nem kérhetsz tőle 429-et egy kiválasztott pillanatban, vagy olyan socketet, amely elfogadja a kapcsolatot és soha nem válaszol, vagy olyan streamet, amely egy szó közepén áll meg — és minden kísérletért fizetnél, miközben az érdekes kísérletek azok, amelyeket százszor futtatsz le.

Ezért a kurzus ezen felének első programja nem kliens. Hanem egy ellenséges szerver: negyven sor sima Node, amely ugyanazt a wire protocolt beszéli, mint egy chat completions endpoint, és kérésre rosszul viselkedik. A fejezet minden száma ebből jött.

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

Négy ellenséges viselkedés, egy-egy sor: a /hang elfogadja a socketet, és soha nem ír rá; a /401 visszautasítja a kulcsot; a capacity check valódi 429-et ad valódi Retry-After headerrel, amint már három request fut; a ?cut=N pedig félúton feladja a választ, vagy a socket resetelésével, vagy — &how=close mellett — rendezett lezárással, amiről kiderül, hogy nagyon is számít. A többi valódi Server-Sent Events stream: egy JSON objektum minden data: sorban, üres sor az események között, a végén a [DONE] string.1

Futtasd, és a fejezet többi része mérés.

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]

A request body, és a kulcs, amely soha nem hagyja el a szervert

Link a szakaszhoz: A request body, és a kulcs, amely soha nem hagyja el a szervert

Egy chat request üzenetek listája, mindegyik szereppel. Ez a lista a model teljes állapota: a hívások között nincs memória, és bármit szeretnél, hogy a model tudjon, annak abban az arrayben kell lennie, amelyet ezúttal elküldesz. A 15. fejezet arról szól, mit tegyél bele, a 16. fejezet pedig arról, mennyibe kerül, így itt most csak a forma a lényeg.

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

Ezek a szerepek nem dekorációk. A 11. fejezet chat template-jébe renderelődnek, mielőtt a model egyetlen tokent is látna, ezért rontja le csendben a választ a rossz szerep elküldése ahelyett, hogy hibát dobna.

Egy kivétel nélküli szabály: az API key soha nem utazik a klienshez. Nem browserhez prefixelt environment variable-ben, nem build-time konstansban, nem „ideiglenesen”. Egy bundle-ben lévő kulcs napokon belül valaki más számláján lévő kulcs. A böngésző a szervereddel beszél, a szervered tartja a kulcsot és beszél a providerrel — és mivel a szervered középen van, ez az egyetlen hely is, ahol mérni tudod, mennyit költ az egyes user, ami a 16. fejezet elszámolásának helye.

Most jön a kísérlet, amelyre a fejezet épül. Egy kérdés, egy mock provider, amely tizenhárom tokent állít elő 60 ms-onként, három kérdezési mód.

Először streaming nélkül. A kliens elküldi a requestet, és megvárja a teljes JSON bodyt.

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

A két szám ugyanaz, és ez az egész probléma. 791 ms-ig a user csak spinnert lát, és egyetlen szó sem volt korábban elérhető — a szervernél megvolt a válasz byte-ról byte-ra, csak úgy döntött, hogy nem mond semmit.

Másodszor streaminggel. Ugyanaz a szerver, ugyanaz a válasz, ugyanaz az összes munka. A különbség egy 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);
      }
    }
  }
}

Három részlet itt teherhordó, és a legtöbb első próbálkozás mindhármat kihagyja. A buffer azért kell, mert egy hálózati chunknak nincs kapcsolata egy eseménnyel: egy read() visszaadhat fél eseményt, vagy kettő és felet. A { stream: true } flag azért kell, mert egy több byte-os UTF-8 karakter két chunk között kettéválhat, és nélküle egy ékezetes betű véletlenszerűen replacement karakterré válik. Az eseményeket pedig üres sor választja el, nem új sor, ezért keresi a loop a \n\n értéket.

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

Tizenkétszer gyorsabban az első szóig, és két milliszekundummal lassabban az utolsóig. A streaming semmit nem gyorsít fel. Azt változtatja meg, mit csinál a user ugyanabban a 790 ms-ban: olvas, nem vár. Ez a teljes előny, óriási, és ez az oka, hogy minden chat product streamel.

Harmadszor húsz kliens egyszerre. A mock provider egyszerre három requestet szolgál ki. Lőj ki húszat:

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

Húsz válasz, hetvennégy request, ötvennégy visszautasítás. Senki nem veszített semmit, minden kliens ugyanazt a szöveget kapta, és az egyetlen látható költség az idő volt. Így működik egy retry policy. A fejezet többi része arról szól, milyen három módon bukhat el helyette.

finish_reason, és két egyformának látszó befejezés

Link a szakaszhoz: finish_reason, és két egyformának látszó befejezés

A hibák előtt jöjjön az a mező, amelyet első körben szinte mindenki figyelmen kívül hagy. Minden stream egy finish_reason mezőt hordozó eseménnyel ér véget. A stop azt jelenti, hogy a model úgy döntött, végzett. A length azt jelenti, hogy elérte a token plafont, így a válasz mondat közben csonkolódott, és ez nem a model hibája. Későbbi fejezetek hozzáadják a tool_calls esetet (18. fejezet) és a content filtereket.

Most nézz meg két befejezést, amelyeket egy naiv kliens nem tud megkülönböztetni. Ugyanaz a szerver, ugyanaz a késleltetés, az egyik max_tokens miatt csonkolt, a másiknál a kapcsolat öt token után tisztán lezárul:

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"

Olvasd el figyelmesen az első két sort. Azonos szöveg. Azonos chunk-szám. Egyik esetben sincs exception. A for await loop mindkét alkalommal normálisan fejeződött be, mert a reader nézőpontjából a body véget ért, és egy body ennél többet nem tud tenni. Az egész megfigyelésben az egyetlen különbség, hogy az egyik hordozza a finish_reason: "length" értéket, a másik pedig semmit.

Tehát a szabály nem az, hogy „kapd el a hibákat streaming közben”. Hanem ez:

Egy stream, amely finish_reason nélkül ér véget, nem ért véget. Megállt.

A hiányzó finish_reason értéket mindig kezeld hibaként, és soha ne mentsd azt a szöveget kész válaszként. A harmadik sor a könnyebb esetet mutatja — egy megsemmisített socket dob exceptiont, és elveszíti azt a chunkot is, amely éppen úton volt, ezért a szöveg egy szóval rövidebb, mint a fenti kettő.

Öt status code, amelyek öt különböző problémát jelentenek

Link a szakaszhoz: Öt status code, amelyek öt különböző problémát jelentenek

Egy új product legdrágább szokása az egyetlen catch blokk mindenre, amit a provider visszaad. Ezek a kódok nem a „nem sikerült” variációi. Öt utasítás, és közülük négy ellentmond egymásnak.

statusmit jelentmit tegyélvárj?
400a requested hibás — rossz JSON, ismeretlen mező, túl hosszú contextjavítsd a kódotsoha
401a kulcs rossz, hiányzik vagy visszavontákjavítsd a deploymentetsoha
429rate limit: túl sok request, vagy túl sok token percenkéntretryRetry-After, majd backoff
500a provider elromlottretrybackoff
503a provider túlterhelt — működik, csak tele vanretrybackoff, és shed load

A lényeges vonal a 4xx és a többi között húzódik. Egy 400 vagy 401 pontosan ugyanazt a választ adja, ha ezerszer küldöd el, mert a próbálkozások között egyik oldalon sem változik semmi. Újrapróbálni nem óvatosság, hanem késleltetés extra lépésekkel. Mérve: egy kliens hat próbálkozással — öt retry exponential backoff-fal —, és egy, amely előbb elolvassa a kódot.

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

Hat másodperc spinner egy olyan válaszig, amely négy milliszekundum alatt elérhető volt. És ez az enyhébb verzió: egy productban a retryk általában egymásba ágyazódnak — egy retryzó HTTP-kliens egy retryzó job runnerben, egy saját redeliveryvel rendelkező queue-ban —, így hat másodpercből hat perc lesz, és egy tartósan hibás deployment lassúnak fog látszani.

A triage kilenc sor, és egy helyre tartozik:

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
}

Még kettő a listádra: 402, amelyet néhány provider arra használ, hogy „elfogyott a credited”, és amely retry helyett olyan képernyőt igényel, ahol link van a vásárlásra; valamint 529 vagy vendor-specifikus megfelelői, amelyek úgy viselkednek, mint az 503.

Backoff, és mit vesz valójában a jitter

Link a szakaszhoz: Backoff, és mit vesz valójában a jitter

Retryzni könnyű. Az, hogy mikor retryzol, az a rész, amelynek mérhetően helyes válasza van.

Az exponential backoff a standard: vársz egy alap késleltetést, minden hiba után duplázod, és megállsz egy plafonnál. Azért létezik, mert egy túlterhelt szerver rosszabb állapotba kerül, ha az épp elbukott kliensek azonnal visszajönnek.

A probléma az, hogy mindenki ugyanarról a kiindulási pontról dupláz. Ha száz kliens ugyanabban a pillanatban ütközik limitbe — és fog, mert ez a forgalmi spike —, akkor mind a száz vár 200 ms-ot, mind a száz együtt próbálkozik újra, mind a száz együtt bukik el, és mind a száz vár 400 ms-ot. A retry ütemezés szinkronizálta őket. Ez a thundering herd, és a véletlen a javítás.2

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

Ezt az egyetlen változtatást — az intervallumból egyenletesen választani a felső végpont vétele helyett — full jitternek hívják. Egyetlen Math.random() hívás, és érdemes mérni, nem hinni benne:

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

Száz kliens, egy szerver, amely egyszerre hármat szolgál ki, minden más azonos, három-három futás:

HTTP requestekvisszautasításoklegrosszabb klienslegforgalmasabb 50 ms-os ablakwall clock
nincs jitter, 1. futás49139110 próbálkozás46 érkezés65,6 mp
nincs jitter, 2. futás78068019 próbálkozás72 érkezés245,7 mp
nincs jitter, 3. futás77067018 próbálkozás97 érkezés225,6 mp
full jitter, 1. futás3242245 próbálkozás32 érkezés2,2 mp
full jitter, 2. futás3132136 próbálkozás31 érkezés2,3 mp
full jitter, 3. futás3182186 próbálkozás25 érkezés1,8 mp

Két dolog van ebben a táblázatban, és a második a fontos.

Az első a medián: 226 másodperc 2,2 ellen, körülbelül százszoros faktor, feleannyi requestnél is kevesebbel. A legforgalmasabb retry ablak megmondja, miért. Jitter nélkül a száz kliensből akár 97 is ugyanabba az 50 milliszekundumos slotba érkezett; a szervernek három helye volt, így 94-et visszautasított, és együtt aludtak el, még mindig szinkronban, hogy hosszabb várakozással újra megtegyék. Jitterrel ugyanaz a száz kliens ugyanazon ablakok között körülbelül harmincas csoportokban oszlott el, és szinte azonnal kiürült.

A második a variance. Jitter nélkül: 65,6 mp, 245,7 mp, 225,6 mp. Vele: 2,2, 2,3, 1,8. Egy jitter nélküli rendszer nem pusztán rosszul teljesít, hanem kiszámíthatatlanul, mert az eredményt mikroszkopikus ütemezési balesetek döntik el, amelyek kiválasztják, a száz szinkronizált kliens közül melyik három érkezik először. Ez ennek a bugnak az aláírása productionben: egy endpoint, amely rendben van, rendben van, rendben van, aztán négy percig tart, és semmilyen változtatásod nem magyarázza.

És a legolcsóbb retry az, amely meg sem történik. Tegyél egy concurrency gate-et a provider elé — egy számlálót, amely soha nem enged N-nél több requestet egyszerre futni —, és ugyanaz a húsz kliens, amelyhez 74 request és 7,1 másodperc kellett, így viselkedik:

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

Húsz request húsz válaszért, nulla visszautasítás, nyolcszor gyorsabban. A retry a bocsánatkérés; a gate az, hogy nincs rá szükség.

A Retry-After alsó korlát, nem javaslat

Link a szakaszhoz: A Retry-After alsó korlát, nem javaslat

Amikor egy provider 429-et ad vissza, általában megmondja, mennyit kell várnod, a Retry-After headerben.3 Ez a szám nem tanács: a cserében a provider az egyetlen fél, amely tudja, mikor nullázódik az ablaka.

Ezért a várakozás a kettő közül a nagyobb: soha nem kevesebb, mint Retry-After, és soha nem kevesebb a saját backoffodnál sem, mert a header azt mondja meg, mikor bocsát meg a limiter, nem azt, mikor van hely a szerveren.

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

A húsz klienses futás legszerencsétlenebb kliensének trace-e megmutatja, hogy a header teszi a dolgát. Az első négy backoff draw mind egy másodperc alatt volt, és mind a négy felül lett írva:

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

Két gyakorlati megjegyzés. A Retry-After lehet HTTP dátum is, nem csak másodpercek száma, ezért mindkettőt parse-old. A providerek pedig egyszerre két tengelyen rate-limitelnek — request per minute és tokens per minute —, ezért utasítanak vissza hosszú promptokat jóval a dokumentált request limit alatt. A header mindkét esetben ugyanúgy néz ki; a javítás nem.

A timeout, amelyet senki nem választott

Link a szakaszhoz: A timeout, amelyet senki nem választott

Kérd a mock providertől a /hang módot. Elfogadja a kapcsolatot, aztán egyáltalán nem csinál semmit: nincs header, nincs body, nincs close. Ez nem egzotikus — ezt csinálja egy load balancer, amikor a mögötte lévő folyamat úgy halt meg, hogy nem zárta le a socketjeit.

Két kliens, egy különbség:

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

Háromszáz másodperc. Öt percig nyitva tartott socket, foglalt request slot és spinnert néző user, a végén egy generikus TypeError hibával, amely semmit nem mond arról, mi történt. Ez a szám nem bug: ez a Node alapértelmezett headers timeoutja, ésszerű egy általános HTTP-klienshez, és katasztrofális egy user-facing requesthez. Minden runtime-nak van ilyen defaultja, a legtöbben soha nem néznek utána, és a tiéd megtalálásának egyetlen módja az, hogy szándékosan felakasztasz egy socketet, ahogy az imént tettük.

Tehát: minden kimenő request kap explicit deadline-t, amelyet te választasz.

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 hívásnál egy deadline nem elég, mert két különböző hiba létezik. Az első: a stream soha nem nyílik meg: egyáltalán nem érkezik esemény, és tíz–harminc másodperc helyes. A második: a stream megnyílik, majd leáll: tokenek jöttek, aztán örökre megálltak, miközben a socket még egészséges. Egy teljes időtartamra vonatkozó timeout nem tud különbséget tenni egy beragadt stream és egy hosszú, helyes válasz között, ezért idle timeoutot akarsz — egy timert, amelyet minden esemény resetel, és csak akkor sül el, ha mondjuk tizenöt másodpercig semmi nem érkezett.

A cancellation ugyanaz a gépezet, csak egy emberre irányítva. A AbortSignal.timeout és a Stopot megnyomó user egyaránt AbortError formájában érkezik, ezért kombináld őket, és rögzítsd, melyik sült el:

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

Az abortálás nem csak rendrakás miatt számít: a tokenek akkor is generálódnak és számlázódnak, amikor te már nem figyelsz. A 16. fejezet árat tesz erre.

Most jön az a hiba, amely nem időbe, hanem pénzbe kerül. Egy request timeoutol a kliensnél, és a kézenfekvő lépés az, hogy elküldjük újra — de egy timeout semmit nem mond arról, hogy a szerver megkapta-e. Nagyon gyakran megkapta, és még dolgozik.

Mérve. A mock providernek 780 ms kell a válaszhoz. A kliens 300 ms-nál feladja, és retryzik. A szerver megszámolja, hány választ generált ténylegesen, vagyis mit számlázna:

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

Kulcs nélkül: két teljes generation, kétszer fizetve, és a kliens egyiket sem kapta meg. Kulccsal: a szerver felismerte a második requestet ugyanannak a requestnek, és azonnal válaszolt a már elkészített válasszal, így a retry egyszerre kerülte el a dupla díjat, és ez lett az a próbálkozás, amely végül sikerült.

Az idempotency key egy egyedi string, amelyet logikai műveletenként generálsz — nem próbálkozásonként —, és változatlanul küldesz el minden retry során. A szerver eltárolja az eredményt a kulcshoz, és visszajátssza. Ugyanezért használják ezt a mechanizmust a payment API-k is.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");
}

Két őszinte korlát. Nem minden provider támogat idempotency keyt completions endpointokon, és ahol az endpoint nem idempotens, a helyes retry-szám egy POST esetén, amely talán már lefutott, nulla. És egy félúton elbukott stream általános esetben nem játszható vissza: vagy újraindítod és újra fizetsz, vagy megtartod a részleges szöveget, és incomplete-ként jelölöd. Hogy ezek közül melyiket teszi a productod, productdöntés, nem hálózati döntés, és érdemes szándékosan meghozni.

Az ebben a fejezetben megírt kliensnek fogalma sincs, mi van a port mögött. Irányítsd a base URL-jét egy commercial providerre, és tokeneket streamel egy billió paraméteres modelből. Irányítsd egy olyan szerverre, amely a 13. fejezet aritmetikájára épül — a 10. fejezetben pretrained modeledet szolgálja ki, a KV cache-ével és quantized súlyaival —, és ugyanaz a kód, változtatás nélkül, tokeneket streamel egy általad épített modelből.

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

Ez az egyetlen sor a kurzus varrata. Az egyik oldalán az van, amit az első tizenhárom fejezet felépített; a másikon az, amit a következő tizenhat fog. A határ tiszta, mert a contract HTTP és SSE, és egyik oldal sem tud semmi mást a másikról.

Érdemes észrevenni, mit veszítettél az átlépéssel. Egy commercial endpoint mögött sem a súlyokat, sem a sampling implementációt, sem azt a verziót nem irányítod, amelyhez beszélsz, sem azt, hogy megváltozott-e ma reggel. Amit irányítasz, az a contract: az üzenetek, amelyeket küldesz, a deadline, amelyet beállítasz, a kódok, amelyeket megkülönböztetsz, és amit akkor teszel, ha semmi nem jön vissza. Ez kisebb felület, mint amivel az 5. fejezetben rendelkeztél, és minden hátralévő fejezet arról szól, hogyan használd jól.

Most már van egy kliensed, amely streamel, időben feladja, a megfelelő dolgokat retryzza, és a rosszakat soha nem retryzza. Amit elküld, az még mindig bármi, amit begépeltél.

A 15. fejezet erről a tartalomról szól, és fegyelmet hoz magával. Az internet tele van prompting tanácsokkal — ajánlj borravalót a modelnek, fenyegesd meg, mondd neki, hogy vegyen mély levegőt —, és ezekből szinte egyik sem érkezik méréssel. Néhány technika nagyon megmozdítja az outputot, néhány egyáltalán nem, és legalább egy rosszabbá tesz egy classification taskot, miközben több tokenbe kerül. Hogy melyik melyik, nem derül ki abból, hogy elolvasod őket, és vitával sem dől el.

Ezért a következő fejezet benchet épít: hatvan eset ismert válaszokkal, ugyanannak a promptnak négy variánsa, párhuzamosan futtatva pontosan azon a kliensen, amelyet most írtál, a 4. fejezet confidence intervaljaival táblázatba rendezve — mert négy variáns húsz eseten semmit nem különböztet meg. Egy mondat irányítja az egész fejezetet: a promptot mérni kell, nem vitatni.


A fenti számok mind a mock providerből jöttek, Node 22-n, loopback interfészen, ezért a latenciák tisztábbak, mint amit bármely valódi hálózat adna. Ez szándékos: a mért hibák egyikét sem a hálózat okozza, és egy újraindítható ellenséges szerver jobban tanít, mint egy valódi, amelyért fizetned kell, és amelyet nem törhetsz el.

  1. Server-Sent Events, WHATWG HTML Living Standard, 9.2. szakasz. A wire format — data: mezők, üres sorral elválasztott események, id: és retry: — ott van definiálva, a EventSource interfésszel együtt. A EventSource nem tud request bodyt vagy custom headereket küldeni, ezért parse-olja minden LLM-kliens kézzel a formátumot fetch fölött, ahelyett hogy azt használná.

  2. Brooker, M. Exponential Backoff and Jitter. AWS Architecture Blog (2015). A fent használt „full jitter” megfogalmazás forrása, azokkal a szimulációkkal, amelyek megmutatják, miért szinkronizálja a naiv verzió a klienseket. A kísérő érv a load shedding mellett a queue-zás helyett Beyer, Jones, Petoff és Murphy (szerk.), Site Reliability Engineering (O’Reilly, 2016) kötetének Handling Overload fejezete.

  3. Fielding, R., Nottingham, M. és Reschke, J. (szerk.), HTTP Semantics, RFC 9110, 15. szakasz, definiálja a status code osztályokat; Nottingham, M. és Fielding, R., Additional HTTP Status Codes, RFC 6585 (2012), 4. szakasz, definiálja a 429 Too Many Requests kódot. A Retry-After az RFC 9110 10.2.3. szakasza, és vagy másodpercek számát, vagy HTTP dátumot fogad el.

  4. Stripe, Idempotent requests, docs.stripe.com/api/idempotent_requests, olvasva: 2026. szeptember 7. — a contract legtisztább megfogalmazása: egy kulcs logikai műveletenként, eltárolt eredmények visszajátszása, conflict visszaadása, amíg az első próbálkozás még fut —, és a minta providerfüggetlen. Az itt használt request- és event-formák normatív hivatkozásai: developers.openai.com/api/reference/resources/chat a streaminghez, error code-okhoz és rate limitekhez, valamint platform.claude.com/docs/en/api/messages a Messages API-hoz; a ai-sdk.dev/docs a legjobb kidolgozott példa ugyanazokra a problémákra librarybe csomagolva. Mind ugyanazon a napon olvasva.


Készítette

David Vicente Campos

A NeuraLIA Labs alapítója és a MyRealFood társalapítója

Mérnökinformatikus vagyok, a Leóni Egyetemen végeztem. Társalapítottam a MyRealFoodot, ahol CTO-ként felépítettem azt az alkalmazást, amelyet emberek milliói használtak arra, hogy egészségesebben táplálkozzanak, és megalapítottam a NeuraLIA Labst, ahol AI-termékeket fejlesztek. Itt arról írok, amit menet közben meg kellett értenem, úgy, ahogy szerettem volna, hogy valaki elmagyarázza nekem.

Továbbiak a szerzőről

Közzétette a NeuraLIA Labs.

Kapj új bejegyzéseket a postaládádba

AI-hírek, útmutatók és termékfrissítések — rövid email, amikor valami igazán hasznosat publikálunk.

Kurzusindex

Abstract software decision engine with branching paths, probability nodes, and glowing gates.
jev11 perc olvasás

A Jev AI-modell döntésekre készült, nem prózára

A TypeSafe AI Jev modellje azért kap figyelmet, mert a szoftveres intelligenciát valószínűségi problémaként kezeli: válaszd ki a megfelelő ágat, rendelj hozzá bizalmi szintet, és ne fizess egy LLM-nek szövegírásért, amikor a kódnak döntésre van szüksége.

Abstract agent runtime sorting documents, memory blocks and pointer nodes inside a bounded context frame.
context-engineering11 perc olvasás

Kontextustervezés hosszú távú AI-ügynökökhöz

A hosszú ideig futó ügynökök nem csak azért vallanak kudarcot, mert kicsi az ablak. Akkor hibáznak, amikor a fájlok, eszközkimenetek és elavult előzmények kiszorítják azt a feladatot, amelyet az ügynöknek be kellett volna fejeznie.

Készen állsz, hogy a LIA válasszon helyetted?

Építs az összes AI-modellel egy helyen – kezdd el ma, ingyen.