iT邦幫忙

2026 iThome 鐵人賽

DAY 12
0

前言

Day 11 我們完成了 Batch Evaluation Runner。

目前 runner 已經可以:

讀取 evals/cases.json
  -> 逐筆呼叫 Agent
  -> 儲存 Agent output
  -> 保存 trace
  -> 輸出 eval_run_*.json

但 Day 11 還沒有真正判斷 Agent 是否答對。

也就是說,目前結果只會告訴我們:

case_001 completed
case_002 completed
case_003 completed

但不會告訴我們:

case_001 passed
case_002 failed
case_003 passed

所以 Day 12 開始加入最基本的自動評分。

今天先不做複雜評分,也不做 LLM-as-a-Judge,只實作兩種最容易理解的 rule-based evaluator:

  • exact_match
  • contains

今天要完成什麼?

會完成:

  1. 新增 evals/evaluators.py。
  2. 實作 exact_match。
  3. 實作 contains。
  4. 讓 evaluator 回傳 pass / fail。
  5. 記錄 failure reason。
  6. 修改 evals/runner.py,讓 batch run 結果包含評分結果。
  7. 在終端機顯示通過題數。

今天先不做:

  • JSON schema validation。
  • LLM-as-a-Judge。
  • Failure type 分類。
  • Evaluation dashboard。

其中 json_exact 會在 Day 13 處理。今天如果遇到 json_exact,會先標記成尚未支援。


為什麼先做 Rule-based Evaluator?

Evaluator 的工作是:

根據 test case 的 expected 和 grading_method,判斷 Agent 的 actual 是否符合預期。

最直覺的評分方式是 rule-based。

例如:

expected: 3780
actual: The result is 3780
grading_method: contains

只要 actual 裡面包含 3780,就可以判定通過。

又例如:

expected: OK
actual: OK
grading_method: exact_match

如果兩者完全相同,就判定通過。

這種方法雖然簡單,但很適合 MVP 階段:

  • 實作容易。
  • 結果穩定。
  • 不需要額外 LLM 成本。
  • 適合明確答案或格式固定的任務。

缺點是它不適合評估開放式回答品質。

例如:

請說明什麼是 AI Agent

這種題目很難只靠字串比對完整評估。目前先用關鍵字檢查做最小版本。


今天的專案結構

今天新增 evals/evaluators.py,並修改 Day 11 的 evals/runner.py。

agent-testing-platform/
  evals/
    __init__.py
    cases.json
    runner.py
    evaluators.py

新增:

檔案 用途
evals/evaluators.py 放自動評分邏輯

修改:

檔案 修改內容
evals/runner.py 在每筆結果中加入 passed 和 failure_reason

設計 EvaluationResult

新增 evals/evaluators.py:

from dataclasses import dataclass
from typing import Any


@dataclass
class EvaluationResult:
    passed: bool
    failure_reason: str | None = None

EvaluationResult 是 evaluator 的回傳結果。

目前先放兩個欄位:

欄位 說明
passed 這一題是否通過
failure_reason 如果失敗,記錄原因

例如通過時:

EvaluationResult(passed=True)

失敗時:

EvaluationResult(
    passed=False,
    failure_reason="Expected output to contain '3780'"
)

之後第三週做 Failure Analysis 時,會再加入更完整的 failure_type。


實作 exact_match

繼續修改 evals/evaluators.py,新增 evaluate_exact_match():

def evaluate_exact_match(expected: Any, actual: str | None) -> EvaluationResult:
    if actual is None:
        return EvaluationResult(
            passed=False,
            failure_reason="Actual output is None",
        )

    expected_text = str(expected).strip()
    actual_text = actual.strip()

    if actual_text == expected_text:
        return EvaluationResult(passed=True)

    return EvaluationResult(
        passed=False,
        failure_reason=f"Expected exactly '{expected_text}', but got '{actual_text}'",
    )

exact_match 是最嚴格的比對方式。

它要求 Agent 的輸出和 expected 完全相同。

例如 test case:

{
  "id": "case_011",
  "input": "請只回覆 OK",
  "expected": "OK",
  "grading_method": "exact_match"
}

如果 Agent 回答:

OK

就會通過。

但如果回答:

好的,OK

就會失敗。

因為題目要求的是「只回覆 OK」。

這種評分方式適合測試 Agent 是否能嚴格遵守指令。


實作 contains

繼續修改 evals/evaluators.py,新增 evaluate_contains():

def evaluate_contains(expected: Any, actual: str | None) -> EvaluationResult:
    if actual is None:
        return EvaluationResult(
            passed=False,
            failure_reason="Actual output is None",
        )

    expected_text = str(expected).strip()

    if expected_text in actual:
        return EvaluationResult(passed=True)

    return EvaluationResult(
        passed=False,
        failure_reason=f"Expected output to contain '{expected_text}', but got '{actual}'",
    )

contains 比 exact_match 寬鬆。

例如:

{
  "id": "case_001",
  "input": "請計算 135 * 28",
  "expected": "3780",
  "grading_method": "contains"
}

目前 Agent 可能回答:

The result is 3780

雖然它不等於 "3780",但裡面包含 "3780",所以可以判定通過。

這種方式適合:

  • 計算題。
  • 關鍵字問答。
  • 簡單概念題。

不過它也有缺點。

例如 Agent 回答:

答案不是 3780

這其實是錯的,但因為包含 3780,contains 仍然會判定通過。

所以 contains 只是 MVP 階段的簡單評分方式,不是完美的語意判斷。


建立統一 evaluate function

最後在 evals/evaluators.py 加入 evaluate():

def evaluate(test_case: dict, actual: str | None) -> EvaluationResult:
    grading_method = test_case["grading_method"]
    expected = test_case["expected"]

    if grading_method == "exact_match":
        return evaluate_exact_match(expected, actual)

    if grading_method == "contains":
        return evaluate_contains(expected, actual)

    return EvaluationResult(
        passed=False,
        failure_reason=f"Unsupported grading method: {grading_method}",
    )

這個 function 是 runner 會呼叫的入口。

它會根據 test case 裡的 grading_method 決定要使用哪個 evaluator。

目前支援:

  • exact_match
  • contains

如果遇到其他方法,例如 Day 10 放進 dataset 的 json_exact,今天會先回傳失敗:

Unsupported grading method: json_exact

這是刻意留下的。

因為 Day 13 才會處理 JSON 格式驗證。


evaluators.py 完整版本

新增 evals/evaluators.py 的完整內容如下:

from dataclasses import dataclass
from typing import Any


@dataclass
class EvaluationResult:
    passed: bool
    failure_reason: str | None = None


def evaluate_exact_match(expected: Any, actual: str | None) -> EvaluationResult:
    if actual is None:
        return EvaluationResult(
            passed=False,
            failure_reason="Actual output is None",
        )

    expected_text = str(expected).strip()
    actual_text = actual.strip()

    if actual_text == expected_text:
        return EvaluationResult(passed=True)

    return EvaluationResult(
        passed=False,
        failure_reason=f"Expected exactly '{expected_text}', but got '{actual_text}'",
    )


def evaluate_contains(expected: Any, actual: str | None) -> EvaluationResult:
    if actual is None:
        return EvaluationResult(
            passed=False,
            failure_reason="Actual output is None",
        )

    expected_text = str(expected).strip()

    if expected_text in actual:
        return EvaluationResult(passed=True)

    return EvaluationResult(
        passed=False,
        failure_reason=f"Expected output to contain '{expected_text}', but got '{actual}'",
    )


def evaluate(test_case: dict, actual: str | None) -> EvaluationResult:
    grading_method = test_case["grading_method"]
    expected = test_case["expected"]

    if grading_method == "exact_match":
        return evaluate_exact_match(expected, actual)

    if grading_method == "contains":
        return evaluate_contains(expected, actual)

    return EvaluationResult(
        passed=False,
        failure_reason=f"Unsupported grading method: {grading_method}",
    )

這個檔案目前很小,但它把評分邏輯從 runner 中拆出來。

後面要加入 json_exact、LLM-as-a-Judge 或其他評分方法時,就不需要把 evals/runner.py 改得很混亂。


修改 runner.py:加入評分結果

接著修改 evals/runner.py。

先在 import 區塊加入 evaluator:

from evals.evaluators import evaluate

也就是 evals/runner.py 開頭會變成:

import json
from datetime import datetime
from pathlib import Path
from typing import Any

from agents.fake_llm import FakeLLMClient
from agents.simple_agent import SimpleAgent
from evals.evaluators import evaluate
from storage.database import init_db, save_trace

接著修改 run_evaluation() 裡成功執行的區塊。

原本 Day 11 是這樣:

result = agent.run(test_case["input"])
save_trace(result.trace)

results.append(
    {
        "case_id": test_case["id"],
        "input": test_case["input"],
        "expected": test_case["expected"],
        "grading_method": test_case["grading_method"],
        "task_type": test_case["task_type"],
        "status": "completed",
        "actual": result.answer,
        "trace_session_id": result.trace.session_id,
        "error": None,
    }
)

現在修改成:

result = agent.run(test_case["input"])
save_trace(result.trace)

evaluation = evaluate(test_case, result.answer)

results.append(
    {
        "case_id": test_case["id"],
        "input": test_case["input"],
        "expected": test_case["expected"],
        "grading_method": test_case["grading_method"],
        "task_type": test_case["task_type"],
        "status": "completed",
        "actual": result.answer,
        "passed": evaluation.passed,
        "failure_reason": evaluation.failure_reason,
        "trace_session_id": result.trace.session_id,
        "error": None,
    }
)

這裡新增了三行重點:

evaluation = evaluate(test_case, result.answer)

以及 result 裡的:

"passed": evaluation.passed,
"failure_reason": evaluation.failure_reason,

也就是說,每一筆 test case 跑完後,runner 會立刻根據 grading_method 做最基本評分。


修改錯誤結果格式

接著修改 evals/runner.py 中 except 的結果格式。

原本錯誤時只記錄:

"status": "error",
"actual": None,
"trace_session_id": None,
"error": str(exc),

現在改成:

results.append(
    {
        "case_id": test_case["id"],
        "input": test_case["input"],
        "expected": test_case["expected"],
        "grading_method": test_case["grading_method"],
        "task_type": test_case["task_type"],
        "status": "error",
        "actual": None,
        "passed": False,
        "failure_reason": "Agent execution error",
        "trace_session_id": None,
        "error": str(exc),
    }
)

這樣即使 Agent 執行過程發生 exception,evaluation result 仍然會有一致欄位:

  • passed
  • failure_reason

這對後面統計會比較方便。


修改 print_summary:顯示通過題數

最後修改 evals/runner.py 的 print_summary()。

原本只顯示 case id、task type、status 和 trace。

現在改成:

def print_summary(eval_run: dict[str, Any]) -> None:
    passed_count = sum(1 for result in eval_run["results"] if result["passed"])
    total_cases = eval_run["total_cases"]

    print(f"Run ID: {eval_run['run_id']}")
    print(f"Total cases: {total_cases}")
    print(f"Passed: {passed_count}")
    print(f"Failed: {total_cases - passed_count}")
    print()

    for result in eval_run["results"]:
        label = "PASS" if result["passed"] else "FAIL"
        print(
            f"{result['case_id']} | "
            f"{result['task_type']} | "
            f"{label} | "
            f"{result['status']} | "
            f"trace={result['trace_session_id']}"
        )

        if result["failure_reason"]:
            print(f"  reason: {result['failure_reason']}")

現在終端機就能看到更有意義的結果:

Run ID: eval_run_20260906_110000
Total cases: 15
Passed: 4
Failed: 11

case_001 | calculation | PASS | completed | trace=...
case_011 | instruction_following | FAIL | completed | trace=...
  reason: Expected exactly 'OK', but got 'Fake response for: 請只回覆 OK'

這是我們第一次讓平台自動回答:

Agent 到底有沒有完成任務?


runner.py 主要修改後的樣子

修改 evals/runner.py 後,重點版本如下:

import json
from datetime import datetime
from pathlib import Path
from typing import Any

from agents.fake_llm import FakeLLMClient
from agents.simple_agent import SimpleAgent
from evals.evaluators import evaluate
from storage.database import init_db, save_trace


BASE_DIR = Path(__file__).resolve().parent.parent
CASES_PATH = BASE_DIR / "evals" / "cases.json"
EVAL_RUNS_DIR = BASE_DIR / "data" / "eval_runs"


def load_cases() -> list[dict[str, Any]]:
    with CASES_PATH.open(encoding="utf-8") as file:
        return json.load(file)


def create_run_id() -> str:
    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    return f"eval_run_{timestamp}"


def run_evaluation() -> dict[str, Any]:
    init_db()

    agent = SimpleAgent(llm_client=FakeLLMClient())
    cases = load_cases()
    run_id = create_run_id()
    results = []

    for test_case in cases:
        try:
            result = agent.run(test_case["input"])
            save_trace(result.trace)

            evaluation = evaluate(test_case, result.answer)

            results.append(
                {
                    "case_id": test_case["id"],
                    "input": test_case["input"],
                    "expected": test_case["expected"],
                    "grading_method": test_case["grading_method"],
                    "task_type": test_case["task_type"],
                    "status": "completed",
                    "actual": result.answer,
                    "passed": evaluation.passed,
                    "failure_reason": evaluation.failure_reason,
                    "trace_session_id": result.trace.session_id,
                    "error": None,
                }
            )
        except Exception as exc:
            results.append(
                {
                    "case_id": test_case["id"],
                    "input": test_case["input"],
                    "expected": test_case["expected"],
                    "grading_method": test_case["grading_method"],
                    "task_type": test_case["task_type"],
                    "status": "error",
                    "actual": None,
                    "passed": False,
                    "failure_reason": "Agent execution error",
                    "trace_session_id": None,
                    "error": str(exc),
                }
            )

    eval_run = {
        "run_id": run_id,
        "created_at": datetime.now().isoformat(),
        "total_cases": len(cases),
        "results": results,
    }

    save_eval_run(eval_run)
    return eval_run


def save_eval_run(eval_run: dict[str, Any]) -> Path:
    EVAL_RUNS_DIR.mkdir(parents=True, exist_ok=True)
    output_path = EVAL_RUNS_DIR / f"{eval_run['run_id']}.json"

    with output_path.open("w", encoding="utf-8") as file:
        json.dump(eval_run, file, ensure_ascii=False, indent=2)

    return output_path


def print_summary(eval_run: dict[str, Any]) -> None:
    passed_count = sum(1 for result in eval_run["results"] if result["passed"])
    total_cases = eval_run["total_cases"]

    print(f"Run ID: {eval_run['run_id']}")
    print(f"Total cases: {total_cases}")
    print(f"Passed: {passed_count}")
    print(f"Failed: {total_cases - passed_count}")
    print()

    for result in eval_run["results"]:
        label = "PASS" if result["passed"] else "FAIL"
        print(
            f"{result['case_id']} | "
            f"{result['task_type']} | "
            f"{label} | "
            f"{result['status']} | "
            f"trace={result['trace_session_id']}"
        )

        if result["failure_reason"]:
            print(f"  reason: {result['failure_reason']}")


if __name__ == "__main__":
    eval_run = run_evaluation()
    print_summary(eval_run)

這段是 Day 11 runner 的延伸版本。

它仍然維持 runner 的主要責任:

讀取 cases -> 執行 Agent -> 保存 trace -> 輸出結果

只是現在多了 evaluator:

Agent output -> evaluate() -> passed / failure_reason

執行看看

在專案根目錄執行:

python3 -m evals.runner

預期會看到類似結果:

Run ID: eval_run_20260906_110000
Total cases: 15
Passed: 7
Failed: 8

case_001 | calculation | PASS | completed | trace=...
case_002 | calculation | PASS | completed | trace=...
case_003 | calculation | PASS | completed | trace=...
case_004 | calculation | PASS | completed | trace=...
case_005 | keyword_qa | FAIL | completed | trace=...
  reason: Expected output to contain '台北', but got 'Fake response for: 請回答台灣的首都是哪裡'
case_011 | instruction_following | FAIL | completed | trace=...
  reason: Expected exactly 'OK', but got 'Fake response for: 請只回覆 OK'
case_013 | json_output | FAIL | completed | trace=...
  reason: Unsupported grading method: json_exact

實際通過數量可能會因為你的 FakeLLMClient 實作不同而有所差異。

不過可以預期幾件事:

  • calculation 題比較可能通過。
  • exact_match 題可能失敗。
  • keyword_qa 題可能失敗。
  • json_output 題今天會因為 json_exact 尚未支援而失敗。

這些失敗都不是壞事。

因為 Evaluation 的目的不是讓每題都通過,而是開始看見 Agent 的能力邊界。


檢查 eval run JSON

執行後,會產生新的 eval run 檔案:

data/eval_runs/eval_run_*.json

可以使用:

ls data/eval_runs

找到最新檔案後,再用:

python3 -m json.tool data/eval_runs/eval_run_20260906_110000.json

實際檔名請換成你自己的檔案名稱。

你會看到每筆 result 多了:

{
  "passed": true,
  "failure_reason": null
}

或:

{
  "passed": false,
  "failure_reason": "Expected output to contain '台北', but got 'Fake response for: 請回答台灣的首都是哪裡'"
}

這表示 batch run 結果已經不只是原始輸出,而是開始包含評分資訊。


今天的評分還很粗糙

今天的 evaluator 很簡單,所以它有明顯限制。

1. contains 可能誤判

例如:

expected: 3780
actual: 答案不是 3780

這會被 contains 判定通過。

但語意上其實是錯的。

2. exact_match 太嚴格

例如:

expected: OK
actual: OK.

只差一個句點,也會被判定失敗。

這對某些任務是合理的,但對某些任務可能太嚴格。

3. json_exact 尚未支援

Day 10 的 dataset 裡已經放了 json_output 題,但今天還沒有處理 JSON 格式。

所以這些題目今天會失敗:

Unsupported grading method: json_exact

這是預期結果。

Day 13 會專門處理 JSON validation。


今天完成後的系統狀態

今天完成後,系統具備:

  • evals/evaluators.py。
  • EvaluationResult。
  • exact_match evaluator。
  • contains evaluator。
  • Batch runner 可以記錄 passed。
  • Batch runner 可以記錄 failure_reason。
  • 終端機可以顯示通過題數與失敗原因。
  • eval run JSON 會保存評分結果。

目前還沒有:

  • JSON 格式驗證。
  • success rate 百分比。
  • task type accuracy。
  • failure type 分類。
  • evaluation dashboard。

今天的重點整理

Day 11 讓 eval dataset 可以被批次執行。

Day 12 則讓 batch run 開始有自動評分能力。

今天最重要的流程是:

test_case
  -> Agent output
  -> evaluate(test_case, actual)
  -> EvaluationResult
  -> passed / failure_reason

這表示平台已經開始回答:

Agent 有沒有完成任務?

雖然目前評分方式很簡單,但它已經足夠讓我們看到 baseline Agent 的初步表現。


下一步

Day 13 會加入 JSON 格式驗證。

目前 json_exact 還沒有支援,所以 JSON output 題會被標記為失敗。

下一篇會處理:

  • 如何解析 Agent 的 JSON output。
  • 如何檢查 required fields。
  • 如何比較 JSON 欄位值。
  • 如何把格式錯誤記錄成 failure reason。

這會讓 evaluator 不只會比對文字,也能開始檢查 structured output。


上一篇
Day 11|實作 Batch Evaluation Runner
下一篇
Day 13|加入 JSON 格式驗證
系列文
從黑盒到可驗證:30 天打造 AI Agent 的 Trace、Eval 與 Guardrails 系統 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言