Първото ви production LLM извикване: streaming, retries, timeouts
Създайте provider, който лъже — 429, увиснали socket-и и прекъснати streams — и измерете как реагира client-ът ви.
На тази страница
Глава 13 завърши с хронометър върху модел, който можехте да докоснете. Теглата бяха във вашата памет, KV cache беше ваш за включване или изключване, а числото, което излезе — време до първия token — беше свойство на вашия хардуер.
Сега сложете този модел зад порт, както прави всеки продукт, и прочетете същото число отново. То все още е време до първия token, но вече не е свойство на нещо, което контролирате. Вече включва TLS handshake, опашка при provider-а, rate limiter и възможността никога да не пристигне нито един token.
Последната част е тази глава. Кодът, който предстои да напишете, не изчислява нищо. Той отваря връзка, чака, парсира това, което пристига, решава какво да прави, когато нищо не пристига, решава отново, когато пристигналото е грешка, и се отменя сам, когато потребителят промени мнението си. Всяко от тези неща е решение за състояние във времето, и всяко има грешен отговор, който стига до production и струва пари.
Ето формата на проблема, измерена, цялата в тази глава:
| какво се случи | какво прави небрежен client | колко струва |
|---|---|---|
| server-ът прие socket-а и никога не отговори | чака | 300,8 s, преди Node да се откаже сам |
| ключът беше грешен (401) | retry-ва пет пъти | 6.325 ms забавяне, после същото 401 |
| сто client-а удариха rate limit-а едновременно | всички retry-ват по същия график | 226 s за източване, срещу 2,2 s |
| заявката изтече по timeout и беше изпратена отново | изпраща я отново | provider-ът генерира — и таксува — отговора два пъти |
| връзката падна по средата на отговора | показва частичния текст | неразличимо е от правилен кратък отговор |
Нито едно от тези неща не е проблем на моделирането. Всички са в първите сто реда на всеки LLM продукт, писан някога.
Защо тази глава сменя езика
Връзка към раздела: Защо тази глава сменя езикаПрочетете таблицата отново и попитайте какъв вид програма описва. Тя държи връзка отворена четиридесет секунди. Трябва да може да се отменя от бутон. Натрупва частичен отговор, който е валиден за показване и невалиден за записване. И работи в server процес или edge worker, до нещото, което рендерира отговора, държейки socket.
Това не е notebook. Не че Python не може да го прави — може, и хората го правят — а че всичко, което предишните тринадесет глави изградиха, беше от друг вид. Глави 1 до 13 държаха тегла, gradients, logits и tokenizer байтове. Оттук нататък кодът държи връзка, retry, cancellation, натрупано състояние и, по-късно, permission prompt. Курсът сменя езика точно на шева, където се сменя обектът.
Затова правилото, написано веднъж:
Ако кодът държи тегла, gradients, logits или tokenizer байтове в ръцете си, той е Python. Ако държи връзка, retries, отменя, натрупва състояние и иска разрешение, той е TypeScript.
Шевът е един и пада тук, между Глава 13 и Глава 14. Три независими критерия го поставят тук.
Първо: екосистемата, преброена. Всичко, което лявата половина на този курс цитира, е Python, а сред дванадесетте курса, прегледани за тази програма, няма нито един прецедент backpropagation да се преподава на друг език: micrograd (17,4K stars), nanoGPT (62,8K), nanochat (57,8K), minbpe (10,7K), PyTorch (102,8K), transformers (164,9K). Да напишем Глава 5 на TypeScript би прекъснало връзката с тези източници, а връзките са половината стойност на глава, която съществува, за да бъде реферирана, а не за да rank-ва. От тази страна аритметиката се обръща: пакетът ai на Vercel има 89,4M изтегляния месечно и доставя самото нещо — tool-calling agent loop, експортиран като ToolLoopAgent — така че концепцията, до която този курс стига в Глава 23, има reference implementation в TypeScript, макар че, както измерва онази глава, никой не се е съгласил за име за нея; Mastra е на 27,7K stars; а SDK-тата на Anthropic, генерирани от една спецификация, декларират 202 endpoints в TypeScript срещу 201 в Python — parity, не любезен port.
Второ: нормативният източник на MCP. Schema-та на спецификацията Model Context Protocol е файл schema.ts. Да се преподава protocol-ът от Глава 26 на друг език означава да се преподава превод на основополагащия му документ.
Трето: search demand, с корекция на очевидното предположение. machine learning python е най-наситената фраза в интернет; ai agent typescript има собствена здрава дълга опашка. Но „MCP екосистемата е предимно TypeScript“ е вярно само според начина на броене: официалният registry изброява 8.275 server-а в npm срещу 3.603 в PyPI, докато по изтегляния Python печели — 287M месечно за mcp плюс 72M за fastmcp срещу 195M за @modelcontextprotocol/sdk. MCP е единствената истински двуезична територия тук, затова Глава 27 пише същия server два пъти, вместо да се преструва.
Покажи подробности
Петте обявени изключения, за да бъде правилото правило, а не лозунг.
Глави 17, 20 и 29 носят втори панел на Python: имплементирането на top-p sampling изисква probability vector да е в ръцете ви, а HTTP API никога не ви дава такъв; честното ценообразуване на fine-tune означава да стартирате такъв, а LoRA adapter е дузина реда nn.Module; и lm-eval-harness, HELM, SWE-bench и τ-bench са Python, така че evaluation harness на TypeScript би бил огледалният образ на грешката с backpropagation. Глава 27 е двуезична, по измерената причина по-горе. Глава 28 е Markdown, защото agent skill е файл SKILL.md и да му дадете програмен език би означавало да не сте разбрали формата.
Тринадесетте Python глави не се изхвърлят. Това, което е от другата страна на порта, е онова, което те построиха, а последният раздел тук свързва client с него.
Provider, който можете да счупите
Връзка към раздела: Provider, който можете да счупитеНе можете да научите нищо от това срещу реален provider. Не можете да поискате от него 429 в избран момент, или socket, който приема връзката ви и никога не отговаря, или stream, който спира по средата на дума — и бихте плащали за всеки експеримент, когато интересните експерименти са тези, които пускате сто пъти.
Затова първата програма в тази половина на курса не е client. Тя е враждебен server: четиридесет реда чист Node, които говорят същия wire protocol като chat completions endpoint и се държат лошо при поискване. Всяко число в тази глава идва от него.
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);Четири враждебни поведения, по един ред: /hang приема socket-а и никога не пише в него; /401 отказва ключа; проверката за capacity произвежда истинско 429 с истински header Retry-After, щом вече има три заявки in flight; а ?cut=N изоставя отговора по средата, или чрез reset на socket-а, или — с &how=close — като го затваря подредено, което се оказва много важно. Останалото е реален Server-Sent Events stream: един JSON object на ред data:, празен ред между events, string-ът [DONE] в края.1
Стартирайте го, и останалата част от главата е измерване.
node mock-provider.mjs &
curl -N "http://127.0.0.1:8787/v1/chat?max_tokens=3"data: {"choices":[{"delta":{"content":"A"},"finish_reason":null}]}
data: {"choices":[{"delta":{"content":" tide"},"finish_reason":null}]}
data: {"choices":[{"delta":{"content":" gauge"},"finish_reason":null}]}
data: {"choices":[{"delta":{},"finish_reason":"length"}]}
data: [DONE]Request body и ключът, който никога не напуска server-а
Връзка към раздела: Request body и ключът, който никога не напуска server-аChat request е списък от messages, всяко с role. Този списък е цялото състояние на модела: няма памет между извикванията, и каквото искате моделът да знае, трябва да бъде вътре в array-а, който изпращате този път. Глава 15 е за това какво да сложите в него, а Глава 16 е за това колко струва, така че тук е само формата.
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,
};Тези roles не са украса. Те се рендерират в chat template-а от Глава 11, преди моделът да види един-единствен token, затова изпращането на грешна role тихо влошава отговора, вместо да вдигне грешка.
Едно правило без изключения: API ключът никога не отива при client-а. Нито в environment variable с prefix за browser-а, нито в build-time constant, нито „временно“. Ключ в bundle е ключ върху чужда сметка в рамките на дни. Browser-ът говори с вашия server, вашият server държи ключа и говори с provider-а — и понеже вашият server е по средата, той е и единственото място, което може да измерва колко харчи всеки потребител, а там трябва да живее счетоводството от Глава 16.
Един и същ въпрос, три пъти
Връзка към раздела: Един и същ въпрос, три пътиСега експериментът, върху който е построена главата. Един въпрос, един mock provider, който произвежда тринадесет token-а по 60 ms всеки, три начина да попитате.
Първо, без streaming. Client-ът изпраща request-а и чака целия JSON body.
blocking first visible = 791 ms complete = 791 ms finish_reason = stopДвете числа са еднакви, и това е целият проблем. В продължение на 791 ms потребителят има spinner, а нито една дума не е била налична по-рано — server-ът е имал отговора, byte по byte, и е избрал да не казва нищо.
Второ, със streaming. Същият server, същият отговор, същата обща работа. Разликата е 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);
}
}
}
}Три детайла там носят тежестта и повечето първи опити пропускат и трите. buffer съществува, защото network chunk няма връзка с event: един read() може да върне половин event или два и половина. Флагът { stream: true } съществува, защото multi-byte UTF-8 character може да бъде разделен между два chunks, и без него accented letter става replacement character на случаен принцип. А events се разделят с празен ред, не с newline, затова loop-ът търси \n\n.
streaming first visible = 65 ms complete = 793 ms finish_reason = stopДванадесет пъти по-бързо до първата дума и две милисекунди по-бавно до последната. Streaming не прави нищо по-бързо. Той променя какво прави потребителят през същите 790 ms: чете, вместо да чака. Това е цялата полза, тя е огромна, и затова всеки chat продукт streams.
Трето, с двадесет client-а едновременно. Mock provider-ът обслужва три request-а наведнъж. Изстреляйте двадесет:
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Двадесет отговора, седемдесет и четири request-а, петдесет и четири отказа. Никой не загуби нищо, всеки client получи същия текст, а единствената видима цена беше време. Това е работеща retry policy. Останалата част от тази глава е за трите начина, по които вместо това тя може да се провали.
finish_reason и два края, които изглеждат еднакво
Връзка към раздела: finish_reason и два края, които изглеждат еднаквоПреди провалите — field-ът, който почти всички игнорират при първия опит. Всеки stream завършва с event, носещ finish_reason. stop означава, че моделът е решил, че е готов. length означава, че е ударил token тавана, така че отговорът е truncated по средата на изречение и вината не е на модела. По-късни глави добавят tool_calls (Глава 18) и content filters.
Сега вижте два края, които наивен client не може да различи. Същият server, същото забавяне, единият truncated от max_tokens, а другият — с чисто затворена връзка след пет token-а:
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"Прочетете внимателно първите два реда. Идентичен текст. Идентичен chunk count. Няма exception в нито един от двата случая. Loop-ът for await приключи нормално и двата пъти, защото от гледната точка на reader-а body-то е приключило и това е всичко, което едно body може да направи. Единствената разлика в цялото наблюдение е, че единият носи finish_reason: "length", а другият не носи нищо.
Затова правилото не е „catch errors while streaming“. То е:
Stream, който приключва без
finish_reason, не е приключил. Той е спрял.
Третирайте липсващ finish_reason като failure, винаги, и никога не записвайте този текст като завършен отговор. Третият ред показва по-лесния случай — destroyed socket наистина throw-ва, и също губи chunk-а, който е бил in flight, затова текстът е с една дума по-кратък от двата по-горе.
Пет status code-а, които са пет различни проблема
Връзка към раздела: Пет status code-а, които са пет различни проблемаНай-скъпият навик на нов продукт е един block catch за всичко, което provider-ът връща. Тези codes не са вариации на „не се получи“. Те са пет инструкции, и четири от тях си противоречат.
| status | какво означава | какво да направите | чакане? |
|---|---|---|---|
| 400 | request-ът ви е malformed — лош JSON, unknown field, context твърде дълъг | поправете кода | никога |
| 401 | ключът е грешен, липсва или е revoked | поправете deployment-а | никога |
| 429 | rate limit: твърде много requests или твърде много tokens на минута | retry | Retry-After, после backoff |
| 500 | provider-ът се счупи | retry | backoff |
| 503 | provider-ът е overloaded — работи, но е пълен | retry | backoff и shed load |
Важната линия минава между 4xx и останалите. 400 или 401 връщат абсолютно същия отговор, ако ги изпратите хиляда пъти, защото между опитите нищо не се променя от нито една страна. Да ги retry-вате не е предпазливост, а забавяне с допълнителни стъпки. Измерено: един client, който прави шест attempts — пет retries с exponential backoff — и един, който първо чете code-а.
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Шест секунди spinner, за да стигнете до отговор, който е бил наличен за четири милисекунди. И това е меката версия: retries в продукт обикновено са вложени — retry-ващ HTTP client вътре в retry-ващ job runner вътре в queue със собствен redelivery — така че шест секунди стават шест минути permanently broken deployment, който изглежда като бавен.
Triage-ът е девет реда и принадлежи на едно място:
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
}Още две за списъка ви: 402, което някои providers използват за „нямате credit“ и което се нуждае от екран с линк за купуване на още, а не от retry, и 529 или vendor-specific еквивалентите му, които се държат като 503.
Backoff и какво всъщност купува jitter
Връзка към раздела: Backoff и какво всъщност купува jitterRetry-ването е лесно. Retry-ването кога е частта с измеримо правилен отговор.
Exponential backoff е стандартът: чака се base delay, удвоява се след всеки failure, спира се при ceiling. Съществува, защото overloaded server става по-зле, ако client-ите, които току-що са fail-нали, се върнат веднага.
Проблемът е, че всички удвояват от една и съща начална точка. Ако сто client-а ударят limit в един и същ момент — а ще го направят, защото това е traffic spike — тогава всички сто чакат 200 ms, всички сто retry-ват заедно, всички сто fail-ват заедно и всички сто чакат 400 ms. Retry schedule-ът ги е синхронизирал. Това е thundering herd, а randomness е поправката.2
Тази единствена промяна — да избирате равномерно от интервала, вместо да вземате горния му край — се нарича full jitter. Това е едно извикване на Math.random(), и си струва да се измери, вместо да се вярва:
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); Сто client-а, един server, който обслужва три наведнъж, всичко друго еднакво, по три run-а:
| HTTP requests | rejections | най-лош client | най-натоварен 50 ms прозорец | wall clock | |
|---|---|---|---|---|---|
| без jitter, run 1 | 491 | 391 | 10 tries | 46 arrivals | 65,6 s |
| без jitter, run 2 | 780 | 680 | 19 tries | 72 arrivals | 245,7 s |
| без jitter, run 3 | 770 | 670 | 18 tries | 97 arrivals | 225,6 s |
| full jitter, run 1 | 324 | 224 | 5 tries | 32 arrivals | 2,2 s |
| full jitter, run 2 | 313 | 213 | 6 tries | 31 arrivals | 2,3 s |
| full jitter, run 3 | 318 | 218 | 6 tries | 25 arrivals | 1,8 s |
Две неща има в тази таблица, и второто е важното.
Първото е median: 226 секунди срещу 2,2, фактор около сто, с по-малко от половината requests. Най-натовареният retry прозорец казва защо. Без jitter до 97 от стоте client-а пристигнаха в същия 50-millisecond slot; server-ът имаше три места, така че 94 бяха rejected и заспаха заедно, все още синхронизирани, за да го направят пак с по-дълго чакане. С jitter същите сто се разпръснаха през същите прозорци на групи от около тридесет и се източиха почти веднага.
Второто е variance. Без jitter: 65,6 s, 245,7 s, 225,6 s. С него: 2,2, 2,3, 1,8. Система без jitter не просто се представя зле, тя се представя непредвидимо, защото резултатът се решава от микроскопични scheduling accidents, които избират кои три от сто синхронизирани client-а пристигат първи. Това е signature-ът на този bug в production: endpoint, който е добре, добре, добре, и после отнема четири минути, без ваша промяна да го обяснява.
А най-евтиният retry е този, който никога не се случва. Сложете concurrency gate пред provider-а — counter, който никога не позволява повече от N requests да са in flight — и същите двадесет client-а, които имаха нужда от 74 requests и 7,1 секунди, се държат така:
client-side gate of 3: 20 HTTP requests, 0 429s, wall 883 msДвадесет requests за двадесет отговора, нула rejections, осем пъти по-бързо. Retry е извинението; gate е да нямате нужда от него.
Retry-After е долна граница, не предложение
Връзка към раздела: Retry-After е долна граница, не предложениеКогато provider върне 429, обикновено ви казва колко да чакате в header-а Retry-After.3 Това число не е съвет: provider-ът е единствената страна в обмена, която знае кога window-ът му се reset-ва.
Затова чакането е по-голямото от двете: никога по-малко от Retry-After и никога по-малко от вашия собствен backoff, защото header-ът ви казва кога limiter-ът ви прощава, а не кога server-ът има място.
const header = res.headers.get("retry-after");
const floor = header ? Number(header) * 1000 : 0; // seconds -> ms
const wait = Math.max(floor, backoffFull(attempt)); Trace-ът на най-нещастния client в run-а с двадесет client-а показва как header-ът си върши работата. Първите му четири backoff draws бяха всички под една секунда, и всички четири бяха overridden:
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Две практични бележки. Retry-After може да е HTTP date, а не брой секунди, така че parse-вайте и двете. И providers rate-limit-ват по две оси едновременно — requests per minute и tokens per minute — затова дълги prompts биват rejected далеч под документирания request limit. Header-ът изглежда еднакво и в двата случая; поправката не е.
Timeout-ът, който никой не избра
Връзка към раздела: Timeout-ът, който никой не избраПоискайте от mock provider-а /hang. Той приема връзката и после не прави абсолютно нищо: няма headers, няма body, няма close. Това не е екзотика — така се държи load balancer, когато process-ът зад него е умрял, без да затвори socket-ите си.
Два client-а, една разлика:
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Триста секунди. Пет минути отворен socket, зает request slot и потребител, който гледа spinner, завършващи с generic TypeError, което не казва нищо за случилото се. Това число не е bug: то е default headers timeout на Node, разумен за generic HTTP client и катастрофален за user-facing request. Всеки runtime има такъв default, повечето хора никога не го проверяват, и единственият начин да намерите вашия е да закачите socket нарочно, както току-що направихме.
Значи: всяка outgoing request получава explicit deadline, избран от вас.
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 call един deadline не стига, защото има два различни failure-а. Първият е stream-ът никога не се отваря: не пристига нито един event, и десет до тридесет секунди е правилно. Вторият е stream-ът се отваря и после stalling-ва: tokens са текли и после са спрели завинаги, със socket, който все още е здрав. Total-duration timeout не може да различи stalled stream от дълъг правилен отговор, така че това, което искате, е idle timeout — timer, reset-ван от всеки event, който firing-ва само когато нищо не е пристигало например петнадесет секунди.
Cancellation е същата механика, насочена към човек. AbortSignal.timeout и потребител, който натиска Stop, и двете пристигат като AbortError, така че ги комбинирайте и запишете кое е fired:
const user = new AbortController();
const signal = AbortSignal.any([user.signal, AbortSignal.timeout(20_000)]);
// stopButton.onclick = () => user.abort();Abort-ването има значение по причина отвъд подредеността: tokens се генерират и таксуват, докато вие не слушате. Глава 16 слага цена на това.
Какво е безопасно за retry
Връзка към раздела: Какво е безопасно за retryСега failure-ът, който струва пари, не време. Request изтича по timeout при client-а, и очевидният ход е да се изпрати отново — но timeout не ви казва нищо за това дали server-ът я е получил. Много често я е получил и все още работи.
Измерено. Mock provider-ът се нуждае от 780 ms за отговора. Client-ът се отказва на 300 ms и retry-ва. Server-ът брои колко отговора всъщност е генерирал, което е онова, което би таксувал:
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Без key: две пълни generations, платени два пъти, и client-ът не получи нито една от тях. С key: server-ът разпозна втория request като същия request и отговори незабавно с отговора, който вече беше произвел, така че retry-то едновременно избегна двойното таксуване и беше attempt-ът, който най-накрая успя.
Idempotency key е unique string, който генерирате за logical operation — не за attempt — и изпращате непроменен при всеки retry на нея. Server-ът съхранява outcome-а срещу key-а и го replay-ва. Това е механизмът, който payment APIs използват, по същата причина.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");
}Две честни ограничения. Не всеки provider поддържа idempotency keys за completions, а когато endpoint-ът не е idempotent, правилният брой retries за POST, който може вече да е изпълнен, е нула. И stream, който се е провалил по средата, обикновено не може да се replay-не: или го рестартирате и плащате отново, или запазвате частичния текст и го маркирате incomplete. Кое от двете прави продуктът ви е product decision, не networking decision, и си струва да го вземете нарочно.
Затваряне на шева
Връзка към раздела: Затваряне на шеваClient-ът, написан в тази глава, няма представа какво има зад порта. Насочете base URL-а му към commercial provider и той streams tokens от модел с трилион parameters. Насочете го към server, построен върху аритметиката от Глава 13 — обслужващ модела, който pretrain-нахте в Глава 10, с неговия KV cache и quantized weights — и същият код, непроменен, streams tokens от модел, който сте построили.
const BASE = process.env.LLM_BASE_URL ?? "http://127.0.0.1:8000/v1"; Този единствен ред е шевът на курса. От едната му страна е това, което първите тринадесет глави построиха; от другата — това, което следващите шестнадесет строят. Границата е чиста, защото contract-ът е HTTP и SSE, и нито една страна не знае нищо друго за другата.
Струва си да забележите какво загубихте при пресичането. Зад commercial endpoint не контролирате нито weights, нито sampling implementation, нито version-а, с който говорите, нито дали се е променил тази сутрин. Контролирате contract-а: messages, които изпращате, deadline-а, който задавате, codes, които различавате, и какво правите, когато нищо не се върне. Това е по-малка повърхност, отколкото имахте в Глава 5, и всяка оставаща глава е за това да я използвате добре.
Накъде отиваме след това
Връзка към раздела: Накъде отиваме след товаВече имате client, който streams, отказва се навреме, retry-ва правилните неща и никога не retry-ва грешните. Това, което изпраща, все още е каквото сте написали.
Глава 15 е за това съдържание, и идва с дисциплина. Интернет е пълен със съвети за prompting — предложете tip на модела, заплашете го, кажете му да си поеме дълбоко въздух — и почти никой от тях не идва с measurement. Някои от тези техники местят output-а много, някои не го местят изобщо, и поне една прави classification task по-лоша, докато струва повече tokens. Кое е кое не е очевидно от прочитането им, и не се решава със спор.
Затова следващата глава строи bench: шестдесет cases с известни answers, четири variants на същия prompt, run-нати паралелно през точно client-а, който току-що написахте, tabulated с confidence intervals от Глава 4 — защото четири variants върху двадесет cases не различават нищо. Едно изречение управлява цялата глава: prompt се измерва, не се дебатира.
Източници и метод
Връзка към раздела: Източници и методВсяко число по-горе дойде от mock provider-а, на Node 22 през loopback interface, така че latencies са по-чисти, отколкото която и да е real network ще ви даде. Това е умишлено: нито един от failures, които се измерват, не е причинен от network-а, а hostile server, който можете да рестартирате, учи по-добре от real такъв, за който трябва да плащате и който не можете да счупите.
Препратки
Връзка към раздела: Препратки-
Server-Sent Events, WHATWG HTML Living Standard, section 9.2. Wire format-ът — fields
data:, events, разделени с blank line,id:иretry:— е дефиниран там, заедно с interface-аEventSource.EventSourceне може да изпраща request body или custom headers, затова всеки LLM client parse-ва format-а на ръка върхуfetch, вместо да го използва. ↩ -
Brooker, M. Exponential Backoff and Jitter. AWS Architecture Blog (2015). Източникът на formulation-а „full jitter“, използван по-горе, със simulations, които показват защо наивната версия синхронизира client-и. Companion argument-ът за shedding load вместо queueing е chapter-ът Handling Overload на Beyer, Jones, Petoff and Murphy (eds.), Site Reliability Engineering (O'Reilly, 2016). ↩
-
Fielding, R., Nottingham, M. and Reschke, J. (eds.), HTTP Semantics, RFC 9110, section 15, дефинира classes на status code-овете; Nottingham, M. and Fielding, R., Additional HTTP Status Codes, RFC 6585 (2012), section 4, дефинира 429 Too Many Requests.
Retry-Afterе RFC 9110 section 10.2.3 и приема или брой секунди, или HTTP date. ↩ -
Stripe, Idempotent requests,
docs.stripe.com/api/idempotent_requests, прочетено на 7 септември 2026 — най-ясното statement на contract-а: един key на logical operation, stored results се replay-ват, conflict се връща, докато първият attempt още е in flight — и pattern-ът е provider-independent. Нормативните references за request и event shapes, използвани тук, саdevelopers.openai.com/api/reference/resources/chatза streaming, error codes и rate limits, иplatform.claude.com/docs/en/api/messagesза Messages API;ai-sdk.dev/docsе най-добрият worked example на същите concerns, wrapped в library. Всички са прочетени в същия ден. ↩