Перейти к содержимому
14/30Глава 14 из 30

Первый 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)делает пять retries6 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 двуязычна по измеренной выше причине. Глава 28Markdown, потому что agent skill и есть файл SKILL.md, а назначать ему язык программирования означало бы не понять формат.

Тринадцать глав на Python не выброшены. По другую сторону port находится то, что они построили, и последний раздел здесь подключает к этому client.

Вы не сможете научиться этому на реальном provider. Нельзя попросить у него 429 в выбранный момент, или socket, который принимает connection и никогда не отвечает, или stream, который останавливается посреди слова, — и вы платили бы за каждый эксперимент, хотя самые интересные эксперименты запускаются по сто раз.

Поэтому первая программа в этой половине курса — не client. Это враждебный server: сорок строк plain Node, говорящие по тому же wire protocol, что и endpoint chat completions, и плохо ведущие себя по требованию. Все числа в этой главе получены из него.

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 принимает 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

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

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]

Request body и ключ, который никогда не покидает server

Ссылка на раздел: Request body и ключ, который никогда не покидает server

Chat request — это список messages, у каждого есть role. Этот список — всё state model: памяти между calls нет, и всё, что model должна знать, должно быть внутри array, который вы отправляете сейчас. Глава 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,
};

Эти 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.

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

Два числа одинаковы, и в этом вся проблема. 791 мс у пользователя spinner, и ни одно слово не было доступно раньше — server имел ответ, byte за byte, но решил ничего не говорить.

Второй — со streaming. Тот же server, тот же answer, та же total work. Разница — 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 никак не связан с event: один read() может вернуть половину event или два с половиной. Флаг { stream: true } нужен потому, что multi-byte UTF-8 character может быть разделён между двумя chunks, и без него буква с акцентом случайно превращается в replacement character. А events разделяются пустой строкой, не newline, поэтому loop ищет \n\n.

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

В двенадцать раз быстрее до первого слова и на две миллисекунды медленнее до последнего. Streaming ничего не ускоряет. Он меняет то, что пользователь делает в течение тех же 790 мс: читает вместо ожидания. В этом вся польза, она огромна, и поэтому все chat-продукты используют streaming.

Третий — с двадцатью clients одновременно. Mock provider обслуживает три requests за раз. Запустите двадцать:

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

Двадцать ответов, семьдесят четыре 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:

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. 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никогда
429rate limit: слишком много requests или слишком много tokens в минутуretryRetry-After, затем backoff
500provider сломалсяretrybackoff
503provider перегружен — он работает, но заполненretrybackoff и shed load

Важная линия проходит между 4xx и остальными. 400 или 401 вернёт ровно тот же ответ, если отправить его тысячу раз, потому что между attempts ничего не меняется ни на одном конце. Retry здесь не осторожность, а задержка с лишними шагами. Измерение: один client делает шесть attempts — пять retries с exponential backoff, — а другой сначала читает code.

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 внутри queue со своей redelivery, — поэтому шесть секунд превращаются в шесть минут permanently broken 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, который некоторые providers используют для «у вас закончились credits» и которому нужен экран со ссылкой купить ещё, а не retry; и 529 или его vendor-specific equivalents, которые ведут себя как 503.

Делать retry легко. Делать retry когда — вот часть, у которой есть измеримо правильный ответ.

Exponential backoff — стандарт: подождать base delay, удваивать её после каждой failure, остановиться на ceiling. Он существует потому, что перегруженному server становится хуже, если только что отказавшие clients сразу приходят обратно.

Проблема в том, что все удваивают от одной и той же стартовой точки. Если сто clients одновременно упрутся в limit — а они упрутся, потому что именно так выглядит traffic spike, — то все сто подождут 200 мс, все сто retry вместе, все сто снова fail, и все сто подождут 400 мс. Retry schedule синхронизировал их. Это thundering herd, и исправление — randomness.2

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

Это единственное изменение — выбирать uniformly из интервала вместо того, чтобы брать его верхнюю границу — называется 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);      

Сто clients, один server, обслуживающий три за раз, всё остальное одинаково, по три запуска:

HTTP requestsrejectionsхудший clientсамое загруженное окно 50 мсwall clock
без jitter, запуск 149139110 tries46 arrivals65,6 с
без jitter, запуск 278068019 tries72 arrivals245,7 с
без jitter, запуск 377067018 tries97 arrivals225,6 с
full jitter, запуск 13242245 tries32 arrivals2,2 с
full jitter, запуск 23132136 tries31 arrivals2,3 с
full jitter, запуск 33182186 tries25 arrivals1,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 секунды, ведут себя так:

TEXT
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 появится место.

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 самого невезучего client в запуске с двадцатью clients показывает, что header делает свою работу. Его первые четыре backoff draws были меньше одной секунды, и все четыре были overridden:

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, а не числом секунд, поэтому разбирайте оба варианта. И providers rate-limit сразу по двум осям — requests per minute и tokens per minute, — поэтому длинные prompts получают rejection намного ниже задокументированного request limit. Header выглядит одинаково в обоих случаях; fix — нет.

Попросите mock provider о /hang. Он принимает connection, а затем вообще ничего не делает: ни headers, ни body, ни close. Это не экзотика — именно так ведёт себя load balancer, когда process за ним умер, не закрыв sockets.

Два clients, одно отличие:

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

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

Итак: каждый outgoing request получает явный 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 недостаточно, потому что есть две разные failures. Первая — stream никогда не открывается: ни один event вообще не приходит, и десять–тридцать секунд здесь правильно. Вторая — stream открылся, а потом stalled: tokens шли, а затем остановились навсегда, при всё ещё здоровом socket. Total-duration timeout не может отличить stalled stream от длинного корректного answer, поэтому вам нужен idle timeout — timer, который сбрасывается каждым event и срабатывает только если, скажем, пятнадцать секунд ничего не приходило.

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 назначит этому цену.

Теперь failure, которая стоит денег, а не времени. Request истекает по timeout на client, и очевидный шаг — отправить его снова, но timeout ничего не говорит о том, получил ли его server. Очень часто получил и всё ещё работает.

Измерение. Mock provider нужно 780 мс на answer. Client сдаётся через 300 мс и делает retry. Server считает, сколько answers он действительно сгенерировал, то есть за что он выставил бы счёт:

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

Без ключа: две полные generations, оплаченные дважды, а client не получил ни одну. С ключом: server распознал второй request как тот же самый request и мгновенно ответил уже произведённым answer, так что retry одновременно избежал двойной оплаты и стал попыткой, которая наконец succeeded.

Idempotency key — это уникальная строка, которую вы генерируете на логическую операцию, а не на attempt, и отправляете без изменений при каждом её retry. Server сохраняет outcome по key и воспроизводит его. 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");
}

Два честных ограничения. Не каждый 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, которую построили вы.

switch.tsTS
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, который можно перезапустить, учит лучше, чем реальный, за который нужно платить и который нельзя ломать.

  1. Server-Sent Events, WHATWG HTML Living Standard, section 9.2. Wire format — поля data:, events, разделённые blank lines, id: и retry: — определён там же, вместе с interface EventSource. EventSource не может отправлять request body или custom headers, поэтому каждый LLM client parse-ит format вручную поверх fetch, вместо того чтобы использовать его.

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

  3. 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-After is RFC 9110 section 10.2.3, and accepts either a number of seconds or an HTTP date.

  4. 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. Все прочитаны в тот же день.


Автор

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.
jev11 мин чтения

AI-модель Jev создана для решений, а не для прозы

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

Abstract legal research workspace with documents, search nodes and governance controls.
openai10 мин чтения

Astra for Law от OpenAI — это юридическая AI-система, а не новая модель

Юридический запуск OpenAI меньше про новую фундаментальную модель и больше про систему вокруг нее: доменный поиск, проверенные инструменты, права доступа, бенчмарки и маршруты проверки.

Abstract agent runtime sorting documents, memory blocks and pointer nodes inside a bounded context frame.
context-engineering11 мин чтения

Инжиниринг контекста для AI-агентов с длинным горизонтом

Долгосрочные агенты дают сбой не только потому, что окно мало. Они ломаются, когда файлы, выводы инструментов и устаревшая история вытесняют задачу, которую агент должен был завершить.

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

Создавайте со всеми ИИ-моделями в одном месте — начните бесплатно уже сегодня.