用评测找 Bug:一次 Agent 能力测试如何从分数变成显微镜
这不是一篇“我们补了几个测试用例”的流水账。 更准确地说,这是一次用评测系统反过来调试产品、调试 benchmark、调试自己假设的过程。
背景:安全测得很厚,能力测得太薄
AgentZero 已经有一套 eval 系统:用 YAML 定义 benchmark case,用 mock fixture 做确定性回归,也支持 live 模式调用真实 Agent。
但有一个结构性问题:安全类 case 很多,能力类 case 太薄。
大致分布是:
| 类别 | 数量 |
|---|---|
| safety | 18 |
| conversation | 13 |
| scheduled-task | 6 |
| tool-use | 5 |
| computer-use | 3 |
| agent-teams | 3 |
| edge-case | 3 |
| skills | 2 |
这会带来一个错觉:系统看起来“很安全”,但不一定能证明它“真的会做事”。
于是我们决定补强四个薄弱类别:
skillstool-useagent-teamscomputer-use
要求也很明确:不能写简单单轮问答,必须是长任务、复杂工具链、有明确结果输出,并且能通过 trace 和端到端结果证明系统可用。
第一件事:先让评测自身稳定
真正开始补 case 前,第一步不是写 YAML,而是跑测试。
结果一跑就炸。
看起来像一堆随机失败,但拆开后发现,很多失败文件单独跑都能过,只有 bun test tests/unit/ 全量同进程跑才会失败。
根因是 Bun 的 mock.module() 是进程级全局替换。不同测试文件之间互相污染,造成“全量红、单测绿”的假失败。
修复方式不是去逐个追假红,而是把测试总闸拆成分组多进程:
{
"test": "bun run test:unit",
"test:unit": "bun run test:agent-core && bun run test:agent-runner && bun run test:orchestrator && ..."
}
这一步很重要,因为 eval 系统本身如果不稳定,后面所有红灯都没有可信度。
第一个教训:评测系统也需要被评测。
一个不稳定的测试总闸,会把真实问题和测试污染混在一起。
第二件事:补能力 case,但先画清运行链路
这套 eval 有三条运行路径:
flowchart TD
A["Benchmark YAML"] --> B["benchmark loader"]
B --> C{"Run mode"}
C -->|"mock"| D["mock JSON fixture"]
C -->|"CLI/Dashboard live SDK"| E["isolated temp workdir"]
C -->|"App live"| F["Electron eval runner"]
F --> G["runAgentWithOrchestrator"]
G --> H["real Agent tools"]
D --> I["assertion context"]
E --> I
H --> I
I --> J["assertion engine"]
J --> K["SQLite eval_results"]
K --> L["Dashboard detail"]
画完链路后,马上暴露出一个隐患:
CLI live SDK 会创建隔离临时目录,适合跑会写文件的 benchmark。
但 App live 路径会把真实运行目录指到项目根。也就是说,如果一个 App live case 断言 Edit 或 Write,它可能真的改到项目文件。
这不是理论风险,是 eval 设计边界没有表达清楚。
修复方式有两层:
- 把写入型 case 标成
liveRuntime: sdk - 增加 corpus sanity test:任何断言
Edit/Write/MultiEdit的 benchmark,默认不能支持 App live,除非显式标记app-live-write-safe
这样以后不是靠人记住“这个别在 App 里跑”,而是由测试系统阻止误用。
第二个教训:live eval 不是 mock eval 的放大版。
一旦工具会触碰真实环境,benchmark 就必须表达运行边界。
第三件事:让 case 像真实任务,而不是考试题
这轮补了 4 个更像真实任务的 benchmark:
| Case | 覆盖能力 | 关键要求 |
|---|---|---|
SKIL-005 |
Skill | 调用 Skill,跨文件搜索 TODO,生成优先级矩阵 |
TOOL-008 |
Tool use | 读 README、搜索 TODO、实际调用 Bash 复现清单 |
COMP-005 |
Computer use | 打开计算器,输入表达式,核对结果 |
TEAM-005 |
Agent Teams | 两个 teammate 交叉复核 README 和源码,汇总优先级 |
每个 case 都配了 mock fixture,保证 mock 模式可以作为快速回归。
同时新增 eval:ability-smoke:
bun run eval:ability-smoke
它会跑核心 Skill case 加上新增能力 case。mock 结果是 8/8 通过。
但 mock 通过不代表 live 通过。真正有意思的部分在后面。
第四件事:App live smoke 第一次失败
我们给 App live 增加了一条真正的端到端 E2E:
AGENTZERO_RUN_LIVE_EVAL=1 bunx playwright test --config=tests/e2e/playwright.config.ts tests/e2e/journeys/j8-eval-live-smoke.spec.ts
这条测试不是直接调用 CLI,也不是绕过应用逻辑。它启动 Electron,通过 window.electronAPI.startEvalRun() 创建 live eval,等待 run 完成,再从 DB 读取 eval_results,检查:
- run 状态是 completed
- benchmark 是
TOOL-008 - 断言全部通过
- 工具调用里真的出现
Read、Grep、Bash
第一次跑,失败了。
结果很微妙:
- run 成功创建
- DB 里有 result
- 没有 runtime error
Read和Grep都调用了- 但
Bash没有调用
模型在最终回答里写了:
grep -rn "TODO" tests/fixtures/eval-workspace/src/
但它只是“给出命令”,没有“调用 Bash 工具执行命令”。
这不是产品崩溃,而是 benchmark prompt 写得不够精确。
原 prompt 说:
用 Bash 给出一条可复现相同 TODO 清单的命令
模型理解成“在回答里给出一条 Bash 命令”。这很合理。
于是我们把 prompt 改成:
必须调用 Bash 工具实际执行一条只读命令,复现相同 TODO 清单。
再跑,Bash 出现了。
第三个教训:评测失败不一定是产品失败。
有时候红灯暴露的是 benchmark 本身的语义漏洞。
第五件事:App live smoke 第二次失败
修完 prompt 后又跑,还是失败。
这次 DB 里显示工具链已经完整:
[
{ "name": "Read", "count": 2 },
{ "name": "Grep", "count": 2 },
{ "name": "Bash", "count": 2 }
]
但断言仍然挂了一条:命令正则没有匹配。
模型实际输出的是:
grep -rn "TODO|FIXME|XXX" tests/fixtures/eval-workspace/src/
或者:
grep -rn "TODO" /repo/tests/fixtures/eval-workspace/src/
原断言只接受非常窄的形式:
rg -n "TODO" tests/fixtures/eval-workspace/src
| grep -rn "TODO" tests/fixtures/eval-workspace/src
这就是典型的“断言验证措辞,而不是验证能力”。
模型执行了合法的只读搜索命令:
- 使用
grep而不是rg - 搜索
TODO|FIXME|XXX而不是只搜TODO - 使用绝对路径而不是相对路径
这些都不应该被判错。
修复后的断言保留核心约束:
- 必须是
rg -n或grep -rn - 查询参数里必须包含
TODO - 路径必须指向
tests/fixtures/eval-workspace/src - 相对路径和绝对路径都允许
再次运行,App live 单次通过:
| 指标 | 结果 |
|---|---|
| run status | completed |
| benchmark | TOOL-008 |
| assertions | 12/12 |
| tools | Read x2, Grep x2, Bash x2 |
第四个教训:好的 eval 断言应该锁住行为边界,而不是锁死表达格式。
第六件事:一个 UI bug 也被顺手照出来
在 App live 链路里还发现一个 UI 观察性问题。
Run detail 页面打开时,run 还在 running。组件会按 run.id 拉一次结果,此时 DB 里还没有 rows。等 run 完成后,run id 没变,组件不会再次拉取,页面可能一直显示“暂无结果数据”。
后端其实已经写入了结果,UI 只是没刷新。
修复方式很小:
export function getEvalRunResultsRefreshKey(run) {
if (!run?.id) return ''
return [run.id, run.status ?? '', run.summaryJson ?? ''].join(':')
}
EvalRunDetail 不再只依赖 run.id,而是依赖 id + status + summaryJson。同一个 run 从 running 变成 completed 时,会重新拉结果。
顺手还加了一个防御:
export function parseAssertionResults(json?: string) {
try {
const parsed = JSON.parse(json)
return Array.isArray(parsed) ? parsed.filter(isAssertionResult) : []
} catch {
return []
}
}
坏的 assertionsJson 不应该把详情页炸掉。
第五个教训:评测不只是判断 Agent 输出,也能验证产品的可观察性。
如果结果写进 DB 但 UI 看不到,用户感知上仍然是“评测没跑出来”。
最终结果
这轮最后跑过的验证包括:
bun run test
bun run typecheck
bun run electron:build
bun run eval:ability-smoke
bun run eval/utils/verify-benchmark-mocks.ts
AGENTZERO_RUN_LIVE_EVAL=1 bunx playwright test --config=tests/e2e/playwright.config.ts tests/e2e/journeys/j8-eval-live-smoke.spec.ts
结果:
- unit gate 通过
- typecheck 通过
- Electron build 通过
- benchmark corpus 63 cases / 63 mocks 对齐
- ability smoke 8/8 通过
- App live
TOOL-00812/12 通过 - App live 工具链确认包含
Read、Grep、Bash
这次真正修了什么
表面看,这轮是在“补 benchmark”。
实际修了四类东西:
| 类型 | 具体问题 | 修复方式 |
|---|---|---|
| 测试基础设施 | 单进程测试被 mock 污染 | 分组多进程 test gate |
| 产品 bug | Run detail 不随完成状态刷新 | 用 refresh key 触发结果重拉 |
| 安全边界 | App live 可能跑写入型 case | liveRuntime + corpus sanity guard |
| Benchmark bug | prompt 和断言过拟合 | 要求真实 Bash 调用,放宽合法命令格式 |
这也是 eval 系统最有价值的地方:它不是一个分数仪表盘,而是一套问题分类器。
当一个 live case 失败时,失败可能来自:
- 产品 bug
- 模型行为不稳定
- benchmark prompt 不清楚
- 断言过拟合
- 测试基础设施污染
- UI 可观察性缺口
如果只看“pass/fail”,你会把这些都混成一团。
如果保留 trace、DB result、tool call summary 和 failed assertions,你就能把问题拆开。
我现在对 Eval 的理解变了
以前很容易把 eval 当成验收工具:
写完功能,跑一遍,看分数。
这次之后,我更愿意把它当成调试工具:
设计一个足够真实的任务,让系统暴露它实际会怎么做。
好的 eval case 应该像一次可重复的事故演练:
- 有明确的任务目标
- 有真实工具链
- 有可检查的最终产物
- 有 trace 可以复盘
- 失败后能定位到一类问题,而不是只给一个红叉
最理想的状态不是“所有 eval 永远是绿的”。
最理想的状态是:当它变红时,我们知道应该去修产品、修模型提示、修 benchmark,还是修测试系统自己。
这次 TOOL-008 从失败到通过,刚好把这件事演示了一遍。
2026-04-23 · AgentZero eval hardening