写于 2026 年 7 月 4 日
过去两天,一个非常奇怪的 Pi issue 把我带进了兔子洞。简短来说,较新的 Claude 模型有时会在调用 Pi 的 edit 工具时,在嵌套的 edits[] 数组里加入额外的、凭空编造的字段。而且不是 Haiku 或某个小模型:是 Opus 4.8。编辑本身通常是正确的,但参数并不符合 schema,因为模型会编造不存在的 key,于是 Pi 拒绝这次工具调用并要求重试。
这件事本身倒不算太意外,因为模型有时确实会输出格式错误的工具调用,尤其是小模型。让我惊讶的是,在较新的 Anthropic 模型上,这个问题反而变得更严重了:Opus 4.8 和 Sonnet 5 都会出现,但旧模型都不会。换句话说,这个家族里的 SOTA 模型,在这个特定的工具 schema 上,比它们的老兄弟们更差。
如果你对 Fable 好奇:我有意没有测试它,因为我不确定他们正在运行的分类器是否会悄悄把我降级到 Opus。
工具调用就是文本
如果你没有花太多时间研究 LLM 工具调用的内部机制,最重要的一点是:工具调用不是魔法,而是使用了某种相当粗糙的带内信号。模型会收到一段 transcript、一个 system prompt,以及一组可用工具。服务器把这些东西咀嚼成一个带特殊标记 token 的大 prompt。由于模型曾经在这种格式的样例上接受训练和强化,在生成过程中的某个时刻,它会输出某种内容,而 API 或客户端会把它解释成“用这些参数调用这个工具”。
对于文件编辑工具,预期的调用 payload 可能大概是这样:
{
"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,然后事后再验证。
- 你可以约束 sampler,让无效 JSON,甚至无效的 schema 形状,从一开始就无法被采样出来。
第二种方法通常被称为 grammar-aware 或 constrained decoding。sampler 会屏蔽那些违反 grammar 的 token。如果模型当前位于一个 JSON 对象内部,而 schema 说只允许 oldText 和 newText,sampler 就可以阻止它输出 "in_file" 或 "type"。grammar-aware decoding 既可以用来约束输出成为语法合法的 JSON,也可以用来强制特定的 enum 值或 key。
在没有任何约束的情况下,模型只是在遵循一个学到的约定。
失败模式
Pi 的 edit 工具支持一次调用中进行多个精确字符串替换。这就是为什么参数里有一个 edits 数组。在失败的情况中,模型会生成这样的条目:
{
"oldText": "...",
"newText": "...",
"requireUnique": true
}
或者这样:
{
"oldText": "...",
"newText": "...",
"oldText2": "",
"newText2": ""
}
在反复试验中,我看到了一整座凭空编造出来的尾随 key 动物园:type、id、kind、unique、requireUnique、matchCase、in_file、forceMatchCount、children、notes、cost、oldText2、newText2、oldText_2、newText_2,甚至还有一个出现在 edit 对象内部的 event.0.additionalProperties key。
最烦人的是,在我检查过的无效调用里,实际的 oldText 和 newText payload 都是按字节正确的。模型事实上已经生成了正确的调用,但随后又在对象末尾加了一些胡言乱语。
这个失败还高度依赖上下文。一个全新的单轮 prompt,比如“edit this file”,在我这里完全无法复现。一个 agentic history——模型已经读取过文件、诊断过问题,然后组织了一个多行编辑——则可以复现。而且更烦的是,并非所有 transcript 都会表现出这种行为。事实上,我需要 Petr Baudis 的 transcript 才能在我这里复现!在那位用户的 session 中,继续该 session 会让 Opus 4.8 大约 20% 的时间失败。把 history 里的 thinking blocks 去掉后,失败率降低了一半。开启 strict tool invocation 后,在我的运行中这个问题消失了。
为什么它正在变糟
我最强的假设是:这不是随机退化,而是训练产物。
较旧的 Anthropic 模型在训练时,也确实见过一些工具(其中一些还有文档)。但那时的训练还没有像 Claude Code 这样用户端 harness 作为明显目标。现代 Anthropic 模型很可能不同,因为它们的 post-training 包含 Claude Code,或者某个看起来非常相似的 harness。模型会学习在那个环境里成功的工具调用长什么样,也会学习那个环境会容忍哪些错误。
Claude Code 自己的工具相对比较扁平。普通 edit 工具并不是 Pi 这种嵌套的 edits[] 形状;它更接近 file_path、old_string、new_string,以及一个可选 flag(replace_all)。看看 Claude Code 的客户端会很有启发:它包含了针对格式错误工具使用的重试路径、参数别名、类型强制转换、Unicode 修复,以及未知 key 的过滤。换句话说,Anthropic 自己的客户端似乎预期并接受相当程度的松散输入,并且大多静默地修复它。
如果强化学习发生在这样的 harness 中,或者发生在它的某种模拟中,那么轻微格式错误的工具调用仍然可以完成任务并获得奖励。harness 完全吸收了错误,于是几乎没有梯度去反对模型编造别名、添加零散字段,或者使用相近的参数名。
更糟的是,模型可能会非常强烈地适应 Claude Code edit 工具的规范形状。另一个 harness 可以提供一个语义意图相同、但 schema 不同的工具。这样的工具可能会越来越偏离分布。训练得更好的模型,实际上可能会更用力地和你对抗,因为它的先验更强。
这并不太令人意外,但它和几个月前的情况已经不同了。Opus 4.5 刚发布时,它对其他 edit 工具的适应非常好。事实上,我当时相当确信我们正走在一条不错的路上:只要指令写得好,模型越来越可能适应各种出现的工具形状。
现在我对我们正在走的轨道有些担心。替代性的工具 schema 可能不只是陌生而已。它们可能会被 post-training 隐式惩罚,因为 post-training 优化的是某一个特定的、宽容的工具生态。而这个生态并没有文档。虽然有一个已文档化的 text editor tool,但你会发现 Claude Code 实际上并不遵循这个格式。Claude Code 内部做了什么(它是闭源 harness)对你来说是隐藏的。
松散的 Harness
Claude Code 显然是闭源的,但我们可以看看它的 minified code,从而大致了解它做了什么。老实说,它对传入数据非常宽容。
首先,Claude Code 会检查模型可见文本中是否泄漏了 <invoke 标记。发生这种情况时,它还会发送一些 telemetry,然后用自己的状态机把这类坏调用推回给模型重试。
它有明确的 Unicode escape 修复逻辑,可以修复字符串值里损坏的 \uXXXX 序列和孤立代理项。它还为每个工具提供参数别名。例如,Edit 接受 old_str(大概来自模型曾经按官方文档中的 text editor tool 训练的时期)、schema 里较新的 old_string、new_str/new_string、作为 file_path 别名的 path,以及更多别名。
它还会静默过滤意外的 key,并且也没有使用 strict mode。strict mode 的问题在于,Anthropic 会对工具定义施加复杂度限制,导致 API 请求失败,所以这大概就是 Claude Code 没有尝试使用它的原因。
严格性
这个问题也会出现在其他 harness 里吗?Anthropic 的一个巨大问题是模型完全闭源,harness 也是闭源的。Codex 模型同样闭源,但至少 harness 不是。我们还有 gpt-oss,它至少有点意思。这些模型明确被训练去使用 OpenAI 的 harmony 响应格式,而且有大量文档至少能告诉我们 OpenAI 的人是如何看待这件事的。
Harmony 把 channel 和工具调用的 content type 变成 prompt format 的一部分。一个 function call 可以像这样:
<|start|>assistant<|channel|>commentary to=functions.get_weather
<|constrain|>json<|message|>{"location":"San Francisco"}<|call|>
关键部分是 <|constrain|>json。模型可以通过带内方式表达这个 message body 是 JSON,而 inference stack 可以利用这个边界,在工具调用 body 部分切换到 JSON-constrained sampling。至少在 Anthropic 的模型里,我猜其中一部分也会发生,至少在 strict mode 下我会这么想。
harmony 中的 marker 帮助 sampler 检测何时需要按特定 grammar 采样,而且因为它是 transcript 的一部分,这件事变得相当容易。对于托管 GPT 模型,也可以为需要遵守类似格式的自定义工具提供 LARK grammar。
Anthropic 看起来不太一样,虽然也许并非完全不同。如果对象数组如看起来那样被表示为 JSON,那么模型就必须在工具参数内部书写 JSON。这里大概存在基础的 grammar-constrained sampling,而这也可能部分解释了额外 key 的出现。对于嵌套数组参数,这段 JSON 包含字符串字面量中转义过的多行文件内容,并且全部位于一个 tag 里。那些意外的、凭空编造的 key 正好出现在这个任务熵最高的位置:关闭一段数百 token 的转义 newText 字符串之后,模型必须决定接下来是 } 还是 , "..."。
Opus 4.8 和 Sonnet 5 似乎对 edit 工具调用应该是什么样子有更强的先验,而这个先验似乎就是 Claude Code 的 edit schema:一个扁平的 old/new string pair,再加上可选的 replace_all flag。我的猜测是,Opus 已经学会了一次 edit 操作可能有一个额外的可选字段,但在 Pi 嵌套的 oldText/newText 形状下,它没有训练过这个字段的名字。所以它每次都重新采样一个看似合理的名字,这就是为什么失败会产出几十个随机 key,而不是一个稳定别名。
由于 Anthropic 的 strict mode 似乎可以修复这个问题,我推测在服务器端,它们拒绝采样 JSON schema 结构不允许的 key。这也解释了为什么启用 strict mode 时,它们会对工具定义复杂度设限。
到目前为止,我测试过的 Codex 模型没有表现出这类退化。我测试了所有可用的模型,除了 5.6,因为我还没有它的访问权限。
这对 Harness 意味着什么
令人不舒服的教训是:工具 schema 并不是中立的,至少在 Anthropic 模型上不是。我们喜欢假装 schema 是一个抽象契约,而模型是一个会遵守它的通用推理器,但对某些工具来说,这可能已经不再成立了。
工具 schema 位于某个分布之中;有些形状接近模型在 post-training 中见过的东西,有些则相距甚远。有些对 provider 隐藏的编码更容易(比如 ANTML 中的顶层属性),而有些则要求模型在嵌套数组内部、长多行字符串之后,书写庞大的转义 JSON 对象。模型可能足够聪明,能理解 schema,但在压力之下仍然不擅长精确采样出对应形状。
如果这种模型行为持续下去,我会好奇它对 harness 会有什么影响。显然,你可以在 Anthropic 上开启 strict sampling,问题应该就会消失。另一方面,模型出现这种行为也展示了强化学习对它们的影响。如果你想获得最好的模型性能,和这种先验硬碰硬大概是徒劳的。
现在的现实是,Claude Code 不是开源的,我们也无法真正知道他们在 RL 环境中做了什么。我们不能假设 Claude-Code-trained 的行为会干净地迁移到你的工具上,除非你的工具和它非常接近。post-training 越多地发生在某一个占主导地位的 harness 内部,其他所有 harness 就越不得不继承它的怪癖。
我过去对严格的 grammar-constrained tool invocation 更持怀疑态度,因为 constrained decoding 可能带来质量上的取舍。我仍然认为总体上这可能是真的,但这个 bug 明显改变了我的先验。如果最新模型在解决任务上变得更强,却在忠实输出另一种工具 schema 上变得更差,那么 harness 就需要在某处提供更强的保证。
如果你想了解更多,或者想讨论这个问题,可以阅读 Pi tracker 上的 issue。