Python json.dumps 默认参数如何让 Claude 多烧 24% Token

Python json.dumps 默认转义非ASCII字符,导致提示词token暴增24%并引发日文姓名提取错误,修复只需加一个参数。
一位开发者将支持工单分类器从TypeScript迁移到Python后,发现同样配置下输入token多出24%,日文工单载荷更暴涨3.1倍。根源是Python `json.dumps` 默认开启 `ensure_ascii=True`,将所有非ASCII字符转义为 `\uXXXX` 字面量,而JavaScript的 `JSON.stringify` 不会这样做。当转义后的JSON直接嵌入提示词字符串时,模型读到的是十六进制字符序列而非原始文字,不仅造成token浪费,还使日文姓名提取错误率从0.9%升至8.5%。修复方案是在所有进入提示词的 `json.dumps` 调用处添加 `ensure_ascii=False`,并在CI中加入包含多语言字符的回归测试。这一案例的更大教训是:每一个中间层看起来都正确,唯有把模型实际收到的字符串打印出来亲眼检查,才能发现问题。
一个开发者把支持工单分类器从 TypeScript 迁移到 Python 后,发现同样的模型、同样的系统提示、同样的 2000 条工单,输入 token 却凭空多了 24%。排查一个下午,罪魁祸首竟是 json.dumps 的一个默认参数 ensure_ascii=True——它悄悄把提示词里的每个非 ASCII 字符改写成了十六进制转义,不仅烧钱,还让 Claude 读错了日文客户姓名。
问题的根源:ensure_ascii 到底做了什么
Python 的 json.dumps 默认开启 ensure_ascii=True,会把 ASCII 之外的所有字符转义成 \uXXXX 序列,而基本多文种平面之外的字符(比如大多数 emoji)则变成两个转义组成的代理对。输出仍是合法 JSON,解析回来也是原始数据——这是一个来自「管道不可信、无法安全传输 UTF-8」时代的传输安全特性。
>>> import json
>>> json.dumps({"city": "München", "name": "東京"})
'{"city": "M\\u00fcnchen", "name": "\\u6771\\u4eac"}'
>>> json.dumps({"city": "München", "name": "東京"}, ensure_ascii=False)
'{"city": "München", "name": "東京"}'
对比之下,JavaScript 的 JSON.stringify 并不会做这种转义,这也是作者在迁移时「代码逐行看着一模一样」却掉进坑里的原因。对一个 JSON 文件或 HTTP 请求体来说,这完全无害——接收方解析后拿到的还是 東京。问题只在于:当 JSON 不再是「传输载体」而成了「内容本身」时。

理解这个问题需要先了解 Unicode 与 token 化之间的关系。大语言模型使用字节对编码(BPE)或类似算法将文本切分为 token——训练语料中频繁出现的字符序列会被合并成单个 token。東京 在 Claude 的词表里很可能是一到两个 token;但 \u6771\u4eac 是由反斜杠、字母、数字组成的十二个 ASCII 字符,每个字符或字符对都会被独立切分,导致 token 数量激增。更重要的是,模型在预训练时虽然见过大量 \uXXXX 形式的 JSON 转义(例如代码仓库里的 JSON 文件),但这和「读取原生日语文本」走的是完全不同的注意力路径——模型需要在推理时动态「解码」这些转义,才能理解其含义,这本身就引入了额外的推理负担和出错概率。
为什么只有提示词出问题,API 调用却没事
关键区别在于:提示词是一个字符串,模型读取之前没有任何环节会把它当作 JSON 来解析。作者踩坑的那行代码是这样的:
prompt = f"""Classify this ticket and extract the customer's name and city.
<ticket>
{json.dumps(ticket, indent=2)}
</ticket>"""
当 SDK 把整个请求体序列化通过 HTTP 发送时,外层这一道是没问题的。但内层的 東 此时已经是字符串值的一部分,于是 SDK 转义反斜杠、API 解码一次,最终模型收到的是六个字面字符:\、u、6、7、7、1。模型拿到的不是 東,而是一道需要在「脑子里」破解的十六进制谜题。
TypeScript 版本用 JSON.stringify(ticket, null, 2) 做了同样的事,但它不会动非 ASCII 字符——这就是迁移时无人察觉的根本原因。
这里涉及一个容易混淆的「双重序列化」陷阱。向 Anthropic API 发送请求时,整个请求体会被 SDK 序列化成一个 JSON 对象通过 HTTP 传输。SDK 在序列化这个外层 JSON 时,会把提示词字符串里的反斜杠再次转义——\u6771 变成 \\u6771。API 服务器解析外层 JSON 后,将 \\u6771 还原为 \u6771,然后把这个六字符字符串原封不动地交给模型。因此模型最终看到的确实是字面量的反斜杠加五个字符,而不是汉字。这个「解包一层但不解包内层」的行为完全符合 JSON 规范,却正是 bug 得以存活的原因:任何中间层的工具打印请求体时看到的都是「合法 JSON」。
到底多烧了多少 token
作者用 Anthropic 的 count_tokens 端点对同样 2000 条工单做了双向渲染测量,结果平均每次请求的输入 token 从 1180 涨到了 1461,涨幅 24%。其中日文工单的载荷部分暴涨约 3.1 倍。
| 工单语言 | 占比 | 载荷 token(转义 vs 原始) |
|---|---|---|
| 英语 | 38% | 1.00x |
| 德语 | 22% | 1.06x |
| 西班牙语/葡萄牙语 | 13% | 1.09x |
| 日语 | 17% | 3.1x |
| 其他(法语、波兰语、emoji 密集) | 10% | 约 2x |
规律很清晰:德语工单只偶尔转义 ü 或 ß,损失仅几个百分点;而日语工单的每一个字符都被转义,每个都变成十来个 ASCII 的十六进制,分词效果极差。emoji 单字符损失最严重——一个 😀 会变成十二个字符的代理对转义。
按每月约 4 万条工单计算,每次请求多出的 281 token 累积下来大约是 1100 万输入 token——这些钱纯粹花在让模型「读十六进制」上。
不只是烧钱,还会读错字
更隐蔽的是准确率问题。这个 bug 实际上是通过准确率暴露的:一位客服发现某条工单提取出的客户姓名里有一个汉字,原始消息里压根不存在。作者 grep 原文搜索提取出的姓名,零命中。
于是他对 340 条日文工单做了评分,只要提取出的姓名或城市字符串没有逐字出现在原文里就算错:
- 转义提示词:340 条中有 29 条至少一个字符错误(8.5%)
- 原始 UTF-8 提示词:340 条中仅 3 条(0.9%)
- 分类标签:两个版本几乎没差别
作者的推测是:像 東京 这种常见字符在训练数据里以转义形式大量出现,模型能可靠解码;但人名里的生僻汉字不常见,模型会落到一个码点相近但字形和含义完全无关的字符上。「十六进制距离近」不等于「视觉距离近」——髙 里差一位就是完全不同的字,而周围文本里没有任何线索让模型察觉异常。
作者也坦诚地给出了局限:单一模型、每版本只跑一次、340 条样本、精确匹配评分。这是在他自己数据上的强信号,而非基准测试,但方向性非常明确。
ensure_ascii 还藏在 Python LLM 管线的哪些角落
凡是你把数据序列化进提示词文本的地方都可能中招。作者修完第一处后又找出三个:
pandas.DataFrame.to_json()默认force_ascii=True——同样的 bug,不同的参数名。- 从文件加载再重新 dump 的 few-shot 示例:加载器正确读取了 UTF-8 JSON,但格式化时又调了一次
json.dumps,于是每个日文示例都被转义。 - 工具结果:agent 把工具输出以
json.dumps(result)放进tool_result,带法语产品名的搜索结果以café的形式传给了模型。
已经安全的地方:Pydantic v2 的 model_dump_json() 输出原始 UTF-8,orjson.dumps() 也始终输出 UTF-8。用这两个的话大概率没事——但还是建议检查一遍。
Python 生态里还有几个值得留意的序列化入口。json.JSONEncoder 的子类继承默认参数,若团队封装了自己的编码器但未覆盖 ensure_ascii,同样中招。标准库的 http.server 和 urllib 在构造响应/请求体时底层也调用 json.dumps,若工具调用通过这些模块转发数据,转义会在无声中发生。flask.jsonify 在 Flask 2.2 之前默认 JSON_AS_ASCII=True,同样存在此问题,2.2 版本之后改为 False。在 LangChain 或 LlamaIndex 等框架里,内置的文档序列化工具链大多已处理好编码,但自定义 Tool 的 func 返回值若用裸 json.dumps 格式化,依然会绕过框架的保护。
怎么修,又怎么防止复发
核心做法是在所有「JSON 最终进入提示词」的地方传 ensure_ascii=False,再加一个测试防止后来的辅助函数把它带回来。作者把提示词序列化集中到了一个函数里:
def to_prompt_json(obj) -> str:
# Compact separators, raw UTF-8. This goes to a model, not a wire.
return json.dumps(obj, ensure_ascii=False, separators=(",", ":"))
顺手去掉 indent=2 改用紧凑分隔符,又省下约 6% 的载荷 token。然后在 CI 里加一道守卫,用包含日语、德语和 emoji 的 fixture 跑每一个提示词模板:
import re
ESCAPE = re.compile(r"\\u[0-9a-fA-F]{4}")
def test_prompts_contain_no_unicode_escapes():
for name, render in PROMPT_TEMPLATES.items():
prompt = render(MULTILINGUAL_FIXTURE)
assert not ESCAPE.search(prompt), f"{name} leaks \\u escapes"
这个测试第一次运行就抓到了那个 pandas 调用。此外还有一个偷懒版的审计 grep:
grep -rn "json.dumps(" src/ | grep -v "ensure_ascii=False"
grep -rn "to_json(" src/ | grep -v "force_ascii=False"
修复后 Python 版本平均输入 token 降到 1148,比 TypeScript 原版还低 3%(得益于紧凑分隔符),日文提取错误也回落到 340 中 3 条。
真正值得记住的教训
可怕的不是 ensure_ascii 的存在,而是每一层看上去都对:JSON 合法、请求成功、日志打印正常(因为作者记的是工单本身,不是渲染后的提示词)、模型自信地回答。唯一可见的症状只是账单略微变大,和一个被人类恰巧核对出来的错误姓名。
作者给出的建议朴素而有力:至少对一部分样本,把模型实际收到的那个字符串原样记录下来,用自己的眼睛读一遍。大多数提示词 bug,花五秒钟看一眼真实内容就能发现。
相关推荐

Orca多Agent编排实战:Claude与Codex并行协作指南
Orca是Stable AI开源的免费Agent工作台,支持Claude Code、Codex、Kimi等34个agent并行运行。本文介绍其Git worktree隔离机制、多模型编排分工方案(强模型拆解+便宜模型编码)、手机远程管理功能及实操上手步骤,帮助你高效管理多个AI编程助手。

思维链不止属于AI:人类如何用CoT提升思考质量
思维链(Chain of Thought)不只是大模型的提示词技巧。本文从Hacker News讨论出发,分析CoT为何对人类思考同样有效,拆解可操作的迁移方法,并指出人类CoT与机器CoT的关键差异。
Cloudflare联手OpenAI:用AI打通漏洞发现与防御闭环
Cloudflare联手OpenAI:用AI打通漏洞发现与防御闭环
Cloudflare 使用 OpenAI Daybreak 调查安全漏洞并生成可评审的修复方案,开发者同时借助 Codex 提升编码效率与代码质量,探索漏洞发现到防御响应的一体化路径。