跳到主要内容

Tokenizer Churn:你的“兼容”模型升级中隐藏的破坏性变更

· 阅读需 13 分钟
Tian Pan
Software Engineer

供应商声称这次升级是无缝替换。API 契约保持不变。配置中的模型名称几乎没变。一周后,你的上下文窗口防御机制(context-window guard)开始在以前从未触发过的提示词(prompts)上报警,你的停止序列(stop-sequence)正则匹配在了错误的位置,而你的少量样本(few-shot)示例之一开始产生一个极其自信的错误答案,而你的评估套件恰好没有覆盖到这一点。没有人动过提示词。没有人动过温度参数(temperature)。有人悄悄重新训练了分词器(tokenizer)。

分词器更改是供应商不会称之为“破坏性”(breaking)的破坏性更改。API 层面保持了字节级稳定,SDK 没有升级主版本,发布说明中提到了“改进的指令遵循能力”——但从你的输入字符串到模型实际看到的整数序列的映射函数已经被替换了。你的代码关于文本如何转换为标记(token)的每一个假设现在都出现了微妙的偏差。这种隐形代价是,在有人重新通过 count_tokens 运行标准提示词并发现答案之前,你会经历两周“感觉模型不太一样”的困惑期。

这并非虚构。Claude Opus 4.7 发布时配备了更新的分词器,虽然每百万标记一美元的定价保持不变,但相同输入的标记计数增加了 1.0× 到 1.35×,其中代码和结构化数据处于该范围的高端。同样的提示词,同样的回答质量,同样的接口价格——却产生了一张增加 35% 的账单,而且无需任何迁移操作就会产生这笔费用。类似形式的变动在不同供应商和过去几年中屡见不鲜;只是它们很少在变更日志中留下清晰的印记。

为什么分词器会发生漂移

BPE 分词器不是一种固定的协议。它是一个学习产物:一个词表(vocabulary)加上一系列在特定语料库上训练的合并规则(merge rules)。当供应商想要更好的代码压缩率、更智能地处理表情符号(emoji)和中日韩文字(CJK)、更精确地处理空白字符,或者为工具调用(tool use)和推理增加新的控制标记时,最廉价的修复方案就是在训练模型的同时重新训练或扩展分词器。模型名称可能只是从 4.6 变为 4.7,而底层的词表已经发生了更替。

有三种形式的变动值得区分,因为每种变动破坏的东西各不相同。

第一种是词表扩展:新分词器增加了特殊标记(通常用于工具调用、思考块、系统角色或新模态)。你的提示词仍然可以正常分词,但任何对原始分词流进行过滤(grep)的操作——如护栏(guardrail)、停止序列、流式解析器——现在都会看到以前从未见过的 ID 范围。模型被训练在以前从不使用这些标记的上下文中输出它们,而你的下游消费者却还没有准备好。

第二种是重新切分(re-segmentation):合并表发生了变化,因此相同的字符串现在被分解为不同的子词。一个通过精确结束在标记边界来引导模型的少量样本(few-shot)示例会失去这种引导作用。一个以前匹配一个标记的停止序列字符串现在匹配三个标记,而你假设原子级输出的后处理程序在流式传输下会崩溃,因为部分匹配会跨越块(chunk)边界。Karpathy 的名言——“大多数 LLM 的怪异行为都是分词器的怪异行为”——在每次此类事件发生时都会得到新一代的印证。

第三种是空白符和特殊字符的重新规范化:前导空格如何合并、多个换行符如何折叠、表情符号和组合变音符号如何编码。较旧的 GPT 类分词器不对空白符进行合并;而较新的分词器会将连续的空格合并为单个标记。代码、JSON、缩进的 YAML,以及任何使用两空格缩进的少量样本示例的提示词,在更改后计数都会有所不同。昨天还在你上下文预算范围内的输入,今天可能就不再安全了。

三种无声的失效模式

以下是实际会发生故障的地方,以及它们在生产环境中的表现,大致按发现所需的时间排序。

你的上下文预算现在出现了 5–15% 的偏差

这是最简单、最明显、一旦知道原因就最容易修复的问题。你的代码根据模型的上下文窗口计算 len(prompt_tokens) + max_completion_tokens。在分词器更改后,相同的提示词字符串分出的标记变长了。你的预算计算出现了偏差,模型开始在你的测试认为没问题的输入上返回上下文长度错误。更糟糕的是,你的截断逻辑——即通过切掉最旧的聊天记录以适应窗口的机制——现在截断在了错误的位置,因为它瞄准的标记计数低估了新分词器的输出。

修复方法简单但繁琐:针对实时 count_tokens 接口重新基准化(re-baseline)每个提示词长度预算,而不是使用离线的 tiktoken 近似值。将 tiktoken 作为 Anthropic 估算器的技巧始终只是近似;当供应商更改分词器时,近似会变得更差,而你不会收到任何通知。

你的停止序列在错误的地方匹配

这个更阴险。停止序列是 API 上的字符串级契约,但模型输出的是标记。运行时将标记流解码为文本,并检查后缀是否匹配你的任何停止字符串。如果你的停止字符串是 \n\nUser:,而新分词器将其编码为一个标记(而旧分词器将其拆分为三个),那么两件事会同时改变:模型现在更有可能想要输出那个确切的标记(因为在训练中它是一个单一单元),而你假设停止字符串会从流式分词器中逐字符到达的后处理程序突然原子级地接收到了它。你那“在停止符前截断”的逻辑以前是切掉匹配序列的最后一个字符,现在则从合法输出中切掉了一个完整的标记。

反向失效:你的停止字符串以前是一个标记,而新分词器将其拆分为两个。现在你的流式消费者在块 N 中看到了停止序列的前一半,将其交给用户,直到块 N+1 才意识到应该停止。模型以文本形式输出了完整的停止序列,而你的客户端在停止逻辑赶上之前,已在聊天界面渲染了带有提示词注入风格的片段“User:”。

已有真实的生产报告显示停止标记在模型更新中无声失效——经典的 Hugging Face 工单中 Llama 类接口在一次分词器相关更新后停止响应 EOS(终止符)就是同类型的错误。供应商不会写“你的停止序列正则表达式现在出现了微妙的错误”这种发布说明。

你的 few-shot 示例产生了不同的答案

这是最耗费工程信任的故障模式,因为它表现为“模型变笨了”,且没有任何其他信号。一个精心设计的 few-shot 提示词就是一段上下文训练数据(in-context training data)。这些示例利用了特定的 token 边界——即一个示例在哪里结束、答案从哪里开始、缩进是什么样——来教给模型你想要的模式。

当分词器(tokenizer)发生变化时,这些边界就会移动。以前用来演示“始终以 JSON 回答”且带有干净的起始 { token 的示例,现在显示的 { 可能变成了与示例分隔符末尾空格合并后的更大 token。模型从相同的字符串中学到了略有不同的模式。在大多数查询中,你看不出区别。但在长尾情况下——即你的评估套件(eval suite)未涵盖的查询中——答案会发生偏移。

团队的第一反应通常是采样漂移(sampling drift),然后是“他们削弱了模型”,接着是胡乱地重写提示词。实际的解决方法是针对新词表重新对每个 few-shot 示例进行分词,并目测对比边界是否符合你的预期。有时修复方法只是修改一个空格;有时则是舍弃某个依赖于已不存在的分词特性的 few-shot 模式。

审计指南

当你升级模型时——包括“小版本”更新——请假设分词器已经改变并去证明它没变,而不是假设它没变却在三周后才发现问题。以下是四个检查步骤,每个都易于自动化,按优先级排序如下:

  1. 对规范提示词进行逐 token 差异对比。挑选生产环境中流量最大的十个提示词。针对旧版和新版分词器端点分别进行分词。对比 token ID。如果数量发生了变化,请深入调查。如果特定位置发生了变化(特别是 few-shot 示例边界、系统提示词到用户提示词的过渡处,以及任何你依赖停止序列的地方),请标记人工审核。

  2. 停止序列回归测试套件。对于在 API 中注册的每个停止字符串(stop string),在两种分词器下针对代表性提示词各生成十个补全结果,并确认模型在相同的字节偏移处停止,确认流式响应中没有漏掉停止字符串的任何碎片,且内容中间没有触发错误的停止。这项测试能在用户发现之前捕获流式截断(streaming-truncation)错误。

  3. 字符数抽查。在你的应用程序代码中,任何强制执行字符限制并以此预估 token 边界的地方(典型例子:将用户输入截断为 4000 个字符,因为“那大约是 1000 个 token”),请在新模型上运行 count_tokens 进行重新采样。每 token 4 个字符的启发式方法一直很粗略;在分词器更改后,根据输入语言和内容类型的不同,这个数值可能会产生正负 20% 的偏差。

  4. 针对长尾情况评估,而非均值。运行你的评估套件,然后根据分词变化程度对失败案例进行排序。如果失败案例集中在 token 边界移动的地方,那么你遇到的是分词器 bug,而不是模型 bug。如果它们不相关,那么模型行为确实发生了变化。无论哪种方式,你现在都知道该调整哪个杠杆。

该指南的目标不是阻止迁移,而是让迁移变得审慎。能在两天内完成这些工作的团队,可以为自己省去长达一个季度的“模型感觉不对劲”的调试工作。

在模型名称旁固定分词器哈希值

更深层次的修复是结构性的。将分词器视为服务注册中心的一个版本化组件,就像对待数据库模式(schema)或 protobuf 定义一样。

配置中的模型标识符不应仅为 claude-opus-4-7。它应该是 claude-opus-4-7@<tokenizer-hash>——即模型名称加上你在验证提示词当天供应商提供的分词器工件(词汇表文件、合并规则、特殊 token 表)的哈希值。当供应商在相同的模型名称下更换分词器时,你的配置校验器会在下次部署时发现哈希不匹配,并强制执行重新验证步骤,然后才允许新分词器处理流量。

对于不公布分词器工件的闭源供应商,实际的替代方案是一组固定的指纹提示词(fingerprint prompts)——即你在验证时存储了 token 计数的一组简短规范字符串。在每次部署时,通过 count_tokens 重新运行这些提示词,如果计数发生漂移则中止部署。虽然不如哈希严密,但它只需在每次部署时调用三次 API 就能捕获所有有意义的分词器更改。

这是数据库社区在一代人之前就确立的规范。模式迁移(Schema migrations)是经过版本控制、签名和准入限制的;它们不会因为存储引擎静默升级而发生。分词器也应该通过同样的关口。尽管字节是通过 HTTPS 而不是连接池流动的,但这并不能让它们免于约束。

这对供应商合同意味着什么

从这一特定事件中值得吸取的更深层教训是:基础模型供应商提供的 API 合同并不是工程师所习惯的那种版本化合同。传输格式(wire format)是稳定的,模型名称字符串是稳定的,但几乎其他所有内容——分词器、系统提示词模板、工具调用序列化方式、推理 token 的计费方式、采样参数的可接受值、延迟曲线、安全过滤器敏感度——供应商都可以不经通知进行修改,且大多数修改都不会出现在发布说明中。

能在基础模型之上稳定交付的团队,是通过在自己的一侧重建尽可能多的缺失版本合同来实现的:将模型隐藏在别名网关之后、对分词器行为进行快照、在每次模型更新时回放规范流量、在供应商变更的第一周保持回滚窗口开启。这些工作都不光鲜。但正是这些工作,决定了你是会面临“这周模型感觉不太对”的玄学问题,还是一个有着清晰修复方案的生产事故。

需要内化的一点是:字节稳定(byte-stable)不等于行为稳定(behavior-stable),从供应商角度看是“兼容”的升级,从你的角度看通常是破坏性变更。在需要之前,就将审计纳入你的发布流程。你第一次跳过它的时候,账单就会寄达——有时是字面意义上的,表现为没人批准的 35% 成本增加。

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