Перший production-виклик LLM: стримінг, повтори, тайм-аути
Зберіть провайдера, який бреше: 429, завислі сокети, обірвані потоки. Full jitter: 2,2 секунди проти 226.
На цій сторінці
Розділ 13 закінчився секундоміром на моделі, до якої можна було доторкнутися. Ваги були у вашій пам’яті, KV cache ви могли ввімкнути або вимкнути, а число на виході — час до першого token — було властивістю вашого заліза.
Тепер поставте цю модель за порт, як це робить кожен продукт, і знову прочитайте те саме число. Це все ще час до першого token, але він уже не є властивістю чогось, що ви контролюєте. Тепер він включає TLS-handshake, чергу в провайдера, rate limiter і можливість того, що жоден token взагалі ніколи не прийде.
Остання частина — і є цей розділ. Код, який ви зараз напишете, нічого не обчислює. Він відкриває з’єднання, чекає, парсить те, що приходить, вирішує, що робити, коли нічого не приходить, знову вирішує, коли те, що прийшло, є помилкою, і скасовує себе, коли користувач передумав. Кожен із цих кроків — це рішення про стан у часі, і для кожного є неправильна відповідь, яку можна випустити в продакшн і за яку доведеться платити.
Ось форма проблеми, виміряна, і вся вона в цьому розділі:
| що сталося | що робить недбалий клієнт | чого це коштує |
|---|---|---|
| сервер прийняв сокет і ніколи не відповів | чекає | 300,8 с, перш ніж Node здається сам |
| ключ був неправильний (401) | повторює п’ять разів | 6 325 мс затримки, а потім той самий 401 |
| сотня клієнтів одночасно вперлася в rate limit | усі повторюють за тим самим розкладом | 226 с на розвантаження проти 2,2 с |
| запит вийшов за timeout і був надісланий знову | надсилає повторно | провайдер генерує — і виставляє рахунок — за відповідь двічі |
| з’єднання обірвалося посеред відповіді | показує частковий текст | не відрізнити від коректної короткої відповіді |
Жодна з цих проблем не є проблемою моделювання. Усі вони є в перших ста рядках кожного LLM-продукту, який коли-небудь писали.
Чому цей розділ змінює мову
Посилання на розділ: Чому цей розділ змінює мовуПрочитайте цю таблицю ще раз і запитайте себе, яку програму вона описує. Вона тримає з’єднання відкритим сорок секунд. Її треба мати змогу скасувати кнопкою. Вона накопичує часткову відповідь, яку валідно показувати й невалідно зберігати. І вона працює в серверному процесі або на edge worker, поруч із тим, що рендерить відповідь, тримаючи сокет.
Це не notebook. Річ не в тому, що Python не може цього робити — може, і люди роблять, — а в тому, що все, що будували попередні тринадцять розділів, було іншого типу. Розділи 1–13 тримали weights, gradients, logits і tokenizer bytes. Відтепер код тримає з’єднання, повтор, скасування, накопичений стан і, пізніше, permission prompt. Курс змінює мову рівно на шві, де змінюється об’єкт.
Отже, правило, записане один раз:
Якщо код тримає у руках weights, gradients, logits або tokenizer bytes, це Python. Якщо він тримає з’єднання, повторює, скасовує, накопичує стан і просить дозвіл, це TypeScript.
Шов один, і він проходить тут, між Розділом 13 і Розділом 14. Три незалежні критерії ставлять його сюди.
Перше: екосистема, порахована. Усе, на що посилається ліва половина цього курсу, написано на Python, і серед дванадцяти курсів, перевірених для цієї програми, немає жодного прецеденту, де backpropagation викладали б іншою мовою: micrograd (17,4K зірок), nanoGPT (62,8K), nanochat (57,8K), minbpe (10,7K), PyTorch (102,8K), transformers (164,9K). Написати Розділ 5 TypeScript означало б розірвати зв’язок із цими джерелами, а посилання — половина цінності розділу, який існує для того, щоб на нього посилалися, а не для того, щоб ранжуватися. На цьому боці арифметика змінюється на протилежну: пакет Vercel ai має 89,4 млн завантажень на місяць і постачає саму річ — tool-calling agent loop, експортований як ToolLoopAgent, — тож концепція, до якої цей курс доходить у Розділі 23, має референсну реалізацію в TypeScript, хоча, як вимірює той розділ, ніхто ще не домовився про назву; Mastra має 27,7K зірок; а SDK Anthropic, згенеровані з однієї специфікації, оголошують 202 endpoints у TypeScript проти 201 у Python — паритет, а не люб’язний порт.
Друге: нормативне джерело MCP. Схема специфікації Model Context Protocol — це файл schema.ts. Викладати протокол із Розділу 26 іншою мовою означає викладати переклад його установчого документа.
Третє: пошуковий попит із поправкою до очевидного припущення. machine learning python — найнасиченіша фраза в інтернеті; ai agent typescript має власний здоровий довгий хвіст. Але «екосистема MCP переважно TypeScript» — це правда лише залежно від того, як рахувати: офіційний registry показує 8 275 серверів на npm проти 3 603 на PyPI, тоді як за завантаженнями перемагає Python — 287M на місяць для mcp плюс 72M для fastmcp проти 195M для @modelcontextprotocol/sdk. MCP — єдина справді двомовна територія тут, саме тому Розділ 27 пише той самий сервер двічі, а не вдає.
Показати подробиці
П’ять оголошених винятків, щоб правило було правилом, а не гаслом.
Розділи 17, 20 і 29 мають другу панель на Python: реалізація top-p sampling потребує, щоб вектор імовірностей був у вас у руках, а 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 не відкидаються. По той бік порту — те, що вони збудували, і останній розділ тут під’єднує до цього клієнт.
Провайдер, який можна зламати
Посилання на розділ: Провайдер, який можна зламатиВи не навчитеся цього на реальному провайдері. Ви не можете попросити його видати 429 у вибраний момент, або сокет, який приймає ваше з’єднання й ніколи не відповідає, або stream, який зупиняється посеред слова — і ви платили б за кожен експеримент, тоді як цікаві експерименти — це ті, які ви запускаєте сто разів.
Тому перша програма в цій половині курсу — не клієнт. Це ворожий сервер: сорок рядків plain 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 приймає сокет і ніколи в нього не пише; /401 відхиляє ключ; перевірка ємності створює справжній 429 зі справжнім заголовком Retry-After, щойно три запити вже в роботі; а ?cut=N кидає відповідь на півдорозі, або скидаючи сокет, або — з &how=close — закриваючи його коректно, що, як виявляється, дуже важливо. Решта — справжній Server-Sent Events stream: один JSON-об’єкт на рядок data:, порожній рядок між подіями, рядок [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]Тіло запиту і ключ, який ніколи не залишає сервер
Посилання на розділ: Тіло запиту і ключ, який ніколи не залишає серверChat-запит — це список повідомлень, кожне з роллю. Цей список — увесь стан моделі: між викликами немає пам’яті, і все, що ви хочете, щоб модель знала, має бути всередині масиву, який ви надсилаєте цього разу. Розділ 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,
};Ці ролі — не декорація. Вони рендеряться в chat template з Розділу 11, перш ніж модель побачить хоч один token, тому надсилання неправильної ролі тихо погіршує відповідь замість того, щоб кинути помилку.
Одне правило без винятків: API key ніколи не їде до клієнта. Не в environment variable з префіксом для браузера, не в build-time constant, не «тимчасово». Ключ у bundle — це ключ на чужому рахунку за кілька днів. Браузер говорить із вашим сервером, ваш сервер тримає ключ і говорить із провайдером — і оскільки ваш сервер посередині, він також є єдиним місцем, яке може вимірювати, скільки витрачає кожен користувач; саме там має жити облік із Розділу 16.
Те саме запитання тричі
Посилання на розділ: Те саме запитання тричіТепер експеримент, на якому побудований розділ. Одне запитання, один mock provider, що створює тринадцять tokens по 60 мс кожен, три способи запитати.
Спершу без streaming. Клієнт надсилає запит і чекає на все JSON-тіло.
blocking first visible = 791 ms complete = 791 ms finish_reason = stopДва числа однакові, і в цьому вся проблема. 791 мс користувач бачить spinner, і жодне слово не було доступне раніше — сервер мав відповідь, байт за байтом, але вирішив нічого не казати.
Далі зі streaming. Той самий сервер, та сама відповідь, та сама загальна робота. Різниця — 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 не має жодного зв’язку з подією: один read() може повернути половину події або дві з половиною. Прапорець { stream: true } існує, бо багатобайтовий UTF-8 символ може бути розділений між двома chunks, і без нього літера з акцентом випадково перетвориться на replacement character. А події розділяються порожнім рядком, а не newline, саме тому цикл шукає \n\n.
streaming first visible = 65 ms complete = 793 ms finish_reason = stopУ дванадцять разів швидше до першого слова і на дві мілісекунди повільніше до останнього. Streaming нічого не прискорює. Він змінює те, що користувач робить протягом тих самих 790 мс: читає замість того, щоб чекати. У цьому вся користь, вона величезна, і саме тому кожен chat-продукт стримить.
Втретє — з двадцятьма клієнтами одночасно. Mock provider обслуговує три запити за раз. Запустіть двадцять:
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Двадцять відповідей, сімдесят чотири запити, п’ятдесят чотири відмови. Ніхто нічого не втратив, кожен клієнт отримав той самий текст, і єдиною видимою ціною був час. Так працює retry policy. Решта цього розділу — про три способи, якими вона може натомість провалитися.
finish_reason і два завершення, що виглядають однаково
Посилання на розділ: finish_reason і два завершення, що виглядають однаковоПеред збоями — поле, яке майже всі ігнорують у першій версії. Кожен stream завершується подією з finish_reason. stop означає, що модель вирішила: вона закінчила. length означає, що вона вперлася в token ceiling, тож відповідь обрізана посеред речення, і це не провина моделі. Пізніші розділи додають tool_calls (Розділ 18) і content filters.
Тепер подивіться на два завершення, які наївний клієнт не може розрізнити. Той самий сервер, та сама затримка, одне обрізане через max_tokens, а в іншому з’єднання чисто закривається після п’яти 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 в обох випадках. Цикл for await нормально завершився обидва рази, бо з точки зору reader тіло закінчилося, і це все, що тіло може зробити. Єдина різниця в усьому спостереженні — одне має finish_reason: "length", а інше не має взагалі нічого.
Тож правило не «ловіть помилки під час streaming». Правило таке:
Stream, який закінчується без
finish_reason, не закінчився. Він зупинився.
Ставтеся до відсутнього finish_reason як до збою, завжди, і ніколи не зберігайте цей текст як завершену відповідь. Третій рядок показує простіший випадок — знищений сокет справді кидає помилку, і він також втрачає chunk, який був у польоті, тому текст на одне слово коротший за два попередні.
П’ять status codes — це п’ять різних проблем
Посилання на розділ: П’ять status codes — це п’ять різних проблемНайдорожча звичка нового продукту — один блок catch для всього, що повертає провайдер. Ці коди — не варіації «щось упало». Це п’ять інструкцій, і чотири з них суперечать одна одній.
| status | що означає | що робити | чекати? |
|---|---|---|---|
| 400 | ваш запит malformed — поганий JSON, невідоме поле, context завеликий | виправити код | ніколи |
| 401 | ключ неправильний, відсутній або відкликаний | виправити deployment | ніколи |
| 429 | rate limit: забагато запитів або забагато tokens за хвилину | retry | Retry-After, потім backoff |
| 500 | провайдер зламався | retry | backoff |
| 503 | провайдер перевантажений — він працює, але заповнений | retry | backoff і shed load |
Важлива межа проходить між 4xx і рештою. 400 або 401 повертає рівно ту саму відповідь, якщо надіслати його тисячу разів, бо між спробами нічого не змінюється ні на одному, ні на іншому кінці. Повторювати це — не обережність, а затримка з додатковими кроками. Виміряно: один клієнт робить шість спроб — п’ять retries з exponential backoff — і один спершу читає код.
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 усередині черги з власним redelivery — тож шість секунд стають шістьма хвилинами, протягом яких назавжди зламаний 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, який деякі провайдери використовують для «у вас закінчився кредит» і якому потрібен екран із посиланням купити більше, а не retry, і 529 або його vendor-specific еквіваленти, які поводяться як 503.
Backoff і що насправді дає jitter
Посилання на розділ: Backoff і що насправді дає jitterRetrying — це легко. Retrying коли — ось частина, для якої є вимірювано правильна відповідь.
Exponential backoff — стандарт: зачекати базову затримку, подвоювати її після кожного збою, зупинитися на стелі. Він існує, бо перевантаженому серверу стає гірше, якщо клієнти, які щойно отримали відмову, одразу повертаються.
Проблема в тому, що всі подвоюють з однієї й тієї самої стартової точки. Якщо сотня клієнтів одночасно впирається в ліміт — а вони впруться, бо саме це і є traffic spike, — тоді всі сто чекають 200 мс, усі сто retry разом, усі сто fail разом, і всі сто чекають 400 мс. Retry schedule синхронізував їх. Це thundering herd, і випадковість — виправлення.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); Сто клієнтів, один сервер, що обслуговує три за раз, усе інше однакове, по три прогони:
| HTTP requests | rejections | worst client | busiest 50 ms window | wall clock | |
|---|---|---|---|---|---|
| no jitter, run 1 | 491 | 391 | 10 tries | 46 arrivals | 65,6 s |
| no jitter, run 2 | 780 | 680 | 19 tries | 72 arrivals | 245,7 s |
| no 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, приблизно у сто разів, із менш ніж половиною запитів. Найзавантаженіше retry window пояснює чому. Без jitter до 97 зі ста клієнтів приходили в той самий 50-мілісекундний слот; сервер мав три місця, тож 94 були відхилені й заснули разом, усе ще синхронізовані, щоб повторити це з довшим очікуванням. Із jitter та сама сотня розмазалася по тих самих вікнах групами приблизно по тридцять і розвантажилася майже одразу.
Друга — variance. Без jitter: 65,6 с, 245,7 с, 225,6 с. З ним: 2,2, 2,3, 1,8. Система без jitter не просто працює погано, вона працює непередбачувано, бо результат визначається мікроскопічними випадковостями планування, які вирішують, які три зі ста синхронізованих клієнтів прийдуть першими. Це сигнатура такого багу в production: endpoint нормальний, нормальний, нормальний, а потім займає чотири хвилини, і жодна ваша зміна цього не пояснює.
А найдешевший retry — той, якого не сталося. Поставте concurrency gate перед провайдером — лічильник, який ніколи не дозволяє мати в польоті більше ніж N запитів, — і ті самі двадцять клієнтів, яким потрібно було 74 запити й 7,1 секунди, поводяться так:
client-side gate of 3: 20 HTTP requests, 0 429s, wall 883 msДвадцять запитів на двадцять відповідей, нуль відмов, у вісім разів швидше. Retry — це вибачення; gate — це коли вибачатися не потрібно.
Retry-After — це підлога, а не порада
Посилання на розділ: Retry-After — це підлога, а не порадаКоли провайдер повертає 429, він зазвичай каже, скільки чекати, у заголовку Retry-After.3 Це число — не порада: провайдер є єдиною стороною обміну, яка знає, коли його вікно скидається.
Тож очікування — більше з двох: ніколи менше ніж Retry-After і ніколи менше ніж ваш власний backoff, бо заголовок каже, коли limiter вас пробачить, а не коли на сервері з’явиться місце.
const header = res.headers.get("retry-after");
const floor = header ? Number(header) * 1000 : 0; // seconds -> ms
const wait = Math.max(floor, backoffFull(attempt)); Trace найневезучішого клієнта у двадцятиклієнтському прогоні показує, як заголовок робить свою роботу. Його перші чотири backoff draws були нижче однієї секунди, і всі чотири були override:
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, а не кількістю секунд, тож парсьте обидва варіанти. І провайдери rate-limit одночасно по двох осях — requests per minute і tokens per minute, — саме тому довгі prompts відхиляються значно нижче задокументованого request limit. Заголовок виглядає однаково в обох випадках; виправлення — ні.
Timeout, якого ніхто не обирав
Посилання на розділ: Timeout, якого ніхто не обиравПопросіть mock provider про /hang. Він приймає з’єднання, а потім не робить взагалі нічого: ні headers, ні body, ні close. Це не екзотика — так поводиться load balancer, коли процес за ним помер, не закривши свої sockets.
Два клієнти, одна різниця:
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Триста секунд. П’ять хвилин відкритого сокета, зайнятого request slot і користувача, що дивиться на spinner, які закінчуються generic TypeError, що нічого не каже про те, що сталося. Це число — не bug: це default headers timeout у Node, розумний для generic HTTP client і катастрофічний для user-facing request. У кожному runtime є такий default, більшість людей ніколи його не шукає, і єдиний спосіб знайти ваш — навмисно підвісити сокет так, як ми щойно зробили.
Отже: кожен 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 недостатньо, бо є два різні збої. Перший — stream ніколи не відкривається: жодна подія не приходить узагалі, і десять–тридцять секунд тут доречні. Другий — stream відкривається, а потім зависає: tokens текли, а потім зупинилися назавжди, хоча сокет усе ще здоровий. Total-duration timeout не може відрізнити stalled stream від довгої правильної відповіді, тож вам потрібен idle timeout — таймер, який скидається кожною подією й спрацьовує лише тоді, коли нічого не приходило, скажімо, п’ятнадцять секунд.
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Тепер збій, який коштує грошей, а не часу. Запит виходить за timeout на клієнті, і очевидний рух — надіслати його знову, але timeout нічого не каже про те, чи сервер його отримав. Дуже часто отримав і досі працює.
Виміряно. Mock provider потребує 780 мс для відповіді. Клієнт здається на 300 мс і retry. Сервер рахує, скільки відповідей він фактично згенерував, тобто за що він виставив би рахунок:
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Без ключа: дві повні генерації, оплачені двічі, і клієнт не отримав жодної з них. З ключем: сервер розпізнав другий запит як той самий запит і миттєво відповів уже створеною відповіддю, тож retry і уникнув подвійного списання, і став спробою, яка нарешті вдалася.
Idempotency key — це унікальний рядок, який ви генеруєте для логічної операції — не для спроби — і надсилаєте незмінним у кожному її retry. Сервер зберігає результат за ключем і 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");
}Дві чесні межі. Не кожен провайдер підтримує idempotency keys для completions, і там, де endpoint не idempotent, правильна кількість retries для POST, який міг уже виконатися, дорівнює нулю. А stream, що впав на півдорозі, у загальному випадку не replayable: ви або перезапускаєте його й платите знову, або залишаєте частковий текст і позначаєте його incomplete. Який варіант обирає ваш продукт — це продуктове рішення, а не мережеве, і його варто ухвалити навмисно.
Закриваючи шов
Посилання на розділ: Закриваючи шовКлієнт, написаний у цьому розділі, не має уявлення, що за портом. Наведіть його base URL на комерційного провайдера — і він стримить tokens з моделі на трильйон parameters. Наведіть його на сервер, побудований на арифметиці Розділу 13 — який обслуговує модель, що ви pretrained у Розділі 10, з її KV cache і quantized weights, — і той самий код, без змін, стримить tokens з моделі, яку ви збудували.
const BASE = process.env.LLM_BASE_URL ?? "http://127.0.0.1:8000/v1"; Цей один рядок — шов усього курсу. По один бік від нього — те, що збудували перші тринадцять розділів; по інший — те, що будують наступні шістнадцять. Межа чиста, бо контракт — HTTP і SSE, і жодна сторона не знає про іншу нічого більше.
Варто помітити, що ви втратили, перейшовши її. За комерційним endpoint ви не контролюєте ні weights, ні sampling implementation, ні версію, з якою говорите, ні те, чи вона змінилася цього ранку. Ви контролюєте контракт: повідомлення, які надсилаєте, deadline, який ставите, коди, які розрізняєте, і те, що робите, коли нічого не повертається. Це менша поверхня, ніж у вас була в Розділі 5, і кожен наступний розділ — про те, як добре нею користуватися.
Куди це веде далі
Посилання на розділ: Куди це веде даліТепер у вас є клієнт, який стримить, вчасно здається, retry правильні речі й ніколи не retry неправильні. Те, що він надсилає, досі є тим, що ви набрали.
Розділ 15 — про цей вміст, і він приходить із дисципліною. Інтернет повний порад щодо prompting — запропонуйте моделі чайові, погрожуйте їй, скажіть їй глибоко вдихнути — і майже жодна не приходить із вимірюванням. Деякі з цих технік сильно рухають output, деякі не рухають його взагалі, а принаймні одна робить classification task гіршою, водночас коштуючи більше tokens. Що є що — не очевидно з читання, і це не вирішується суперечкою.
Тож наступний розділ будує bench: шістдесят cases із відомими відповідями, чотири варіанти того самого prompt, запущені паралельно через рівно той клієнт, який ви щойно написали, зведені в таблицю з confidence intervals із Розділу 4 — бо чотири варіанти на двадцяти cases не відрізняють нічого. Одне речення керує всім розділом: prompt вимірюють, а не обговорюють.
Джерела й метод
Посилання на розділ: Джерела й методКожне число вище прийшло з mock provider, на Node 22 через loopback interface, тож latencies чистіші, ніж дасть будь-яка реальна мережа. Це навмисно: жоден із вимірюваних failures не спричинений мережею, і ворожий сервер, який можна перезапустити, навчає краще, ніж реальний, за який треба платити і який не можна ламати.
Примітки
Посилання на розділ: Примітки-
Server-Sent Events, WHATWG HTML Living Standard, розділ 9.2. Wire format — поля
data:, події, розділені порожнім рядком,id:іretry:— визначено там, разом з інтерфейсомEventSource.EventSourceне може надсилати request body або custom headers, саме тому кожен LLM client парсить формат вручну поверхfetch, а не використовує його. ↩ -
Brooker, M. Exponential Backoff and Jitter. AWS Architecture Blog (2015). Джерело формулювання «full jitter», використаного вище, із симуляціями, які показують, чому наївна версія синхронізує клієнтів. Супровідний аргумент на користь shedding load замість queueing it — розділ 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, розділ 15, визначає класи status code; Nottingham, M. and Fielding, R., Additional HTTP Status Codes, RFC 6585 (2012), розділ 4, визначає 429 Too Many Requests.
Retry-After— це RFC 9110, розділ 10.2.3, і він приймає або кількість секунд, або HTTP date. ↩ -
Stripe, Idempotent requests,
docs.stripe.com/api/idempotent_requests, прочитано 7 вересня 2026 — найясніший опис контракту: один ключ на логічну операцію, stored results replayed, conflict повертається, поки перша спроба ще in flight, — і цей патерн не залежить від провайдера. Нормативні посилання для 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— найкраще пропрацьований приклад тих самих турбот, загорнутих у library. Усе прочитано того самого дня. ↩