تخطَّ إلى المحتوى
14/30الفصل 14 من 30

أول استدعاء LLM في الإنتاج: البث، إعادة المحاولة، المهل الزمنية

ابنِ مزوّدًا يخدعك: 429، مقابس معلّقة وبث ينقطع. وقِس عميلك. full jitter: 2.2 ثانية مقابل 226.

في هذه الصفحة

الفصل 13 انتهى بساعة إيقاف على نموذج تستطيع لمسه. كانت الأوزان في ذاكرتك، وكان KV cache لك أن تفعّله أو تعطّله، وكان الرقم الذي خرج — زمن الوصول إلى أول token — خاصية من خصائص عتادك.

الآن ضع ذلك النموذج خلف منفذ، وهذا ما يفعله كل منتج، واقرأ الرقم نفسه من جديد. ما زال هو زمن الوصول إلى أول token، لكنه لم يعد خاصية لأي شيء تتحكم فيه. صار يشمل مصافحة TLS، وطابورًا لدى المزوّد، ومحدِّد معدل، واحتمال ألا يصل أي token على الإطلاق.

هذه الجملة الأخيرة هي الفصل. الشفرة التي أنت على وشك كتابتها لا تحسب شيئًا. إنها تفتح اتصالًا، وتنتظر، وتفسّر ما يصل، وتقرر ماذا تفعل عندما لا يصل شيء، ثم تقرر مرة أخرى عندما يكون ما وصل خطأ، وتلغي نفسها عندما يغيّر المستخدم رأيه. كل واحد من هذه القرارات هو قرار عن حالة عبر الزمن، ولكل واحد جواب خاطئ يُشحن ويكلّف مالًا.

هذا هو شكل المشكلة، مقاسًا، وكلها في هذا الفصل:

ما الذي حدثما الذي يفعله عميل غير حذرما التكلفة
قبل الخادم المقبس ولم يرد أبدًاينتظر300.8 s قبل أن يستسلم Node من تلقاء نفسه
كان المفتاح خاطئًا (401)يعيد المحاولة خمس مرات6,325 ms من التأخير، ثم 401 نفسها
وصل مئة عميل إلى حد المعدل معًايعيدون جميعًا المحاولة وفق الجدول نفسه226 s للتصريف، مقابل 2.2 s
انتهت مهلة الطلب وأُرسل من جديديرسله من جديديولّد المزوّد الإجابة — ويحاسب عليها — مرتين
انقطع الاتصال في منتصف الإجابةيعرض النص الجزئيلا يمكن تمييزه عن إجابة قصيرة صحيحة

لا شيء من هذا مشكلة نمذجة. كلها تقع في أول مئة سطر من كل منتج LLM كُتب يومًا.

اقرأ ذلك الجدول مرة أخرى واسأل أي نوع من البرامج يصف. إنه يُبقي اتصالًا مفتوحًا أربعين ثانية. يجب أن يكون قابلًا للإلغاء من زر. يجمع إجابة جزئية صالحة للعرض وغير صالحة للحفظ. ويعمل في عملية خادم أو على edge worker، بجانب الشيء الذي يعرض الإجابة، ماسكًا بمقبس.

هذا ليس notebook. ليس لأن Python لا تستطيع فعل ذلك — تستطيع، والناس يفعلون — بل لأن كل ما بنته الفصول الثلاثة عشر السابقة كان من نوع مختلف. الفصول 1 إلى 13 أمسكت أوزانًا و gradients و logits وبايتات tokenizer. من هنا تمسك الشفرة اتصالًا، وإعادة محاولة، وإلغاء، وحالة متراكمة، ولاحقًا prompt إذن. يغيّر المنهج اللغة عند الدرزة نفسها التي يتغيّر فيها الكائن.

إذًا القاعدة، مكتوبة مرة واحدة:

إذا كانت الشفرة تمسك بأوزان أو gradients أو logits أو بايتات tokenizer، فهي Python. إذا كانت تمسك اتصالًا، وتعيد المحاولة، وتلغي، وتراكم الحالة، وتطلب الإذن، فهي TypeScript.

الدرزة واحدة وتقع هنا، بين الفصل 13 والفصل 14. ثلاثة معايير مستقلة تضعها هنا.

واحد: المنظومة، معدودة. كل ما يستشهد به النصف الأيسر من هذا المنهج هو Python، وعبر الدورات الاثنتي عشرة التي دُقّقت لهذا المنهج لا توجد سابقة واحدة لتعليم backpropagation بلغة أخرى: micrograd (17.4K نجمة)، nanoGPT (62.8K)، nanochat (57.8K)، minbpe (10.7K)، PyTorch (102.8K)، transformers (164.9K). كتابة الفصل 5 بـ TypeScript كانت ستكسر الصلة بتلك المصادر، والصلات نصف قيمة فصل وُجد ليُستشهَد به لا ليتصدر الترتيب. على هذا الجانب تنعكس الحسابات: حزمة Vercel ‏ai عند 89.4M تنزيل شهريًا وتشحن الشيء نفسه — حلقة agent لـ tool calling، مصدّرة باسم ToolLoopAgent — لذا فالمفهوم الذي يصل إليه هذا المنهج في الفصل 23 له تنفيذه المرجعي في TypeScript، حتى وإن كان، كما يقيس ذلك الفصل، لا أحد اتفق على اسم له؛ Mastra عند 27.7K نجمة؛ وSDKs الخاصة بـ Anthropic، المولّدة من مواصفة واحدة، تعلن 202 endpoint في TypeScript مقابل 201 في Python — تكافؤ، لا منفذ مجاملة.

اثنان: المصدر المعياري لـ MCP. مخطط مواصفة Model Context Protocol هو ملف schema.ts. تعليم بروتوكول الفصل 26 بلغة أخرى يعني تعليم ترجمة لوثيقته المؤسسة.

ثلاثة: طلب البحث، مع تصحيح للتخمين البديهي.machine learning python هي العبارة الأكثر تشبعًا على الإنترنت؛ وai agent typescript لها ذيل صحي خاص بها. لكن «منظومة MCP أغلبها TypeScript» لا تكون صحيحة إلا بحسب طريقة العد: السجل الرسمي يسرد 8,275 خادمًا على npm مقابل 3,603 على PyPI، بينما من حيث التنزيلات تفوز Python — 287M شهريًا لـ mcp زائد 72M لـ fastmcp مقابل 195M لـ @modelcontextprotocol/sdk. MCP هو المنطقة الثنائية اللغة بصدق هنا، ولهذا يكتب الفصل 27 الخادم نفسه مرتين بدل التظاهر.

عرض التفاصيل

الاستثناءات الخمسة المعلنة، حتى تكون القاعدة قاعدة لا شعارًا.

الفصول 17 و20 و29 تحمل لوحة ثانية في Python: تنفيذ أخذ عينات top-p يحتاج متجه الاحتمالات في يدك وHTTP API لا يعطيك واحدًا أبدًا؛ وتسعير fine-tune بصدق يعني تشغيل واحدة، ومحوّل LoRA لا يتجاوز عشرات الأسطر من nn.Module؛ وlm-eval-harness وHELM وSWE-bench وτ-bench هي Python، لذا سيكون evaluation harness في TypeScript صورة معكوسة من خطأ backpropagation. الفصل 27 ثنائي اللغة، للسبب المقاس أعلاه. الفصل 28 هو Markdown، لأن skill الخاصة بـ agent هي ملف SKILL.md، ومنحها لغة برمجة يعني أنك لم تفهم الصيغة.

الفصول الثلاثة عشر في Python لا تُرمى. ما على الجانب الآخر من المنفذ هو ما بنته، والقسم الأخير هنا يصل عميلًا به.

لا يمكنك تعلم أي من هذا ضد مزوّد حقيقي. لا يمكنك أن تطلب منه 429 في لحظة تختارها، أو مقبسًا يقبل اتصالك ولا يجيب أبدًا، أو بثًا يتوقف في منتصف كلمة — وستدفع مقابل كل تجربة، بينما التجارب المثيرة للاهتمام هي التي تشغّلها مئة مرة.

لذلك فالبرنامج الأول في هذا النصف من المنهج ليس عميلًا. إنه خادم عدائي: أربعون سطرًا من Node عادي تتحدث بروتوكول السلك نفسه مثل 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 يقبل المقبس ولا يكتب إليه أبدًا؛ /401 يرفض المفتاح؛ وفحص السعة ينتج 429 حقيقية مع ترويسة Retry-After حقيقية عندما تكون ثلاثة طلبات قيد التنفيذ بالفعل؛ و?cut=N يترك الإجابة في منتصفها، إما بإعادة ضبط المقبس أو — مع &how=close — بإغلاقه بطريقة منتظمة، وهو ما يتضح أنه مهم جدًا. والباقي بث Server-Sent Events حقيقي: كائن JSON واحد لكل سطر data:، وسطر فارغ بين الأحداث، والسلسلة [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]

جسم الطلب، والمفتاح الذي لا يغادر الخادم أبدًا

رابط إلى القسم: جسم الطلب، والمفتاح الذي لا يغادر الخادم أبدًا

طلب المحادثة هو قائمة رسائل، لكل منها دور. تلك القائمة هي حالة النموذج كلها: لا توجد ذاكرة بين الاستدعاءات، وكل ما تريد أن يعرفه النموذج يجب أن يكون داخل المصفوفة التي ترسلها هذه المرة. الفصل 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,
};

تلك الأدوار ليست زينة. تُعرض داخل قالب المحادثة في الفصل 11 قبل أن يرى النموذج token واحدًا، ولهذا يؤدي إرسال الدور الخاطئ إلى تدهور الإجابة بصمت بدل رفع خطأ.

قاعدة بلا استثناءات: مفتاح API لا يسافر إلى العميل أبدًا. لا في متغير بيئة مهيأ للمتصفح، ولا في ثابت وقت البناء، ولا «مؤقتًا». المفتاح داخل bundle يصبح مفتاحًا على فاتورة شخص آخر خلال أيام. المتصفح يتحدث إلى خادمك، وخادمك يحتفظ بالمفتاح ويتحدث إلى المزوّد — وبما أن خادمك في المنتصف، فهو أيضًا المكان الوحيد الذي يستطيع قياس ما ينفقه كل مستخدم، وهذا هو موضع محاسبة الفصل 16.

الآن التجربة التي بُني عليها الفصل. سؤال واحد، ومزوّد وهمي واحد ينتج ثلاثة عشر token بمعدل 60 ms لكل واحد، وثلاث طرق للسؤال.

أولًا، من دون بث. يرسل العميل الطلب وينتظر جسم JSON كله.

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

الرقمان متساويان، وهذه هي المشكلة كلها. طوال 791 ms كان لدى المستخدم مؤشر تحميل، ولم تكن كلمة واحدة متاحة أبكر — كان لدى الخادم الجواب، بايتًا بعد بايت، واختار ألا يقول شيئًا.

ثانيًا، مع البث. الخادم نفسه، الإجابة نفسها، العمل الكلي نفسه. الفرق هو 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 موجود لأن chunk الشبكة لا علاقة له بحدث: يمكن لـ read() أن يعيد نصف حدث، أو حدثين ونصف. وعلم { stream: true } موجود لأن حرف UTF-8 متعدد البايتات يمكن أن ينقسم عبر chunkين، وبدونه تتحول الحروف ذات العلامات إلى رمز استبدال عشوائيًا. والأحداث تفصلها سطر فارغ، لا سطر جديد، ولهذا تبحث الحلقة عن \n\n.

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

أسرع باثنتي عشرة مرة إلى الكلمة الأولى، وأبطأ بميلي ثانيتين إلى الأخيرة. البث لا يجعل شيئًا أسرع. إنه يغيّر ما يفعله المستخدم خلال الـ 790 ms نفسها: يقرأ بدل أن ينتظر. هذه هي الفائدة كلها، وهي هائلة، وهي سبب أن كل منتج محادثة يبث.

ثالثًا، مع عشرين عميلًا دفعة واحدة. يخدم المزوّد الوهمي ثلاثة طلبات في كل مرة. أطلق عشرين:

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

عشرون إجابة، أربعة وسبعون طلبًا، أربعة وخمسون رفضًا. لم يفقد أحد شيئًا، حصل كل عميل على النص نفسه، وكانت التكلفة المرئية الوحيدة هي الوقت. هذه سياسة إعادة محاولة تعمل. بقية هذا الفصل عن الطرق الثلاث التي يمكن أن تفشل بها بدل ذلك.

finish_reason، ونهايتان تبدوان متشابهتين

رابط إلى القسم: finish_reason، ونهايتان تبدوان متشابهتين

قبل الإخفاقات، الحقل الذي يتجاهله الجميع تقريبًا في الجولة الأولى. ينتهي كل بث بحدث يحمل finish_reason. ‏stop تعني أن النموذج قرر أنه انتهى. ‏length تعني أنه اصطدم بسقف token، لذا فالإجابة مقطوعة في منتصف جملة وليس هذا ذنب النموذج. تضيف فصول لاحقة tool_calls (الفصل 18) ومرشحات محتوى.

الآن شاهد نهايتين لا يستطيع عميل ساذج التفريق بينهما. الخادم نفسه، التأخير نفسه، واحدة مقطوعة بواسطة max_tokens وواحدة يُغلق فيها الاتصال بشكل نظيف بعد خمسة tokens:

TEXT
max_tokens=5           loop ended NORMALLY   chunks=5  finish_reason=length  text="A tide gauge is a"
socket closed cleanly  loop ended NORMALLY   chunks=5  finish_reason=null    text="A tide gauge is a"
socket destroyed       threw TypeError: terminated (UND_ERR_SOCKET)
                                             chunks=4  finish_reason=null    text="A tide gauge is"

اقرأ الصفين الأولين بعناية. النص متطابق. عدد chunks متطابق. لا استثناء في أي منهما. انتهت حلقة for await طبيعيًا في الحالتين، لأن الجسم من وجهة نظر القارئ انتهى، وهذا كل ما يستطيع جسم فعله. الفرق الوحيد في الرصد كله هو أن واحدة تحمل finish_reason: "length" والأخرى لا تحمل شيئًا إطلاقًا.

لذا فالقاعدة ليست «التقط الأخطاء أثناء البث». بل هي:

البث الذي ينتهي بلا finish_reason لم ينتهِ. لقد توقف.

عامل غياب finish_reason كفشل، دائمًا، ولا تحفظ ذلك النص أبدًا كإجابة مكتملة. يبيّن الصف الثالث الحالة الأسهل — المقبس المدمّر يرمي خطأ فعلًا، ويفقد أيضًا chunk الذي كان قيد النقل، ولهذا فالنص أقصر بكلمة واحدة من الاثنين أعلاه.

خمسة رموز حالة هي خمس مشكلات مختلفة

رابط إلى القسم: خمسة رموز حالة هي خمس مشكلات مختلفة

أغلى عادة لدى منتج جديد هي كتلة catch واحدة لكل ما يعيده المزوّد. هذه الرموز ليست تنويعات على «فشل». إنها خمس تعليمات، وأربع منها تناقض بعضها.

الحالةما معناهاما الذي تفعلههل تنتظر؟
400طلبك مشوّه — JSON سيئ، حقل غير معروف، context طويل جدًاأصلح الشفرةأبدًا
401المفتاح خاطئ أو مفقود أو أُلغيأصلح النشرأبدًا
429حد معدل: طلبات كثيرة جدًا، أو tokens كثيرة جدًا، في الدقيقةأعد المحاولةRetry-After، ثم backoff
500المزوّد تعطّلأعد المحاولةbackoff
503المزوّد محمّل فوق طاقته — يعمل، لكنه ممتلئأعد المحاولةbackoff، وخفّف الحمل

الخط المهم يقع بين 4xx والباقي. 400 أو 401 يعيدان الإجابة نفسها تمامًا إذا أرسلتهما ألف مرة، لأن لا شيء في أي طرف يتغير بين المحاولات. إعادة المحاولة هنا ليست حذرًا، بل تأخير بخطوات إضافية. مقاسًا: عميل يقوم بست محاولات — خمس إعادات مع exponential backoff — وعميل يقرأ الرمز أولًا.

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

ست ثوان من مؤشر التحميل للوصول إلى إجابة كانت متاحة في أربع ميلي ثوان. وهذه النسخة اللطيفة: في المنتج تكون إعادات المحاولة عادة متداخلة — عميل HTTP يعيد المحاولة داخل مشغّل مهام يعيد المحاولة داخل طابور له إعادة تسليم خاصة به — فتتحول ست ثوان إلى ست دقائق من نشر مكسور دائمًا يبدو بطيئًا.

الفرز تسعة أسطر وينتمي إلى مكان واحد:

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، الذي يستخدمه بعض المزوّدين لمعنى «نفد رصيدك» ويحتاج شاشة مع رابط للشراء لا إعادة محاولة، و529 أو مكافئاته الخاصة بالبائع، والتي تتصرف مثل 503.

Backoff، وما الذي يشتريه jitter فعليًا

رابط إلى القسم: Backoff، وما الذي يشتريه jitter فعليًا

إعادة المحاولة سهلة. إعادة المحاولة متى هي الجزء الذي له جواب صحيح قابل للقياس.

Exponential backoff هو المعيار: انتظر تأخيرًا أساسيًا، ضاعفه بعد كل فشل، وتوقف عند سقف. وُجد لأن الخادم المثقل يسوء أكثر إذا عاد إليه العملاء الذين فشلوا للتو مباشرة.

المشكلة أن الجميع يضاعف من نقطة البداية نفسها. إذا وصل مئة عميل إلى حد في اللحظة نفسها — وسيحدث ذلك، لأن هذه هي طفرة الزيارات — فسينتظر المئة كلهم 200 ms، وسيعيد المئة كلهم المحاولة معًا، وسيفشل المئة كلهم معًا، وسينتظر المئة كلهم 400 ms. جدول إعادة المحاولة زامنهم. هذا thundering herd، والعشوائية هي الإصلاح.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);      

مئة عميل، وخادم واحد يخدم ثلاثة في كل مرة، وكل شيء آخر متطابق، ثلاث جولات لكل حالة:

طلبات HTTPالرفضأسوأ عميلأكثر نافذة 50 ms ازدحامًازمن الجدار
بلا jitter، الجولة 149139110 محاولات46 وصولًا65.6 s
بلا jitter، الجولة 278068019 محاولة72 وصولًا245.7 s
بلا jitter، الجولة 377067018 محاولة97 وصولًا225.6 s
full jitter، الجولة 13242245 محاولات32 وصولًا2.2 s
full jitter، الجولة 23132136 محاولات31 وصولًا2.3 s
full jitter، الجولة 33182186 محاولات25 وصولًا1.8 s

شيئان في ذلك الجدول، والثاني هو المهم.

الأول هو الوسيط: 226 ثانية مقابل 2.2، عامل يقارب المئة، مع أقل من نصف الطلبات. نافذة إعادة المحاولة الأكثر ازدحامًا تقول السبب. من دون jitter، وصل حتى 97 من المئة عميل داخل خانة الخمسين ميلي ثانية نفسها؛ لدى الخادم ثلاثة فقط، لذا رُفض 94 وناموا معًا، ما زالوا متزامنين، ليعيدوا الأمر مع انتظار أطول. مع jitter انتشر المئة أنفسهم عبر النوافذ نفسها في مجموعات تقارب الثلاثين وتصرّفوا شبه فورًا.

الثاني هو التباين. من دون jitter: 65.6 s، 245.7 s، 225.6 s. معه: 2.2، 2.3، 1.8. النظام بلا jitter لا يؤدي أداءً سيئًا فقط، بل يؤدي بشكل غير قابل للتنبؤ، لأن النتيجة يقررها حادث جدولة مجهري يختار أي ثلاثة من مئة عميل متزامن يصلون أولًا. هذه هي بصمة هذا الخطأ في الإنتاج: endpoint يكون جيدًا، جيدًا، جيدًا، ثم يستغرق أربع دقائق، ولا يفسر ذلك أي تغيير منك.

وأرخص إعادة محاولة هي التي لا تحدث أبدًا. ضع بوابة تزامن أمام المزوّد — عدادًا لا يسمح أبدًا بأكثر من N طلبات قيد التنفيذ — فيتصرف العملاء العشرون أنفسهم الذين احتاجوا 74 طلبًا و7.1 ثانية هكذا:

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

عشرون طلبًا لعشرين إجابة، صفر رفض، أسرع ثماني مرات. إعادة المحاولة هي الاعتذار؛ البوابة هي ألا تحتاج إلى اعتذار.

عندما يعيد المزوّد 429 فإنه غالبًا يخبرك كم تنتظر، في ترويسة Retry-After.3 ذلك الرقم ليس نصيحة: المزوّد هو الطرف الوحيد في التبادل الذي يعرف متى تُعاد نافذته.

لذا فالانتظار هو الأكبر بين الاثنين: لا يقل أبدًا عن Retry-After، ولا يقل أبدًا عن backoff الخاص بك أيضًا، لأن الترويسة تخبرك متى يسامحك المحدِّد لا متى يصبح لدى الخادم متسع.

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

يوضح أثر العميل الأقل حظًا في جولة العشرين عميلًا أن الترويسة تؤدي عملها. كانت أول أربع سحوبات backoff لديه كلها أقل من ثانية، وكل الأربع استُبدلت:

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 بدل عدد ثوانٍ، لذا فسّر كليهما. كما أن المزوّدين يطبّقون حدود المعدل على محورين في الوقت نفسه — الطلبات في الدقيقة وtokens في الدقيقة — ولهذا تُرفض prompts الطويلة قبل حد الطلبات الموثّق بكثير. تبدو الترويسة نفسها في الحالتين؛ لكن الإصلاح ليس نفسه.

المهلة الزمنية التي لم يخترها أحد

رابط إلى القسم: المهلة الزمنية التي لم يخترها أحد

اطلب من المزوّد الوهمي /hang. يقبل الاتصال، ثم لا يفعل شيئًا إطلاقًا: لا ترويسات، لا جسم، لا إغلاق. هذا ليس غريبًا — هذا ما يفعله موزّع حمل عندما تموت العملية خلفه من دون إغلاق مقابسها.

عميلان، فرق واحد:

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

ثلاثمئة ثانية. خمس دقائق من مقبس مفتوح، وخانة طلب مشغولة، ومستخدم يحدّق في مؤشر تحميل، تنتهي بخطأ TypeError عام لا يقول شيئًا عما حدث. ذلك الرقم ليس خطأ برمجيًا: إنه مهلة الترويسات الافتراضية في Node، معقولة لعميل HTTP عام وكارثية لطلب يواجه المستخدم. لكل runtime افتراضي كهذا، ومعظم الناس لا يبحثون عنه أبدًا، والطريقة الوحيدة لمعرفة خاصتك هي أن تعلّق مقبسًا عمدًا كما فعلنا للتو.

لذا: كل طلب صادر يحصل على موعد نهائي صريح تختاره أنت.

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

لا تكفي مهلة واحدة لاستدعاء بث، لأن هناك فشلين مختلفين. الأول هو البث لا يفتح أبدًا: لا يصل أي حدث إطلاقًا، وعشر إلى ثلاثين ثانية مناسبة. والثاني هو البث يفتح ثم يتوقف: تدفقت tokens ثم توقفت إلى الأبد، مع بقاء المقبس سليمًا. لا تستطيع مهلة مدة كلية تمييز بث متوقف من إجابة صحيحة طويلة، لذا ما تريده هو مهلة خمول — مؤقت يعاد ضبطه مع كل حدث، ولا ينطلق إلا عندما لا يصل شيء لمدة، مثلًا، خمس عشرة ثانية.

الإلغاء هو الآلية نفسها موجّهة إلى شخص. ‏AbortSignal.timeout وضغط المستخدم على Stop كلاهما يصلان كـ AbortError، لذا ادمجهما وسجل أيهما انطلق:

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

الإجهاض مهم لسبب يتجاوز النظافة: tokens تُولَّد وتُحاسب عليها بينما أنت لا تستمع. الفصل 16 يضع سعرًا لذلك.

الآن الفشل الذي يكلف مالًا لا وقتًا. تنتهي مهلة طلب لدى العميل، والخطوة البديهية هي إرساله مرة أخرى — لكن انتهاء المهلة لا يخبرك شيئًا عن ما إذا كان الخادم استلمه. غالبًا جدًا أنه استلمه، وما زال يعمل.

مقاسًا. يحتاج المزوّد الوهمي 780 ms للإجابة. يستسلم العميل عند 300 ms ويعيد المحاولة. يحصي الخادم كم إجابة ولّد فعلًا، وهذا ما كان سيحاسب عليه:

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

بلا مفتاح: توليدان كاملان، مدفوع عنهما مرتين، ولم يتلق العميل أيًا منهما. مع مفتاح: تعرّف الخادم على الطلب الثاني كأنه الطلب نفسه ورد فورًا بالإجابة التي كان قد أنتجها، لذا تجنبت إعادة المحاولة التكلفة المزدوجة وكانت أيضًا المحاولة التي نجحت أخيرًا.

مفتاح idempotency هو سلسلة فريدة تولدها لكل عملية منطقية — لا لكل محاولة — وترسلها بلا تغيير في كل إعادة محاولة لها. يخزن الخادم النتيجة مقابل المفتاح ويعيد تشغيلها. هذه هي الآلية التي تستخدمها 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");
}

حدّان صادقان. لا يدعم كل مزوّد مفاتيح idempotency على completions، وحيث لا يكون endpoint idempotent، فالعدد الصحيح لإعادات المحاولة لطلب POST ربما يكون قد نفّذ هو صفر. كما أن البث الذي فشل في منتصفه لا يمكن إعادته عمومًا: إما تعيد تشغيله وتدفع مرة أخرى، أو تبقي النص الجزئي وتعلّمه كغير مكتمل. أيهما يفعله منتجك قرار منتج، لا قرار شبكات، ويستحق اتخاذه عمدًا.

العميل المكتوب في هذا الفصل لا يعرف ماذا وراء المنفذ. وجّه base URL الخاص به إلى مزوّد تجاري فيبث tokens من نموذج بتريليون معلمة. وجّهه إلى خادم مبني على حساب الفصل 13 — يخدم النموذج الذي دربته مسبقًا في الفصل 10، مع KV cache وأوزانه المكمّمة — وستبث الشفرة نفسها، بلا تغيير، tokens من نموذج بنيته أنت.

switch.tsTS
const BASE = process.env.LLM_BASE_URL ?? "http://127.0.0.1:8000/v1";  

ذلك السطر الواحد هو درزة هذا المنهج. على جانب منه ما بنته الفصول الثلاثة عشر الأولى؛ وعلى الجانب الآخر ما تبنيه الفصول الستة عشر التالية. الحد نظيف لأن العقد هو HTTP وSSE، ولا يعرف أي جانب شيئًا آخر عن الآخر.

يجدر ملاحظة ما فقدته بالعبور. خلف endpoint تجاري لا تتحكم في الأوزان، ولا في تنفيذ أخذ العينات، ولا في النسخة التي تتحدث إليها، ولا في ما إذا كانت تغيّرت هذا الصباح. ما تتحكم فيه هو العقد: الرسائل التي ترسلها، والموعد النهائي الذي تضبطه، والرموز التي تميّزها، وما تفعله عندما لا يعود شيء. هذه مساحة أصغر مما كان لديك في الفصل 5، وكل فصل باقٍ عن استخدامها جيدًا.

لديك الآن عميل يبث، ويستسلم في الوقت المناسب، ويعيد محاولة الأشياء الصحيحة ولا يعيد محاولة الخاطئة أبدًا. ما يرسله ما زال أيًا كان ما كتبته.

الفصل 15 عن ذلك المحتوى، ويأتي بانضباط. الإنترنت مليء بنصائح prompting — اعرض على النموذج إكرامية، هدده، قل له أن يأخذ نفسًا عميقًا — وقليل جدًا منها يأتي مع قياس. بعض هذه التقنيات يحرّك الناتج كثيرًا، وبعضها لا يحرّكه إطلاقًا، وواحدة على الأقل تجعل مهمة تصنيف أسوأ بينما تكلف tokens أكثر. أيها أي ليس واضحًا من قراءتها، ولا يُحسم بالجدال.

لذلك يبني الفصل التالي bench: ستون حالة ذات إجابات معروفة، وأربع نسخ من prompt نفسه، تُشغّل بالتوازي عبر العميل الذي كتبته للتو، وتُجدول مع فواصل الثقة من الفصل 4 — لأن أربع نسخ على عشرين حالة لا تميز شيئًا إطلاقًا. جملة واحدة تحكم الفصل كله: prompt يُقاس، لا يُناقش.


كل رقم أعلاه خرج من المزوّد الوهمي، على Node 22 عبر واجهة loopback، لذا فالكمونات أنظف مما ستعطيك أي شبكة حقيقية. هذا مقصود: لا واحد من الإخفاقات المقاسة سببه الشبكة، وخادم عدائي تستطيع إعادة تشغيله يعلّم أفضل من خادم حقيقي يجب أن تدفع له ولا تستطيع كسره.

  1. Server-Sent Events، معيار WHATWG HTML Living Standard، القسم 9.2. صيغة السلك — حقول data:، والأحداث المفصولة بأسطر فارغة، وid: وretry: — معرّفة هناك، إلى جانب واجهة EventSource. لا يستطيع EventSource إرسال جسم طلب أو ترويسات مخصصة، ولهذا يفسر كل عميل LLM الصيغة يدويًا فوق fetch بدل استخدامه.

  2. Brooker, M. Exponential Backoff and Jitter. AWS Architecture Blog (2015). مصدر صياغة «full jitter» المستخدمة أعلاه، مع المحاكاة التي تبيّن لماذا تزامن النسخة الساذجة العملاء. والحجة المصاحبة لتخفيف الحمل بدل وضعه في طابور هي فصل 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، القسم 15، يعرّف فئات رموز الحالة؛ Nottingham, M. and Fielding, R., Additional HTTP Status Codes, RFC 6585 (2012)، القسم 4، يعرّف 429 Too Many Requests. ‏Retry-After هو RFC 9110 القسم 10.2.3، ويقبل إما عدد ثوانٍ أو تاريخ HTTP.

  4. Stripe، Idempotent requests، docs.stripe.com/api/idempotent_requests، قُرئ في 7 سبتمبر 2026 — أوضح صياغة للعقد: مفتاح واحد لكل عملية منطقية، وإعادة تشغيل النتائج المخزنة، وإرجاع تعارض بينما المحاولة الأولى ما زالت قيد التنفيذ — والنمط مستقل عن المزوّد. المراجع المعيارية لأشكال الطلب والأحداث المستخدمة هنا هي developers.openai.com/api/reference/resources/chat للبث ورموز الأخطاء وحدود المعدل، وplatform.claude.com/docs/en/api/messages لـ Messages API؛ وai-sdk.dev/docs هو أفضل مثال معمول لهذه المخاوف نفسها داخل مكتبة. كلها قُرئت في اليوم نفسه.

هل أنت مستعد لتترك الاختيار لـ LIA؟

ابنِ بكل نماذج الذكاء الاصطناعي في مكان واحد — ابدأ مجانًا اليوم.