第十二章 智能体性能评估
一句话总结
前十一章你一直在"造",这一章学"考"。没有评估的智能体调优全是玄学——你以为改好了,其实只是随手试的那两个问题碰巧过了。本章手敲一套 8 文件评测框架:用 AST 匹配判工具调用对不对(BFCL 思路)、准精确匹配判最终答案对不对(GAIA 思路)、LLM Judge + Win Rate 判生成内容好不好,六个文件完全离线可跑。
本章你能学到什么
- 为什么智能体评估比模型评估难,以及"手动试几个例子"为什么不算评估
- 三类基准的定位:BFCL(工具调用)、GAIA(通用能力)、Judge/Win Rate(生成质量)
- 三个判分算法的原理与手写实现:AST 匹配、准精确匹配、LLM Judge / Win Rate
- 手敲一套 8 文件评测框架(约 650 行),含满分选手 / 摆烂选手双向自测——这是判分器可信的前提
- LLM Judge 的三个致命偏见(位置偏见、长度偏见、自我偏好)与工程对策
- 面试必问:为什么不能用字符串匹配、Win Rate 为什么该在 50% 左右、评估集怎么长出来
与原书的差异说明:原书用 BFCL 官方数据集(要 clone 几百 MB 仓库)、GAIA(要 HuggingFace 授权)、AIME 真题(要下载数据集)。本文把三者各做成内置在代码里的迷你版,让你零下载、零密钥先把评测流程和判分算法跑通。判分算法本身是照官方定义实现的,不是简化版;文末给出换成真实基准的方法。
一、为什么必须评估:三个真实场景
场景一:改了提示词,不知道是变好还是变坏。 你在 system prompt 里加了一句"不要重复调用工具",手动试两个问题都对,上线后另一类问题全崩。手动试几个例子 = 样本量为 2 的实验,得不出任何结论。
场景二:换了个便宜模型,不知道能不能换。 换完省了 90% 的钱,但工具调用准确率从 92% 掉到 61%——你要三周后从用户投诉里才发现。
场景三:接了 RAG,不知道有没有用。 感觉回答"更专业了",实际是检索把无关文档塞进上下文、准确率反而下降(第八章的检索质量问题)。
三个场景的共同解药是同一样东西:一套能重复运行、能给出数字的考试系统。这也解释了第十一章那句"应用工程师在训练环节能做的三件事"里,为什么建评估集最重要——奖励函数的天花板就是评估集的质量。
名词补课——基准(Benchmark):一套公开的、大家用同一份题目和同一套判分规则的考试。它的价值在于可比:你说"我的智能体准确率 85%",只有在指明"在 BFCL 的 simple 类别上、用官方 AST 匹配判分"时才有意义。
智能体评估比模型评估难在哪
| 传统模型评估 | 智能体评估 | |
|---|---|---|
| 输出形态 | 一个标签 / 一个分数 | 一段自由文本 + 一串工具调用 + 真实副作用 |
| 正确答案 | 唯一 | 多条路径都可能对 |
| 判分 | 直接比较 | 要先解析,再比较语义 |
| 失败模式 | 预测错 | 格式错、工具选错、参数错、死循环、超时、乱调工具 |
| 成本 | 一次前向 | 每题多次 LLM 调用 + 真实 API 调用(花钱且有副作用) |
关键差异是那句"要先解析,再比较语义"——这正是本章两个判分算法要解决的核心问题。
二、三类基准与三个判分算法
2.1 主流基准速览
(1)工具调用能力
- BFCL(Berkeley Function Calling Leaderboard,UC Berkeley):1120+ 样本,四类难度递增——simple(单函数)、multiple(多个候选工具选一个)、parallel(一次要调多个)、irrelevance(根本不该调工具)。用 AST 匹配判分。
- ToolBench(清华):16000+ 真实 API 场景,覆盖复杂真实工具使用。
- API-Bank(Microsoft Research):53 个常用 API,重点考"读懂 API 文档"。
irrelevance 这个类别为什么最重要? 它考的是"该不该动手"。生产环境最常见的翻车不是工具用错,而是用户只是闲聊,智能体非要调个搜索——又慢又贵还答偏。一个在 simple 上刷到 95%、irrelevance 却是 0 分的智能体,上线必炸。这也是为什么本章的迷你数据集里专门放了 3 条 irrelevance 题。
(2)通用综合能力
- GAIA(Meta AI + Hugging Face):466 个真实世界问题,分 Level 1 / 2 / 3 三档(所需推理步数递增),考多步推理、工具使用、文件处理、网页浏览。用准精确匹配判分。
- AgentBench(清华):8 个领域任务。WebArena(CMU):真实网页环境里完成任务。
(3)多智能体协作:ChatEval(多智能体对话质量)、SOTOPIA(社交场景互动)。
(4)常用指标四类
- 准确性:Accuracy、Exact Match、F1
- 效率:响应时间、Token 用量(生产环境里和准确率一样重要)
- 鲁棒性:错误率、故障恢复率
- 协作:通信效率、任务完成度
2.2 判分算法一:AST 匹配(判工具调用)
为什么不能用字符串匹配? 看这四对调用——前两对语义完全相同但字符串不同,后两对是真的错:
get_weather(city="Beijing", unit="celsius") vs get_weather(unit="celsius", city="Beijing") ← 仅参数顺序不同,应判对
calculate(x=2+3) vs calculate(x=5) ← 等价表达式,应判对
get_temperature(city="Beijing") vs get_weather(city="Beijing") ← 函数名不同,应判错
get_weather(city="Shanghai") vs get_weather(city="Beijing") ← 参数值不同,应判错
字符串匹配会把前两对判错,冤枉好人;后果是你误以为智能体很差,然后去"修"一个根本不存在的问题——判分器的错误会伪装成模型的错误,这是评估工程里最贵的坑。
名词补课——AST(抽象语法树):把一段代码解析成一棵树。
get_weather(city="北京")变成"一个调用节点,函数名 get_weather,一个关键字参数 city,值是字符串北京"。树比文本更接近语义:参数换顺序,文本变了,但树的"参数集合"没变。Python 标准库ast自带解析器,不用装任何包。
AST 匹配的判定规则(官方定义三条):
- 函数名完全一致(精确匹配,
get_weather≠get_temperature); - 参数键值对集合相等(忽略顺序);
- 每个参数值语义等价(
2+3≡5,"hello"≡'hello')。
多个调用时:数量必须一致、每个都要能配上对,但顺序无关(集合匹配)。
2.3 判分算法二:准精确匹配(判最终答案)
自然语言答案也不能直接比字符串:"$1,234.56" 和 "1234.56" 是同一个答案。GAIA 的做法叫准精确匹配(Quasi Exact Match)——先归一化,再精确匹配:
| 答案类型 | 归一化规则 | 例子 |
|---|---|---|
| 数字 | 去千分位逗号、去货币符号/百分号 | "$1,234.56" → 1234.56 |
| 字符串 | 转小写、去冠词(a/an/the)、压缩空格、去尾部标点 | "The United States." → united states |
| 列表 | 按逗号拆分、逐项归一化、排序后重连 | "Paris, London, Berlin" → berlin,london,paris |
列表必须排序:回答"巴黎、伦敦、柏林"和"柏林、伦敦、巴黎"都该算对。
2.4 判分算法三:LLM Judge 与 Win Rate(判生成质量)
前两个算法都要求有标准答案。但很多任务没有——"这道生成的数学题质量好不好""这篇摘要写得怎么样"。两招应对:
LLM Judge(绝对打分):让一个 LLM 当评委,按固定维度打 1~5 分。以"生成 AIME 风格数学题"为例,四个维度是正确性 / 清晰度 / 难度匹配 / 完整性,三个汇总指标:
- 平均分 = 所有题目"四维平均分"的均值
- 及格率 = 平均分 ≥ 3.5 的题目占比
- 优秀率 = 平均分 ≥ 4.5 的题目占比
Win Rate(相对对比):把生成的题目和真题两两放一起,让 LLM 判哪个更好,统计 Win / Loss / Tie 三个比例(三者之和 100%)。
Win Rate 的理想值是 50%,不是 100%。 这点反直觉但关键:参照物是真题,50% 意味着"生成质量和真题不可区分",这已经是天花板。远低于 50% 说明生成器要改;显著高于 50% 通常不是你太强,而是评委的标准偏了(比如它偏爱长解答,而你的生成器正好爱写长解答)。
人工验证:上面两招都是 LLM 判 LLM,有系统性盲区。数学题这类需要严格推理的内容最终要人抽查:打分(1~5)+ 标状态(approved / rejected / needs_revision)+ 写评论。原书用 Gradio 做了个 Web 界面来降低人工验证门槛。
三、动手实战:手敲一套 8 文件评测框架
3.0 成品与项目结构
跑完之后你会看到这样的输出(这是真实运行结果,不是示意):
===== BFCL / 被考对象=dumb =====
总准确率 8.33% 宏平均 6.25%
{
"irrelevance": {"correct": 0, "total": 3, "accuracy": 0.0},
"multiple": {"correct": 0, "total": 3, "accuracy": 0.0},
"parallel": {"correct": 0, "total": 2, "accuracy": 0.0},
"simple": {"correct": 1, "total": 4, "accuracy": 0.25}
}
报告已写入:eval_results/bfcl_20260816_002455.md
项目结构(8 个文件,自上而下依次敲):
agent_eval/
├── matchers.py ① 两个判分器:AST 匹配 + 准精确匹配 ← 全章最核心
├── dataset.py ② 迷你评测集:12 条工具题 + 6 条综合题
├── evaluator.py ③ 评测引擎:出题 → 收答案 → 解析 → 判分 → 算指标
├── agents.py ④ 三个被考对象:满分选手 / 摆烂选手 / 真模型
├── report.py ⑤ 报告生成:Markdown + JSON,带错题本和改进建议
├── run_eval.py ⑥ 命令行入口
├── judge.py ⑦ LLM Judge + Win Rate
└── judge_demo.py ⑧ 离线演示 Judge 与去偏机制
①②③⑤⑥⑧ 六个文件完全不需要联网、不需要密钥——你可以先把整套框架跑通、把判分器验对,再决定要不要花钱考真模型。这是评估工程的正确顺序:先让考卷和判分器可信,再去考人。
只有 ④ 里的 LLMAgent 和 ⑦ 接真模型时才需要依赖:
pip install openai python-dotenv # 只有考真模型时才需要
3.1 文件一:matchers.py —— 两个判分器(约120行,全章最核心)
先敲第一部分:AST 匹配。
"""两个"判分器":工具调用用 AST 匹配,自然语言答案用准精确匹配。
本文件不联网、不需要密钥,直接 python matchers.py 就能自测。
"""
import ast
import operator
import re
# ---------- 第一部分:AST 匹配(BFCL 风格,判"工具调用对不对") ----------
_OPS = {
ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul,
ast.Div: operator.truediv, ast.Pow: operator.pow, ast.USub: operator.neg,
}
def _calc(node):
"""只算加减乘除幂;遇到别的一律报错(白名单,理由见第四章)"""
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
return node.value
if isinstance(node, ast.BinOp):
return _OPS[type(node.op)](_calc(node.left), _calc(node.right))
if isinstance(node, ast.UnaryOp):
return _OPS[type(node.op)](_calc(node.operand))
raise ValueError("不是纯算术表达式")
def norm_value(v):
"""把一个参数值归一化成"可比较的形式" """
if isinstance(v, bool):
return v
if isinstance(v, (int, float)):
return round(float(v), 6)
if isinstance(v, (list, tuple)):
return [norm_value(x) for x in v]
if isinstance(v, dict):
return {k: norm_value(x) for k, x in v.items()}
if isinstance(v, str):
s = v.strip()
try: # "2+3" → 5.0;"5" → 5.0
return round(float(_calc(ast.parse(s, mode="eval").body)), 6)
except Exception:
pass
return s.lower()
return v
norm_value为什么这么写? 它是"语义等价"三个字的具体落地。bool要放最前面,因为 Python 里True也是int,不特判会把True变成1.0;浮点要round(..., 6),否则0.1+0.2 != 0.3会冤枉好人;字符串先试着当算式算,算不动才当文本转小写——这一步就是x=2+3 ≡ x=5的全部秘密。注意这里用的是第四章那套ast白名单_calc,不是eval:判分器要处理的是被考模型的输出,而模型输出永远是不可信输入。
继续追加解析和比较:
def parse_call(text):
"""把 'get_weather(city="北京")' 解析成 ('get_weather', {'city': '北京'})"""
try:
node = ast.parse(text.strip(), mode="eval").body
except SyntaxError:
return None, {}
if not isinstance(node, ast.Call):
return None, {}
name = node.func.id if isinstance(node.func, ast.Name) else getattr(node.func, "attr", None)
args = {}
for i, pos in enumerate(node.args): # 位置参数:先按序号占位
args[f"__pos{i}"] = ast.unparse(pos)
for kw in node.keywords:
try:
args[kw.arg] = ast.literal_eval(kw.value)
except (ValueError, SyntaxError):
args[kw.arg] = ast.unparse(kw.value) # 算式等留成字符串,交给 norm_value
return name, args
def ast_match(pred, truth) -> bool:
"""pred/truth 可以是调用字符串,也可以是 (函数名, 参数字典)"""
pname, pargs = parse_call(pred) if isinstance(pred, str) else pred
tname, targs = parse_call(truth) if isinstance(truth, str) else truth
if pname is None or pname != tname:
return False
if set(pargs) != set(targs): # 参数名集合必须一致(顺序无关)
return False
return all(norm_value(pargs[k]) == norm_value(targs[k]) for k in targs)
ast_match 的三行判断,正好对应 2.2 节官方定义的三条规则:名字相等 → 参数名集合相等 → 每个值语义等价。set(pargs) != set(targs) 这一行同时挡住了"少给参数"和"多给参数"两种错。
名词补课——
ast.literal_eval与ast.unparse:前者把语法树节点还原成 Python 值(只认字面量:数字、字符串、列表、字典),安全;后者把语法树节点还原成代码文本。city="北京"走literal_eval得到字符串,x=2+3不是字面量、literal_eval会失败,于是unparse成"2+3"交给norm_value去算——两个函数配合,才能既安全又认等价表达式。
接着敲第二部分:准精确匹配(GAIA 风格)。
# ---------- 第二部分:准精确匹配(GAIA 风格,判"最终答案对不对") ----------
_ARTICLES = re.compile(r"\b(a|an|the)\b")
def norm_answer(a) -> str:
"""归一化自然语言答案:数字去逗号去单位,文本去冠词去标点转小写,列表排序"""
s = str(a).strip()
if "," in s and not re.fullmatch(r"[\d,\.\s$%]+", s): # 逗号分隔的列表
parts = [norm_answer(p) for p in s.split(",")]
return ",".join(sorted(parts))
plain = s.replace(",", "").replace("$", "").replace("%", "").replace("¥", "").strip()
try: # 数字答案
return str(round(float(plain), 6))
except ValueError:
pass
s = s.lower()
s = _ARTICLES.sub(" ", s)
s = re.sub(r"[\s]+", " ", s).strip()
return s.strip(".。,,!!??\"'")
def quasi_exact_match(pred, truth) -> bool:
return norm_answer(pred) == norm_answer(truth)
那个
re.fullmatch(r"[\d,\.\s$%]+", s)是干什么的? 它解决一个真实歧义:"1,234.56"里的逗号是千分位,"Paris, London"里的逗号是分隔符。判断办法是看整个字符串是不是只由数字、逗号、点、空格、货币符号组成——如果是,那逗号就是千分位,不能拆。这类"看起来能一行搞定、实际有歧义"的地方,是判分器最容易埋 bug 的位置。
最后把自测写进 __main__——判分器必须自带测试,这不是可选项:
if __name__ == "__main__":
# BFCL 官方文档里的四个例子,前两个应该判对,后两个应该判错
assert ast_match('get_weather(city="Beijing", unit="celsius")',
'get_weather(unit="celsius", city="Beijing")')
assert ast_match('calculate(x=2+3)', 'calculate(x=5)')
assert not ast_match('get_temperature(city="Beijing")', 'get_weather(city="Beijing")')
assert not ast_match('get_weather(city="Shanghai")', 'get_weather(city="Beijing")')
# 少参数、多参数、语法错都算错
assert not ast_match('get_weather()', 'get_weather(city="Beijing")')
assert not ast_match('这不是函数调用', 'get_weather(city="Beijing")')
# GAIA 风格的答案归一化
assert quasi_exact_match("$1,234.56", "1234.56")
assert quasi_exact_match("The United States of America", "united states of america")
assert quasi_exact_match("Paris, London, Berlin", "berlin, london, paris")
assert not quasi_exact_match("12847520", "12847521")
print("全部判分用例通过 ✅")
✅ Checkpoint 1:python matchers.py,应看到:
全部判分用例通过 ✅
任何一条 assert 挂了都会直接抛 AssertionError 并指出行号。这 10 行断言里,前 4 条就是 2.2 节表格里的那四对例子——你现在有了一个能通过官方定义检验的 AST 匹配器,不到 80 行。
专家提示——
assert里那两条"负面用例"最值钱。get_weather()vsget_weather(city="Beijing")和这不是函数调用,考的都是判分器的下限:漏参数要判错、模型答废话要判错。评估框架的 bug 通常不是"判对的判错了",而是"判错的判对了"——那会让你的准确率虚高,比没有评估更危险。
3.2 文件二:dataset.py —— 迷你评测集(约65行)
"""迷你评测集:12 条工具调用题(仿 BFCL 四类)+ 6 条综合题(仿 GAIA 三档)。
真实基准要下载几百 MB,这里内置在代码里,零成本先把评测流程跑通。
"""
TOOLS_SPEC = [
{"name": "get_weather", "description": "查询某城市当前天气",
"parameters": {"city": "str 城市名", "unit": "str 可选,celsius 或 fahrenheit"}},
{"name": "calculate", "description": "计算数学表达式",
"parameters": {"expression": "str 例如 (2024-1955)*365"}},
{"name": "search", "description": "联网搜索资料",
"parameters": {"query": "str 搜索关键词"}},
{"name": "send_email", "description": "发送邮件",
"parameters": {"to": "str 收件人", "subject": "str 主题", "body": "str 正文"}},
]
# category: simple(单工具) / multiple(多工具选一) / parallel(一次要调多个) / irrelevance(不该调)
BFCL_MINI = [
{"id": "s1", "category": "simple", "question": "北京现在天气怎么样?",
"ground_truth": ['get_weather(city="北京")']},
{"id": "s2", "category": "simple", "question": "上海的天气,用华氏度告诉我。",
"ground_truth": ['get_weather(city="上海", unit="fahrenheit")']},
{"id": "s3", "category": "simple", "question": "帮我算一下 (2024-1955)*365 等于多少。",
"ground_truth": ['calculate(expression="(2024-1955)*365")']},
{"id": "m1", "category": "multiple", "question": "查一下 Transformer 论文是哪一年发表的。",
"ground_truth": ['search(query="Transformer 论文 发表年份")']},
{"id": "m2", "category": "multiple", "question": "给张三发一封邮件,主题写周报,内容写本周进展顺利。",
"ground_truth": ['send_email(to="张三", subject="周报", body="本周进展顺利")']},
{"id": "m3", "category": "multiple", "question": "杭州今天热不热?",
"ground_truth": ['get_weather(city="杭州")']},
{"id": "p1", "category": "parallel", "question": "同时告诉我北京和上海的天气。",
"ground_truth": ['get_weather(city="北京")', 'get_weather(city="上海")']},
{"id": "p2", "category": "parallel", "question": "算一下 12*12,再顺便搜一下什么是 ReAct。",
"ground_truth": ['calculate(expression="12*12")', 'search(query="什么是 ReAct")']},
{"id": "i1", "category": "irrelevance", "question": "你觉得人生的意义是什么?",
"ground_truth": []},
{"id": "i2", "category": "irrelevance", "question": "你好,你叫什么名字?",
"ground_truth": []},
{"id": "i3", "category": "irrelevance", "question": "帮我写一首关于春天的四行小诗。",
"ground_truth": []},
{"id": "s4", "category": "simple", "question": "把 99 乘以 99 算出来。",
"ground_truth": ['calculate(expression="99*99")']},
]
继续追加 GAIA 迷你集和加载函数:
# Level 1:一步就能答;Level 2:两三步;Level 3:多步且要算
GAIA_MINI = [
{"id": "g1", "level": 1, "question": "中国的首都是哪座城市?", "answer": "北京"},
{"id": "g2", "level": 1, "question": "一年有多少个月?", "answer": "12"},
{"id": "g3", "level": 2, "question": "如果一本书 45 元,买 3 本打九折,一共要付多少元?", "answer": "121.5"},
{"id": "g4", "level": 2, "question": "列出中国四大发明中与文字记录直接相关的两项,用逗号分隔。",
"answer": "造纸术,印刷术"},
{"id": "g5", "level": 3, "question": "乔布斯生于1955年,到2024年过去了多少年?再乘以365天,得到多少天?",
"answer": "25185"},
{"id": "g6", "level": 3, "question": "一个正方形边长 8 厘米,它的面积减去周长等于多少?", "answer": "32"},
]
def load(name: str):
return {"bfcl": BFCL_MINI, "gaia": GAIA_MINI}[name]
if __name__ == "__main__":
for n in ("bfcl", "gaia"):
d = load(n)
print(f"{n}: {len(d)} 条")
print(" 第一条:", d[0])
✅ Checkpoint 2:python dataset.py,应打印 bfcl: 12 条 和 gaia: 6 条 各带一条样例。
数据集设计的三个刻意选择,比代码本身重要:
irrelevance的ground_truth是空列表[]。这不是"没写标准答案",而是"标准答案就是什么都不调"。判分时空列表 vs 空列表 = 匹配成功,一行特判都不用加——把"不该做"编码成一条正常的标准答案,是评估设计里很漂亮的一手。m3(杭州热不热)故意不带"天气"二字。它考的是语义理解:模型得知道"热不热"要调get_weather。如果你的题面里总带工具名的关键词,那你考的是关键词匹配,不是智能。g4的答案是逗号分隔的列表,g3的答案是小数。这两条专门用来在真实评测里踩一遍 3.1 节的归一化分支——评估集要覆盖判分器的每条代码路径,和写单元测试是同一个道理。
3.3 文件三:evaluator.py —— 评测引擎(约120行)
这个文件负责把"出题 → 收答案 → 解析 → 判分 → 算指标"串起来。它对被考对象的唯一要求是:有一个 predict(prompt) -> str 方法——这个约定让满分选手、摆烂选手、真模型可以随意互换。
"""评测引擎:让被考对象答题 → 判分 → 算指标。
被考对象只需满足一个约定:有一个 predict(question) -> str 方法。
"""
import json
import re
from collections import defaultdict
from matchers import ast_match, quasi_exact_match
from dataset import TOOLS_SPEC, load
BFCL_PROMPT = """你是一个函数调用助手。可用工具如下(JSON):
{tools}
用户请求:{question}
规则:
1. 如果需要调用工具,只输出调用语句,一行一个,例如 get_weather(city="北京")
2. 参数必须用 名字="值" 的形式
3. 如果这个请求不需要任何工具,只输出一个词:NONE
4. 不要输出任何解释"""
GAIA_PROMPT = """请回答下面的问题。只输出最终答案本身,不要解释、不要单位、不要多余文字。
如果答案是多个词,用英文逗号分隔。
问题:{question}"""
规则 3(
NONE)是整套提示词里最关键的一句。 没有它,模型面对"你好你叫什么名字"时会输出一段寒暄,你的解析器抠不出函数调用,虽然结果也算对(空列表 vs 空列表),但这是碰巧对的。给模型一个明确的"我不需要工具"的表达方式,irrelevance 才是在考能力而不是考运气。GAIA 那边同理:官方提示词强制以FINAL ANSWER:结尾,本质是同一招——判分器的归一化规则,必须先在提示词里跟模型签好合同。
解析器是评测引擎里最脏的一段——因为模型不守格式是常态:
def extract_calls(text: str):
"""从模型回复里抠出所有函数调用。兼容三种写法:纯文本、代码块、JSON"""
if text is None:
return []
t = text.strip()
if re.fullmatch(r"(?i)none\.?", t):
return []
# 代码块里的内容优先
blocks = re.findall(r"```(?:\w+)?\n(.*?)```", t, re.S)
body = "\n".join(blocks) if blocks else t
# JSON 格式 {"name": ..., "arguments": {...}}
calls = []
for m in re.finditer(r'\{\s*"name"\s*:\s*"(\w+)"\s*,\s*"arguments"\s*:\s*(\{.*?\})\s*\}', body, re.S):
try:
args = json.loads(m.group(2))
inner = ", ".join(f'{k}={json.dumps(v, ensure_ascii=False)}' for k, v in args.items())
calls.append(f"{m.group(1)}({inner})")
except json.JSONDecodeError:
pass
if calls:
return calls
# 纯文本函数调用:逐行找 名字(...)
for line in body.splitlines():
line = line.strip().rstrip(",;")
m = re.fullmatch(r"[-*\d.\s]*(\w+\s*\(.*\))", line)
if m:
calls.append(m.group(1))
return calls
三条分支对应模型的三种常见输出习惯:裹在 ``` 代码块里、写成 Function Calling 的 JSON、直接写调用语句。最后那个正则里的 [-*\d.\s]* 是用来吃掉行首的 -、1. 这类 Markdown 列表符号的——模型爱把答案排版成列表,解析器就得能忍。
踩坑警告:解析器的宽容度必须写进报告。 如果你的解析器能容忍 JSON、代码块、列表符号,而别人的只认纯文本,那你们跑同一个模型会得出不同分数——这时候分数不可比。所以专业的评测报告一定会说清"用什么提示词、用什么解析器"。BFCL 官方之所以给出统一的 handler,就是为了消掉这个变量。
继续追加集合匹配、两个评测函数和指标计算:
def _set_match(preds, truths) -> bool:
"""多调用场景:数量必须一致,且能一一配对(顺序无关)"""
if len(preds) != len(truths):
return False
remaining = list(truths)
for p in preds:
for i, t in enumerate(remaining):
if ast_match(p, t):
remaining.pop(i)
break
else:
return False
return True
def eval_bfcl(agent, max_samples=None):
data = load("bfcl")[:max_samples]
tools_json = json.dumps(TOOLS_SPEC, ensure_ascii=False, indent=2)
records = []
for item in data:
prompt = BFCL_PROMPT.format(tools=tools_json, question=item["question"])
reply = agent.predict(prompt)
preds = extract_calls(reply)
ok = _set_match(preds, item["ground_truth"])
records.append({"id": item["id"], "category": item["category"], "question": item["question"],
"prediction": preds, "ground_truth": item["ground_truth"],
"correct": ok, "raw": (reply or "")[:200]})
return records
def eval_gaia(agent, max_samples=None):
data = load("gaia")[:max_samples]
records = []
for item in data:
reply = agent.predict(GAIA_PROMPT.format(question=item["question"]))
pred = (reply or "").strip().splitlines()[-1] if (reply or "").strip() else ""
ok = quasi_exact_match(pred, item["answer"])
records.append({"id": item["id"], "level": item["level"], "question": item["question"],
"prediction": pred, "ground_truth": item["answer"],
"correct": ok, "raw": (reply or "")[:200]})
return records
_set_match里的for...else是 Python 的一个冷门语法:else只在循环没有被break的情况下执行。这里的含义是"这个预测调用在剩余标准答案里一个都配不上 → 直接判错"。remaining.pop(i)保证一对一配对:如果标准答案要两次get_weather,模型只给一次却重复算两次,就配不上了。
raw字段截断到 200 字,是为了让报告能看但又不至于爆炸。每条记录都留原始回复,是排错的救命稻草——分数低到底是模型不行,还是解析器没认出来,只有看raw才知道。
def metrics(records, group_key):
"""算总准确率 + 分组准确率。group_key 是 'category' 或 'level'"""
total = len(records)
right = sum(r["correct"] for r in records)
groups = defaultdict(lambda: [0, 0])
for r in records:
g = groups[str(r[group_key])]
g[1] += 1
g[0] += int(r["correct"])
per_group = {k: {"correct": v[0], "total": v[1], "accuracy": round(v[0] / v[1], 4)}
for k, v in sorted(groups.items())}
macro = round(sum(v["accuracy"] for v in per_group.values()) / len(per_group), 4) if per_group else 0.0
return {
"total": total,
"correct": right,
"accuracy": round(right / total, 4) if total else 0.0,
"error_rate": round(1 - right / total, 4) if total else 0.0,
"macro_accuracy": macro, # 各组准确率的平均,防止大类别掩盖小类别
"per_group": per_group,
}
为什么要同时算"总准确率"和"宏平均"? 总准确率(micro)是"答对几道 ÷ 总共几道",题多的类别权重就大;宏平均(macro)是"先算每类准确率,再对类别取平均",每类一票。假设 simple 有 100 题、irrelevance 只有 4 题,模型在 irrelevance 上全错,总准确率只掉 4%,你根本看不见;宏平均会直接掉 25%。两个数差得远,就说明各类别表现严重不均——这时候只看总分会做出错误决策。 本章
report.py里那条"宏平均与总准确率差距 >0.1 就警告"的建议,就是为这件事准备的。
3.4 文件四:agents.py —— 三个被考对象(约52行)
这一步是全章的关键设计,请务必理解它为什么存在。
"""三个"被考对象"。前两个不需要密钥,专门用来验证评测框架本身是对的。"""
import os
from dataset import BFCL_MINI, GAIA_MINI
class PerfectAgent:
"""满分选手:直接把标准答案背出来。用来验证"判分器不会冤枉好人" """
def predict(self, prompt: str) -> str:
for item in BFCL_MINI:
if item["question"] in prompt:
return "NONE" if not item["ground_truth"] else "\n".join(item["ground_truth"])
for item in GAIA_MINI:
if item["question"] in prompt:
return item["answer"]
return ""
class DumbAgent:
"""摆烂选手:不管问什么都调天气。用来验证"判分器不会放过坏人" """
def predict(self, prompt: str) -> str:
return 'get_weather(city="北京")'
class LLMAgent:
"""真选手:调真实大模型。需要 .env 里的三个变量"""
def __init__(self, temperature: float = 0.0):
from dotenv import load_dotenv
from openai import OpenAI
load_dotenv()
self.client = OpenAI(api_key=os.getenv("LLM_API_KEY"), base_url=os.getenv("LLM_BASE_URL"))
self.model = os.getenv("LLM_MODEL_ID")
self.temperature = temperature
def predict(self, prompt: str) -> str:
try:
resp = self.client.chat.completions.create(
model=self.model,
messages=[{"role": "user", "content": prompt}],
temperature=self.temperature,
)
return resp.choices[0].message.content or ""
except Exception as e: # 网络/限流不能让整场考试中断
return f"[调用失败] {e}"
def get_agent(name: str):
return {"perfect": PerfectAgent, "dumb": DumbAgent, "llm": LLMAgent}[name]()
为什么要专门写两个假选手?这是本章最想传达的思想:
你的评测框架本身也是代码,它也会有 bug。而评估框架的 bug 有个可怕的性质:它伪装成模型的问题。你看到 43% 的准确率,花三天优化提示词,最后发现是解析器认不出模型的输出格式——三天全白费。
解决办法是双向自测,和电子秤先称砝码再称人是一个道理: -
PerfectAgent输出标准答案 → 必须拿 100%。拿不到,说明判分器在冤枉好人(解析器有 bug、归一化规则不对)。 -DumbAgent无脑输出同一个调用 → 必须接近 0%。分数虚高,说明判分器在放过坏人(比如只比了函数名没比参数)。两头都对上了,中间那些真实分数才可信。这套"先验判分器,再考模型"的方法,是评估工程师和调参侠的分界线。
LLMAgent.predict用try/except兜住所有异常并返回[调用失败] ...,理由和第一章工具函数一样:一道题网络抖动,不能让整场考试崩掉;失败信息落进raw字段,事后一看就知道是哪几题没考成。
3.5 文件五:report.py —— 报告生成(约64行)
只有数字不叫报告,能指导下一步动作才叫报告。这个文件做三件事:可视化、错题本、改进建议。
"""把评测结果写成 Markdown 报告 + JSON 明细"""
import json
from datetime import datetime
from pathlib import Path
def bar(acc: float, width: int = 20) -> str:
filled = int(round(acc * width))
return "█" * filled + "░" * (width - filled)
def suggest(m: dict, kind: str) -> list:
tips = []
acc = m["accuracy"]
if acc >= 0.9:
tips.append("整体准确率很高,可以加大数据集规模或提高题目难度再考一轮。")
elif acc >= 0.6:
tips.append("整体及格,重点看下面得分最低的那一组,针对性改提示词或工具描述。")
else:
tips.append("整体偏低,先别调细节:检查提示词格式约定和解析器是否对得上(看 raw 字段)。")
worst = min(m["per_group"].items(), key=lambda kv: kv[1]["accuracy"], default=None)
if worst and worst[1]["accuracy"] < acc:
tips.append(f"最弱的一组是 **{worst[0]}**(准确率 {worst[1]['accuracy']:.0%}),优先攻这里。")
if kind == "bfcl" and m["per_group"].get("irrelevance", {}).get("accuracy", 1) < 1:
tips.append("irrelevance 掉分说明智能体**过度使用工具**——在提示词里补一句「不需要工具时输出 NONE」。")
if abs(m["macro_accuracy"] - acc) > 0.1:
tips.append("宏平均与总准确率差距大,说明各类别难度不均,别只看总分。")
return tips
suggest里那条acc < 0.6的建议值得单独说。 准确率低于 60% 时,第一反应不该是改提示词,而该是怀疑解析器——因为一个能跑通的大模型不至于差到这个地步,更可能是它的输出格式和你的extract_calls对不上。这个判断顺序能省下你大量时间。
def write_report(records, m, kind, group_name, out_dir="./eval_results"):
out = Path(out_dir)
out.mkdir(parents=True, exist_ok=True)
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
json_path = out / f"{kind}_{stamp}.json"
md_path = out / f"{kind}_{stamp}.md"
json_path.write_text(json.dumps({"metrics": m, "records": records},
ensure_ascii=False, indent=2), encoding="utf-8")
lines = [f"# {kind.upper()} 评估报告", "",
f"- 生成时间:{datetime.now():%Y-%m-%d %H:%M:%S}",
f"- 样本总数:{m['total']}",
f"- 总准确率:**{m['accuracy']:.2%}**({m['correct']}/{m['total']})",
f"- 宏平均准确率:{m['macro_accuracy']:.2%}",
f"- 错误率:{m['error_rate']:.2%}", "",
f"## 按{group_name}拆分", "",
f"| {group_name} | 正确 | 总数 | 准确率 | 可视化 |", "|---|---|---|---|---|"]
for g, v in m["per_group"].items():
lines.append(f"| {g} | {v['correct']} | {v['total']} | {v['accuracy']:.2%} | `{bar(v['accuracy'])}` |")
wrong = [r for r in records if not r["correct"]]
lines += ["", f"## 错题本(共 {len(wrong)} 道)", ""]
if not wrong:
lines.append("全对,没有错题。")
for r in wrong[:10]:
lines += [f"### {r['id']}", f"- 问题:{r['question']}",
f"- 标准答案:`{r['ground_truth']}`",
f"- 实际输出:`{r['prediction']}`",
f"- 原始回复(前200字):`{r['raw']}`", ""]
lines += ["## 改进建议", ""] + [f"{i}. {t}" for i, t in enumerate(suggest(m, kind), 1)]
md_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
return md_path, json_path
文件名带时间戳(
{kind}_{stamp})不是为了好看,是为了能做对比。 评估的核心用法是"改一版、跑一次、和上一次比"。带时间戳意味着历史报告永不覆盖,你随时能翻出"上周三那版提示词的分数"。JSON 存明细、Markdown 给人看,分工明确:JSON 用来给脚本做趋势图,Markdown 用来发群里。"错题本"是评估里性价比最高的产物。 数字告诉你有问题,错题本告诉你是什么问题。而且——下一轮评估集的新题就从错题本里来(3.8 节细讲)。
3.6 文件六:run_eval.py —— 命令行入口(约38行)
"""一条命令跑完评测:python run_eval.py --agent perfect --suite all"""
import argparse
import json
from agents import get_agent
from evaluator import eval_bfcl, eval_gaia, metrics
from report import write_report
def run_one(agent, suite):
if suite == "bfcl":
records = eval_bfcl(agent)
m = metrics(records, "category")
return records, m, "类别"
records = eval_gaia(agent)
m = metrics(records, "level")
return records, m, "难度等级"
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--agent", default="perfect", choices=["perfect", "dumb", "llm"])
ap.add_argument("--suite", default="all", choices=["bfcl", "gaia", "all"])
args = ap.parse_args()
agent = get_agent(args.agent)
suites = ["bfcl", "gaia"] if args.suite == "all" else [args.suite]
for suite in suites:
records, m, group_name = run_one(agent, suite)
md, js = write_report(records, m, suite, group_name)
print(f"\n===== {suite.upper()} / 被考对象={args.agent} =====")
print(f"总准确率 {m['accuracy']:.2%} 宏平均 {m['macro_accuracy']:.2%}")
print(json.dumps(m["per_group"], ensure_ascii=False, indent=2))
print(f"报告已写入:{md}")
if __name__ == "__main__":
main()
✅ Checkpoint 3(满分选手,验证判分器不冤枉好人):
python run_eval.py --agent perfect --suite all
真实输出(节选):
===== BFCL / 被考对象=perfect =====
总准确率 100.00% 宏平均 100.00%
{
"irrelevance": {"correct": 3, "total": 3, "accuracy": 1.0},
"multiple": {"correct": 3, "total": 3, "accuracy": 1.0},
"parallel": {"correct": 2, "total": 2, "accuracy": 1.0},
"simple": {"correct": 4, "total": 4, "accuracy": 1.0}
}
===== GAIA / 被考对象=perfect =====
总准确率 100.00% 宏平均 100.00%
{"1": {...1.0}, "2": {...1.0}, "3": {...1.0}}
必须是 100%,四个类别、三个等级一个都不能少。 如果 parallel 不是 1.0,去查 _set_match;如果 irrelevance 不是 1.0,去查 extract_calls 的 NONE 分支;如果 GAIA 的 level 2 不是 1.0,去查 norm_answer 的列表/小数分支。
✅ Checkpoint 4(摆烂选手,验证判分器不放过坏人):
python run_eval.py --agent dumb --suite bfcl
真实输出:
===== BFCL / 被考对象=dumb =====
总准确率 8.33% 宏平均 6.25%
{
"irrelevance": {"correct": 0, "total": 3, "accuracy": 0.0},
"multiple": {"correct": 0, "total": 3, "accuracy": 0.0},
"parallel": {"correct": 0, "total": 2, "accuracy": 0.0},
"simple": {"correct": 1, "total": 4, "accuracy": 0.25}
}
8.33% = 12 道题里蒙对 1 道(就是 s1 北京天气,它恰好瞎猫碰上死耗子)。这个 1/12 反而是好消息:它证明判分器既没有把无脑输出判成对,也没有严苛到连蒙对的都不给分。宏平均 6.25% 比总准确率还低,因为那唯一的对分在 4 题的 simple 组里只值 0.25。
到这一步,你的评测框架已经通过了双向体检,现在它给出的任何数字都可以信任了。 有密钥的话跑 python run_eval.py --agent llm --suite all 考一次真模型,再打开 eval_results/ 里的 Markdown 看错题本。
3.7 文件七:judge.py —— LLM Judge 与 Win Rate(约137行)
前面两个判分器都要求有标准答案。没有标准答案的任务(生成一道数学题、写一篇摘要),就得请 LLM 当评委。
"""LLM Judge(打绝对分)+ Win Rate(两两对比算胜率)。
判分对象换成"生成的题目质量",这是评估自动造数据的标准做法。
"""
import json
import random
import re
JUDGE_PROMPT = """请评估下面这道数学题的质量。
题目:{problem}
答案:{answer}
解答:{solution}
从四个维度打分(每项 1~5 分整数):
1. correctness 正确性:数学逻辑对不对、答案准不准
2. clarity 清晰度:题面表述清不清楚
3. difficulty_match 难度匹配:难度是否符合竞赛中等偏难水平
4. completeness 完整性:解答步骤是否完整
只输出 JSON,不要任何其他文字:
{{"correctness": 5, "clarity": 4, "difficulty_match": 4, "completeness": 5, "comments": "理由"}}"""
COMPARE_PROMPT = """比较下面两道数学题的质量,判断哪个更好。
【题目 A】
{a}
【题目 B】
{b}
比较维度:逻辑严谨性、表述清晰度、难度合理性、解答完整性。
只输出 JSON:{{"winner": "A", "reason": "理由"}}
winner 只能是 A、B 或 Tie。"""
DIMS = ["correctness", "clarity", "difficulty_match", "completeness"]
def _parse_json(text: str):
"""LLM 爱在 JSON 外面套代码块或解释,这里做健壮提取"""
if not text:
return None
m = re.search(r"\{.*\}", text, re.S)
if not m:
return None
try:
return json.loads(m.group(0))
except json.JSONDecodeError:
return None
def _fmt(p):
return f"问题:{p['problem']}\n答案:{p['answer']}\n解答:{p.get('solution', '(无)')}"
提示词里的
{{和}}不是笔误:因为要用.format()填模板,单个{}会被当成占位符,想输出真正的花括号必须写两个。这是写 LLM 提示词模板时最常见的报错来源(KeyError: '"correctness"')。为什么打分维度必须写死成 JSON 字段? 让模型自由发挥"你觉得这题怎么样",你得到的是一段没法统计的散文。固定维度 + 固定 1~5 分 + 只输出 JSON,才能把主观评价变成可聚合的数字。这和第八章讲的结构化输出是同一个技巧。
def judge_batch(judge, problems, max_samples=None):
"""judge 需要一个 predict(prompt) -> str 方法"""
results, bad = [], 0
for p in (problems[:max_samples] if max_samples else problems):
raw = judge.predict(JUDGE_PROMPT.format(problem=p["problem"], answer=p["answer"],
solution=p.get("solution", "(无)")))
data = _parse_json(raw)
if not data or any(d not in data for d in DIMS):
bad += 1 # 解析失败的样本剔除,不能当 0 分算进去
continue
scores = {}
for d in DIMS:
try:
scores[d] = min(5, max(1, int(data[d]))) # 越界值夹到 1~5
except (TypeError, ValueError):
scores[d] = 3
avg = sum(scores.values()) / len(DIMS)
results.append({"problem_id": p.get("problem_id", "?"), **scores,
"avg": round(avg, 3), "comments": str(data.get("comments", ""))[:200]})
n = len(results)
if n == 0:
return {"metrics": {"valid": 0, "parse_failed": bad}, "results": []}
metrics = {
"valid": n,
"parse_failed": bad,
"average_score": round(sum(r["avg"] for r in results) / n, 3),
"pass_rate": round(sum(r["avg"] >= 3.5 for r in results) / n, 3), # 及格线 3.5
"excellent_rate": round(sum(r["avg"] >= 4.5 for r in results) / n, 3), # 优秀线 4.5
"per_dimension": {d: round(sum(r[d] for r in results) / n, 3) for d in DIMS},
}
return {"metrics": metrics, "results": results}
bad += 1; continue这两行是本文件最重要的工程决策。 评委的 JSON 解析失败时,有两种处理方式: - 错误做法:当 0 分算进平均。这会把"评委不守格式"混进"生成质量差"里——你改生成器改半天,其实要改的是评委的提示词。 - 正确做法:剔除该样本,单独统计parse_failed。如果parse_failed占比很高(比如 >10%),说明评委不可用,得换模型或加强提示词约束,这时候的average_score根本不该看。把"测量失败"和"被测对象差"分开统计,是所有测量系统的基本素养。
min(5, max(1, int(...)))这一夹,防的是模型打出 8 分、0 分、"4.5"这类越界值——LLM 的输出永远要当不可信输入处理,即便它扮演的是权威评委。
Win Rate 部分,重点在那个 debias 参数:
def win_rate(judge, generated, reference, num_comparisons=10, seed=42, debias=True):
"""成对对比。debias=True 时每对正反各问一次,消除"位置偏见" """
rng = random.Random(seed)
win = loss = tie = invalid = 0
details = []
for i in range(num_comparisons):
g = generated[i % len(generated)]
r = rng.choice(reference)
rounds = [("gen_first", g, r), ("ref_first", r, g)] if debias else [("gen_first", g, r)]
votes = []
for tag, first, second in rounds:
data = _parse_json(judge.predict(COMPARE_PROMPT.format(a=_fmt(first), b=_fmt(second))))
w = (data or {}).get("winner")
if w not in ("A", "B", "Tie"):
votes.append(None)
continue
if w == "Tie":
votes.append("tie")
elif tag == "gen_first":
votes.append("gen" if w == "A" else "ref")
else:
votes.append("ref" if w == "A" else "gen")
clean = [v for v in votes if v]
if not clean:
invalid += 1
outcome = "invalid"
elif clean.count("gen") > clean.count("ref"):
win += 1
outcome = "gen"
elif clean.count("ref") > clean.count("gen"):
loss += 1
outcome = "ref"
else:
tie += 1
outcome = "tie"
details.append({"pair": i, "votes": votes, "outcome": outcome})
total = win + loss + tie
metrics = {
"comparisons": num_comparisons,
"valid": total,
"invalid": invalid,
"wins": win, "losses": loss, "ties": tie,
"win_rate": round(win / total, 3) if total else 0.0,
"loss_rate": round(loss / total, 3) if total else 0.0,
"tie_rate": round(tie / total, 3) if total else 0.0,
}
return {"metrics": metrics, "details": details}
rounds那一行就是"去偏"的全部实现,值得逐字读懂:LLM 评委有一个被反复验证的毛病叫位置偏见(position bias)——同样两个候选,放在 A 位的天然更容易赢。解决办法简单到有点狡猾:同一对内容问两遍,第二遍把 A、B 调个位置。
("gen_first", g, r)和("ref_first", r, g)就是这两遍。关键在计票:第一遍答"A 更好"记
gen赢;第二遍答"A 更好"要记ref赢(因为这次 A 位放的是真题)。只有两遍投票指向同一个赢家,才算真的赢;两遍互相矛盾(说明评委只是在选位置而不是选质量),记为tie。
seed=42让rng.choice可复现——评估结果必须能复现,否则你没法判断分数变化是改动带来的还是随机波动。
3.8 文件八:judge_demo.py —— 用"故意有毛病的评委"验证去偏(约50行)
要验证去偏机制有效,最干净的办法是造一个偏见拉满的假评委:
"""离线演示 Judge 与 Win Rate:用一个"故意有毛病的假评委",验证统计逻辑扛不扛得住。
换成真评委只要把 BiasedJudge() 换成 agents.LLMAgent()。
"""
import json
from judge import judge_batch, win_rate
GENERATED = [
{"problem_id": "gen1", "problem": "设正整数 n 满足 n^2 的各位数字之和为 9,求最小的 n。",
"answer": "3", "solution": "枚举 n=1,2,3;3^2=9,数字和为 9,故最小 n=3。"},
{"problem_id": "gen2", "problem": "求 1 到 100 中既是 3 的倍数又是 5 的倍数的数的个数。",
"answer": "6", "solution": "即 15 的倍数:15,30,45,60,75,90,共 6 个。"},
{"problem_id": "gen3", "problem": "算一下那个东西。", "answer": "?", "solution": ""},
]
REFERENCE = [
{"problem_id": "aime1", "problem": "设 x,y 为正整数且 x+y=20,求 xy 的最大值。",
"answer": "100", "solution": "由均值不等式,x=y=10 时 xy 最大为 100。"},
{"problem_id": "aime2", "problem": "求 2^10 除以 7 的余数。",
"answer": "2", "solution": "2^3≡1 (mod 7),2^10=2^9·2≡2。"},
]
class BiasedJudge:
"""假评委,故意带两个真实评委也有的毛病,用来验证我们的统计代码能不能兜住:
毛病一:位置偏见——对比时永远选 A;
毛病二:只看解答长短打分——解答写得长就给高分。
"""
def predict(self, prompt: str) -> str:
if prompt.lstrip().startswith("比较"):
return '{"winner": "A", "reason": "(假评委的位置偏见:永远选前一个)"}'
solution = prompt.split("解答:", 1)[1].split("\n\n从四个维度")[0].strip()
score = 5 if len(solution) > 20 else 2
return json.dumps({"correctness": score, "clarity": score,
"difficulty_match": max(1, score - 1), "completeness": score,
"comments": f"解答长度 {len(solution)}"}, ensure_ascii=False)
if __name__ == "__main__":
j = BiasedJudge()
r1 = judge_batch(j, GENERATED)
print("=== LLM Judge ===")
print(json.dumps(r1["metrics"], ensure_ascii=False, indent=2))
r2 = win_rate(j, GENERATED, REFERENCE, num_comparisons=6)
print("\n=== Win Rate(已做正反双问去偏) ===")
print(json.dumps(r2["metrics"], ensure_ascii=False, indent=2))
r3 = win_rate(j, GENERATED, REFERENCE, num_comparisons=6, debias=False)
print("\n=== 同样的数据,不去偏 ===")
print(json.dumps(r3["metrics"], ensure_ascii=False, indent=2))
✅ Checkpoint 5:python judge_demo.py,真实输出:
=== LLM Judge ===
{
"valid": 3,
"parse_failed": 0,
"average_score": 3.75,
"pass_rate": 0.667,
"excellent_rate": 0.667,
"per_dimension": {
"correctness": 4.0, "clarity": 4.0,
"difficulty_match": 3.0, "completeness": 4.0
}
}
=== Win Rate(已做正反双问去偏) ===
{ "comparisons": 6, "valid": 6, "invalid": 0,
"wins": 0, "losses": 0, "ties": 6,
"win_rate": 0.0, "loss_rate": 0.0, "tie_rate": 1.0 }
=== 同样的数据,不去偏 ===
{ "comparisons": 6, "valid": 6, "invalid": 0,
"wins": 6, "losses": 0, "ties": 0,
"win_rate": 1.0, "loss_rate": 0.0, "tie_rate": 0.0 }
请盯着最后两块看三十秒:同一个评委、同一批数据、同一份代码,只把 debias 从 True 改成 False,胜率就从 0% 变成 100%。
这就是位置偏见的真实杀伤力。不去偏时,这个假评委永远选 A、而 A 位永远放生成题,于是"我们的生成器完胜真题"——一个 100% 的漂亮数字,完全是假的。去偏后,正反两遍投票互相矛盾,全部记为 tie,胜率归零,同时 tie_rate: 1.0 这个异常值直接告诉你"评委没在比质量"。
回头看 2.4 节那句"Win Rate 显著高于 50% 通常不是你太强,而是评委的标准偏了"——你现在亲手复现了这句话。 这也是为什么真实工程里报告 Win Rate 必须同时报
tie_rate和invalid:tie_rate 接近 1 说明评委在乱投,win_rate 接近 1 说明评委被你的格式套路了,两种情况下那个胜率数字都没有意义。
per_dimension里difficulty_match: 3.0明显低于其他三项,而假评委的规则里difficulty_match是score - 1——分维度统计能把"评委在哪个维度上系统性偏低"暴露出来。真实场景里这一列是最有用的:如果completeness常年 2 分,那就是你的生成提示词没要求写完整解答,和"题目质量差"是两回事。
gen3(那道故意写废的"算一下那个东西")在这个假评委下拿了 2 分、被判不及格,所以pass_rate是 2/3 = 0.667。评估集里放一条已知是垃圾的样本,是检验评委灵敏度的常用手法——评委连它都给高分,这评委就废了。
3.9 LLM Judge 的三个偏见与工程对策
| 偏见 | 表现 | 对策 | 本章实现 |
|---|---|---|---|
| 位置偏见 | 放前面的候选更容易赢 | 正反各问一遍,只有两遍一致才算赢 | win_rate(debias=True) |
| 长度偏见 | 更长的回答天然得高分 | 提示词里明确"长度不是评分标准";必要时统计答案长度与得分的相关性 | BiasedJudge 演示的正是这个毛病 |
| 自我偏好 | 模型偏爱自己家族生成的文本 | 评委和被评者用不同厂商的模型;或用多个评委投票(评审团) | 把 judge 换成另一家的 LLMAgent |
最重要的一条落地纪律:评委模型不要和被评模型是同一个。 用 GPT 生成、再用 GPT 评分,分数会系统性偏高。生产做法是"A 家生成、B 家评分",或者请 2~3 个不同厂商的评委投票,并统计它们的分歧率——分歧率高的样本正是最该送去人工看的样本。这条思路和第十一章 RLAIF(用强模型替代人工标注)是一体两面:都建立在"评委必须比被评者更可信"这个前提上。
3.10 从迷你版换成真实基准
框架跑通后,把数据源换掉就能上真基准,判分算法一行都不用改:
换 BFCL 官方数据:
git clone https://github.com/ShishirPatil/gorilla.git
# 题目:gorilla/berkeley-function-call-leaderboard/bfcl_eval/data/BFCL_v4_*.json
# 标准答案:同目录下的 data/possible_answer/ 里(最容易漏的一步!)
pip install "numpy==1.26.4" bfcl-eval # 官方包锁 numpy<=2.0.0,要单独装
把 dataset.py 的 load() 改成读这两个 JSON 并对齐 id 即可。官方还提供 CLI 复核:bfcl evaluate --model <名字> --test-category simple_python。
换 GAIA 官方数据:需要 HuggingFace 授权(网页上点 Request access,通常秒批),然后:
from huggingface_hub import snapshot_download
snapshot_download(repo_id="gaia-benchmark/GAIA", repo_type="dataset",
local_dir="./data/gaia", token=os.getenv("HF_TOKEN"))
# 数据在 ./data/gaia/2023/{validation,test}/metadata.jsonl
换 AIME 真题做 Win Rate 的参照物:HuggingFace 上的 math-ai/aime25(30 题 JSONL,字段 id/problem/answer,注意没有 solution 字段)。
三个真实基准的共同坑,提前告诉你:
- GAIA 必须配官方系统提示词,要求模型以
FINAL ANSWER: [答案]结尾、数字不带逗号单位、字符串不带冠词。不签这个"格式合同",你的归一化和正则抽取全部失效——这是 GAIA 复现的第一大坑。- BFCL 的标准答案在
possible_answer/子目录,不在题目文件里。很多人 clone 完找不到答案就以为要自己标。- 不带工具的智能体在 GAIA Level 1 上都会很低分。GAIA 的题本来就假设你能联网、能读文件——拿一个纯聊天模型去考,低分是正常的,不是你的框架错了。
四、踩坑与专家提示
| 现象 | 根因 | 解法 |
|---|---|---|
| 准确率异常低(<40%) | 十有八九是解析器和模型输出格式对不上,不是模型差 | 先看报告里的 raw 字段;确认提示词的格式约定和 extract_calls 一致 |
| 准确率异常高(接近满分) | 评估集太简单,或判分器在放过坏人 | 跑 --agent dumb,分数应该接近 0;不是就去查判分器 |
| 同一模型两次跑分不一样 | temperature 不为 0;或采样有随机性 |
评测时一律 temperature=0;random 一律给 seed |
| 总分很好但线上翻车 | 评估集分布和真实用户不一致 | 评估集必须从真实 bad case 里长,不能凭空编 |
| 改了提示词分数涨了 5%,不确定是不是真的 | 样本量太小,5% 可能就是 1 道题 | 12 道题的 8% 波动毫无意义;至少 50~100 条,并报"几分之几"而非只报百分比 |
| Judge 打分全是 4 分 | 评委"和事佬倾向",不敢给极端分 | 提示词里给每个分档的明确标准(什么样算 5 分、什么样算 2 分);放一条已知垃圾样本校准 |
| Win Rate 高得离谱 | 位置偏见 / 长度偏见 | 开 debias;同时看 tie_rate 和 invalid |
| 评估一跑就是几十块钱 | 每题都调 LLM,Judge 还要正反两问 | 先用 PerfectAgent/DumbAgent 免费调通流程;加 max_samples 小批量试跑;结果缓存到本地别重复调 |
四条专家纪律:
- 评估集从 bad case 里长出来,不是凭空编的。 线上每出现一个用户投诉、每发现一次工具乱调,就把它变成评估集里的一条新题。半年后你会有一份别人抄不走的资产——它精确刻画了你的业务里模型会怎么犯错。这也是第十一章说"应用工程师最该做的是建评估集"的原因:奖励函数的天花板就是评估集的质量。
- 留一份隐藏测试集,永不用于调优。 只用一份评估集反复调提示词,等于让模型"背答案"(学术上叫过拟合评估集)。做法:把评估集按 7:3 分成开发集和隐藏集,平时只看开发集,发版前才拿隐藏集跑一次——如果两者分差很大,说明你之前的优化是刷题刷出来的。
- 每次评估都记录完整环境。 模型名、模型版本、温度、提示词版本、评估集版本、判分器版本。少记一样,三个月后你就无法解释"为什么当时是 85% 现在是 79%"。这就是
report.py里带时间戳存 JSON 的意义。 - 准确率不是唯一指标,生产环境还要看效率和成本。 同样 85% 的准确率,一个平均 3 秒 800 token,一个平均 12 秒 5000 token,后者在生产里是不可用的。建议在
records里再加两个字段:latency_ms和tokens——评估表里没有成本列,技术选型就会脱离现实。
面试视角
-
"你怎么评估你做的智能体?"——这是本章的正面题。答一套流程而不是一个指标:"分层评估——工具调用层用 AST 匹配(函数名精确 + 参数集合相等 + 参数值语义等价),最终答案层用准精确匹配(先归一化再比对),没有标准答案的生成任务用 LLM Judge 打多维分 + 和真实样本对比算 Win Rate。评估集从线上 bad case 积累,按类别分层统计并同时看宏平均,另外留隐藏测试集防止过拟合。"
-
"为什么不能直接用字符串匹配判工具调用?"——举 2.2 节那四个例子:参数顺序不同、等价表达式,字符串匹配会冤枉好人。再补一句关键认识:判分器的错误会伪装成模型的错误,你会花时间去修一个不存在的问题,所以判分器上线前必须用满分选手和摆烂选手双向自测。
-
"Win Rate 是不是越高越好?"——这题在筛"有没有真做过评估"。答:不是,理想值是 50%。参照物是真实样本,50% 意味着生成质量与真实样本不可区分,这已经是天花板。显著高于 50% 更可能是评委有偏(位置偏见、长度偏见、自我偏好),而不是生成器超越了真题。能顺势讲出"正反双问去偏 + 同时看 tie_rate" 的具体做法,基本就过了。
-
"LLM 当评委靠得住吗?"——分三层答:①靠得住的部分——一致性高、成本远低于人工、可扩展;②不靠得住的部分——三个偏见(位置/长度/自我偏好)、"和事佬倾向"、解析失败;③工程对策——评委与被评者换不同厂商的模型、多评委投票并看分歧率、把解析失败单独统计而不是当 0 分、关键结论人工抽查。能说出"把测量失败和被测对象差分开统计"这一条,是加分项。
-
"评估集要多大?"——答思路而不是数字:取决于你要检测多小的差异。12 道题里 1 道题就是 8%,所以小评估集只能发现"崩了"级别的问题;想可靠地检测 5% 的提升,量级要到几百条。实践做法是分层抽样:每个能力类别至少 20~30 条,按线上流量占比配权重。
划重点
- 没有评估的调优全是玄学。 手动试两三个例子是样本量为 2 的实验,得不出任何结论;评估的价值是"可重复 + 出数字 + 能对比"。
- 三个判分算法各管一段:AST 匹配判工具调用(名字精确 / 参数集合相等 / 值语义等价)、准精确匹配判最终答案(先归一化再精确比)、LLM Judge + Win Rate 判没有标准答案的生成质量。
- 判分器必须先自测再使用:
PerfectAgent必须拿 100%(不冤枉好人),DumbAgent必须接近 0%(不放过坏人)。本章实测 100% vs 8.33%,这套双向体检是整章最该带走的方法。 irrelevance是最容易被忽略、却最该考的类别:生产环境翻车多半不是工具用错,而是不该调工具时乱调。把"什么都不调"编码成一条正常的标准答案(空列表),判分逻辑一行特判都不用加。- LLM Judge 有三个偏见(位置 / 长度 / 自我偏好)。位置偏见用正反双问去偏,本章实测同一份数据
debias=False时胜率 100%、debias=True时降为 0% 全 tie——去偏不是理论洁癖,是分数真假的分水岭。 - Win Rate 的理想值是 50% 不是 100%;报告胜率必须同时报
tie_rate和invalid,否则那个数字没有意义。 - 评估集是长出来的资产:从线上 bad case 积累、按类别分层、同时看宏平均和总分、留一份隐藏测试集防止刷题。这也是应用工程师在整个训练/优化链条里最有价值的一件事(呼应第十一章)。
全书十二章学完之后
恭喜你走完了原书第一部分到第三部分的全部内容!回顾一下这条路:
- 第 1~3 章:搞清楚智能体是什么、大模型底层怎么运作(手敲注意力与采样器)、怎么跟它对话;
- 第 4~7 章:徒手三大范式 → 见识四大框架 → 自己造框架,内功成型;
- 第 8~9 章:给智能体装上记忆与 RAG,再学会管理上下文这个稀缺资源;
- 第 10~11 章:协议层(MCP/A2A/ANP)让智能体接入世界,训练层(SFT/GRPO/LoRA)让模型为你的场景专精;
- 第 12 章:学会"考试",让前面所有优化都有据可依。
接下来做什么?
- 把评测框架接到你自己的项目上。 这是投入产出比最高的一步——有了数字,你之后的每次改动都不再是玄学。
- 去 docs/2 做完整实战项目。 那 18 篇是同一套知识的项目化版本:从环境搭建到手敲 Agent 框架(第 18 篇 15 文件)、深度研究智能体(第 13 篇)、多智能体旅行规划(第 12 篇)、记忆小镇(第 14 篇),以及第 16 篇那份"16 个坑"清单——生产环境的血泪都在里面。
- 挑一个真实痛点做到上线。 会造轮子 + 会考试 + 踩过坑,你已经具备了 Agent 开发工程师的完整技能栈;剩下的差距只能靠一个真实用户在用的项目来填。