全栈开发知识库

第十二章 智能体性能评估

一句话总结

前十一章你一直在"造",这一章学"考"。没有评估的智能体调优全是玄学——你以为改好了,其实只是随手试的那两个问题碰巧过了。本章手敲一套 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 匹配的判定规则(官方定义三条):

  1. 函数名完全一致(精确匹配,get_weather ≠ get_temperature);
  2. 参数键值对集合相等(忽略顺序);
  3. 每个参数值语义等价(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() vs get_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 条 各带一条样例。

数据集设计的三个刻意选择,比代码本身重要:

  1. irrelevance 的 ground_truth 是空列表 []。这不是"没写标准答案",而是"标准答案就是什么都不调"。判分时空列表 vs 空列表 = 匹配成功,一行特判都不用加——把"不该做"编码成一条正常的标准答案,是评估设计里很漂亮的一手。
  2. m3(杭州热不热)故意不带"天气"二字。它考的是语义理解:模型得知道"热不热"要调 get_weather。如果你的题面里总带工具名的关键词,那你考的是关键词匹配,不是智能。
  3. 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 字段)。

三个真实基准的共同坑,提前告诉你:

  1. GAIA 必须配官方系统提示词,要求模型以 FINAL ANSWER: [答案] 结尾、数字不带逗号单位、字符串不带冠词。不签这个"格式合同",你的归一化和正则抽取全部失效——这是 GAIA 复现的第一大坑。
  2. BFCL 的标准答案在 possible_answer/ 子目录,不在题目文件里。很多人 clone 完找不到答案就以为要自己标。
  3. 不带工具的智能体在 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 小批量试跑;结果缓存到本地别重复调

四条专家纪律:

  1. 评估集从 bad case 里长出来,不是凭空编的。 线上每出现一个用户投诉、每发现一次工具乱调,就把它变成评估集里的一条新题。半年后你会有一份别人抄不走的资产——它精确刻画了你的业务里模型会怎么犯错。这也是第十一章说"应用工程师最该做的是建评估集"的原因:奖励函数的天花板就是评估集的质量。
  2. 留一份隐藏测试集,永不用于调优。 只用一份评估集反复调提示词,等于让模型"背答案"(学术上叫过拟合评估集)。做法:把评估集按 7:3 分成开发集和隐藏集,平时只看开发集,发版前才拿隐藏集跑一次——如果两者分差很大,说明你之前的优化是刷题刷出来的。
  3. 每次评估都记录完整环境。 模型名、模型版本、温度、提示词版本、评估集版本、判分器版本。少记一样,三个月后你就无法解释"为什么当时是 85% 现在是 79%"。这就是 report.py 里带时间戳存 JSON 的意义。
  4. 准确率不是唯一指标,生产环境还要看效率和成本。 同样 85% 的准确率,一个平均 3 秒 800 token,一个平均 12 秒 5000 token,后者在生产里是不可用的。建议在 records 里再加两个字段:latency_ms 和 tokens——评估表里没有成本列,技术选型就会脱离现实。

面试视角

  1. "你怎么评估你做的智能体?"——这是本章的正面题。答一套流程而不是一个指标:"分层评估——工具调用层用 AST 匹配(函数名精确 + 参数集合相等 + 参数值语义等价),最终答案层用准精确匹配(先归一化再比对),没有标准答案的生成任务用 LLM Judge 打多维分 + 和真实样本对比算 Win Rate。评估集从线上 bad case 积累,按类别分层统计并同时看宏平均,另外留隐藏测试集防止过拟合。"

  2. "为什么不能直接用字符串匹配判工具调用?"——举 2.2 节那四个例子:参数顺序不同、等价表达式,字符串匹配会冤枉好人。再补一句关键认识:判分器的错误会伪装成模型的错误,你会花时间去修一个不存在的问题,所以判分器上线前必须用满分选手和摆烂选手双向自测。

  3. "Win Rate 是不是越高越好?"——这题在筛"有没有真做过评估"。答:不是,理想值是 50%。参照物是真实样本,50% 意味着生成质量与真实样本不可区分,这已经是天花板。显著高于 50% 更可能是评委有偏(位置偏见、长度偏见、自我偏好),而不是生成器超越了真题。能顺势讲出"正反双问去偏 + 同时看 tie_rate" 的具体做法,基本就过了。

  4. "LLM 当评委靠得住吗?"——分三层答:①靠得住的部分——一致性高、成本远低于人工、可扩展;②不靠得住的部分——三个偏见(位置/长度/自我偏好)、"和事佬倾向"、解析失败;③工程对策——评委与被评者换不同厂商的模型、多评委投票并看分歧率、把解析失败单独统计而不是当 0 分、关键结论人工抽查。能说出"把测量失败和被测对象差分开统计"这一条,是加分项。

  5. "评估集要多大?"——答思路而不是数字:取决于你要检测多小的差异。12 道题里 1 道题就是 8%,所以小评估集只能发现"崩了"级别的问题;想可靠地检测 5% 的提升,量级要到几百条。实践做法是分层抽样:每个能力类别至少 20~30 条,按线上流量占比配权重。


划重点

  1. 没有评估的调优全是玄学。 手动试两三个例子是样本量为 2 的实验,得不出任何结论;评估的价值是"可重复 + 出数字 + 能对比"。
  2. 三个判分算法各管一段:AST 匹配判工具调用(名字精确 / 参数集合相等 / 值语义等价)、准精确匹配判最终答案(先归一化再精确比)、LLM Judge + Win Rate 判没有标准答案的生成质量。
  3. 判分器必须先自测再使用:PerfectAgent 必须拿 100%(不冤枉好人),DumbAgent 必须接近 0%(不放过坏人)。本章实测 100% vs 8.33%,这套双向体检是整章最该带走的方法。
  4. irrelevance 是最容易被忽略、却最该考的类别:生产环境翻车多半不是工具用错,而是不该调工具时乱调。把"什么都不调"编码成一条正常的标准答案(空列表),判分逻辑一行特判都不用加。
  5. LLM Judge 有三个偏见(位置 / 长度 / 自我偏好)。位置偏见用正反双问去偏,本章实测同一份数据 debias=False 时胜率 100%、debias=True 时降为 0% 全 tie——去偏不是理论洁癖,是分数真假的分水岭。
  6. Win Rate 的理想值是 50% 不是 100%;报告胜率必须同时报 tie_rate 和 invalid,否则那个数字没有意义。
  7. 评估集是长出来的资产:从线上 bad case 积累、按类别分层、同时看宏平均和总分、留一份隐藏测试集防止刷题。这也是应用工程师在整个训练/优化链条里最有价值的一件事(呼应第十一章)。

全书十二章学完之后

恭喜你走完了原书第一部分到第三部分的全部内容!回顾一下这条路:

  • 第 1~3 章:搞清楚智能体是什么、大模型底层怎么运作(手敲注意力与采样器)、怎么跟它对话;
  • 第 4~7 章:徒手三大范式 → 见识四大框架 → 自己造框架,内功成型;
  • 第 8~9 章:给智能体装上记忆与 RAG,再学会管理上下文这个稀缺资源;
  • 第 10~11 章:协议层(MCP/A2A/ANP)让智能体接入世界,训练层(SFT/GRPO/LoRA)让模型为你的场景专精;
  • 第 12 章:学会"考试",让前面所有优化都有据可依。

接下来做什么?

  1. 把评测框架接到你自己的项目上。 这是投入产出比最高的一步——有了数字,你之后的每次改动都不再是玄学。
  2. 去 docs/2 做完整实战项目。 那 18 篇是同一套知识的项目化版本:从环境搭建到手敲 Agent 框架(第 18 篇 15 文件)、深度研究智能体(第 13 篇)、多智能体旅行规划(第 12 篇)、记忆小镇(第 14 篇),以及第 16 篇那份"16 个坑"清单——生产环境的血泪都在里面。
  3. 挑一个真实痛点做到上线。 会造轮子 + 会考试 + 踩过坑,你已经具备了 Agent 开发工程师的完整技能栈;剩下的差距只能靠一个真实用户在用的项目来填。
本页由 Markdown 自动渲染 · 共 628 篇笔记