模型更强,工具反而更差

过去两天里,Pi 上一个很奇怪的 issue 把我带进了一个越挖越深的坑。简而言之,较新的 Claude 模型有时会在调用 Pi 的编辑工具时,在嵌套的 edits[] 数组里额外塞进一些凭空捏造的字段。而且出问题的还不是 Haiku 这类小模型,而是 Opus 4.8。编辑动作本身通常是对的,但参数不符合模式定义,因为模型会发明一些不存在的键,结果 Pi 会拒绝这次工具调用,并要求它重试。

这件事本身其实不算特别意外,模型偶尔会产出格式错误的工具调用,尤其是小模型。真正让我意外的是,这个问题在较新的 Anthropic 模型上反而更严重了:Opus 4.8 和 Sonnet 5 都会出现,但更早的模型不会。换句话说,这个系列里最先进的模型,在这个特定工具模式上反而比它们的老一代更差。

如果你好奇 Fable:我刻意没有测它,因为我不确定它背后的分类器会不会悄悄把我降级到 Opus。

工具调用本质上是文本

如果你没有花太多时间研究过 LLM 工具调用的内部机制,最重要的一点是:工具调用并不神秘,它依赖的是一种相当粗糙的带内信号机制。模型会收到一段 transcript、一个 system prompt,以及一组可用工具。服务器会把这些内容拼接成一个很长的 prompt,并插入一些特殊标记 token。

由于模型是在这种格式的样例上训练并强化过的,它在生成过程中某个时刻就会输出一些东西,被 API 或客户端解释为“用这些参数调用这个工具”。

对于一个文件编辑工具,理想中的调用载荷大致会像这样:

{
  "path": "some/file.py",
  "edits": [
    {
      "oldText": "text to replace",
      "newText": "replacement text"
    }
  ]
}

然后,一个 harness 会校验参数、执行编辑,再把结果反馈给模型。如果校验失败,模型就会看到错误信息,并通常会再试一次。

Anthropic 模型到底如何完成这种格式化,目前并不清楚。不过有些人曾经导出过 “ANTML” 标记,而且它们有时也会泄漏到公开通信里。据我所知,上面的调用很可能会被模型序列化成下面这样:

<antml:function_calls>
  <antml:invoke name="edit">
    <antml:parameter name="path">some/file.py</antml:parameter>
    <antml:parameter name="edits">
[
  {
    "oldText": "text to replace",
    "newText": "replacement text"
  }
]
    </antml:parameter>
  </antml:invoke>
</antml:function_calls>

这里有两点很重要。第一,这东西虽然看起来像 XML,但其实并不真是 XML;它只是他们为了分词和训练方便而采用的一种格式。第二,一个普通的顶层字符串参数会直接内联出现,而对象数组这样的参数则是通过 JSON 序列化嵌进去的。虽然我不能完全确定实现细节就是这样,但有不少迹象表明它大差不差。后面这一点会变得很关键。

要让模型产出这样的结构,大致有两种截然不同的办法:

  1. 让模型生成符合某个模式的合法 JSON,然后在事后再校验。
  2. 约束采样器,使其从一开始就不可能采样出非法 JSON,甚至不可能采样出不符合模式形状的结构。

第二种方法通常就是人们所说的 grammar-aware 或 constrained decoding。采样器会屏蔽掉那些会破坏语法的 token。如果模型当前位于一个 JSON 对象内部,而模式规定这里只允许 oldTextnewText,那采样器就可以阻止它输出 "in_file""type"。这种受约束解码既可以用来保证输出至少是语法合法的 JSON,也可以进一步强制特定的枚举值或键名。

如果没有任何约束,模型就只是在遵循一种它学来的习惯。

失败是怎么发生的

Pi 的编辑工具支持一次调用中执行多个精确的字符串替换,所以参数里才会有一个 edits 数组。出错时,模型会生成这样的条目:

{
  "oldText": "...",
  "newText": "...",
  "requireUnique": true
}

或者这样:

{
  "oldText": "...",
  "newText": "...",
  "oldText2": "",
  "newText2": ""
}

在多次重复试验里,我简直看到了一个“附加键动物园”:typeidkinduniquerequireUniquematchCasein_fileforceMatchCountchildrennotescostoldText2newText2oldText_2newText_2,甚至还有一个 event.0.additionalProperties,直接出现在编辑对象内部。

最烦人的地方在于,我检查过的那些非法调用里,真正的 oldTextnewText 载荷在字节级别上都是正确的。也就是说,模型其实已经生成了正确的调用,只是在对象末尾又添了一些胡来的内容。

这个失败还高度依赖上下文。像“edit this file”这种全新单轮 prompt,我完全复现不出来。但如果是带有 agentic 执行历史的会话,也就是模型先读过文件、诊断过问题、再拼出一段多行编辑,那就能复现。而且更麻烦的是,并不是所有 transcript 都会表现出这种行为。事实上,我最后是借助 Petr Baudis 的 transcript 才复现出来的。在那个用户的 session 里,继续沿着原会话往下跑,Opus 4.8 大约有 20% 的概率失败。

把 thinking blocks 从历史里剥掉后,失败率降了一半。开启 strict 工具调用后,我的测试里这个问题就消失了。

为什么会越来越糟

我最强的假设是,这不是随机退化,而是训练产物。

较早的 Anthropic 模型训练时,确实也接触过一些工具,其中一些甚至是公开文档化的。但那时还没有一个像 Claude Code 这样由用户实际交付的 harness,成为明确的训练目标。现代 Anthropic 模型很可能不一样,因为它们的后训练过程里包含了 Claude Code,或者包含了一个与之非常相似的 harness。模型会学到,在那种环境里,什么样的工具调用算“成功”。它也会学到,那种环境会容忍哪些错误。

Claude Code 自带的工具模式相对扁平。普通编辑工具并不是 Pi 那种嵌套的 edits[] 结构,而更接近 file_pathold_stringnew_string,再加一个可选标志位 replace_all。看看 Claude Code 的客户端实现会很有启发:里面包含了针对格式错误工具调用的重试路径、参数别名、类型强制转换、Unicode 修复,以及对未知键的过滤。换句话说,Anthropic 自家的客户端显然预期并接受相当程度的“脏输入”,而且大多数时候会悄悄帮你修掉。

如果强化学习是在这种 harness,或者在它的某种模拟环境中进行的,那么轻微格式错误的工具调用仍然可能完成任务并获得奖励。harness 会把错误完全吞掉,模型也就几乎得不到什么负向梯度,去阻止它发明一个别名、加一个多余字段,或者用一个相近但不完全正确的参数名。

更糟的是,模型可能会非常强地适配到 Claude Code 的标准编辑工具形状。另一个 harness 也许会提供语义意图相同、但模式不同的工具,而这种工具就会越来越偏离训练分布。训练得更好的模型,反而可能更不愿偏离这种先验,因为它的先验更强。

这其实不算太意外,但和几个月前相比,情况确实变了。Opus 4.5 刚发布时,它对其他编辑工具的适配能力异常好。那时候我还相当相信,我们正走在一条好路上:只要指令写得够清楚,模型会越来越容易适应各种工具形状。

但现在,我开始有点担心我们走的这条路了。替代性的工具模式可能不只是“不熟悉”而已。它们甚至可能会在后训练里被隐式惩罚,因为后训练优化的是某一种特定、而且容错性很高的工具生态。而那个生态本身又没有被文档化。虽然现在有一个公开文档化的 text editor tool,但你会发现,Claude Code 实际上并不遵循这种格式。Claude Code 内部到底怎么做的,作为一个闭源 harness,对你来说是不可见的。

宽松容错的 harness

Claude Code 显然是闭源的,但我们还是可以看看它压缩后的代码,大致猜到它在做什么。而且说实话,它对输入数据相当宽容。

首先,Claude Code 会检查模型可见文本里是否泄漏了 <invoke 标记。如果出现,它会打一些遥测数据,然后启动自己的状态机,把这类坏调用重新推回给模型,让它重试。

它还明确做了 Unicode 转义修复,会修复损坏的 \uXXXX 序列以及字符串值中的孤立代理项。它还给不同工具准备了参数别名。比如 Edit 就接受 old_str,接受模式里现在使用的 old_stringnew_strnew_string,也把 path 当作 file_path 的别名,等等。old_str 这个别名大概还来自模型曾经针对官方文档里的 text editor tool 受训的时期。

它还会静默过滤掉意料之外的键,而且也没有使用 strict 模式。strict 模式的问题在于,Anthropic 会对工具定义施加复杂度限制,超过后 API 请求会直接失败,所以这大概就是 Claude Code 没有尝试使用它的原因。

严格性

这个问题会不会也出现在其他 harness 中?Anthropic 的一个大问题在于,模型是彻底闭源的,harness 也是。Codex 模型同样是闭源的,但至少 harness 不是。我们还有 gpt-oss,至少提供了一个有意思的参照。那些模型被明确训练成使用 OpenAI 的 harmony 响应格式,而且相关文档很多,至少能让我们知道 OpenAI 是怎么思考这件事的。

在 harmony 里,channel 和工具调用内容类型都是 prompt 格式的一部分。一个函数调用可以长这样:

<|start|>assistant<|channel|>commentary to=functions.get_weather
<|constrain|>json<|message|>{"location":"San Francisco"}<|call|>

关键在于 <|constrain|>json。模型可以用带内方式表达“这段消息体是 JSON”,而推理栈就可以利用这个边界,把工具调用消息体切换到 JSON 约束采样模式。至少在 strict 模式下,我猜 Anthropic 模型内部大概也会发生一点类似的事。

harmony 里的这个标记有助于采样器识别自己什么时候需要按特定语法来采样;而因为它本来就是 transcript 的一部分,实现这件事会相对容易。对于托管版 GPT 模型,你还可以提供一个 LARK 语法,用于那些必须遵循类似结构的自定义工具。

不过 Anthropic 看起来又不太一样,虽然可能也没那么不一样。如果对象数组真的是以 JSON 形式表示的,就像现在看起来那样,那模型就必须在工具参数里再写一层 JSON。这里面很可能已经存在一些基础的语法约束采样,这也许能部分解释为什么会冒出这些额外键。对于嵌套数组参数来说,这段 JSON 还要在一个标签内部,包含转义后的多行文件内容,而这些内容又被包在字符串字面量里。

那些意外捏造的键,恰好出现在这个任务熵最高的位置:模型刚刚结束一个长达数百 token、经过转义的 newText 字符串,接下来必须在 }, "..." 之间做决定。

Opus 4.8 和 Sonnet 5 似乎对“编辑工具调用应该长什么样”有了更强的先验,而这个先验看起来就是 Claude Code 的编辑模式:一对扁平的 old/new 字符串,再加一个可选的 replace_all 标志。我的猜测是,Opus 已经学会“编辑操作也许可以带一个额外的可选字段”,但在 Pi 那种嵌套的 oldText / newText 结构里,它并没有学到这个字段应该叫什么。

于是它每次都会现场采样一个看起来合理的名字,这就是为什么失败结果里会出现几十个随机键,而不是某一个稳定的别名。

既然 Anthropic 的 strict 模式似乎能修掉这个问题,我推测它们在服务端确实会拒绝采样那些不被 JSON 模式结构允许的键。这也能解释,为什么一旦启用 strict mode,它们就要对工具定义的复杂度设限。

到目前为止,我测试过的 Codex 模型还没有表现出这种类型的退化。我把现有能用的都测了,只有 5.6 还没测,因为我暂时拿不到。

这对 harness 意味着什么

一个让人不太舒服的教训是:至少在 Anthropic 模型上,工具模式并不是中立的。我们喜欢假装模式只是一个抽象契约,而模型是一个会遵守契约的通用推理器,但对某些工具来说,情况可能已经不是这样了。

工具模式本身也处在训练分布里。有些形状离模型在后训练阶段见过的东西很近,有些则很远。有些形状对提供方隐藏的编码方式来说更轻松,比如 ANTML 里的顶层属性;另一些则要求模型在长长的多行字符串之后,在嵌套数组里继续写大型、转义后的 JSON 对象。模型也许足够聪明,能理解这个模式,但在高压采样时,仍然不擅长稳定地产出那个精确形状。

如果这种模型行为继续存在,我很好奇它会对各种 harness 带来什么影响。显然,你可以在 Anthropic 上开启 strict 采样,这个问题应该就会消失。另一方面,模型会表现出这种行为,本身也说明了强化学习对它们的影响有多大。如果你还想拿到最好的模型表现,那硬要对抗这种先验,可能是徒劳的。

眼下的现实是,Claude Code 不是开源的,我们也根本无法真正知道他们在自己的 RL 环境里做了什么。我们不能假设那些经过 Claude Code 训练出来的行为会干净利落地迁移到你的工具上,除非你的工具与它非常接近。后训练越是发生在某一个占主导地位的 harness 里,其他所有 harness 就越不得不继承它的怪癖。

过去我对严格的、基于语法约束的工具调用一直更怀疑一些,因为受约束解码可能会带来质量上的权衡。我现在仍然认为,一般情况下这点依然成立。但这个 bug 明显改变了我的先验。如果最新模型在解决任务上越来越强,却在忠实输出另一种工具模式上越来越差,那么 harness 就必须在某个地方补上更强的保证。

如果你想进一步了解,或者想讨论这个问题,可以去看看 Pi tracker 上的 issue