更好的模型,更差的工具
原文由 Armin Ronacher 于 发布,订阅该博客
过去两天里,一个非常奇怪的 Pi issue 让我钻进了一个兔子洞。简而言之,较新的 Claude 模型有时会在调用 Pi 的编辑工具时,在嵌套的 edits[] 数组里塞进额外、凭空捏造的字段。而且出问题的还不是 Haiku 这类小模型:是 Opus 4.8。编辑内容本身通常是正确的,但参数并不符合 schema——模型编造了不存在的键,导致 Pi 拒绝了这次工具调用并要求重试。
单看这一点倒也不算太意外,模型有时确实会产生格式错误的工具调用,尤其是小模型。让我意外的是,这个问题在更新的 Anthropic 模型上反而愈演愈烈——Opus 4.8 和 Sonnet 5 都会出现,而更老的模型却没有。换句话说,该系列中最顶尖的模型,在这个特定的工具 schema 上反而比它们的前代更差。
如果你好奇 Fable 的情况:我特意没有去测它,因为不确定他们在后台运行的分类器会不会在暗中把我降级到 Opus。
工具调用本质上是文本
如果你没有花太多时间研究 LLM 工具调用的内部机制,需要理解的关键一点是,工具调用并没有什么魔法,用的只是相当粗糙的带内信令。模型收到的是对话记录、系统提示和可用工具列表。服务端会把这些拼成一个带有特殊标记 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 序列化来实现的。我并不能完全确定就是这么运作的,但有一些迹象表明实际情况也相差不远。这一点后面会变得重要。
要让模型产出这样的结构,有两种截然不同的方式:
- 你可以要求模型产出符合 schema 的合法 JSON,然后事后再做校验。
- 你也可以约束采样器,让它从一开始就无法采样出非法的 JSON,甚至无法采样出不符合 schema 的形态。
第二种方式通常被称为语法感知或约束解码。采样器会屏蔽掉那些会违反语法的 token。如果模型当前正在一个 JSON 对象内部,而 schema 规定只允许 oldText 和 newText,采样器就可以阻止它输出 "in_file" 或 "type"。语法感知解码既可以用来约束输出为语法上合法的 JSON,也可以用来强制限定特定的枚举值或键名。
如果没有任何形式的约束,模型只是在遵循一种学来的约定。
故障现象
Pi 的编辑工具支持在一次调用中进行多次精确字符串替换,所以参数里才会包含一个 edits 数组。在失败的案例中,模型会产出这样的条目:
{
"oldText": "...",
"newText": "...",
"requireUnique": true
}或者这样的:
{
"oldText": "...",
"newText": "...",
"oldText2": "",
"newText2": ""
}在反复试验中,我看到了一大堆凭空编造的尾随键名:type、id、kind、unique、requireUnique、matchCase、in_file、forceMatchCount、children、notes、cost、oldText2、newText2、oldText_2、newText_2,甚至还有直接出现在 edit 对象里的 event.0.additionalProperties。
最让人恼火的是,在我检查过的那些非法调用里,实际的 oldText 和 newText 载荷在字节层面是完全正确的。模型明明已经给出了正确的调用,却又在对象末尾添上了无意义的内容。
这个故障还高度依赖上下文。像“编辑这个文件”这样全新的单轮提示,我完全无法复现它。但在一个 agent 式的历史记录中——模型先读取了文件、诊断了问题,然后再拼凑出一个多行编辑——就能复现。更烦人的是,并非所有对话记录都会表现出这种行为。事实上,我完全是靠 Petr Baudis 的对话记录才复现了这个问题!在那位用户的会话中,继续对话会让 Opus 4.8 大约有 20% 的概率失败。从历史记录中去掉思考块后,失败率减半。而开启严格工具调用后,在我的测试中则完全消除了该问题。
为什么反而更糟了
我最有把握的假设是,这并非随机的退化,而是训练产物。
更早的 Anthropic 模型在训练时,确实也针对一些工具(其中部分是有文档的)做过训练。但当时的训练还没有把像 Claude Code 这样面向用户发布的 harness 作为明确目标。现代的 Anthropic 模型很可能不同,它们的后训练包含了 Claude Code 或一个与之非常相似的 harness。模型会学到在那种环境下什么样的工具调用才算成功,也会学到那种环境会容忍什么样的错误。
Claude Code 自身的工具则相对扁平。常规的编辑工具并不是 Pi 那种嵌套的 edits[] 结构,而是更接近 file_path、old_string、new_string 再加一个可选标志(replace_all)。看看 Claude Code 的客户端就很有启发:里面包含了针对格式错误的工具调用的重试路径、参数别名、类型强制转换、Unicode 修复以及对未知键的过滤。换句话说,Anthropic 自家的客户端似乎预期并接受相当程度的“宽松”输入,并大多在静默中完成修复。
如果强化学习是在这样一个 harness 或其仿真环境中进行的,那么略微畸形的工具调用仍然可以完成任务并获得奖励。harness 完全吸收了错误,于是对于编造别名、添加多余字段或使用相近参数名,几乎不会产生什么负向梯度。
更糟的是,模型可能会对 Claude Code 那种标准编辑工具形态产生极强的适配。另一个 harness 可能会提供一个语义意图相同但 schema 不同的工具,这样的工具会越来越偏离分布。训练得越好的模型,反而可能越会跟你“较劲”,因为它的先验更强了。
这倒也不算太意外,但相比几个月前的情况已经发生了变化。Opus 4.5 发布时,它对其他编辑工具的适配异常出色。事实上,当时我相当确信我们正走在一条正确的路上——只要指令写得好,模型就更有可能适配各种工具形态。
现在我对我们正走的这条路多少有些担忧。替代性的工具 schema 可能不仅仅是“不熟悉”而已。它们可能会被那种针对某个特定、宽容的工具生态做优化的后训练隐式地惩罚。而那个生态本身是没有文档的。虽然有一个有文档的 文本编辑工具,但你会发现 Claude Code 实际上并未遵循这一格式。Claude Code 在内部具体做了什么(它是一个闭源 harness)是对你隐藏的。
“宽松”的 Harness
Claude Code 显然是闭源的,但我们可以通过查看其压缩后的代码来大致了解它在做什么。说实话,它对输入数据的容忍度非常高。
首先,Claude Code 会检查模型可见文本中是否泄露了 <invoke 标记。发生这种情况时,它还会上报一些遥测数据,然后通过自己的状态机把错误推回给模型以重试这类糟糕的调用。
它有专门的 Unicode 转义修复功能,会修正字符串值中损坏的 \uXXXX 序列和孤立的代理对。它还为每个工具的参数设置了别名。例如,Edit 接受 old_str(大概是模型在官方文档化的文本编辑工具上训练时的遗留)、来自新 schema 的 old_string、new_str/new_string,以及作为 file_path 别名的 path,还有更多。
它还会静默地过滤掉非预期的键,而且它本身也不使用 strict 模式。strict 模式的问题在于,Anthropic 会对工具定义施加复杂度限制,导致 API 请求失败,所以大概这就是 Claude Code 不尝试使用它的原因。
严格模式
这个问题也会在其他 harness 中出现吗?Anthropic 的一个巨大问题是,模型完全闭源,harness 也是。Codex 模型同样闭源,但至少 harness 不是。我们还有 gpt-oss,它至少有点意思。这些模型被明确训练为使用 OpenAI 的 harmony 响应格式,而且有大量文档至少能告诉我们 OpenAI 的人是怎么思考这个问题的。
Harmony 把通道和工具调用的内容类型都作为提示词格式的一部分。一次函数调用可能看起来像这样:
<|start|>assistant<|channel|>commentary to=functions.get_weather
<|constrain|>json<|message|>{"location":"San Francisco"}<|call|>关键在于 <|constrain|>json。模型可以在带内声明该消息体是 JSON,而推理栈可以利用这个边界,为工具调用的主体切换到受 JSON 约束的采样。我猜想在 Anthropic 的模型中也会有类似的机制,至少在 strict 模式下应该是这样。
harmony 中的这个标记有助于采样器判断何时需要以特定语法进行采样,而且因为它是对话记录的一部分,所以实现起来相当容易。对于托管的 GPT 模型,还可以选择为需要遵循此类约束的自定义工具提供 LARK 语法。
Anthropic 的做法看起来与此不同,尽管可能也不完全不同。如果对象数组确实如看起来那样是以 JSON 表示的,那么模型就必须在工具参数内部编写 JSON。很可能存在基础的语法约束采样,这或许可以部分解释那些多余的键。对于嵌套数组参数,那段 JSON 在一个标签内包含了以字符串字面量形式存在的、经过转义的多行文件内容。那些意外编造的键恰恰出现在该任务熵最高的时刻:在闭合一个长达数百 token、经过转义的 newText 字符串之后,模型必须在 } 和 , "..." 之间做出抉择。
Opus 4.8 和 Sonnet 5 似乎对编辑工具调用应该长什么样有着强得多的先验,而那个先验看起来就是 Claude Code 的编辑 schema:扁平的 old/new 字符串对,外加可选的 replace_all 标志。我的猜测是,Opus 已经学到一次编辑操作可能带有一个额外的可选字段,但在 Pi 的嵌套式 oldText/newText 结构下,它并没有学过这个字段该叫什么。于是它每次都会采样出一个看似合理的新名字,这也解释了为什么失败时会产生数十种随机的键名,而不是某一个稳定的别名。
鉴于 Anthropic 的 strict 模式似乎能修复这个问题,我推测在服务端他们会拒绝采样 JSON schema 结构中不允许的键。这也能解释为什么在启用严格模式时,他们会对工具定义的复杂度设限。
到目前为止,我测试过的 Codex 模型都没有出现这类退化。除了尚无权限的 5.6 之外,所有可用的版本我都测过了。
这对 Harness 意味着什么
令人不安的教训是,工具 schema 并非中立的——至少在 Anthropic 模型上不是这样。我们总喜欢假定 schema 是一份抽象契约,而模型是一个会遵守契约的通用推理者,但对于某些工具而言,情况可能已不再如此。
工具 schema 本身就处在分布的某个位置,有些形态与模型在后训练中见过的很接近,有些则相去甚远。有些对提供方隐藏的编码方式来说很容易(例如 ANTML 中的顶层属性),而有些则要求模型在很长的多行字符串之后,在嵌套数组中编写大段经过转义的 JSON 对象。模型或许足够聪明,能理解 schema,但在压力下却仍可能无法精确采样出所需的形态。
如果这类模型行为持续下去,我不禁想,harness 将会面临怎样的影响。显然,人们可以在 Anthropic 上开启 strict 采样,问题应该就会消失。但另一方面,模型出现这种行为本身就说明了强化学习对它们的影响之深。如果你想获得最佳的模型性能,与这种先验对抗恐怕是徒劳的。
现实是,Claude Code 并非开源,我们也无从得知他们在 RL 环境中究竟做了什么。除非你的工具与之高度吻合,否则不能想当然地认为在 Claude Code 上训练出的行为会干净地迁移到你的工具上。在一个主导性的 harness 内进行的后训练越多,其他所有 harness 就越不得不继承它的怪癖。
我过去对严格的、受语法约束的工具调用更为怀疑,因为约束解码可能会带来质量上的折衷。总体上我依然认为这可能是事实,但这个 bug 显著改变了我的先验判断。如果最新的模型在解决任务上变得更强,却在忠实地输出另一种工具 schema 上变得更差,那么 harness 就需要在某些环节获得更强的保障。
如果你想了解更多,或想就此展开讨论,可以去阅读 Pi 跟踪器上的 issue。
随机一篇博客
评论
登录后参与讨论