当前语言模型通常先用 Tokenizer 将文本转换成 Token ID,其中 BPE 是最常见的子词算法。BPE 的原理并不复杂:从字符或字节开始,反复统计相邻符号对,将最高频的一对合成新 Token,直到词表达到指定大小。推理时再按训练得到的顺序重放这些合并。 不过当真正实现一个 Tokenizer 时,会发现 BPE 只解决了中间的一部分问题。文本怎样进入 BPE,中文要不要预分词,训练出的规则又怎样快速应用,都会改变最后的词表和 Token 序列。衡量 Tokenizer 时经常关注压缩率,但有些明显问题不会直接反映在压缩率上,尤其涉及不同语言特性时。这里结合 PieceTokenizer 的实现,谈三个容易忽略的地方。

PreTokenizer

Tokenizer 通常不会把整段文本直接交给 BPE,而是先经过 PreTokenizer。它将文本拆成若干片段,BPE 只能在片段内部合并,不能跨越片段边界。

面向英文时,一个常见做法是把空格归到后面的单词:

Hello world
→ Hello | ▁world

这样既能保留空格,也能用一个 Token 表示“空格+单词”。SentencePiece 会先把空格替换成 ,TikToken 的正则也允许一个非字母字符成为后面单词的前缀。对英文来说,这种设计很自然。

中文却没有依靠空格分词。中文语料里的空格可能来自中英文混排、网页排版、OCR 或数据清洗,并不是一个中文词稳定的词法属性。如果继续把空格粘到后面的汉字段,BPE 就可能同时学习:

世界
▁世界

二者表达的中文内容相同,却占用两个 Token ID,也对应两套 Embedding 和 LM Head 参数。

我下载了一些公开模型的 Tokenizer,只分析词表,没有下载模型权重。Gemma 2 中有 2504 个“前导空格+纯中文”词条,其中 2441 个还能找到完全相同的无空格版本,比例为 97.5%;Gemma 3 的对应数字是 1161、1140 和 98.2%。例如输入 你好 世界,Gemma 会得到:

你好 | ▁世界

这不能说明 SentencePiece 或 TikToken 一定会粘连中文。cl100k_base 的这类词条就很少,一些中文模型也倾向于让空格独立。更准确地说,只要 PreTokenizer 允许空格和汉字段进入同一个片段,词表就可能将同一个中文词拆成带空格与不带空格两种位置变体。

PieceTokenizer 因此对中文单独处理:

Hello world → Hello | ▁world
你好 世界    → 你好 | ▁ | 世界

英文仍然可以利用“空格+单词”压缩序列,汉字段的前导空格则会被剥离。这样做可能让空格多占一个 Token,但 世界 在句首、标点后和空格后可以共享同一个 ID。这里并没有唯一正确的答案,只是在压缩率与词表参数共享之间做了一个明确选择。

内嵌分词器

第二个问题也来自中文没有书写词界。假如把下面的句子作为一个连续片段交给 BPE:

酒店还会提供一项住宿的免费政策

BPE 只知道相邻符号出现得是否频繁,并不知道什么是词。只要这句话在网页模板中重复得足够多,它就可能从 酒店 一直合并出整句话。

这种现象可以直接在公开词表中看到:

Tokenizer不少于四字的中文 Piece(忽略前导空格)较长的例子
Gemma 2162点击下一页继续阅读
DeepSeek V32702习近平新时代中国特色社会主义思想
Yi 1.51278判决书送达之日起十五日内
InternLM 2.52604酒店还会提供一项住宿的免费政策
Baichuan 210589最长达到 32 个汉字
GPT o200k_base723最长达到 10 个汉字

长 Token 本身并不一定有问题。中华人民共和国自然语言处理 或一个稳定的机构名称,作为整体可能更节省序列,也有完整语义。真正不太合适的是跨越多个普通词界的句法片段、网页模板和重复噪声。Baichuan 的词表里甚至能找到多次重复的“网络配图”和其他模板串,这些内容主要反映训练语料的局部频率。

PieceTokenizer 在 BPE 前嵌入了一个可选的中文分词器。它先用 Trie 找出词典候选,再通过 Viterbi Unigram 选择当前连续汉字段得分最高的分词路径:

酒店 | 还会 | 提供 | 一项 | 住宿 | 的 | 免费 | 政策

每个分词结果分别进入 BPE。BPE 可以在 住宿 内部合并,却不能跨过边界学出 住宿的免费政策。这并不是退回传统的固定词表分词:未登录词仍然可以按字或 UTF-8 Byte 编码,一个词内部也仍然可以被 BPE 拆成多个子词。

所以更准确的说法不是“中文分词能隔绝长词”,而是:

中文分词为 BPE 提供不可跨越的词界,从结构上隔绝跨词长串。

我也用一份重复模板构成的小型人工语料做了对照实验。使用完全相同的语料和 BPE 参数,不分词时,词表学出了 18 字的完整模板 北京大学位于北京自然语言处理很有意思;加入中文分词后,最长 Token 是词典中的六字词 自然语言处理,没有 Token 跨越分词边界。

目前 PieceTokenizer 会把 PreTokenizer 的主要配置写入模型,但中文词典本身仍需在训练和推理时提供。更完整的做法应该是把词典与模型一起发布,或者至少记录词典的版本和 SHA-256,避免两边使用不同词典。

BPE 推理

BPE 训练和推理解决的是两个不同问题。训练要决定下一条 Merge 是什么,因此需要不断执行:

统计全局相邻对 → 选择最高频对 → 合并 → 更新频率

高效训练器会用链表、增量计数和倒排索引,只更新受到本次合并影响的局部统计,但它仍然要维护整个语料上的状态。

推理时,Merge 已经训练好了,不再需要任何频率统计。只要从初始字符或 Byte 出发,优先应用 Rank 更小、也就是训练时更早加入词表的 Merge 即可。最直接的写法是反复扫描当前片段,找到 Rank 最小的相邻对并合并。PieceTokenizer 当前的 piece 推理器就是这种实现,代码简单,也容易验证;最坏复杂度约为 (O(n^2)),但 PreTokenizer 已经把长文本切成许多较短片段,实际的 (n) 通常不大。

片段较长时,可以用双向链表维护 Token 邻接关系,用最小堆保存 Merge 候选。每次合并只会改变新 Token 左右两个邻接关系,因此只需更新局部候选,主体复杂度可以降到近似 (O(n\log n))。TikToken 的 Rust 实现会根据片段长度选择不同路径:短片段使用缓存更友好的向量扫描,长片段再使用堆。理论复杂度更好,并不代表短输入一定更快,内存分配和缓存局部性同样重要。

推理性能也取决于 Merge 规则怎样保存:

实现模型保存的主要信息推理查询
PieceTokenizer显式的左右 Parent 和顺序Token Pair → Rank
TikTokenToken Bytes → Rank拼接 Bytes → Rank
SentencePiece BPEPiece → Score拼接字符串 → Score

PieceTokenizer 会保存 abc = ab + c 这样的生成路径,加载时可以直接建立 (ab_id, c_id) → abc_id。TikToken 不保存显式 Parent,而是查询当前相邻区间拼接后的 Bytes Rank;这种做法更紧凑,但要求 Token ID 始终保持标准 BPE 的生成顺序。SentencePiece 保存最终 Piece 和 Score,同一个字符串即使存在多条生成路径,推理时也视为同一个 Symbol。

这些实现都利用了同一个事实:昂贵的全局频率统计只需要做一次,在线编码只需重放冻结的优先级。训练器和推理器不使用同一套数据结构并不是不一致;真正需要一致的是 Normalizer、PreTokenizer、初始符号、边界和 Merge 顺序。为了加速而改变其中任何一项,得到的就可能是另一套 Tokenizer。

三个问题

回头看,这三个问题正好位于 Tokenizer 的三个阶段:

Normalizer
PreTokenizer:中文空格不粘连
内嵌分词:建立不可跨越的词界
BPE:在片段内部学习子词
推理阶段:按 Rank 高效重放 Merge

PreTokenizer 决定什么内容有机会进入同一个 Token;中文分词决定哪些统计相关性不应该突破语言边界;推理器则决定训练出的规则能否被快速、确定地应用。

BPE 的算法很简单,但 Tokenizer 并不简单。它不是一个孤立的词表文件,而是一条从文本规范化、语言边界到规则重放的完整流水线。PieceTokenizer 没有改变 BPE 的基本原理,只是尝试把这三处原本容易隐藏在实现细节里的选择明确下来。

项目与实验: