Pierwsze produkcyjne wywołanie LLM: streaming, ponowienia i timeouty
Zbuduj providera, który kłamie: 429, wiszące sockety i ucięte streamy. Full jitter: 2,2 s zamiast 226.
Na tej stronie
Rozdział 13 skończył się stoperem przy modelu, którego można było dotknąć. Wagi były w Twojej pamięci, KV cache mogłeś włączyć albo wyłączyć, a liczba, która wyszła — czas do pierwszego token — była właściwością Twojego sprzętu.
Teraz schowaj ten model za portem, tak jak robi to każdy produkt, i odczytaj tę samą liczbę jeszcze raz. To nadal czas do pierwszego token, ale nie jest już właściwością niczego, nad czym masz kontrolę. Obejmuje teraz handshake TLS, kolejkę u providera, rate limiter i możliwość, że żaden token w ogóle nie nadejdzie.
To ostatnie zdanie jest tym rozdziałem. Kod, który zaraz napiszesz, niczego nie oblicza. Otwiera połączenie, czeka, parsuje to, co przychodzi, decyduje, co zrobić, gdy nic nie przychodzi, decyduje ponownie, gdy to, co przyszło, jest błędem, i anuluje samego siebie, gdy użytkownik zmieni zdanie. Każda z tych rzeczy jest decyzją o stanie w czasie, a każda ma błędną odpowiedź, która trafia do produkcji i kosztuje pieniądze.
Oto kształt problemu, zmierzony w całości w tym rozdziale:
| co się stało | co robi niedbały klient | ile to kosztuje |
|---|---|---|
| serwer zaakceptował socket i nigdy nie odpowiedział | czeka | 300,8 s, zanim Node podda się sam |
| klucz był zły (401) | ponawia pięć razy | 6325 ms opóźnienia, a potem to samo 401 |
| stu klientów naraz trafia w rate limit | wszyscy ponawiają według tego samego harmonogramu | 226 s do rozładowania, zamiast 2,2 s |
| request przekroczył timeout i został wysłany ponownie | wysyła go ponownie | provider generuje — i nalicza opłatę — za odpowiedź dwa razy |
| połączenie zerwało się w połowie odpowiedzi | pokazuje częściowy tekst | nie do odróżnienia od poprawnej krótkiej odpowiedzi |
Żaden z tych problemów nie jest problemem modelowania. Wszystkie mieszczą się w pierwszych stu liniach każdego produktu LLM, jaki kiedykolwiek napisano.
Dlaczego ten rozdział zmienia język
Link do sekcji: Dlaczego ten rozdział zmienia językPrzeczytaj tę tabelę jeszcze raz i zapytaj, jaki rodzaj programu opisuje. Utrzymuje połączenie otwarte przez czterdzieści sekund. Musi dać się anulować przyciskiem. Gromadzi częściową odpowiedź, którą można wyświetlić, ale której nie wolno zapisać. I działa w procesie serwera albo na edge workerze, obok tego, co renderuje odpowiedź, trzymając socket.
To nie jest notebook. Nie chodzi o to, że Python nie potrafi tego zrobić — potrafi, i ludzie tak robią — tylko o to, że wszystko, co zbudowało poprzednie trzynaście rozdziałów, było innego rodzaju. Rozdziały 1–13 trzymały wagi, gradienty, logit i bajty tokenizera. Od tego miejsca kod trzyma połączenie, ponowienie, anulowanie, zgromadzony stan i, później, prompt o zgodę. Kurs zmienia język dokładnie na szwie, na którym zmienia się obiekt.
Zasada, zapisana raz:
Jeśli kod ma w rękach wagi, gradienty, logit albo bajty tokenizera, jest w Pythonie. Jeśli trzyma połączenie, ponawia, anuluje, gromadzi stan i prosi o zgodę, jest w TypeScript.
Szew jest jeden i wypada tutaj, między Rozdziałem 13 a Rozdziałem 14. Trzy niezależne kryteria umieszczają go tutaj.
Po pierwsze: ekosystem, policzony. Wszystko, na co powołuje się lewa połowa tego kursu, jest w Pythonie, a w dwunastu kursach przejrzanych na potrzeby tego sylabusa nie ma ani jednego precedensu, by backpropagation uczono w innym języku: micrograd (17,4 tys. gwiazdek), nanoGPT (62,8 tys.), nanochat (57,8 tys.), minbpe (10,7 tys.), PyTorch (102,8 tys.), transformers (164,9 tys.). Napisanie Rozdziału 5 w TypeScript zerwałoby link z tymi źródłami, a linki są połową wartości rozdziału, który istnieje po to, by można było się do niego odwoływać, a nie po to, by rankował. Po tej stronie arytmetyka się odwraca: pakiet Vercela ai ma 89,4 mln pobrań miesięcznie i dostarcza samą rzecz — pętlę agent z tool calling, eksportowaną jako ToolLoopAgent — więc pojęcie, do którego ten kurs dochodzi w Rozdziale 23, ma swoją implementację referencyjną w TypeScript, mimo że, jak mierzy tamten rozdział, nikt nie uzgodnił dla niego nazwy; Mastra ma 27,7 tys. gwiazdek; a SDK Anthropic, generowane z jednej specyfikacji, deklaruje 202 endpointy w TypeScript wobec 201 w Pythonie — parytet, nie uprzejmy port.
Po drugie: normatywne źródło MCP. Schemat specyfikacji Model Context Protocol jest plikiem schema.ts. Uczenie protokołu z Rozdziału 26 w innym języku oznacza uczenie tłumaczenia jego dokumentu założycielskiego.
Po trzecie: popyt w wyszukiwarce, z korektą oczywistej hipotezy. machine learning python to najbardziej nasycona fraza w internecie; ai agent typescript ma własny, zdrowy long tail. Ale „ekosystem MCP jest głównie TypeScriptowy” jest prawdą tylko zależnie od tego, jak liczysz: oficjalny rejestr podaje 8275 serwerów na npm wobec 3603 na PyPI, podczas gdy w pobraniach wygrywa Python — 287 mln miesięcznie dla mcp plus 72 mln dla fastmcp wobec 195 mln dla @modelcontextprotocol/sdk. MCP jest tutaj jedynym naprawdę dwujęzycznym terytorium, dlatego Rozdział 27 pisze ten sam serwer dwa razy, zamiast udawać.
Pokaż szczegóły
Pięć zadeklarowanych wyjątków, żeby reguła była regułą, a nie sloganem.
Rozdziały 17, 20 i 29 mają drugi panel w Pythonie: implementacja próbkowania top-p wymaga wektora prawdopodobieństw w ręku, a HTTP API nigdy go nie daje; uczciwa wycena fine-tune oznacza uruchomienie jednego, a adapter LoRA to kilkanaście linii nn.Module; a lm-eval-harness, HELM, SWE-bench i τ-bench są w Pythonie, więc evaluation harness w TypeScript byłby lustrzanym odbiciem błędu z backpropagation. Rozdział 27 jest dwujęzyczny, z powodu zmierzonego powyżej. Rozdział 28 jest Markdown, ponieważ agent skill jest plikiem SKILL.md, a nadanie mu języka programowania oznaczałoby niezrozumienie formatu.
Trzynaście rozdziałów w Pythonie nie zostaje wyrzuconych. Po drugiej stronie portu jest to, co zbudowały, a ostatnia sekcja tutaj podłącza do tego klienta.
Provider, którego możesz zepsuć
Link do sekcji: Provider, którego możesz zepsućNie nauczysz się tego na prawdziwym providerze. Nie możesz poprosić go o 429 w wybranym momencie, o socket, który akceptuje połączenie i nigdy nie odpowiada, ani o stream, który zatrzymuje się w środku słowa — i płaciłbyś za każdy eksperyment, podczas gdy interesujące eksperymenty to te, które uruchamiasz sto razy.
Dlatego pierwszy program w tej połowie kursu nie jest klientem. To wrogi serwer: czterdzieści linii czystego Node, które mówią tym samym wire protocol co endpoint chat completions i zachowują się źle na żądanie. Każda liczba w tym rozdziale pochodzi z niego.
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);Cztery wrogie zachowania, po jednej linii: /hang akceptuje socket i nigdy do niego nie pisze; /401 odrzuca klucz; sprawdzenie pojemności daje prawdziwe 429 z prawdziwym nagłówkiem Retry-After, gdy trzy requesty są już w toku; a ?cut=N porzuca odpowiedź w połowie, albo resetując socket, albo — z &how=close — zamykając go w uporządkowany sposób, co okazuje się mieć ogromne znaczenie. Reszta to prawdziwy stream Server-Sent Events: jeden obiekt JSON na linię data:, pusta linia między zdarzeniami, string [DONE] na końcu.1
Uruchom go, a reszta rozdziału jest pomiarem.
node mock-provider.mjs &
curl -N "http://127.0.0.1:8787/v1/chat?max_tokens=3"data: {"choices":[{"delta":{"content":"A"},"finish_reason":null}]}
data: {"choices":[{"delta":{"content":" tide"},"finish_reason":null}]}
data: {"choices":[{"delta":{"content":" gauge"},"finish_reason":null}]}
data: {"choices":[{"delta":{},"finish_reason":"length"}]}
data: [DONE]Body requestu i klucz, który nigdy nie opuszcza serwera
Link do sekcji: Body requestu i klucz, który nigdy nie opuszcza serweraChat request to lista wiadomości, każda z rolą. Ta lista jest całym stanem modelu: między wywołaniami nie ma pamięci, a wszystko, co model ma wiedzieć, musi znaleźć się w tablicy, którą wysyłasz tym razem. Rozdział 15 dotyczy tego, co do niej włożyć, a Rozdział 16 tego, ile to kosztuje, więc tutaj chodzi tylko o kształt.
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,
};Te role nie są dekoracją. Są renderowane do chat template z Rozdziału 11, zanim model zobaczy choć jeden token, dlatego wysłanie złej roli po cichu pogarsza odpowiedź, zamiast zgłosić błąd.
Jedna zasada bez wyjątków: klucz API nigdy nie trafia do klienta. Nie w zmiennej środowiskowej z prefiksem dla przeglądarki, nie w stałej z build time, nie „tymczasowo”. Klucz w bundlu to w ciągu kilku dni klucz na cudzym rachunku. Przeglądarka rozmawia z Twoim serwerem, Twój serwer trzyma klucz i rozmawia z providerem — a ponieważ Twój serwer jest pośrodku, jest też jedynym miejscem, które może mierzyć, ile wydaje każdy użytkownik, czyli tam musi mieszkać księgowość z Rozdziału 16.
To samo pytanie, trzy razy
Link do sekcji: To samo pytanie, trzy razyTeraz eksperyment, na którym zbudowany jest rozdział. Jedno pytanie, jeden mock provider produkujący trzynaście tokenów po 60 ms każdy, trzy sposoby pytania.
Najpierw bez streamingu. Klient wysyła request i czeka na całe body JSON.
blocking first visible = 791 ms complete = 791 ms finish_reason = stopTe dwie liczby są takie same, i to jest cały problem. Przez 791 ms użytkownik widzi spinner, a ani jedno słowo nie było dostępne wcześniej — serwer miał odpowiedź, bajt po bajcie, i postanowił nic nie mówić.
Potem ze streamingiem. Ten sam serwer, ta sama odpowiedź, ta sama całkowita praca. Różnicą jest parser.
export async function* readSSE(res: Response) {
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let sep: number;
while ((sep = buffer.indexOf("\n\n")) !== -1) {
const event = buffer.slice(0, sep);
buffer = buffer.slice(sep + 2);
for (const line of event.split("\n")) {
if (!line.startsWith("data:")) continue;
const payload = line.slice(5).trim();
if (payload === "[DONE]") return;
yield JSON.parse(payload);
}
}
}
}Trzy szczegóły są tam nośne i większość pierwszych podejść pomija wszystkie trzy. buffer istnieje dlatego, że chunk sieciowy nie ma związku ze zdarzeniem: jedno read() może zwrócić pół zdarzenia albo dwa i pół. Flaga { stream: true } istnieje dlatego, że wielobajtowy znak UTF-8 może zostać przecięty między dwoma chunkami, a bez niej litera z akcentem losowo zamienia się w znak zastępczy. A zdarzenia są rozdzielane pustą linią, nie znakiem nowej linii, dlatego pętla szuka \n\n.
streaming first visible = 65 ms complete = 793 ms finish_reason = stopDwanaście razy szybciej do pierwszego słowa i o dwie milisekundy wolniej do ostatniego. Streaming niczego nie przyspiesza. Zmienia to, co użytkownik robi podczas tych samych 790 ms: czyta zamiast czekać. To cała korzyść, jest ogromna i to dlatego każdy produkt chat streamuje.
Po trzecie, z dwudziestoma klientami naraz. Mock provider obsługuje trzy requesty jednocześnie. Odpal dwadzieścia:
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: trueDwadzieścia odpowiedzi, siedemdziesiąt cztery requesty, pięćdziesiąt cztery odrzucenia. Nikt niczego nie stracił, każdy klient dostał ten sam tekst, a jedynym widocznym kosztem był czas. Tak wygląda działająca polityka ponowień. Reszta tego rozdziału jest o trzech sposobach, na jakie może zamiast tego zawieść.
finish_reason i dwa zakończenia, które wyglądają tak samo
Link do sekcji: finish_reason i dwa zakończenia, które wyglądają tak samoPrzed awariami pole, które prawie każdy ignoruje za pierwszym podejściem. Każdy stream kończy się zdarzeniem niosącym finish_reason. stop oznacza, że model uznał, że skończył. length oznacza, że uderzył w sufit tokenów, więc odpowiedź jest ucięta w połowie zdania i to nie jest wina modelu. Późniejsze rozdziały dodają tool_calls (Rozdział 18) i filtry treści.
Teraz zobacz dwa zakończenia, których naiwny klient nie potrafi rozróżnić. Ten sam serwer, to samo opóźnienie, jedno ucięte przez max_tokens i jedno, w którym połączenie zostaje czysto zamknięte po pięciu tokenach:
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"Przeczytaj uważnie pierwsze dwa wiersze. Identyczny tekst. Identyczna liczba chunków. W żadnym przypadku nie ma wyjątku. Pętla for await zakończyła się normalnie oba razy, bo z punktu widzenia readera body się skończyło i to wszystko, co body może zrobić. Jedyna różnica w całej obserwacji jest taka, że jedno niesie finish_reason: "length", a drugie nie niesie niczego.
Zasada nie brzmi więc „łap błędy podczas streamingu”. Brzmi:
Stream, który kończy się bez
finish_reason, nie zakończył się. Zatrzymał się.
Brakujące finish_reason traktuj zawsze jako awarię i nigdy nie zapisuj takiego tekstu jako zakończonej odpowiedzi. Trzeci wiersz pokazuje łatwiejszy przypadek — zniszczony socket faktycznie rzuca wyjątek, a przy tym traci chunk, który był w locie, dlatego tekst jest o jedno słowo krótszy niż dwa powyższe.
Pięć kodów statusu, które są pięcioma różnymi problemami
Link do sekcji: Pięć kodów statusu, które są pięcioma różnymi problemamiNajdroższy nawyk nowego produktu to jeden blok catch na wszystko, co zwraca provider. Te kody nie są wariantami „nie udało się”. To pięć instrukcji, a cztery z nich sobie przeczą.
| status | co oznacza | co zrobić | czekać? |
|---|---|---|---|
| 400 | Twój request jest źle sformułowany — zły JSON, nieznane pole, za długi context | popraw kod | nigdy |
| 401 | klucz jest zły, brakujący albo unieważniony | popraw deployment | nigdy |
| 429 | rate limit: za dużo requestów albo za dużo tokenów na minutę | ponów | Retry-After, potem backoff |
| 500 | provider się zepsuł | ponów | backoff |
| 503 | provider jest przeciążony — działa, ale jest pełny | ponów | backoff i odrzucaj nadmiar obciążenia |
Ważna linia przebiega między 4xx a resztą. 400 albo 401 zwróci dokładnie tę samą odpowiedź, jeśli wyślesz je tysiąc razy, bo między próbami nic nie zmienia się po żadnej stronie. Ponawianie tego nie jest ostrożnością, tylko opóźnieniem z dodatkowymi krokami. Pomiar: jeden klient wykonuje sześć prób — pięć ponowień z exponential backoff — a drugi najpierw czyta kod.
retry everything -> 6 requests, gave up after 6,325 ms, still HTTP 401
triage first -> 1 request, gave up after 4 ms, still HTTP 401Sześć sekund spinnera, by dojść do odpowiedzi, która była dostępna w cztery milisekundy. A to łagodna wersja: ponowienia w produkcie są zwykle zagnieżdżone — ponawiający klient HTTP wewnątrz ponawiającego job runnera wewnątrz kolejki z własnym redelivery — więc sześć sekund zmienia się w sześć minut trwale zepsutego deploymentu wyglądającego jak wolny.
Triage ma dziewięć linii i należy do jednego miejsca:
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
}Dwa kolejne do listy: 402, którego niektórzy providerzy używają jako „skończyły Ci się środki” i które potrzebuje ekranu z linkiem do dokupienia, a nie ponowienia, oraz 529 albo jego vendor-specific odpowiedniki, które zachowują się jak 503.
Backoff i co naprawdę daje jitter
Link do sekcji: Backoff i co naprawdę daje jitterPonawianie jest łatwe. Ponawianie kiedy to część z mierzalnie dobrą odpowiedzią.
Exponential backoff jest standardem: poczekaj bazowe opóźnienie, podwajaj je po każdej porażce, zatrzymaj się na suficie. Istnieje dlatego, że przeciążony serwer ma jeszcze gorzej, jeśli klienci, którzy właśnie zawiedli, wracają od razu.
Problem polega na tym, że wszyscy podwajają od tego samego punktu startu. Jeśli stu klientów uderzy w limit w tym samym momencie — a uderzą, bo tym jest skok ruchu — wtedy cała setka czeka 200 ms, cała setka ponawia razem, cała setka razem zawodzi i cała setka czeka 400 ms. Harmonogram ponowień ich zsynchronizował. To thundering herd, a losowość jest poprawką.2
Ta jedna zmiana — wybieranie jednostajnie z przedziału zamiast brania jego górnego końca — nazywa się full jitter. To jedno wywołanie Math.random() i warto to zmierzyć, zamiast wierzyć:
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); Stu klientów, jeden serwer obsługujący trzy naraz, wszystko inne identyczne, po trzy uruchomienia:
| requesty HTTP | odrzucenia | najgorszy klient | najbardziej zajęte okno 50 ms | wall clock | |
|---|---|---|---|---|---|
| bez jitter, run 1 | 491 | 391 | 10 prób | 46 przyjść | 65,6 s |
| bez jitter, run 2 | 780 | 680 | 19 prób | 72 przyjścia | 245,7 s |
| bez jitter, run 3 | 770 | 670 | 18 prób | 97 przyjść | 225,6 s |
| full jitter, run 1 | 324 | 224 | 5 prób | 32 przyjścia | 2,2 s |
| full jitter, run 2 | 313 | 213 | 6 prób | 31 przyjść | 2,3 s |
| full jitter, run 3 | 318 | 218 | 6 prób | 25 przyjść | 1,8 s |
W tej tabeli są dwie rzeczy, a druga jest ważniejsza.
Pierwsza to mediana: 226 sekund kontra 2,2, około stukrotna różnica, przy mniej niż połowie requestów. Najbardziej zajęte okno ponowień mówi dlaczego. Bez jitter do 97 ze stu klientów przychodziło w tym samym 50-milisekundowym slocie; serwer miał trzy miejsca, więc 94 odrzucano i szły spać razem, nadal zsynchronizowane, żeby zrobić to ponownie z dłuższym czekaniem. Z jitter ta sama setka rozkładała się po tych samych oknach w grupach około trzydziestu i opróżniała się niemal natychmiast.
Druga to wariancja. Bez jitter: 65,6 s, 245,7 s, 225,6 s. Z nim: 2,2, 2,3, 1,8. System bez jitter nie tylko działa źle, działa nieprzewidywalnie, bo wynik zależy od mikroskopijnych przypadków harmonogramowania, które wybierają, którzy trzej ze stu zsynchronizowanych klientów przyjdą pierwsi. To sygnatura tego błędu w produkcji: endpoint działa dobrze, dobrze, dobrze, a potem nagle trwa cztery minuty i żadna Twoja zmiana tego nie wyjaśnia.
A najtańsze ponowienie to to, którego nigdy nie ma. Postaw przed providerem bramkę współbieżności — licznik, który nigdy nie pozwala, by więcej niż N requestów było w toku — a tych samych dwudziestu klientów, którzy potrzebowali 74 requestów i 7,1 sekundy, zachowuje się tak:
client-side gate of 3: 20 HTTP requests, 0 429s, wall 883 msDwadzieścia requestów na dwadzieścia odpowiedzi, zero odrzuceń, osiem razy szybciej. Ponowienie jest przeprosinami; bramka sprawia, że nie musisz przepraszać.
Retry-After to podłoga, nie sugestia
Link do sekcji: Retry-After to podłoga, nie sugestiaGdy provider zwraca 429, zwykle mówi, jak długo czekać, w nagłówku Retry-After.3 Ta liczba nie jest radą: provider jest jedyną stroną wymiany, która wie, kiedy resetuje się jego okno.
Czekanie jest więc większą z dwóch wartości: nigdy mniej niż Retry-After i nigdy mniej niż Twój własny backoff, bo nagłówek mówi, kiedy limiter Ci wybacza, a nie kiedy serwer ma miejsce.
const header = res.headers.get("retry-after");
const floor = header ? Number(header) * 1000 : 0; // seconds -> ms
const wait = Math.max(floor, backoffFull(attempt)); Trace najbardziej pechowego klienta w uruchomieniu z dwudziestoma klientami pokazuje, że nagłówek robi swoje. Jego pierwsze cztery losowania backoff były poniżej jednej sekundy i wszystkie cztery zostały nadpisane:
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 msDwie praktyczne uwagi. Retry-After może być datą HTTP zamiast liczby sekund, więc parsuj oba warianty. A providerzy rate-limitują jednocześnie w dwóch osiach — requesty na minutę i tokeny na minutę — dlatego długie prompts są odrzucane znacznie poniżej udokumentowanego limitu requestów. Nagłówek wygląda tak samo w obu przypadkach; poprawka nie.
Timeout, którego nikt nie wybrał
Link do sekcji: Timeout, którego nikt nie wybrałPoproś mock providera o /hang. Akceptuje połączenie, a potem nie robi absolutnie nic: żadnych nagłówków, żadnego body, żadnego zamknięcia. To nie egzotyka — tak zachowuje się load balancer, gdy proces za nim umarł bez zamykania socketów.
Dwóch klientów, jedna różnica:
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_TIMEOUTTrzysta sekund. Pięć minut otwartego socketu, zajęte miejsce requestu i użytkownik patrzący na spinner, zakończone generycznym TypeError, które nic nie mówi o tym, co się stało. Ta liczba nie jest błędem: to domyślny headers timeout Node, rozsądny dla ogólnego klienta HTTP i katastrofalny dla requestu widocznego dla użytkownika. Każdy runtime ma taki default, większość ludzi nigdy go nie sprawdza, a jedyny sposób, żeby znaleźć swój, to celowo zawiesić socket tak, jak właśnie zrobiliśmy.
Więc: każdy wychodzący request dostaje jawny deadline, wybrany przez Ciebie.
const res = await fetch(url, {
method: "POST",
headers: { "content-type": "application/json", authorization: `Bearer ${key}` },
body: JSON.stringify(payload),
signal: AbortSignal.timeout(20_000),
});Dla streaming call jeden deadline nie wystarczy, bo są dwie różne awarie. Pierwsza to stream nigdy się nie otwiera: nie przychodzi żadne zdarzenie, i właściwe jest dziesięć do trzydziestu sekund. Druga to stream się otwiera, a potem staje: tokeny płynęły, a potem zatrzymały się na zawsze, przy wciąż zdrowym sockecie. Timeout całkowitego czasu trwania nie odróżni zablokowanego streamu od długiej poprawnej odpowiedzi, więc chcesz idle timeout — timer resetowany przez każde zdarzenie, odpalany tylko wtedy, gdy nic nie przyszło przez, powiedzmy, piętnaście sekund.
Anulowanie to ta sama maszyneria skierowana na człowieka. AbortSignal.timeout i użytkownik naciskający Stop przychodzą jako AbortError, więc połącz je i zapisz, które zadziałało:
const user = new AbortController();
const signal = AbortSignal.any([user.signal, AbortSignal.timeout(20_000)]);
// stopButton.onclick = () => user.abort();Abort ma znaczenie z powodu większego niż porządek: tokeny są generowane i naliczane, gdy Ty już nie słuchasz. Rozdział 16 wycenia to.
Co można bezpiecznie ponowić
Link do sekcji: Co można bezpiecznie ponowićTeraz awaria, która kosztuje pieniądze, nie czas. Request przekracza timeout po stronie klienta, a oczywistym ruchem jest wysłać go ponownie — ale timeout nie mówi nic o tym, czy serwer go otrzymał. Bardzo często otrzymał i nadal pracuje.
Zmierzone. Mock provider potrzebuje 780 ms na odpowiedź. Klient poddaje się po 300 ms i ponawia. Serwer liczy, ile odpowiedzi faktycznie wygenerował, czyli za co by naliczył opłatę:
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): 1Bez klucza: dwie pełne generacje, zapłacone dwa razy, a klient nie otrzymał żadnej z nich. Z kluczem: serwer rozpoznał drugi request jako ten sam request i natychmiast odpowiedział wynikiem, który już wyprodukował, więc ponowienie uniknęło podwójnej opłaty i było próbą, która w końcu się udała.
Idempotency key to unikalny string generowany przez Ciebie dla operacji logicznej — nie dla próby — i wysyłany bez zmian przy każdym jej ponowieniu. Serwer zapisuje wynik pod tym kluczem i go odtwarza. To mechanizm używany przez payment APIs, z tego samego powodu.4
async function send(url: string, payload: unknown) {
const key = crypto.randomUUID(); // once per turn, not per attempt
for (let attempt = 0; attempt < 5; attempt++) {
const res = await fetch(url, {
method: "POST",
body: JSON.stringify(payload),
headers: { "content-type": "application/json", "idempotency-key": key },
signal: AbortSignal.timeout(20_000),
});
if (res.ok) return res;
if (classify(res.status) === "fatal") throw new Error(`HTTP ${res.status}`);
await sleep(backoffFull(attempt));
}
throw new Error("out of attempts");
}Dwa uczciwe ograniczenia. Nie każdy provider wspiera idempotency keys dla completions, a tam, gdzie endpoint nie jest idempotentny, poprawna liczba ponowień dla POST, który mógł już się wykonać, wynosi zero. A stream, który zawiódł w połowie, zasadniczo nie jest odtwarzalny: albo uruchamiasz go od nowa i płacisz ponownie, albo zachowujesz częściowy tekst i oznaczasz go jako niepełny. To, co robi Twój produkt, jest decyzją produktową, nie sieciową, i warto podjąć ją świadomie.
Domknięcie szwu
Link do sekcji: Domknięcie szwuKlient napisany w tym rozdziale nie ma pojęcia, co jest za portem. Skieruj jego base URL na komercyjnego providera, a streamuje tokeny z modelu o bilionie parametrów. Skieruj go na serwer zbudowany na arytmetyce z Rozdziału 13 — serwujący model, który pretrained w Rozdziale 10, z jego KV cache i kwantyzowanymi wagami — a ten sam kod, bez zmian, streamuje tokeny z modelu, który zbudowałeś.
const BASE = process.env.LLM_BASE_URL ?? "http://127.0.0.1:8000/v1"; Ta jedna linia jest szwem tego kursu. Po jednej jej stronie jest to, co zbudowało pierwszych trzynaście rozdziałów; po drugiej to, co zbuduje następnych szesnaście. Granica jest czysta, bo kontraktem są HTTP i SSE, a żadna strona nie wie o drugiej nic więcej.
Warto zauważyć, co straciłeś, przechodząc przez nią. Za komercyjnym endpointem nie kontrolujesz ani wag, ani implementacji próbkowania, ani wersji, z którą rozmawiasz, ani tego, czy zmieniła się dziś rano. Kontrolujesz kontrakt: wiadomości, które wysyłasz, deadline, który ustawiasz, kody, które rozróżniasz, i to, co robisz, gdy nic nie wraca. To mniejsza powierzchnia niż ta, którą miałeś w Rozdziale 5, a każdy kolejny rozdział jest o tym, jak dobrze jej używać.
Dokąd dalej
Link do sekcji: Dokąd dalejMasz teraz klienta, który streamuje, poddaje się na czas, ponawia właściwe rzeczy i nigdy nie ponawia niewłaściwych. To, co wysyła, nadal jest po prostu tym, co wpisałeś.
Rozdział 15 dotyczy tej treści i przychodzi z dyscypliną. Internet jest pełen porad o prompting — zaoferuj modelowi napiwek, zagroź mu, powiedz mu, żeby wziął głęboki oddech — i prawie żadna z nich nie przychodzi z pomiarem. Niektóre z tych technik przesuwają output bardzo mocno, niektóre wcale, a co najmniej jedna pogarsza zadanie klasyfikacji, kosztując przy tym więcej tokenów. Która jest która, nie wynika z ich czytania i nie rozstrzyga się tego argumentem.
Następny rozdział buduje więc bench: sześćdziesiąt przypadków ze znanymi odpowiedziami, cztery warianty tego samego prompt, uruchomione równolegle dokładnie przez klienta, którego właśnie napisałeś, zestawione w tabeli z przedziałami ufności z Rozdziału 4 — bo cztery warianty na dwudziestu przypadkach nie rozróżniają absolutnie niczego. Jedno zdanie rządzi całym rozdziałem: prompt się mierzy, a nie dyskutuje.
Źródła i metoda
Link do sekcji: Źródła i metodaKażda liczba powyżej pochodzi z mock providera, na Node 22 przez loopback interface, więc latencje są czystsze niż w jakiejkolwiek prawdziwej sieci. To celowe: żadna z mierzonych awarii nie jest powodowana przez sieć, a wrogi serwer, który możesz zrestartować, uczy lepiej niż prawdziwy, za który musisz płacić i którego nie możesz zepsuć.
Przypisy
Link do sekcji: Przypisy-
Server-Sent Events, WHATWG HTML Living Standard, sekcja 9.2. Wire format — pola
data:, zdarzenia rozdzielane pustą linią,id:iretry:— jest tam zdefiniowany razem z interfejsemEventSource.EventSourcenie może wysłać body requestu ani własnych nagłówków, dlatego każdy klient LLM parsuje format ręcznie przezfetch, zamiast go używać. ↩ -
Brooker, M. Exponential Backoff and Jitter. AWS Architecture Blog (2015). Źródło sformułowania „full jitter” użytego powyżej, z symulacjami pokazującymi, dlaczego naiwna wersja synchronizuje klientów. Towarzyszący argument za shedding load zamiast kolejkowania go to rozdział Handling Overload w Beyer, Jones, Petoff i Murphy (red.), Site Reliability Engineering (O'Reilly, 2016). ↩
-
Fielding, R., Nottingham, M. i Reschke, J. (red.), HTTP Semantics, RFC 9110, sekcja 15, definiuje klasy kodów statusu; Nottingham, M. i Fielding, R., Additional HTTP Status Codes, RFC 6585 (2012), sekcja 4, definiuje 429 Too Many Requests.
Retry-Afterto RFC 9110 sekcja 10.2.3 i przyjmuje albo liczbę sekund, albo datę HTTP. ↩ -
Stripe, Idempotent requests,
docs.stripe.com/api/idempotent_requests, odczytane 7 września 2026 — najjaśniejsze sformułowanie kontraktu: jeden klucz na operację logiczną, zapisane wyniki są odtwarzane, konflikt zwracany, gdy pierwsza próba nadal jest w toku — a wzorzec jest niezależny od providera. Normatywne odniesienia dla kształtów requestów i zdarzeń użytych tutaj todevelopers.openai.com/api/reference/resources/chatdla streamingu, kodów błędów i rate limits orazplatform.claude.com/docs/en/api/messagesdla Messages API;ai-sdk.dev/docsto najlepszy przepracowany przykład tych samych problemów opakowanych w bibliotekę. Wszystkie odczytane tego samego dnia. ↩