我修的那个 bug,制造了另一个 bug
开发记录 · 2026-08-10 · RepoPilot
昨天的第一篇写的是:一条孤儿 tool_use 让产品接真实模型完全跑不通。 我修了,加了平台级校验,加了 4 处替身守卫,反向验证 10 条测试确实会红, 写进了 commit message,还写了篇博客。
今天早上准备把那篇发出去,重读修复后的消息序列,心里咯噔一下。
那个咯噔
修复之后,发给 provider 的历史长这样:
css
[0] user 任务描述
[1] assistant [tool_use: submit_plan]
[2] user [tool_result: 计划已提交,等待用户审批] ← 我补的
[3] user "用户已批准以下计划,现在开始执行..." ← 本来就有的
[2] 和 [3] 都是 user。
Anthropic 要求 role 严格交替,连续两条同角色会返回:
sql
roles must alternate between "user" and "assistant"
同样是 400。我把一个 400 换成了另一个 400。
写个小脚本确认了一下,不是我记错:
ini
$ node /tmp/probe.mjs
连续同角色: messages[2] 与 messages[3] 都是 user
为什么自己加的校验没抓到
因为 findOrphanToolUse 只查一件事 ------ tool_use 有没有被回填。它对 role 一无所知:
ts
for (let i = 0; i < messages.length; i += 1) {
const msg = messages[i]!;
if (msg.role !== 'assistant') continue; // ← 只看 assistant 消息
const uses = toolUsesOf(msg.content);
if (uses.length === 0) continue;
// ...检查下一条有没有回填全部 id...
}
[2] 和 [3] 都是 user,都被 continue 跳过了。
463 个测试全绿。4 个 ScriptedModel 守卫全部放行。因为它们调用的就是这个函数。
我给防线定的规格本身就是不完整的。 加防线的时候,我脑子里只有刚踩过的那个坑。
为什么这类错误特别难发现
还有一层:这个坏序列在 OpenAI 兼容端根本不成立。
openai-compatible.ts 的 toWireMessages 是按块类型拆的,不是按消息拆的:
ts
// user 侧:tool_result 必须变成独立的 role:'tool' 消息,且要排在文本之前
for (const r of toolResults) {
out.push({ role: 'tool', tool_call_id: r.toolUseId, content: r.content });
}
if (texts.length) {
out.push({ role: 'user', content: texts.map((t) => t.text).join('\n') });
}
所以我那两条连续 user 消息里,第一条是纯 tool_result → 变成 role:'tool', 第二条是纯 text → 变成 role:'user'。OpenAI 侧看到的是 assistant(tool_calls) → tool → user,完全合法。
Anthropic 那边没有这层翻译,role 原样发过去,于是连续两条 user 就撞上了交替检查。
表现就是:用 DeepSeek / OpenRouter / 硅基流动测都正常,一换成 Anthropic 官方就全挂。
跨供应商适配最麻烦的不是"两家 API 长得不一样",而是两家的严格程度不一样。 宽容的那家会让你以为代码是对的。
修法:合并,而不是新起一条
ts
/**
* 追加一条 user 消息;**末尾已经是 user 消息时合并进去**,不新起一条。
*
* 因为 Anthropic 要求 role 严格交替(连续两条 user 返回
* `roles must alternate between "user" and "assistant"`,同样是 400),
* 而在 OpenAI 兼容端这个坏序列压根不成立 ------ toWireMessages 按块类型拆,
* 纯 tool_result 的那条会变成 role:'tool',所以那边看到的是合法的
* assistant → tool → user。只在一家上炸的错误最容易漏。
*
* 这个坑是修孤儿 tool_use 时自己造出来的:规划期提交计划后先回填 tool_result(user),
* 紧接着 runAgent 又 push 审批通知(user)。合并对两家都合法:
* Anthropic 允许一条 user 消息里同时有 tool_result 和 text 块;
* OpenAI 适配器的 toWireMessages 会把它们拆成 role:'tool' + role:'user' 两条。
*/
function pushUser(conversation: ModelMessage[], blocks: readonly ContentBlock[]): void {
const last = conversation[conversation.length - 1];
if (last?.role === 'user') {
conversation[conversation.length - 1] = { role: 'user', content: [...last.content, ...blocks] };
return;
}
conversation.push({ role: 'user', content: [...blocks] });
}
agent.ts 里 7 处 user push 全部改用它(另外还剩两处裸的 role: 'user', 那是 runAgent 与 runReviewPass 各自 conversation 数组的初始化 ------ 第一条消息不存在"合并进上一条"的问题,本来就不该走 pushUser)。 包括几处目前不会出问题的:
ts
// 上一轮若以 BUDGET_EXHAUSTED 提前返回,末尾可能仍是 user ------ 用 pushUser 合并
pushUser(conversation, [
{ type: 'text', text: `验证仍未通过。这是第 ${round}/${maxRounds} 轮自修复...` },
]);
自修复那条现在恰好安全(executionTurns 结束时末尾是 assistant),但只要 executionTurns 在别的分支提前返回,它就不安全了。既然有了统一入口,就全走它 ------ 留一个"目前恰好对"的裸 push 是在等下一次事故。
校验器也得补
单独一个 findOrphanToolUse 已经不够了。加一个,然后合成一个总入口:
ts
export function findRoleAlternationViolation(messages: readonly ModelMessage[]): string | null {
for (let i = 1; i < messages.length; i += 1) {
if (messages[i]!.role === messages[i - 1]!.role) {
return `messages[${i - 1}] 与 messages[${i}] 都是 ${messages[i]!.role} ------ role 必须交替`;
}
}
return null;
}
/** 出站前的消息序列体检:孤儿 tool_use + role 交替。返回 null 表示合法。 */
export function findWireViolation(messages: readonly ModelMessage[]): string | null {
return findOrphanToolUse(messages) ?? findRoleAlternationViolation(messages);
}
网关 preflight 和 4 个替身守卫统一改用 findWireViolation。
这个结构比昨天好在:下次再发现第三条 wire 契约,只需要加一个函数、 在一处 ?? 上接一下,所有接了总入口的调用点自动生效。昨天那版是一个具体检查 散布在 7 个文件里,补的时候得挨个改。
注意"所有调用点"这个说法当时不成立。 写这篇时我漏掉了交叉审核套件里的两处守卫 (agent.crossreview.test.ts 的审核方替身和内联 gateway stub),它们还停在 findOrphanToolUse,所以审核通路上的替身拿不到 role 交替这条检查。 总入口只对已经接进来的调用点生效 ------ 这正好又印证了下面那条"防线是照着上一次 出的事建的"。审核开发记录时才发现,现在 6 处守卫都改过来了。
反向验证
老规矩,把合并分支注释掉跑一遍:
css
× 预算耗尽必须让循环停下 > 执行阶段超预算:发出 BUDGET_EXHAUSTED,且此后不再有任何模型调用
→ 第 2 次调用(EXECUTION)收到非法消息序列:messages[2] 与 messages[3] 都是 user ------ role 必须交替
× 【期望行为】预算把执行截断、但工作区已有改动时,结果应说明"被截断"而不是当成正常完工
→ 第 2 次调用(EXECUTION)收到非法消息序列:messages[2] 与 messages[3] 都是 user ------ role 必须交替
× 未验证模式 > 跳过基线、跳过重验、不做自修复,直接产出 PATCH_READY
→ 第 2 次调用(EXECUTION)收到非法消息序列:messages[2] 与 messages[3] 都是 user ------ role 必须交替
报错信息直接给出下标和角色。恢复后 463 全绿。
复盘:昨天哪一步本可以拦住它
我认真想了想,几个候选:
"再检查一遍消息序列" ------ 没用。我昨天就是这么做的,我用的是自己刚写的 findOrphanToolUse,它按定义不会报这个。
"跑一次真实 API" ------ 有用,但成本高,而且我当时手上没有 Anthropic 的可用配置。 不过这确实指向一件该做的事:至少要有一条能打真实端点的冒烟测试 , 哪怕只在本地手动跑。目前 core/model/ 的测试全是不联网的。
"改动一处协议时,去查一遍那个协议的完整约束列表" ------ 这条最实际。 我改的是"发给 Anthropic 的消息序列",那就应该去把 Anthropic 对 messages 的 硬性要求整个过一遍,而不是只盯着我刚修的那一条。
前两条是运气和资源问题,第三条是方法问题。所以我给自己定的规矩是: 碰外部协议的字段/结构时,先把那个协议在这一处的约束列全,再动手。 这次列出来就是两条:tool_use 必须被回填、role 必须交替。两条都写进 findWireViolation 里,比修完再想强。
一个更一般的模式
这两天连着两次同一个形状:
- 昨天:
saveCustomProvider校验了 https,updateProfile没有 ------ 检查漏了一个入口 - 今天:
findOrphanToolUse查了回填,没查交替 ------ 检查漏了一条规则
共同点是:防线是照着"上一次出的事"建的,而不是照着"这一类事有哪些可能"建的。
我不觉得这能靠"更小心"解决。能做的是给防线一个可扩展的形状 ------ findWireViolation 这种总入口就是:它承认自己现在只查两条,但它是"wire 契约体检" 这个概念的落点,第三条来的时候有地方放。
而昨天那版没有这个形状,所以今天补的时候得改 5 个文件。
可以带走的
- 修完一个协议 bug,去把那个协议在这一处的完整约束列一遍。 只盯刚踩的那条,很容易顺手造出第二条。
- 宽容的那一端会骗你。 多供应商适配里,只在最严格的那家上炸的错误最难发现。
- 给校验器一个总入口。 单个具体检查散在各处,补规则时要改一圈; 一个
findWireViolation让新规则接一处就全生效。 - "目前恰好安全"的裸调用要一起改掉。 留着它就是在等下一次事故。
- 反向验证要成为习惯而不是仪式。 昨天我做了反向验证,它证明了"这条修复有效", 但证明不了"这条修复完整"。两者是不同的问题。
- 打脸的记录要写下来。 昨天那篇博客的结论没错,但它给人的印象是这件事已经完了 ------ 这篇是它的必要补丁。
上一篇:两个 wire 的翻译层,以及它可以怎么骗你 回到:开发记录目录