当前语言模型通常先用 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 2 | 162 | 点击下一页继续阅读 |
| DeepSeek V3 | 2702 | 习近平新时代中国特色社会主义思想 |
| Yi 1.5 | 1278 | 判决书送达之日起十五日内 |
| InternLM 2.5 | 2604 | 酒店还会提供一项住宿的免费政策 |
| Baichuan 2 | 10589 | 最长达到 32 个汉字 |
GPT o200k_base | 723 | 最长达到 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 |
| TikToken | Token Bytes → Rank | 拼接 Bytes → Rank |
| SentencePiece BPE | Piece → 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 的基本原理,只是尝试把这三处原本容易隐藏在实现细节里的选择明确下来。
项目与实验:
- PieceTokenizer:PieceTokenizer
- SentencePiece:google/sentencepiece
- TikToken:openai/tiktoken