跳到主要内容

分词器漂移:你的本地计数在撒谎,账单才说真话

· 阅读需 10 分钟
Tian Pan
Software Engineer

我认识的一个团队花了三周时间追踪一个“上下文截断”的 Bug,这个 Bug 只在针对日本客户的生产环境中触发。他们的 CI 测试用例是英文的。他们的 tiktoken 计数显示 Prompt 符合 8K 的限制,且留有 600 个 Token 的余量。但供应商的账单显示,该请求因超过限制而被拒绝。这两个数字相差 11%,而安全余量正好落在在那 11% 之内,而且从未有人衡量过中日韩 (CJK) 文本上的这种差异。修复方案不是换一个新模型——而是不再将本地计数器作为事实标准。

这就是 Tokenizer 漂移那种隐蔽且昂贵的形式:不是一个简单的错误数字,而是一类在被你忽略的测试边界处累积的小型系统性误差。你 IDE 中的本地计数器、网关中的预算计算器、重试中间件中的速率限制评估器,以及供应商据以收费的权威计数——这些都不一致,而且差距恰恰在你用户所在的领域扩大。

栈中的每一层都有自己的 Tokenizer

仔细审视一个典型的请求,数数它接触了多少个 Tokenizer。IDE 插件运行 tiktoken 来渲染“已用 Token”徽章。CI 的 Prompt 预算 Linter 加载它自己固定的 BPE 表版本。应用网关在路由请求之前运行 Token 预算计算器,通常使用完全不同的库(如 @anthropic-ai/tokenizerjs-tiktoken,或者因为没有官方库而用 Java 手写的近似实现)。重试中间件会估算后续调用的剩余空间。最后,供应商运行 真正 的 Tokenizer,即决定你的请求是否符合要求以及成本是多少的那个。

五个 Tokenizer,每一个都旨在模拟同一事物,却在同一个输入上产生分歧。它们在某人于 2024 年编写的英文测试用例上足够接近,因此没人将这种分歧固定为一项指标。当差距扩大时,没人会报警。供应商发布了一个带有略微更新的 Tokenizer 的新模型,SDK 在六周后发布新版本,CI 库又滞后两个月,在那个窗口期,每一层都在计算不同的数字。

这种漂移在工程师查看的仪表板上是不可见的,但在没人会逐行阅读的账单上却是显而易见的。

长尾内容是余量消失的地方

如果你只测试单元测试中的内容,你永远不会看到真正关键的漂移。本地 Tokenizer 和权威计数器往往在平均情况下达成一致——即带有正常标点符号的简短英文 Prompt。但在以下一小组内容形态上,它们会产生可预见的分歧:

  • 中日韩 (CJK) 文本。cl100k_base 中,大多数中文字符、日文字符和韩文字符每个占用两到三个 Token,而英文基准约为每个 Token 3.8–4.2 个字符。一个看起来像 2,000 个字符的“输入” Prompt 可能达到 5,000 到 6,000 个 Token。本地估算中几个百分点的误差,在绝对数值上就变成了数百个 Token 的误差,并落入你为英文设置的安全余量之内。
  • Emoji 和 ZWJ 序列。 一个单一的组合 Emoji,如国旗或带肤色修饰符的家庭图标,可能变成三到六个字节 Token。你的输入字段不会将它们渲染为多 Token,你的本地计数器经常误算它们,而用户生成的内容路径却经常碰到它们。
  • 混合缩进的代码。 Tab 与空格、尾随空格、在 LF 和 CRLF 之间切换的换行符——BPE 合并取决于精确的字节序列,而“在计数前规范化空格”是没人在发送未经修改的代码前会遵循的建议。
  • 工具调用和结构化参数。 这是最大的影响因素。openai/tiktoken 仓库中报道的一个案例显示,一个 Prompt 的本地计数为 47,194 个 Token,而 API 收费却是 140,384 个 Token——约 3 倍的差距,完全是由工具定义和工具调用参数在请求封装(Request Envelope)中的构建、格式化和 Token 化方式引起的。
  • 推理模型开销。 推理模型会对任何本地计数器都无法预测的隐形思维 Token(Thinking Tokens)收费。从计费的 Completion 中减去可见的输出是看到它们的唯一方法;围绕它们制定预算需要一个每模型开销常量,而没人有时间去维护这个常量。

规律是一致的:你在输入分布乏味的中间地带进行了测试。账单却是由长尾地带支付的。

Token 计数是 Tokenizer 的属性,而非你的库的属性

这里有一个修复这类 Bug 的思维转变。Token 计数不是对文本的通用操作。它是 模型用于解析请求封装的特定 Tokenizer 的属性,包括供应商将系统 Prompt 包裹在角色标记中的方式、内联工具定义的方式、展开多模态占位符的方式,以及注入你看不见且未被告知的安全前导语的方式。

你的本地库实现的是该 Tokenizer 的 某个版本。它并没有实现供应商完整的请求格式化流水线。发布的 BPE 表是系统的一部分,而不是系统的全部。隐藏的角色 Token、消息开销(对于对话模型,通常是每条消息的一小部分额外添加)、工具 Schema 框架以及供应商端的 Prompt 封装器都不在表中。

这很重要,因为封装部分随功能丰富程度而增长。添加一个工具,就添加了工具 Schema 框架。添加一个多模态输入,就添加了本地库无法正确处理的图像 Token 核算。添加一个带有长指令集的系统 Prompt,你在每一轮对话中都要为此付费。本地计数正日益成为一种“低估”,而这种低估随着使 Agent 具备能力的那些功能的增加而增长。

清晰的抽象是:估算来自本地库,权威计数来自供应商,两者不是同一类东西。将它们混为一谈的工程师,是在移动的地板上构建预算。

工程纪律的表现

那些不再对 Tokenizer 漂移感到意外的团队,大多殊途同归,采用了大致相同的应对策略。这些做法并不离奇,而是大多数团队因为本地计数器“足够接近”而选择跳过的乏味基础设施工作——直到出问题的那天为止。

在网关层设立单一的 Token 化边界。 所有影响计费或上下文适配的 Prompt 预算决策,都应调用供应商提供的权威计数器——对于 Anthropic 是 messages.countTokens,对于 Gemini 和 Vertex Anthropic 是类似的端点;对于 OpenAI 的工具调用路径,则需要进行真实的往返估算,因为 tiktoken 目前还无法准确模拟工具调用的框架开销(Framing)。除了网关层以外,其他各层可以使用本地估算,但只有网关层的数值被允许作为调用门控。

针对边界情况字符串的 CI 测试集。 挑选出本地计数器与权威计数器产生分歧的案例:中日韩文字、Emoji ZWJ 序列、从右向左书写的阿拉伯语和希伯来语、具有混合缩进的代码块、带有深层嵌套参数的工具调用、以及包含多个系统消息的 Prompt。在 CI 中运行这两种计数器,当本地计数与权威计数的偏差超过某个阈值(纯文本为百分之几,工具调用密集的路径可以更宽松点)时,判定构建失败。当模型发布导致差距扩大时,你会在 PR 审查阶段就能发现,而不是等到月底账单寄来时。

按租户进行 Token 计费对账。 汇总网关的调用前估算值,汇总供应商报告的实际使用量,按租户记录这两者,并在每日偏差(Daily Delta)超过阈值时发出告警。这是捕获在版本发布之间悄然上线的 Tokenizer 漂移的唯一方法。它还能捕获另一类 Bug——即在预算检查之后重写 Prompt 的中间件——否则你只能靠直觉来诊断。

在 SDK 中明确记录“估算值 vs 权威值”的区别。 如果你的团队交付内部 SDK,本地计数辅助函数应命名为 estimate_tokens,并返回一个明确标记为估算值的结构。权威调用应命名为 count_tokens_authoritative 或通过网关路由。当 API 不再混淆两者时,工程师们自然也会停止这种混淆。

针对 Framing 开销设置复杂度预算。 你添加的每个工具、每次系统提示词的修订、每个多模态输入,都会拉大本地计数与权威计数之间的差距。将每个请求的平均 Framing 开销作为一个指标进行追踪。当它在版本更迭中增长超过(比如)20% 时,这是一个信号,表明请求封装(Request Envelope)已经变得比本地计数器所能理解的更复杂,预算计算器需要重新校准。

架构上的感悟

更深层的教训是,Token 化是你与供应商之间的契约接口。就像任何契约接口一样,如果你认为它完全存在于你的本地端,就会产生一种虚假的掌控感。真正重要的数字不是“这个字符串有多少个 Token”——对于不同的供应商、模型和发布版本,这个问题没有唯一的正确答案。真正重要的数字是:“在这个特定的时刻,针对这个特定的模型,供应商会针对这个精确的请求封装收我多少钱。”这个数字掌握在他们手中,而不是你的。

本地计数器有其合理的用途。它们快速、廉价,足以应对 IDE 插件标记、仪表盘的大致估算以及粗略的预算规划。但它们不足以用于计费、长输入的上下文适配决策、速率限制核算,或者任何客户所依赖的保证。将它们视为真理,结果就是你预留的 5–15% 的安全边际,会被那些内容处于“分歧区”的客户悄无声息地消耗殆尽。

尽早意识到这一点的团队会构建一套小巧而乏味的护栏——一个权威边界、一组 CI 测试用例、一个对账告警、一个标记清晰的估算 API——从此不再感到意外。而意识到得太晚的团队,则会像我开头提到的那个团队一样:花费三周时间调试一个特定客户的截断 Bug,结果发现那是因为他们对自己 Tokenizer 的假设中隐藏了 11% 的漂移。漂移不是任何单个库的 Bug。它是架构的一种属性。要么针对它进行构建,要么为此付出代价。

References:Let's stay in touch and Follow me for more thoughts and updates