测试 Agent 为什么漏掉 Bug?从读 trace 到失败分类
先读 trace,再定指标。一条条读,记下第一个出错的地方,归成几类,数一数,再决定先修什么、给什么写评测。
召回率 45% 只告诉你"不够好"。错误分析回答"怎么不好":是没进到页面、看到了没认出来,还是把环境故障当成了 Bug。这几种的修法完全不同。
1为什么先做错误分析,而不是先定指标
场景:BugHunt 的测试 Agent 在 8 个注入了 Bug 的功能区上跑,召回率 45%。常见的反应有三种:换一个更强的模型;在 prompt 里加一句"请仔细检查";接一个现成的"有用性 1~5 分"Judge 天天打分。三种都没回答一个问题:它到底是怎么漏的。
Hamel Husain 和 Shreya Shankar 的 FAQ《AI Evals: Everything You Need to Know》开门见山:"Error analysis is the most important activity in evals.",理由是它"helps you decide what evals to write in the first place"。对现成的通用指标,他们的态度是:拿来当质量度量会浪费时间、制造虚假的信心,但可以用来挑出值得看的 trace。(这份 FAQ 页面标注发布于 2026-09-18,作者自己说明这些是"sharp opinions",不是普遍真理。)
所以顺序是:先读 trace,让失败分类从数据里长出来,再给每一类决定怎么修、要不要写评测。trace 怎么记,见第 2 周「Agent 做错了,你怎么知道它错在哪一步?」。
2四步:读 trace → 开放编码 → 轴向编码 → 计数排序
| 步骤 | 做什么 | FAQ 里的建议 |
|---|---|---|
| 1. 收集 trace | 攒一批有代表性的 trace | 没有真实数据就先用合成数据;先随机抽样,再用异常值、按指标排序、分层抽样去找有意思的 |
| 2. 开放编码 | 一条条读,用自由文本写下哪里不对,像写日记 | 刚开始只记 trace 里第一个失败,因为上游的错会引出下游的错(熟练后可以再标相互独立的失败);至少先自己标 30 条,再看 agent 的建议 |
| 3. 轴向编码 | 把笔记归成一张失败分类表(failure taxonomy) | "Axial coding is the most important step.";可以让 LLM 先帮忙分组,但必须自己审 |
| 4. 计数、迭代 | 数每一类有几条,排序;继续读,直到新的 trace 不再带来新类别 | 这叫理论饱和(theoretical saturation);建议至少读 100 条 |
开放编码、轴向编码这两个词来自质性研究。谁来做:FAQ 建议中小公司指定一位懂业务的领域专家(他们叫 "benevolent dictator")说了算,而不是把标注外包出去。对 BugHunt 来说,这个人就是最懂被测应用的测开。
3开放编码:一条笔记该怎么写
| 不好的笔记 | 问题 | 好的笔记(合成样例) |
|---|---|---|
| "漏报了" | 这是结局,不是哪里出了错 | "跳到优惠券叠加后又被重定向回登录页,后面都在登录页里转" |
| "模型能力不够" | 是猜原因,读不出发生了什么 | "购物车合计页面上的数字明显不对,Agent 截了图但只说页面正常显示" |
| "一直重复点按钮,然后漏报" | 记的是下游症状,第一个错在更前面 | "订单取消:点了按钮但页面没变化,选择器指向了一个隐藏元素" |
- 写看到的现象,先不追原因。FAQ 的说法是先别管为什么出错,等决定修哪些之后再查根因;这样标注质量更高,也能多看一些数据。
- 刚开始只记第一个上游失败。登录态丢了,后面的"在登录页里反复点"、"判定没 Bug"都是连带的。FAQ 也说,熟练后可以再标同一条 trace 里相互独立的失败,但下游连带症状不算独立失败。
- 不是模型的问题也记。登录态丢失很可能是 harness 没处理会话过期,这同样让产品变差。
- 通过的 trace 也要读。没 Bug 的功能区上说"没 Bug",可能是根本没进到页面,结论碰巧对。
4轴向编码:分类表要满足什么
- 按原因分,不按结局分。"漏报 / 误报"是另一个维度(结局),把它放进分类表,它会因为每条失败都带一个而排到第一,却什么也没告诉你。
- 互斥:每条失败恰好归一类。放不进去就新开一类,不要硬塞进"其他"。
- 能判定:拿一条新 trace,另一个人按类名和说明也能归到同一类。标注一致性怎么量,留到本周后面讲 Judge 校准时再说。
- 会变:读得越多,类会拆、会并、会重新定义。这就是 criteria drift,正常现象。
5读多少条才够:饱和与 rule of three
某类失败在线上流量里占比 \(p\),独立地读 \(n\) 条,至少碰到它一次的概率和需要的条数:
$$P(\text{至少一次}) = 1-(1-p)^n,\qquad n_{95} = \left\lceil \frac{\ln 0.05}{\ln(1-p)} \right\rceil$$| 占比 p | 读 100 条至少见到一次 | 要 95% 把握需读 |
|---|---|---|
| 10% | >99.9% | 29 |
| 5% | 99.4% | 59 |
| 3% | 95.2% | 99 |
| 2% | 86.7% | 149 |
| 1% | 63.4% | 299 |
反过来读:100 条里一次也没见到某类,按第 1 周「30 次全过,能说明什么?」的反证法,它的占比 95% 上界是 \(1-0.05^{1/100} \approx 2.95\%\),rule of three 近似给 3%。"读 100 条"的意思是:占比 3% 以上的失败类型,大概率至少露一次面;更少见的可能一次都碰不到。
前提是各条 trace 独立。同一个功能区重复跑的 trace 会扎堆(第 1 周「跑了 300 次,就是 300 个样本吗?」),有效样本更少,要读得更多,或者按功能区分层抽。
6自动信号不等于失败:本机 Codex 会话的只读统计
能不能跳过人读,直接用程序算出的信号当失败率?拿本机 ~/.codex/sessions 试一下。本机会话由多个 Codex 版本写成,命令执行有两种记录方式:
- A. 直接调用
exec_command(2026-03~07):输出头里有一行Process exited with code N,进程还在跑时没有这一行(core/src/tools/context.rs:534-540)。 - B. 代码模式(2026-07 以后):模型调用
exec工具,在 JS 里再调tools.exec_command,退出码以结构化字段返回给 JS(context.rs:448-479),没有上面那行文字;但每条命令结束时会写一个CommandExecution条目,带exit_code和status,而且命令跑完退出码非 0 就记成Failed(工具调用被拒的另记Declined)(core/src/tools/events.rs:532-548)。
脚本只读、只输出聚合数字,截止 2026-10-01 18:00(北京时间):
| 来源 | 文件 | 带退出码 | 非零 |
|---|---|---|---|
A. 直接调用 exec_command(2026-03~07) | 45 | 4,685 | 178(3.8%) |
B. CommandExecution 条目(2026-09~10) | 176 | 10,591 | 860(8.1%) |
| A + B 合计 | 221 | 15,276 | 1,038(6.8%) |
| 未纳入:代码模式但没有条目(2026-07~09,退出码只在 JS 打印的文本里) | 41 | exec 调用 1,513 次 | |
| 程序大类(A + B) | 带退出码 | 非零 | 退出码 1 | 127 | 128 |
|---|---|---|---|---|---|
| 查看 / 搜索(grep、sed、cat、test、diff 等) | 7,332 | 252 | 219 | 0 | 2 |
| 版本控制(git 等) | 1,527 | 84 | 50 | 0 | 27 |
| 运行 / 构建 / 测试 | 1,443 | 258 | 208 | 5 | 0 |
| 其他(本机工具、shell 控制结构) | 4,974 | 444 | 346 | 16 | 4 |
同一个数字,含义因程序而异(本机实测):BSD grep 2.6.0 没匹配到返回 1;git 2.50.1 在非仓库目录执行 git status 返回 128,git status、git diff 这类命令参数写错返回 129(git log 参数写错是 128);bash 3.2 找不到命令返回 127,被 SIGINT 中断返回 130(128 + 2),被 kill -9 返回 137(128 + 9)。查看 / 搜索类的 252 次非零里 219 次是退出码 1,多半只是没搜到、条件不成立或有差异。
反过来,失败也不一定表现为非零退出码。A 里有 176 次调用的输出没有退出码:146 次是进程还在跑,24 次是 apply_patch 被拦截执行成功,剩下 6 次是真的失败(3 次 exec_command failed,命令没能执行;3 次 apply_patch 校验失败)。只数非零退出码,这 6 次一次也数不到。跑错了测试文件、测试全过,退出码也照样是 0。
failed 也不是。自动信号的用处是决定先读哪些(FAQ 里说的按指标排序、找异常值),读完再归类。▶互动演示:错误分析工作台
120 条合成的 BugHunt trace(12 个功能区各跑 10 次,其中 8 个功能区注入了 Bug;种子固定,和本地 analysis.py 的输出一致)。一条条读:左边是 trace 的每一步和评审笔记,右边是到目前为止的计数。可以切换"只记第一个上游失败"和"看到什么记什么",看排行怎么变;下面的曲线是"读到第 n 条时见过几种失败模式"。
▶互动演示:读多少条才够
某类失败在线上流量里占比 p,独立地读 n 条。拖动看至少碰到一次的概率,以及"读了 n 条一次没见到"时,它的占比最多是多少。
✎练习
7面试要点
一句话讲清楚
错误分析是评测的第一步:读一百条左右的 trace,每条记下第一个上游失败,归成互斥的失败分类,数每一类的条数,再按频率、严重程度和修复成本排优先级;能用代码判的写断言,需要人判断的才上 LLM Judge,并且先用人工标注校准。
追问准备
- 为什么不直接上一个通用的打分 Judge?通用分数说不清怎么坏、该修哪里;而且标准要看过输出才定得下来(criteria drift)。先错误分析,Judge 只给分类表里需要人判断的那几类写。
- 读多少条够?读到新 trace 不再带来新类别为止。粗算:占比 3% 的类,读 99 条有 95% 把握至少见一次;读 100 条没见到某类,只能说它的占比上界约 3%。
- 两类条数接近(17 对 14),谁先修?这批数据分不出谁更常见(条件二项检验双侧 p = 0.72)。再看严重程度和修复成本:如果登录态丢失是 harness 没处理会话过期,改一处就能修好,可以排在前面。
- LLM 能不能替你做?开放编码自己做,至少先标 30 条;轴向编码可以让 LLM 先出分组,再逐条审。
常见错误说法
❌ "漏报最多,先修漏报":漏报是结局,不是原因。
❌ "每个症状都记一笔,越全越好":下游症状会挤到根因前面,排行失真;熟练后可以补标相互独立的失败,但下游症状不算。
❌ "非零退出码 6.8%,命令失败率就是 6.8%":退出码的含义因程序而异;命令没能执行时根本没有退出码,0 也不代表做对了。
❌ "读了 30 条没见过,就说明没有这类问题":只能说占比上界约 10%(rule of three)。
代码
本地 week06_Judge与错误分析/code/error_analysis/:traces.py 生成合成 trace,analysis.py 计数、区间和饱和,codex_signals.py 只读统计 Codex 退出码,test_error_analysis.py 22 个测试。