اولین فراخوان LLM در production: streaming، retry و timeout
یک provider دروغگو بسازید: 429، socket معلق، stream نیمهکاره؛ و رفتار client را بسنجید. full jitter: 2.2 ثانیه در برابر 226.
در این صفحه
فصل 13 با یک کرنومتر روی مدلی تمام شد که میتوانستید لمسش کنید. وزنها در حافظهٔ شما بودند، KV cache دست خودتان بود که فعال یا غیرفعالش کنید، و عددی که بیرون میآمد — زمان تا اولین token — ویژگی سختافزار شما بود.
حالا آن مدل را پشت یک port بگذارید، همان کاری که هر محصولی میکند، و دوباره همان عدد را بخوانید. هنوز زمان تا اولین token است، اما دیگر ویژگی چیزی نیست که شما کنترلش کنید. حالا شامل TLS handshake، صف provider، rate limiter، و این احتمال است که اصلاً هیچ tokenی هرگز نرسد.
همین بند آخر، موضوع فصل است. کدی که قرار است بنویسید هیچ چیزی را محاسبه نمیکند. یک connection باز میکند، منتظر میماند، چیزی را که میرسد parse میکند، وقتی چیزی نمیرسد تصمیم میگیرد چه کند، وقتی چیزی که میرسد error است دوباره تصمیم میگیرد، و وقتی کاربر نظرش عوض میشود خودش را cancel میکند. هرکدام از اینها تصمیمی دربارهٔ state در طول زمان است، و هرکدام پاسخ غلطی دارد که وارد محصول میشود و هزینه میسازد.
شکل مسئله این است؛ اندازهگیریشده، و همهاش در همین فصل:
| چه اتفاقی افتاد | یک client بیدقت چه میکند | هزینهاش چیست |
|---|---|---|
| server، socket را پذیرفت و هرگز پاسخ نداد | منتظر میماند | 300.8 s تا Node خودش تسلیم شود |
| key اشتباه بود (401) | پنج بار retry میکند | 6,325 ms تأخیر، و بعد همان 401 |
| صد client همزمان به rate limit خوردند | همه طبق یک زمانبندی retry میکنند | 226 s برای تخلیه، در برابر 2.2 s |
| request timeout شد و دوباره ارسال شد | دوباره میفرستد | provider پاسخ را دو بار تولید — و bill — میکند |
| connection وسط پاسخ قطع شد | متن ناقص را نشان میدهد | از یک پاسخ کوتاهِ درست قابلتشخیص نیست |
هیچکدام از اینها مسئلهٔ modelling نیست. همهٔ آنها در صد خط اول هر محصول LLMی هستند که تا حالا نوشته شده است.
چرا این فصل زبان را عوض میکند
لینک به بخش: چرا این فصل زبان را عوض میکندآن جدول را دوباره بخوانید و بپرسید چه نوع برنامهای را توصیف میکند. یک connection را چهل ثانیه باز نگه میدارد. باید از یک دکمه cancel شود. یک پاسخ ناقص را جمع میکند که برای نمایش معتبر است و برای ذخیره نامعتبر. و در یک server process یا در یک edge worker اجرا میشود، کنار چیزی که پاسخ را render میکند، در حالی که یک socket را نگه داشته است.
این notebook نیست. مسئله این نیست که Python نمیتواند این کار را بکند — میتواند، و آدمها هم میکنند — مسئله این است که همهٔ چیزی که سیزده فصل قبلی ساختند از نوع دیگری بود. فصلهای 1 تا 13 weights, gradients, logits and tokenizer bytes را نگه میداشتند. از اینجا به بعد، کد یک connection، یک retry، یک cancellation، state جمعشده و بعداً یک permission prompt را نگه میدارد. دوره دقیقاً در درزی زبان عوض میکند که شیء عوض میشود.
پس قانون، یکبار نوشتهشده:
اگر کد weights، gradients، logits یا tokenizer bytes را در دست دارد، Python است. اگر connection نگه میدارد، retry میکند، cancel میکند، state جمع میکند و permission میخواهد، TypeScript است.
درز یکی است و همینجا میافتد، بین فصل 13 و فصل 14. سه معیار مستقل آن را اینجا میگذارند.
یک: اکوسیستم، با شمارش. هر چیزی که نیمهٔ چپ این دوره به آن ارجاع میدهد Python است، و در دوازده دورهای که برای این سرفصل بررسی شدند حتی یک precedent از آموزش backpropagation به زبان دیگر وجود ندارد: micrograd (17.4K ستاره)، nanoGPT (62.8K)، nanochat (57.8K)، minbpe (10.7K)، PyTorch (102.8K)، transformers (164.9K). نوشتن فصل 5 با TypeScript پیوند با آن منابع را قطع میکرد، و لینکها نصف ارزش فصلی هستند که برای ارجاعدادن وجود دارد نه برای رتبهگرفتن. در این سمت، حساب برعکس میشود: package ai از Vercel ماهی 89.4M دانلود دارد و خودِ چیز را ship میکند — یک tool-calling agent loop، export شده بهصورت ToolLoopAgent — پس مفهومی که این دوره در فصل 23 به آن میرسد reference implementation خودش را در TypeScript دارد، حتی با اینکه، همانطور که آن فصل اندازه میگیرد، هیچکس روی نامش توافق نکرده است؛ Mastra 27.7K ستاره دارد؛ و SDKهای Anthropic که از یک specification تولید شدهاند، در TypeScript تعداد 202 endpoint را اعلام میکنند و در Python تعداد 201 تا — برابری، نه یک port از سر لطف.
دو: منبع normative برای MCP. schemaی specification مربوط به Model Context Protocol یک فایل schema.ts است. آموزش protocol فصل 26 به زبان دیگر یعنی آموزش ترجمهای از سند بنیانگذارش.
سه: تقاضای جستوجو، با اصلاح حدس بدیهی. 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 یک panel دوم در 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 است و زبان برنامهنویسیدادن به آن یعنی format را نفهمیدهایم.
سیزده فصل Python دور ریخته نمیشوند. آنطرف port همان چیزی است که آنها ساختند، و بخش آخر اینجا یک client را به آن وصل میکند.
providerی که میتوانید خرابش کنید
لینک به بخش: providerی که میتوانید خرابش کنیدنمیتوانید هیچکدام از اینها را مقابل یک provider واقعی یاد بگیرید. نمیتوانید از آن بخواهید در لحظهای که شما انتخاب میکنید 429 بدهد، یا socketی بدهد که connection شما را بپذیرد و هرگز پاسخ ندهد، یا streamی که وسط یک کلمه متوقف شود — و برای هر آزمایش هم پول میدهید، در حالی که آزمایشهای جالب همانهایی هستند که صد بار اجرا میکنید.
پس اولین برنامه در این نیمهٔ دوره client نیست. یک server خصمانه است: چهل خط Node ساده که همان wire protocol یک chat completions endpoint را حرف میزند و بهدلخواه بدرفتاری میکند. هر عددی در این فصل از آن بیرون آمده است.
import { createServer } from "node:http";
const WORDS = "A tide gauge is a device that measures sea level over time .".split(" ");
const CAPACITY = 3; // how many requests it will serve at once
let inflight = 0;
const sse = (res, obj) => res.write(`data: ${JSON.stringify(obj)}\n\n`);
createServer(async (req, res) => {
const url = new URL(req.url, "http://x");
if (url.pathname === "/hang") return;
if (url.pathname === "/401") { res.writeHead(401); return res.end("{}"); }
if (inflight >= CAPACITY) {
res.writeHead(429, { "retry-after": "1" });
return res.end(JSON.stringify({ error: { type: "rate_limit_error" } }));
}
inflight++;
const cut = Number(url.searchParams.get("cut") ?? -1); // abandon after N chunks
const how = url.searchParams.get("how"); // "close" = orderly, else reset
const max = Number(url.searchParams.get("max_tokens") ?? 999);
const delay = Number(url.searchParams.get("delay") ?? 60); // ms per token
res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache" });
for (let i = 0; i < Math.min(WORDS.length, max); i++) {
if (i === cut) {
how === "close" ? res.end() : res.destroy();
inflight--; return;
}
await new Promise((r) => setTimeout(r, delay));
sse(res, { choices: [{ delta: { content: (i ? " " : "") + WORDS[i] }, finish_reason: null }] });
}
sse(res, { choices: [{ delta: {}, finish_reason: max < WORDS.length ? "length" : "stop" }] });
res.write("data: [DONE]\n\n");
inflight--;
res.end();
}).listen(8787);چهار رفتار خصمانه، هرکدام یک خط: /hang socket را میپذیرد و هرگز چیزی در آن نمینویسد؛ /401 key را رد میکند؛ capacity check وقتی سه request از قبل در جریاناند یک 429 واقعی با header واقعی Retry-After تولید میکند؛ و ?cut=N پاسخ را نیمهراه رها میکند، یا با reset کردن socket یا — با &how=close — با بستن منظم آن، که معلوم میشود خیلی اهمیت دارد. بقیه یک stream واقعی Server-Sent Events است: هر خط data: یک object JSON، بین eventها یک خط خالی، و رشتهٔ [DONE] در پایان.1
اجرایش کنید، و بقیهٔ فصل اندازهگیری است.
node mock-provider.mjs &
curl -N "http://127.0.0.1:8787/v1/chat?max_tokens=3"data: {"choices":[{"delta":{"content":"A"},"finish_reason":null}]}
data: {"choices":[{"delta":{"content":" tide"},"finish_reason":null}]}
data: {"choices":[{"delta":{"content":" gauge"},"finish_reason":null}]}
data: {"choices":[{"delta":{},"finish_reason":"length"}]}
data: [DONE]request body، و keyیی که هرگز از server خارج نمیشود
لینک به بخش: request body، و keyیی که هرگز از server خارج نمیشودیک chat request فهرستی از messageهاست، هرکدام با یک role. آن فهرست تمام state مدل است: بین callها حافظهای وجود ندارد، و هر چیزی که میخواهید مدل بداند باید داخل arrayی باشد که همین بار میفرستید. فصل 15 دربارهٔ این است که چه چیزی در آن بگذارید و فصل 16 دربارهٔ هزینهٔ آن است، پس اینجا فقط شکل آن را میبینیم.
const body = {
model: "gpt-4.1-mini",
messages: [
{ role: "system", content: "You explain instruments in one sentence." },
{ role: "user", content: "What is a tide gauge?" },
],
stream: true,
max_tokens: 200,
};آن roleها تزئین نیستند. پیش از آنکه مدل حتی یک token ببیند، در chat template فصل 11 render میشوند؛ برای همین فرستادن role اشتباه، بهجای error دادن، بیصدا پاسخ را ضعیف میکند.
یک قانون بدون استثنا: API key هرگز به client نمیرود. نه در environment variableی با prefix مخصوص browser، نه در constant زمان build، نه «موقتاً». key داخل bundle ظرف چند روز یعنی key روی bill شخص دیگری. browser با server شما حرف میزند، server شما key را نگه میدارد و با provider حرف میزند — و چون server شما وسط است، تنها جایی هم هست که میتواند اندازه بگیرد هر کاربر چقدر خرج میکند؛ همانجایی که accounting فصل 16 باید زندگی کند.
یک سؤال، سه بار
لینک به بخش: یک سؤال، سه بارحالا آزمایشی که فصل روی آن ساخته شده است. یک سؤال، یک mock provider که سیزده token را هرکدام با فاصلهٔ 60 ms تولید میکند، و سه روش پرسیدن.
اول، بدون streaming. client request را میفرستد و منتظر کل JSON body میماند.
blocking first visible = 791 ms complete = 791 ms finish_reason = stopدو عدد یکی هستند، و کل مسئله همین است. برای 791 ms کاربر spinner دارد، و حتی یک کلمه هم زودتر در دسترس نبود — server پاسخ را byte به byte داشت، اما انتخاب کرد چیزی نگوید.
دوم، با streaming. همان server، همان پاسخ، همان کل کار. تفاوت یک parser است.
export async function* readSSE(res: Response) {
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let sep: number;
while ((sep = buffer.indexOf("\n\n")) !== -1) {
const event = buffer.slice(0, sep);
buffer = buffer.slice(sep + 2);
for (const line of event.split("\n")) {
if (!line.startsWith("data:")) continue;
const payload = line.slice(5).trim();
if (payload === "[DONE]") return;
yield JSON.parse(payload);
}
}
}
}سه جزئیات آنجا باربرند و بیشتر تلاشهای اول هر سه را جا میاندازند. buffer وجود دارد چون network chunk هیچ رابطهای با event ندارد: یک read() میتواند نصف یک event را برگرداند، یا دو و نیم event. flag { stream: true } وجود دارد چون یک کاراکتر UTF-8 چندبایتی میتواند بین دو chunk شکسته شود، و بدون آن یک حرف accentدار بهصورت تصادفی به replacement character تبدیل میشود. و eventها با یک خط خالی جدا میشوند، نه یک newline؛ برای همین loop دنبال \n\n میگردد.
streaming first visible = 65 ms complete = 793 ms finish_reason = stopدوازده برابر سریعتر تا اولین کلمه، و دو میلیثانیه کندتر تا آخرین. Streaming هیچچیز را سریعتر نمیکند. فقط کاری را عوض میکند که کاربر در همان 790 ms انجام میدهد: خواندن بهجای انتظار. کل سود همین است، عظیم است، و دلیل این است که هر محصول chat stream میکند.
سوم، با بیست client همزمان. mock provider هر بار سه request را سرویس میدهد. بیستتا را شلیک کنید:
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، پنجاه و چهار reject. هیچکس چیزی از دست نداد، هر client همان متن را گرفت، و تنها هزینهٔ قابلمشاهده زمان بود. این یعنی retry policy کار میکند. بقیهٔ این فصل دربارهٔ سه روشی است که بهجایش ممکن است شکست بخورد.
finish_reason، و دو پایان که یکسان به نظر میرسند
لینک به بخش: finish_reason، و دو پایان که یکسان به نظر میرسندقبل از failureها، fieldی که تقریباً همه در pass اول نادیده میگیرند. هر stream با eventی تمام میشود که finish_reason را حمل میکند. stop یعنی مدل تصمیم گرفت تمام شده است. length یعنی به سقف token خورده، پس پاسخ وسط جمله truncate شده و تقصیر مدل نیست. فصلهای بعدی tool_calls (فصل 18) و content filterها را اضافه میکنند.
حالا دو پایان را ببینید که یک client سادهلوح نمیتواند از هم تشخیص دهد. همان server، همان delay، یکی با max_tokens truncate شده و یکی که connection بعد از پنج token تمیز بسته میشود:
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 یکسان. در هیچکدام exception نیست. loop for await هر دو بار normal تمام شد، چون از نگاه reader، body تمام شد و body کار دیگری نمیتواند بکند. تنها تفاوت در کل مشاهده این است که یکی finish_reason: "length" دارد و دیگری هیچ چیزی ندارد.
پس قانون «catch کردن errorها هنگام streaming» نیست. قانون این است:
streamی که بدون
finish_reasonتمام میشود، تمام نشده است. متوقف شده است.
finish_reason گمشده را همیشه failure حساب کنید، و هرگز آن متن را بهعنوان پاسخ کامل persist نکنید. ردیف سوم حالت آسانتر را نشان میدهد — socket نابودشده واقعاً throw میکند، و chunk در حال پرواز را هم از دست میدهد؛ برای همین متنش یک کلمه از دو مورد بالا کوتاهتر است.
پنج status code که پنج مسئلهٔ متفاوتاند
لینک به بخش: پنج status code که پنج مسئلهٔ متفاوتاندگرانترین عادت یک محصول تازه این است که برای هر چیزی که provider برمیگرداند یک block catch داشته باشد. این codeها نسخههای مختلف «خراب شد» نیستند. پنج دستورالعملاند، و چهار تای آنها با هم تناقض دارند.
| status | معنیاش چیست | چه باید کرد | صبر؟ |
|---|---|---|---|
| 400 | request شما malformed است — JSON بد، field ناشناخته، context بیش از حد بلند | کد را درست کنید | هرگز |
| 401 | key اشتباه، گمشده یا revoked است | deployment را درست کنید | هرگز |
| 429 | rate limit: requestهای خیلی زیاد، یا tokenهای خیلی زیاد، در هر دقیقه | retry | Retry-After، سپس backoff |
| 500 | provider خراب شد | retry | backoff |
| 503 | provider overloaded است — بالا است، اما پر است | retry | backoff، و load را کم کنید |
خط مهم بین 4xx و بقیه میگذرد. یک 400 یا 401 اگر هزار بار هم بفرستید دقیقاً همان پاسخ را برمیگرداند، چون بین attemptها هیچ چیزی در هیچکدام از دو سمت عوض نمیشود. retry کردنش احتیاط نیست، تأخیری با مراحل اضافه است. اندازهگیری: یک client که شش attempt میزند — پنج retry با exponential backoff — و یکی که اول code را میخواند.
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 برای رسیدن به پاسخی که در چهار میلیثانیه در دسترس بود. و این نسخهٔ ملایم است: retryها در محصول معمولاً تو در تو هستند — یک HTTP client که retry میکند داخل job runnerی که retry میکند داخل queueیی با redelivery خودش — پس شش ثانیه به شش دقیقه از یک deployment دائماً خراب تبدیل میشود که شبیه deployment کند به نظر میرسد.
triage نه خط است و جای آن یک جاست:
export type Verdict = "retry" | "retry-after" | "fatal";
export function classify(status: number): Verdict {
if (status === 429) return "retry-after";
if (status === 408 || status >= 500) return "retry";
return "fatal"; // 400, 401, 403, 404, 422 — nothing changes by waiting
}دو مورد دیگر برای فهرستتان: 402، که بعضی providerها برای «اعتبارتان تمام شده» استفاده میکنند و بهجای retry به صفحهای با لینک خرید بیشتر نیاز دارد، و 529 یا معادلهای vendor-specific آن، که مثل 503 رفتار میکنند.
Backoff، و jitter واقعاً چه میخرد
لینک به بخش: Backoff، و jitter واقعاً چه میخردRetry کردن آسان است. اینکه کی retry کنیم همان بخشی است که پاسخ درستِ قابلاندازهگیری دارد.
Exponential backoff استاندارد است: یک base delay صبر کن، بعد از هر failure دو برابرش کن، در یک سقف متوقف شو. وجود دارد چون server overloaded بدتر میشود اگر clientهایی که همین حالا شکست خوردهاند مستقیم برگردند.
مشکل این است که همه از همان نقطهٔ شروع دو برابر میکنند. اگر صد client در یک لحظه به limit بخورند — و میخورند، چون spike ترافیک همین است — آنوقت همهٔ صدتا 200 ms صبر میکنند، همهٔ صدتا با هم retry میکنند، همهٔ صدتا با هم fail میشوند، و همهٔ صدتا 400 ms صبر میکنند. زمانبندی retry آنها را synchronise کرده است. این یک thundering herd است، و randomness درمان آن است.2
همین تغییر واحد — انتخاب uniform از interval بهجای گرفتن انتهای بالایی آن — full jitter نام دارد. یک call به Math.random() است، و ارزش دارد اندازهگیریاش کنید بهجای اینکه باورش کنید:
export const backoffNaive = (n: number, base = 200, cap = 20_000) =>
Math.min(cap, base * 2 ** n);
export const backoffFull = (n: number, base = 200, cap = 20_000) =>
Math.random() * Math.min(cap, base * 2 ** n); صد client، یک server که هر بار سهتا را سرویس میدهد، همهچیز دیگر یکسان، هرکدام سه run:
| HTTP requests | rejections | بدترین client | شلوغترین پنجرهٔ 50 ms | wall clock | |
|---|---|---|---|---|---|
| بدون jitter، run 1 | 491 | 391 | 10 تلاش | 46 ورود | 65.6 s |
| بدون jitter، run 2 | 780 | 680 | 19 تلاش | 72 ورود | 245.7 s |
| بدون jitter، run 3 | 770 | 670 | 18 تلاش | 97 ورود | 225.6 s |
| full jitter، run 1 | 324 | 224 | 5 تلاش | 32 ورود | 2.2 s |
| full jitter، run 2 | 313 | 213 | 6 تلاش | 31 ورود | 2.3 s |
| full jitter، run 3 | 318 | 218 | 6 تلاش | 25 ورود | 1.8 s |
دو چیز در آن جدول هست، و دومی مهم است.
اولی median است: 226 ثانیه در برابر 2.2، ضریبی حدود صد، با کمتر از نصف requestها. شلوغترین retry window میگوید چرا. بدون jitter، تا 97 تا از صد client داخل همان slot پنجاهمیلیثانیهای رسیدند؛ server سه ظرفیت داشت، پس 94 تا reject شدند و با هم خوابیدند، هنوز synchronised، تا با wait طولانیتر دوباره همین کار را بکنند. با jitter همان صدتا در همان windowها در گروههایی حدود سیتایی پخش شدند و تقریباً فوراً تخلیه شدند.
دومی variance است. بدون jitter: 65.6 s، 245.7 s، 225.6 s. با آن: 2.2، 2.3، 1.8. سیستمی بدون jitter فقط بد عمل نمیکند، غیرقابلپیشبینی عمل میکند، چون outcome را تصادفهای microscopic زمانبندی تعیین میکنند که انتخاب میکنند کدام سهتا از صد client هماهنگشده اول برسند. امضای این bug در production همین است: endpointی که خوب است، خوب است، خوب است، و بعد چهار دقیقه طول میکشد، و هیچ تغییری از سمت شما توضیحش نمیدهد.
و ارزانترین retry آنی است که هرگز اتفاق نمیافتد. جلوی provider یک concurrency gate بگذارید — counterی که هرگز نمیگذارد بیش از N request همزمان in flight باشد — و همان بیست client که به 74 request و 7.1 ثانیه نیاز داشتند اینطور رفتار میکنند:
client-side gate of 3: 20 HTTP requests, 0 429s, wall 883 msبیست request برای بیست پاسخ، صفر reject، هشت برابر سریعتر. retry عذرخواهی است؛ gate یعنی اصلاً به عذرخواهی نیاز نداشته باشید.
Retry-After کف است، نه پیشنهاد
لینک به بخش: Retry-After کف است، نه پیشنهادوقتی provider، 429 برمیگرداند معمولاً در header Retry-After به شما میگوید چقدر صبر کنید.3 آن عدد توصیه نیست: provider تنها طرف exchange است که میداند window آن چه زمانی reset میشود.
پس wait، بزرگترِ دو عدد است: هرگز کمتر از Retry-After نباشد، و هرگز کمتر از backoff خودتان هم نباشد، چون header به شما میگوید limiter چه زمانی شما را میبخشد نه اینکه server چه زمانی جا دارد.
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 کارش را انجام میدهد. چهار draw اول backoff او همگی زیر یک ثانیه بودند، و هر چهار تا override شدند:
t+ 26ms attempt 0 HTTP 429 -> sleep 1000 ms
t+ 1032ms attempt 1 HTTP 429 -> sleep 1000 ms
t+ 2034ms attempt 2 HTTP 429 -> sleep 1000 ms
t+ 3046ms attempt 3 HTTP 429 -> sleep 1000 ms
t+ 4047ms attempt 4 HTTP 429 -> sleep 2782 ms
t+ 6852ms attempt 5 HTTP 200 -> sleep 0 msدو نکتهٔ عملی. Retry-After ممکن است بهجای تعداد ثانیه، یک HTTP date باشد، پس هر دو را parse کنید. و providerها همزمان روی دو محور rate-limit میکنند — request per minute و tokens per minute — برای همین promptهای بلند خیلی پایینتر از request limit مستندشده reject میشوند. header در هر دو حالت یکشکل است؛ fix یکی نیست.
timeoutی که هیچکس انتخاب نکرد
لینک به بخش: timeoutی که هیچکس انتخاب نکرداز mock provider برای /hang بخواهید. connection را میپذیرد، و بعد هیچ کاری نمیکند: نه header، نه body، نه close. این عجیب نیست — همان کاری است که load balancer وقتی process پشتش بدون بستن socketهایش مرده انجام میدهد.
دو client، یک تفاوت:
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 خیره شده، و در پایان یک TypeError generic که چیزی دربارهٔ اتفاقی که افتاد نمیگوید. آن عدد bug نیست: timeout پیشفرض headers در Node است، برای یک HTTP client عمومی منطقی و برای request روبهکاربر فاجعهبار. هر runtime چنین defaultی دارد، بیشتر آدمها هرگز آن را نگاه نمیکنند، و تنها راه فهمیدن مال خودتان این است که عمداً یک socket را همانطور که الان کردیم معلق کنید.
پس: هر outgoing request یک deadline صریح میگیرد، انتخابشده توسط شما.
const res = await fetch(url, {
method: "POST",
headers: { "content-type": "application/json", authorization: `Bearer ${key}` },
body: JSON.stringify(payload),
signal: AbortSignal.timeout(20_000),
});برای یک streaming call یک deadline کافی نیست، چون دو failure متفاوت وجود دارد. اولی این است که stream هرگز باز نمیشود: هیچ eventی اصلاً نمیرسد، و ده تا سی ثانیه درست است. دومی این است که stream باز میشود و بعد stall میکند: tokenها جریان داشتند و بعد برای همیشه متوقف شدند، در حالی که socket هنوز سالم است. timeout کل duration نمیتواند stream stalled را از پاسخ درستِ طولانی تشخیص دهد، پس چیزی که میخواهید idle timeout است — timerی که با هر event reset میشود و فقط وقتی مثلاً پانزده ثانیه هیچ چیزی نرسیده fire میکند.
Cancellation همان machinery است که به سمت یک آدم نشانه رفته. AbortSignal.timeout و کاربری که Stop را میزند هر دو بهصورت یک AbortError میرسند، پس آنها را combine کنید و ثبت کنید کدامیک fire کرد:
const user = new AbortController();
const signal = AbortSignal.any([user.signal, AbortSignal.timeout(20_000)]);
// stopButton.onclick = () => user.abort();Abort کردن به دلیلی فراتر از مرتببودن مهم است: tokenها در حال تولید و bill شدن هستند وقتی شما دیگر گوش نمیدهید. فصل 16 روی آن قیمت میگذارد.
چه چیزی برای retry امن است
لینک به بخش: چه چیزی برای retry امن استحالا failureی که بهجای زمان، پول هزینه میکند. یک request روی client timeout میشود، و حرکت بدیهی این است که دوباره بفرستیدش — اما timeout هیچ چیزی دربارهٔ اینکه server آن را دریافت کرده یا نه به شما نمیگوید. خیلی وقتها دریافت کرده، و هنوز مشغول کار است.
اندازهگیریشده. mock provider برای پاسخ به 780 ms نیاز دارد. client در 300 ms تسلیم میشود و retry میکند. server میشمارد واقعاً چند پاسخ generate کرده است، یعنی همان چیزی که bill میکرد:
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: دو generation کامل، دو بار پرداخت، و client هیچکدام را دریافت نکرد. با key: server request دوم را بهعنوان همان request شناخت و فوراً با پاسخی که قبلاً تولید کرده بود جواب داد، پس retry هم از double charge جلوگیری کرد و همان attemptی بود که بالاخره موفق شد.
یک idempotency key رشتهای یکتا است که برای هر logical operation تولید میکنید — نه برای هر attempt — و در هر retry همان را بدون تغییر میفرستید. server outcome را در برابر key ذخیره میکند و replay میکند. این همان mechanismی است که payment APIها به همان دلیل استفاده میکنند.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");
}دو محدودیت صادقانه. همهٔ providerها روی completions از idempotency key پشتیبانی نمیکنند، و جایی که endpoint idempotent نیست، تعداد درست retryها برای POSTی که شاید قبلاً اجرا شده باشد صفر است. و streamی که نیمهراه fail کرده در حالت کلی replayable نیست: یا دوباره شروعش میکنید و دوباره پول میدهید، یا متن ناقص را نگه میدارید و incomplete علامت میزنید. اینکه محصول شما کدام را انجام میدهد تصمیم محصول است، نه تصمیم networking، و ارزش دارد آگاهانه گرفته شود.
بستن درز
لینک به بخش: بستن درزclientی که در این فصل نوشته شد هیچ ایدهای ندارد پشت port چیست. base URL آن را به یک provider تجاری نشانه بروید و tokenها را از مدلی با یک تریلیون parameter stream میکند. آن را به serverی نشانه بروید که روی arithmetic فصل 13 ساخته شده — با سرویسدادن مدلی که در فصل 10 pretrain کردید، همراه با KV cache و weights quantized آن — و همان کد، بیتغییر، tokenها را از مدلی که خودتان ساختهاید stream میکند.
const BASE = process.env.LLM_BASE_URL ?? "http://127.0.0.1:8000/v1"; آن تک خط درز این دوره است. یک سوی آن چیزی است که سیزده فصل اول ساختند؛ سوی دیگرش چیزی است که شانزده فصل بعدی میسازند. boundary تمیز است چون contract، HTTP و SSE است، و هیچکدام از دو سمت هیچ چیز دیگری دربارهٔ دیگری نمیداند.
ارزش دارد ببینید با عبور از این مرز چه چیزی را از دست دادید. پشت یک endpoint تجاری نه weights را کنترل میکنید، نه implementation مربوط به sampling را، نه versionی را که با آن حرف میزنید، نه اینکه آیا همین صبح عوض شده یا نه. چیزی که کنترل میکنید contract است: messageهایی که میفرستید، deadlineی که set میکنید، codeهایی که distinguish میکنید، و کاری که وقتی هیچ چیزی برنمیگردد انجام میدهید. این سطح از چیزی که در فصل 5 داشتید کوچکتر است، و همهٔ فصلهای باقیمانده دربارهٔ خوب استفادهکردن از آن است.
بعد به کجا میرویم
لینک به بخش: بعد به کجا میرویمحالا clientی دارید که stream میکند، بهموقع تسلیم میشود، چیزهای درست را retry میکند و چیزهای غلط را هرگز retry نمیکند. چیزی که میفرستد هنوز همان چیزی است که تایپ کردهاید.
فصل 15 دربارهٔ آن محتواست، و همراه یک discipline میآید. اینترنت پر از توصیههای prompting است — به مدل انعام پیشنهاد بدهید، تهدیدش کنید، به او بگویید نفس عمیق بکشد — و تقریباً هیچکدام با اندازهگیری نمیآید. بعضی از این techniqueها output را خیلی جابهجا میکنند، بعضی اصلاً تکانش نمیدهند، و دستکم یکی classification task را بدتر میکند در حالی که token بیشتری هزینه میکند. از خواندنشان معلوم نیست کدام کدام است، و با بحث هم حل نمیشود.
پس فصل بعد یک bench میسازد: شصت case با answerهای معلوم، چهار variant از همان prompt، اجراشده بهصورت parallel از طریق دقیقاً همان clientی که همین حالا نوشتید، tabulate شده با confidence intervalهای فصل 4 — چون چهار variant روی بیست case هیچ چیزی را distinguish نمیکند. یک جمله بر کل فصل حکم میراند: prompt اندازهگیری میشود، نه بحث.
منابع و روش
لینک به بخش: منابع و روشهر عدد بالا از mock provider آمده، روی Node 22 و loopback interface، پس latencyها تمیزتر از چیزی هستند که هر network واقعی به شما میدهد. این عمدی است: هیچکدام از failureهایی که اندازهگیری میشوند ناشی از network نیستند، و server خصمانهای که میتوانید restart کنید بهتر از server واقعیای آموزش میدهد که باید برایش پول بدهید و نمیتوانید خرابش کنید.
ارجاعات
لینک به بخش: ارجاعات-
Server-Sent Events، WHATWG HTML Living Standard، section 9.2. wire format — fieldهای
data:، eventهای جداشده با خط خالی،id:وretry:— آنجا تعریف شده، همراه با interfaceEventSource.EventSourceنمیتواند request body یا header سفارشی بفرستد، برای همین هر LLM client بهجای استفاده از آن، format را دستی رویfetchparse میکند. ↩ -
Brooker, M. Exponential Backoff and Jitter. AWS Architecture Blog (2015). منبع formulation مربوط به «full jitter» که بالا استفاده شد، با simulationهایی که نشان میدهند چرا نسخهٔ سادهلوحانه clientها را synchronise میکند. استدلال همراه برای shed کردن load بهجای queue کردن آن، فصل Handling Overload از Beyer, Jones, Petoff and Murphy (eds.)، Site Reliability Engineering (O'Reilly, 2016) است. ↩
-
Fielding, R., Nottingham, M. and Reschke, J. (eds.)، HTTP Semantics، RFC 9110، section 15، کلاسهای 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 را. ↩ -
Stripe، Idempotent requests،
docs.stripe.com/api/idempotent_requests، خواندهشده در 7 سپتامبر 2026 — روشنترین بیان contract: یک key برای هر logical operation، replay شدن resultهای ذخیرهشده، و بازگشت conflict وقتی attempt اول هنوز in flight است — و pattern مستقل از provider است. referenceهای normative برای شکلهای request و event استفادهشده اینجاdevelopers.openai.com/api/reference/resources/chatبرای streaming، error codeها و rate limitها، وplatform.claude.com/docs/en/api/messagesبرای Messages API هستند؛ai-sdk.dev/docsبهترین مثال کاملشده از همین concernهاست که در یک library پیچیده شده. همه در همان روز خوانده شدهاند. ↩