درس ۳ از ۱۱

Module 3

عنوان اصلی: Prompt Evaluation هدف یادگیری ماژول: ساخت یک حلقه‌ی تکرارشونده‌ی eval — از تعریف معیار موفقیت تا گرفتن نمره — که prompt را مثل کد تحت کنترل نگه می‌دارد و اجازه نمی‌دهد روی «حس درونی» تنظیم شود. مفاهیم کلیدی: prompt evaluation, eval, test dataset, held-out set, model-based grading, code-based grading, LLM-as-judge, regression, Batch API.

تا اینجای فصل، prompt را با چشم نگاه کردیم و گفتیم «خوب کار می‌کند». این روش برای prototype جواب می‌دهد، اما به محض اینکه قرار باشد چیزی به production برود، باید بتوانیم با یک عدد جواب بدهیم: «این prompt بهتر از prompt قبلی است یا نه؟» Module 3 دقیقاً همین را می‌سازد. مستندات رسمی Anthropic روی این جمله تاکید می‌کنند: «قبل از prompt-engineering، روی تعریف معیار موفقیت و ساختن eval وقت بگذارید.» بدون eval، prompt را روی hype تنظیم می‌کنید؛ با eval، روی داده.

۳.۱ The eval workflow — حلقه‌ی چهارمرحله‌ای

هدف: درونی‌کردن چرخه‌ی توصیه‌شده‌ی Anthropic: تعریف success → ساخت dataset → اجرای prompt → grade → iterate.

چهار مرحله. این loop ستون فقرات هر پروژه‌ی جدی AI است:

  1. تعریف success criteria. بنویسید «موفقیت» یعنی چه — به‌شکل specific و measurable. نه «خلاصه‌ها خوب هستند» بلکه «روی یک test set دویست‌مقاله‌ای، ROUGE-L F1 ≥ 0.45، p95 latency < 800ms، نرخ refusal کمتر از ۱٪».
  2. ساخت یا جمع‌آوری test dataset. یک مجموعه‌ی held-out که هیچ‌گاه در زمان prompt-engineering نگاه نمی‌شود.
  3. اجرای prompt و گرفتن نمره. prompt فعلی را روی دیتاست بدوانید و خروجی‌ها را grade کنید (با code یا با مدل).
  4. Iterate. فقط prompt را عوض کنید — نه dataset، نه threshold، نه model — و loop را تکرار کنید تا به bar برسید. سپس ship کنید و در production مراقب drift باشید.

معیار خوب چه شکلی است؟ عملیاتی است، با عدد و واحد. معیار بد، کپی marketing است:

  • خوب: «دقت طبقه‌بندی روی ۲۰۰ نمونه‌ی متعادل ≥ ۸۵٪، latency p95 < ۸۰۰ms، هزینه‌ی هر request < ۱ سنت.»
  • بد: «پاسخ‌ها مفید و دقیق و حرفه‌ای باشند.»

نمونه کد. اسکلت loop که در ادامه‌ی ماژول پر می‌شود:

import anthropic, json
client = anthropic.Anthropic()

CRITERIA = {"min_accuracy": 0.85, "p95_latency_ms": 800}

def run_eval(prompt_template, dataset):
    results = []
    for ex in dataset:
        out = client.messages.create(
            model="claude-haiku-4-5", max_tokens=64, temperature=0,
            messages=[{"role": "user", "content": prompt_template.format(**ex)}],
        ).content[0].text.strip()
        results.append({"input": ex, "output": out, "expected": ex["expected"]})
    accuracy = sum(r["output"].lower() == r["expected"].lower() for r in results) / len(results)
    return {"accuracy": accuracy, "results": results}

print(run_eval("Classify sentiment: {text}", json.load(open("test.json"))))

اشتباهات رایج.

  • تغییر دادن dataset بین iteration ها — نتایج دیگر قابل‌مقایسه نیستند.
  • گزارش فقط mean (بدون p95 latency) — یک کاربر بدشانس همیشه ناراضی است.
  • شاد شدن از دقت ۹۵٪ روی datasetای که ۹۰٪ آن از یک کلاس است.

۳.۲ Building test datasets — ساخت test dataset

هدف: ساختن یک held-out set که نمونه‌های نرمال، edge case ها و ورودی‌های adversarial را پوشش بدهد.

سه اصل. یک test dataset، قراردادی است که با Morteza-ی شش‌ماه‌بعد می‌بندید:

  1. حجم بر کیفیت ظاهری ارجح است. ۲۰۰ نمونه‌ی متوسط بهتر از ۲۰ نمونه‌ی بی‌نقص است. تنوع، signal تولید می‌کند.
  2. Stratified by failure mode. برای classification احساسات، عمداً sarcasm، double-negation، نظر mixed، و متن code-switched (مثلاً فارسی + انگلیسی) بگذارید. اگر کلاس‌ها نامتعادل باشند، یک constant predictor به دقت ظاهری بالا می‌رسد.
  3. Held out. هرگز روی این dataset prompt-engineering نکنید، نمونه‌ها را در few-shot نگذارید، حتی نخوانیدش. فقط روی آن نمره می‌گیرید.

Bootstrapping با Claude. Anthropic توصیه می‌کند ۲۰ تا ۳۰ نمونه را دستی بنویسید و سپس از Claude بخواهید با rubric مشخص و few-shot examples، نمونه‌های بیشتری بسازد. اما همیشه قبل از اضافه کردن به gold set، یک انسان (یا مدل قوی دیگر) آنها را verify کند — چون synthetic data، biases مولد را به ارث می‌برد.

نمونه کد.

seed = [
    {"text": "Loved the camera!", "expected": "positive"},
    {"text": "Absolutely the worst purchase.", "expected": "negative"},
    {"text": "It works, I guess.", "expected": "neutral"},
]
resp = client.messages.create(
    model="claude-sonnet-4-6", max_tokens=2048, temperature=1,   # sampling params: 4.6-era models only
    system="You are a test-data generator. Output JSON list of {text, expected}.",
    messages=[{"role": "user", "content": json.dumps({
        "seed": seed,
        "request": "Generate 30 more sentiment examples. Include 5 sarcasm, "
                   "5 mixed-sentiment, 5 Persian-language, 5 with typos, 10 normal."
    })}],
    output_config={"format": {"type": "json_schema", "schema": {
        "type": "array",
        "items": {"type": "object",
                  "properties": {"text": {"type": "string"},
                                 "expected": {"type": "string", "enum": ["positive","negative","neutral","mixed"]}},
                  "required": ["text","expected"], "additionalProperties": False}
    }}},
)

اشتباهات رایج.

  • Test-set leakage: کپی کردن نمونه‌های test در few-shot prompt — دقت تصنعی بالا می‌رود و در production فرو می‌ریزد.
  • عدم versioning: اگر dataset را با تاریخ یا hash تگ نکنید، نتایج هفته‌ی پیش با این هفته قابل‌مقایسه نیستند.
  • کلاس‌های نامتعادل: ۹۰٪ کلاس positive یعنی یک مدل که همیشه «positive» می‌گوید، ۹۰٪ دقت می‌گیرد.

۳.۳ Running evals — اجرای موازی

هدف: اجرای prompt روی کل dataset به‌شکل parallel و جمع‌آوری metric ها.

چرا parallel؟ یک eval هزارنمونه‌ای با ۱ ثانیه برای هر نمونه، می‌شود ۱۷ دقیقه‌ی serial. این برای iteration دستی غیرقابل تحمل است. دو راه:

  • Concurrent: با asyncio.gather یا concurrent.futures، معمولاً ۵ تا ۲۰ worker — متناسب با rate limit tier شما.
  • Batch API: برای dataset های بزرگ‌تر از ۱۰هزار نمونه، endpoint رسمی POST /v1/messages/batches را استفاده کنید. یک JSONL از request ها submit می‌کنید، poll می‌کنید، تا ۲۴ ساعت بعد نتیجه می‌گیرید — با ۵۰٪ تخفیف قیمت.

همیشه هر run را با prompt version + git sha + dataset hash تگ کنید. شش ماه بعد که می‌خواهید بفهمید کدام prompt چه نمره گرفت، فقط همین تگ‌ها شما را نجات می‌دهند.

نمونه کد — async.

import anthropic, asyncio
from anthropic import AsyncAnthropic
aclient = AsyncAnthropic()

async def grade_one(ex, prompt):
    resp = await aclient.messages.create(
        model="claude-haiku-4-5", max_tokens=32, temperature=0,
        messages=[{"role": "user", "content": prompt.format(**ex)}],
    )
    return resp.content[0].text.strip().lower() == ex["expected"].lower()

async def run(dataset, prompt, parallelism=10):
    sem = asyncio.Semaphore(parallelism)
    async def bounded(ex):
        async with sem: return await grade_one(ex, prompt)
    results = await asyncio.gather(*(bounded(e) for e in dataset))
    return sum(results) / len(results)

print(asyncio.run(run(test_set, "Classify: {text} -> positive/negative/neutral.")))

نمونه کد — Batch API.

batch = client.messages.batches.create(requests=[
    {"custom_id": ex["id"], "params": {
        "model": "claude-haiku-4-5", "max_tokens": 32, "temperature": 0,
        "messages": [{"role": "user", "content": prompt.format(**ex)}],
    }}
    for ex in test_set
])
# poll batch.id, then download results JSONL

اشتباهات رایج.

  • شلیک به API بیشتر از RPM/TPM tier — ارور ۴۲۹ می‌گیرید. parallelism را بر اساس tier ست کنید.
  • فراموش کردن temperature=0 در eval — نتایج run-to-run jitter می‌کند و نمی‌فهمید بهبود واقعی است یا شانس.
  • ذخیره نکردن latency هر request — وقتی متوجه می‌شوید p95 خراب است که در production هستید.

۳.۴ Model-based grading — استفاده از Claude به‌عنوان judge

هدف: استفاده از یک مدل قوی (LLM-as-judge) برای نمره دادن به خروجی‌های open-ended که کد نمی‌تواند آنها را grade کند.

چه وقت لازم می‌شود؟ code-based grading برای وظایف open-ended شکست می‌خورد — خلاصه‌ها، توضیح‌ها، نوشته‌ی خلاقانه، پاسخ به سوالات customer support. در این موارد، model-based grading جایگزین می‌شود: به یک مدل قوی، ورودی + خروجی نامزد + rubric شفاف می‌دهیم، از او می‌خواهیم در یک بلاک <thinking> استدلال کند، سپس verdict را در <result> بنویسد (یا یک نمره‌ی ۱ تا ۵ Likert).

دو قانون hygiene که نقض آنها eval شما را بی‌اعتبار می‌کند.

  1. با مدل متفاوت grade کنید. اگر همان مدلی که خروجی را تولید کرده، خودش grade کند، یک positivity bias به سبک خودش پیدا می‌کند. حداقل از یک prompt متفاوت استفاده کنید، ترجیحاً مدل متفاوت.
  2. برای زبان غیرانگلیسی، rubric را به همان زبان بنویسید. اگر خروجی فارسی است و rubric انگلیسی، judge ممکن است در سکوت پاسخ‌های فارسی را پایین‌تر نمره بدهد.

نمونه کد. verbatim از صفحه‌ی Develop tests در docs.claude.com:

import anthropic
client = anthropic.Anthropic()

def build_grader_prompt(answer, rubric):
    return f"""Grade this answer based on the rubric:
<rubric>{rubric}</rubric>
<answer>{answer}</answer>
Think through your reasoning in <thinking> tags, then output 'correct' or 'incorrect' in <result> tags."""

def grade(output, rubric):
    resp = client.messages.create(
        model="claude-opus-4-8", max_tokens=1024,
        messages=[{"role": "user", "content": build_grader_prompt(output, rubric)}],
    ).content[0].text
    return "correct" if "<result>correct</result>" in resp.lower() else "incorrect"

اشتباهات رایج.

  • grade کردن با همان مدل + همان prompt که خروجی را تولید کرد — bias خودشیفتگی.
  • rubric های نرم («آیا پاسخ خوب است؟») — یک یک‌خطی هیچ سیگنالی تولید نمی‌کند.
  • اجازه دادن به judge برای خروجی free-form — parsing کابوس می‌شود. همیشه فرمت محدود (correct/incorrect یا عدد ۱-۵) بخواهید.

۳.۵ Code-based grading — نمره‌ی deterministic

هدف: استفاده از exact match، regex، ROUGE/BLEU و cosine similarity برای grading سریع، ارزان و قابل‌تکرار.

چهار سطح code-based grading. این روش ارزان‌ترین، سریع‌ترین و قابل‌تکرارترین گزینه است — هر جا فضای پاسخ به اندازه‌ی کافی محدود است، اولویت با این روش است:

  1. Exact match: برای classification ("positive" == output.strip().lower()). در مواردی که فضای پاسخ یک مجموعه‌ی محدود است، بی‌رقیب.
  2. Substring / regex: برای fact-extraction («باید SKU شماره X را شامل شود»، «باید با IRR شروع شود»).
  3. ROUGE-L F1: برای summarization. بر مبنای longest common subsequence است و اطلاعات کلیدی به‌ترتیب را capture می‌کند.
  4. Cosine similarity روی embeddings: برای semantic equivalence (تشخیص paraphrase، یکپارچگی FAQ). از Voyage یا SBERT استفاده کنید.

استراتژی عملی: ترکیب code-based + model-based. کد ۸۰٪ موارد ساده را ارزان grade می‌کند، LLM-judge فقط ۲۰٪ سخت را.

نمونه کد.

from rouge import Rouge

def grade_summary(generated, reference):
    return Rouge().get_scores(generated, reference)[0]["rouge-l"]["f"]

# Cosine similarity over embeddings (Voyage)
import voyageai, numpy as np
vo = voyageai.Client()

def cos_sim(a, b):
    e1, e2 = vo.embed([a, b], model="voyage-4", input_type="document").embeddings
    return float(np.dot(e1, e2))   # normalized

print(cos_sim("How do I return an item?", "What is your return policy?"))

اشتباهات رایج.

  • BLEU برای جمله‌های کوتاه — bias منفی شدید روی short text.
  • گزارش فقط ROUGE-L بدون نگاه کردن به نمونه‌های failure — یک نمره‌ی بالا می‌تواند hallucination را پنهان کند.
  • استفاده از هر embedding model که دم دست بود — برای دامنه‌ی finance یا medical، model اختصاصی (voyage-finance-2) سیگنال بهتری می‌دهد.

۳.۶ Eval در CI — prompt-as-code

هدف: تبدیل eval به یک test اتوماتیک که در GitHub Actions روی هر PR اجرا می‌شود و build را در صورت regression می‌شکند.

ایده‌ی محوری. prompt را مثل کد رفتار کنید. eval harness را در همان repo نگه دارید. هر PR که prompts/ را تغییر می‌دهد، harness را trigger می‌کند. اگر دقت زیر threshold بیاید یا هزینه از سقف بگذرد، build قرمز می‌شود. نتیجه را به‌عنوان JSON artifact ذخیره کنید و در PR comment بگذارید.

سه چیز که نباید فراموش کنید.

  • API key محدود به workspace کم‌خرج. اگر CI کلید production را داشته باشد، یک loop خراب می‌تواند صدها دلار بسوزاند.
  • Pin model snapshot. به‌جای claude-haiku-4-5 ساده، snapshot دقیق را pin کنید. وقتی alias آپدیت می‌شود، نتایج ساکت تغییر می‌کنند.
  • Budget cap. قبل از run، یک سقف cost ست کنید (مثلاً «اگر بیش از ۲ دلار خرج شد، abort کن»).

نمونه کد.

# tests/run_eval.py
import json, sys, anthropic
client = anthropic.Anthropic()

THRESHOLD = 0.85
data = [json.loads(l) for l in open("tests/eval_data.jsonl")]
correct = 0
for ex in data:
    out = client.messages.create(
        model="claude-haiku-4-5", max_tokens=16, temperature=0,
        messages=[{"role": "user", "content": f"Sentiment of: {ex['text']}"}],
    ).content[0].text.strip().lower()
    correct += out == ex["expected"]
acc = correct / len(data)
print(f"accuracy={acc:.3f}")
sys.exit(0 if acc >= THRESHOLD else 1)

این فایل را در GitHub Actions روی هر push اجرا می‌کنید. اگر دقت زیر ۰.۸۵ بیاید، exit code ۱ می‌شود و PR قرمز می‌شود.