評測:把感覺變成數字Evals: from vibes to numbers
改了 prompt、換了模型、調了切段,系統是變好還是變差?沒有評測,你只能憑感覺回答;而感覺通常來自你剛好試的那三個問題。評測讓「改進」變成可以驗證的主張。
You changed the prompt, the model or the chunking. Is the system better or worse? Without evals you answer by feel, and the feeling usually comes from the three questions you happened to try. Evals turn "improvement" into a claim you can check.
讀完這章你能夠After this chapter you can
- 從真實的失敗案例出發,建立有分類標籤的測試集。
- 為每一種檢查選對評分方式:程式、LLM 評審或人工。
- 在 CI 中與 baseline 比較,擋下退步,同時不被雜訊誤導。
- 說明 LLM 評審的常見偏誤,以及怎麼用人工標註校準它。
- Build a tagged test set starting from real failures.
- Pick the right grader for each check: code, LLM judge or human.
- Gate against a baseline in CI, blocking regressions without being fooled by noise.
- Explain common LLM-judge biases and how to calibrate a judge with human labels.
從錯誤分析開始,不是從指標開始Start with error analysis, not metrics
常見的錯誤是一開始就套用現成的指標庫(「faithfulness」「relevance」),然後對著分數調整。比較有效的順序是:
A common mistake is to start by adopting an off-the-shelf metric suite ("faithfulness", "relevance") and tune against those scores. A more effective order:
- 看真實的輸出Read real outputs 拿 50 到 100 筆真實的請求與回應(含檢索結果與工具呼叫),一筆一筆讀,記下哪裡不對。 Take 50–100 real requests and responses (with retrieval results and tool calls) and read them one by one, noting what's wrong.
- 把問題分類Group the problems 例如:沒找到對的文件、找到但答錯、引用不存在的段落、該拒答卻硬答、格式錯。數一數各類有多少。 For example: right document not retrieved, retrieved but misread, cited a non-existent passage, answered when it should have declined, wrong format. Count each category.
- 針對最大的類別寫測試Write tests for the biggest categories 每個案例加上類別標籤。這樣分數退步時,你知道是哪一類壞了。 Tag each case with its category, so when a score drops you know which kind of behaviour broke.
- 持續補充Keep adding 每次正式環境出事,修好之後把那個案例加進測試集,讓它不會再發生。 Every production incident, once fixed, becomes a test case so it can't recur unnoticed.
測試集長什麼樣子What a test set looks like
lab 的測試集是一個 JSONL 檔,每行一個案例。注意它包含權限測試(forbidden)與一個已知會失敗的案例:誠實的測試集應該反映系統真正的弱點,而不是全部通過。
The lab's test set is a JSONL file, one case per line. Note it includes permission tests (forbidden) and a case that is known to fail: an honest test set reflects the system's real weaknesses rather than passing everything.
{"id": "exact-error-code", "query": "what does PAY-4102 mean", "relevant": ["CLAUSE-006"], "must_include": ["merchant category code"], "tags": ["exact-id"]}
{"id": "paraphrase-migration", "query": "how do I migrate a big postgres table safely", "relevant": ["CLAUSE-004"], "must_include": ["lock_timeout"], "tags": ["paraphrase"]}
{"id": "gdpr-forbidden", "query": "personal data leaving the EU", "relevant": [], "forbidden": ["CLAUSE-002", "CLAUSE-009"], "user_groups": [], "tags": ["acl"]}
{"id": "semantic-oncall", "query": "who is woken up at night", "relevant": ["CLAUSE-010"], "must_include": ["on-call"], "tags": ["synonym"], "note": "Known failure: needs a real embedding model"}
- 來源:真實流量為主,加上邊界案例與對抗性案例(權限、提示注入)。
- 大小:從 50 到 100 題開始就有用。重要的是涵蓋各個類別,而不是總數。
- 標準答案由領域專家確認,不是由模型產生後直接採用。
- 不要讓測試案例流進 prompt(例如當作 few-shot 範例),否則分數會虛高。
- Sources: mostly real traffic, plus edge cases and adversarial cases (permissions, prompt injection).
- Size: 50–100 cases is already useful. Coverage of categories matters more than the total.
- Reference answers are confirmed by domain experts, not generated by a model and accepted as-is.
- Keep test cases out of the prompt (e.g. as few-shot examples), or scores will be inflated.
評分方式Graders
| 方式Type | 適合Good for | 注意Watch out |
|---|---|---|
| 程式評分Code-based | 格式、必含字串、引用是否存在、recall@k、權限、數值比對Format, required strings, citation existence, recall@k, permissions, numeric checks | 便宜、快、可重現,能用就優先用Cheap, fast, reproducible: prefer it whenever possible |
| LLM 評審LLM judge | 語氣、完整性、答案是否有出處支持等語意判斷Tone, completeness, whether claims are supported: semantic judgments | 有偏誤,必須用人工標註校準Biased; must be calibrated against human labels |
| 人工Human | 建立標準答案、校準 LLM 評審、抽查高風險案例Creating references, calibrating judges, spot-checking high-risk cases | 慢又貴,用在最需要的地方Slow and expensive; spend it where it matters most |
LLM 評審的偏誤LLM-judge biases
- 位置偏誤:兩兩比較時偏好第一個或第二個。對策:交換順序各評一次。
- 長度偏誤:偏好比較長的回答。對策:評分標準明確寫出「長度不是優點」,並檢查分數與長度的相關性。
- 自我偏好:偏好和自己風格相近的輸出。對策:評審模型與被評模型分開,並用人工標註驗證。
- 模糊的量表:1 到 10 分很難一致。對策:用「通過/不通過」加上一句理由,比分數更穩定也更好除錯。
- Position bias: in pairwise comparison it favours the first or second answer. Fix: grade both orders.
- Length bias: it favours longer answers. Fix: say explicitly in the rubric that length is not a virtue, and check score/length correlation.
- Self-preference: it favours outputs in its own style. Fix: separate judge and subject models, and validate against human labels.
- Vague scales: 1–10 ratings are hard to keep consistent. Fix: pass/fail plus a one-sentence reason is steadier and easier to debug.
請領域專家標註 50 到 100 筆「通過/不通過」,再讓 LLM 評審評同一批。分別看兩個數字:專家說不通過的,評審抓到多少(漏掉的會讓壞答案溜過);評審說不通過的,有多少專家也同意(誤判會讓 CI 一直紅燈)。兩者都夠高之前,不要用評審的分數擋合併。
Have domain experts label 50–100 outputs pass/fail, then run the judge on the same set. Look at two numbers: of the outputs experts failed, how many did the judge catch (misses let bad answers through)? Of those the judge failed, how many did experts agree with (false alarms keep CI red)? Don't gate merges on the judge until both are high enough.
在 CI 中擋下退步Blocking regressions in CI
lab 的評測做三件事:逐題評分並列出失敗原因、依標籤彙總、和存檔的 baseline 比較。權限外洩是零容忍,其餘指標則看「有沒有比 baseline 差」。
The lab's eval does three things: grades each case and lists failure reasons, aggregates by tag, and compares against a stored baseline. Permission leaks are zero-tolerance; other metrics are judged against the baseline.
def evaluate_case(case: dict, system: System) -> CaseResult:
groups = set(case.get("user_groups", ["everyone"]))
hits = system(case["query"], groups)
ids = [doc_id for doc_id, _ in hits]
relevant = set(case.get("relevant", []))
r = CaseResult(case["id"], case.get("tags", []), ids,
recall_at_k(ids, relevant, K) if relevant else None,
reciprocal_rank(ids, relevant) if relevant else None)
if r.recall is not None and r.recall < 1.0:
r.failures.append(f"missing relevant {sorted(relevant - set(ids[:K]))}")
leaked = set(case.get("forbidden", [])) & set(ids)
if leaked:
r.failures.append(f"returned forbidden docs {sorted(leaked)}")
...
def compare(current: dict, baseline: dict, tolerance: float = TOLERANCE) -> list[str]:
problems = []
if current["acl_leaks"] > 0:
problems.append(f"acl_leaks = {current['acl_leaks']} (must be 0)")
for metric in ("pass_rate", f"recall@{K}", "mrr"):
if current[metric] < baseline.get(metric, 0) - tolerance:
problems.append(f"{metric}: {current[metric]:.3f} < baseline {baseline[metric]:.3f}")
return problems
[PASS] exact-error-code top3=['CLAUSE-006', 'CLAUSE-001', 'CLAUSE-010']
[PASS] paraphrase-migration top3=['CLAUSE-004', 'CLAUSE-003', 'CLAUSE-008']
[PASS] gdpr-forbidden top3=['CLAUSE-003', 'CLAUSE-008', 'CLAUSE-005']
[FAIL] semantic-oncall top3=['CLAUSE-005', 'CLAUSE-003', 'CLAUSE-004'] missing relevant ['CLAUSE-010']; context lacks 'on-call'
{
"cases": 13,
"pass_rate": 0.9231,
"recall@3": 0.9091,
"mrr": 0.9091,
"acl_leaks": 0,
"pass_rate_by_tag": {"acl": 1.0, "exact-id": 1.0, "keyword": 1.0, "paraphrase": 1.0, "synonym": 0.5}
}
name: tests-and-evals
on: [pull_request]
jobs:
evals:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -r requirements.txt
- run: python -m pytest # 單元測試:確定性的邏輯
- run: python -m labs.core.evals # 評測:有退步時 exit code = 1
分數變好時,由人看過結果後執行 --update-baseline,並把 baseline.json 的變更和程式一起提交審查。不要讓 CI 自動更新 baseline,否則退步會被悄悄吸收。
When scores improve, a person reviews the results and runs --update-baseline, committing the baseline.json change for review alongside the code. Never let CI update the baseline automatically, or regressions get quietly absorbed.
雜訊:分數掉了 3 分是真的退步嗎?Noise: is a 3-point drop a real regression?
測試題數有限,模型輸出又有隨機性,分數本來就會跳動。題數少的時候,一題就是好幾個百分點。用下面的模擬感受一下:
With a finite number of cases and stochastic model output, scores move on their own. With few cases, one case is several percentage points. Get a feel for it with the simulation below:
樣本數與雜訊Sample size and noise
實務上的對策:
Practical countermeasures:
- 零容忍的項目用程式擋(權限、格式),不受雜訊影響。
- 題數少時設容忍度,或改看「哪幾題從通過變失敗」,逐題檢查。
- 輸出有隨機性時,每題跑多次,看平均或通過比例。
- 重要決策(換模型、上線)用更大的測試集,例如數百題以上。
- Block zero-tolerance items in code (permissions, format); they are immune to noise.
- With few cases, allow a tolerance, or look at which cases flipped from pass to fail and inspect them.
- For stochastic output, run each case several times and use the average or pass ratio.
- Use a larger set for big decisions (model switch, launch), e.g. several hundred cases.
上線後的評測Evaluation after launch
離線測試集代表的是過去。上線後還需要:
An offline test set represents the past. After launch you also need:
- 使用者訊號:讚/倒讚、重新提問、轉人工、複製答案的比例。
- 抽樣人工審查:每週隨機抽一批正式環境的對話讓專家評分,追蹤趨勢。
- 線上的程式檢查:每次回應都跑格式、引用、權限檢查,失敗就告警。
- 把新的失敗加回測試集,形成迴圈。
- User signals: thumbs up/down, rephrased questions, escalations to a human, copy rate.
- Sampled human review: each week experts grade a random sample of production conversations; track the trend.
- Online code checks: run format, citation and permission checks on every response and alert on failures.
- Feed new failures back into the test set, closing the loop.
現成的工具(例如 Ragas、DeepEval、promptfoo、LangSmith、Braintrust、Arize Phoenix)可以省下建置追蹤與報表的時間,但這些工具的 API 變動很快。先理解本章的概念,工具只是實作方式。
Off-the-shelf tools (Ragas, DeepEval, promptfoo, LangSmith, Braintrust, Arize Phoenix and others) save time on tracing and reporting, but their APIs change quickly. Understand the concepts in this chapter first; the tools are just implementations.