Tokenization:LLM 的原子单位
"如果你不理解 Tokenization,你就无法理解为什么 LLM 在简单任务上会失败。"
Tokenization 是将原始文本转换为模型可处理的整数序列(ID)的过程。它是整个流水线的第一步,也是许多与数学、拼写和代码相关的"幻觉"的根源。
为什么需要 Tokenization?
计算机理解数字,而不是字符串。我们需要一种将文本映射到数字的方法。
粒度范围
我们可以在不同层级进行分词:
| 方法 | 词表大小 | 序列长度 | 优点 | 缺点 |
|---|---|---|---|---|
| 字符级 | 小(~100-256) | 很长 | 没有 OOV(词表外)问题 | 上下文窗口很快填满;单个字符缺乏语义 |
| 词级 | 巨大(1M+) | 短 | 语义丰富 | "稀有词"问题;巨大的 Embedding 矩阵参数 |
| 子词级(BPE) | 最优(~32k-100k) | 中等 | 平衡效率与灵活性 | 实现复杂性 |
现代 LLM 普遍使用子词 Tokenization(特别是 BPE 或其变体)。
2025 年 Tokenization 现状
关键发展:
- 字节级 BPE 已成为标准(GPT-4o、Llama 3/4)——处理所有 Unicode,无 OOV 错误
- tiktoken 主导地位:OpenAI 的分词器比替代方案快 3-6 倍,成为事实标准
- 多语言优化:SentencePiece 的 Unigram 在形态丰富的语言上优于 BPE
- 效率提升:BlockBPE 和并行分词用于更快推理
字节对编码(BPE)
工作原理
BPE 是一种迭代算法,从字符开始,不断合并最频繁的相邻 token 对。
- 初始化:词表 = 所有单个字符(或字节级 BPE 中的字节)
- 计数:找出语料库中最频繁的相邻 token 对(如 "e" 和 "r" → "er")
- 合并:为该对创建新 token
- 重复:继续直到达到目标词表大小(如 32k)
交互式示例
考虑语料库:["hug", "pug", "pun", "bun"]
- 开始:
h, u, g, p, n, b - 最频繁对:
u+g→ug - 新状态:
h, ug, p, n, b(新增ugtoken) - 下一个频繁对:
u+n→un - 最终 token:
ug、un、h、p、b
现在,hug 被编码为 [h, ug]。
字节级 BPE(2025 年标准)
问题:传统字符级 BPE 的基础词表约有 ~100K 字符(所有 Unicode),仍可能遇到 OOV 错误。
解决方案:字节级 BPE 直接操作 UTF-8 字节:
- 基础词表:256 个字节(覆盖所有 Unicode,无 OOV)
- 示例:"é" 可以是
195, 169(两个字节)或如果被学到合并则为单字节
为什么重要:
import tiktoken
enc = tiktoken.encoding_for_model("gpt-4o")
# 处理任何 Unicode 无错误
print(enc.encode("你好世界")) # 中文: [32409, 30255, 9892, 162]
print(enc.encode("こんにちは")) # 日文: [32864, 25669, 32465, 27414, 28821]
print(enc.encode("مرحبا")) # 阿拉伯文: [2174, 1945, 10982, 2686]
采用情况:
- GPT-2/3/4:字节级 BPE
- Llama 3/4:通过 tiktoken 使用 GPT-4 分词器
- Claude:自定义字节级 BPE 变体
Python 实现(简化版)
def get_stats(vocab):
pairs = collections.defaultdict(int)
for word, freq in vocab.items():
symbols = word.split()
for i in range(len(symbols)-1):
pairs[symbols[i],symbols[i+1]] += freq
return pairs
def merge_vocab(pair, v_in):
v_out = {}
bigram = re.escape(' '.join(pair))
p = re.compile(r'(?<!\S)' + bigram + r'(?!\\S)')
for word in v_in:
w_out = p.sub(''.join(pair), word)
v_out[w_out] = v_in[word]
return v_out
算法对比:BPE vs WordPiece vs Unigram
面试中需要了解这三种算法的区别。
| 特性 | BPE(GPT-2/3/4、Llama) | WordPiece(BERT) | Unigram(T5、ALBERT) |
|---|---|---|---|
| 合并策略 | 确定性:合并最频繁的 pair | 概率性:合并提升数据似然的 pair(PMI) | 概率性:从大规模开始,剪枝最不有用的 token |
| 理念 | 自底向上(字符 → 子词) | 自底向上 | 自顶向下(所有子串 → 保留最佳) |
| 正则化 | 无(确定性) | 无 | 子词正则化:训练期间可采样不同分割(增加噪声/鲁棒性) |
| 词表初始化 | 小(字符/字节)→ 增长 | 小 → 增长 | 大(所有子串)→ 缩小 |
| Token 选择 | 基于频率 | 基于 PMI(点互信息) | 基于概率(unigram 语言模型) |
| 繁殖力(平均 token/词) | 中等(~2.5-3.0) | 高(~3.0-3.5) | 低(~2.0)——最佳压缩 |
| 形态学 | 可解释性 较差 | 中等 | 最佳——产生更具形态可解释性的 token |
| 库 | tiktoken、HuggingFace | HuggingFace | SentencePiece(默认) |
2025 年研究洞察
Unigram 在形态保持上优于 BPE:
- Bostrom & Durrett (2020):Unigram 产生更具形态可解释性的 token
- 示例:
destabilizing→ Unigram:de + stabilizing,BPE:dest + abil + iz + ing - 下游影响:在 Unigram token 上训练的模型微调性能更好
何时使用哪种:
- BPE:默认选择,高效,广泛采用(GPT、Llama)
- WordPiece:BERT 风格模型,需要基于 PMI 的合并时
- Unigram:多语言模型,形态丰富的语言(阿拉伯语、土耳其语、芬兰语),压缩重要时
注意:大多数生成式模型(GPT 系列、Llama)使用 BPE,因为它是标准且高效的。T5 使用 SentencePiece(Unigram),处理多语言文本稍好。
"Strawberry" 问题
为什么 GPT-4 无法数清 "Strawberry" 中的 'r'?
答案:因为它从未看到单词 "Strawberry"。 它看到的是 token ID。
import tiktoken
enc = tiktoken.encoding_for_model("gpt-4o")
print(enc.encode("Strawberry"))
# 输出: [9241, 8075] -> 对应 ["Straw", "berry"]
模型接收到 [ID_1, ID_2]。
ID_1("Straw")向量:包含"干草杆"、"吸管"的语义概念ID_2("berry")向量:包含"小水果"的语义概念
除非模型在训练期间记住了每个 token ID 的拼写(它试图这样做,但不完美),否则它无法"数"字母。
面试启示:
- 不要要求 LLM 在没有工具的情况下执行字符级操作(反转字符串、密码)
- 这是根本性的架构限制,不仅仅是"训练不好"
- 变通方案:使用工具/代码进行字符级任务,而非原始 LLM 推理
2025 年更新:Strawberry 基准测试
不同分词器的处理方式不同:
# GPT-4o (cl100k_base)
# "Strawberry" -> [9241, 8075] (["Straw", "berry"])
# 无法数清 r:3 个 token,'r' 分散在其中
# Llama 3(使用 GPT-4 分词器)
# "strawberry" -> [49607, 8698, 11, 8205] (["str", "aw", "berry", "."])
# 仍然被分割,但边界不同
# Claude 3.5(自定义分词器)
# "strawberry" -> [9900, 12072, 9177] (["str", "aw", "berry"])
# 类似的限制
没有现代分词器能解决这个问题——这是子词 Tokenization 的固有特性。
技术深入
1. 预分词
BPE 运行前,文本会被规范化。
Unicode 规范化:
- NFC(规范化组合):
é作为单个字符(U+00E9) - NFD(规范化分解):
e+´(U+0065 U+0301) - 影响:影响分词边界和词表大小
分割规则:
- GPT-4 在
'(撇号)和空格上分割 - 确保标点符号一致处理
- 示例:
"don't"→["don", "'", "t"]或["do", "n't"],取决于训练
2. 空格处理
不同分词器的方法不同:
| 分词器 | 空格表示 | 示例 |
|---|---|---|
| SentencePiece(Llama/T5) | 将空格视为字符(通常是 _ 或 <0x20>) | " Hello" → _Hello |
| Tiktoken(GPT) | 空格是 token 的一部分 | " Hello" → Hello |
| WordPiece(BERT) | 使用 ## 表示续接 | " Hello" → Hello(无前导空格 token) |
影响:" hello" 和 "hello" 有不同的 ID。这就是为什么提示对尾部空格很敏感。
2025 年更新:
- 大多数现代分词器使用字节级 BPE,空格只是字节
0x20 - 避免特殊处理,跨语言更一致
3. 词表大小权衡
为什么不使用 100 万个 token?
Embedding 矩阵大小:
- 100k 词表,4096 维 = 仅 Embedding 就有 400M 参数!
- 32k 词表,4096 维 = 131M 参数
边际收益递减:
- 稀有 token 出现频率太低,模型学不到好的 Embedding
- 最优范围:大多数模型 32k-100k
- Llama 2:32k 词表
- GPT-2:50k 词表
- GPT-4o:100k 词表(cl100k_base)
- Llama 3:128k 词表
2025 年研究:
- Ali 等人(2024):33k 和 50k 词表在英文任务上优于更大词表
- 多语言权衡:多语言模型需要更大词表(100k+)
- 特定领域:代码模型受益于更大词表(150k+ 用于编程 token)
4. 各语言的 Token 效率
并非所有语言的分词效率相同:
| 语言 | 每 token 词汇数(约) | 效率 |
|---|---|---|
| 英语 | 0.75-1.0 tokens/词 |