跳到主要内容

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-tokenizationBPE 训练 / 推理前用 regex 把输入预切成 chunk 的步骤,BPE 合并只在同一 chunk 内进行,不跨 chunk
bytes_to_unicodeGPT-2 tokenizer 的工程 hack,把 256 个字节值映射到 256 个可打印 Unicode 字符,让 BPE 能在 string 上跑而非 raw bytes
tiktokenOpenAI 自研的 Rust 实现 byte-level BPE 库,GPT-4 / Llama 3 / Qwen 等沿用;直接在 bytes 类型上操作,无需 bytes_to_unicode
Glitch token词表里存在但模型 embedding 没学好的 token;推理时 mention 它会触发模型乱码或异常输出
Under-trained tokenglitch 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"),流程是:

  1. 先跑 pre-tokenization regex 切 chunk
  2. 每个 chunk 拆成最细粒度 (字节)
  3. 按训练时学到的合并规则按顺序扫一遍,能合就合
    • 规则 (e, s) → 合并 → l o w es t
    • 规则 (es, t) → 合并 → l o w est
    • 规则 (l, o) → 合并 → lo w est
  4. 最终 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 token12121211

@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-3byte-level BPE (OpenAI 自研)50,257
Llama 1 / Llama 2SentencePiece BPE32,000
Llama 3 / Llama 4tiktoken (byte-level BPE)128,256
Qwen2 / Qwen2.5tiktoken (byte-level BPE)~152K
DeepSeek-V3byte-level BPE~128K
GPT-3.5 / GPT-4tiktoken cl100k_base100,277
GPT-4otiktoken o200k_base200,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-tokenizationregex 划合并边界 (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 token0.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 凭工程简洁性占主流,学术上仍有争议。

延伸阅读 (本章外链)

参考资料

  1. Sennrich, Haddow, Birch. Neural Machine Translation of Rare Words with Subword Units. ACL 2016. https://arxiv.org/abs/1508.07909
  2. 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
  3. GlitchProber: Detection and Mitigation of Glitch Tokens in Large Language Models. 2024. https://arxiv.org/abs/2408.04905
  4. Land, Bartolo. Fishing for Magikarp: Automatically Detecting Under-trained Tokens in Large Language Models. EMNLP 2024. https://arxiv.org/abs/2405.05417
  5. Tao et al. Scaling Laws with Vocabulary. NeurIPS 2024. https://arxiv.org/abs/2407.13623
  6. Luong et al. Compute Optimal Tokenization (input/output vocab 解耦). 2025. https://arxiv.org/abs/2605.01188
  7. Meta AI. The Llama 3 Herd of Models. 2024. https://arxiv.org/abs/2407.21783
  8. Wu et al. Over-Tokenized Transformer. 2025. https://arxiv.org/abs/2501.16975