13. הערכת סוכנים

"אי אפשר לשפר מה שלא מודדים"

25 דקות קריאה
💡חדש בנושא?

מה זה eval של סוכן?

בדיקה מובנית שמודדת כמה טוב הסוכן שלכם מבצע משימה. בניגוד לבדיקות יחידה שבודקות פונקציה אחת, evals מודדים התנהגות מקצה לקצה: האם הסוכן השתמש בכלים הנכונים, השלים את המשימה, ונשאר בתקציב?

למה אי אפשר לבדוק סוכנים כמו קוד רגיל?

כי סוכנים הם לא דטרמיניסטיים — אותו פרומפט יכול לייצר רצפי קריאות כלים שונים. Evals מטפלים בזה על ידי בדיקת תוצאות (האם הקובץ נוצר נכון?) ולא צעדים מדויקים (האם הוא קרא ל-write_file בשורה 3?).

מה זה רובריקת ניקוד?

קבוצת קריטריונים שמגדירים הצלחה. לדוגמה: 'הקובץ קיים' (עבר/נכשל), 'הקובץ מכיל פונקציה נכונה' (עבר/נכשל), 'הושלם בפחות מ-5 קריאות כלים' (ציון יעילות). הרובריקה הופכת איכות סובייקטיבית למספרים מדידים.

הבעיה

בניתם סוכן. יש לו לולאה, כלים, תכנון, תת-סוכנים, מיומנויות, ניהול הקשר, משימות, ביצוע ברקע, צוותים, פרוטוקולים, אוטונומיה, ובידוד. אתם יכולים לצפות בו עובד וזה נראה מרשים. אבל האם הוא באמת טוב?

לא ניתן לענות על השאלה הזו על ידי צפייה. סוכנים הם לא דטרמיניסטיים — אותו פרומפט מייצר רצפי קריאות כלים שונים בהרצות שונות. שינוי בפרומפט המערכת שלכם עשוי לשפר יצירת קבצים אבל לשבור בשקט שכתוב קוד. לא תשימו לב עד שמשתמש ישים לב.

בדיקות יחידה מסורתיות לא עוזרות. אי אפשר לבדוק שהסוכן קרא ל-write_file בתור 3, כי מחר הוא עשוי לקרוא ל-bash בתור 2 ולקבל את אותה תוצאה. אתם צריכים בדיקות שבודקות תוצאות, לא צעדים.

הפתרון

רתמת eval. הגדירו תרחישים עם תוצאות ידועות מראש, הריצו את הסוכן בסנדבוקס, בדקו מה הוא ייצר, ודרגו את התוצאות.

הגדרת תרחיש  →  הרצת סוכן בסנדבוקס  →  בדיקת תוצאות  →  ניקוד  →  דוח

בניית רתמת ה-Eval

הליבה היא שני dataclasses ופונקציה אחת.

from dataclasses import dataclass
from typing import Callable
import tempfile, os

@dataclass
class EvalCase:
    name: str
    prompt: str
    check: Callable[[str], "EvalResult"]
    max_turns: int = 20
    max_tokens: int = 50000

@dataclass
class EvalResult:
    passed: bool
    score: float   # 0.0 to 1.0
    details: str

EvalCase הוא הקלט. EvalResult הוא הפלט. כל eval, לא משנה כמה מורכב, עומד בממשק הזה.

הרצה יוצרת סביבת עבודה מבודדת, מריצה את הסוכן, ומעבירה את סביבת העבודה לבודק:

def run_eval(case: EvalCase, agent_fn) -> EvalResult:
    workspace = tempfile.mkdtemp()
    messages = [{"role": "user", "content": case.prompt}]
    turns = 0
    total_tokens = 0

    while turns < case.max_turns:
        response = agent_fn(messages)
        total_tokens += response.usage.input_tokens + response.usage.output_tokens
        if total_tokens > case.max_tokens:
            return EvalResult(False, 0.0, "Token budget exceeded")
        if response.stop_reason != "tool_use":
            break
        execute_tools(response, messages, cwd=workspace)
        turns += 1

    return case.check(workspace)

הפרמטר cwd=workspace הוא קריטי. כל קריאת כלי מתבצעת בתוך התיקייה הזמנית. הסוכן יכול ליצור קבצים, להריץ פקודות ולשנות מצב — הכל מוגבל לסביבת העבודה הזו.

אסטרטגיות ניקוד

לא כל eval הוא עבר/נכשל. שלוש אסטרטגיות, עולות ברזולוציה:

עבר/נכשל בינארי

הפשוט ביותר. האם הקובץ קיים? האם הבדיקה עברה?

def check_file_exists(workspace):
    if os.path.exists(os.path.join(workspace, "output.txt")):
        return EvalResult(True, 1.0, "File created")
    return EvalResult(False, 0.0, "File missing")

ניקוד חלקי

הענקת נקודות עבור כל קריטריון שהתמלא:

def check_refactor(workspace):
    path = os.path.join(workspace, "math_utils.py")
    if not os.path.exists(path):
        return EvalResult(False, 0.0, "File not found")

    content = open(path).read()
    score = 0.0
    details = []

    if "def calculate_average" in content:
        score += 0.25
        details.append("PASS: function exists")

    if "def calculate_average(numbers: list" in content:
        score += 0.25
        details.append("PASS: type hints present")

    if '"""' in content or "'''" in content:
        score += 0.25
        details.append("PASS: docstring present")

    result = subprocess.run(
        ["python", "-c", f"import math_utils; print(math_utils.calculate_average([1,2,3]))"],
        capture_output=True, text=True, cwd=workspace
    )
    if result.returncode == 0 and "2" in result.stdout:
        score += 0.25
        details.append("PASS: correct output")

    return EvalResult(score >= 0.75, score, "; ".join(details))

התובנה המרכזית: קריטריון 4 באמת מריץ את הקוד שנוצר. בדיקת תוכן מחרוזת אומרת לכם שהסוכן כתב משהו שנראה נכון. הרצתו אומרת לכם שהוא נכון.

הרצת Evals בסקלה

from concurrent.futures import ThreadPoolExecutor
import json, time

def run_suite(cases: list[EvalCase], agent_fn, workers: int = 4) -> dict:
    results = {}
    start = time.time()

    with ThreadPoolExecutor(max_workers=workers) as pool:
        futures = {
            pool.submit(run_eval, case, agent_fn): case.name
            for case in cases
        }
        for future in futures:
            name = futures[future]
            try:
                results[name] = future.result(timeout=300)
            except Exception as e:
                results[name] = EvalResult(False, 0.0, f"Error: {e}")

    elapsed = time.time() - start
    passed = sum(1 for r in results.values() if r.passed)
    total_score = sum(r.score for r in results.values()) / len(results)

    return {
        "timestamp": time.strftime("%Y-%m-%dT%H:%M:%S"),
        "total": len(cases),
        "passed": passed,
        "avg_score": round(total_score, 3),
        "results": {
            name: {"passed": r.passed, "score": r.score, "details": r.details}
            for name, r in results.items()
        },
    }

מה השתנה מבידוד Worktree ומשימות

רכיבבידוד Worktreeהערכת סוכנים
מיקודבניית הסוכןמדידת הסוכן
סביבת עבודהGit worktree למשימהתיקייה זמנית ל-eval
קריטריון הצלחהמשימה סומנה כהושלמהפונקציית בדיקה מחזירה ציון
בידודמניעת הפרעה בין סוכניםמניעת דליפת מצב בין evals
פלטענף ממוזגדוח JSON עם ציונים

נקודה מרכזית

Evals סוגרים את הלולאה. בלעדיהם, כל שינוי בסוכן שלכם הוא ניחוש — אתם מקווים שהשתפר, אתם מניחים ששום דבר לא נשבר. עם רתמת eval, אתם יודעים. הגדירו תרחישים, כתבו בודקים, הריצו את החבילה, וקראו את הציונים. הסוכן טוב רק כמו היכולת שלכם למדוד אותו. עכשיו אתם יכולים למדוד.

מדריך קוד אינטראקטיבי

בניית רתמת Eval
1@dataclass
2class EvalCase:
3 name: str
4 prompt: str
5 check: Callable[[str], EvalResult]
6 max_turns: int = 20
7 max_tokens: int = 50000
8 
9@dataclass
10class EvalResult:
11 passed: bool
12 score: float # 0.0 to 1.0
13 details: str
14 
15def run_eval(case: EvalCase, agent_fn) -> EvalResult:
16 workspace = tempfile.mkdtemp()
17 messages = [{"role": "user", "content": case.prompt}]
18 turns = 0
19 total_tokens = 0
20 
21 while turns < case.max_turns:
22 response = agent_fn(messages)
23 total_tokens += response.usage.input_tokens + response.usage.output_tokens
24 if total_tokens > case.max_tokens:
25 return EvalResult(False, 0.0, "Token budget exceeded")
26 if response.stop_reason != "tool_use":
27 break
28 execute_tools(response, messages, cwd=workspace)
29 turns += 1
30 
31 return case.check(workspace)
32 
33# Example eval case
34def check_hello_world(workspace):
35 path = os.path.join(workspace, "hello.py")
36 if not os.path.exists(path):
37 return EvalResult(False, 0.0, "hello.py not found")
38 content = open(path).read()
39 if "print" in content and "Hello" in content:
40 return EvalResult(True, 1.0, "Correct")
41 return EvalResult(False, 0.5, "File exists but content wrong")
42 
EvalCase מגדיר תרחיש בדיקה אחד: פרומפט לשליחה לסוכן, פונקציית בדיקה, ומגבלות משאבים. המגבלות מונעות מסוכנים פרועים לשרוף טוקנים בזמן בדיקות.
שלב 1 מתוך 4
🧪 נסו בעצמכם
🔥 חימום ~5 min

חזו: אם תריצו את אותו eval 10 פעמים, האם הסוכן יקבל את אותו ציון בכל פעם? למה כן או למה לא?

רמז

חשבו על temperature, סדר ביצוע כלים לא דטרמיניסטי, ותזמון רשת.

🔨 בנייה ~20 min

כתבו 3 eval cases לסוכן מניפולציית קבצים: (1) יצירת קובץ, (2) קריאה וסיכום קובץ, (3) שכתוב פונקציה. כללו רובריקות ניקוד.

רמז

השתמשו ב-subprocess להריץ את הקוד שנוצר ולבדוק אם הוא באמת עובד, לא רק אם הוא נראה נכון.

🚀 אתגר ~45 min

בנו חבילת eval שמריצה N מקרים במקביל, אוספת ציונים לדוח JSON, ומסמנת רגרסיות כשציונים יורדים מתחת לבסיס.

רמז

השתמשו ב-concurrent.futures.ThreadPoolExecutor והשוו מול baseline.json שמור.

מצאתם טעות? דווחו ←