Skip to main content
التقييمات (evals) اختبارات تراجع (regression) لسلوك الوكيل: أي الأدوات استدعى، وبأي ترتيب، وبأي وسائط، وكم خطوة استغرق، وماذا قال. تُكتب بـ defineEval()، وتوضع في ملفات *.eval.ts، وتعمل على vitest، وأفضل طريقة لتشغيلها في CI هي lousho eval، الذي يطبع ملخصًا ويكتب تقريرَي JUnit وJSON.

كتابة تقييم لمسار التنفيذ

أعطِ defineEval() وكيلًا (من createAgent()) ودالة test. أرسل الرسائل بـ t.send()، ثم اكتب تأكيداتك (assertions) على التشغيل. واستخدام mockModel مزوّدًا للوكيل يجعل التقييم حتميًّا: لا شبكة، ولا مفتاح API، ولا نتائج متذبذبة. هذه هي الطريقة الافتراضية لكتابة تقييم؛ ولا تستخدم نموذجًا حقيقيًا إلا في تقييمات المحكِّم أدناه.
ما زالت defineEval() تقبل أيضًا الصيغة الأصلية ({ name, agent, input, provider, score, threshold }): تعمل كما هي دون تغيير، وتظهر نتيجتها في lousho eval تأكيدَ score واحدًا.

التأكيدات

كل تأكيد يُسجَّل؛ وتفشل الحالة في النهاية وتسرد كل تأكيد إلزامي فشل، فيُظهر تشغيل واحد كل ما هو خاطئ. الفحوص المتاحة لـ t.check() وt.soft(): includes(text) وmatches(regex) وequals(value) (نعم/لا، والدرجة 1 أو 0)، وatLeast(n) وatMost(n) (والعدد نفسه هو الدرجة). رسائل الفشل تذكر ما شوهد فعلًا:
أعضاء السياق الأخرى: t.reply (نص آخر رد)، وt.result (آخر ExecutionResult)، وt.toolCalls (كل الاستدعاءات حتى اللحظة، بوسائطها المحلَّلة). يمكنك استدعاء send() أكثر من مرة؛ فتتراكم الخطوات والرموز واستدعاءات الأدوات.

مجموعات البيانات

مرّر cases فتعمل test مرة لكل حالة، وتظهر كل حالة في التقرير على حدة. الحالة أي كائن؛ ويسمّيها label (أو name، أو input) الخاص بها.
مرّر دالة مُنشئة (factory) (agent: () => createAgent(...)) لتحصل كل حالة على وكيلها الخاص وعلى سيناريو mockModel الخاص بها. فمع وكيل واحد مشترك، تنفد دورات النموذج المُعَدّ سلفًا عند الحالة الثانية.

تقييمات المحكِّم

t.judge(rubric) يقيّم آخر رد بواسطة LLM ويُرجع درجة من 0 إلى 1. يحتاج إلى مزوّد للمحكِّم، ولا يستدعي lousho أي LLM حقيقي ما لم تضبط واحدًا: فمن دون judge يرمي t.judge() خطأً يوضّح كيف تعالجه.
الملفات المسمّاة *.judge.eval.ts لا يلتقطها التشغيل العادي أبدًا (ولا npm test)؛ ولا يشغّلها إلا lousho eval --judge (أو npm run test:evals:judge في هذا المستودع).

lousho eval

يشغّل الأمر نسخة vitest المثبّتة في مشروعك (vitest اعتمادية تخصك أنت؛ فإن كانت مفقودة طبع الأمر npm install --save-dev vitest وخرج بالرمز 2)، ثم يطبع صفًّا لكل حالة فيه درجاتها ومدتها، ثم المجاميع، ثم إخفاقات التأكيدات الإلزامية والإخفاقات المرنة كلٌّ في قائمة مستقلة. رمز الخروج: 1 عند أي إخفاق إلزامي (أو إخفاق مرن مع --strict، أو حين يفشل vitest نفسه، كملف لا يُحمَّل مثلًا)، و2 حين يتعذر التشغيل أصلًا (وسائط خاطئة، أو vitest مفقود)، و0 فيما عدا ذلك. كيف تُجمع النتائج: كل حالة تُلحق سطر JSON واحدًا بملف يسمّيه متغير البيئة LOUSHO_EVAL_RESULTS، الذي يضبطه lousho eval ثم يقرؤه. وهذا أمتن من مُراسل (reporter) مخصص لـ vitest: يعمل مع مختلف إصدارات vitest ومجمّعات العمّال (worker pools)، ولا يحتاج إلى تحميل أي وحدة من مشروعك.

JUnit

التقرير بصيغة JUnit القياسية: testsuite واحد لكل تقييم، وtestcase واحد لكل حالة. التأكيد الإلزامي الفاشل هو <failure message="..."> يحمل رسالة التشخيص؛ والحالة التي رمت خطأً هي <error>؛ والإخفاقات المرنة ملاحظات <system-out> (أو إخفاقات مع --strict).

في CI

شغّل --tag smoke عند كل طلب دمج (pull request) والمجموعة الكاملة كل ليلة؛ ولا تشغّل --judge إلا حيث تقبل استدعاءات LLM حقيقية وتكلفتها.

التسجيل وإعادة التشغيل والانحراف

التقييم على نموذج حقيقي بطيء، ومكلف، ويحتاج إلى مفتاح؛ والتقييم نفسه على mockModel لا يختبر إلا السيناريو الذي كتبته أنت. وlousho eval يقف بينهما: يسجّل كل حالة مرة واحدة على المزوّد الحقيقي عبر recordReplay ثم يعيد تشغيل التسجيل في CI. وملفات تقييمك لا تتغير.
  • --record يشغّل كل حالة بالمزوّد الحقيقي لوكيلها ويكتب شريط تسجيل واحدًا لكل حالة بجوار ملف التقييم: __cassettes__/<eval-name>/<case>.json (تُحوَّل الأسماء إلى صيغة slug، مثل refund-flow/polite.json؛ والتقييم الذي بلا cases يكتب default.json). أعطِ كل حالة label فريدًا لتبقى الأسماء ثابتة. والحالة التي يستخدم وكيلها أكثر من مزوّد (وكيل فرعي على نموذج آخر) تحصل على <case>.2.json وهكذا. تستخدم الملفات صيغة شريط التسجيل المعتادة، مع حجب مفاتيح API؛ راجعها ثم أودعها في المستودع (commit).
  • --replay لا يستدعي النموذج أبدًا. الحالة التي بلا شريط تسجيل تفشل بالرسالة no cassette for "<eval> [<case>]" at ... مع أمر --record المطلوب تشغيله؛ وحين تتغير طلبات الوكيل، يفشل استدعاء النموذج المُعاد تشغيله بخطأ CassetteMismatchError يسمّي أول اختلاف. أما lousho eval المجرد مع ضبط CI فيعيد تشغيل كل حالة لها شريط تسجيل ويشغّل البقية تشغيلًا حيًّا؛ ومن دون CI يشغّل تشغيلًا حيًّا كما كان من قبل.
  • --drift يعيد تسجيل كل حالة في مجلد مؤقت (ولا تُمسّ الأشرطة المودَعة في المستودع) ويقارن كل تسجيل بشريطه المودَع: أسماء الأدوات بترتيبها، ووسائط كل استدعاء (بعد توحيد صيغة JSON، فلا يُعتدّ بترتيب المفاتيح)، وعدد الخطوات، وسبب الانتهاء. استهلاك الرموز يتغير في كل تشغيل حقيقي، فلا يُقارن إلا مع --drift-usage. ويُضاف إلى الملخص جدول Drift: (التقييم، الحالة، الحقل، المودَع، الحالي)؛ وكل حالة انحرفت تحصل على تأكيد drift مرن، فتظهر بالصيغة PASS (soft fail) وملاحظةَ <system-out> في تقرير JUnit. ومع --strict يصير الانحراف إخفاقًا إلزاميًا: تفشل الحالة، ويحوي JUnit عنصر <failure>، ويخرج التشغيل بالرمز 1.
شغّل --drift كل ليلة أو قبل ترقية النموذج، و--replay (أو lousho eval المجرد في CI) عند كل طلب دمج. المحكِّمون لا يُسجَّلون: لا تقيّم بمحكِّم حقيقي إلا في ملفات *.judge.eval.ts. كيف يعمل ذلك: حلقة التشغيل ترسل كل استدعاء للنموذج (generate أو stream، سواء من تشغيل عادي، أو تشغيل مبثوث، أو وكيل فرعي، أو استئناف بعد موافقة) عبر نقطة اعتراض واحدة للمزوّد، هي setProviderInterceptor() في src/providers/interception.ts. لا شيء مثبَّت فيها افتراضيًا، فلا كلفة لها. وحين تعمل حالة في أحد هذه الأوضاع، يجيب lousho eval عندها بالمزوّد مغلَّفًا بـ recordReplay() لشريط تسجيل تلك الحالة. ويمكنك استخدام نقطة الاعتراض بنفسك لوضع أي مغلِّف عند حدّ النموذج: يستقبل المعترِض مزوّد التشغيل ويُرجع المزوّد المراد استدعاؤه (ويجب أن يُرجع المغلِّف نفسه للمزوّد نفسه، وألا يمسّ مزوّدًا سبق أن غلّفه).

تشغيل التقييمات على نشر قائم

ملف التقييم نفسه الذي يحرس CI داخل العملية يصلح لاختبار دخاني (smoke test) لوكيل منشور (خادم node أو Cloudflare Worker). وجّه lousho eval إلى عنوانه الأساسي فتعمل كل حالة على النشر بدل الوكيل داخل العملية:
تحصل كل حالة على جلستها البعيدة الخاصة (POST /chat { sessionId, input }، جلسة واحدة لكل حالة، تتشاركها استدعاءات t.send() فيها) ويُقرأ بث SSE حتى run.done. وتتحول الأحداث إلى النتيجة نفسها التي ينتجها التشغيل داخل العملية: استدعاءات الأدوات، والرد النهائي، وسبب الانتهاء، والخطوات، والاستهلاك. فتعمل t.calledTool() وt.completed() ودوال التقييم وt.judge() والملخص و--junit دون تغيير. وagent المذكور في الملف لا يُستخدم؛ فاجعل نموذج النشر نفسه وأدواته على السلوك الذي تريد فحصه.
  • الفحص الذي يحتاج إلى بيانات لا يحملها البث يفشل ويصرّح بذلك (مثل maxTokens حين لا يحوي run.done بيانات استهلاك)؛ وmaxCostUsd يُبلَّغ عنه متخطًّى حين لا توجد تكلفة.
  • لا يمكن الجمع بين --url و--record أو --replay أو --drift (LOUSHO_CONFIG_CONFLICTING_OPTIONS): فأشرطة التسجيل تسجّل مزوّدًا داخل العملية. وتقييمات الدرجة/العتبة (صيغة score:) تحتاج هي أيضًا إلى مزوّد داخل العملية وتفشل بالرمز نفسه؛ فاستخدم تقييمات مسار التنفيذ.
  • النشر الذي يتعذر الوصول إليه، أو الرد بغير 2xx، أو البث المبتور يُفشل تلك الحالة بالخطأ LOUSHO_REMOTE_REQUEST_FAILED، و401 بالخطأ LOUSHO_REMOTE_UNAUTHORIZED؛ ويستمر التشغيل. ولا يُطبع الرمز أبدًا ولا يُكتب في أي تقرير.
في الشيفرة، مرّر target (وهو يغلب agent؛ و--url يغلب الاثنين):
remoteTarget({ url, auth?, fetch? }) يقبل fetch قابلة للحقن، وبهذه الطريقة تشغّل اختبارات SDK نفسها المسارات الحقيقية داخل العملية دون مقبس (socket).