Към съдържанието
14/30Глава 14 от 30

Първото ви production LLM извикване: streaming, retries, timeouts

Създайте provider, който лъже — 429, увиснали socket-и и прекъснати streams — и измерете как реагира client-ът ви.

На тази страница

Глава 13 завърши с хронометър върху модел, който можехте да докоснете. Теглата бяха във вашата памет, KV cache беше ваш за включване или изключване, а числото, което излезе — време до първия token — беше свойство на вашия хардуер.

Сега сложете този модел зад порт, както прави всеки продукт, и прочетете същото число отново. То все още е време до първия token, но вече не е свойство на нещо, което контролирате. Вече включва TLS handshake, опашка при provider-а, rate limiter и възможността никога да не пристигне нито един token.

Последната част е тази глава. Кодът, който предстои да напишете, не изчислява нищо. Той отваря връзка, чака, парсира това, което пристига, решава какво да прави, когато нищо не пристига, решава отново, когато пристигналото е грешка, и се отменя сам, когато потребителят промени мнението си. Всяко от тези неща е решение за състояние във времето, и всяко има грешен отговор, който стига до production и струва пари.

Ето формата на проблема, измерена, цялата в тази глава:

какво се случикакво прави небрежен clientколко струва
server-ът прие socket-а и никога не отговоричака300,8 s, преди Node да се откаже сам
ключът беше грешен (401)retry-ва пет пъти6.325 ms забавяне, после същото 401
сто client-а удариха rate limit-а едновременновсички retry-ват по същия график226 s за източване, срещу 2,2 s
заявката изтече по timeout и беше изпратена отновоизпраща я отновоprovider-ът генерира — и таксува — отговора два пъти
връзката падна по средата на отговорапоказва частичния текстнеразличимо е от правилен кратък отговор

Нито едно от тези неща не е проблем на моделирането. Всички са в първите сто реда на всеки LLM продукт, писан някога.

Прочетете таблицата отново и попитайте какъв вид програма описва. Тя държи връзка отворена четиридесет секунди. Трябва да може да се отменя от бутон. Натрупва частичен отговор, който е валиден за показване и невалиден за записване. И работи в server процес или edge worker, до нещото, което рендерира отговора, държейки socket.

Това не е notebook. Не че Python не може да го прави — може, и хората го правят — а че всичко, което предишните тринадесет глави изградиха, беше от друг вид. Глави 1 до 13 държаха тегла, gradients, logits и tokenizer байтове. Оттук нататък кодът държи връзка, retry, cancellation, натрупано състояние и, по-късно, permission prompt. Курсът сменя езика точно на шева, където се сменя обектът.

Затова правилото, написано веднъж:

Ако кодът държи тегла, gradients, logits или tokenizer байтове в ръцете си, той е Python. Ако държи връзка, retries, отменя, натрупва състояние и иска разрешение, той е TypeScript.

Шевът е един и пада тук, между Глава 13 и Глава 14. Три независими критерия го поставят тук.

Първо: екосистемата, преброена. Всичко, което лявата половина на този курс цитира, е Python, а сред дванадесетте курса, прегледани за тази програма, няма нито един прецедент backpropagation да се преподава на друг език: micrograd (17,4K stars), nanoGPT (62,8K), nanochat (57,8K), minbpe (10,7K), PyTorch (102,8K), transformers (164,9K). Да напишем Глава 5 на TypeScript би прекъснало връзката с тези източници, а връзките са половината стойност на глава, която съществува, за да бъде реферирана, а не за да rank-ва. От тази страна аритметиката се обръща: пакетът ai на Vercel има 89,4M изтегляния месечно и доставя самото нещо — tool-calling agent loop, експортиран като ToolLoopAgent — така че концепцията, до която този курс стига в Глава 23, има reference implementation в TypeScript, макар че, както измерва онази глава, никой не се е съгласил за име за нея; Mastra е на 27,7K stars; а SDK-тата на Anthropic, генерирани от една спецификация, декларират 202 endpoints в TypeScript срещу 201 в Python — parity, не любезен port.

Второ: нормативният източник на MCP. Schema-та на спецификацията Model Context Protocol е файл schema.ts. Да се преподава protocol-ът от Глава 26 на друг език означава да се преподава превод на основополагащия му документ.

Трето: search demand, с корекция на очевидното предположение. machine learning python е най-наситената фраза в интернет; ai agent typescript има собствена здрава дълга опашка. Но „MCP екосистемата е предимно TypeScript“ е вярно само според начина на броене: официалният registry изброява 8.275 server-а в npm срещу 3.603 в PyPI, докато по изтегляния Python печели — 287M месечно за mcp плюс 72M за fastmcp срещу 195M за @modelcontextprotocol/sdk. MCP е единствената истински двуезична територия тук, затова Глава 27 пише същия server два пъти, вместо да се преструва.

Покажи подробности

Петте обявени изключения, за да бъде правилото правило, а не лозунг.

Глави 17, 20 и 29 носят втори панел на Python: имплементирането на top-p sampling изисква probability vector да е в ръцете ви, а HTTP API никога не ви дава такъв; честното ценообразуване на fine-tune означава да стартирате такъв, а LoRA adapter е дузина реда nn.Module; и lm-eval-harness, HELM, SWE-bench и τ-bench са Python, така че evaluation harness на TypeScript би бил огледалният образ на грешката с backpropagation. Глава 27 е двуезична, по измерената причина по-горе. Глава 28 е Markdown, защото agent skill е файл SKILL.md и да му дадете програмен език би означавало да не сте разбрали формата.

Тринадесетте Python глави не се изхвърлят. Това, което е от другата страна на порта, е онова, което те построиха, а последният раздел тук свързва client с него.

Не можете да научите нищо от това срещу реален provider. Не можете да поискате от него 429 в избран момент, или socket, който приема връзката ви и никога не отговаря, или stream, който спира по средата на дума — и бихте плащали за всеки експеримент, когато интересните експерименти са тези, които пускате сто пъти.

Затова първата програма в тази половина на курса не е client. Тя е враждебен server: четиридесет реда чист 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 приема socket-а и никога не пише в него; /401 отказва ключа; проверката за capacity произвежда истинско 429 с истински header Retry-After, щом вече има три заявки in flight; а ?cut=N изоставя отговора по средата, или чрез reset на socket-а, или — с &how=close — като го затваря подредено, което се оказва много важно. Останалото е реален Server-Sent Events stream: един JSON object на ред data:, празен ред между events, string-ът [DONE] в края.1

Стартирайте го, и останалата част от главата е измерване.

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. Този списък е цялото състояние на модела: няма памет между извикванията, и каквото искате моделът да знае, трябва да бъде вътре в 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, преди моделът да види един-единствен token, затова изпращането на грешна role тихо влошава отговора, вместо да вдигне грешка.

Едно правило без изключения: API ключът никога не отива при client-а. Нито в environment variable с prefix за browser-а, нито в build-time constant, нито „временно“. Ключ в bundle е ключ върху чужда сметка в рамките на дни. Browser-ът говори с вашия server, вашият server държи ключа и говори с provider-а — и понеже вашият server е по средата, той е и единственото място, което може да измерва колко харчи всеки потребител, а там трябва да живее счетоводството от Глава 16.

Сега експериментът, върху който е построена главата. Един въпрос, един mock provider, който произвежда тринадесет token-а по 60 ms всеки, три начина да попитате.

Първо, без streaming. Client-ът изпраща request-а и чака целия JSON body.

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

Двете числа са еднакви, и това е целият проблем. В продължение на 791 ms потребителят има spinner, а нито една дума не е била налична по-рано — server-ът е имал отговора, byte по byte, и е избрал да не казва нищо.

Второ, със streaming. Същият server, същият отговор, същата обща работа. Разликата е 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, и без него accented letter става replacement character на случаен принцип. А events се разделят с празен ред, не с newline, затова loop-ът търси \n\n.

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

Дванадесет пъти по-бързо до първата дума и две милисекунди по-бавно до последната. Streaming не прави нищо по-бързо. Той променя какво прави потребителят през същите 790 ms: чете, вместо да чака. Това е цялата полза, тя е огромна, и затова всеки chat продукт streams.

Трето, с двадесет client-а едновременно. Mock provider-ът обслужва три request-а наведнъж. Изстреляйте двадесет:

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

Двадесет отговора, седемдесет и четири request-а, петдесет и четири отказа. Никой не загуби нищо, всеки client получи същия текст, а единствената видима цена беше време. Това е работеща retry policy. Останалата част от тази глава е за трите начина, по които вместо това тя може да се провали.

finish_reason и два края, които изглеждат еднакво

Връзка към раздела: finish_reason и два края, които изглеждат еднакво

Преди провалите — field-ът, който почти всички игнорират при първия опит. Всеки stream завършва с event, носещ finish_reason. stop означава, че моделът е решил, че е готов. length означава, че е ударил token тавана, така че отговорът е truncated по средата на изречение и вината не е на модела. По-късни глави добавят tool_calls (Глава 18) и content filters.

Сега вижте два края, които наивен client не може да различи. Същият server, същото забавяне, единият truncated от max_tokens, а другият — с чисто затворена връзка след пет token-а:

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"

Прочетете внимателно първите два реда. Идентичен текст. Идентичен chunk count. Няма exception в нито един от двата случая. Loop-ът for await приключи нормално и двата пъти, защото от гледната точка на reader-а body-то е приключило и това е всичко, което едно body може да направи. Единствената разлика в цялото наблюдение е, че единият носи finish_reason: "length", а другият не носи нищо.

Затова правилото не е „catch errors while streaming“. То е:

Stream, който приключва без finish_reason, не е приключил. Той е спрял.

Третирайте липсващ finish_reason като failure, винаги, и никога не записвайте този текст като завършен отговор. Третият ред показва по-лесния случай — destroyed socket наистина throw-ва, и също губи chunk-а, който е бил in flight, затова текстът е с една дума по-кратък от двата по-горе.

Пет status code-а, които са пет различни проблема

Връзка към раздела: Пет status code-а, които са пет различни проблема

Най-скъпият навик на нов продукт е един block catch за всичко, което provider-ът връща. Тези codes не са вариации на „не се получи“. Те са пет инструкции, и четири от тях си противоречат.

statusкакво означавакакво да направитечакане?
400request-ът ви е malformed — лош JSON, unknown field, context твърде дълъгпоправете коданикога
401ключът е грешен, липсва или е revokedпоправете deployment-аникога
429rate limit: твърде много requests или твърде много tokens на минутаretryRetry-After, после backoff
500provider-ът се счупиretrybackoff
503provider-ът е overloaded — работи, но е пъленretrybackoff и shed load

Важната линия минава между 4xx и останалите. 400 или 401 връщат абсолютно същия отговор, ако ги изпратите хиляда пъти, защото между опитите нищо не се променя от нито една страна. Да ги 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 в продукт обикновено са вложени — retry-ващ HTTP client вътре в retry-ващ 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 използват за „нямате credit“ и което се нуждае от екран с линк за купуване на още, а не от retry, и 529 или vendor-specific еквивалентите му, които се държат като 503.

Retry-ването е лесно. Retry-ването кога е частта с измеримо правилен отговор.

Exponential backoff е стандартът: чака се base delay, удвоява се след всеки failure, спира се при ceiling. Съществува, защото overloaded server става по-зле, ако client-ите, които току-що са fail-нали, се върнат веднага.

Проблемът е, че всички удвояват от една и съща начална точка. Ако сто client-а ударят limit в един и същ момент — а ще го направят, защото това е traffic spike — тогава всички сто чакат 200 ms, всички сто retry-ват заедно, всички сто fail-ват заедно и всички сто чакат 400 ms. Retry schedule-ът ги е синхронизирал. Това е thundering herd, а randomness е поправката.2

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);      

Сто client-а, един server, който обслужва три наведнъж, всичко друго еднакво, по три run-а:

HTTP requestsrejectionsнай-лош clientнай-натоварен 50 ms прозорецwall clock
без jitter, run 149139110 tries46 arrivals65,6 s
без jitter, run 278068019 tries72 arrivals245,7 s
без 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, фактор около сто, с по-малко от половината requests. Най-натовареният retry прозорец казва защо. Без jitter до 97 от стоте client-а пристигнаха в същия 50-millisecond slot; server-ът имаше три места, така че 94 бяха rejected и заспаха заедно, все още синхронизирани, за да го направят пак с по-дълго чакане. С jitter същите сто се разпръснаха през същите прозорци на групи от около тридесет и се източиха почти веднага.

Второто е variance. Без jitter: 65,6 s, 245,7 s, 225,6 s. С него: 2,2, 2,3, 1,8. Система без jitter не просто се представя зле, тя се представя непредвидимо, защото резултатът се решава от микроскопични scheduling accidents, които избират кои три от сто синхронизирани client-а пристигат първи. Това е signature-ът на този bug в production: endpoint, който е добре, добре, добре, и после отнема четири минути, без ваша промяна да го обяснява.

А най-евтиният retry е този, който никога не се случва. Сложете concurrency gate пред provider-а — counter, който никога не позволява повече от N requests да са in flight — и същите двадесет client-а, които имаха нужда от 74 requests и 7,1 секунди, се държат така:

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

Двадесет requests за двадесет отговора, нула rejections, осем пъти по-бързо. Retry е извинението; gate е да нямате нужда от него.

Когато provider върне 429, обикновено ви казва колко да чакате в header-а Retry-After.3 Това число не е съвет: provider-ът е единствената страна в обмена, която знае кога window-ът му се reset-ва.

Затова чакането е по-голямото от двете: никога по-малко от 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 в run-а с двадесет client-а показва как 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, а не брой секунди, така че parse-вайте и двете. И providers rate-limit-ват по две оси едновременно — requests per minute и tokens per minute — затова дълги prompts биват rejected далеч под документирания request limit. Header-ът изглежда еднакво и в двата случая; поправката не е.

Поискайте от mock provider-а /hang. Той приема връзката и после не прави абсолютно нищо: няма headers, няма body, няма close. Това не е екзотика — така се държи load balancer, когато process-ът зад него е умрял, без да затвори socket-ите си.

Два client-а, една разлика:

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 получава 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 не стига, защото има два различни failure-а. Първият е stream-ът никога не се отваря: не пристига нито един event, и десет до тридесет секунди е правилно. Вторият е stream-ът се отваря и после stalling-ва: tokens са текли и после са спрели завинаги, със socket, който все още е здрав. Total-duration timeout не може да различи stalled stream от дълъг правилен отговор, така че това, което искате, е idle timeout — timer, reset-ван от всеки event, който firing-ва само когато нищо не е пристигало например петнадесет секунди.

Cancellation е същата механика, насочена към човек. AbortSignal.timeout и потребител, който натиска Stop, и двете пристигат като AbortError, така че ги комбинирайте и запишете кое е fired:

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 ms за отговора. Client-ът се отказва на 300 ms и retry-ва. Server-ът брои колко отговора всъщност е генерирал, което е онова, което би таксувал:

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

Без key: две пълни generations, платени два пъти, и client-ът не получи нито една от тях. С key: server-ът разпозна втория request като същия request и отговори незабавно с отговора, който вече беше произвел, така че retry-то едновременно избегна двойното таксуване и беше attempt-ът, който най-накрая успя.

Idempotency key е unique string, който генерирате за logical operation — не за attempt — и изпращате непроменен при всеки retry на нея. Server-ът съхранява outcome-а срещу key-а и го 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");
}

Две честни ограничения. Не всеки provider поддържа idempotency keys за completions, а когато endpoint-ът не е idempotent, правилният брой retries за POST, който може вече да е изпълнен, е нула. И stream, който се е провалил по средата, обикновено не може да се replay-не: или го рестартирате и плащате отново, или запазвате частичния текст и го маркирате incomplete. Кое от двете прави продуктът ви е product decision, не networking decision, и си струва да го вземете нарочно.

Client-ът, написан в тази глава, няма представа какво има зад порта. Насочете base URL-а му към commercial provider и той streams tokens от модел с трилион parameters. Насочете го към server, построен върху аритметиката от Глава 13 — обслужващ модела, който pretrain-нахте в Глава 10, с неговия KV cache и quantized weights — и същият код, непроменен, streams tokens от модел, който сте построили.

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, който streams, отказва се навреме, retry-ва правилните неща и никога не retry-ва грешните. Това, което изпраща, все още е каквото сте написали.

Глава 15 е за това съдържание, и идва с дисциплина. Интернет е пълен със съвети за prompting — предложете tip на модела, заплашете го, кажете му да си поеме дълбоко въздух — и почти никой от тях не идва с measurement. Някои от тези техники местят output-а много, някои не го местят изобщо, и поне една прави classification task по-лоша, докато струва повече tokens. Кое е кое не е очевидно от прочитането им, и не се решава със спор.

Затова следващата глава строи bench: шестдесет cases с известни answers, четири variants на същия prompt, run-нати паралелно през точно client-а, който току-що написахте, tabulated с confidence intervals от Глава 4 — защото четири variants върху двадесет cases не различават нищо. Едно изречение управлява цялата глава: prompt се измерва, не се дебатира.


Всяко число по-горе дойде от mock provider-а, на Node 22 през loopback interface, така че latencies са по-чисти, отколкото която и да е real network ще ви даде. Това е умишлено: нито един от failures, които се измерват, не е причинен от network-а, а hostile server, който можете да рестартирате, учи по-добре от real такъв, за който трябва да плащате и който не можете да счупите.

  1. Server-Sent Events, WHATWG HTML Living Standard, section 9.2. Wire format-ът — fields data:, events, разделени с blank line, id: и retry: — е дефиниран там, заедно с interface-а EventSource. EventSource не може да изпраща request body или custom headers, затова всеки LLM client parse-ва format-а на ръка върху fetch, вместо да го използва.

  2. Brooker, M. Exponential Backoff and Jitter. AWS Architecture Blog (2015). Източникът на formulation-а „full jitter“, използван по-горе, със simulations, които показват защо наивната версия синхронизира client-и. Companion argument-ът за shedding load вместо queueing е chapter-ът Handling Overload на Beyer, Jones, Petoff and Murphy (eds.), Site Reliability Engineering (O'Reilly, 2016).

  3. Fielding, R., Nottingham, M. and Reschke, J. (eds.), HTTP Semantics, RFC 9110, section 15, дефинира classes на status code-овете; Nottingham, M. and Fielding, R., Additional HTTP Status Codes, RFC 6585 (2012), section 4, дефинира 429 Too Many Requests. Retry-After е RFC 9110 section 10.2.3 и приема или брой секунди, или HTTP date.

  4. Stripe, Idempotent requests, docs.stripe.com/api/idempotent_requests, прочетено на 7 септември 2026 — най-ясното statement на contract-а: един key на logical operation, stored results се replay-ват, conflict се връща, докато първият attempt още е in flight — и pattern-ът е provider-independent. Нормативните references за request и event shapes, използвани тук, са developers.openai.com/api/reference/resources/chat за streaming, error codes и rate limits, и platform.claude.com/docs/en/api/messages за Messages API; ai-sdk.dev/docs е най-добрият worked example на същите concerns, wrapped в library. Всички са прочетени в същия ден.


Създадено от

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

AI моделът Jev е създаден за решения, не за проза

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

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

Astra for Law на OpenAI е правна AI система, не нов модел

Правният старт на OpenAI е не толкова за нов базов модел, колкото за системата около него: домейн извличане, надеждни инструменти, права, бенчмаркове и пътища за преглед.

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

Инженеринг на контекста за AI агенти с дълъг хоризонт

Дълго работещите агенти не се провалят само защото прозорецът е малък. Те се провалят, когато файлове, изходи от инструменти и остаряла история изтласкат задачата, която агентът е трябвало да завърши.

Готови ли сте LIA да избира вместо вас?

Създавайте с всички AI модели на едно място — започнете безплатно още днес.