پرش به محتوا
14/30فصل 14 از 30

اولین فراخوان 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 را حرف می‌زند و به‌دلخواه بدرفتاری می‌کند. هر عددی در این فصل از آن بیرون آمده است.

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 key را رد می‌کند؛ capacity check وقتی سه request از قبل در جریان‌اند یک 429 واقعی با header واقعی Retry-After تولید می‌کند؛ و ?cut=N پاسخ را نیمه‌راه رها می‌کند، یا با reset کردن socket یا — با &how=close — با بستن منظم آن، که معلوم می‌شود خیلی اهمیت دارد. بقیه یک stream واقعی Server-Sent Events است: هر خط data: یک object JSON، بین eventها یک خط خالی، و رشتهٔ [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، و keyیی که هرگز از server خارج نمی‌شود

لینک به بخش: request body، و keyیی که هرگز از server خارج نمی‌شود

یک chat request فهرستی از messageهاست، هرکدام با یک role. آن فهرست تمام state مدل است: بین callها حافظه‌ای وجود ندارد، و هر چیزی که می‌خواهید مدل بداند باید داخل 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,
};

آن 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 می‌ماند.

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 را برگرداند، یا دو و نیم event. flag { stream: true } وجود دارد چون یک کاراکتر UTF-8 چندبایتی می‌تواند بین دو chunk شکسته شود، و بدون آن یک حرف accentدار به‌صورت تصادفی به replacement character تبدیل می‌شود. و eventها با یک خط خالی جدا می‌شوند، نه یک newline؛ برای همین loop دنبال \n\n می‌گردد.

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

دوازده برابر سریع‌تر تا اولین کلمه، و دو میلی‌ثانیه کندتر تا آخرین. Streaming هیچ‌چیز را سریع‌تر نمی‌کند. فقط کاری را عوض می‌کند که کاربر در همان 790 ms انجام می‌دهد: خواندن به‌جای انتظار. کل سود همین است، عظیم است، و دلیل این است که هر محصول chat stream می‌کند.

سوم، با بیست 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، پنجاه و چهار 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 تمیز بسته می‌شود:

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 یکسان. در هیچ‌کدام 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معنی‌اش چیستچه باید کردصبر؟
400request شما malformed است — JSON بد، field ناشناخته، context بیش از حد بلندکد را درست کنیدهرگز
401key اشتباه، گم‌شده یا revoked استdeployment را درست کنیدهرگز
429rate limit: requestهای خیلی زیاد، یا tokenهای خیلی زیاد، در هر دقیقهretryRetry-After، سپس backoff
500provider خراب شدretrybackoff
503provider overloaded است — بالا است، اما پر استretrybackoff، و load را کم کنید

خط مهم بین 4xx و بقیه می‌گذرد. یک 400 یا 401 اگر هزار بار هم بفرستید دقیقاً همان پاسخ را برمی‌گرداند، چون بین attemptها هیچ چیزی در هیچ‌کدام از دو سمت عوض نمی‌شود. retry کردنش احتیاط نیست، تأخیری با مراحل اضافه است. اندازه‌گیری: یک client که شش attempt می‌زند — پنج retry با 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 برای رسیدن به پاسخی که در چهار میلی‌ثانیه در دسترس بود. و این نسخهٔ ملایم است: retryها در محصول معمولاً تو در تو هستند — یک HTTP client که retry می‌کند داخل job runnerی که retry می‌کند داخل queueیی با redelivery خودش — پس شش ثانیه به شش دقیقه از یک deployment دائماً خراب تبدیل می‌شود که شبیه 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، که بعضی 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

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

همین تغییر واحد — انتخاب uniform از interval به‌جای گرفتن انتهای بالایی آن — full jitter نام دارد. یک call به 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 mswall clock
بدون jitter، run 149139110 تلاش46 ورود65.6 s
بدون jitter، run 278068019 تلاش72 ورود245.7 s
بدون jitter، run 377067018 تلاش97 ورود225.6 s
full jitter، run 13242245 تلاش32 ورود2.2 s
full jitter، run 23132136 تلاش31 ورود2.3 s
full jitter، run 33182186 تلاش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 ثانیه نیاز داشتند این‌طور رفتار می‌کنند:

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

بیست request برای بیست پاسخ، صفر reject، هشت برابر سریع‌تر. retry عذرخواهی است؛ gate یعنی اصلاً به عذرخواهی نیاز نداشته باشید.

وقتی provider، 429 برمی‌گرداند معمولاً در header Retry-After به شما می‌گوید چقدر صبر کنید.3 آن عدد توصیه نیست: provider تنها طرف exchange است که می‌داند window آن چه زمانی reset می‌شود.

پس wait، بزرگ‌ترِ دو عدد است: هرگز کمتر از 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 کارش را انجام می‌دهد. چهار draw اول backoff او همگی زیر یک ثانیه بودند، و هر چهار تا override شدند:

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

دو نکتهٔ عملی. Retry-After ممکن است به‌جای تعداد ثانیه، یک HTTP date باشد، پس هر دو را 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، یک تفاوت:

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 خیره شده، و در پایان یک TypeError generic که چیزی دربارهٔ اتفاقی که افتاد نمی‌گوید. آن عدد bug نیست: timeout پیش‌فرض headers در Node است، برای یک HTTP client عمومی منطقی و برای request روبه‌کاربر فاجعه‌بار. هر runtime چنین defaultی دارد، بیشتر آدم‌ها هرگز آن را نگاه نمی‌کنند، و تنها راه فهمیدن مال خودتان این است که عمداً یک socket را همان‌طور که الان کردیم معلق کنید.

پس: هر outgoing request یک deadline صریح می‌گیرد، انتخاب‌شده توسط شما.

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

برای یک streaming call یک deadline کافی نیست، چون دو 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 کرد:

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

Abort کردن به دلیلی فراتر از مرتب‌بودن مهم است: tokenها در حال تولید و bill شدن هستند وقتی شما دیگر گوش نمی‌دهید. فصل 16 روی آن قیمت می‌گذارد.

حالا failureی که به‌جای زمان، پول هزینه می‌کند. یک request روی client timeout می‌شود، و حرکت بدیهی این است که دوباره بفرستیدش — اما timeout هیچ چیزی دربارهٔ این‌که server آن را دریافت کرده یا نه به شما نمی‌گوید. خیلی وقت‌ها دریافت کرده، و هنوز مشغول کار است.

اندازه‌گیری‌شده. mock provider برای پاسخ به 780 ms نیاز دارد. client در 300 ms تسلیم می‌شود و retry می‌کند. server می‌شمارد واقعاً چند پاسخ generate کرده است، یعنی همان چیزی که bill می‌کرد:

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: دو generation کامل، دو بار پرداخت، و client هیچ‌کدام را دریافت نکرد. با key: server request دوم را به‌عنوان همان request شناخت و فوراً با پاسخی که قبلاً تولید کرده بود جواب داد، پس retry هم از double charge جلوگیری کرد و همان attemptی بود که بالاخره موفق شد.

یک idempotency key رشته‌ای یکتا است که برای هر logical operation تولید می‌کنید — نه برای هر attempt — و در هر retry همان را بدون تغییر می‌فرستید. server outcome را در برابر key ذخیره می‌کند و replay می‌کند. این همان mechanismی است که payment APIها به همان دلیل استفاده می‌کنند.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ها روی 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 می‌کند.

switch.tsTS
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 واقعی‌ای آموزش می‌دهد که باید برایش پول بدهید و نمی‌توانید خرابش کنید.

  1. Server-Sent Events، WHATWG HTML Living Standard، section 9.2. wire format — fieldهای data:، eventهای جداشده با خط خالی، id: و retry: — آن‌جا تعریف شده، همراه با interface EventSource. EventSource نمی‌تواند request body یا header سفارشی بفرستد، برای همین هر LLM client به‌جای استفاده از آن، format را دستی روی fetch parse می‌کند.

  2. 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) است.

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

  4. 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 پیچیده شده. همه در همان روز خوانده شده‌اند.


تهیه‌شده توسط

David Vicente Campos

بنیان‌گذار NeuraLIA Labs و هم‌بنیان‌گذار MyRealFood

من مهندس کامپیوتر و فارغ‌التحصیل دانشگاه لئون هستم. هم‌بنیان‌گذار MyRealFood بودم، جایی که به‌عنوان مدیر ارشد فناوری اپلیکیشنی را ساختم که میلیون‌ها نفر برای سالم‌تر غذا خوردن از آن استفاده کرده‌اند، و NeuraLIA Labs را بنیان‌گذاری کردم؛ جایی که محصولات هوش مصنوعی می‌سازم. اینجا از چیزهایی می‌نویسم که در طول مسیر باید می‌فهمیدم، همان‌طور که دوست داشتم کسی برایم توضیح می‌داد.

بیشتر درباره نویسنده

منتشرشده توسط NeuraLIA Labs.

پست‌های جدید را در ایمیل خود دریافت کنید

اخبار AI، راهنماها و به‌روزرسانی‌های محصول — هر وقت چیزی ارزشمند منتشر کنیم، یک ایمیل کوتاه می‌فرستیم.

فهرست دوره

Abstract software decision engine with branching paths, probability nodes, and glowing gates.
jev12 دقیقه مطالعه

مدل هوش مصنوعی Jev برای تصمیم ساخته شده، نه نثر

Jev از TypeSafe AI توجه‌ها را جلب کرده چون هوشمندی نرم‌افزار را مسئله‌ای احتمالاتی می‌بیند: شاخه درست را انتخاب کنید، میزان اطمینان را کنار آن بگذارید، و وقتی کد به یک تصمیم نیاز دارد برای نوشتن متن به یک LLM پول ندهید.

Abstract agent runtime sorting documents, memory blocks and pointer nodes inside a bounded context frame.
context-engineering13 دقیقه مطالعه

مهندسی کانتکست برای عامل‌های AI بلندافق

عامل‌های طولانی‌اجرا فقط به‌خاطر کوچک بودن پنجره شکست نمی‌خورند. وقتی فایل‌ها، خروجی ابزارها و تاریخچهٔ کهنه وظیفه‌ای را که عامل قرار بود تمام کند کنار می‌زنند، شکست رخ می‌دهد.

آماده‌اید انتخاب مدل را به LIA بسپارید؟

با همه مدل‌های هوش مصنوعی در یک جا بسازید — همین امروز رایگان شروع کنید.