跳到主要内容

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(终止符)就是同类型的错误。供应商不会写“你的停止序列正则表达式现在出现了微妙的错误”这种发布说明。

加载中…
References:Let's stay in touch and Follow me for more thoughts and updates