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) الخاص بها.
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).