Первый production-вызов LLM: streaming, retries и timeouts
Соберите provider, который обманывает: 429, зависшие сокеты, оборванные streams. Измерьте client: full jitter даёт 2,2 с вместо 226.
На этой странице
Глава 13 закончилась секундомером на model, которую можно было потрогать. Веса были у вас в памяти, KV cache вы могли включить или выключить, а получившееся число — time to first token — было свойством вашего железа.
Теперь поставьте эту model за port, как делает любой продукт, и прочитайте то же число снова. Это всё ещё time to first token, но теперь это уже не свойство чего-либо, что вы контролируете. В него входят TLS handshake, очередь у provider, rate limiter и вероятность того, что ни один token вообще не придёт.
Последняя часть — и есть эта глава. Код, который вы сейчас напишете, ничего не вычисляет. Он открывает соединение, ждёт, разбирает то, что приходит, решает, что делать, когда ничего не приходит, снова решает, когда пришла ошибка, и отменяет сам себя, когда пользователь передумал. Каждое из этих действий — решение о состоянии во времени, и у каждого есть неправильный ответ, который уходит в production и стоит денег.
Вот форма проблемы, измеренная, вся она — в этой главе:
| что произошло | что делает небрежный client | сколько это стоит |
|---|---|---|
| server принял socket и никогда не ответил | ждёт | 300,8 с, прежде чем Node сдастся сам |
| ключ был неверным (401) | делает пять retries | 6 325 мс задержки, затем тот же 401 |
| сто clients одновременно упёрлись в rate limit | все повторяют запрос по одному расписанию | 226 с на разгрузку, против 2,2 с |
| request истёк по timeout и был отправлен заново | отправляет его снова | provider генерирует — и выставляет счёт — за ответ дважды |
| connection оборвалось на середине ответа | показывает частичный текст | неотличимо от корректного короткого ответа |
Ни одна из этих проблем не относится к моделированию. Все они находятся в первых ста строках любого когда-либо написанного LLM-продукта.
Почему в этой главе меняется язык
Ссылка на раздел: Почему в этой главе меняется языкПрочитайте таблицу ещё раз и спросите себя, какую программу она описывает. Она держит connection открытым сорок секунд. Её нужно уметь отменять кнопкой. Она накапливает частичный ответ, который можно показывать и нельзя сохранять. И она работает в server process или на edge worker, рядом с тем, что рендерит ответ, удерживая socket.
Это не notebook. Дело не в том, что Python этого не умеет — умеет, и люди так делают, — а в том, что всё, что строили предыдущие тринадцать глав, было другого рода. Главы 1–13 держали weights, gradients, logits и tokenizer bytes. Дальше код держит connection, retry, cancellation, накопленное состояние и, позже, permission prompt. Курс меняет язык ровно на том шве, где меняется объект.
Итак, правило, записанное один раз:
Если код держит в руках weights, gradients, logits или tokenizer bytes — это Python. Если он держит connection, retries, cancels, накапливает state и просит permission — это TypeScript.
Шов один, и он проходит здесь, между Главой 13 и Главой 14. Три независимых критерия ставят его здесь.
Первое: ecosystem, в числах. Всё, на что ссылается левая половина этого курса, написано на Python, и среди двенадцати курсов, проверенных для этой программы, нет ни одного прецедента, где backpropagation преподавали бы на другом языке: micrograd (17,4K stars), nanoGPT (62,8K), nanochat (57,8K), minbpe (10,7K), PyTorch (102,8K), transformers (164,9K). Написать Главу 5 на TypeScript значило бы разорвать связь с этими источниками, а ссылки — половина ценности главы, которая существует, чтобы на неё ссылались, а не чтобы ранжироваться. На этой стороне арифметика меняется: пакет Vercel ai имеет 89,4M downloads в месяц и поставляет саму вещь — tool-calling agent loop, экспортированный как ToolLoopAgent, — поэтому концепт, до которого курс доходит в Главе 23, имеет reference implementation на TypeScript, хотя, как измеряет та глава, никто ещё не договорился, как это называть; Mastra имеет 27,7K stars; а SDK Anthropic, сгенерированные из одной спецификации, объявляют 202 endpoints в TypeScript против 201 в Python — паритет, а не порт из вежливости.
Второе: нормативный источник MCP. Schema спецификации Model Context Protocol — это файл schema.ts. Преподавать protocol из Главы 26 на другом языке значит преподавать перевод его основополагающего документа.
Третье: search demand, с поправкой к очевидной догадке. machine learning python — самая насыщенная фраза в интернете; у ai agent typescript есть собственный здоровый tail. Но утверждение «MCP ecosystem в основном TypeScript» верно только в зависимости от способа подсчёта: официальный registry показывает 8 275 servers на npm против 3 603 на PyPI, а по downloads выигрывает 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 не выброшены. По другую сторону port находится то, что они построили, и последний раздел здесь подключает к этому client.
Provider, который можно сломать
Ссылка на раздел: Provider, который можно сломатьВы не сможете научиться этому на реальном provider. Нельзя попросить у него 429 в выбранный момент, или socket, который принимает connection и никогда не отвечает, или stream, который останавливается посреди слова, — и вы платили бы за каждый эксперимент, хотя самые интересные эксперименты запускаются по сто раз.
Поэтому первая программа в этой половине курса — не client. Это враждебный server: сорок строк plain Node, говорящие по тому же wire protocol, что и endpoint chat completions, и плохо ведущие себя по требованию. Все числа в этой главе получены из него.
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 check выдаёт настоящий 429 с настоящим header Retry-After, когда три requests уже in flight; а ?cut=N бросает ответ на полпути — либо reset-ит socket, либо, с &how=close, закрывает его штатно, что, как выясняется, имеет огромное значение. Остальное — настоящий Server-Sent Events stream: один JSON object на строку data:, пустая строка между events, строка [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 и ключ, который никогда не покидает serverChat request — это список messages, у каждого есть role. Этот список — всё state model: памяти между calls нет, и всё, что model должна знать, должно быть внутри 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 до того, как model увидит хотя бы один token, поэтому отправка неправильной role тихо ухудшает ответ вместо того, чтобы вызвать ошибку.
Правило без исключений: API key никогда не отправляется client. Не в environment variable с префиксом для browser, не в build-time constant, не «временно». Ключ в bundle — это ключ на чужом счёте в течение нескольких дней. Browser говорит с вашим server, ваш server держит ключ и говорит с provider — и поскольку ваш server находится посередине, это также единственное место, где можно измерять расходы каждого пользователя; именно там должна жить бухгалтерия Главы 16.
Один и тот же вопрос, три раза
Ссылка на раздел: Один и тот же вопрос, три разаТеперь эксперимент, на котором построена глава. Один вопрос, один mock provider, генерирующий тринадцать tokens по 60 мс каждый, три способа спросить.
Первый — без streaming. Client отправляет request и ждёт весь JSON body.
blocking first visible = 791 ms complete = 791 ms finish_reason = stopДва числа одинаковы, и в этом вся проблема. 791 мс у пользователя spinner, и ни одно слово не было доступно раньше — server имел ответ, byte за byte, но решил ничего не говорить.
Второй — со streaming. Тот же server, тот же answer, та же total work. Разница — 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, и без него буква с акцентом случайно превращается в replacement character. А events разделяются пустой строкой, не newline, поэтому loop ищет \n\n.
streaming first visible = 65 ms complete = 793 ms finish_reason = stopВ двенадцать раз быстрее до первого слова и на две миллисекунды медленнее до последнего. Streaming ничего не ускоряет. Он меняет то, что пользователь делает в течение тех же 790 мс: читает вместо ожидания. В этом вся польза, она огромна, и поэтому все chat-продукты используют streaming.
Третий — с двадцатью clients одновременно. Mock provider обслуживает три requests за раз. Запустите двадцать:
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Двадцать ответов, семьдесят четыре requests, пятьдесят четыре отказа. Никто ничего не потерял, каждый client получил тот же текст, и единственной видимой ценой было время. Это работающая retry policy. Остальная часть главы — о трёх способах, которыми она вместо этого может сломаться.
finish_reason и два окончания, которые выглядят одинаково
Ссылка на раздел: finish_reason и два окончания, которые выглядят одинаковоПрежде чем перейти к сбоям, поле, которое почти все игнорируют при первом проходе. Каждый stream заканчивается event с finish_reason. stop означает, что model решила, что закончила. length означает, что она упёрлась в token ceiling, поэтому answer обрезан посреди предложения, и это не вина model. Позже главы добавят tool_calls (Глава 18) и content filters.
Теперь посмотрите на два окончания, которые наивный client не отличит. Тот же server, та же задержка: один answer усечён через max_tokens, а в другом connection чисто закрывается после пяти tokens:
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"Внимательно прочитайте первые две строки. Одинаковый текст. Одинаковое число chunks. Ни в одном случае нет exception. Loop for await оба раза завершился нормально, потому что с точки зрения reader body закончилось, а body ничего другого сделать не может. Единственная разница во всём наблюдении — у одного есть finish_reason: "length", у другого нет вообще ничего.
Значит, правило не «ловите ошибки при streaming». Правило такое:
Stream, который закончился без
finish_reason, не закончился. Он остановился.
Считайте отсутствие finish_reason failure всегда и никогда не сохраняйте этот текст как завершённый answer. Третья строка показывает более простой случай — уничтоженный socket действительно бросает exception, а ещё теряет chunk, который был in flight, поэтому текст на одно слово короче, чем в двух строках выше.
Пять status codes — это пять разных проблем
Ссылка на раздел: Пять status codes — это пять разных проблемСамая дорогая привычка нового продукта — один block catch на всё, что возвращает provider. Эти codes не варианты «сломалось». Это пять инструкций, и четыре из них противоречат друг другу.
| status | что это значит | что делать | ждать? |
|---|---|---|---|
| 400 | ваш request malformed — плохой JSON, неизвестное поле, context слишком длинный | исправить код | никогда |
| 401 | ключ неверный, отсутствует или отозван | исправить deployment | никогда |
| 429 | rate limit: слишком много requests или слишком много tokens в минуту | retry | Retry-After, затем backoff |
| 500 | provider сломался | retry | backoff |
| 503 | provider перегружен — он работает, но заполнен | retry | backoff и shed load |
Важная линия проходит между 4xx и остальными. 400 или 401 вернёт ровно тот же ответ, если отправить его тысячу раз, потому что между attempts ничего не меняется ни на одном конце. 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 в продукте обычно вложены — retrying HTTP client внутри retrying 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 используют для «у вас закончились credits» и которому нужен экран со ссылкой купить ещё, а не retry; и 529 или его vendor-specific equivalents, которые ведут себя как 503.
Backoff и что на самом деле даёт jitter
Ссылка на раздел: Backoff и что на самом деле даёт jitterДелать retry легко. Делать retry когда — вот часть, у которой есть измеримо правильный ответ.
Exponential backoff — стандарт: подождать base delay, удваивать её после каждой failure, остановиться на ceiling. Он существует потому, что перегруженному server становится хуже, если только что отказавшие clients сразу приходят обратно.
Проблема в том, что все удваивают от одной и той же стартовой точки. Если сто clients одновременно упрутся в limit — а они упрутся, потому что именно так выглядит traffic spike, — то все сто подождут 200 мс, все сто retry вместе, все сто снова fail, и все сто подождут 400 мс. Retry schedule синхронизировал их. Это thundering herd, и исправление — randomness.2
Это единственное изменение — выбирать uniformly из интервала вместо того, чтобы брать его верхнюю границу — называется 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); Сто clients, один server, обслуживающий три за раз, всё остальное одинаково, по три запуска:
| HTTP requests | rejections | худший client | самое загруженное окно 50 мс | wall clock | |
|---|---|---|---|---|---|
| без jitter, запуск 1 | 491 | 391 | 10 tries | 46 arrivals | 65,6 с |
| без jitter, запуск 2 | 780 | 680 | 19 tries | 72 arrivals | 245,7 с |
| без jitter, запуск 3 | 770 | 670 | 18 tries | 97 arrivals | 225,6 с |
| full jitter, запуск 1 | 324 | 224 | 5 tries | 32 arrivals | 2,2 с |
| full jitter, запуск 2 | 313 | 213 | 6 tries | 31 arrivals | 2,3 с |
| full jitter, запуск 3 | 318 | 218 | 6 tries | 25 arrivals | 1,8 с |
В этой таблице две вещи, и вторая важнее.
Первая — median: 226 секунд против 2,2, примерно стократная разница при менее чем половине requests. Самое загруженное retry window объясняет почему. Без jitter до 97 из ста clients приходили в один и тот же 50-миллисекундный slot; у server было три места, поэтому 94 были rejected и вместе уходили спать, всё ещё синхронизированные, чтобы повторить это с более длинным ожиданием. С jitter та же сотня распределялась по тем же windows группами примерно по тридцать и разгружалась почти сразу.
Вторая — variance. Без jitter: 65,6 с, 245,7 с, 225,6 с. С ним: 2,2, 2,3, 1,8. Система без jitter не просто работает плохо, она работает непредсказуемо, потому что исход определяется микроскопическими случайностями scheduling, выбирающими, какие три из ста синхронизированных clients придут первыми. Это signature такого bug в production: endpoint ведёт себя нормально, нормально, нормально — а потом занимает четыре минуты, и никакое ваше изменение этого не объясняет.
А самый дешёвый retry — тот, которого не было. Поставьте concurrency gate перед provider — счётчик, который никогда не позволяет более чем N requests быть in flight, — и те же двадцать clients, которым нужны были 74 requests и 7,1 секунды, ведут себя так:
client-side gate of 3: 20 HTTP requests, 0 429s, wall 883 msДвадцать requests на двадцать answers, ноль rejections, в восемь раз быстрее. Retry — это извинение; gate — это отсутствие необходимости извиняться.
Retry-After — это нижняя граница, а не совет
Ссылка на раздел: Retry-After — это нижняя граница, а не советКогда provider возвращает 429, он обычно сообщает, сколько ждать, в header Retry-After.3 Это число не рекомендация: provider — единственная сторона обмена, которая знает, когда сбросится его window.
Поэтому ожидание — это большее из двух: не меньше 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 в запуске с двадцатью clients показывает, что 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, а не числом секунд, поэтому разбирайте оба варианта. И providers rate-limit сразу по двум осям — requests per minute и tokens per minute, — поэтому длинные prompts получают rejection намного ниже задокументированного request limit. Header выглядит одинаково в обоих случаях; fix — нет.
Timeout, который никто не выбирал
Ссылка на раздел: Timeout, который никто не выбиралПопросите mock provider о /hang. Он принимает connection, а затем вообще ничего не делает: ни headers, ни body, ни close. Это не экзотика — именно так ведёт себя load balancer, когда process за ним умер, не закрыв sockets.
Два clients, одно отличие:
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 получает явный 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 недостаточно, потому что есть две разные failures. Первая — stream никогда не открывается: ни один event вообще не приходит, и десять–тридцать секунд здесь правильно. Вторая — stream открылся, а потом stalled: tokens шли, а затем остановились навсегда, при всё ещё здоровом socket. Total-duration timeout не может отличить stalled stream от длинного корректного answer, поэтому вам нужен idle timeout — timer, который сбрасывается каждым event и срабатывает только если, скажем, пятнадцать секунд ничего не приходило.
Cancellation — тот же механизм, направленный на человека. AbortSignal.timeout и пользователь, нажавший Stop, оба приходят как AbortError, поэтому объединяйте их и записывайте, какой из них сработал:
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 мс на answer. Client сдаётся через 300 мс и делает retry. Server считает, сколько answers он действительно сгенерировал, то есть за что он выставил бы счёт:
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Без ключа: две полные generations, оплаченные дважды, а client не получил ни одну. С ключом: server распознал второй request как тот же самый request и мгновенно ответил уже произведённым answer, так что retry одновременно избежал двойной оплаты и стал попыткой, которая наконец succeeded.
Idempotency key — это уникальная строка, которую вы генерируете на логическую операцию, а не на attempt, и отправляете без изменений при каждом её retry. Server сохраняет outcome по key и воспроизводит его. 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: вы либо запускаете его заново и платите снова, либо оставляете partial text и помечаете его incomplete. Что из этого делает ваш продукт — product decision, а не networking decision, и его стоит принять намеренно.
Закрывая шов
Ссылка на раздел: Закрывая шовClient, написанный в этой главе, не имеет понятия, что находится за port. Укажите его base URL на commercial provider, и он stream-ит tokens из model с триллионом parameters. Укажите его на server, построенный на арифметике Главы 13, — обслуживающий model, которую вы pretrained в Главе 10, с её KV cache и quantized weights, — и тот же самый код, без изменений, stream-ит tokens из model, которую построили вы.
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, который stream-ит, вовремя сдаётся, retry-ит правильные вещи и никогда не retry-ит неправильные. Но отправляет он всё ещё то, что вы набрали.
Глава 15 — об этом content, и у неё есть дисциплина. Интернет полон советов по prompting — предложите model чаевые, пригрозите ей, скажите сделать глубокий вдох, — и почти ни один совет не приходит с измерением. Некоторые из этих techniques сильно меняют output, некоторые не меняют его совсем, а как минимум одна делает classification task хуже, одновременно расходуя больше tokens. Что есть что, не очевидно при чтении, и спором это не решается.
Поэтому следующая глава строит bench: шестьдесят cases с известными answers, четыре variants одного и того же prompt, запущенные parallel ровно через client, который вы только что написали, и сведённые в таблицу с confidence intervals из Главы 4, — потому что четыре variants на двадцати cases вообще ничего не различают. Одно предложение управляет всей главой: prompt измеряют, а не обсуждают.
Источники и метод
Ссылка на раздел: Источники и методВсе числа выше получены от mock provider на Node 22 через loopback interface, поэтому latencies чище, чем даст любая реальная network. Это намеренно: ни одна из измеряемых failures не вызвана network, а hostile server, который можно перезапустить, учит лучше, чем реальный, за который нужно платить и который нельзя ломать.
Сноски
Ссылка на раздел: Сноски-
Server-Sent Events, WHATWG HTML Living Standard, section 9.2. Wire format — поля
data:, events, разделённые blank lines,id:иretry:— определён там же, вместе с interfaceEventSource.EventSourceне может отправлять request body или custom headers, поэтому каждый LLM client parse-ит format вручную поверхfetch, вместо того чтобы использовать его. ↩ -
Brooker, M. Exponential Backoff and Jitter. AWS Architecture Blog (2015). Источник формулировки «full jitter», использованной выше, с simulations, которые показывают, почему наивная версия синхронизирует clients. Сопутствующий аргумент в пользу shedding load вместо queueing — глава 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, defines the status code classes; Nottingham, M. and Fielding, R., Additional HTTP Status Codes, RFC 6585 (2012), section 4, defines 429 Too Many Requests.
Retry-Afteris RFC 9110 section 10.2.3, and accepts either a number of seconds or an HTTP date. ↩ -
Stripe, Idempotent requests,
docs.stripe.com/api/idempotent_requests, прочитано 7 сентября 2026 года — самое ясное изложение contract: один key на logical operation, stored results replayed, conflict возвращается, пока первая attempt ещё in flight, — и pattern не зависит от provider. Нормативные 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, упакованных в library. Все прочитаны в тот же день. ↩