← 返回文章列表← Back to posts

用评测找 Bug:一次 Agent 能力测试如何从分数变成显微镜Using Evals to Find Bugs: How an Agent Capability Test Became a Microscope

一次 Agent 能力评测从补 case 开始,最终暴露出测试污染、App live 写入边界、断言过拟合和 Dashboard 刷新等问题,展示评测如何成为调试系统的显微镜。An Agent capability eval started as benchmark expansion, then exposed test pollution, App live runtime boundaries, overfit assertions, and a Dashboard refresh bug, showing how evals can become a debugging microscope.

·14 分钟阅读min read

用评测找 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

这会带来一个错觉:系统看起来“很安全”,但不一定能证明它“真的会做事”。

于是我们决定补强四个薄弱类别:

  • skills
  • tool-use
  • agent-teams
  • computer-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 设计边界没有表达清楚。

修复方式有两层:

  1. 把写入型 case 标成 liveRuntime: sdk
  2. 增加 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-008 12/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 应该像一次可重复的事故演练:

  1. 有明确的任务目标
  2. 有真实工具链
  3. 有可检查的最终产物
  4. 有 trace 可以复盘
  5. 失败后能定位到一类问题,而不是只给一个红叉

最理想的状态不是“所有 eval 永远是绿的”。

最理想的状态是:当它变红时,我们知道应该去修产品、修模型提示、修 benchmark,还是修测试系统自己。

这次 TOOL-008 从失败到通过,刚好把这件事演示了一遍。


2026-04-23 · AgentZero eval hardening