你的第一套评估套件。
这是区分"做过一个"与"持续交付"的关键所在。评估(eval)分三层:组件层、轨迹层、端到端层。无法衡量的东西无从改进,而阅读追踪(trace)日志根本无法衡量智能体——你需要的是数据集、测试框架和评判者。本章从头到尾构建第一个版本;Part III · Evaluate 将把这一实践发展成持续的规范。
构建一个包含 50 道题的数据集。
评估中最难的部分不是测试框架,而是数据集(dataset)。糟糕的数据集让你对一个糟糕的智能体盲目自信。优质的数据集能暴露真实的失败模式。为此花一天时间,别外包出去,也别完全靠 LLM 生成然后照单全收。
四个难度层
将 50 道题按四个难度层分层,让评估能区分不同能力,而非仅仅看平均分。
- 第一层(15 道题):直查。单一事实,来自单篇文档。"Postgres 默认使用哪个端口?"若智能体答错这类题,说明检索(retrieval)已出问题。
- 第二层(15 道题):多跳推理。需要综合 2–3 篇文档。"PgBouncer 连接池与预处理语句如何相互影响?"
- 第三层(10 道题):比较分析。需权衡不同选项的取舍。"何时应使用分区而非分片。"
- 第四层(10 道题):否定案例。语料库中没有答案的问题。"如何配置 PgBouncer 与 Redis?"智能体应回答"语料库中没有相关信息",而非编造答案。
模式(schema)
与提供商无关,纯 JSON。
# evals/dataset.jsonl — one question per line { "id": "q001", "tier": "lookup", "question": "What port does PostgreSQL listen on by default?", "expected_answer_contains": ["5432"], "expected_chunks": ["runtime-config-connection::0"], "forbidden_phrases": ["I don't know", "not sure"], "notes": "basic fact, must succeed on every run" } { "id": "q024", "tier": "multi_hop", "question": "Why do prepared statements break in PgBouncer transaction mode?", "expected_answer_contains": ["session", "transaction"], "expected_chunks": ["pgbouncer-modes::1", "sql-prepare::0"], "requires_synthesis": true } { "id": "q045", "tier": "negative", "question": "How does PostgreSQL integrate with Redis Streams?", "expected_behavior": "refuse", "expected_answer_contains": ["not in", "corpus"], "forbidden_phrases": ["you can use", "the integration"] }
如何构建题目
按优先级排列,来源有三:
- 第 1–3 阶段的追踪日志。智能体曾答错的每一道题,都是完美的评估题目。真实的失败案例胜过凭空捏造。
- 浏览文档。打开语料库,找到你认为非平凡的章节,针对该章节的内容写一道自然语言问题。
- LLM 生成,再经人工筛选。让模型从语料库中生成 100 道题;你从中保留 30 道真正有实际价值的,其余丢弃。这是 LLM 唯一能帮上忙的环节,且必须有人工把关。
切勿让智能体自己生成评估数据集。结果只会是"智能体觉得简单的题",而那些它处理不了的题永远不会出现。
从渐近意义上说,题目越多越好——但前 50 道题是你写过的杠杆效应最大的题目。每花一美元 API 费用,这 50 道题能修复的 bug 最多。把精力放在质量上:每道题都应针对某个具体问题,且有明确的通过/失败标准。
随着对生产中常见失败模式的了解加深,再逐步扩充数据集:50 → 200 → 500,而不是一开始就堆 5000 道题。
单独评估各组件。
端到端评估噪声很大。检索失败、规划失败、综合失败、验证器失败会被混在一个数字里。要排查问题,就必须知道是哪个组件出了毛病。因此要分开评估各组件。
检索评估:recall@k 与 MRR
对数据集中每道有 expected_chunks 字段的题,运行检索并判断:前 k 个结果中是否包含预期的文档块?
# evals/eval_retrieval.py import json from retrieval import retrieve from retrieval.bm25_index import BM25Index from retrieval.dense_index import DenseIndex from retrieval.hybrid import HybridSearch def load_dataset(): return [json.loads(line) for line in open("evals/dataset.jsonl")] def eval_retrieval(retriever, dataset, k=5): hits, mrr_sum, n = 0, 0.0, 0 for q in dataset: if not q.get("expected_chunks"): continue n += 1 results = retriever(q["question"], top_k=k) result_ids = [c.chunk_id for c in results] # Recall: at least one expected chunk in top-k if any(eid in result_ids for eid in q["expected_chunks"]): hits += 1 # MRR: 1 / rank of first expected chunk for rank, rid in enumerate(result_ids, 1): if rid in q["expected_chunks"]: mrr_sum += 1.0 / rank break return {"recall@k": hits / n, "mrr": mrr_sum / n, "n": n}
数据说明了什么
用三种不同的检索器运行同一个评估——纯 BM25、纯稠密向量、混合——再做比较:
recall@5 recall@20 MRR n
BM25 only 0.62 0.81 0.41 40
Dense only 0.68 0.85 0.47 40
Hybrid (RRF) 0.82 0.94 0.58 40
Hybrid+rerank 0.89 0.94 0.71 40
Recall@5 从 0.62 提升到 0.89,正是第 2 阶段所有工作的价值所在。没有这样的数字,你只能凭感觉猜那些复杂性是否值得。
recall@20 在两种混合方案下都达到 0.94 并趋于饱和。这说明绝大多数情况下正确的文档块已经出现在前 20 名中——重排序(reranker)并没有找到更好的块,而是在排列顺序上做得更好。MRR(0.58 → 0.71)也印证了这一点:正确块的排名在上移。
recall@20 缺失的 6% 是检索根本无法找到正确块的题目。这是分块问题(块中不含答案短语)或语料库问题(答案需要多文档综合,单靠检索无法实现)。
验证器评估:对声明的精确率与召回率
人工标注 30 组(声明、来源、判定)三元组——由你亲手完成,不要交给 LLM——然后运行验证器,检查一致性。
# evals/verifier_gold.jsonl — hand-labeled {"claim": "Transaction pooling breaks prepared statements", "sources": ["pgbouncer-modes::1"], "human_verdict": "SUPPORT"} {"claim": "This is the most common migration failure", "sources": ["pgbouncer-modes::1"], "human_verdict": "NOT_SUPPORTED"}
def eval_verifier(gold): correct = 0 for ex in gold: result = verify(ex["claim"], load_chunks(ex["sources"])) v = result["claims"][0]["verdict"] if v == ex["human_verdict"]: correct += 1 return {"agreement": correct / len(gold)}
verifier agreement with humans: 0.87 (26 of 30)
inspecting disagreements:
3 cases where verifier said PARTIAL, human said SUPPORT
→ verifier is slightly too strict on phrasing
1 case where verifier said SUPPORT, human said PARTIAL
→ genuine bug; source says "X under condition Y"
but verifier missed the condition
30 条人工标注的声明中,验证器答对了 26 条。4 处分歧分布如下:3 处误判为 PARTIAL(过于严格——不构成安全问题),1 处误判为 SUPPORT(真实 bug——幻觉(hallucination)会因此漏网)。目前可以接受;那个误判为 SUPPORT 的案例成为下一轮验证器提示词迭代的回归(regression)测试用例。
若一致性低于 80%,说明验证器在引入噪声;请调优至超过 80% 再依赖它。
同样的工作,用 RAGAS 术语来说
我们刚刚构建的东西有一套标准名称,从业者现在期望你使用它们。RAGAS 是封装这些指标的常用框架(底层用 LLM 作为评判者,和我们的验证器与评判者是同一思路):
- 忠实度(faithfulness)——答案是否扎根于检索到的上下文?这正是我们的落地验证器:每条结论都必须能追溯到引用的块。
- 答案(响应)相关性(answer/response relevancy)——答案是否真正回应了问题,而不只是停留在话题上?
- 上下文精确率(context precision)——我们检索到的块中,有多少是真正相关的(top-k 中的信号与噪声之比)?
- 上下文召回率(context recall)——检索是否找全了回答所需的一切?这就是我们的 recall@k,换了个名字。
你不需要新工具——recall@k 就是上下文召回率,验证器就是一次忠实度检查。但当队友或第三方平台说"我们的忠实度是 0.91,但上下文精确率很低"时,你应当听懂这意味着"答案有据可依,但检索拖进了垃圾",并知道该拧哪个旋钮。学会这套术语;工作其实已经做完了。
用 LLM 作为评判者(LLM-as-judge)评估轨迹。
组件评估告诉你每个部件是否正常工作。轨迹评估告诉你整体智能体是否能成功完成任务——而这不只是"最终答案是否正确"。还包括:它的步骤数是否合理?是否引用了正确的来源?是否避免了被禁止的行为?
先做硬检查(廉价、确定性)
# evals/eval_trajectory.py def hard_checks(q: dict, result: dict) -> dict: answer = result.get("answer", "").lower() checks = {} # Did the answer mention expected terms? expected = q.get("expected_answer_contains", []) checks["contains_expected"] = all( term.lower() in answer for term in expected ) # Avoid forbidden phrases (especially for negatives) forbidden = q.get("forbidden_phrases", []) checks["no_forbidden"] = not any( p.lower() in answer for p in forbidden ) # Citation overlap with expected chunks cited = set(result.get("citations", [])) expected_chunks = set(q.get("expected_chunks", [])) if expected_chunks: checks["citation_overlap"] = bool(cited & expected_chunks) # Step efficiency: didn't burn budget checks["under_budget"] = result.get("steps_used", 99) <= 8 # For negative cases: agent must have refused if q.get("expected_behavior") == "refuse": checks["refused"] = ( "not in" in answer or "corpus" in answer or "don't have" in answer ) return checks
再用 LLM 评判者做软检查
对于"答案是否正确且格式良好"这类硬检查无法回答的问题,使用评判者模型。使用与智能体不同的模型来评判(跨模型评判可减少自我确认偏差)。如果智能体运行在 Claude 上,就用 GPT 来评判,反之亦然。
# evals/judge.py — judge with GPT from openai import OpenAI judge_client = OpenAI() JUDGE_PROMPT = """Rate the agent's answer on three axes. QUESTION: {question} EXPECTED ANSWER NOTES: {expected_notes} AGENT'S ANSWER: {answer} AGENT'S CITATIONS: {citations} Score each from 1 (terrible) to 5 (excellent): - correctness: does it answer the question accurately? - completeness: are key aspects covered? - groundedness: are claims supported by citations? Output JSON: {{"correctness": N, "completeness": N, "groundedness": N, "reasoning": "one sentence"}}""" def judge(q, result): response = judge_client.responses.create( model="gpt-5.5", input=JUDGE_PROMPT.format( question=q["question"], expected_notes=q.get("notes", ""), answer=result.get("answer", ""), citations=result.get("citations", []), ), text={"format": {"type": "json_object"}}, ) return json.loads(response.output_text)
# evals/judge.py — judge with Claude from anthropic import Anthropic judge_client = Anthropic() JUDGE_PROMPT = """Rate the agent's answer on three axes. QUESTION: {question} EXPECTED ANSWER NOTES: {expected_notes} AGENT'S ANSWER: {answer} AGENT'S CITATIONS: {citations} Score each from 1 (terrible) to 5 (excellent): - correctness: does it answer the question accurately? - completeness: are key aspects covered? - groundedness: are claims supported by citations? Output JSON only: {{"correctness": N, "completeness": N, "groundedness": N, "reasoning": "one sentence"}}""" def judge(q, result): response = judge_client.messages.create( model="claude-sonnet-4-5", max_tokens=512, messages=[{"role": "user", "content": JUDGE_PROMPT.format( question=q["question"], expected_notes=q.get("notes", ""), answer=result.get("answer", ""), citations=result.get("citations", []), )}], ) return json.loads(response.content[0].text)
每道题运行评判者 3 次
LLM 评判者存在噪声。即使 temperature=0,同一道题的评分也会因运行不同而变化。为获得可靠的信号,每次判断运行 3 遍,取中位数。方差本身也是有价值的数据:如果评判者对同一个例子给出 [3, 5, 4] 的评分,说明该题的评估信号较弱。
def judge_with_variance(q, result, n=3): scores = [judge(q, result) for _ in range(n)] def median(key): return sorted(s[key] for s in scores)[n // 2] return { "correctness": median("correctness"), "completeness": median("completeness"), "groundedness": median("groundedness"), "variance": max(s["correctness"] for s in scores) - min(s["correctness"] for s in scores), }
完整评估运行
$ python scripts/eval.py --full
evaluating 50 questions across 4 tiers...
Tier 1 (lookup): 15/15 hard pass avg judge: 4.7 / 5.0
Tier 2 (multi-hop): 13/15 hard pass avg judge: 4.2 / 5.0
Tier 3 (comparative): 8/10 hard pass avg judge: 3.9 / 5.0
Tier 4 (negative): 9/10 hard pass avg judge: 4.5 / 5.0
OVERALL hard-pass rate: 45/50 = 90.0%
OVERALL judge correctness (median): 4.3 / 5.0
OVERALL judge groundedness (median): 4.6 / 5.0
high-variance questions (judge disagrees with itself):
q017: scores [3, 5, 4] — answer is partially correct,
judge unsure which way to score
q031: scores [2, 5, 3] — answer is verbose; judge
differs on whether length helps or hurts
failing questions:
q024: missed expected chunk pgbouncer-modes::1
(retrieval ranked it at 7)
q037: cited a chunk that doesn't support the claim
(verifier should have caught this — bug)
q045: invented Redis integration details
(negative case failed)
90% 硬通过率是一个真实、站得住脚的数字,可以随时间绘图。具体的失败题目对应具体的待修复 bug。高方差题目告诉你评估信号最薄弱的地方——这些题目是改写的候选(更精确的问法,更清晰的预期答案)。
现在你有东西可以展示给队友了:"我们的智能体通过了 90% 的测试集,这是失败原因,这是过去 20 次提交中该数字的走势。"
不加防护措施确实如此。已知的失败模式包括:位置偏差(偏好先出现的答案)、长度偏差(偏好较长的答案)、自我亲和(模型给自己的输出打高分),以及主观问题上的冗长偏差。这些问题确实存在。
有效的缓解措施包括:跨模型评判(Claude 评 GPT 或反之,绝不自评)、每道题运行 3 次以上并取中位数、在任何 LLM 判断之前先对客观事实做硬检查,以及每月人工审核约 10% 的判断以检测漂移。
有了这些护栏(guardrail),LLM 作为评判者是我们评分开放式输出的最佳工具。没有这些措施,它就是一个感觉很严谨的随机数生成器。
为了对异常值的鲁棒性。如果评判者给出 [3, 4, 5],均值和中位数都是 4——没问题。如果给出 [4, 4, 1],均值是 3(被异常值拉低),而中位数是 4(正确地忽略了异常值)。LLM 评判者偶尔会给出严重偏离的评分;中位数更稳定。
n=3 时,你实际上得到的是"带异常值容忍的多数投票"。n=5 以上时,均值会更安全。我们用 3 是因为每次评判的成本在 50 道题 × 3 次 = 150 次评判调用时不容忽视。
将评估接入开发循环。
每周运行一次的评估套件是研究;每次提交都运行的评估套件才是工程。让它足够轻量以便你真的会去运行,足够快速以便在开发中使用,且有清晰的通过/失败信号。
两个层级:快速与完整
完整评估需要数分钟;每次代码改动后都等它不现实。拆分为快速层(10 道题,约 30 秒)用于迭代,完整层(50 道题,3–5 分钟)用于确认。
# Makefile .PHONY: eval-fast eval-full eval-watch eval-fast: python scripts/eval.py --tier fast --n 10 eval-full: python scripts/eval.py --full --judge --variance 3 eval-watch: # Re-run fast eval whenever agent/ changes fswatch -o agent/ retrieval/ | xargs -n1 -I{} make eval-fast ci: make eval-full python scripts/eval.py --regression-check
随时间追踪分数
最有价值的单一产出物:一份记录每次评估运行的 CSV,包含日期、git commit 和各项分数。把它画出来。
# evals/scoreboard.csv — append after every run date,commit,hard_pass,judge_correct,judge_grounded,steps_p50 2026-05-08,a1b2c3d,0.72,3.8,4.1,5.2 2026-05-09,e4f5g6h,0.78,4.0,4.3,4.8 # added reranker 2026-05-10,i7j8k9l,0.84,4.2,4.5,4.5 # tuned chunk size 2026-05-12,m0n1o2p,0.88,4.3,4.6,4.2 # added planner 2026-05-15,q3r4s5t,0.90,4.3,4.6,3.8 # subagent context isolation
现在你有了一个故事。"加入重排序器为硬通过率带来了 6 个百分点的提升。子智能体没有提升正确率,但将中位步骤数从 4.2 降到了 3.8。"这就是你写进 PR 描述和在评审会上呈现的内容。
回归检查
一个简单的门控:上次通过而这次失败的任何题目,都是回归。阻止合并。
def regression_check(current, baseline): regressed = [] for qid, c in current.items(): b = baseline.get(qid) if b and b["passed"] and not c["passed"]: regressed.append(qid) if regressed: print(f"REGRESSION: {regressed}") sys.exit(1) print("no regressions")
当队友第一次因为 make ci 显示 q024 出现回归而拒绝合并你的 PR,你就知道评估套件真正发挥作用了。这就是目标。
你的数据集没有反映真实使用情况。常见原因:
- 题目分布有误。测试集中 30% 是比较分析题,但真实用户 80% 的问题是直查。应针对直查优化。
- 问法过于规整。真实用户会拼错字、缩写、使用行话。你的评估应该反映这一点——一旦有了日志,就从中提取真实问题。
- 缺少失败模式。用户抱怨格式或语气;你的评估只检查正确性。补充语气/格式检查。
解决方法是迭代数据集。把数据集当作活的软件来对待——根据用户的实际需求每月审查并更新。
当多人同时运行评估、需要在团队间共享结果,或需要跨运行的 A/B 比较和更丰富的仪表板等高级功能时。我们在这里构建的模式——预期输出数据集、硬检查加带方差的 LLM 评判、按组件评估、回归门控——与那些工具用更漂亮的界面封装的是同一套思路。
先自己动手实现,以便理解其中的抽象。等规模或团队协作有需求时再采用第三方平台。对于单个团队来说,Python 加 Makefile 的方案完全达到生产级别——不必为自己暂时用不到的复杂性付费。
交付物
一套在每次提交时运行、随时间追踪分数、并阻止回归的评估套件。你现在可以修改智能体,并用数字知道这些改动是帮助、损害还是无影响。这就是业余项目与产品之间的差距。Part III · Evaluate 将进一步延伸:将评估驱动开发作为持续实践、校准 LLM 作为评判者、解读公开基准(benchmark),以及 CI 集成。
- 50 道题的分层数据集(4 个层级,JSONL 格式)
- 检索评估:recall@5、recall@20、MRR
- 验证器评估:与 30 条人工标注的一致性
- 轨迹评估:硬检查 + LLM 评判者 × 3 带方差
- Makefile 含 eval-fast / eval-full / CI 门控
- scoreboard.csv 按提交追踪分数