تخطَّ إلى المحتوى
أسامة جنينة
مقالات
6 دقائق قراءة

طبقة AI مستقلة عن المزوّد: ضَع الموديل خلف واجهة

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

AILLMOpenAINode.jsArchitecture

في أي مساعد جزءان يتغيّران لأسباب لا علاقة بينها. المحادثة تتغيّر حين يتغيّر العمل: منتج جديد، قاعدة جديدة للاسترجاع، قناة جديدة. والموديل يتغيّر حين يصدر مزوّدٌ شيئاً أفضل، أو يغيّر أسعاره، أو يمرّ بيوم سيئ، أو حين يسأل عميل إلى أين تُرسَل رسائل عملائه.

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

الاقتران الذي لا تنتبه له

النسخة الأولى من أي مساعد تستدعي SDK المزوّد حيثما احتاجت جواباً. إنها أسرع طريقة ليعمل شيء، وهي تعمل.

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

الحدّ

الحل واجهة واحدة، مكتوبة بكلمات التطبيق لا بكلمات أي مزوّد:

export type Turn = { role: 'customer' | 'assistant'; text: string };
 
export type Reply =
  | { kind: 'answer'; text: string; usage: { inputTokens: number; outputTokens: number } }
  | { kind: 'handoff'; reason: string };
 
/** كل ما يُسمح لكود المحادثة أن يعرفه عن الموديل. */
export interface ChatProvider {
  reply(request: {
    instructions: string;
    history: Turn[];
    maxOutputTokens: number;
    timeoutMs: number;
  }): Promise<Reply>;
}

وهي صغيرة بالقصد. عميل ومساعد يتبادلان الأدوار؛ والرد إما جواب أو قرار بالتحويل. لا ذكر فيها لشرائح tokens في الدقيقة ولا لمخططات الأدوات ولا لقطع الـ streaming، لأن المحادثة لا تحتاج أن تعرف عنها.

ما الذي يبقى خارجها

ثلاثة أشياء لا تمرّ عبر تلك الواجهة، وإبقاؤها خارجها مهم بقدر الواجهة نفسها.

القنوات. في المساعد متعدد القنوات، نشرٌ واحد يردّ على واتساب وماسنجر وإنستغرام لكل صفحة وكل رقم تملكه الشركة، من webhook واحد. ولا يبقى ذلك قابلاً للإدارة إلا إذا صار مظروف كل قناة هو الـ Turn نفسه قبل أن يدخل أي موديل: المزوّد لا يعرف أبداً أي قناة يجيب عليها، وإضافة قناة لا تمسّ طبقة الـ AI إطلاقاً.

الحدود. سقف المخرجات والمهلة وسيطان يحددهما المستدعي. وكذلك الميزانية، التي تُفحص قبل إجراء الاستدعاء:

export async function answer(conversation: Conversation, provider: ChatProvider, budget: Budget) {
  if (!budget.allows(conversation.accountId)) {
    return handOff(conversation, 'budget reached');
  }
 
  const reply = await provider.reply({
    instructions: conversation.instructions,
    history: conversation.recentTurns(),
    maxOutputTokens: 400,
    timeoutMs: 15_000,
  });
 
  if (reply.kind === 'handoff') return handOff(conversation, reply.reason);
 
  budget.record(conversation.accountId, reply.usage);
  return send(conversation, reply.text);
}

الميزانية رقمٌ يُفحص في الكود، لا جملة في prompt. فالموديل يمكن إقناعه بالتخلي عن جملة. وهذا الفحص هو ما يمنع شهراً غير متوقع من أن يصير فاتورة غير متوقعة.

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

مُهايئ واحد

ثم يحصل كل مزوّد على ملف واحد، وهو الملف الوحيد الذي يستورد الـ SDK الخاص به:

import OpenAI from 'openai';
 
export class OpenAiProvider implements ChatProvider {
  constructor(
    private readonly client: OpenAI,
    private readonly model: string,
  ) {}
 
  async reply(request: Parameters<ChatProvider['reply']>[0]): Promise<Reply> {
    const completion = await this.client.chat.completions.create(
      {
        model: this.model,
        max_completion_tokens: request.maxOutputTokens,
        messages: [
          { role: 'system', content: request.instructions },
          ...request.history.map((turn) => ({
            role: turn.role === 'customer' ? ('user' as const) : ('assistant' as const),
            content: turn.text,
          })),
        ],
      },
      { timeout: request.timeoutMs },
    );
 
    const text = completion.choices[0]?.message.content?.trim();
    if (!text) return { kind: 'handoff', reason: 'empty model reply' };
 
    return {
      kind: 'answer',
      text,
      usage: {
        inputTokens: completion.usage?.prompt_tokens ?? 0,
        outputTokens: completion.usage?.completion_tokens ?? 0,
      },
    };
  }
}

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

البطء هو الحالة الطبيعية

استدعاء الموديل يأخذ ثوانيَ، وأحياناً كثيرة. وأي شيء ينتظره داخل طلب ويب قد سلّم المزوّد التحكم بزمن استجابته.

لذلك يذهب العمل المكلف إلى طابور. في نظام الترجمة الصوتية الفورية، خدمة Python تتولى عمل الصوت بينما يدير control plane على Laravel المتحدثين والاشتراكات والحصص، وكل ما هو مكلف يمرّ عبر طوابير Redis — فالاستجابة البطيئة من الموديل لا تحجز طلباً أبداً. والواجهة أعلاه تناسب ذلك دون تغيير: الـ worker يستدعي reply تماماً كما يفعل معالج الطلب.

ما الذي يجعله رخيصاً

تجربة موديل آخر. اكتب مُهايئاً واحداً، وغيّر إعداداً واحداً. كود المحادثة لا يُفتح.

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

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

ما الذي لا يخفيه

الموديلات تختلف في السلوك، لا في الـ API فقط. التعليمات المضبوطة على واحد ستحتاج عملاً مع آخر، ولا واجهة تُزيل ذلك. الحدّ يجعل التبديل ممكناً ومحصوراً؛ لا يجعله مجانياً.

وهي لا تكبر إلا حين يكبر المنتج. إذا كان المساعد لا يستدعي أدوات، فلا استدعاءات أدوات في الواجهة. والواجهة التي تحاول تغطية كل ميزة عند كل مزوّد ليست إلا SDK ثانية، وهي أسوأ من الأولى.

ضَع الموديل خلف واجهة مبكراً، وهو ما زال استدعاءً واحداً. العمل صغير حينها، وهو الفرق بين أن تختار مزوّدك وأن يحتفظ هو بك.