← 返回文章列表← Back to posts

当 Eval 全线飘红:一次 Live 模式调试的四层洋葱When All Evals Fail: A Four-Layer Debugging Onion in Live Mode

一个"测试全挂"的 bug report,剥开后是四个独立问题的完美叠加:fixture 未播种到隔离目录、mode 过滤缺失让 mock-only case 被错误执行、latency 阈值不适配中转 API、断言太死板惩罚了高质量输出。每层修复不超过 10 行代码。A "zero pass rate" bug report peeled back into four independent issues stacked perfectly: fixtures not seeded into isolated workdirs, missing mode filtering, latency thresholds too tight for proxy APIs, and assertions punishing high-quality but non-templated responses. Each fix under 10 lines.

·7 分钟阅读min read

当 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 模式通过 ≠ 系统正确。真实环境永远比你假设的复杂,而复杂性喜欢叠加。