با یک دستور، یک خط npm install و بدون هیچ کلید API می‌توانید خط لوله‌ی ارزیابی پرامپت را بسازید: promptfoo پاسخ مدل را به یک اسکریپت محلی می‌دهد، شما با چهار assert ثابت داوری می‌کنید، و در پایان یک کد خروجی ۱۰۰ می‌گیرید که در خط لوله‌ی CI خطا را متوقف می‌کند. در این پست هر سه حالت را اجرا می‌کنیم: ارزیابی‌ای که عمدا یک تست را رد می‌کند، اجرای فقط تست‌های شکست‌خورده، و اجرای دوباره بعد از رفع همان یک خطا تا سه از سه سبز شود.

پرامپت را با یک assert دستی تست نکنید

بیشتر تیم‌ها وقتی پرامپت را عوض می‌کنند، سه تا پنج نمونه را دستی در پنجره‌ی چت می‌بینند و تصمیم می‌گیرند. این روش تا بیست مورد جواب می‌دهد و در مورد بیست‌ویکم جواب نمی‌دهد، چون حافظه‌ی تست شما در همان لحظه‌ای که بهترین حالت را می‌بینید عوض می‌شود.

promptfoo یک ریپوی MIT با ۲۵٬۷۹۶ ستاره در لحظه‌ی خواندن این پست است که دقیقا همین کار را مکانیزه می‌کند: پرامپت‌ها، پروبایدرها و تست‌ها را در یک فایل YAML نگه می‌دارد، هر تست را اجرا می‌کند و با assertهای ثابت داوری می‌کند [1][2]. ریپو از ۲۸ آوریل ۲۰۲۳ شروع شده و آخرین commit آن در ۷ اکتبر ۲۰۲۶ ثبت شده است [1].

این ابزار از خانواده‌ی همان لایه‌ی تستی است که در پست ارزیابی کانتکست اسکیل‌ها برای پلاگین کلاد کد ساختیم، با یک تفاوت مهم: این‌جا مدل را شما انتخاب می‌کنید، پس یک پرامپت را می‌توانید بین چند مدل و چند نسخه مقایسه کنید.

دو چیز این ابزار را از یک اسکریپت ساده جدا می‌کند. اول اینکه خروجی‌اش یک JSON قابل ماشین‌خواندن است، نه یک جدول در ترمینال.

دوم اینکه کد خروجی‌اش با شکست غیر صفر برمی‌گردد، پس همان فرمان را مستقیم در CI می‌گذارید [4].

نصب و اولین ارزیابی در چهار خط

نصب در یک پوشه‌ی خالی، بدون نصب سراسری و بدون ساختن حساب کاربری. نسخه‌ای که در این پست نصب شد 0.124.0 است [8]. همان نسخه را صفحه‌ی بسته در npm هم نشان می‌دهد.

# یک پوشه‌ی تازه بساز و promptfoo را فقط داخل همان پوشه نصب کن
$ mkdir pf && cd pf
$ npm install promptfoo
$ npx promptfoo --version
0.124.0

# اجرای همان ارزیابی در هر خط لوله، با یک تصمیم
$ npx promptfoo eval; echo "exit=$?"
exit=100

عدد ۱۰۰ در خروجی بالا تصادفی نیست. مستندات خط فرمان می‌گویند وقتی دست‌کم یک تست شکست بخورد، promptfoo eval کد ۱۰۰ برمی‌گردارد و برای هر خطای دیگر کد ۱ را [4]. اگر این رفتار را نمی‌خواهید، با متغیر محیطی PROMPTFOO_FAILED_TEST_EXIT_CODE آن را عوض می‌کنید [4].

در ادامه یک ارزیابی واقعی می‌سازیم که نه به شبکه وصل می‌شود و نه به مدل. اول اسکریپت پاسخ‌دهنده، که نقش مدل را بازی می‌کند:

import sys

# پاسخ‌های نمونه برای سه موضوع پشتیبانی
BANK = {
    "billing": "صورتحساب ما از اول تا ۲۸ هر ماه صادر می‌شود.",
    "refund": "بازگشت وجه تا ۱۴ روز کاری بعد از خرید انجام می‌شود.",
    "hours": "پشتیبانی شنبه تا چهارشنبه از ۹ تا ۱۸ پاسخ می‌دهد.",
}
# اشتباه عمدی: ساعت کاری را ۲۴ ساعته اعلام می‌کنیم تا یک تست رد شود
WRONG = {"hours": "پشتیبانی ۲۴ ساعته و بدون تعطیلی پاسخ می‌دهد."}

prompt = sys.argv[1] if len(sys.argv) > 1 else ""
topic = prompt.strip().split()[-1] if prompt.strip() else ""

if topic in WRONG:
    print(WRONG[topic])
else:
    print(BANK.get(topic, "متوجه نشدم؛ لطفا سوال را واضح‌تر بپرسید."))

خط prompt = sys.argv[1] همان چیزی است که در مستندات آمده: یک پروبایدر اسکریپتی سه آرگومان می‌گیرد که اولی متن رندرشده‌ی پرامپت است، دومی JSON پیکربندی و سومی JSON متغیرهای تست است [3]. اسکریپت باید پرامپت را به‌عنوان آرگومان اول بگیرد و نتیجه را روی خروجی استاندارد بنویسد [3].

حالا فایل پیکربندی، که کوچک‌ترین بخش قابل فهم کار است:

prompts:
  - "سوال مشتری: {{q}}"

providers:
  # خروجی این دستور، پاسخ مدل فرض می‌شود
  - id: exec:python3 answer.py

tests:
  - description: زمان‌بندی پشتیبانی
    vars:
      q: "صورتحساب چه زمانی صادر می‌شود؟ billing"
    assert:
      - type: contains
        value: "۲۸"
      - type: not-contains
        value: "۲۴ ساعته"

  - description: شرایط بازگشت وجه
    vars:
      q: "چطور پولم را پس بگیرم؟ refund"
    assert:
      - type: icontains
        value: "۱۴ روز"

  - description: ساعت کاری، پاسخ عمدا غلط
    vars:
      q: "پشتیبانی کی بیدار است؟ hours"
    assert:
      - type: javascript
        value: output.includes("۹ تا ۱۸")

سه چیز در این فایل ارزش دیدن دارد. {{q}} همان متغیر تست است و مقدارش از هر بلوک vars می‌آید [7]. نوع‌های contains و icontains از assertهای قطعی‌اند، یعنی هیچ مدلی داوری نمی‌کند و نتیجه کاملا تکرارپذیر است [2]. سوم javascript است که تابع شما با متغیر output صدا زده می‌شود و باید true برگرداند [6].

خروجی چه می‌گوید و چه وقتی خطا می‌دهد

اجرای همین پیکربندی، همان چیزی را چاپ می‌کند که در پایین می‌بینید: دو تست سبز و یک تست قرمز، با کد خروجی ۱۰۰.

$ npx promptfoo eval --no-progress-bar
Running 3 test cases (up to 4 at a time)...

│ صورتحساب چه زمانی صادر می‌شود؟ billing │ [PASS] صورتحساب ما از اول تا ۲۸ هر ماه صادر می‌شود. │
│ چطور پولم را پس بگیرم؟ refund         │ [PASS] بازگشت وجه تا ۱۴ روز کاری بعد از خرید انجام می‌شود. │
│ پشتیبانی کی بیدار است؟ hours          │ [FAIL] پشتیبانی ۲۴ ساعته و بدون تعطیلی پاسخ می‌دهد. │

Results:
  ✓ 2 passed (66.67%)
  ✗ 1 failed (33.33%)
  0 errors (0%)

این‌جا دو عدد را از هم جدا کنید. دو از سه یعنی ۶۶٫۶۷ درصد، که از تقسیم دو بر سه به دست می‌آید. خطا صفر است، چون پروبایدر اجرا شده و فقط داوری شکست خورده، نه خود فرمان. همین تفاوت مهم است: یک ERROR یعنی اسکریپت شما جواب نداده و باید آن را درست کنید، یک FAIL یعنی پاسخ آمده ولی غلط بوده و باید مدل یا پرامپت را درست کنید.

همین اجرا یک فایل JSON هم می‌نویسد، اگر -o results.json بدهید. در آن فایل برای هر تست، gradingResult.pass و دلیل هر assert جداگانه ثبت می‌شود، و برای تست شکست‌خورده دلیل خطا عینا همین متن است: Custom function returned false output.includes("۹ تا ۱۸"). یعنی assert جاوااسکریپت اجرا شد و false برگرداند، نه اینکه اصلا اجرا نشده باشد.

فقط تست شکست‌خورده را دوباره اجرا کنید

وقتی دویست تست دارید، اجرای دوباره‌ی همه برای پیدا کردن یک خطا، همان چیزی است که تیم‌ها از تست کردن دست می‌کشند. promptfoo یک ورودی برای همین دارد: --filter-failing-only با یک فایل خروجی قبلی، فقط تست‌هایی را اجرا می‌کند که assert شکست خورده‌اند و خطاهای فنی را کنار می‌گذارد [4].

$ npx promptfoo eval --filter-failing-only results.json
Running 1 test cases (up to 4 at a time)...

│ پشتیبانی کی بیدار است؟ hours │ [FAIL] پشتیبانی ۲۴ ساعته و بدون تعطیلی پاسخ می‌دهد. │

Results:
  0 passed (0%)
  ✗ 1 failed (100%)
  0 errors (0%)

فقط یک تست از سه اجرا شد. اگر پس از اصلاح، دوباره همه‌ی تست‌ها را اجرا کنید، مطمئن می‌شوید که اصلاح شما دو تست دیگر را نشکسته است.

حالا همان یک خط اشتباه را در اسکریپت درست می‌کنیم. مقدار WRONG را خالی می‌گذاریم تا پاسخ غلط دیگر ساخته نشود:

$ sed -i 's/^WRONG = .*/WRONG = {}/' answer.py
$ npx promptfoo eval --no-progress-bar; echo "exit=$?"
Running 3 test cases (up to 4 at a time)...

│ صورتحساب چه زمانی صادر می‌شود؟ billing │ [PASS] صورتحساب ما از اول تا ۲۸ هر ماه صادر می‌شود. │
│ چطور پولم را پس بگیرم؟ refund         │ [PASS] بازگشت وجه تا ۱۴ روز کاری بعد از خرید انجام می‌شود. │
│ پشتیبانی کی بیدار است؟ hours          │ [PASS] پشتیبانی شنبه تا چهارشنبه از ۹ تا ۱۸ پاسخ می‌دهد. │

Results:
  ✓ 3 passed (100%)
  0 failed (0%)
  0 errors (0%)
exit=0

کد خروجی از ۱۰۰ به صفر رفت. این همان سیگنالی است که در CI به شما می‌گوید پرامپت بعدی را ادامه بده.

وقتی assert ثابت کافی نیست

assertهای قطعی برای متنی خوب‌اند که می‌دانید باید چه رشته‌ای در آن باشد. برای سنجش خوب بودن یک پاسخ، promptfoo نوع‌های دیگری هم دارد که خودشان یک مدل را داور می‌کنند. این دو دسته را در مرجع assertهای promptfoo می‌توانید کنار هم ببینید، و فهرست کامل providerهایی که می‌توانید داور بگذارید در مرجع پروبایدرها آمده است [5].

نوع assertداور با چه چیزیهزینه‌ی هر اجرا
contains و icontainsمقایسه‌ی رشته‌ای، بدون مدل۰
equals و regexالگوی ثابت، بدون مدل۰
javascript و pythonتابعی که خودتان می‌نویسید۰
llm-rubricیک مدل، در برابر یک محور نوشته‌شدهیک فراخوانی مدل
similarembedding و شباهت کسینوسیدو فراخوانی مدل
context-relevanceمدل، با آستانه‌ی پیش‌فرض ۰٫۵یک فراخوانی مدل

تفکیک بالا عملی است، چون سه ردیف اول را می‌توانید در خط لوله‌ی هر روز اجرا کنید و سه ردیف آخر را بهتر است فقط شب‌ها یا پیش از انتشار پرامپت بزنید. similar برای نزدیکی معنایی است و context-relevance برای سنجش مرتبط بودن زمینه‌ی بازیابی‌شده، و هر دو یک provider جدا می‌خواهند، پس کلید API و هزینه‌ی جدا دارند.

همین پست یک پست دیگر را کامل می‌کند که در آن به‌جای حدس زدن هزینه‌ی یک ایجنت، از تعداد واقعی context استفاده کردیم؛ آن مقاله در پست بودجه‌ی کانتکست توضیح داده شده است. آنجا هزینه‌ی هر توکن مسئله بود؛ این‌جا مسئله این است که آیا پاسخ اصلا درست است، پیش از آنکه درباره‌ی قیمتش حرف بزنید. تست کردن پیش از بهینه‌سازی، ترتیب درست کار است.

و یک نکته‌ی دوم که در اجرای این پست به آن خوردم: در پیکربندی، مسیر پروبایدر اسکریپتی را نسبت به پوشه‌ی همان فایل YAML حساب کنید. وقتی مسیر را از پوشه‌ی والد آدرس دادم، promptfoo فایل را از کنار خود YAML پیدا کرد و هر سه تست ERROR خورد. با یک مسیر نسبی ساده، هر سه تست اجرا شدند [3]. اگر دیدید همه‌ی تست‌ها ERROR می‌خورند و صفر پاسخ دارند، اول مسیر را درست کنید.

منابع

  1. ریپوی promptfoo روی گیت‌هاب
  2. مستندات assertها و متریک‌های promptfoo
  3. مستندات پروبایدر اسکریپتی در promptfoo
  4. مرجع خط فرمان promptfoo
  5. فهرست پروبایدرهای promptfoo
  6. مستندات assert جاوااسکریپتی
  7. مرجع پیکربندی promptfoo
  8. بسته‌ی promptfoo در npm