cd ../back to blog
$Engineering//June 4, 2026//9 min read

كيف نُحقّق SLO بنسبة 99.95%: هندسة gateway موثوق للذكاء الاصطناعي

تشريح هندسي لكيفية صمود إتاحة Brievio الشهرية عند 99.95% عبر 12 upstream: توجيه مرشّحين موزون مع تحلّل أوزان آني، وحارس بايت أول مدته 50ms، وحجوزات رصيد معامِلاتية — والأمور التشغيلية المملّة التي تهمّ أكثر.

إن الـ SLO المُعلن لـ Brievio هو إتاحة شهرية بنسبة 99.95% على واجهة المحادثة. يبدو هذا كرقم تسويقي، لكنه حقيقي — فهو يحدّ ميزانية الأخطاء لدينا عند 21 دقيقة في الشهر، وقد بلغناه في كل شهر منذ الإطلاق. هذا المقال يكشف ما وراءه فعلاً: الفشل التجاوزي بين الـ upstreams، وحارس البايت الأول، ومعداد الفوترة، والأمور التشغيلية المملّة التي تهمّ أكثر من كل ما سبق.

الواقع: لكل upstream أيام سيئة دون استثناء

نتعامل مع نحو 12 upstream خلف الكواليس. وفي أي شهر نراها جميعاً تمرّ بتراجع جزئي واحد على الأقل تتراوح مدته بين 5 و30 دقيقة. أحياناً يكون انقطاعاً في إقليم (شهدت us-east-1 لدى Anthropic حادثتين هذا الربع). وأحياناً يكون خنقاً صامتاً لدلو التوكنات (مُحدِّد المعدل في Vertex AI بخيل أيام الإثنين). وأحياناً يكون انقطاعاً كاملاً لدى المزوّد (توقفت kie.ai 47 دقيقة في مارس).

إن سقط upstream واحد ولم يكن لديك فشل تجاوزي، فإن الـ SLO الخاص بك يصبح مساوياً لـ SLO الخاص بهم — ناقصاً عبء النقل لديك. وهذا يضع سقفاً صارماً حول 99.5%. وتجاوز 99.9% يقتضي ألّا يقدر أي فشل في upstream واحد على إسقاطك، أما 99.95% فيقتضي ألّا يقدر على ذلك حتى فشلان متزامنان.

الطبقة 1: توجيه مرشّحين موزون

لكل نموذج نستضيفه، هناك من مسار upstream واحد إلى 4 مسارات. يسير الموزّع بينها بترتيب موزون، بأوزان تتحلّل في الزمن الحقيقي بناءً على معدل نجاح متدحرج (آخر 100 نداء لكل upstream لكل إقليم). فحين تبدأ us-east-1 بإرجاع 5xx، ينخفض وزنها دون us-west-2 خلال نحو 5 ثوانٍ، وتتوقف الطلبات الجديدة عن التوجّه إليها قبل أن يلاحظ العميل.

dispatch.ts
// كود شبه برمجي لموزّع الـ upstream. التطبيق الحقيقي
// (TypeScript، على الخادم فقط) قريب الشكل من هذا.
async function dispatch(req: ChatRequest): Promise<ChatResponse> {
  const candidates = pickCandidates(req.model);
  // candidates = [{provider: "anthropic-direct", weight: 100},
  //               {provider: "google-vertex",   weight:  80},
  //               {provider: "kie-wholesale",   weight:  60}]
  // وزن أقل = احتياطي. الأوزان الابتدائية تأتي من معدلات نجاح متدحرجة.

  const deadline = Date.now() + 540_000;          // حدّ جداري صارم 540 ثانية
  let lastError: Error | null = null;

  for (const candidate of candidates) {
    const budgetMs = chunkBudget(deadline, candidates.length);
    try {
      return await Promise.race([
        callUpstream(candidate, req),
        timeout(budgetMs),
      ]);
    } catch (err) {
      if (!isRetryable(err)) throw err;          // 4xx → لا فشل تجاوزي
      recordFailure(candidate, err);              // الوزن يتحلّل في الزمن الحقيقي
      lastError = err;
    }
  }
  throw lastError ?? new Error("all upstreams exhausted");
}

أمران يهمّان هنا ومن السهل الإخفاق فيهما:

  • لا تنفّذ فشلاً تجاوزياً على 4xx. إن إرجاع 400 من الـ upstream يعني أن الطلب كان سيئاً — وإعادة المحاولة على upstream آخر ستنتج الـ 400 نفسه، لكن أبطأ. لا يُطلق الفشل التجاوزي إلا 408 و429 و5xx وأخطاء الشبكة.
  • حُدّ ميزانية كل مرشّح. إن قُسّمت مهلة 540 ثانية على 3 مرشّحين، فأعطِ كلاً منهم نحو 150 ثانية إما ليبدأ الدفق أو ليموت. لا تعطِ الأول كامل الـ 540 — فإن علّق، فلن يتبقى لديك وقت للاحتياطي.

الطبقة 2: حارس البايت الأول (50ms)

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

يضبط موزّعنا مؤقّتاً صارماً للبايت الأول مدته 50ms بعد وصول الطلب إلى الشبكة. فإن لم نرَ بايت استجابة واحداً ضمن تلك النافذة، نُجهض وننتقل. والـ 50ms دون عتبة الإدراك، فعلى المسار السعيد لا يلمس العميل أي تأخير. وعلى مسار الفشل، ينتقل طلب العميل تجاوزياً إلى الـ upstream الاحتياطي ضمن إطار زمني واحد يدركه الإنسان.

ttfb-watchdog.ts
// كشف البايت الأول — افشل بسرعة إن كان الـ upstream معلّقاً.
async function callUpstream(c: Candidate, req: ChatRequest) {
  const ac = new AbortController();
  // إن لم نرَ بايت استجابة واحداً خلال 50ms بعد وصول الطلب إلى
  // الشبكة، فعامِل الـ upstream كأنه صامت وانتقل إلى التالي.
  const firstByteWatchdog = setTimeout(() => ac.abort("ttfb-50ms"), 50);

  const res = await fetch(c.url, {
    method: "POST",
    body: JSON.stringify(req),
    signal: ac.signal,
  });

  clearTimeout(firstByteWatchdog);
  if (!res.body) throw new Error("no-body");

  // الآن لدينا البايت الأول. نعيد الدفق؛ يحصل العميل على كل
  // قطعة فور وصولها — دون تخزين مؤقت.
  return new Response(res.body, {
    headers: { "content-type": "text/event-stream" },
  });
}

وصلنا إلى 50ms بقياس P99 لزمن البايت الأول (TTFB) في الـ upstreams الخاصة بنا: حتى أبطأ upstream ينتج بايت استجابته الأول دون 40ms في 99% من الحالات. وأي شيء فوق 50ms إشارة، لا ضجيج. اضبط هذا الرقم وفق P99 الخاص بـ upstreams لديك.

الطبقة 3: معداد الفوترة

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

reserve-balance.ts
// "معداد الفوترة" — احجز التكلفة المقدّرة مقدماً كي لا يمرّ طلبان
// كبيران متزامنان فحص الرصيد معاً ثم يتجاوزا السحب.
async function reserveBalance(userId: string, estCents: number) {
  return await db.transaction(async (tx) => {
    const bal = await tx.balance.findUnique({ where: { userId } });
    if (bal.balanceCents - bal.reservedCents < estCents) {
      throw new InsufficientQuotaError();
    }
    await tx.balance.update({
      where: { userId },
      data: { reservedCents: { increment: estCents } },
    });
    return { release: () => releaseReservation(userId, estCents) };
  });
}

كل نداء يقدّر تكلفته القصوى مقدماً (توكنات المدخلات × سعر المدخلات + max_tokens × سعر المخرجات)، ويحجز ذلك المبلغ على المحفظة في معاملة واحدة، ثم يحرّر الجزء غير المستخدم بعد معرفة الاستهلاك الفعلي. والطلبات المتزامنة تتسلسل عند طبقة المعاملة. لا تجاوز سحب، أبداً.

الأمور المملّة التي تهمّ أكثر

كل مهندس يقرأ هذا يومئ برأسه أمام مخططات الموزّع ويفكّر «رائع، إعادة محاولة موزونة، حارس بايت أول، فهمت.» لكن ما جعل الـ SLO لدينا يصمد شهراً بعد شهر أمور أقل إثارة:

  • نبضات قلب على كل cron. تدوير التوكنات، وكنس الأرصدة، وتجميعات الاستهلاك — كل واحدة منها تنبض إلى نقطة نهاية في Healthchecks.io عند اكتمالها. والنبضة المفقودة تُنبّه خلال 5 دقائق. وقد اصطدنا من النبضات المفقودة انقطاعات أكثر مما اصطدناه من الإنذارات الصريحة.
  • صفحة حالة دقيقة فعلاً. brievio.com/status تجري فحوصاً اصطناعية ضد كل نموذج كل 90 ثانية. وحين يتراجع upstream، تصبح صفراء قبل أن يلاحظ العملاء.
  • أدلة تشغيل قبل الحاجة إليها. كل حادثة مررنا بها، بما فيها تلك التي اصطدناها قبل أن تمسّ العملاء، أنتجت مدخلاً في دليل تشغيل. فجهاز النداء الساعة الرابعة فجراً لديه قائمة تحقّق؛ والمهندس لا يحتاج إلى التفكير.
  • Sentry على كل شيء، وتحليل أداء على المسارات الحارّة. يسجّل الموزّع كل قرار فشل تجاوزي مع المرشّح والسبب والميزانية المتبقية. فإن تسلّل تراجع، صار البحث «أرني الحالات التجاوزية بسبب reason=ttfb-50ms مجمّعة حسب الـ upstream هذه الساعة.»

ما لا نفعله

بضعة أمور تجنّبناها عمداً، وقد تتوقع من gateway بنسبة 99.95% أن يفعلها:

  • قاعدة بيانات نشطة-نشطة متعددة الأقاليم. قاعدة بياناتنا أحادية الإقليم (iad). فتأخّر النسخ المتماثل سينشئ تناقضات في الفوترة لا نريد تنقيحها. وإن سقطت iad سقوطاً قاطعاً، نتحوّل إلى وضع قراءة فقط — فيبقى بوسع العملاء استدعاء النماذج (الموزّع عديم الحالة عند الحافة) لكن دون رؤية تاريخ الاستهلاك لساعة. وهذه المقايضة صائبة بالنسبة لنا.
  • تبديل النماذج بصمت. بعض المجمّعات، حين تعجز عن الوصول إلى Claude Opus، توجّه إلى Haiku. نحن لا نفعل — نفضّل أن تحصل على 503 على أن تحصل على نموذج مختلف لم تدفع مقابله. والموزّع لا ينفّذ الفشل التجاوزي إلا إلى النموذج نفسه على upstream مختلف.
  • إعادة المحاولة استباقياً. إن أكواد 429 لدى Anthropic تعني شيئاً. وإعادة المحاولة فوراً تزيد المشكلة سوءاً على الجميع. وتراجعنا أُسّي مع تشويش، بسقف 5 محاولات و30 ثانية انتظاراً إجمالياً — وهي السياسة نفسها التي نوصي بها في دليل الأخطاء لدينا.

إلى أين تذهب الميزانية فعلاً

كانت ميزانية الأخطاء لدينا في مايو 2026 هي 21 دقيقة و36 ثانية. استخدمنا:

  • 3 دقائق و12 ثانية: ومضة Anthropic في us-east-1 أثناء نشر. أُطلق الفشل التجاوزي، ولم يسقط أي طلب، لكن كمون P99 قفز فوق الهدف خلال النافذة.
  • دقيقة واحدة و50 ثانية: دفقة 5xx من kie.ai على نموذج فيديو. نجحت كل عمليات إعادة المحاولة، لكن كمون كل نداء خالف الـ SLO.
  • صفر توقف قاطع. لم يُرجِع أي طلب 5xx دون خيار إعادة محاولة.

هذا 5:02 من حرق الميزانية مقابل ميزانية 21:36 — أي 23% مستخدمة، ضمن الهدف بمريح. والـ 77% الباقية هي ما يتيح لنا النشر بجرأة وامتصاص المفاجأة التالية.

ماذا يعني هذا لك

إن كنت تبني على Brievio، فلست بحاجة إلى تطبيق الفشل التجاوزي بين المزوّدين بنفسك، ولا حارس البايت الأول الخاص بك، ولا توجيهك بين Anthropic وVertex. وهذا هو بيت القصيد كله: base URL واحد، وtoken حامل واحد، والنموذج الأصلي، والـ SLO علينا أن ندافع عنه.

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

اطّلع على أرقام وقت التشغيل الحالية والتفصيل حسب الإقليم في brievio.com/status، أو على التصنيف الكامل للأخطاء في وثائق الأخطاء لدينا.