我修的那个 bug,制造了另一个 bug

我修的那个 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.tstoWireMessages 是按块类型拆的,不是按消息拆的:

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)tooluser完全合法

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', 那是 runAgentrunReviewPass 各自 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 个文件。

可以带走的

  1. 修完一个协议 bug,去把那个协议在这一处的完整约束列一遍。 只盯刚踩的那条,很容易顺手造出第二条。
  2. 宽容的那一端会骗你。 多供应商适配里,只在最严格的那家上炸的错误最难发现。
  3. 给校验器一个总入口。 单个具体检查散在各处,补规则时要改一圈; 一个 findWireViolation 让新规则接一处就全生效。
  4. "目前恰好安全"的裸调用要一起改掉。 留着它就是在等下一次事故。
  5. 反向验证要成为习惯而不是仪式。 昨天我做了反向验证,它证明了"这条修复有效", 但证明不了"这条修复完整"。两者是不同的问题。
  6. 打脸的记录要写下来。 昨天那篇博客的结论没错,但它给人的印象是这件事已经完了 ------ 这篇是它的必要补丁。

上一篇:两个 wire 的翻译层,以及它可以怎么骗你 回到:开发记录目录

相关推荐
白杨SEO营销1 小时前
白杨SEO:豆包正式按成交收费、微信AI工具小微内测放大、7月AI工具网站流量TOP排行出炉,对AI获客有哪些启示?
人工智能
Smartgiant20051 小时前
高速车牌识别 LPR 相机
人工智能·视觉检测
做前端的娜娜子1 小时前
第二个 AI 调用——invoke 阻塞式与 stream 流式生成
人工智能·llm·掘金·金石计划
gis开发之家1 小时前
《Vue3 从入门到大神50篇》Vue3 源码详解(二十):生命周期钩子源码解析 —— onMounted / onUpdated 如何实现?
前端·javascript·前端框架·vue3·vue3源码
sbjdhjd1 小时前
大三网安秋招核心学习前言(AI 安全 + Web 漏洞 + 内网渗透全考点)
人工智能·网络协议·学习·安全·网络安全·开源·php
Narrastory1 小时前
我用 Claude Code 写代码不到 2 小时,却花了 3 天做完这个软件—Vibe Coding 的正确姿势,是设计不是生成
前端·人工智能·github
Enaium1 小时前
我实现了KMP的SDL绑定并编写了高性能软光追
前端·kotlin
chaoyuanl1 小时前
悬空玻璃剧场源头厂家选型全解析|270°裸眼3D悬空玻璃剧场投资避坑指南
大数据·人工智能·3d·xr·娱乐·mr
tuanxiang1 小时前
AI内容真人化处理:踩完3次审核红线后摸出来的落地流程
人工智能