最近在 OceanBase 的 PowerContext 项目里做了一次完整的开源贡献:接了一个 issue,给 Agent 的"工作交接"(Rollover Handoff)写一个评测 harness,和"压缩式摘要"类方法做对比。功能写完只是一个开始——reviewer 用六条带复现例子的 inline comment 告诉我:这个 harness 在六个地方测的不是它声称要测的东西。
这篇文章把设计和这六个洞都整理出来。设计部分讲"怎么让一个没有模型、没有流量的项目也能跑对比实验",review 部分讲"评测类代码最常见的病"。PR 在 oceanbase/powercontext#1819,issue 是 #1791。
一、先把 issue 读对:要的是"能测",不是"测出如何"
场景是这样的:一个 Agent 在长会话里干了几百轮的活,上下文装不下了,需要把"工作状态"交接给一个全新的会话。项目里的 RFC 1783 定义了交接的格式——Objective、State、Disposition、Next Action、Evidence、Omissions 六个字段。issue 的诉求是:这个格式到底比"把原对话压缩一下再塞过去"强多少?
这类需求最大的坑是把它理解成"跑个实验、出个对比结论"。但现实约束摆在那里:仓库里没有真实 host、没有模型调用、没有生产流量,任何"实测结论"都无从谈起。所以正确的产品形态是:
- 交付物是 harness——一套可以反复运行、别人可以往里填数据的评测设施;
- 结论部分只说"harness 能测什么",并把"要得出真实结论还需要什么"写清楚。
换句话说,这个 issue 的验收标准是"项目从此有了这把尺子",而不是"尺子量出了什么数"。
二、harness 的骨架:让没有模型的东西也能对比
整个 harness 的输入有两样:一份任务锁(声明式的 ground truth),一份录制(host 集成记录的会话行为)。核心数据流是:
task lock ──► 装配四种上下文 ──► 注入字节测量
│
recorded attempts ────────────► 逐条评分 ──► 失败分类 ──► 报告
几个关键设计决策:
1. Ground truth 是声明的,不是采集的。 每个任务声明:新会话要安全续接必须知道哪些状态事实、每个事实的证据在哪个 turn、声明的下一个动作是什么、哪些事实已被替代(superseded)、哪些证据根本做不出来。加载器 fail-closed——一个无法被评分的任务直接拒绝,而不是凑合着打分。
2. 字节上限属于协议,不属于方法。 四个方法(完整转录 / 头 2 尾 4 的压缩转录 / 只留最后一轮的非正式摘要 / rollover-handoff)共用同一个字节上限。这样"注入字节数"测的是方法在预算内的取舍,而不是谁的额度大。
3. 字节和结果永不合并成一张表。 排名函数 outcome_rank 刻意不含注入字节数——这个排名的存在意义是找 treatment 落后的 case,不是奖励"注得少"的方法。预算省了 40% 但事没办成,在任何意义上都不算赢。
4. 不跑模型。 评分的输入是录制:host 集成把新会话每一步"依赖了哪些声明的事实、执行了哪个动作"记录下来,评分因此是纯函数——同样的任务、同样的上下文、同样的录制,跑多少遍数字都一样。
三、review 揪出的六个洞
第一版自测全绿,但 reviewer 的六条 comment 全部成立。列个总表,然后挑三条细说:
| # | 问题 | 本质 |
|---|---|---|
| 1 | 只要步骤"声称依赖了"必需事实就算恢复成功 | 判定条件缺了"真的做了那件事" |
| 2 | 录制被拿来对新装配的上下文打分,没有身份绑定 | 录制与交付物之间没有契约 |
| 3 | 比较只按 host 名分组,忽略 model / host_revision | 把配置差异算到了方法头上 |
| 4 | 质量检查对着完整草稿,而不是字节上限筛剩的内容 | 检查对象是输入,报告说的是输出 |
| 5 | 转录类方法永远拿不到"预算截断"这个分类 | 分类器的输入少了一类载体 |
| 6 | 不传录制时,报告打印"每个任务都有录制" | 未测量被当成了通过 |
洞 2 是最典型的一个。 录制按 task/arm/host 匹配之后,直接对当次运行新装配的上下文评分——两者之间没有任何绑定。我把字节上限压到 1 字节复现了一下:每个上下文都被压得只剩一个字母,但报告照样输出 23 次成功。修法是让录制声明它是在什么协议下产生的:artifact 顶部加 protocol 块(task set id、任务锁摘要、字节上限),每条录制声明自己收到的上下文的 SHA-256,运行时逐一比对,对不上就拒绝,且错误信息把两个摘要都打出来。这里有个取舍值得记:评分函数本身保持纯函数(允许未绑定摘要),绑定只在 run 层强制——否则评分层的测试会全部背上绑定的负担。
洞 5 是最隐蔽的一个。 "预算截断"这个失败分类原来只认"被丢弃的 state 项",而转录类方法根本没有独立的 state 项——它的状态事实藏在对话轮次里。结果就是:字节上限把一个转录方法的证据轮全砍掉了,报告却把它分类为"上下文缺失",然后建议"扩大转录窗口"——可它的窗口本来就选了全部轮次。修法是把被丢弃的 turn:N 也映射回它承载的必需事实。修完跑一遍,budget_truncation 从 0 涨到 30 条。一个分类如果对某一类输入永远不可达,那它就不是保守,是坏了。
洞 1 是最根本的一个。 原来的判定是"步骤依赖了动作所需的事实"即算恢复。但"读了交接材料"不等于"接着干了活"——一个只把约束条件读一遍的录制照样满分。修法是给录制步骤加 performed_action_id 字段,恢复必须同时满足:依赖了必需事实、没踩已废弃事实、执行了任务声明的那个动作。修完之后完整转录的成功数从 12 掉到 7——掉的五条,正是 reviewer 点名的五条。
四、六个洞背后是同一个病
复盘下来,六条意见的形状完全一致:harness 拿一个对象做判断,而那个对象不是它声称要测的东西。 评分对象、绑定关系、分组维度、检查对象、分类输入、呈现语义——每一层都可能"测错对象"。这轮 review 沉淀了四条纪律:
- 绑定,而不是放宽。 reviewer 说"你在对新对象打分",错误回应是把检查调松,正确回应是让输入声明自己的身份,对不上就 fail closed。
- 按层归属,不按文件归属。 同一句"成功数偏高"可能同时涉及判定、绑定、呈现三层;只在报告层改措辞,下一轮 review 一定打回。
- 别为了让新检查通过而改小 fixture。 新门禁会让已入库的测试数据失效,正确做法是从源头脚本重新生成它,而不是删检查或手工补字段。
- 每条修复给实机前后数字。 "12→7"、"0→30"、"6→0"——数字对得上,reviewer 就不用自己复跑一遍。这比任何"已修复"都省沟通成本。
五、边界要写在产物里,不是写在 PR 描述里
最后说边界。这个 harness 跑出来的所有数字都来自 authored fixture——它们证明的是"harness 能测",而不是任何真实环境的表现。这些边界没有只写在 PR 描述里,而是写进了报告输出的第一行横幅、README 和文档的 Boundaries 段:reviewer 会读代码里的字符串,声称什么就该在产物里体现什么。
要走向真实的 benchmark,还差两步:接真实 host 的录制(这是唯一携带"host 实际做了什么"的输入),以及把六个任务的声明式任务集扩到更大的规模。harness 本身已经为此留好了位置——这大概就是"先造尺子,再谈量"的顺序问题。
六、附记:CI 红了,但被判红的不是我的代码
正文讲的是 harness 测错对象。收尾时撞上一个同构的问题,只是发生在流程层:CI 判红,被归因的对象也不是我改的东西。
现象是 tests job 红,而且红的姿势一直在动:红的 leg 在 3.12 / 3.13 / 3.14 之间轮转,红的 step 在 Run unit tests 和 Run end-to-end tests 之间交替。最干净的一个反例是:一个纯依赖 bump 的提交在 e2e 上红,而单测是通过的——版本号变动不可能让端到端行为变红。
原因是 pull_request 事件默认 checkout refs/pull/N/merge,被测的树 = master + 你的提交。所以 master 上的红会算到你头上,而且你不能说"我没改那里"。这和正文是同一个病:判定用的对象(merge 树)不等于你想评价的对象(你的 diff)。
取证分三层,从便宜到贵:
1. 先量 diff 边界。 git diff master...HEAD -- tests/ src/ 是空的,24 个文件全在 evaluation/。再加一条更硬的:evaluation/ 是独立 uv 工程,而根 pyproject.toml 的 [tool.ty.src] exclude 显式列着它、[tool.uv.sources] 只引 integrations/*、也没有 [tool.uv.workspace]——那个 job 在物理上收集不到我的代码。这比"别的分支也红"更硬,因为它不需要任何别人的数据。
2. 找反例提交。 同一个 step 在 master 上、在一个纯依赖 bump 的提交上也红。这比"我本地是绿的"有力得多。
3. 才轮到日志。 /actions/jobs/{id}/logs 匿名是 403,step 注解只有 Process completed with exit code 2.——而这一步跑的是 make,GNU make 对任何失败的 recipe 都返回 2,所以这个注解等于没有信息。我一开始拿它论证"不是断言失败",论据是错的(结论碰巧对),弯路记在这里。
拿到日志之后,最有价值的读法不是断言那一行:
| 别这么读 | 这么读 |
|---|---|
盯着 TimeoutError 找原因 |
它出自"等某个对象出现"的 helper,意思是那件事根本没发生;因在它上面的 Captured log 里 |
把 WARNING … write failed 当成"机器慢" |
该分支是 except Exception,而它不捕获 CancelledError(那是 BaseException)→ 抛的是真异常;且代码只对某类错重试(SQLite 5/6),没重试就说明不是竞争,也不是慢(30s 重试预算 > 测试的 5s 等待) |
| 看到日志里有异常就当根因 | finally: raise 会顶掉原始异常。日志里那条 ValueError: Connection closed 是 teardown 抛的,真因只剩在 __context__ 里,而捕获处又不打 exc_info → 现场不可诊断。这本身就是该单独上报的可观测性缺陷 |
| 凭印象说"应该就是这里" | 拿日志里的 pool/base.py:373 → :986 → :1441 去对本地同版本的库源码(2.0.51),把"可能是"变成"就是这条路径"。这一步极便宜,而且常常决定性 |
复现也有讲究。日志里 /opt/hostedtoolcache/Python/3.12.14 说明 CI 用的是 3.12.14,本地就得建同小版本的 venv,不能用"3.12 就行"糊过去。然后报次数,而不是"试了几次没复现":单跑 1 次 + 串行 40 次 + 进程内插桩重放 16 次,0 失败。这才叫"它是罕见的交织",而不是"我这边跑不出来"。
经验法则:偶发红 check 的处置是请维护者点 Re-run failed jobs(没有写权限就发不了重跑),并且不要把修复塞进本 PR——它不在你的 diff 边界内,而且被违反的那个不变量,恰恰是另一个 PR 自己的测试在断言的。归因错了就动手改代码,是把"测错对象"这个病又犯一遍。
结论最好分三档写清楚,别用"疑似"糊过去:已确定(失败用例名、失败的断言、库路径行号、与本 diff 无关的证据)、强推断(因果链,标注为推断)、未证实(具体哪个交织,标注待确认,并给出"一次就能证实"的最小插桩——比如只打对象 id())。维护者能直接接着往下走,比一句"我无法复现"有用得多。
PR:https://github.com/oceanbase/powercontext/pull/1819 Issue:https://github.com/oceanbase/powercontext/issues/1791