Opus 4.6 工具调用的 JSON 转义变了:字符串解析翻车实录
这是一个很小的改动,小到官方只用了一句话描述:
Opus 4.6 may produce slightly different JSON string escaping in tool call arguments.
但就是这"slightly different",让一些生产系统出了问题。
问题是什么
当模型调用工具时,参数通过 JSON 字符串传递。比如你定义了一个文件编辑工具,模型调用时传的参数大概长这样:
{
"file_path": "src/utils/parser.ts",
"content": "export function parse(input: string): Result {\n return JSON.parse(input);\n}"
}
Opus 4.5 和 4.6 在生成这段 JSON 时,对某些字符的转义方式不一样。
具体变化:
- Unicode 转义:比如中文字符,4.5 可能直接输出
"文件",4.6 可能输出"\u6587\u4ef6"。两者在 JSON 标准里等价,但作为字符串不一样。 - 正斜杠:4.5 可能输出
"path/to/file",4.6 可能输出"path\/to\/file"。JSON 标准允许两种写法,但正则匹配不一样。 - 换行符的表示:
\nvs\\n的处理可能有细微差异。
用 json.loads() 或 JSON.parse() 解析,这些差异完全透明——解析器会自动处理。但如果你的代码里用了其他方式处理 tool call 的参数,就可能出事。
谁会被影响
安全的:用标准 JSON 解析器的代码。
import json
# 安全——json.loads 自动处理转义差异
tool_input = json.loads(response.content[0].input)
file_path = tool_input["file_path"]
危险的:用正则、字符串匹配、或者手动解析的代码。
import re
# 危险——正则假设了特定的转义格式
raw = str(response.content[0].input)
match = re.search(r'"file_path":\s*"([^"]+)"', raw)
file_path = match.group(1) # 如果值里有转义的引号或 Unicode,这里会出错
# 也危险——直接比较原始字符串
raw_input = str(response.content[0])
if '"file_path": "src/utils/parser.ts"' in raw_input:
# 如果模型输出的是 "src\/utils\/parser.ts",匹配不上
do_something()
一个真实的翻车场景
我见过一个代码审查工具的实现。它定义了一个 edit_file 工具,模型调用时传入文件路径和修改内容。下游代码用正则从 tool call 的 input 字段提取文件路径:
# 从 tool call 的原始文本里提取路径
path_match = re.search(r'file_path.*?:\s*"(.*?)"', raw_tool_input)
4.5 时代,模型返回 "file_path": "src/components/Header.tsx",正则匹配正常。
切到 4.6 后,某些请求里模型返回 "file_path": "src\/components\/Header.tsx"——多了转义的正斜杠。正则提取出来的路径带着反斜杠,传给文件系统就找不到文件,工具执行失败。
更隐蔽的是:这不是 100% 复现。模型有时候会转义,有时候不会——取决于上下文和生成过程中的采样。你在测试环境跑 10 次都正常,上线后第 11 次就炸了。
怎么修
1. 用标准 JSON 解析器
最正确的做法。不管模型输出什么格式的 JSON,解析器都能正确处理。
# Python
import json
tool_input = json.loads(tool_call.input)
# 或者如果你用 Anthropic SDK
# tool_call.input 已经是解析好的 dict,直接用
file_path = tool_call.input["file_path"]
// JavaScript/TypeScript
const toolInput = JSON.parse(toolCall.input);
2. 搜一遍代码里的字符串操作
在你的代码库里搜这些模式:
# 搜索潜在的危险模式
# 正则提取 tool call 参数
re.search(.*tool.*input.*)
re.findall(.*tool.*input.*)
# 字符串比较 tool call 参数
"tool" in str(...)
str(tool_call)
每一个对 tool call input 做字符串操作的地方,都要检查是否会被转义变化影响。
3. 加一层标准化
如果你实在有理由不想改下游的字符串处理逻辑(比如改动太大),可以在中间加一层:先用 JSON 解析器 parse,再 dumps 回标准格式。
import json
def normalize_tool_input(raw_input):
"""确保 tool input 的 JSON 格式一致"""
parsed = json.loads(raw_input) if isinstance(raw_input, str) else raw_input
return json.dumps(parsed, ensure_ascii=False)
这样下游代码看到的永远是同一种格式。但这只是权宜之计——长期来看还是应该用正确的解析方式。
除了转义,还有参数末尾的换行
从 Opus 4.5 开始(不只是 4.6),tool call 的字符串参数可能带有末尾换行符。比如你期望的是 "hello",实际拿到的是 "hello\n"。
# 如果你的代码做精确匹配
if tool_input["command"] == "hello": # 不匹配,因为实际值是 "hello\n"
execute()
官方迁移指南里提到了这个问题。建议在处理 tool call 参数时,对字符串值做 .strip():
command = tool_input["command"].strip()
一个检查清单
- 所有 tool call 的 input 解析都走标准 JSON parser
- 没有用正则从 tool call 原始文本里提取参数
- 没有用字符串比较来判断 tool call 的参数值
- 字符串参数做了 strip() 处理
- 文件路径类参数做了 normalize(处理
\/等) - 在测试环境用 4.6 跑了至少 50 个 tool call 场景
这个 breaking change 不会让你的服务宕机(tool call 的结构没变,只是值的编码方式有差异),但会导致工具执行失败、结果不符合预期。如果你的 Agent 有"失败重试"逻辑,可能表现为"偶尔需要多重试几次"——成本上去了,体验下来了,但你可能不知道根因是什么。
趁迁移到 4.6 的时候,把 tool call 的解析逻辑过一遍。半天的工作,能避免一堆难查的线上 bug。
参考链接
- 迁移指南(tool parameter quoting):
https://platform.claude.com/docs/en/about-claude/models/migration-guide - What's New in Claude 4.6(breaking changes):
https://platform.claude.com/docs/en/about-claude/models/whats-new-claude-4-6