Перейти до вмісту
14/30Розділ 14 з 30

Перший 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 — двомовний з виміряної вище причини. Розділ 28Markdown, бо agent skill є файлом SKILL.md, і дати йому мову програмування означало б не зрозуміти формат.

Тринадцять розділів на Python не відкидаються. По той бік порту — те, що вони збудували, і останній розділ тут під’єднує до цього клієнт.

Ви не навчитеся цього на реальному провайдері. Ви не можете попросити його видати 429 у вибраний момент, або сокет, який приймає ваше з’єднання й ніколи не відповідає, або stream, який зупиняється посеред слова — і ви платили б за кожен експеримент, тоді як цікаві експерименти — це ті, які ви запускаєте сто разів.

Тому перша програма в цій половині курсу — не клієнт. Це ворожий сервер: сорок рядків plain Node, які говорять тим самим wire protocol, що й chat completions endpoint, і навмисно поводяться погано. Кожне число в цьому розділі вийшло з нього.

mock-provider.mjsJS
import { createServer } from "node:http";

const WORDS = "A tide gauge is a device that measures sea level over time .".split(" ");
const CAPACITY = 3;                      // how many requests it will serve at once
let inflight = 0;

const sse = (res, obj) => res.write(`data: ${JSON.stringify(obj)}\n\n`);

createServer(async (req, res) => {
  const url = new URL(req.url, "http://x");

  if (url.pathname === "/hang") return;                        
  if (url.pathname === "/401") { res.writeHead(401); return res.end("{}"); }

  if (inflight >= CAPACITY) {                                  
    res.writeHead(429, { "retry-after": "1" });                
    return res.end(JSON.stringify({ error: { type: "rate_limit_error" } }));
  }
  inflight++;

  const cut = Number(url.searchParams.get("cut") ?? -1);       // abandon after N chunks
  const how = url.searchParams.get("how");                     // "close" = orderly, else reset
  const max = Number(url.searchParams.get("max_tokens") ?? 999);
  const delay = Number(url.searchParams.get("delay") ?? 60);   // ms per token

  res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache" });
  for (let i = 0; i < Math.min(WORDS.length, max); i++) {
    if (i === cut) {                                           
      how === "close" ? res.end() : res.destroy();             
      inflight--; return;                                      
    }
    await new Promise((r) => setTimeout(r, delay));
    sse(res, { choices: [{ delta: { content: (i ? " " : "") + WORDS[i] }, finish_reason: null }] });
  }
  sse(res, { choices: [{ delta: {}, finish_reason: max < WORDS.length ? "length" : "stop" }] });
  res.write("data: [DONE]\n\n");
  inflight--;
  res.end();
}).listen(8787);

Чотири ворожі поведінки, по рядку на кожну: /hang приймає сокет і ніколи в нього не пише; /401 відхиляє ключ; перевірка ємності створює справжній 429 зі справжнім заголовком Retry-After, щойно три запити вже в роботі; а ?cut=N кидає відповідь на півдорозі, або скидаючи сокет, або — з &how=close — закриваючи його коректно, що, як виявляється, дуже важливо. Решта — справжній Server-Sent Events stream: один JSON-об’єкт на рядок data:, порожній рядок між подіями, рядок [DONE] наприкінці.1

Запустіть його, і решта розділу — вимірювання.

terminalBASH
node mock-provider.mjs &
curl -N "http://127.0.0.1:8787/v1/chat?max_tokens=3"
TEXT
data: {"choices":[{"delta":{"content":"A"},"finish_reason":null}]}

data: {"choices":[{"delta":{"content":" tide"},"finish_reason":null}]}

data: {"choices":[{"delta":{"content":" gauge"},"finish_reason":null}]}

data: {"choices":[{"delta":{},"finish_reason":"length"}]}

data: [DONE]

Тіло запиту і ключ, який ніколи не залишає сервер

Посилання на розділ: Тіло запиту і ключ, який ніколи не залишає сервер

Chat-запит — це список повідомлень, кожне з роллю. Цей список — увесь стан моделі: між викликами немає пам’яті, і все, що ви хочете, щоб модель знала, має бути всередині масиву, який ви надсилаєте цього разу. Розділ 15 — про те, що в нього класти, а Розділ 16 — про те, скільки це коштує, тож тут важлива лише форма.

call.tsTS
const body = {
  model: "gpt-4.1-mini",
  messages: [
    { role: "system", content: "You explain instruments in one sentence." },
    { role: "user", content: "What is a tide gauge?" },
  ],
  stream: true,
  max_tokens: 200,
};

Ці ролі — не декорація. Вони рендеряться в chat template з Розділу 11, перш ніж модель побачить хоч один token, тому надсилання неправильної ролі тихо погіршує відповідь замість того, щоб кинути помилку.

Одне правило без винятків: API key ніколи не їде до клієнта. Не в environment variable з префіксом для браузера, не в build-time constant, не «тимчасово». Ключ у bundle — це ключ на чужому рахунку за кілька днів. Браузер говорить із вашим сервером, ваш сервер тримає ключ і говорить із провайдером — і оскільки ваш сервер посередині, він також є єдиним місцем, яке може вимірювати, скільки витрачає кожен користувач; саме там має жити облік із Розділу 16.

Тепер експеримент, на якому побудований розділ. Одне запитання, один mock provider, що створює тринадцять tokens по 60 мс кожен, три способи запитати.

Спершу без streaming. Клієнт надсилає запит і чекає на все JSON-тіло.

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

Два числа однакові, і в цьому вся проблема. 791 мс користувач бачить spinner, і жодне слово не було доступне раніше — сервер мав відповідь, байт за байтом, але вирішив нічого не казати.

Далі зі streaming. Той самий сервер, та сама відповідь, та сама загальна робота. Різниця — parser.

sse.tsTS
export async function* readSSE(res: Response) {
  const reader = res.body!.getReader();
  const decoder = new TextDecoder();
  let buffer = "";
  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    buffer += decoder.decode(value, { stream: true });   
    let sep: number;
    while ((sep = buffer.indexOf("\n\n")) !== -1) {       
      const event = buffer.slice(0, sep);
      buffer = buffer.slice(sep + 2);
      for (const line of event.split("\n")) {
        if (!line.startsWith("data:")) continue;
        const payload = line.slice(5).trim();
        if (payload === "[DONE]") return;
        yield JSON.parse(payload);
      }
    }
  }
}

Три деталі тут несуть навантаження, і більшість перших спроб пропускає всі три. buffer існує, бо network chunk не має жодного зв’язку з подією: один read() може повернути половину події або дві з половиною. Прапорець { stream: true } існує, бо багатобайтовий UTF-8 символ може бути розділений між двома chunks, і без нього літера з акцентом випадково перетвориться на replacement character. А події розділяються порожнім рядком, а не newline, саме тому цикл шукає \n\n.

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

У дванадцять разів швидше до першого слова і на дві мілісекунди повільніше до останнього. Streaming нічого не прискорює. Він змінює те, що користувач робить протягом тих самих 790 мс: читає замість того, щоб чекати. У цьому вся користь, вона величезна, і саме тому кожен chat-продукт стримить.

Втретє — з двадцятьма клієнтами одночасно. Mock provider обслуговує три запити за раз. Запустіть двадцять:

TEXT
jitter=true  clients=20  server capacity=3
HTTP requests made: 74   429s received: 54   200s: 20
wall clock: 7,100 ms
retries per client: 0 0 0 1 1 1 2 2 2 3 4 3 3 5 4 5 4 5 4 5
every answer identical: true

Двадцять відповідей, сімдесят чотири запити, п’ятдесят чотири відмови. Ніхто нічого не втратив, кожен клієнт отримав той самий текст, і єдиною видимою ціною був час. Так працює retry policy. Решта цього розділу — про три способи, якими вона може натомість провалитися.

finish_reason і два завершення, що виглядають однаково

Посилання на розділ: finish_reason і два завершення, що виглядають однаково

Перед збоями — поле, яке майже всі ігнорують у першій версії. Кожен stream завершується подією з finish_reason. stop означає, що модель вирішила: вона закінчила. length означає, що вона вперлася в token ceiling, тож відповідь обрізана посеред речення, і це не провина моделі. Пізніші розділи додають tool_calls (Розділ 18) і content filters.

Тепер подивіться на два завершення, які наївний клієнт не може розрізнити. Той самий сервер, та сама затримка, одне обрізане через max_tokens, а в іншому з’єднання чисто закривається після п’яти tokens:

TEXT
max_tokens=5           loop ended NORMALLY   chunks=5  finish_reason=length  text="A tide gauge is a"
socket closed cleanly  loop ended NORMALLY   chunks=5  finish_reason=null    text="A tide gauge is a"
socket destroyed       threw TypeError: terminated (UND_ERR_SOCKET)
                                             chunks=4  finish_reason=null    text="A tide gauge is"

Уважно прочитайте перші два рядки. Ідентичний текст. Ідентична кількість chunks. Жодного exception в обох випадках. Цикл for await нормально завершився обидва рази, бо з точки зору reader тіло закінчилося, і це все, що тіло може зробити. Єдина різниця в усьому спостереженні — одне має finish_reason: "length", а інше не має взагалі нічого.

Тож правило не «ловіть помилки під час streaming». Правило таке:

Stream, який закінчується без finish_reason, не закінчився. Він зупинився.

Ставтеся до відсутнього finish_reason як до збою, завжди, і ніколи не зберігайте цей текст як завершену відповідь. Третій рядок показує простіший випадок — знищений сокет справді кидає помилку, і він також втрачає chunk, який був у польоті, тому текст на одне слово коротший за два попередні.

Найдорожча звичка нового продукту — один блок catch для всього, що повертає провайдер. Ці коди — не варіації «щось упало». Це п’ять інструкцій, і чотири з них суперечать одна одній.

statusщо означаєщо робитичекати?
400ваш запит malformed — поганий JSON, невідоме поле, context завеликийвиправити кодніколи
401ключ неправильний, відсутній або відкликанийвиправити deploymentніколи
429rate limit: забагато запитів або забагато tokens за хвилинуretryRetry-After, потім backoff
500провайдер зламавсяretrybackoff
503провайдер перевантажений — він працює, але заповненийretrybackoff і shed load

Важлива межа проходить між 4xx і рештою. 400 або 401 повертає рівно ту саму відповідь, якщо надіслати його тисячу разів, бо між спробами нічого не змінюється ні на одному, ні на іншому кінці. Повторювати це — не обережність, а затримка з додатковими кроками. Виміряно: один клієнт робить шість спроб — п’ять retries з exponential backoff — і один спершу читає код.

TEXT
retry everything  ->  6 requests, gave up after 6,325 ms, still HTTP 401
triage first      ->  1 request,  gave up after     4 ms, still HTTP 401

Шість секунд spinner, щоб дійти до відповіді, яка була доступна за чотири мілісекунди. І це м’яка версія: retries у продукті зазвичай вкладені — retrying HTTP client усередині retrying job runner усередині черги з власним redelivery — тож шість секунд стають шістьма хвилинами, протягом яких назавжди зламаний deployment виглядає як повільний.

Triage займає дев’ять рядків і має жити в одному місці:

classify.tsTS
export type Verdict = "retry" | "retry-after" | "fatal";

export function classify(status: number): Verdict {
  if (status === 429) return "retry-after";        
  if (status === 408 || status >= 500) return "retry";
  return "fatal";  // 400, 401, 403, 404, 422 — nothing changes by waiting
}

Ще два для вашого списку: 402, який деякі провайдери використовують для «у вас закінчився кредит» і якому потрібен екран із посиланням купити більше, а не retry, і 529 або його vendor-specific еквіваленти, які поводяться як 503.

Retrying — це легко. Retrying коли — ось частина, для якої є вимірювано правильна відповідь.

Exponential backoff — стандарт: зачекати базову затримку, подвоювати її після кожного збою, зупинитися на стелі. Він існує, бо перевантаженому серверу стає гірше, якщо клієнти, які щойно отримали відмову, одразу повертаються.

Проблема в тому, що всі подвоюють з однієї й тієї самої стартової точки. Якщо сотня клієнтів одночасно впирається в ліміт — а вони впруться, бо саме це і є traffic spike, — тоді всі сто чекають 200 мс, усі сто retry разом, усі сто fail разом, і всі сто чекають 400 мс. Retry schedule синхронізував їх. Це thundering herd, і випадковість — виправлення.2

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

Ця одна зміна — вибирати рівномірно з інтервалу замість брати його верхню межу — називається full jitter. Це один виклик Math.random(), і його варто виміряти, а не приймати на віру:

backoff.tsTS
export const backoffNaive = (n: number, base = 200, cap = 20_000) =>
  Math.min(cap, base * 2 ** n);

export const backoffFull = (n: number, base = 200, cap = 20_000) =>
  Math.random() * Math.min(cap, base * 2 ** n);      

Сто клієнтів, один сервер, що обслуговує три за раз, усе інше однакове, по три прогони:

HTTP requestsrejectionsworst clientbusiest 50 ms windowwall clock
no jitter, run 149139110 tries46 arrivals65,6 s
no jitter, run 278068019 tries72 arrivals245,7 s
no jitter, run 377067018 tries97 arrivals225,6 s
full jitter, run 13242245 tries32 arrivals2,2 s
full jitter, run 23132136 tries31 arrivals2,3 s
full jitter, run 33182186 tries25 arrivals1,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 секунди, поводяться так:

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

Двадцять запитів на двадцять відповідей, нуль відмов, у вісім разів швидше. Retry — це вибачення; gate — це коли вибачатися не потрібно.

Коли провайдер повертає 429, він зазвичай каже, скільки чекати, у заголовку Retry-After.3 Це число — не порада: провайдер є єдиною стороною обміну, яка знає, коли його вікно скидається.

Тож очікування — більше з двох: ніколи менше ніж Retry-After і ніколи менше ніж ваш власний backoff, бо заголовок каже, коли limiter вас пробачить, а не коли на сервері з’явиться місце.

wait.tsTS
const header = res.headers.get("retry-after");
const floor = header ? Number(header) * 1000 : 0;   // seconds -> ms
const wait = Math.max(floor, backoffFull(attempt));  

Trace найневезучішого клієнта у двадцятиклієнтському прогоні показує, як заголовок робить свою роботу. Його перші чотири backoff draws були нижче однієї секунди, і всі чотири були override:

TEXT
t+   26ms  attempt 0  HTTP 429  -> sleep 1000 ms
t+ 1032ms  attempt 1  HTTP 429  -> sleep 1000 ms
t+ 2034ms  attempt 2  HTTP 429  -> sleep 1000 ms
t+ 3046ms  attempt 3  HTTP 429  -> sleep 1000 ms
t+ 4047ms  attempt 4  HTTP 429  -> sleep 2782 ms
t+ 6852ms  attempt 5  HTTP 200  -> sleep 0 ms

Дві практичні нотатки. Retry-After може бути HTTP-date, а не кількістю секунд, тож парсьте обидва варіанти. І провайдери rate-limit одночасно по двох осях — requests per minute і tokens per minute, — саме тому довгі prompts відхиляються значно нижче задокументованого request limit. Заголовок виглядає однаково в обох випадках; виправлення — ні.

Попросіть mock provider про /hang. Він приймає з’єднання, а потім не робить взагалі нічого: ні headers, ні body, ні close. Це не екзотика — так поводиться load balancer, коли процес за ним помер, не закривши свої sockets.

Два клієнти, одна різниця:

TEXT
AbortSignal.timeout(5s)   gave up after   5.0 s  (TimeoutError: The operation was aborted due to timeout)
no timeout                gave up after 300.8 s  (TypeError: fetch failed)
                          cause: HeadersTimeoutError UND_ERR_HEADERS_TIMEOUT

Триста секунд. П’ять хвилин відкритого сокета, зайнятого request slot і користувача, що дивиться на spinner, які закінчуються generic TypeError, що нічого не каже про те, що сталося. Це число — не bug: це default headers timeout у Node, розумний для generic HTTP client і катастрофічний для user-facing request. У кожному runtime є такий default, більшість людей ніколи його не шукає, і єдиний спосіб знайти ваш — навмисно підвісити сокет так, як ми щойно зробили.

Отже: кожен outgoing request отримує explicit deadline, вибраний вами.

deadline.tsTS
const res = await fetch(url, {
  method: "POST",
  headers: { "content-type": "application/json", authorization: `Bearer ${key}` },
  body: JSON.stringify(payload),
  signal: AbortSignal.timeout(20_000),   
});

Для streaming call одного deadline недостатньо, бо є два різні збої. Перший — stream ніколи не відкривається: жодна подія не приходить узагалі, і десять–тридцять секунд тут доречні. Другий — stream відкривається, а потім зависає: tokens текли, а потім зупинилися назавжди, хоча сокет усе ще здоровий. Total-duration timeout не може відрізнити stalled stream від довгої правильної відповіді, тож вам потрібен idle timeout — таймер, який скидається кожною подією й спрацьовує лише тоді, коли нічого не приходило, скажімо, п’ятнадцять секунд.

Cancellation — це та сама механіка, спрямована на людину. AbortSignal.timeout і натискання користувачем Stop обидва приходять як AbortError, тож об’єднайте їх і запишіть, який саме спрацював:

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

Abort важливий не лише для охайності: tokens генеруються й оплачуються, поки ви не слухаєте. Розділ 16 ставить на це ціну.

Тепер збій, який коштує грошей, а не часу. Запит виходить за timeout на клієнті, і очевидний рух — надіслати його знову, але timeout нічого не каже про те, чи сервер його отримав. Дуже часто отримав і досі працює.

Виміряно. Mock provider потребує 780 мс для відповіді. Клієнт здається на 300 мс і retry. Сервер рахує, скільки відповідей він фактично згенерував, тобто за що він виставив би рахунок:

TEXT
idempotency-key: no    attempt 0: TimeoutError after 300 ms  |  attempt 1: TimeoutError after 300 ms
                       answers generated (and billed): 2

idempotency-key: yes   attempt 0: TimeoutError after 300 ms  |  attempt 1: HTTP 200 (replay) id=cmpl_1
                       answers generated (and billed): 1

Без ключа: дві повні генерації, оплачені двічі, і клієнт не отримав жодної з них. З ключем: сервер розпізнав другий запит як той самий запит і миттєво відповів уже створеною відповіддю, тож retry і уникнув подвійного списання, і став спробою, яка нарешті вдалася.

Idempotency key — це унікальний рядок, який ви генеруєте для логічної операції — не для спроби — і надсилаєте незмінним у кожному її retry. Сервер зберігає результат за ключем і replay. Це механізм, який використовують payment APIs, з тієї самої причини.4

idempotent.tsTS
async function send(url: string, payload: unknown) {
  const key = crypto.randomUUID();      // once per turn, not per attempt

  for (let attempt = 0; attempt < 5; attempt++) {
    const res = await fetch(url, {
      method: "POST",
      body: JSON.stringify(payload),
      headers: { "content-type": "application/json", "idempotency-key": key },  
      signal: AbortSignal.timeout(20_000),
    });
    if (res.ok) return res;
    if (classify(res.status) === "fatal") throw new Error(`HTTP ${res.status}`);
    await sleep(backoffFull(attempt));
  }
  throw new Error("out of attempts");
}

Дві чесні межі. Не кожен провайдер підтримує idempotency keys для completions, і там, де endpoint не idempotent, правильна кількість retries для POST, який міг уже виконатися, дорівнює нулю. А stream, що впав на півдорозі, у загальному випадку не replayable: ви або перезапускаєте його й платите знову, або залишаєте частковий текст і позначаєте його incomplete. Який варіант обирає ваш продукт — це продуктове рішення, а не мережеве, і його варто ухвалити навмисно.

Клієнт, написаний у цьому розділі, не має уявлення, що за портом. Наведіть його base URL на комерційного провайдера — і він стримить tokens з моделі на трильйон parameters. Наведіть його на сервер, побудований на арифметиці Розділу 13 — який обслуговує модель, що ви pretrained у Розділі 10, з її KV cache і quantized weights, — і той самий код, без змін, стримить tokens з моделі, яку ви збудували.

switch.tsTS
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 не спричинений мережею, і ворожий сервер, який можна перезапустити, навчає краще, ніж реальний, за який треба платити і який не можна ламати.

  1. Server-Sent Events, WHATWG HTML Living Standard, розділ 9.2. Wire format — поля data:, події, розділені порожнім рядком, id: і retry: — визначено там, разом з інтерфейсом EventSource. EventSource не може надсилати request body або custom headers, саме тому кожен LLM client парсить формат вручну поверх fetch, а не використовує його.

  2. 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).

  3. 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.

  4. 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. Усе прочитано того самого дня.


Створено

David Vicente Campos

Засновник NeuraLIA Labs і співзасновник MyRealFood

Я інженер-програміст, випускник Університету Леона. Я співзаснував MyRealFood, де на посаді CTO створив застосунок, яким користувалися мільйони людей, щоб харчуватися здоровіше, і заснував NeuraLIA Labs, де створюю AI-продукти. Тут я пишу про те, що мені довелося зрозуміти дорогою, — так, як я сам хотів би, щоб мені це свого часу пояснили.

Більше про автора

Опубліковано NeuraLIA Labs.

Отримуйте нові дописи на пошту

Новини AI, гайди й оновлення продукту — короткий лист, коли ми публікуємо щось варте вашого часу.

Зміст курсу

Abstract software decision engine with branching paths, probability nodes, and glowing gates.
jev10 хв читання

AI-модель Jev створена для рішень, а не прози

Jev від TypeSafe AI привертає увагу, бо розглядає програмний інтелект як задачу ймовірності: вибрати правильну гілку, додати впевненість і не платити LLM за написання тексту, коли коду потрібне рішення.

Abstract agent runtime sorting documents, memory blocks and pointer nodes inside a bounded context frame.
context-engineering12 хв читання

Context engineering for long-horizon AI agents

Long-running agents do not fail only because the window is small. They fail when files, tool outputs and stale history crowd out the task the agent was supposed to finish.

Готові довірити вибір моделі LIA?

Створюйте з усіма моделями ШІ в одному місці — почніть безкоштовно вже сьогодні.