Váš první produkční LLM call: streaming, retry a timeouty
Postavte providera, který vám lže: 429, zaseklé sockety i uříznuté streamy. Full jitter: 2,2 s proti 226 s.
Na této stránce
Kapitola 13 skončila stopkami u modelu, kterého jste se mohli dotknout. Váhy byly ve vaší paměti, KV cache jste mohli zapnout nebo vypnout a číslo, které vyšlo — time to first token — bylo vlastností vašeho hardwaru.
Teď dejte ten model za port, což dělá každý produkt, a přečtěte si stejné číslo znovu. Pořád je to time to first token, ale už to není vlastnost ničeho, co ovládáte. Teď zahrnuje TLS handshake, frontu u providera, rate limiter a možnost, že žádný token nedorazí vůbec.
Ta poslední věta je tato kapitola. Kód, který se chystáte napsat, nic nepočítá. Otevře spojení, čeká, parsuje, co dorazí, rozhoduje, co dělat, když nedorazí nic, rozhoduje znovu, když dorazí chyba, a zruší sám sebe, když si to uživatel rozmyslí. Každá z těchto věcí je rozhodnutí o stavu v čase a každá má špatnou odpověď, která se dostane do produkce a stojí peníze.
Tady je tvar problému, změřený, celý v této kapitole:
| co se stalo | co udělá neopatrný klient | co to stojí |
|---|---|---|
| server přijal socket a nikdy neodpověděl | čeká | 300,8 s, než to Node vzdá sám |
| klíč byl špatný (401) | zkusí to pětkrát znovu | 6 325 ms zpoždění a pak stejná 401 |
| sto klientů narazí na rate limit zároveň | všichni opakují podle stejného plánu | 226 s na vyprázdnění oproti 2,2 s |
| požadavek vypršel a byl poslán znovu | pošle ho znovu | provider vygeneruje — a naúčtuje — odpověď dvakrát |
| spojení spadlo uprostřed odpovědi | zobrazí částečný text | k nerozeznání od správné krátké odpovědi |
Nic z toho není problém modelování. Všechno je v prvních sto řádcích každého LLM produktu, který kdy byl napsán.
Proč tato kapitola mění jazyk
Odkaz na sekci: Proč tato kapitola mění jazykPřečtěte si tu tabulku znovu a zeptejte se, jaký druh programu popisuje. Drží spojení otevřené čtyřicet sekund. Musí jít zrušit tlačítkem. Akumuluje částečnou odpověď, kterou lze zobrazit, ale nelze ji uložit. A běží v serverovém procesu nebo na edge workeru, vedle věci, která vykresluje odpověď, a drží socket.
To není notebook. Nejde o to, že by to Python neuměl — umí a lidé to dělají — jde o to, že všechno, co předchozích třináct kapitol stavělo, bylo jiného druhu. Kapitoly 1 až 13 držely váhy, gradients, logits a bytes tokenizeru. Odteď kód drží spojení, retry, zrušení, akumulovaný stav a později prompt o povolení. Kurz mění jazyk přesně ve švu, kde se mění objekt.
Takže pravidlo, jednou napsané:
Pokud má kód v rukou váhy, gradients, logits nebo bytes tokenizeru, je to Python. Pokud drží spojení, retries, ruší, akumuluje stav a žádá o povolení, je to TypeScript.
Šev je jediný a leží tady, mezi kapitolou 13 a kapitolou 14. Tři nezávislá kritéria ho dávají sem.
Za prvé: ekosystém, spočítaný. Všechno, co cituje levá polovina tohoto kurzu, je Python, a napříč dvanácti kurzy auditovanými pro tuto osnovu neexistuje jediný precedent, kde by se backpropagation učila v jiném jazyce: micrograd (17,4 tis. hvězd), nanoGPT (62,8 tis.), nanochat (57,8 tis.), minbpe (10,7 tis.), PyTorch (102,8 tis.), transformers (164,9 tis.). Napsat kapitolu 5 v TypeScriptu by přetrhlo vazbu na tyto zdroje a odkazy jsou polovina hodnoty kapitoly, která existuje proto, aby se na ni odkazovalo, ne aby rankovala. Na této straně se aritmetika obrací: balíček ai od Vercelu má 89,4 mil. stažení měsíčně a dodává samotnou věc — tool-calling agent loop exportovaný jako ToolLoopAgent — takže koncept, ke kterému se tento kurz dostane v kapitole 23, má referenční implementaci v TypeScriptu, i když se, jak ta kapitola měří, nikdo neshodl na jeho názvu; Mastra má 27,7 tis. hvězd; a SDK od Anthropic, generovaná z jedné specifikace, deklarují 202 endpointů v TypeScriptu proti 201 v Pythonu — paritu, ne zdvořilostní port.
Za druhé: normativní zdroj MCP. Schéma specifikace Model Context Protocol je soubor schema.ts. Učit protokol z kapitoly 26 v jiném jazyce znamená učit překlad jeho zakládajícího dokumentu.
Za třetí: poptávka ve vyhledávání, s opravou zjevného odhadu. machine learning python je nejpřesycenější fráze na internetu; ai agent typescript má vlastní zdravý long tail. Ale „ekosystém MCP je převážně TypeScript“ platí jen podle toho, jak počítáte: oficiální registry uvádí 8 275 serverů na npm proti 3 603 na PyPI, zatímco podle stažení vítězí Python — 287 mil. měsíčně pro mcp plus 72 mil. pro fastmcp proti 195 mil. pro @modelcontextprotocol/sdk. MCP je tady jediné skutečně dvojjazyčné území, proto kapitola 27 píše stejný server dvakrát, místo aby něco předstírala.
Zobrazit podrobnosti
Pět deklarovaných výjimek, aby pravidlo bylo pravidlo, ne slogan.
Kapitoly 17, 20 a 29 nesou druhý panel v Pythonu: implementovat top-p sampling vyžaduje mít v ruce vektor pravděpodobností a HTTP API vám ho nikdy nedá; poctivě nacenit fine-tune znamená jeden spustit a LoRA adapter je tucet řádků nn.Module; a lm-eval-harness, HELM, SWE-bench a τ-bench jsou Python, takže evaluation harness v TypeScriptu by byl zrcadlový obraz chyby s backpropagation. Kapitola 27 je dvojjazyčná, z výše změřeného důvodu. Kapitola 28 je Markdown, protože agent skill je soubor SKILL.md a dát mu programovací jazyk by znamenalo nepochopit formát.
Třináct kapitol v Pythonu se nevyhazuje. To, co je na druhé straně portu, je to, co postavily, a poslední část tady k tomu připojí klienta.
Provider, kterého můžete rozbít
Odkaz na sekci: Provider, kterého můžete rozbítProti skutečnému providerovi se nic z toho nenaučíte. Nemůžete ho požádat o 429 ve zvoleném okamžiku, ani o socket, který přijme spojení a nikdy neodpoví, ani o stream, který se zastaví uprostřed slova — a platili byste za každý experiment, přičemž zajímavé experimenty jsou ty, které spustíte stokrát.
První program v této polovině kurzu tedy není klient. Je to nepřátelský server: čtyřicet řádků čistého Node, které mluví stejným wire protokolem jako chat completions endpoint a na požádání se chovají špatně. Každé číslo v této kapitole pochází z něj.
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);Čtyři nepřátelská chování, každé na řádek: /hang přijme socket a nikdy do něj nezapíše; /401 odmítne klíč; kontrola kapacity vytvoří skutečnou 429 se skutečnou hlavičkou Retry-After, jakmile už běží tři požadavky; a ?cut=N opustí odpověď v půlce, buď resetováním socketu, nebo — s &how=close — jeho řádným zavřením, což se ukáže jako velmi důležité. Zbytek je skutečný Server-Sent Events stream: jeden JSON objekt na řádek data:, prázdný řádek mezi událostmi, řetězec [DONE] na konci.1
Spusťte ho a zbytek kapitoly je měření.
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]Tělo požadavku a klíč, který nikdy neopustí server
Odkaz na sekci: Tělo požadavku a klíč, který nikdy neopustí serverChat požadavek je seznam zpráv, každá s rolí. Tento seznam je celý stav modelu: mezi voláními není žádná paměť a cokoli chcete, aby model věděl, musí být uvnitř pole, které posíláte tentokrát. Kapitola 15 je o tom, co do něj dát, a kapitola 16 o tom, co to stojí, takže tady jde jen o tvar.
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,
};Tyto role nejsou dekorace. Předtím, než model uvidí jediný token, vykreslí se do chat template z kapitoly 11, a proto odeslání špatné role tiše zhorší odpověď místo toho, aby vyvolalo chybu.
Jedno pravidlo bez výjimek: API klíč nikdy necestuje ke klientovi. Ne v proměnné prostředí s prefixem pro prohlížeč, ne v konstantě vložené při buildu, ne „dočasně“. Klíč v bundlu je během pár dní klíč na cizím účtu. Prohlížeč mluví s vaším serverem, váš server drží klíč a mluví s providerem — a protože váš server je uprostřed, je také jediným místem, které může měřit, kolik každý uživatel utratí, což je místo, kde musí žít účetnictví z kapitoly 16.
Stejná otázka, třikrát
Odkaz na sekci: Stejná otázka, třikrátTeď experiment, na kterém kapitola stojí. Jedna otázka, jeden mock provider produkující třináct token při 60 ms na každý, tři způsoby dotazu.
Poprvé, bez streamingu. Klient pošle požadavek a čeká na celé JSON tělo.
blocking first visible = 791 ms complete = 791 ms finish_reason = stopTa dvě čísla jsou stejná, a to je celý problém. Po 791 ms má uživatel spinner a ani jedno slovo nebylo dostupné dřív — server měl odpověď, byte po bytu, a rozhodl se nic neříct.
Podruhé, se streamingem. Stejný server, stejná odpověď, stejná celková práce. Rozdíl je parser.
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);
}
}
}
}Tři detaily jsou tam nosné a většina prvních pokusů vynechá všechny tři. buffer existuje proto, že síťový chunk nemá žádný vztah k události: jeden read() může vrátit půl události nebo dvě a půl. Flag { stream: true } existuje proto, že vícebytový znak UTF-8 může být rozdělen přes dva chunks a bez něj se písmeno s diakritikou náhodně změní na náhradní znak. A události jsou oddělené prázdným řádkem, ne novým řádkem, proto smyčka hledá \n\n.
streaming first visible = 65 ms complete = 793 ms finish_reason = stopDvanáctkrát rychleji k prvnímu slovu a o dvě milisekundy pomaleji k poslednímu. Streaming nic nezrychluje. Mění to, co uživatel dělá během stejných 790 ms: čte místo čekání. To je celý přínos, je obrovský a je to důvod, proč každý chat produkt streamuje.
Potřetí, s dvaceti klienty najednou. Mock provider obslouží tři požadavky najednou. Spusťte dvacet:
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: trueDvacet odpovědí, sedmdesát čtyři požadavků, padesát čtyři odmítnutí. Nikdo o nic nepřišel, každý klient dostal stejný text a jediná viditelná cena byl čas. Tak vypadá funkční retry policy. Zbytek kapitoly je o třech způsobech, jak může místo toho selhat.
finish_reason a dva konce, které vypadají stejně
Odkaz na sekci: finish_reason a dva konce, které vypadají stejněPřed selháními pole, které při prvním průchodu ignoruje skoro každý. Každý stream končí událostí nesoucí finish_reason. stop znamená, že model se rozhodl, že je hotov. length znamená, že narazil na token strop, takže odpověď je uříznutá uprostřed věty a není to chyba modelu. Pozdější kapitoly přidají tool_calls (kapitola 18) a content filtry.
Teď sledujte dva konce, které naivní klient nerozezná. Stejný server, stejné zpoždění, jeden uříznutý pomocí max_tokens a jeden, kde se spojení po pěti token čistě zavře:
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"Přečtěte si první dva řádky pozorně. Identický text. Identický počet chunk. Žádná výjimka v obou případech. Smyčka for await v obou případech skončila normálně, protože z pohledu readeru tělo skončilo a to je všechno, co tělo umí. Jediný rozdíl v celém pozorování je, že jedno nese finish_reason: "length" a druhé nenese vůbec nic.
Pravidlo tedy není „catch chyby během streamingu“. Je to:
Stream, který skončí bez
finish_reason, neskončil. Zastavil se.
Chybějící finish_reason vždy považujte za selhání a nikdy tento text neukládejte jako dokončenou odpověď. Třetí řádek ukazuje jednodušší případ — zničený socket výjimku vyhodí a zároveň ztratí chunk, který byl na cestě, proto je text o jedno slovo kratší než dva výše.
Pět status kódů, které jsou pět různých problémů
Odkaz na sekci: Pět status kódů, které jsou pět různých problémůNejdražší návyk nového produktu je jeden blok catch pro všechno, co provider vrátí. Tyto kódy nejsou variace na „selhalo to“. Jsou to pět pokynů a čtyři z nich si navzájem odporují.
| status | co znamená | co dělat | čekat? |
|---|---|---|---|
| 400 | váš požadavek je špatně sestavený — špatný JSON, neznámé pole, příliš dlouhý context | opravit kód | nikdy |
| 401 | klíč je špatný, chybí nebo byl odvolán | opravit deployment | nikdy |
| 429 | rate limit: příliš mnoho požadavků nebo příliš mnoho tokens za minutu | retry | Retry-After, pak backoff |
| 500 | provider se rozbil | retry | backoff |
| 503 | provider je přetížený — běží, ale je plný | retry | backoff a shodit zátěž |
Důležitá čára vede mezi 4xx a zbytkem. 400 nebo 401 vrátí přesně stejnou odpověď, i když ji pošlete tisíckrát, protože mezi pokusy se na žádné straně nic nezmění. Opakovat to není opatrnost, je to zpoždění s kroky navíc. Změřeno: jeden klient udělá šest pokusů — pět retries s exponenciálním backoff — a jeden nejdřív přečte kód.
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Šest sekund spinneru k odpovědi, která byla dostupná za čtyři milisekundy. A to je mírná verze: retries v produktu bývají obvykle vnořené — retrying HTTP klient uvnitř retrying job runneru uvnitř fronty s vlastním redelivery — takže šest sekund se změní v šest minut trvale rozbitého deploymentu, který vypadá jako pomalý.
Třídění má devět řádků a patří na jedno místo:
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
}Ještě dva na seznam: 402, které někteří provideri používají pro „došel vám kredit“ a které potřebuje obrazovku s odkazem na nákup dalších kreditů, ne retry, a 529 nebo jeho vendor-specific ekvivalenty, které se chovají jako 503.
Backoff a co jitter skutečně kupuje
Odkaz na sekci: Backoff a co jitter skutečně kupujeRetry je snadný. Retry kdy je část s měřitelně správnou odpovědí.
Exponenciální backoff je standard: čekat základní zpoždění, po každém selhání ho zdvojnásobit, zastavit na stropu. Existuje proto, že přetížený server se zhorší, pokud se klienti, kteří právě selhali, okamžitě vrátí.
Problém je, že každý zdvojnásobuje ze stejného výchozího bodu. Pokud sto klientů narazí na limit ve stejném okamžiku — a narazí, protože tak vypadá traffic spike — pak všech sto čeká 200 ms, všech sto opakuje společně, všech sto selže společně a všech sto čeká 400 ms. Retry schedule je synchronizoval. To je thundering herd a oprava je náhodnost.2
Ta jediná změna — vybírat rovnoměrně z intervalu místo vzít jeho horní konec — se jmenuje full jitter. Je to jedno volání Math.random() a vyplatí se ho měřit, ne mu věřit:
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); Sto klientů, jeden server obsluhující tři najednou, všechno ostatní identické, tři běhy každý:
| HTTP požadavky | odmítnutí | nejhorší klient | nejrušnější 50ms okno | wall clock | |
|---|---|---|---|---|---|
| bez jitter, běh 1 | 491 | 391 | 10 pokusů | 46 příchodů | 65,6 s |
| bez jitter, běh 2 | 780 | 680 | 19 pokusů | 72 příchodů | 245,7 s |
| bez jitter, běh 3 | 770 | 670 | 18 pokusů | 97 příchodů | 225,6 s |
| full jitter, běh 1 | 324 | 224 | 5 pokusů | 32 příchodů | 2,2 s |
| full jitter, běh 2 | 313 | 213 | 6 pokusů | 31 příchodů | 2,3 s |
| full jitter, běh 3 | 318 | 218 | 6 pokusů | 25 příchodů | 1,8 s |
V té tabulce jsou dvě věci a druhá je důležitá.
První je medián: 226 sekund proti 2,2, přibližně stonásobek, s méně než polovinou požadavků. Nejrušnější retry okno říká proč. Bez jitter dorazilo až 97 ze sta klientů do stejného 50milisekundového slotu; server měl tři místa, takže 94 bylo odmítnuto a šlo spát společně, stále synchronizovaně, aby to udělali znovu s delším čekáním. S jitter se stejná stovka rozprostřela přes stejná okna ve skupinách kolem třiceti a vyprázdnila se téměř okamžitě.
Druhá je variance. Bez jitter: 65,6 s, 245,7 s, 225,6 s. S ním: 2,2, 2,3, 1,8. Systém bez jitter nepodává jen špatný výkon, podává nepředvídatelný výkon, protože výsledek určují mikroskopické plánovací náhody, které vyberou, kteří tři ze sta synchronizovaných klientů dorazí první. To je podpis této chyby v produkci: endpoint, který je v pořádku, v pořádku, v pořádku, a pak trvá čtyři minuty, aniž by to vysvětlovala jakákoli vaše změna.
A nejlevnější retry je ten, který nikdy nenastane. Dejte před providera concurrency gate — čítač, který nikdy nedovolí mít v běhu víc než N požadavků — a stejných dvacet klientů, kteří potřebovali 74 požadavků a 7,1 sekundy, se chová takto:
client-side gate of 3: 20 HTTP requests, 0 429s, wall 883 msDvacet požadavků na dvacet odpovědí, nula odmítnutí, osmkrát rychleji. Retry je omluva; gate znamená, že ji nepotřebujete.
Retry-After je spodní hranice, ne návrh
Odkaz na sekci: Retry-After je spodní hranice, ne návrhKdyž provider vrátí 429, obvykle vám v hlavičce Retry-After řekne, jak dlouho čekat.3 Toto číslo není rada: provider je jediná strana výměny, která ví, kdy se jeho okno resetuje.
Čekání je tedy větší z těch dvou: nikdy méně než Retry-After a nikdy méně než váš vlastní backoff, protože hlavička říká, kdy vám limiter odpustí, ne kdy má server místo.
const header = res.headers.get("retry-after");
const floor = header ? Number(header) * 1000 : 0; // seconds -> ms
const wait = Math.max(floor, backoffFull(attempt)); Trace nejméně šťastného klienta v běhu s dvaceti klienty ukazuje, že hlavička dělá svou práci. Jeho první čtyři backoff losy byly všechny pod jednu sekundu a všechny čtyři byly přepsány:
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 msDvě praktické poznámky. Retry-After může být HTTP datum, ne počet sekund, takže parsujte obojí. A provideri rate-limitují na dvou osách zároveň — požadavky za minutu a tokens za minutu — proto dlouhé prompts dostanou odmítnutí hluboko pod dokumentovaným limitem požadavků. Hlavička vypadá v obou případech stejně; oprava ne.
Timeout, který nikdo nevybral
Odkaz na sekci: Timeout, který nikdo nevybralPožádejte mock providera o /hang. Přijme spojení a pak neudělá vůbec nic: žádné hlavičky, žádné tělo, žádné zavření. Není to exotika — přesně to udělá load balancer, když proces za ním zemřel, aniž by zavřel své sockety.
Dva klienti, jeden rozdíl:
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_TIMEOUTTři sta sekund. Pět minut otevřeného socketu, obsazený request slot a uživatel dívající se na spinner, končící generickým TypeError, který neříká nic o tom, co se stalo. Toto číslo není bug: je to výchozí headers timeout Node, rozumný pro obecný HTTP klient a katastrofální pro požadavek směrem k uživateli. Každý runtime má takový default, většina lidí ho nikdy nehledá a jediný způsob, jak najít ten svůj, je úmyslně pověsit socket, jako jsme to právě udělali.
Takže: každý odchozí požadavek dostane explicitní deadline, zvolený vámi.
const res = await fetch(url, {
method: "POST",
headers: { "content-type": "application/json", authorization: `Bearer ${key}` },
body: JSON.stringify(payload),
signal: AbortSignal.timeout(20_000),
});Pro streaming call jeden deadline nestačí, protože existují dvě různá selhání. První je stream se nikdy neotevře: nedorazí žádná událost a deset až třicet sekund je správně. Druhé je stream se otevře a pak se zasekne: tokens tekly a pak se zastavily navždy, se socketem pořád zdravým. Timeout podle celkové délky nedokáže rozlišit zaseknutý stream od dlouhé správné odpovědi, takže chcete idle timeout — časovač resetovaný každou událostí, který vystřelí jen tehdy, když třeba patnáct sekund nic nedorazilo.
Zrušení je stejný mechanismus namířený na člověka. AbortSignal.timeout i uživatel stisknutím Stop dorazí jako AbortError, takže je zkombinujte a zaznamenejte, který vystřelil:
const user = new AbortController();
const signal = AbortSignal.any([user.signal, AbortSignal.timeout(20_000)]);
// stopButton.onclick = () => user.abort();Abort je důležitý z důvodu přesahujícího pořádek: tokens se generují a účtují, zatímco vy už neposloucháte. Kapitola 16 tomu dává cenu.
Co je bezpečné zkusit znovu
Odkaz na sekci: Co je bezpečné zkusit znovuTeď selhání, které stojí peníze, ne čas. Požadavku na klientovi vyprší čas a zjevný tah je poslat ho znovu — ale timeout vám neříká nic o tom, zda ho server přijal. Velmi často přijal a stále pracuje.
Změřeno. Mock provider potřebuje na odpověď 780 ms. Klient to vzdá po 300 ms a zkusí to znovu. Server počítá, kolik odpovědí skutečně vygeneroval, tedy co by účtoval:
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): 1Bez klíče: dvě plné generace, zaplacené dvakrát, a klient neobdržel ani jednu. S klíčem: server rozpoznal druhý požadavek jako stejný požadavek a okamžitě odpověděl odpovědí, kterou už vyprodukoval, takže retry zabránil dvojímu účtování a zároveň byl pokusem, který nakonec uspěl.
Idempotency key je unikátní řetězec, který generujete pro logickou operaci — ne pro pokus — a posíláte beze změny při každém jejím retry. Server uloží výsledek pod klíčem a přehraje ho. Je to mechanismus, který používají payment API, ze stejného důvodu.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");
}Dvě poctivá omezení. Ne každý provider podporuje idempotency keys u completions, a tam, kde endpoint není idempotentní, je správný počet retries pro POST, který už mohl proběhnout, nula. A stream, který selhal v půlce, obecně nelze přehrát: buď ho restartujete a zaplatíte znovu, nebo ponecháte částečný text a označíte ho jako nedokončený. Kterou z těchto možností váš produkt zvolí, je produktové rozhodnutí, ne síťové, a stojí za to ho udělat záměrně.
Uzavření švu
Odkaz na sekci: Uzavření švuKlient napsaný v této kapitole netuší, co je za portem. Namiřte jeho base URL na komerčního providera a streamuje tokens z modelu s bilionem parametrů. Namiřte ho na server postavený na aritmetice kapitoly 13 — obsluhující model, který jste pretrained v kapitole 10, s jeho KV cache a quantized váhami — a stejný kód, beze změny, streamuje tokens z modelu, který jste postavili.
const BASE = process.env.LLM_BASE_URL ?? "http://127.0.0.1:8000/v1"; Ten jediný řádek je šev tohoto kurzu. Na jedné jeho straně je to, co postavilo prvních třináct kapitol; na druhé to, co postaví dalších šestnáct. Hranice je čistá, protože kontrakt je HTTP a SSE a žádná strana o té druhé neví nic dalšího.
Stojí za to všimnout si, co jste přechodem ztratili. Za komerčním endpointem neovládáte ani váhy, ani implementaci samplingu, ani verzi, se kterou mluvíte, ani to, jestli se dnes ráno změnila. Ovládáte kontrakt: zprávy, které posíláte, deadline, který nastavíte, kódy, které rozlišíte, a co uděláte, když se nic nevrátí. Je to menší plocha, než jste měli v kapitole 5, a každá zbývající kapitola je o tom, jak ji dobře používat.
Kam to pokračuje dál
Odkaz na sekci: Kam to pokračuje dálTeď máte klienta, který streamuje, včas to vzdá, retry správné věci a nikdy nerepeatne ty špatné. To, co posílá, je pořád jen to, co jste napsali.
Kapitola 15 je o tomto obsahu a přichází s disciplínou. Internet je plný rad k prompting — nabídněte modelu spropitné, vyhrožujte mu, řekněte mu, ať se zhluboka nadechne — a téměř žádná nepřichází s měřením. Některé z těchto technik posunou výstup výrazně, některé vůbec a nejméně jedna zhorší klasifikační úlohu, zatímco stojí více tokens. Co je co, není z jejich čtení zřejmé a spor to nerozhodne.
Další kapitola proto staví bench: šedesát případů se známými odpověďmi, čtyři varianty stejného prompt, spuštěné paralelně přes přesně toho klienta, kterého jste právě napsali, tabulované s intervaly spolehlivosti z kapitoly 4 — protože čtyři varianty přes dvacet případů nerozliší vůbec nic. Celou kapitolu řídí jedna věta: prompt se měří, ne debatuje.
Zdroje a metoda
Odkaz na sekci: Zdroje a metodaKaždé číslo výše pochází z mock providera, na Node 22 přes loopback interface, takže latence jsou čistší, než vám dá jakákoli skutečná síť. Je to záměr: žádné z měřených selhání není způsobeno sítí a nepřátelský server, který můžete restartovat, učí lépe než skutečný, za který musíte platit a který nemůžete rozbít.
Reference
Odkaz na sekci: Reference-
Server-Sent Events, WHATWG HTML Living Standard, část 9.2. Wire format — pole
data:, události oddělené prázdným řádkem,id:aretry:— je definován tam, spolu s rozhranímEventSource.EventSourceneumí poslat request body ani vlastní hlavičky, proto každý LLM klient parsuje formát ručně nadfetchmísto toho, aby ho používal. ↩ -
Brooker, M. Exponential Backoff and Jitter. AWS Architecture Blog (2015). Zdroj formulace „full jitter“ použité výše, se simulacemi, které ukazují, proč naivní verze synchronizuje klienty. Doprovodný argument pro shazování zátěže místo jejího řazení do fronty je kapitola Handling Overload od Beyera, Jonese, Petoffa a Murphyho (eds.), Site Reliability Engineering (O'Reilly, 2016). ↩
-
Fielding, R., Nottingham, M. a Reschke, J. (eds.), HTTP Semantics, RFC 9110, část 15, definuje třídy status kódů; Nottingham, M. a Fielding, R., Additional HTTP Status Codes, RFC 6585 (2012), část 4, definuje 429 Too Many Requests.
Retry-Afterje RFC 9110 část 10.2.3 a přijímá buď počet sekund, nebo HTTP datum. ↩ -
Stripe, Idempotent requests,
docs.stripe.com/api/idempotent_requests, čteno 7. září 2026 — nejjasnější vyjádření kontraktu: jeden klíč na logickou operaci, přehrávání uložených výsledků, konflikt vrácený, dokud je první pokus stále v běhu — a vzor je nezávislý na providerovi. Normativní reference pro tvary požadavků a událostí použité zde jsoudevelopers.openai.com/api/reference/resources/chatpro streaming, error codes a rate limits aplatform.claude.com/docs/en/api/messagespro Messages API;ai-sdk.dev/docsje nejlepší propracovaný příklad stejných starostí zabalených v knihovně. Vše čteno tentýž den. ↩