09 评测
“你怎么知道改完之后变好了?“这是 AI 应用岗面试里区分”做过 Demo”和”做过工程”最直接的一个问题。改一句提示词、换一个模型、加一个知识库,看几个例子觉得不错就上线,是最常见的做法,也是最容易出事的做法。
这篇要讲清楚:Agent 为什么难评测;评测集怎么建;用代码、模型、人工三种方式评分各自的坑;样本少、结果有随机性时怎么下结论;pass@k 和 pass^k 的区别;回归测试怎么接进发布流程。最后把我项目从第一版评测到留出集评测踩过的坑完整走一遍。
怎么读:
前置:05 Agent 循环、08 RAG。
第一部分 速记页
| 问题 | 一句话答案 |
|---|---|
| 为什么 Agent 难评测 | 多步、调工具、改外部状态、同一任务每次路径不同,还可能用意想不到的方式”完成” |
| 评测的基本单位 | 任务(一道题)、试验(一次尝试)、评分器(判断对错的逻辑)、轨迹(完整过程记录)、结果(最终环境状态) |
| 评测集从哪来 | 真实失败案例、用户反馈、线上日志抽样;先 20 到 50 道题起步,不必一开始就几百道 |
| 评分器三类 | 代码检查(快、稳、死板)、模型当裁判(灵活、要校准)、人工(准、贵、慢) |
| 优先用什么评分 | 能检查最终状态就检查状态:测试是否通过、文件是否生成、数据库记录是否存在 |
| 模型当裁判的偏差 | 位置偏差、偏爱长回答、偏爱自己风格的回答;要用人工标注校准 |
| 怎么衡量裁判可靠 | 和人工标注比一致率,看 Cohen’s kappa,特别看它能不能抓到失败的样本 |
| 一次跑的结果能信吗 | 不能。每个配置多跑几轮,报平均值和波动 |
| 怎么判断差异是真的 | 看差值和波动的相对大小、置信区间;同一批题做配对比较;样本少只能说趋势 |
| pass@k | k 次里至少成功一次的概率,衡量”能不能做到” |
| pass^k | k 次全部成功的概率,衡量”稳不稳”;面向用户的产品更该看这个 |
| 能力评测 vs 回归评测 | 前者通过率本来就低,看能力上限;后者应接近 100%,防止改坏 |
| 评测集饱和 | 所有配置都 100% 通过,就没法区分好坏了,要加难题 |
| 留出集 | 调参和提炼经验时没用过的数据,防止”提前看到答案” |
| 读轨迹 | 分数只告诉你结果,轨迹告诉你原因;评分器自己的 bug 也靠读轨迹发现 |
| 公开基准还能信吗 | 要打折扣。OpenAI 2026-02 停报 SWE-bench Verified:审计 138 道难题 59.4% 题目或测试有问题,还有模型见过答案的迹象,改推 SWE-bench Pro |
| 环境配置影响分数吗 | 影响。Anthropic 2026-02 测得只改容器资源限制,Terminal-Bench 2.0 分数差 6 个百分点;排行榜上差距小于 3 个点要存疑 |
| 模型会”作弊”吗 | 会。Claude Opus 4.6 在 BrowseComp 上有 2 次自己推断出在被评测,找到加密的答案文件并解密 |
| 我项目最大的教训 | 验收标准写错了两次,一次把没编译任何东西的运行判成功,一次把正确结果判失败 |
第二部分 易混对照
| 容易混的两个 | 区别 | 一句话记法 |
|---|---|---|
| 离线评测 vs 线上评测 | 离线用固定测试集,发布前跑;线上看真实用户数据,发布后监控 | 考试 vs 实战 |
| 能力评测 vs 回归评测 | 能力评测找上限,通过率低正常;回归评测守底线,掉分就是改坏了 | 能跳多高 vs 别摔倒 |
| 结果评分 vs 过程评分 | 结果看最终状态对不对;过程看每一步做得合不合理 | 看成品 vs 看做法 |
| pass@k vs pass^k | 前者 k 次至少一次成功;后者 k 次全部成功 | 能做到 vs 每次都能做到 |
| 标准差 vs 标准误 | 标准差描述数据本身有多分散;标准误描述平均值这个估计有多不确定,随样本增多变小 | 数据的波动 vs 均值的波动 |
| 样本标准差 vs 总体标准差 | 分母分别是 n-1 和 n;样本少时前者更大 | 我项目报告里用的是总体标准差 |
| 一致率 vs kappa | 一致率是两者判断相同的比例;kappa 扣除了”碰巧相同”的部分 | 表面一致 vs 真实一致 |
| 过拟合测试集 vs 真实提升 | 反复针对同一批题调,分数会涨但换题就不行 | 背答案 vs 学会了 |
| 评测集泄漏 vs 留出集 | 泄漏是测试数据被用在了提示词、知识库或调参里;留出集是刻意没用过的数据 | 我项目换 5 个新项目测知识库 |
| 模型结束 vs 评分通过 | 模型说完成只是自述;评分器检查的是外部状态 | 05 篇讲过,这里是评测版本 |
第三部分 面试口述稿
3.1 “你怎么证明改动让效果变好了?”
我会做一个固定的评测集,改动前后用同样的题各跑几轮,比较结果。有几个要点。
一是评分不能看模型自己说的,要检查外部结果。我项目是交叉编译,模型说编译成功不算,程序会重新构建一遍,并检查产物是不是目标架构的 ELF 文件。
二是每个配置要跑多轮,因为同一个任务每次路径不同。我项目里同一个项目跑三次,步数标准差平均有两三步,单次对比得出的结论很可能是随机波动。
三是判断差异要看波动。我给自己定的规则是,差距不到一倍标准差就只能算趋势,不能算确认。
四是不能只看总分,要分类看,还要去读轨迹,确认分数变化的原因真的是改动带来的。
3.2 “评测集怎么建?”
我会从真实的失败案例开始,而不是凭空编题。先有二三十道就能开始用,之后不断把线上或测试中遇到的新失败加进去。
题目要覆盖不同难度和类型,并分组统计,不然总分会掩盖问题。每道题要写清楚成功标准,最好能用程序检查。
还有两件事容易被忽略。第一是防泄漏:用来调提示词、提炼知识库的题,不能再拿来证明效果,要留一部分完全没用过的题当留出集。我项目提炼知识库用了原来 10 个项目的运行记录,测知识库效果时就换了 5 个从没跑过的项目。第二是防饱和:所有配置都 100% 通过,评测集就失去区分度了,我项目的第三轮就是 60 次全部通过,需要加更难的项目。
3.3 “用大模型当裁判靠谱吗?”
能用,但要校准。有研究发现强模型当裁判和人类判断的一致率能超过 80%,和人与人之间的一致程度差不多,但同时存在几种偏差:换一下两个回答的顺序,判断可能就变了;倾向于给长回答高分;倾向于偏爱和自己风格相近的回答。
我的做法是先人工标注一批样本,拿裁判的判断和人工对比,除了看一致率,还要看 kappa,因为如果大部分样本都是通过的,裁判全判通过一致率也很高。更要看它对失败样本的识别率,这才是评测真正要抓的。评分标准尽量写成具体的是非判断,比如”回答是否引用了资料里没有的数字”,而不是”打 1 到 10 分”。能用代码检查的部分,就不用模型。
3.4 “Agent 结果有随机性,样本又少,怎么下结论?”
首先承认样本少时能说的有限。我会做几件事:每个配置跑多轮,报平均值和标准差,不只报一个数;比较两个配置时,用同一批题做配对比较,看每道题上的差值,因为不同题之间难度差异很大,混在一起波动会很大;算一个置信区间,区间跨过零就不能说有差异。
拿我项目的留出集举例:不开知识库平均 14.2 步,开混合检索 16.8 步,看起来多了两步多。但按项目算差值,五个项目里一个少了三步多、四个多了一到六步,按项目重抽样算出来的 95% 区间是负零点七到五点三,跨过了零,所以只能说”没有观察到可测量的收益”,不能说”知识库让步数变多了”。
3.5 “pass@k 和 pass^k 有什么区别?”
pass@k 是跑 k 次至少成功一次的概率,衡量模型有没有能力做到,适合有人会从多个结果里挑、或者能自动验证并重试的场景,比如代码生成可以跑测试挑出对的那个。
pass^k 是 k 次全部成功的概率,衡量稳定性。面向用户的产品更应该看这个,因为用户每次用都希望成功。两者差别很大:单次成功率 70% 的任务,pass@3 超过 99%,pass^3 只有 29% 左右。τ-bench 论文里就发现当时最强的模型在零售场景单次成功率不到一半,连续 8 次都成功的比例不到 25%。
3.6 “回归测试怎么做?”
把评测分成两类。回归集是已经能稳定做对的题,每次改提示词、换模型、改工具都要跑,通过率应该接近 100%,掉分就是改坏了,要拦住发布。能力集是还做不好的难题,用来看改进方向,通过率低是正常的。
流程上,改动先跑回归集,没掉分再跑能力集看有没有提升,再小流量上线看线上指标。评测很贵的话,回归集可以挑有代表性的子集,完整评测按天或按版本跑。我项目跑一轮完整评测要八分钟、上百万 token,所以像只改步数上限这种改动,我只重跑了受影响的困难档。
3.7 “你的评测过程中发现过什么问题?”
最有价值的发现是评分器自己错了两次,都是读轨迹才发现的。
第一次,验收只看
cmake --build的退出码,结果有两次运行一个目标都没编译就判了通过。后来验收改成必须在构建目录里找到目标架构的 ELF 产物。第二次是在留出集上,libarchive 九次里失败了八次。查下来不是 Agent 的问题:这个仓库自带一个叫 build 的源码目录,Agent 聪明地换了个名字做构建目录并且编译成功了,但验收写死去 build 目录检查。修复后第一次的四十五次结果全部作废重跑。
另外还发现过一次模型为了满足验收,自己往 CMakeLists 里加了一个示例目标。这些都说明分数本身不可信,要定期读轨迹检查评分器测的是不是你真正想要的东西。
第四部分 逐个详解
4.1 为什么 Agent 难评测
普通的模型评测是”一个输入、一个输出、和标准答案比”。Agent 不一样:
| 特点 | 带来的困难 |
|---|---|
| 多步执行 | 中间某一步错了,最终可能还是成功了,也可能失败;只看结果不知道原因 |
| 调用工具、改变外部状态 | 要检查的是环境状态,不是一段文字 |
| 同一任务路径不同 | 跑一次的结果有很大随机性 |
| 可能用意想不到的方式完成 | 可能是创新,也可能是钻评分标准的空子 |
| 运行慢、成本高 | 一轮评测几分钟到几小时、几十万到上百万 token,没法随便多跑 |
术语。Anthropic 在 2026 年 1 月的 Demystifying evals for AI agents 里给了一套清楚的术语,面试时用这些词会显得有体系:
| 术语 | 含义 | 我项目里对应 |
|---|---|---|
| 任务(task) | 一道题,带输入和成功标准 | 一个开源项目 + 目标平台 |
| 试验(trial) | 对一个任务的一次尝试;因为有随机性,要多次 | 每个项目每个配置跑 3 轮 |
| 评分器(grader) | 给表现打分的逻辑,一个任务可以有多个 | verify:重新构建 + ELF 架构检查 |
| 轨迹(transcript / trace) | 完整记录,包括输出、工具调用、推理 | runs.db 的 tool_calls 表 |
| 结果(outcome) | 试验结束时的环境最终状态 | 构建目录里的产物 |
| 评测框架(evaluation harness) | 端到端跑评测、管理任务、汇总结果的程序 | eval/run_eval.py |
| 评测集(suite) | 一组任务 | projects.json(10 个)、projects_holdout.json(5 个) |
flowchart LR
S[(评测集<br/>一组任务)] --> H[评测框架]
H --> T1[任务 A 试验 1]
H --> T2[任务 A 试验 2]
H --> T3[任务 B 试验 1]
T1 --> AG[被测的 Agent<br/>提示词 / 工具 / 模型]
AG --> TR[轨迹<br/>每一步做了什么]
AG --> OUT[结果<br/>环境最终状态]
OUT --> G[评分器<br/>代码 / 模型 / 人工]
TR -.也可以评过程.-> G
G --> AGG[汇总<br/>通过率 / 步数 / 成本 / 分类统计]
TR --> READ[人读轨迹<br/>找原因、查评分器 bug]
4.2 评什么:分层评测
一个 Agent 应用可以在四个层次上评测,越往下越接近真实,也越贵:
flowchart TB
L1["单元层:单个组件<br/>工具是否正确、检索 Recall@k、结构化输出能否解析<br/>快、便宜、可以每次提交都跑"]
L2["单步层:给定上下文,模型这一步<br/>选对工具没有、参数对不对、该停时停没停"]
L3["端到端层:完整任务<br/>最终结果对不对、用了多少步和 token"]
L4["线上层:真实用户<br/>任务完成率、用户反馈、人工抽检"]
L1 --> L2 --> L3 --> L4
| 层次 | 我项目做了什么 |
|---|---|
| 单元 | tests/ 下的回归测试(验收找构建目录、Milvus 重启加载、MCP 工具);检索测试集 48 条 |
| 单步 | 没做 |
| 端到端 | 10 个项目 × 多个配置 × 3 轮;留出集 5 个项目 |
| 线上 | 没有线上用户 |
分层的意义在于定位。08 篇的例子:检索层分数很高,端到端没有收益,两层结果放在一起才能说出”瓶颈不在检索算法”。
能力评测和回归评测是另一种划分,按用途分:
| 能力评测(capability) | 回归评测(regression) | |
|---|---|---|
| 目的 | 看 Agent 能做到多难的事,找改进方向 | 保证已经能做好的事没有被改坏 |
| 期望通过率 | 一开始低是正常的 | 接近 100% |
| 掉分意味着 | 需要分析 | 这次改动有问题,拦住 |
| 题目来源 | 还做不好的难题 | 已经解决的问题,特别是修过的 bug |
一道题在能力集里被稳定做对之后,就可以移进回归集。
4.3 评测集怎么建
从哪来:
| 来源 | 优点 | 注意 |
|---|---|---|
| 真实失败案例 | 最能反映实际问题 | 要脱敏 |
| 用户反馈、客服工单 | 用户真正在意的 | 描述常常不完整 |
| 线上日志抽样 | 分布接近真实 | 大部分是简单样本 |
| 人工构造 | 能覆盖边界情况 | 容易按自己的想象出题,偏离真实 |
| 公开基准(SWE-bench 等) | 可以和别人比较 | 可能已经被模型训练时见过;和你的业务不一定相关 |
公开基准在 2026 年的几个坑。 公开基准适合横向比较模型,但这一年暴露的问题说明分数要打折看:
| 问题 | 例子 | 对自己做评测的启发 |
|---|---|---|
| 题目本身有错 | OpenAI 2026 年 2 月宣布不再报告 SWE-bench Verified:审计 138 道它的模型一直做不对的难题,59.4% 的题目描述或测试有问题,其中不少是测试写得太窄,换一种正确写法就判错。半年里最高分只从 74.9% 涨到 80.9%,分不清是模型不行还是题错了。OpenAI 改为推荐 SWE-bench Pro(1865 道题,含不公开的题目集) | 分数长期不动时,先抽查失败的题是不是题目的错;本篇 4.12 节我验收标准写错两次也是同一类问题 |
| 训练数据泄漏 | 同一次审计里,GPT-5.2 做出了 31 道”几乎不可能”的题,推理过程里出现了题目里没有、只在项目发布说明里有的信息 | 自己的评测集别公开;留出集只用来测,不用来调 |
| 模型识破评测 | Anthropic 的 Eval awareness in BrowseComp(2026-03):1266 道题里 11 道是”不按预期”做出来的,9 道是网上搜到了泄露的答案,2 道是 Claude Opus 4.6 查不到之后推断自己在被评测,认出是哪个基准,找到 GitHub 上加密的答案文件并写代码解密。多 Agent 配置出现这种情况的比例是单 Agent 的 3.7 倍 | 能联网的 Agent,评测时要屏蔽基准名称和答案来源;只封 URL 不够 |
| 环境噪声 | Anthropic 的 Quantifying infrastructure noise(2026-02):只改容器的内存和 CPU 限制,Terminal-Bench 2.0 最好和最差配置差 6 个百分点,严格限制时 5.8% 的任务因为基础设施原因失败 | 资源限制、超时要当作实验变量写进报告;差距小于 3 个百分点、又没说明环境配置的排行榜对比,不要当真 |
Anthropic 那篇文章的建议是:先从 20 到 50 个来自真实失败的任务开始,不要等攒够几百道才开始评测;每个任务写清楚无歧义的成功标准,最好附参考解;正例和反例要平衡,比如既有”应该调工具”的,也有”不该调工具”的,否则 Agent 会被优化成只会一种行为。
题目要分类、分难度。 我项目 10 个项目按依赖复杂度分三档:简单(cJSON、fmt、json、Catch2)、中等(spdlog、googletest、zlib、benchmark)、困难(libpng、re2)。统计时分档看,因为困难档的步数动辄二三十,混进平均值会把简单档的变化淹没。
防泄漏。 泄漏(leakage)指测试数据以某种形式进入了被测系统:
- 用测试题调提示词,然后用同样的题证明提示词变好了
- 从测试题的运行记录里提炼知识库,然后用同样的题测知识库
- 把测试题的答案写进了 few-shot 示例
我项目的做法:知识库新增的 5 条经验来自原来 10 个项目的运行记录,所以测知识库的端到端效果时,另选 5 个 Agent 从没跑过的项目(libuv、curl、libjpeg-turbo、freetype、libarchive)作为留出集。而且在留出集上发现的问题(libarchive 的链接错误),不能补进知识库后再用这 5 个项目测。
防饱和。 评测集太简单,所有配置都满分,就无法区分好坏。我项目经历了这个过程:
| 轮次 | 通过率 | 说明 |
|---|---|---|
| 第一版 baseline | 9/10 | 只剩困难档有改进空间,任何改动最多影响 1 到 2 个项目 |
| v3 两个配置 | 60/60 | 完全没有区分度 |
| 留出集三个配置 | 45/45 | 同样全部通过 |
通过率饱和之后,只能退而看步数和 token,而步数的波动又很大。评测集要跟着 Agent 的能力一起变难:依赖链更深的项目、非 CMake 构建系统、需要在目标平台运行测试的项目。
记录环境版本。 被测对象会变:开源项目每天都有新提交。我项目评测时在一小时内克隆所有项目,并把每次运行拿到的 commit 记在结果文件里,确认 60 次运行的 commit 一致,对比才有意义。模型服务也可能悄悄更新,要记录模型名和日期。
4.4 评分器:三种方式和它们的坑
| 类型 | 例子 | 优点 | 缺点 |
|---|---|---|---|
| 代码检查 | 测试是否通过、文件是否存在、JSON 是否符合 Schema、数据库记录是否正确 | 快、便宜、客观、可复现 | 死板,合理的不同做法可能被判错;复杂质量判断做不了 |
| 模型当裁判 | 回答是否忠实于资料、语气是否合适、方案是否合理 | 灵活,能评开放式任务 | 有随机性、有偏差、要花钱、必须用人工校准 |
| 人工 | 专家打分、用户评价 | 最接近真实判断 | 贵、慢、难规模化,不同人标准也不一样 |
优先检查最终状态。 能用环境状态判断的,就不要去评模型说了什么:
| Agent 类型 | 状态检查 |
|---|---|
| 编程 Agent | 测试是否通过、指定文件是否被正确修改 |
| 客服 Agent | 退款记录是否存在、工单状态是否正确 |
| 浏览器 Agent | 目标页面状态是否按要求改变 |
| 数据 Agent | 查询结果和副作用是否符合预期 |
| 我的构建 Agent | 构建目录里是否有目标架构的产物 |
评分器本身也会错,而且错得很隐蔽。 我项目的验收函数改了三次,每次都是读轨迹或分析异常结果才发现的:
flowchart TD
V1["验收 v1:cmake --build 退出码为 0"] --> B1["问题:json 有两次运行一个目标都没编译<br/>复验输出为空,却判通过"]
B1 --> V2["验收 v2:退出码为 0<br/>且 build 目录里有目标架构 ELF 产物<br/>(排除 CMake 自己的探测程序)"]
V2 --> B2["问题:libarchive 自带 build/ 源码目录<br/>Agent 改用 out、_build 等目录并成功编译<br/>验收写死去 build/ 检查,9 次失败 8 次"]
B2 --> V3["验收 v3:读 CMakeCache.txt<br/>找 CMAKE_HOME_DIRECTORY 等于项目根目录的构建目录<br/>排除依赖项的构建目录,有多个取最近修改的"]
V3 --> R["补回归测试;第一次的 45 次结果作废重跑"]
第一个是假阳性(没做成判成功),会让你以为效果很好;第二个是假阴性(做成了判失败),会让你以为 Agent 有问题去改错地方。两种都只有看具体轨迹才能发现。
Agent 可能钻评分标准的空子。 v2 验收要求”至少有目标架构的产物”。json 是一个只有头文件的库,本身不产生编译产物。run 44 里 Agent 修改了项目的 CMakeLists.txt,自己加了一个示例目标来满足验收。这算不算成功?按任务要求(交叉编译这个项目)有点牵强。系统提示词里后来明确写了”header-only 项目至少保留一个会被编译的测试或示例”,把期望说清楚。这类情况要在任务定义里写明允许和不允许的做法。
4.5 动手实验 1:一个最小的评测框架
用假 Agent 演示评测框架的结构:多个任务、每个任务多次试验、状态检查评分、同时统计”谎报完成”、和基线比较。只用标准库,保存为 eval_harness.py。
import json
import random
TASKS = [
{"id": "cjson", "tier": "简单", "difficulty": 0.1},
{"id": "zlib", "tier": "中等", "difficulty": 0.3},
{"id": "libpng", "tier": "困难", "difficulty": 0.6},
]
def fake_agent(task, version, rng):
skill = 0.75 if version == "v2" else 0.6
passed = rng.random() > task["difficulty"] * (1 - skill) * 2.5
artifact = "aarch64" if passed else None
claimed = passed or rng.random() < 0.5
return {"claimed_success": claimed, "artifact_arch": artifact, "steps": rng.randint(6, 30)}
def grade(outcome):
return {
"artifact_ok": outcome["artifact_arch"] == "aarch64",
"false_claim": outcome["claimed_success"] and outcome["artifact_arch"] != "aarch64",
}
def run_suite(version, trials=5, seed=0):
rng = random.Random(seed)
results = {}
for task in TASKS:
grades = [grade(fake_agent(task, version, rng)) for _ in range(trials)]
results[task["id"]] = {
"pass": sum(g["artifact_ok"] for g in grades),
"false_claims": sum(g["false_claim"] for g in grades),
"trials": trials,
}
return results
baseline = run_suite("v1", seed=1)
candidate = run_suite("v2", seed=2)
print(json.dumps({"baseline": baseline, "candidate": candidate}, ensure_ascii=False))
for task_id in baseline:
b, c = baseline[task_id]["pass"], candidate[task_id]["pass"]
flag = "回归" if c < b else ("提升" if c > b else "持平")
print(f"{task_id:<8} 基线 {b}/5 新版 {c}/5 {flag} 新版谎报完成 {candidate[task_id]['false_claims']} 次")
实际输出:
{"baseline": {"cjson": {"pass": 3, "false_claims": 2, "trials": 5}, "zlib": {"pass": 5, "false_claims": 0, "trials": 5}, "libpng": {"pass": 0, "false_claims": 3, "trials": 5}}, "candidate": {"cjson": {"pass": 5, "false_claims": 0, "trials": 5}, "zlib": {"pass": 5, "false_claims": 0, "trials": 5}, "libpng": {"pass": 2, "false_claims": 2, "trials": 5}}}
cjson 基线 3/5 新版 5/5 提升 新版谎报完成 0 次
zlib 基线 5/5 新版 5/5 持平 新版谎报完成 0 次
libpng 基线 0/5 新版 2/5 提升 新版谎报完成 2 次
逐段讲。
① random.Random(seed)。 创建一个独立的随机数生成器,seed(种子)固定时,每次运行产生的随机数序列完全相同。评测框架里固定种子是为了让实验可复现。rng.random() 返回 0 到 1 之间的随机小数,rng.randint(6, 30) 返回 6 到 30 之间的随机整数(包括两端)。
注意:真实的模型 API 即使设了温度为 0 也不保证完全可复现,所以真实评测要靠多次试验而不是固定种子。
② 假 Agent。 skill 越高、任务越简单,passed 为真的概率越大。claimed = passed or rng.random() < 0.5:成功时一定声称成功;失败时也有一半概率谎称成功。这是在模拟”模型说完成但其实没完成”。
③ 评分器 grade。 只看结果状态 artifact_arch,完全不看 claimed_success。同时额外统计 false_claim:声称成功但状态不对。这个指标在真实项目里很有用,它反映 Agent 的”自我认知”是否可靠。
④ run_suite。
[grade(fake_agent(...)) for _ in range(trials)]:每个任务跑trials次。_是一个约定俗成的变量名,表示”这个循环变量用不到”sum(g["artifact_ok"] for g in grades):括号里是生成器表达式(和列表推导式类似,但不先生成列表),True 当 1 加起来就是通过次数
⑤ 对比。 逐个任务比较通过次数,分成回归、提升、持平。
这个输出里藏着一个值得思考的问题:cjson 是最简单的任务,基线版本 5 次里只过了 3 次。按设定的参数,基线在 cjson 上每次成功概率是 90%,5 次里只过 3 次纯粹是随机波动。如果只跑一轮、每题只试一次,你可能因为这种波动得出完全错误的结论。 下一节讲怎么处理。
自己改一改:
- 把
seed=1和seed=2换成别的数,看结论会不会变 - 把
trials改成 50,看”提升”是否仍然成立
4.6 模型当裁判:怎么用、怎么校准
先说为什么会用模型当裁判。 自然语言生成以前主要靠两种自动指标:机器翻译用的 BLEU(IBM 的 Papineni 等人 2002 年提出,论文)和摘要用的 ROUGE(Chin-Yew Lin 2004 年提出,论文)。它们的做法都是数模型输出和人写的参考答案之间有多少相同的词和词组。翻译、摘要的答案大致有个范围,这么数还算管用。到了聊天模型,同一个问题可以有很多种都对的回答,措辞和参考答案完全不同也可能更好,数重合词就不灵了;请人来打分又贵又慢。2023 年 GPT-4 这类强模型出来以后,研究者开始试着让它来判断两个回答哪个好。下面这篇论文开头就是这么交代动机的:聊天助手能力太广,现有的评测基准衡量不了人的偏好。
| 做法 | 怎么判 | 卡在哪 |
|---|---|---|
| BLEU、ROUGE 这类重合度指标 | 数和参考答案重合的词 | 意思对但措辞不同会被判低;开放式问题没有唯一参考答案 |
| 人工打分 | 人读完给分 | 贵、慢,每改一次都要重新请人 |
| 模型当裁判 | 让强模型按标准判断 | 便宜、快,但有下面表里的几种偏差,要拿人工结果校准 |
官方说明:Zheng 等人 2023 年的 Judging LLM-as-a-Judge with MT-Bench and Chatbot Arena 系统研究了用大模型评判回答质量。结论是强模型当裁判和人类偏好的一致率能超过 80%,与人类之间的一致程度相当;同时指出了几种偏差:
| 偏差 | 表现 | 缓解 |
|---|---|---|
| 位置偏差(position bias) | 两个回答比较时,倾向于选排在前面(或后面)的 | 交换顺序各评一次,两次一致才算 |
| 冗长偏差(verbosity bias) | 倾向于给更长的回答高分 | 评分标准里明确”长度不是标准”;控制对比回答的长度 |
| 自我偏好(self-enhancement) | 倾向于偏爱自己或同系列模型生成的回答 | 用不同系列的模型当裁判 |
| 推理能力有限 | 数学、代码类问题判断出错 | 这类任务用代码检查 |
写裁判提示词的建议:
| 不好 | 好 |
|---|---|
| ”给这个回答打 1 到 10 分" | "回答是否包含资料里没有出现的数字或结论?只回答 是 / 否,并引用出问题的句子” |
| 一个问题判断多个维度 | 每个维度单独判断 |
| 只要一个分数 | 先要求给出理由再给结论(理由可以用来人工抽查) |
| 没有参考 | 提供参考答案或评分细则(rubric) |
是非判断比打分稳定得多:让模型区分 7 分和 8 分,每次结果都可能不同;让它判断”有没有引用资料外的数字”,结果一致得多。
怎么校准:先人工标注一批,再对比。
动手实验 2:假设人工标注了 20 条样本,模型裁判也判了一遍,计算一致性。数据是为演示编的。保存为 judge_agreement.py。
from collections import Counter
human = ["pass", "pass", "fail", "pass", "fail", "pass", "pass", "fail", "pass", "pass",
"fail", "pass", "pass", "pass", "fail", "pass", "fail", "pass", "pass", "pass"]
judge = ["pass", "pass", "pass", "pass", "fail", "pass", "pass", "pass", "pass", "pass",
"fail", "pass", "pass", "pass", "pass", "pass", "fail", "pass", "pass", "pass"]
pairs = Counter(zip(human, judge))
n = len(human)
agree = sum(v for (h, j), v in pairs.items() if h == j) / n
labels = ["pass", "fail"]
expected = sum((human.count(l) / n) * (judge.count(l) / n) for l in labels)
kappa = (agree - expected) / (1 - expected)
print("人工 \\ 裁判 pass fail")
for h in labels:
print(f"{h:<12}{pairs[(h, 'pass')]:>5}{pairs[(h, 'fail')]:>6}")
print(f"一致率 {agree:.2f} 随机也能达到 {expected:.2f} Cohen's kappa {kappa:.2f}")
caught = pairs[("fail", "fail")] / human.count("fail")
print(f"人工判失败的 {human.count('fail')} 条里,裁判抓到 {pairs[('fail', 'fail')]} 条({caught:.0%})")
实际输出:
人工 \ 裁判 pass fail
pass 14 0
fail 3 3
一致率 0.85 随机也能达到 0.64 Cohen's kappa 0.58
人工判失败的 6 条里,裁判抓到 3 条(50%)
逐段讲。
zip(human, judge)把两个列表按位置配成对,比如("fail", "pass")表示人工判失败、裁判判通过Counter(...)统计每种组合出现几次,得到一个混淆矩阵(confusion matrix):行是人工判断,列是裁判判断for (h, j), v in pairs.items():items()每次给出((人工, 裁判), 次数),前面的括号直接把键拆成两个变量human.count(l):列表里l出现几次expected:如果裁判完全随机、只是和人工有相同的判 pass 比例,碰巧一致的概率。人工 70% 判 pass,裁判 85% 判 pass,碰巧都判 pass 的概率 0.7×0.85,碰巧都判 fail 的概率 0.3×0.15,加起来约 0.64- Cohen’s kappa = (实际一致率 - 随机一致率) / (1 - 随机一致率)。1 表示完全一致,0 表示和随机猜差不多。经验上 0.6 以上算较好,0.4 到 0.6 一般
f"{caught:.0%}":%格式把小数显示成百分比,.0表示不保留小数
这个结果说明什么? 一致率 85% 看起来不错,但:
- 大部分样本是 pass,裁判”倾向于判 pass”就能拿到很高的一致率,kappa 只有 0.58
- 人工判失败的 6 条,裁判只抓到 3 条。评测最想发现的就是失败,这个裁判漏掉一半
所以校准时要专门看裁判对失败样本的识别率,不能只看总一致率。发现漏判,就去看漏掉的那几条,改评分标准或换更强的模型当裁判,再重新对比。
4.7 统计:样本少、有波动时怎么下结论
先建立直觉:同一个 Agent 跑同一个项目三次,步数可能是 28、11、39(这是我项目 libarchive 的真实数据)。这时候拿”配置 A 跑一次 28 步、配置 B 跑一次 16 步”得出”B 更好”,毫无意义。
几个基本概念:
| 概念 | 含义 | 公式 |
|---|---|---|
| 平均值(mean) | 数据的中心 | 总和 / 个数 |
| 标准差(standard deviation) | 数据有多分散 | 各数与平均值之差的平方的平均,再开根号 |
| 样本标准差 | 用样本估计总体时用,分母是 n-1 | Python statistics.stdev |
| 总体标准差 | 分母是 n | Python statistics.pstdev |
| 标准误(standard error) | 平均值这个估计有多不确定 | 标准差 / √n,样本越多越小 |
| 95% 置信区间 | 大致可以理解为”真实平均值很可能落在这个范围” | 平均值 ± 1.96 × 标准误(样本够多时的近似) |
比较两个配置时,配对比较比直接比平均值好。 不同题之间的差异往往远大于配置带来的差异:困难项目几十步,简单项目几步。把所有题混在一起比平均值,配置的影响会被题目差异淹没。配对比较是在每道题上算”配置 B 减配置 A”的差值,再看这些差值。
Evan Miller 2024 年的 Adding Error Bars to Evals 专门讲了这件事,核心建议是:评测就是实验,要报告标准误;同一道题有多次试验或题目之间有关联时,用聚类(clustered)标准误;两个模型在同一批题上比较时用配对差值;评测之前先估算需要多少样本。
动手实验 3:用我项目留出集的真实步数数据。只用标准库,保存为 eval_stats.py。
import random
import statistics
STEPS = {
"libuv": {"base": [16, 14, 11], "hybrid": [10, 7, 14]},
"curl": {"base": [13, 11, 13], "hybrid": [23, 17, 16]},
"libjpeg-turbo": {"base": [9, 9, 13], "hybrid": [9, 13, 13]},
"freetype": {"base": [9, 9, 8], "hybrid": [13, 15, 9]},
"libarchive": {"base": [28, 11, 39], "hybrid": [33, 28, 32]},
}
def describe(name, values):
mean = statistics.mean(values)
sd = statistics.stdev(values)
se = sd / len(values) ** 0.5
print(f"{name:<8} n={len(values)} 平均 {mean:5.2f} 标准差 {sd:5.2f} 95%区间约 [{mean - 1.96 * se:5.2f}, {mean + 1.96 * se:5.2f}]")
base = [s for p in STEPS.values() for s in p["base"]]
hybrid = [s for p in STEPS.values() for s in p["hybrid"]]
describe("不开库", base)
describe("混合", hybrid)
diffs = [statistics.mean(p["hybrid"]) - statistics.mean(p["base"]) for p in STEPS.values()]
print("逐项目差值(混合 - 不开库):", [round(d, 1) for d in diffs])
random.seed(0)
projects = list(STEPS)
boot = []
for _ in range(10000):
sample = [random.choice(projects) for _ in projects]
boot.append(statistics.mean(statistics.mean(STEPS[p]["hybrid"]) - statistics.mean(STEPS[p]["base"]) for p in sample))
boot.sort()
print(f"按项目重抽样的差值 95% 区间: [{boot[250]:.2f}, {boot[9750]:.2f}]")
实际输出:
不开库 n=15 平均 14.20 标准差 8.41 95%区间约 [ 9.94, 18.46]
混合 n=15 平均 16.80 标准差 8.35 95%区间约 [12.57, 21.03]
逐项目差值(混合 - 不开库): [-3.3, 6.3, 1.3, 3.7, 5]
按项目重抽样的差值 95% 区间: [-0.73, 5.27]
逐段讲。
statistics是 Python 自带的统计模块len(values) ** 0.5:**是乘方,0.5 次方就是开平方根[s for p in STEPS.values() for s in p["base"]]:把 5 个项目各 3 次的步数摊平成 15 个数round(d, 1):保留 1 位小数
先说为什么会有重抽样。 上面表里的 “平均值 ± 1.96 × 标准误” 是教科书公式,它背后有前提:样本够多,或者数据大致服从正态分布。只有 5 个项目,这个前提很难说成立;而且要算的量一复杂(比如配对差值的中位数、两个比例之比),往往根本没有现成的公式。1979 年斯坦福的统计学家 Bradley Efron 在《统计年鉴》(Annals of Statistics)上发表了 Bootstrap Methods: Another Look at the Jackknife,思路是不推公式,直接拿手上的样本当”总体”,从里面有放回地反复抽,看算出来的量波动多大。它全靠反复计算,所以是随着计算机普及才流行起来的,下面的实验几行 Python 就抽了 1 万次。名字 bootstrap 来自”拽着自己的鞋带把自己提起来”这句俗话,意思是只靠手里这点数据自己估计自己的不确定性。
重抽样(bootstrap) 这一段是新方法,重点讲:
- 思路:我们只有 5 个项目的数据,不知道”真实世界所有项目”上的差值分布。那就从这 5 个项目里有放回地随机抽 5 次,组成一个”假想的评测集”,算一次平均差值。重复 1 万次,得到 1 万个平均差值
random.choice(projects):从列表里随机选一个,可能重复选到同一个(有放回)- 把 1 万个结果排序,第 250 个和第 9750 个之间就是中间 95% 的范围
- 为什么按项目抽而不是按单次运行抽? 同一个项目的 3 次运行彼此相关(难度相同),它们不是 15 个独立样本。按项目整体抽样,就是聚类标准误的思路
结果解读:
- 两个配置的 95% 区间 [9.94, 18.46] 和 [12.57, 21.03] 大幅重叠
- 逐项目看,5 个项目里 1 个变少、4 个变多
- 配对差值的 95% 区间是 [-0.73, 5.27],跨过了 0
结论:不能说开混合检索让步数变多了,也不能说变少了,只能说没有观察到可测量的差异。 这和 REPORT 里”步数差异在一倍标准差以内”的结论一致。样本只有 5 个项目,重抽样的区间本身也很粗糙,但足以说明不能下强结论。
关于标准差的数字:这里用样本标准差算出 8.41,REPORT 表格里写的是 8.1,因为报告里用的是总体标准差(pstdev,结果 8.13)。样本少时两者差别明显,报告里要写清楚用的是哪种。
环境本身也会带来波动。 上面统计的都是”模型每次走的路不同”带来的随机性。Agent 评测还多一层:机器负载、API 延迟、容器资源限制、网络都会影响结果。Anthropic 建议每个任务写明保证分配的资源和强制上限、分多天多次跑取平均。我项目每次评测都在同一台机器上跑,资源没设限,但编译速度受机器负载影响,命令超时的判定可能因此不稳定,这一点 REPORT 里没写。
“一倍标准差”规则:我项目 REPORT 里给自己定了一个简单规则:“差距小于一倍标准差,不算确认,只算趋势”。这不是严格的统计检验,但比只看平均值谨慎得多,适合样本很少、跑一轮成本很高的情况。面试时说出这个规则和它的局限,比说”效果提升了 X%“可信得多。
4.8 pass@k 和 pass^k
先说为什么会有 pass@k。 代码生成早期也是借用翻译的评法,拿 BLEU 这类指标比生成的代码和参考代码有多少字符、词相同。问题是两段代码可以长得很像,一段对一段错;也可以写法完全不同,却都能跑通。Codex 论文专门做了对比,发现同一道题正确和错误答案的 BLEU 分数分布明显重叠,BLEU 高不代表代码对。于是评测改成看功能是否正确:直接跑单元测试,过了就是对。模型每次生成结果不同,自然就有了”给它 k 次机会,至少一次跑通”的算法。Codex 论文里也写明,这个 pass@k 的评法之前 Kulal 等人 2019 年在伪代码转代码的研究里已经用过,Codex 论文的贡献是下面那个无偏估计的算法,以及 HumanEval 这套 164 道手写题。
pass@k:出自 Chen 等人 2021 年的 Codex 论文 Evaluating Large Language Models Trained on Code。每道题生成 k 个结果,至少一个通过就算这道题通过。论文里 Codex 单次生成能解决 HumanEval 28.8% 的题,生成 100 次取任一通过则达到 70.2%。
直接用 k 个样本算方差很大,论文给了一个无偏估计:每道题生成 n 个(n ≥ k),其中 c 个正确:
pass@k = 1 - C(n-c, k) / C(n, k)
C(a, b) 是组合数,从 a 个里选 b 个的方法数。C(n-c, k) / C(n, k) 是”随机选 k 个全都是错的”的概率,1 减去它就是”至少一个对”。
pass^k:出自 τ-bench(2024),衡量 k 次全部成功的概率。τ-bench 模拟客服场景里 Agent 和用户、工具的多轮交互,发现当时最强的 GPT-4o 在零售场景单次成功率不到 50%,pass^8 更是不到 25%。
动手实验 4:保存为 pass_k.py。
from math import comb
def pass_at_k(n, c, k):
if n - c < k:
return 1.0
return 1 - comb(n - c, k) / comb(n, k)
def pass_hat_k(n, c, k):
return comb(c, k) / comb(n, k)
print("每道题跑 n=10 次,成功 c 次")
print(f"{'c':>3} {'单次成功率':>8} {'pass@3':>8} {'pass^3':>8}")
for c in [10, 9, 7, 5, 3]:
print(f"{c:>3} {c / 10:>10.2f} {pass_at_k(10, c, 3):>8.3f} {pass_hat_k(10, c, 3):>8.3f}")
实际输出:
每道题跑 n=10 次,成功 c 次
c 单次成功率 pass@3 pass^3
10 1.00 1.000 1.000
9 0.90 1.000 0.700
7 0.70 0.992 0.292
5 0.50 0.917 0.083
3 0.30 0.708 0.008
逐段讲。
from math import comb:comb(a, b)计算组合数 C(a, b),Python 3.8 起自带pass_at_k:错误的数量少于 k 个时,选 k 个必然至少有一个对,直接返回 1。否则按公式算pass_hat_k:从 c 个成功里选 k 个的方法数 / 从 n 个里选 k 个的方法数,就是”随机选 k 次全是成功的”的概率f"{'c':>3}":花括号里直接写字符串常量'c'并右对齐,用来打印表头
看这张表: 单次成功率 70% 时,pass@3 高达 99%,pass^3 只有 29%。同一个 Agent,按 pass@k 看”几乎总能做到”,按 pass^k 看”十次里七次不能连续三次都成功”。
| 场景 | 该看 |
|---|---|
| 代码生成,能自动跑测试挑出对的 | pass@k |
| 研究探索,只要有一次找到方案就行 | pass@k |
| 面向用户的客服、助手,每次都要成功 | pass^k |
| 自动化流水线,失败了没人兜底 | pass^k |
我项目每个配置跑 3 轮,留出集三种配置都是 15/15,pass^3 在这个评测集上都是 100%,这又回到了”评测集饱和”的问题。
4.9 读轨迹:分数之外的信息
Anthropic 那篇文章把”定期读轨迹”列为关键实践:确认评分器测的是真正重要的东西,找出误判的失败。分数告诉你结果,轨迹告诉你原因。
我项目从轨迹里发现的问题,没有一个是看总分能发现的:
| 发现 | 怎么发现的 | 后续 |
|---|---|---|
| 验收把没编译的运行判成功 | 复查 json 两次运行的复验输出是空的 | 验收加 ELF 产物检查 |
| 验收把正确结果判失败 | libarchive 9 次失败 8 次,报错全是 could not load cache | 验收自己找构建目录,45 次作废重跑 |
read_file 缺陷导致绕路 | 看到模型反复写 head.cmake、peek.cmake 读文件 | 改分页,15 次绕路降到 0 |
| 路径检查误拦合法参数 | libpng 失败的 run 80,看到 ../.xbuild/zig.cmake 被拦 | toolchain 改环境变量 |
| 安全漏洞 | re2 运行里看到 ls /opt/homebrew/Cellar 执行成功 | 参数路径检查,去掉 cat |
| 知识库没被用 | 统计 search_knowledge 调用次数和查询内容 | 定位到覆盖不足和调用率低 |
| 第一版”通过率提升”不能归因 | 步数变化方向混杂、检索只调用 3 次、救回的 re2 查的内容库里没有 | 结论改为”未能证明有效” |
| 困难项目失败原因 | libpng 轨迹走的路是对的,第 25 步刚开始编 zlib | 步数上限 25 → 40 |
怎么系统地读轨迹:
- 先统计再抽样:按失败类型、工具失败率、步数分布统计,找出异常的运行,再重点读
- 给失败分类:我项目
errors.py把报错分成 8 类,REPORT 的”下一步”里也写了compile_error这一类太粗,需要更细的分类 - 对照改动看:改了什么,就去看和这个改动相关的轨迹里对应的行为有没有变。v2 到 v3 只改了 toolchain 的传递方式,“子目录找不到 toolchain”从 8 次降到 0、误拦消失,这两项在轨迹里能直接对应到改动,因果清楚;而困难档平均步数的下降,没法在轨迹里直接对应,只能算趋势
4.10 回归测试接进发布流程
回归测试(regression test):拿以前已经能做对的题再跑一遍,确认这次改动没有把原来好的地方改坏。它不是用来证明”变好了”,而是用来拦住”变坏了”。
对 Agent 来说,下面这些改动都要跑回归,哪怕看起来只是一句话的事:
| 改动 | 为什么可能改坏 |
|---|---|
| 改提示词 | 加一条规则可能让模型在别的场景过度执行这条规则 |
| 换模型或升级模型版本 | 同一段提示词,不同模型的理解和习惯不同 |
| 改工具的描述、参数、返回格式 | 模型选工具、填参数的方式会跟着变 |
| 改工具的实现(比如安全检查) | 我项目加相对路径检查后误拦了合法参数,libpng 一次运行因此失败 |
| 改步数上限、上下文压缩策略 | 影响长任务能不能跑完 |
一条分层把关的流程:
flowchart LR
C[改动] --> U[单元测试<br/>秒级、不调模型]
U -->|过| R[回归集<br/>已经稳定能做对的题]
R -->|不掉分| A[能力集<br/>难题,看有没有提升]
A --> G[小流量上线]
G --> M[线上监控]
U -->|失败| X[拦住]
R -->|掉分| X
M -->|指标变差| B[回滚]
从左往右,越往后越慢、越贵、越接近真实情况。便宜的检查放前面,先把明显的问题拦住,就不用为它花贵的评测钱。
- 单元测试:不调模型,只测确定性的代码。我项目的
tests/test_regressions.py就是这一层:路径越界检查、退出码不被截断、read_file翻页、验收能识别非build名字的构建目录、主机构建和空项目必须判失败。里面甚至用写死剧本的假模型(FakeClient)测了 Agent 循环的计数逻辑。每一个在真实评测里踩到的 bug,修完都补一条这样的测试,下次就不用靠跑几十分钟的评测才发现 - 回归集:通过率应该接近 100%,所以判断规则可以写得很死:原来能过的,现在过不了就拦住
- 能力集:通过率本来就不高,看的是提升,不作为拦截条件
动手实验 5:一个最小的回归关卡。保存为 gate.py。
import sys
BASELINE = {
"cJSON": [True, True, True],
"fmt": [True, True, True],
"zlib": [True, True, True],
"libpng": [True, True, False],
}
CANDIDATE = {
"cJSON": [True, True, True],
"fmt": [True, False, True],
"zlib": [True, True, True],
"libpng": [True, True, True],
}
def check(baseline, candidate, allowed_drop=0):
problems = []
for name, before in baseline.items():
after = candidate.get(name)
if after is None:
problems.append(f"{name}: 候选版本没有跑")
continue
drop = sum(before) - sum(after)
if drop > allowed_drop:
problems.append(f"{name}: 通过 {sum(before)}/{len(before)} -> {sum(after)}/{len(after)}")
return problems
problems = check(BASELINE, CANDIDATE)
for p in problems:
print("退化", p)
print("结论:", "拦住发布" if problems else "可以继续")
sys.exit(1 if problems else 0)
实际输出(Python 3.14,只用标准库):
退化 fmt: 通过 3/3 -> 2/3
结论: 拦住发布
进程退出码是 1。
逐段讲。
BASELINE、CANDIDATE:两个字典(dict),键是项目名,值是列表(list),记录每轮是否通过。前者是改动前的结果,后者是改动后的。这里数据是写死的,实际使用时从result_*.json读def check(baseline, candidate, allowed_drop=0):allowed_drop=0是默认参数,调用时不传就用 0,意思是”通过次数一次都不许少”baseline.items():同时取出字典的键和值,for name, before in ...把每一对拆成两个变量candidate.get(name):按键取值,键不存在时返回None,不会报错;后面if after is None专门处理”候选版本漏跑了这个项目”,漏跑也要当问题报出来,不然少跑几道题分数反而更好看sum(before):Python 里True当作 1、False当作 0,所以对布尔列表求和就是通过次数continue:跳过本次循环剩下的代码,直接处理下一个项目sys.exit(1 if problems else 0):让程序以退出码 1 结束。problems是空列表时在条件判断里当作假。退出码是接进自动化流程的关键:CI(持续集成,代码提交后自动跑检查的系统)看到非 0 退出码,就会把这次提交标红,不让合并
注意看结果:libpng 从 2/3 变成 3/3,是”变好”;fmt 从 3/3 变成 2/3,是”变坏”。关卡只关心有没有变坏,一个项目的提升不能抵消另一个项目的退化,所以不看总数(改动前后都是 11/12),逐项比。
自己改一改:
- 把
check(BASELINE, CANDIDATE)改成check(BASELINE, CANDIDATE, allowed_drop=1),看结论变成什么。想一想:Agent 有随机性,3 轮里偶尔失败一次,该不该拦? - 从
CANDIDATE里删掉"zlib"这一行,看输出 - 改成只把
BASELINE里 3 轮全过的项目当回归集,libpng 这种不稳定的项目不参与拦截
第 1 题没有标准答案。容忍度设成 0,随机失败会频繁误拦;设得太宽,真退化会漏过去。常见的折中是:先拦住,再对掉分的题多跑几轮确认,确认是真退化再算失败。
评测成本怎么控制。 我项目跑一轮 10 个项目的完整评测要 8 到 10 分钟、一百多万输入 token,每个配置跑 3 轮就翻三倍。几种做法:
| 做法 | 说明 | 我项目 |
|---|---|---|
| 按层跑 | 能在便宜的层测出来的,就不跑端到端 | 检索质量单独用 48 条测试集测,一次几秒;Agent 端到端才调模型 |
| 只跑受影响的子集 | 改动只影响部分任务时,只重跑那部分 | 步数上限 25 → 40 只重跑了困难档两个项目;run_eval.py 第一个参数可以按难度档过滤 |
| 保存结果文件 | 每组结果存成文件,比较时直接读,不用重跑 | result_<标签>.json,compare.py 读两个文件逐项对比 |
| 并行 | 多组配置同时跑,互不干扰 | WORKSPACE 环境变量给每组指定单独的工作目录,留出集三种配置并行跑 |
| 记录版本 | 结果要能复现、能对上号 | 结果里记被测项目的 commit、模型、是否开知识库、步数上限;60 次运行拿到的 commit 一致 |
最后一条容易被忽略。开源项目每天都在更新,今天克隆的和三天前克隆的可能不是同一份代码,两次评测的差异可能根本不是 Agent 改动带来的。我项目的第一版评测结果里没有记 commit,是在修复提交 1941ce9 里才补上的。
4.11 线上评测简述
离线评测集再好,也只是真实使用的一个小样本。上线之后还要看真实数据。这里只列要点,怎么记录、怎么搭看板放在 10 可观测与线上运维。
| 信号 | 例子 | 特点 |
|---|---|---|
| 隐式信号 | 用户重新提问、点重试、中途放弃、要求转人工、复制了回答 | 量大、不用用户额外操作,但含义要自己推断 |
| 显式反馈 | 点赞点踩、打分、写意见 | 含义明确,但只有少数用户会给,而且偏向不满意的人 |
| 人工抽检 | 每天随机抽一批对话或轨迹人工看 | 最准,能发现评测集没覆盖的新问题 |
| A/B 测试 | 一部分用户用新版本,一部分用旧版本,比较指标 | 能做因果比较,但需要足够的流量 |
| 模型当裁判 | 对线上对话批量打标签(比如”是否答非所问”) | 覆盖面大,要先按 4.6 节校准 |
线上发现的失败要回流到离线评测集,这是评测集持续变好的主要来源:
flowchart LR
ON[线上运行] --> SIG[重试 / 放弃 / 点踩 / 抽检]
SIG --> FAIL[确认是失败案例]
FAIL --> CASE[写成评测任务<br/>带成功标准]
CASE --> SET[(离线评测集)]
SET --> DEV[改提示词 / 工具 / 模型]
DEV --> ON
我项目没有线上用户,这一层完全没做。能对应上的只有 runs.db:每次运行的工具调用和结果都存下来了,从里面统计失败命令、提炼知识库经验,和”从线上日志找失败案例”是同一个思路。
4.12 我项目评测全过程
把前面零散提到的事按时间串起来。所有数字都来自项目的 eval/REPORT.md。
flowchart TD
V1["第一版 09-11<br/>10 个项目、25 步、每配置 1 轮<br/>baseline 9/10,rag 10/10"] --> H40["只改步数上限 25→40<br/>libpng 29 步、re2 32 步通过"]
H40 --> BUG1["读轨迹发现验收 bug<br/>json 没编译任何目标也判通过"]
BUG1 --> V2["v2 09-13<br/>验收加 ELF 检查、修工具缺陷、每配置 3 轮<br/>baseline 30/30,rag 29/30"]
V2 --> V3["v3<br/>toolchain 改环境变量<br/>60/60 全过"]
V3 --> KB["知识库扩到 15 条<br/>检索测试集 48 条"]
KB --> HO["留出集<br/>5 个新项目 × 3 配置 × 3 轮"]
HO --> BUG2["验收 bug<br/>libarchive 构建目录不叫 build"]
BUG2 --> HO2["修复后重跑 45 次<br/>全部 15/15"]
V3 --> LG["LangGraph 重写对比<br/>手写 30/30,LangGraph 29/29"]
第一版(2026-09-11)
| 项 | 内容 |
|---|---|
| 设置 | 10 个开源 C/C++ 项目分简单、中等、困难三档;步数上限 25;每个配置只跑 1 轮 |
| 结果 | baseline 9/10,rag 10/10,rag2 8/10 |
| 当时的结论 | 90% → 100% 不能归因于知识库,三条证据:步数变化方向混杂(4 个变好、4 个变差、2 个持平);两百多次工具调用里只查了 3 次知识库;救回的 re2 查的内容库里根本没有 |
| 排除困难档 | 8 个简单中等项目平均步数 10.12 → 9.38 → 8.25,但差距小于 baseline 一倍标准差(3.44),只算趋势 |
| 方法上的妥协 | rag2 同时改了三处(补知识库、加强调用引导、修安全漏洞),不是单变量,结论只作参考 |
| 困难档 | 三轮六次里五次跑满 25 步,失败都是步数用完。只改步数上限到 40:libpng 29 步、re2 32 步通过,但各只跑一次 |
| 成本 | baseline 输入 1,375,134 token、输出 49,840 token、9.7 分钟;输入是输出的 27 倍 |
v2:修正验收和工具之后(2026-09-13)
复查 runs.db 发现 json 有两次一个目标都没编译就判了通过,旧数字不能再用。同时改了:验收要求目标架构 ELF 产物、read_file 从开头翻页、截断后退出码不丢、路径检查按层级判断、写文件后重复计数清零。每个配置改成跑 3 轮,步数上限 40。
结果 baseline 30/30,rag 29/30。rag 失败的那次(libpng,run 80)读轨迹发现是自己加的相对路径检查误拦了 ../.xbuild/zig.cmake。
v3:toolchain 改环境变量
唯一改动是用 CMAKE_TOOLCHAIN_FILE 环境变量传 toolchain。结果 60/60;“子目录找不到 toolchain”从 8 次降到 0;被路径检查拦下的 5 次全是真越界,没有误拦。通过率从这里开始完全没有区分度。
知识库扩充与检索评测:从运行记录提炼到 15 条,检索测试集扩到 48 条、分四类统计,混合检索 R@1 45/48 最高。详见 08 RAG 全链路。
留出集:为了防泄漏换 5 个没跑过的项目,3 种配置各 3 轮。第一次跑出 libarchive 9 次失败 8 次,查出是验收写死 build/ 目录,修复后 45 次作废重跑。结果全部 15/15,平均步数 14.2 / 15.7 / 16.8,差异在一倍标准差以内,重抽样区间跨过 0(见 4.7 节实验)。
LangGraph 对比:同样的提示词、工具、验收,只换循环写法。手写 30/30,LangGraph 29/29(有 1 次因为 DeepSeek 余额不足返回 402 崩溃,不计入)。步数、token 看不出差别。详见 12 框架与协议。
回头看这一路,几件事值得记住:
- 评分器被修了两次,每次都让之前的结果作废。 评测里最该怀疑的不是 Agent,是评分器
- 能说清因果的只有单变量改动:步数上限、toolchain 传递方式。同时改几处的那一轮,只能当参考
- 通过率一路从有区分度走到饱和,后面的比较只能看步数和 token,而这些指标波动大,样本又少,大多只能算趋势
- 结论写成”未能证明有效”,而不是”有效”或”无效”,这是样本量允许说的最多的话
第五部分 对照项目
| 本篇知识点 | 项目里的位置 | 做到了什么 | 没做到或可以改进的 |
|---|---|---|---|
| 评测框架 | eval/run_eval.py | 按难度档过滤、崩溃单独记状态、结果存 result_<标签>.json、打印按难度和报错类型的汇总 | 每轮要手动带不同标签调用,汇总多轮靠手工 |
| 评测集 | eval/projects.json(10 个)、eval/projects_holdout.json(5 个) | 分三档;留出集防泄漏 | 规模小;已经饱和,缺依赖链更深的项目 |
| 评分器 | build_agent.verify | 独立重新构建、要求目标架构 ELF 产物、自动找构建目录 | 只判通过与否,不评过程;验收出过两次 bug |
| 对比工具 | eval/compare.py | 两组结果逐项对比,标出救回、退化、步数变化 | 只比较单轮,不算多轮的均值、波动和区间 |
| 分层评测 | eval/retrieval_eval.py | 检索单独测,分四类统计 R@1、R@3、MRR、延迟 | 测试集按已有条目写,高估实际作用 |
| 单元回归测试 | tests/test_regressions.py | 路径边界、输出截断、翻页、验收的几种失败情况、假模型测循环计数 | 没有接进 CI 自动跑 |
| 多轮试验 | v2 起每配置 3 轮 | 能看到同一项目重复跑的波动 | 3 轮仍然少;没有算 pass^k,也没有正式的显著性检验 |
| 记录版本 | 结果文件的 commit、model、use_kb、max_steps 字段 | 可以确认多次运行用的是同一份项目代码 | 没有记录 Agent 自身代码的版本,靠 git 历史和报告日期对应 |
| 读轨迹 | runs.db、errors.py | 发现两次验收 bug、工具缺陷、安全漏洞、知识库调用率低 | 靠手工查 SQL;compile_error 分类太粗 |
| 单步评测 | — | 没做 | 可以单独测”给定报错,模型选的下一个动作对不对” |
| 模型当裁判 | — | 没用,结果能用程序判断 | 如果要评”排查思路是否合理”这类过程质量,需要引入并校准 |
| 线上评测 | — | 没有线上用户 | 失败回流评测集只在 runs.db 层面做过 |
对照开源实现:pi
pi(05 篇第五部分介绍过)有一个很小的评测包 packages/evals,源码一千多行,建在开源的 vitest-evals 上。规模撑不起一整套方法论,但有几处做法和本篇、和我项目的评测可以直接对照。源码以 commit 7b4cfd6 为准。
怎么跑一道题。 每次运行在系统临时目录里新建一个独立目录,放进项目和 Agent 配置,起一个真实的 Agent 会话跑完;结束后先把整份会话记录(07 篇讲的 JSONL 轨迹)存成评测附件,再删掉临时目录(pi-harness.ts L216-L234)。所有运行的索引写进 runs.jsonl,轨迹放在 sessions/ 下。
怎么比较两个配置。 把”基线”和”候选”定义成两套配置,可以差在提示词、工具、技能、模型任意一项,同一批题各跑若干轮(README 的例子是 6 轮)。报告里:
| 指标 | 怎么算 |
|---|---|
| 通过率提升 | 同一道题、同一轮次配对,算”候选通过率 - 基线通过率”,单位是百分点 |
| token、耗时、估算费用 | 同样配对,分别算”候选 - 基线”的平均差值,不和通过率混在一起 |
| 缺数据 | 标成不完整,不当成 0 |
另外一个细节:对比评测里把”分数低”当成观察结果,不让测试失败(judgeThreshold: null);只有”评测本身能不能正常跑”这类前提才用会失败的断言。
怎么打分。 仓库里的文档审查评测是个好例子(docs.eval.ts):让 Agent 逐页检查官方文档和源码是否一致。它没有让 Agent 用文字回答再去解析,而是专门给了一个 submit_documentation_audit 工具,参数 Schema 固定为”结论(一致 / 不一致)、说明、文档证据、代码证据”,调用后直接结束运行。评分就是检查”这个工具恰好被调用了一次、结论是一致”。
和本篇、和我的项目对比:
| 本篇讲的 | pi 的做法 | 我的项目 |
|---|---|---|
| 配对比较(4.7 节) | 按”题目 + 轮次”配对算差值,框架直接出报告 | 有 compare.py 逐项对比,多轮均值和波动靠手工算 |
| 多轮试验 | 轮数作为配置项,例子 6 轮 | 每配置 3 轮 |
| 保存轨迹(4.9 节) | 每次运行都存完整会话记录 | 存在 runs.db |
| 评测工作目录 | 跑完就删,失败也删;只留轨迹 | 早期也是跑完就删,run 208 因此续不上;后来改成崩溃时保留 |
| 最终状态检查 | 看题目,文档审查用”提交结构化结论”代替 | 独立重新构建并检查 ELF |
| 置信区间、显著性 | 没有,只报差值 | 用一倍标准差的经验规则 |
值得讲的点:
1. 用一个”提交结论”工具把开放式答案变成可以程序判断的结果。 本篇 4.4 节说能用代码判断就别用模型当裁判。文档审查本来是开放式任务,pi 的办法是把输出格式交给工具的参数 Schema 约束,评分只看结构化字段。这和 02 篇”强制调用某个工具来拿结构化输出”是同一个技巧,用在了评测上。
2. 低分不算测试失败,这是评测和单元测试的区别。 单元测试是”必须对”,对比评测是”测量有多好”。如果分数低就让整个测试挂掉,一次候选配置表现差就会中断整批对比,拿不到完整数据。
3. 它也没做统计检验。 pi 的报告只给配对差值,没有置信区间。README 把方法论交给外部的指南。所以面试时说”开源项目都这么做”不能当成结论正确的理由,4.7 节讲的标准误、配对差值的置信区间仍然是更严谨的做法。
第六部分 追问清单
| 你刚讲完 | 下一个追问 | 回答方向 |
|---|---|---|
| 评测集 | 只有 10 个项目,够吗 | 不够;每配置 3 轮缓解随机性;差异只能说趋势;已经饱和需要加难题 |
| 独立验收 | 验收自己错了怎么办 | 我遇到过两次;靠读轨迹发现;修完补单元测试;受影响的结果作废重跑 |
| 模型当裁判 | 为什么你没用 | 构建结果能用程序判断,不需要;要评过程质量才需要,而且要先人工标注校准 |
| 一倍标准差规则 | 这是严格的统计检验吗 | 不是,是样本少时的保守经验规则;更严格的是配对比较加置信区间 |
| 标准差 | 用的是哪种 | 报告里是总体标准差;样本少时样本标准差更大,要写清楚 |
| 重抽样 | 为什么按项目抽而不是按运行抽 | 同一项目的几次运行难度相同、彼此相关,不是独立样本 |
| pass^k | 你项目 pass^3 是多少 | 留出集三种配置都是 100%,说明评测集太简单,不说明 Agent 很稳 |
| 留出集 | 留出集用过一次之后还算留出集吗 | 看完结果再针对它改,就不算了;libarchive 的经验不能补进库再用它测 |
| 回归测试 | Agent 有随机性,怎么避免误拦 | 容忍度加复跑确认;只把稳定通过的题放进回归集 |
| 评测成本 | 一轮太贵怎么办 | 分层、子集、缓存结果、并行、只重跑受影响部分 |
| 第一版结论 | 通过率从 90% 到 100%,为什么不说知识库有效 | 三条证据:步数方向混杂、只查 3 次、救回的查询库里没有 |
| 线上 | 上线后怎么知道效果 | 隐式信号、显式反馈、抽检、A/B;失败回流评测集 |
| 评测集饱和 | 你打算怎么加难度 | 依赖链更深的项目、非 CMake 构建系统、需要 try_run 的项目 |
| 读轨迹 | 几百次运行怎么读得过来 | 先统计失败类型和异常步数,再抽样重点读;对照改动看相关行为 |
| 评分 | 开放式任务怎么用程序打分 | 给 Agent 一个”提交结论”工具,参数 Schema 固定字段,评分只看字段;pi 的文档审查评测就是这样 |
| 公开基准 | SWE-bench 分数高说明模型编程强吗 | 要打折:Verified 被 OpenAI 停报(题目有错、有泄漏迹象);看 SWE-bench Pro 这类新基准,并看评测环境配置 |
| 环境噪声 | 两个模型差 2 个点,能说谁强吗 | 不能;Anthropic 测到仅资源配置就能差 6 个点,差距小于 3 个点又没写配置要存疑 |
| 评测作弊 | 联网的 Agent 评测要防什么 | 搜到泄露答案、认出基准去找答案文件;屏蔽基准名和答案来源,读轨迹核查 |
| 对比评测 | 候选配置分数低,要不要让测试失败 | 不要;对比评测是测量,低分是观察结果,只有评测前提坏了才失败 |
第七部分 闭卷自测
1. 说出评测里的五个基本术语,并对应到我项目。
答案
任务(一个开源项目 + 目标平台)、试验(每个项目每个配置跑一次,跑 3 轮)、评分器(verify 重新构建并检查 ELF 架构)、轨迹(runs.db 的工具调用记录)、结果(构建目录里的产物)。另外还有评测框架(run_eval.py)和评测集(projects.json)。
2. 能力评测和回归评测的通过率分别应该是什么样?各自用来做什么?
答案
能力评测的题本来就难,通过率低是正常的,用来看能力上限和改进方向;回归评测的题是已经能稳定做对的,通过率应接近 100%,用来防止改动把原来好的地方改坏,掉分就拦住。
3. 模型当裁判有哪几种常见偏差?为什么只看它和人工的一致率不够?
答案
位置偏差(换顺序结论变)、偏爱长回答、偏爱和自己风格相近的回答。只看一致率不够:如果大部分样本都是通过的,裁判全判通过一致率也很高,所以要看扣除碰巧一致的 kappa,更要看它对失败样本的识别率。
4. 实验 3 里,留出集配对差值的 95% 区间是 [-0.73, 5.27],能得出什么结论?为什么按项目重抽样?
答案
区间跨过 0,不能说开混合检索让步数变多,也不能说变少,只能说没观察到可测量的差异。按项目抽样是因为同一项目 3 次运行难度相同、彼此相关,不是 15 个独立样本。
5. 单次成功率 70%,pass@3 和 pass^3 大约是多少?客服 Agent 该看哪个?
答案
按实验 4 的 n=10、c=7 算,pass@3 约 0.992,pass^3 约 0.292。客服每次都要成功,没人帮你挑,所以看 pass^3。
6. 我项目第一版 baseline 9/10、rag 10/10,为什么不能说知识库有效?
答案
一是步数变化方向混杂,4 个变好 4 个变差 2 个持平,像随机波动;二是两百多次工具调用只查了 3 次知识库,几乎没被用的组件解释不了差异;三是被救回的 re2 查的内容当时库里没有。另外每个配置只跑了 1 轮。
7. 我项目的验收出过哪两次 bug?分别是怎么发现的、怎么修的?
答案
第一次:只看 cmake --build 退出码,json 有两次一个目标都没编译也判通过;复查 runs.db 发现复验输出是空的;修成要求构建目录里有目标架构的 ELF 产物。第二次:写死去 build/ 目录检查,libarchive 自带 build/ 源码目录,Agent 换了名字构建成功却被判失败,9 次失败 8 次,看报错都是 could not load cache;修成读 CMakeCache.txt 找构建目录,45 次作废重跑。
8. v2 到 v3 只改了 toolchain 的传递方式。哪些变化可以归因于这个改动,哪些不行?
答案
可以归因:“子目录找不到 toolchain”从 8 次降到 0、路径检查误拦消失,这两项在轨迹里能直接对应到改动。不能确认:困难档平均步数下降、用满 40 步次数减少,方向一致,但每格只有 6 次,差距不到一倍标准差,只能算趋势。
9. 实验 5 的回归关卡里,改动前后总通过数都是 11/12,为什么还是拦住了?
答案
关卡逐项比较:fmt 从 3/3 降到 2/3 是退化。libpng 从 2/3 升到 3/3 的提升不能抵消另一个项目的退化,只看总数会把退化藏起来。
10. 为什么评测结果里要记录被测项目的 commit?
答案
开源项目持续更新,不同时间克隆的代码可能不同,两次评测的差异可能来自项目代码变化而不是 Agent 改动。记录 commit 才能确认比较的是同一份任务。
11. 跑一轮完整评测很贵,列出至少四种控制成本的办法。
答案
按层跑(检索单独测,不调 Agent);只重跑受影响的子集(改步数上限只跑困难档);结果存文件、对比时直接读;多组配置用单独工作目录并行跑;便宜的单元测试放前面先拦明显问题。
12. 所有配置都 100% 通过,说明什么?该怎么办?
答案
评测集饱和了,失去区分度,不能说明 Agent 很强或很稳。要往评测集里加更难的题,比如依赖链更深的项目、非 CMake 构建系统、需要 try_run 的项目。
13. 线上发现的失败案例应该怎么处理?
答案
确认是真失败后,写成带成功标准的评测任务加进离线评测集,下次改动时就能被回归测到。修复对应的代码问题时,能用单元测试复现的也补一条单元测试。
14. pi 的文档审查评测要判断”文档和代码是否一致”,这是开放式问题。它是怎么做到不用模型当裁判、直接用程序打分的?
答案
给被评测的 Agent 一个专门的提交工具,参数 Schema 固定为结论(一致或不一致)、说明、文档证据、代码证据,调用后直接结束运行,并在任务里要求最后必须调用它一次。评分只检查这个工具是否恰好调用了一次、结论字段是什么。开放式的判断仍由 Agent 做,但输出被格式约束成了结构化数据,打分就能用代码完成。
延伸阅读
- Anthropic:Demystifying evals for AI agents(2026-01)— 术语、三类评分器、pass@k 与 pass^k、能力与回归评测、读轨迹
- Judging LLM-as-a-Judge with MT-Bench and Chatbot Arena(2023)— 模型当裁判的一致率和几种偏差
- Adding Error Bars to Evals(Evan Miller,2024)— 给评测结果算置信区间、配对比较、聚类标准误
- τ-bench(2024)— 工具和用户交互场景的 Agent 评测,提出 pass^k
- Evaluating Large Language Models Trained on Code(2021)— Codex 论文,pass@k 无偏估计
- RAGAS(2023)— RAG 的自动评测指标
- BFCL 伯克利工具调用排行榜 — 工具调用能力的公开评测
- pi:evals 包(commit
7b4cfd6)— 一个编程 Agent 的端到端评测:隔离目录、保存轨迹、基线和候选配对对比 - Anthropic:Quantifying infrastructure noise in agentic coding evals(2026-02)— 容器资源配置对分数的影响和报告建议
- Anthropic:Eval awareness in Claude Opus 4.6’s BrowseComp performance(2026-03)— 模型识破评测、找到答案文件的真实案例
- SWE-Bench Pro(Scale AI,2025)— OpenAI 停报 SWE-bench Verified 后推荐的编程基准
下一篇:10 可观测与线上运维——评测管发布前,可观测管发布后,Agent 在线上每一步做了什么要能查到。