Tokenization 与 BPE
为何不按词或字符切分、BPE 如何用子词单元同时控制词表大小与序列长度
核心要点:
- 单词级 → OOV 必然 + 词表爆炸;字符级 → 序列翻 5×, attention ×25
- 子词 (subword) 落在中间,BPE 是事实标准
- BPE 训练前先做 pre-tokenization regex,它划合并边界,而非"切词"
- 字节级起点 256 byte,任意输入可逆 encode (GPT-2 起)
- vocab size 不是工程拍脑袋,有 scaling law: $N_v \propto N_{nv}^{0.83}$
- vocab 大代价:embedding 梯度比其他层大 100×, logits 显存 GB 级
- Glitch token (SolidGoldMagikarp) 是 tokenizer 与模型训练数据分布不一致的副作用
名词定义
本篇所用的共享名词在 3.1 总览 已定义 (Subword tokenization / BPE / Byte-level BPE / OOV / Vocab / Embedding matrix / Weight tying)。本篇新引入若干工程细节名词:
| 名词 | 定义 |
|---|---|
| Pre-tokenization | BPE 训练 / 推理前用 regex 把输入预切成 chunk 的步骤,BPE 合并只在同一 chunk 内进行,不跨 chunk |
bytes_to_unicode | GPT-2 tokenizer 的工程 hack,把 256 个字节值映射到 256 个可打印 Unicode 字符,让 BPE 能在 string 上跑而非 raw bytes |
tiktoken | OpenAI 自研的 Rust 实现 byte-level BPE 库,GPT-4 / Llama 3 / Qwen 等沿用;直接在 bytes 类型上操作,无需 bytes_to_unicode |
| Glitch token | 词表里存在但模型 embedding 没学好的 token;推理时 mention 它会触发模型乱码或异常输出 |
| Under-trained token | glitch token 的统称,主因 tokenizer 训练数据与模型预训练数据分布不一致 |
@tbl-bpe-glossary 本篇新引入工程细节名词
为什么不直接按单词或按字符切?
核心问题:把一句话切成模型能处理的离散单元,最直觉的两个方案 (按单词 / 按字符) 都不能用。卡在哪?
单词级 tokenization 同时撞上"词表爆炸"和"OOV 必然发生"两堵墙。
- 词表爆炸:英文 Wiki 语料的去重单词数轻松破百万;加上人名 / 地名 / 代码标识符 / URL / 表情符 / 多语言,实际上限远高于此。词表越大,embedding 矩阵 $[V, h]$ 和 LM head 都越大,训练和推理成本随 $V$ 线性涨。
- OOV 必然发生:无论训练词表开多大,测试时遇到训练里没见过的单词 (新人名 / 拼错 / 新词 / 外语) 就只能丢个
<unk>,丢掉所有内部结构。LLM 时代用户输入完全不可控,这条等于宣判单词级方案死刑。 - 没有形态学:"play / playing / played / replay" 模型看不出关系,每个当独立 token 学,参数严重浪费。
字符级 tokenization 解掉了 OOV,但把另一个代价推到极致:序列长度爆炸。
- 英文一段 1000 词 ≈ 5000 字符,序列长度 ×5;中文按字符切也类似量级。
- self-attention 复杂度 $O(s^2)$,序列 ×5 → attention 计算 ×25。
- 字符本身语义稀薄:"c" 单独看没含义,模型要堆很多层才能从字符拼出概念,表达效率低。
Subword (子词) tokenization 在两者中间落点:切到比单词小、比字符大的子词单元,同时控制词表大小和序列长度。Sennrich, Haddow, Birch 2016 把 BPE 引入神经机器翻译,解决了 NMT 的 OOV 问题[1];后续 GPT-2 把它推到字节级 (下文展开),成了所有现代 LLM 的事实标准。
BPE 算法到底在做什么?
核心问题:LLM 语境下 "BPE" 三个字母指代三件事——pre-tokenization (划边界) / 训练 (从语料学合并规则) / 编码 (推理时把字符串切成 token)。三步具体怎么做?
训练前还有一步:pre-tokenization 划边界
Pre-tokenization 是被入门教程普遍忽略但工程上至关重要的设计决策。BPE 不直接对整段文本跑合并,而是先用一个 regex 把文本切成 chunk, BPE 合并只在同一 chunk 内部进行,不跨 chunk。这等于在合并阶段提前划了一堵墙,防止"the_dog" 这种跨词组合被学进 vocab。
GPT-2 用的 regex 是:
's|'t|'re|'ve|'m|'ll|'d| ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+
把缩写 / 单词 / 数字串 / 标点串 / 空白各自切成独立 chunk。这条 regex 的演化是观察 OpenAI 工程经验的好窗口:
| Tokenizer | 数字 chunking | 备注 |
|---|---|---|
| GPT-2 | \p{N}+ (任意长度) | OpenAI 后来承认有 bug:区分大小写匹配 "HOW'S" 失败;不识别 Unicode 弯引号 |
cl100k_base (GPT-3.5/4) | \p{N}{2,} (1-2 位一组) | 加 (?i:...) 修复缩写大小写问题 |
o200k_base (GPT-4o) | \p{N}{1,3} (1-3 位一组) | 显式动机改善算数任务表现 |
@tbl-bpe-pretokenize OpenAI 三代 tokenizer 的 pre-tokenization regex 演化
训练:一轮合并就是一次 "find-and-replace"
BPE 训练就是反复扫语料、找最频繁的相邻 token 对、合并成一个新 token。从一个最小起点 (字节 / 字符) 出发,每一轮做完整 chunk 内的频次统计,直到词表长到目标大小。
以一个迷你语料 low low low low low lower newest newest newest widest widest widest 为例,起点把每个词拆成字符 + 词尾标记 </w>:
初始 token 序列:
l o w </w> l o w </w> l o w </w> l o w </w> l o w </w>
l o w e r </w>
n e w e s t </w> n e w e s t </w> n e w e s t </w>
w i d e s t </w> w i d e s t </w> w i d e s t </w>
第 1 轮:统计所有相邻 pair 频次。(e, s) 出现 6 次 (n e w e s t × 3 + w i d e s t × 3),最高。合并 (e, s) → 新 token es,整个语料里 e s 全部替换。
第 2 轮:重新统计。现在 (es, t) 是 6 次最高。合并 → est。
第 3 轮:(l, o) 是 5 次最高 (来自 l o w × 5)。合并 → lo。
如此循环,每一轮:统计 pair → 取最高频 → 合并 → 写入合并规则表。规则表的顺序就是合并的优先级,推理时按这个顺序应用。停机条件:达到目标词表大小 (比如 50,000 merges) 就停。
训练复杂度:朴素 O(V·N) vs 优化 O(N log N)
朴素实现每轮全量重扫语料统计 pair 频次,复杂度 O(vocab_size × corpus_size)。tiktoken 官方的 _educational.py 是朴素实现,清晰但慢。
生产实现的关键观察:每次合并只有左右邻居的 pair 计数会变,不需要全量重算。配合 linked list (token 序列) + priority queue (pair 频次),复杂度降到 O(N log N)。实测同一语料从约 8.4 小时压缩到 13 秒,约 2000× 加速。
编码:把输入贪心套用合并规则
推理时对一个新输入 (比如 "lowest"),流程是:
- 先跑 pre-tokenization regex 切 chunk
- 每个 chunk 拆成最细粒度 (字节)
- 按训练时学到的合并规则按顺序扫一遍,能合就合
- 规则
(e, s)→ 合并 →l o w es t - 规则
(es, t)→ 合并 →l o w est - 规则
(l, o)→ 合并 →lo w est
- 规则
- 最终 token 序列就是结果
关键直觉:BPE 编码不是"最长前缀匹配",而是按合并规则学习顺序贪心。先学的合并优先级更高,常见英文单词 ("the" / "and") 直接 1 个 token;罕见或拼错词被拆成多个子词,模型仍能从已知子词复合出语义。
特殊 token 不是训练学的,是训练后追加
<|endoftext|> / <|im_start|> / <|im_end|> 这类特殊 token 不参与 BPE 训练,是事后手动追加到 vocab 尾部。例如 GPT-4 的 <|endoftext|> ID = 100257,紧接 100256 个 BPE token 之后。
Encoding 时先用 regex 识别特殊 token 字符串,匹配到直接返回 ID,不走 BPE 合并。tiktoken 强制调用方用 allowed_special 参数显式声明哪些特殊 token 允许从用户输入识别——这是 prompt injection 的关键防御:默认不允许,用户写 <|endoftext|> 字面字符串只会被当成普通文本切分,不会被解释为控制 token。
字节级 BPE 为什么是现代主流?
核心问题:BPE 起点可以是字符 (Unicode codepoint),也可以是字节 (raw bytes)。后者要解决 byte → 字符 解码的工程复杂度,为什么 GPT-2 起所有现代模型都选了字节级?
字节级 BPE 用 256 个字节作底,保证任何字节串都能 encode,彻底消灭 OOV。
Radford et al. 2019 在 GPT-2 引入字节级 BPE[2]。换字节级的根本动机:
- Unicode 字符级仍可能 OOV:Unicode 有 14 万 + codepoint (emoji / 罕见汉字 / 古文字),词表覆盖不全的字符会变
<unk>。 - 字节级保证可逆:任何 UTF-8 字符串都是字节串,256 字节作底必然覆盖。模型输出的 token 序列也总能 decode 回字节再 decode 回字符串。
- 代价低:词表多 256 个底字节是常数开销,常用字符在 BPE 训练时会被合并成单 token,不会一直停留在字节级别。
bytes_to_unicode: GPT-2 的工程妥协
GPT-2 的 BPE 算法实际上跑在 printable Unicode 上,不是 raw bytes。原因是当时 BPE 库的代码处理不了控制字符 (0x00-0x1F 等不可见字节)。bytes_to_unicode() 把 256 个 byte 值映射成 256 个可打印 Unicode 字符:可见 ASCII 段直接映射,不可打印字节映射到 U+0100 以后的码点。
tiktoken (GPT-4 / Llama 3 / Qwen 用的) 是 Rust 重写,直接在 bytes 类型上操作,不需要这个 hack——这也是看 GPT-2 encoder.py 比看 tiktoken 更难读懂的根源。
Pre-tokenization regex 与字节级的双层操作
Regex 在 Unicode 码点层切 chunk, BPE 合并在字节层进行。这一点对中文影响巨大:
- 一个汉字是单个 Unicode 码点 (
\p{L}视角),被 regex 切为独立 pre-token - 但 UTF-8 下一个汉字是 3 个字节,进入 BPE 时是 3 字节序列
- 训练语料里若汉字频次低,这 3 字节不会被合并,一个汉字 = 3 token
- 反之若是高频汉字,3 字节被合并成 1 token,一字 = 1 token
这解释了 Llama 2 (SentencePiece 32K vocab,中文覆盖 < 1000 字) 中文为何每字 3-4 token,而 Llama 3 (tiktoken 128K,含 28K 非英语扩展) 降到约 1 token / 字。
中文与代码 token 效率实测
| 场景 | GPT-2 (50K) | Llama 2 (32K SP) | cl100k_base (100K) | o200k_base (200K) | Llama 3 (128K tiktoken) | Qwen 2.5 (151K) |
|---|---|---|---|---|---|---|
| 常用汉字 | ~2.5/字 | 3-4/字 (字节 fallback) | ~1/字 | ~1/字 | ~1/字 | ~1/字 |
Python 示例 (def calculate_sum(a, b): return a + b) | 13 token | — | 12 | 12 | 12 | 11 |
@tbl-bpe-efficiency 中文与代码 token 效率实测对比 (cl100k 与 o200k 对常规中文无显著差异,多语言差异在印度语系 / 阿拉伯语等)
GPT-4o 改用 o200k_base 的实测收益:20 种语言平均减少 1.1-4.4× token,印度语系达 7× 压缩改善。
Glitch token:训练数据不一致的副作用
词表里能存在的 token,模型不一定能正确处理它。最著名案例是 SolidGoldMagikarp (Reddit 计数帖一个高频用户名)。它被 BPE 训练数据 (含大量 Reddit 爬取) 合并进 GPT-2/3 vocab;但在 LLM 预训练语料里出现极少,它的 embedding 没怎么被更新,接近随机初始化。推理时 mention 这个 token, embedding 偏向整体质心,attention 异常,模型输出胡言乱语。
根因:tokenizer 训练数据 ≠ 模型预训练数据分布。tokenizer 通常用相对小的、可能不同源的语料训练;模型预训练用更大、过滤更严格的语料。两者分布有 gap 的部分就是 under-trained tokens。
规模:GlitchProber 2024 实测 Llama-2-7b-chat (32K vocab) 含 6,425 个 glitch token (~20%), Qwen-7B (151K vocab) 含 30,700 个 (~20%)[3]。Land & Bartolo "Fishing for Magikarp" EMNLP 2024 给出自动检测方法[4]。比例随 vocab 增大趋势一致,不会因为词表变大而自动消失。
业界对策:主要靠 (1) tokenizer 与模型预训练用同源数据 (2) 后训练阶段对 under-trained token 加权 (3) 推理时检测 + 替换。Llama 3 / Qwen 等新模型 glitch 现象已显著缓解但未消失。
主流 tokenizer 与 vocab 大小
| 模型 | tokenizer 实现 | vocab 大小 |
|---|---|---|
| GPT-2 / GPT-3 | byte-level BPE (OpenAI 自研) | 50,257 |
| Llama 1 / Llama 2 | SentencePiece BPE | 32,000 |
| Llama 3 / Llama 4 | tiktoken (byte-level BPE) | 128,256 |
| Qwen2 / Qwen2.5 | tiktoken (byte-level BPE) | ~152K |
| DeepSeek-V3 | byte-level BPE | ~128K |
| GPT-3.5 / GPT-4 | tiktoken cl100k_base | 100,277 |
| GPT-4o | tiktoken o200k_base | 200,019 |
@tbl-bpe-vocab 主流模型 tokenizer 与词表大小 (数字以官方 release 为准)
vocab size 怎么选?
核心问题:看到 32K → 50K → 128K → 200K 的演化,直觉是"越大越好"。但每多一个 token 都有持续成本。怎么权衡?是否有理论依据还是工程拍脑袋?
直到 2024 年,vocab size 选择基本是工程经验驱动。Tao et al. NeurIPS 2024 给出第一个系统的 vocab scaling law[5]。
vocab scaling law (Tao et al. 2024)
最优 vocab 参数量 $N_v$ 与非 vocab 模型参数量 $N_{nv}$ 的关系:
$$\begin{equation} N_v \propto N_{nv}^{0.83} \label{eq:bpe-vocab-scaling} \end{equation}$$指数 < 1 说明 vocab 应该随模型变大但增速慢于主参数。IsoFLOPs 拟合给出训练算力 $C$ 与最优 vocab 的关系:$N_v = 0.20 \cdot C^{0.42}$。
实证差距:论文事后推算 Llama 2-70B 的理论最优 vocab 应为 216K,实际只有 32K, 差了 7×。这解释了为什么 Llama 3 大幅涨到 128K——不是工程师拍脑袋,是补偿历史欠账。
注意这个 scaling law 是在 BPE 框架下、用标准训练数据得到的。Luong et al. 2025 在 input/output vocab 解耦的架构下得出方向相反的结论[6],但 BPE 工业标准下 Tao 结果仍是首选参考。
Llama 3 128K 的官方分解
Llama 3 tech report (arXiv:2407.21783 §3.2) 给出 128K 的来源:100K tiktoken base + 28K 非英语 token 扩展[7]。量化收益:
- 英文压缩率:3.17 → 3.94 chars/token (+24%)
- 整体 token 效率最多提升 15%
但官方没说明为什么选 128K 而不是 64K 或 200K——这一步仍是工程对齐 GPT-4 cl100k base 的决策,而非独立优化。
增大 $V$ 的代价
vocab 大不只是 embedding 矩阵涨那么简单,训练稳定性也付代价:
- embedding + LM head 参数线性涨:两者各占 $V \cdot h$ 参数。$h$ = 8192 时,$V$ 从 50K 涨到 200K,单一矩阵从 400M 涨到 1.6B 参数。
- LM head 计算线性涨:每步前向最后是 $[h] \to [V]$ 矩阵乘,decode 阶段每个 token 都要算一次,对推理延迟影响明显。
- embedding 梯度不稳定:实测 embedding 层是最大梯度不稳定来源 (其他层的 100×)。原因是只有当前 batch 出现的 token 才有梯度,大 vocab 下 token 见到次数稀疏。
- Logits 显存爆炸:128K vocab + batch 8 + seq 2048 时 logits tensor 已是 4.2 GB,大模型训练这是一笔不容忽视的临时显存。
- 长尾 under-trained:0.1-1% 的词表 token 严重 under-trained[4],直接对应 glitch 现象。词表越大长尾越长。
实际三档典型
主流 LLM vocab 落在 32K-200K 范围,三档参考:
- 32K 左右 (Llama 1/2, Mistral):偏小,序列长但参数省,英文主导场景够用;按 Tao 2024 scaling law 看,大模型这一档其实偏低
- 100-130K (GPT-4 cl100k, Llama 3, DeepSeek-V3):中档,平衡多语言 / 代码 / 推理速度
- 150-200K (Qwen2, GPT-4o o200k):偏大,中文 / 代码 / 多语言场景下序列显著缩短,工程上对齐主流 tiktoken base
没有"最优 vocab",模型团队按训练数据分布、目标语言、推理预算综合决定。一个经验信号:训练数据每 token 平均代表字符数若 < 3.5,说明 vocab 太小,可考虑加大。
Takeaway
| 知识点 | 核心结论 |
|---|---|
| 单词级失败 | 词表爆炸 + OOV 必然发生 + 形态学丢失 |
| 字符级失败 | 序列长度 ×5+, attention 二次代价 ×25+,字符语义稀薄 |
| Subword 落点 | 子词单元同时控制词表与序列长度,BPE 是事实标准 |
| Pre-tokenization | regex 划合并边界 (BPE 不跨 chunk),GPT-2 → cl100k → o200k 三代演化主要在数字 chunking |
| BPE 训练 | 反复扫语料找最频繁相邻 pair → 合并 → 写入规则表,直到词表满;朴素 O(V·N),优化 O(N log N) |
| 特殊 token | 训练后手动追加,allowed_special 防 prompt injection |
| 字节级 BPE | 起点 256 字节,任何输入可逆 encode, GPT-2 起的事实标准 |
| 中文 token 效率 | Llama 2 SP 3-4/字 → tiktoken cl100k/o200k ~1/字 |
| Vocab scaling law | $N_v \propto N_{nv}^{0.83}$ (Tao 2024); Llama 2-70B 理论 216K 实际 32K,差 7× |
| Glitch token | 0.1-20% 词表 token under-trained,因 tokenizer 与模型训练数据分布不一致 |
| Vocab 取舍 | 大 → 序列短但 embedding 梯度 100× 不稳定 / logits GB 级 / 长尾 under-trained |
开放问题
- 超大 vocab 的边际收益:Qwen2 ~152K, GPT-4o ~200K,继续涨是否还有收益?按 Tao 2024 scaling law 大模型仍有空间,但训练稳定性代价随之上升,工程平衡点不明。
- Input / output vocab 解耦的潜力:Luong et al. 2025 / Wu et al. 2025 Over-Tokenized Transformer 提出 input vocab 可以远大于 output vocab[8] — 在 Llama / Qwen 已经主流 untie 的基础上更进一步,让两端连形状都不一致。还不是工业主流,但实证收益可观。
- Glitch token 的根治:当前对策都是缓解 (检测 + 替换 + 同源训练数据),没有从训练算法层根除的方案。
- 多模态 token 统一编码:文本 + 图像 + 音频用同一套 token 序列是否可行 (VQ-VAE / patch token),还在快速演化,本章不展开。
- BPE vs SentencePiece Unigram:多语言场景 Unigram 常被认为效果更好,但 BPE 凭工程简洁性占主流,学术上仍有争议。
延伸阅读 (本章外链)
- 位置编码 (token id 拿到后下一步注入位置信息) → 3.4 位置编码
- Token embedding (token id 怎么变成 $h$ 维向量) → 3.3 Token Embedding
- Karpathy 的 BPE 实战视频 (从 0 实现 GPT tokenizer, 2 小时) → https://www.youtube.com/watch?v=zduSFxRajkE
- Tiktoken 官方教学源码 → https://github.com/openai/tiktoken/blob/main/tiktoken/_educational.py
参考资料
- Sennrich, Haddow, Birch. Neural Machine Translation of Rare Words with Subword Units. ACL 2016. https://arxiv.org/abs/1508.07909
- Radford et al. Language Models are Unsupervised Multitask Learners (GPT-2). OpenAI Technical Report, 2019. https://cdn.openai.com/better-language-models/language_models_are_unsupervised_multitask_learners.pdf
- GlitchProber: Detection and Mitigation of Glitch Tokens in Large Language Models. 2024. https://arxiv.org/abs/2408.04905
- Land, Bartolo. Fishing for Magikarp: Automatically Detecting Under-trained Tokens in Large Language Models. EMNLP 2024. https://arxiv.org/abs/2405.05417
- Tao et al. Scaling Laws with Vocabulary. NeurIPS 2024. https://arxiv.org/abs/2407.13623
- Luong et al. Compute Optimal Tokenization (input/output vocab 解耦). 2025. https://arxiv.org/abs/2605.01188
- Meta AI. The Llama 3 Herd of Models. 2024. https://arxiv.org/abs/2407.21783
- Wu et al. Over-Tokenized Transformer. 2025. https://arxiv.org/abs/2501.16975