← 返回文章列表← Back to posts

给 AI 评测平台做体检:8 小时拆了 7 个连环坑An 8-Hour Deep Clean of an AI Eval Platform: 7 Hidden Bugs Uncovered

从 benchmark 质量审计意外演变成数据库架构深度清理——43% 的测试其实什么都没测、一个沉默的 loader bug 让两种断言从未被激活、测试套件偷偷污染生产数据库、9 张从未写过的"愿景式"死表、以及单例模式关闭后的静默回退陷阱。一个完整的 7 层连环坑剥洋葱故事。What started as a benchmark audit turned into a deep DB architecture cleanup — 43% of tests checked nothing, a silent loader bug disabled two assertion types forever, the test suite was polluting the production DB, 9 'aspirational' dead tables, and a treacherous singleton-fallback trap. A full 7-layer onion-peeling postmortem.

·15 分钟阅读min read

给 AI 评测平台做体检:8 小时拆了 7 个连环坑

一次"benchmark 审计"意外演变成 DB 架构深度清理的实录

2026-04-09 到 2026-04-10 · 8 commits · ~2000 行净删除


TL;DR

我用的一个 AI 产品自带评测平台:51 个测试用例、14 张数据库表、12 个 Dashboard 页面、飞轮、CI —— 看上去应有尽有。

但当我打开第一个叫"文件读取"的测试,发现它的断言里一个字都没提"文件"。

那天我原本只想"好好用起来",结果剥洋葱一样一层一层挖下去,发现了 7 个互相掩盖的架构 bug。一句话核心:

"有工具无流程" 是评测平台的猝死综合症 —— 所有功能都是未拆封的新器械,第一次有人认真用就会发现螺丝没拧紧。


第 1 层:43% 的测试其实什么都没测

这是我盯着看了 5 秒才确认的事:

id: TOOL-001
name: "文件读取"
input:
  prompt: "读取 package.json 文件内容"
assertions:
  - type: no_error
  - type: latency_below_ms: 30000

Agent 回答 "对不起我做不到":没报错 ✓ 没超时 ✓ 完美通过。

这不是个别问题。我用三问法(假阳性?假阴性?覆盖能力?)把 51 个用例过了一遍:

等级 占比 含义
🟢 A 12% 断言真的反映能力
🟡 B 45% 能抓严重退化
🔴 C 43% 只检查没崩溃

C 级集体出现在"工具使用"类(TOOL/COMP/CRON/SKIL)—— 这类测试本来就应该检查"Agent 调用了某工具",但没有一条用 tool_called 断言。整个项目里的"工具使用测试"其实只测了"Agent 没崩"。

修复思路:好的断言必须锚在 ground truth 上。我新建了一个 fixture 工作区,每个文件都埋了独一无二的锚点字符串:

tests/fixtures/eval-workspace/
├── package.json         ← "name": "eval-fixture-project" v1.4.2
└── src/
    ├── index.ts         ← "TODO: 添加错误处理"
    ├── utils.ts         ← "TODO: 优化性能"
    └── config.ts        ← "TODO: 从环境变量读取配置"

重写后的 TOOL-001:

assertions:
  - tool_called: Read                        # 必须调用 Read
  - contains_text: "eval-fixture-project"    # 必须说出独一无二的锚点
  - contains_text: "1.4.2"
  - file_contains: '"name":\s*"eval-fixture-project"'  # Fixture 完整性

教训:如果断言不指向一个具体的、只有做对才能说出来的事实,你测的不是"做对了",是"没崩"。


第 2 层:一个 loader bug 让 2 种断言类型从未被激活

给 TOOL-001 加 file_exists 断言后,loader 吐出:

[评测] Benchmark "TOOL-001" 断言类型无效: file_exists

翻源码:

const VALID_ASSERTION_TYPES: Set<string> = new Set([
  'contains_text', 'regex_match', 'tool_called',
  'no_error', 'latency_below_ms', ...
  // ← 缺了 file_exists, file_contains
])

断言引擎实现了这两种类型(200 行代码),类型系统也定义了,就差 loader 这个 Set 里没加。

整个项目历史上从未有任何 case 真正用过这两种断言。作者可能试过,看到 warning 以为自己拼错了,放弃了。

修复是一行代码。但它让整个"文件系统断言"子系统第一次真正可用。

教训:架构漂移是沉默的 —— 四层里修了三层,剩的一层可以静默存活任意长时间,直到有人第一次认真用它。


第 3 层:Mock 数据和测试用例早就漂移

跑 baseline,3 个 TEAM 用例失败。打开 TEAM-001 的 mock:

{
  "response": "我已将调研任务分派给 teammate...\n\n## React Server Components 最新进展..."
}

而这个测试的断言在检查:

regex: "硬编码|JWT_SECRET"
regex: "MD5|哈希.*salt"
regex: "SQL.*注入"
regex: "XSS"

Mock 讲 React Server Components,断言检查 SQL 注入 —— 风马牛不相及。

这不是今天坏的 —— 几周前断言升级时留下的漂移。意味着:CI 每天都红 3 个,但没人看。

教训:机械化的告警如果没有审阅节奏配套,两周内就会变成静态噪声。修 mock 容易,建节奏难。


第 4 层:测试套件在偷偷污染生产数据库 🚨

打开生产 DB:

Unreviewed:  793
Good:        115   ← 居然有人标注过 115 条 good!
Bad:         579   ← 还有 579 条 bad!

激动地开始审阅。前几条:

id: api-test-uj6ynk3d
prompt: Test prompt for open coding API
response: Test response

过滤掉 test-* / api-test-* 前缀的行之后:

Quality 总数 真实 测试污染
good 115 0 115
bad 579 1 578

真实的人工标注是 0 条。所有"已标注"的都是 bun test 写进来的测试残渣。任何基于这些数据的 Dashboard 图表都是在画沙子。

继续挖根因 —— 我以为一修就完事,结果发现这是4 层独立的 bug 叠加:

4a · 测试文件没有 :memory: 隔离

// trace-db.test.ts — 没 beforeEach,没 :memory:
insertTrace(trace)  // ← 默认路径 = ~/.agentzero/eval.db

4b · 模块加载时机陷阱

const DB_PATH = resolveDashboardDbPath()  // ← 模块加载那一刻就冻结了

谁先加载这个文件,DB_PATH 就被永久冻结为当时解析的值。后来者设环境变量已经晚了。

4c · 单例模式的静默回退(全场最深的坑)

export function getEvalDb(dbPath?: string): EvalSqliteDb {
  if (db) return db
  const path = dbPath ?? getEvalDbPath()  // ← 无参时回退到生产路径
  db = createSqliteDb(path)
  return db
}

一个经典调用序列:

  1. 测试 A 用 :memory: → 跑完 closeEvalDb() → db 置空
  2. 测试 B 调 getEvalDb() 不传参 → 悄悄跳到生产 DB

一次 close,下次 open 就静默污染生产。

4d · import hoisting 让环境变量来不及生效

process.env.AGENTZERO_EVAL_DB = ':memory:'   // 意图:先设
import { app } from '../server'              // 后 import

ES module 的 import hoisting 会把 import 提到最前面 —— 环境变量设置其实发生在 server 已经初始化之后。

修复:防御深度

Layer 1: 测试文件用 :memory: + beforeEach
Layer 2: run-executor 延迟解析路径
Layer 3: getEvalDb 记住 sticky path
Layer 4: 测试文件改用动态 import

任何一层失效,其他层都能兜住。修完之后,跑完整个 2344 个测试的全套,生产 DB 行数依然是 0。

教训:

  1. 污染问题是系统性的,不是个别的 —— 修完一层问"还有哪个路径能绕过"
  2. 单例模式 + 无参默认 = 危险组合 —— closed 之后的 reopen 要粘住原路径
  3. import hoisting + 顶层副作用 —— 需要的环境变量要么用动态 import,要么靠进程级配置

第 5 层:9 张"愿景式"表成为技术债

清理数据库时发现 14 张表里:

活跃:  eval_runs, eval_results, eval_traces, trace_spans,
      trace_scores, pairwise_comparisons          (6 张)

死表:  datasets, dataset_versions, dataset_cases,
      benchmark_versions, eval_prompts, eval_scores,
      scoring_configs, annotation_queues,
      annotation_queue_items                      (9 张 · 全部 0 行)

64% 的 schema 是空壳。看 git 历史能看出清晰的模式 —— 每次迭代都照搬一个行业平台的能力:

v2 → 借鉴 Langfuse
v5 → 借鉴 BrainTrust
v8 → 借鉴 Phoenix + LangSmith + Langfuse
     ← 从这里开始全是画饼

每次都加表,但没有一次清理遗留。加表简单(CREATE TABLE IF NOT EXISTS 幂等安全);删表麻烦(要考虑数据、回退、测试、类型)。默认行为是"先加再说",一年后 schema 变成博物馆。

修复:schema v9 迁移删 5 张表。但这里又踩了一个坑 —— "安全删除"本身不安全。

第一版:

d.exec('DROP TABLE IF EXISTS eval_scores')  // ← 我确定是空的

Codex(外部 review)立刻指出:"只对 production 确定,用户的开发副本可能有数据。"

第二版:先备份再删。

try { writeFileSync(backupPath, backup) }
catch { console.warn('备份失败,继续...') }  // ← 这是自欺欺人
d.exec('DROP TABLE...')

Codex 又说:"console.warn 不是备份机制 —— 备份失败了 DROP 照跑,数据还是丢了。"

第三版:备份失败则 abort 整个迁移。

try {
  writeFileSync(backupPath, backup)
} catch (err) {
  throw new Error(`迁移中止:无法备份到 ${backupPath},DB 保持 v8`)
}
// 只有备份真的写成功才执行 DROP

原则:宁可让 DB 停在 v8 让用户手动介入,也不能静默删数据。

教训:

  1. 加表比删表容易 10 倍 —— 新表 PR 必须回答"生命周期是什么?如果半年没人用怎么删?"
  2. try/catch/warn/continue 是数据丢失的经典模式 —— 看到就要警觉
  3. Fail loud, fail early —— 保护失败要 abort,不要 log

外部 Review 的价值

修完第一版我自己跑测试,全绿。交给 Codex review,找出 4 个问题:

  1. 🔴 HIGH — 漏审了另一个目录下的 tests/integration/eval-dashboard.test.ts
  2. 🟡 MEDIUM — CLI 默认 :memory: 破坏了回归检测
  3. 🟡 MEDIUM — 迁移的备份失败处理不严
  4. 🟢 LOW — Triage filter 用内容启发式会误杀短 prompt

修完第二版又交给 Codex,再找出 1 个:迁移 abort 逻辑不彻底。

第三版终于过了。

为什么我自己找不到?

因为我验证修复时跑的是"我预期能过的测试"。都绿了就开心。

Codex 反过来问:"有没有我没想到的文件/顺序/路径能破坏这个?" 然后真的跑命令复现:

"I reproduced it with bun test tests/integration/eval-dashboard.test.ts ...; the API tests then wrote against the already-open default DB"

你永远想不出自己没想到的盲点。这是外部视角不可替代的价值。

教训:大规模重构每修完一层立刻 review,不要等到"都做完了"—— 因为每一层 bug 揭示的盲点往往指向下一层 bug。


7 条可迁移的方法论

  1. "有工具无流程"是猝死综合症 —— 验收平台问"上次谁用过",答不出的就是死的
  2. 断言必须锚在 ground truth 上 —— fixture 预埋 + 精确事实
  3. 反鹦鹉学舌 —— 关键词只能选 reference 里有、prompt 里没有的词
  4. 测试污染是系统性的 —— 必须防御深度,不是单层修复
  5. 每加一张表都是技术债 —— PR 必须答生命周期 + 删除路径
  6. try/catch/warn/continue 是隐藏的数据丢失 —— Fail loud, fail early
  7. 外部 review 对抗 confirmation bias —— 早做、频繁做

最后的数字

  • 🗑 5 张表删除(从 14 张减到 9 张)
  • ✏️ 22 个 benchmark case 重写(C 级从 43% → 6%)
  • 🧹 694 条污染 trace 清零
  • 🛡 5 层防御深度
  • 🔍 3 轮 Codex review
  • 📉 ~2000 行净删除

给维护评测平台的你:5 个诊断问题

(答案让你心虚的地方,就是你和我一样的问题)

  1. 上次真的有人用人工 triage 工具审阅过生产 trace 是什么时候?
  2. 随便打开三个 benchmark,断言能分辨"Agent 做错了"和"Agent 没崩"吗?
  3. 跑完整个测试套件,生产数据库的行数有没有变?
  4. 你 schema 里每张表都有至少一个 production code path 在写它吗?
  5. 你的 CI 名字叫 "Live" 但跑的是 "mock" 吗?

最后一点

这不是在批评 AgentZero 的原作者。每一个坑都来自"按正确的方向走但没走到底"—— 作者引入了行业方法论、建了完整的 schema、写了 12 个 Dashboard 页面,这是一个有品味的设计。

只是因为没有"使用者 × 时间"的压力测试,所有画好的饼都没被吃过。

一个评测平台真正的敌人不是代码质量,而是没人真的每天用它。


2026-04-10