当 Eval 全线飘红:一次 Live 模式调试的四层洋葱
一个"测试全挂"的 bug report,剥开后是四个独立问题的完美叠加。
背景
AgentZero 有一套 eval 平台,用 YAML 定义 benchmark case,通过断言引擎自动评分。支持两种模式:
- Mock 模式:加载预录制的 fixture 数据,断言跑在确定性数据上
- Live 模式:通过 Claude Agent SDK 真实调用 LLM,断言跑在真实响应上
Mock 模式一直绿着。某天切到 Live 模式跑了一把 —— 0/5 全挂。
这不是一个 bug,而是五个。
第一层:空房间里找家具
现象:Agent 说"工作目录是空的,文件不存在"。
benchmark 定义了相对路径 tests/fixtures/eval-workspace/package.json,Agent 被要求读取这个文件。但 Live 模式为每个 case 创建了隔离的临时目录 /tmp/agentzero-eval-xxx/,里面什么都没有。
// 创建了空目录,但 fixture 从未复制进去
export function createEvalWorkdir(runId: string, caseId: string): string {
const workdir = `/tmp/agentzero-eval-${runId}-${caseId}`
mkdirSync(workdir, { recursive: true })
return workdir // 空的!
}
修复:创建目录后,将 tests/fixtures/ 递归复制进去。
if (existsSync(FIXTURES_DIR)) {
const destFixtures = resolve(workdir, 'tests/fixtures')
cpSync(FIXTURES_DIR, destFixtures, { recursive: true, force: true })
}
Mock 模式不需要工作目录(它直接读预录制数据),所以这个缺失从未被发现。只有 Live 模式暴露了"隔离"和"可用"之间的矛盾。
第二层:不该上场的选手
现象:TOOL-005 期望 Agent 调用 unstable_tool,但 Live 模式下这个工具根本不存在。
TOOL-005 是一个错误恢复测试 —— 在 Mock 模式下注入一个必然失败的虚构工具调用,验证 Agent 是否能优雅降级。它的 YAML 明确标记了 mode: mock。
但 executeRun() 在筛选 benchmark 时,完全无视了 input.mode 字段:
// 只按 ID 筛选,没有检查 mode 兼容性
const selected = allBenchmarks.filter((b) => idSet.has(b.id))
修复:加一层 mode 过滤。
const selected = allBenchmarks
.filter((b) => idSet.has(b.id))
.filter((b) => b.input.mode === 'both' || b.input.mode === mode)
同时将 TOOL-005 重新设计为 Live 兼容 —— 从虚构的 unstable_tool 改为 curl localhost:19999(无服务监听,必然 Connection refused),mode 改为 both。
教训:有数据无消费。mode 字段在 YAML schema 里定义了,在类型系统里声明了,在 UI 上渲染了,唯独在最关键的执行路径上没有被检查。
第三层:尺子太短
现象:TOOL-001 到 TOOL-003 全部 latency 超标(30s 阈值,实际 70-97s)。
这些阈值是基于直连 Anthropic API 设定的。但我们通过智谱 BigModel 中转(https://open.bigmodel.cn/api/anthropic),额外的网络跳跃增加了 10-20s 延迟。再加上 Agent 在空目录中反复搜索、重试(第一层的连锁效应),耗时进一步膨胀。
修复:全局调整 latency 阈值。
| 类型 | 旧值 | 新值 | 理由 |
|---|---|---|---|
| 纯对话 (CONV-*) | 15-30s | 60s | 中转 API 开销 |
| 工具调用 (TOOL-*) | 30-45s | 120s | 多轮工具交互 + 中转开销 |
| 多 Agent (TEAM-*) | 90-120s | 不变 | 已有合理余量 |
Mock 模式通常 <1s 完成,所以放宽阈值不影响 Mock 的有效性。更好的方案是让断言引擎按模式区分阈值,但那是另一个 PR 了。
第四层:断言验的是措辞,不是理解
第一层修复后重跑,大部分 case 通过了,但有两个"差一点"的失败:
TOOL-003:断言要求响应匹配 /Date\s*\)\s*:\s*string|function formatDate/(代码签名格式)。模型实际回复了"该函数接收 Date 对象,通过 getFullYear()... 返回 YYYY-MM-DD 格式字符串"—— 自然语言描述,完全正确,但不含代码签名。
TOOL-005:断言要求匹配 /refused|timeout|失败|error/i。模型做了更专业的诊断 —— 先用 lsof 检查端口,直接得出"端口 19999 上没有服务运行"的结论,跳过了贴 curl 错误信息的环节。
修复:放宽正则,接受高层诊断表述。
# Before: 只匹配低级错误关键词
pattern: "refused|reset|timeout|失败|error|failed"
# After: 同时接受高层诊断
pattern: "refused|reset|timeout|失败|error|failed|没有服务|未启动|not running"
教训:好的 eval 断言验证"理解"而非"措辞"。模型用专业诊断替代直接贴错误信息,这是更好的行为。断言不应惩罚高质量输出。
总结
| 层 | 问题 | 类型 | 影响范围 |
|---|---|---|---|
| 1 | Fixture 未播种到隔离目录 | 环境缺失 | 所有 fixture-backed case |
| 2 | Mode 过滤缺失 | 防御性编码 | mock-only case 被错误执行 |
| 3 | Latency 阈值过紧 | 环境适配 | 中转 API 的所有 case |
| 4 | 断言过于死板 | eval 设计 | 高质量但非模板化的响应 |
四个问题各自独立,但在同一次 Live 跑批中完美叠加,制造了"全线飘红"的假象。每一层的修复都不超过 10 行代码。
最大的收获不是某个具体的 fix,而是一个工程原则:
Mock 模式通过 ≠ 系统正确。真实环境永远比你假设的复杂,而复杂性喜欢叠加。