阶段 2:Tokenizer 原理与实现
教学目标
- 理解 Tokenizer 在 LLM 中的核心作用
- 掌握 BPE(Byte Pair Encoding)算法的原理
- 理解 Byte-Level BPE 的优势与实现
- 了解 MiniMind Tokenizer 的词表设计和特殊 Token
- 能够独立使用 Tokenizer 进行文本编码/解码实验
课时安排
- 理论讲解:1.5 课时(分词原理 + BPE 算法)
- 上机实践:0.5 课时(编码/解码实验 + 分析)
2.1 为什么需要 Tokenizer?
2.1.1 从文本到数字
大语言模型的输入输出都是数字(整数索引) ,但人类使用的是自然语言文本。Tokenizer 就是连接两者的"翻译器":
plain
原始文本 模型输入
"Transformer 是一种 → [2341, 589, 1023, 87,
深度学习模型" 4521, 312, 7689]
│
词表索引(vocab_size=6400)
2.1.2 Tokenizer 的三大核心任务
| 任务 | 说明 | 示例 |
|---|---|---|
| 编码(Encode) | 文本 → Token 序列 → 整数序列 | "你好世界" → "你", "好", "世", "界" → 234, 567, 89, 12 |
| 解码(Decode) | 整数序列 → Token 序列 → 文本 | 234, 567, 89, 12 → "你好世界" |
| 词表管理 | 维护 token → index 的映射 | {"你": 234, "好": 567, ...} |
2.1.3 Tokenizer 设计的核心矛盾
| 方案 | 优点 | 缺点 |
|---|---|---|
| 字符级(Character) | 词表极小(几千),无 OOV 问题 | 序列太长,模型难以捕获长距离依赖 |
| 词级(Word) | 序列较短,语义完整 | 词表巨大(数十万),大量 OOV(未登录词) |
| 子词级(Subword / BPE) | 平衡词表大小和序列长度 | 需要训练,实现稍复杂 |
教学提示:MiniMind 选择的是**子词级(BPE)**方案,这也是当前主流 LLM(GPT、LLaMA、Qwen 等)的共同选择。
2.2 BPE(Byte Pair Encoding)算法原理
2.2.1 BPE 核心思想
BPE 是一种数据驱动的子词切分算法,通过不断合并语料中出现频率最高的相邻 token 对,逐步构建词表。
核心直觉:经常一起出现的字符组合应该被合并为一个 token。
2.2.2 BPE 算法步骤(训练阶段)
以一个简单语料为例:
plain
语料:"low lower newest widest"
初始词序列(字符级):
l o w </w> l o w e r </w> n e w e s t </w> w i d e s t </w>
Step 1:统计所有相邻字符对的频率
| 字符对 | 频率 |
|---|---|
| e, s | 3 |
| t, | 2 |
| s, t | 2 |
| l, o | 2 |
| o, w | 2 |
| w, | 1 |
| ... | ... |
Step 2 :合并频率最高的字符对 → es 成为一个新 token
plain
l o w </w> l o w e r </w> n e w es t </w> w i d es t </w>
词表新增:es
Step 3 :重新统计,合并下一个最高频对 → est
plain
l o w </w> l o w e r </w> n e w est </w> w i d est </w>
词表新增:est
Step 4 :继续合并 → lo、low、low</w>、new、newest</w> ...
plain
low</w> low e r </w> newest</w> w i d est</w>
Step 5:重复直到达到目标词表大小
最终词表包含:所有单个字符 + 所有合并产生的新 token
2.2.3 BPE 编码过程(推理阶段)
对新文本进行切分时,按照训练得到的合并规则从低频到高频逐步应用:
plain
输入:"lowest"
1. 初始字符分解:l o w e s t </w>
2. 查表,es 存在?是 → l o w es t </w>
3. 查表,est 存在?是 → l o w est </w>
4. 查表,lo 存在?是 → lo w est </w>
5. 查表,low 存在?是 → low est </w>
6. 查表,lowest 存在?否 → 停止
编码结果:[low, est, </w>] → [4521, 312, 7689]
2.2.4 BPE 算法伪代码
python
def train_bpe(corpus, vocab_size):
# 初始化:将所有词拆分为字符序列
word_freqs = count_words(corpus) # {"l o w </w>": 1, ...}
vocab = set(all_chars(corpus)) # 初始词表 = 所有单个字符
while len(vocab) < vocab_size:
# 统计所有相邻对的频率
pairs = count_pair_freqs(word_freqs)
# 找出最高频的相邻对
best_pair = max(pairs, key=pairs.get)
# 将该对合并为新 token
new_token = best_pair[0] + best_pair[1]
vocab.add(new_token)
# 在所有词中执行合并
word_freqs = merge_pair(best_pair, word_freqs)
return vocab, merge_rules
2.3 Byte-Level BPE
2.3.1 为什么选择 Byte-Level?
传统 BPE 直接在字符级别工作,存在一个问题:不同语言的字符集差异巨大。
| 语言 | 字符集大小 | 问题 |
|---|---|---|
| 英文 | 128(ASCII) | 字符集小,容易处理 |
| 中文 | ~20,000 常用字 | 字符集巨大,词表膨胀 |
| 日文/韩文/emoji | 数千种特殊字符 | 混合文本处理困难 |
Byte-Level BPE 的解决方案:不再以 Unicode 字符为单位,而是以**字节(Byte,0-255)**为单位。
2.3.2 Byte-Level BPE 核心优势
| 优势 | 说明 |
|---|---|
| 词表基础固定 | 基础词表只有 256 个字节 token,无需为每种语言维护单独字符集 |
| 无 OOV 问题 | 任何文本(包括 emoji、代码、特殊符号)都可以被编码 |
| 多语言统一 | 中英文混合文本使用同一套词表,无需额外处理 |
| 与模型输入兼容 | 模型看到的始终是固定范围的整数(0-255 或合并后的更大范围) |
2.3.3 Byte-Level BPE 工作流程
plain
原始文本:"你好,Hello!"
│
▼ UTF-8 编码
字节序列:[228, 189, 160, 229, 165, 189, 239, 188, 140, 72, 101, 108, 108, 111, 33]
│
▼ BPE 合并(训练阶段学习到的合并规则)
Token 序列:[228, 189, 160, 229, 165, 189, 239, 188, 140, 72, "ell", "o", 33]
│
▼ 查词表映射为整数索引
整数序列:[142, 78, 203, 156, 312, 445, 789, 45, 2341, 567, 12]
2.4 MiniMind Tokenizer 详解
2.4.1 词表设计
MiniMind 使用自定义的 minimind_tokenizer,关键参数:
| 参数 | 值 | 说明 |
|---|---|---|
| 词表大小(vocab_size) | 6400 | 极简设计,远小于主流模型(通常 32K-128K) |
| 编码方式 | Byte-Level BPE | 基于字节的子词切分 |
| 特殊 Token | __(PAD)、`< |
endoftext |
| 思考 Token | minimum、sorry、tool_calls |
用于自适应思考和工具调用 |
教学提示:6400 的词表在主流 LLM 中非常小(GPT-4 约 100K,LLaMA 约 32K)。这是 MiniMind "大道至简" 理念的体现,但也意味着每个 token 携带的信息更少,序列更长。
2.4.2 中文/英文压缩比
| 语言 | 平均字符/token 比 | 说明 |
|---|---|---|
| 中文 | 1.5~1.7 字符/token | 一个中文字通常需要 2-3 个 UTF-8 字节,经 BPE 合并后平均 1.5-1.7 字符对应 1 个 token |
| 英文 | 4~5 字符/token | 英文单词常见组合被 BPE 合并为完整 token |
示例:
python
text_zh = "Transformer通过自注意力机制建模上下文关系"
tokens_zh = tokenizer.encode(text_zh)
# token 数量:约 20 个
# 字符数:19
# 压缩比:19/20 ≈ 0.95 字符/token
text_en = "Transformer models context relationships through self-attention"
tokens_en = tokenizer.encode(text_en)
# token 数量:约 8 个
# 字符数:64(含空格)
# 压缩比:64/8 = 8 字符/token
2.4.3 Tokenizer 代码结构
MiniMind 的 Tokenizer 相关代码通常位于项目中的 tokenizer 模块:
plain
tokenizer/
├── minimind_tokenizer.py # Tokenizer 核心实现
├── vocab.json # 词表文件(token → id 映射)
├── merges.txt # BPE 合并规则文件
└── special_tokens.json # 特殊 Token 定义
2.5 Tokenizer 实践实验
2.5.1 基础编码/解码
python
from tokenizer.minimind_tokenizer import MiniMindTokenizer
# 初始化 Tokenizer
tokenizer = MiniMindTokenizer()
# 编码:文本 → 整数序列
text = "大语言模型是人工智能的重要发展方向"
token_ids = tokenizer.encode(text)
print(f"原始文本: {text}")
print(f"Token IDs: {token_ids}")
print(f"Token 数量: {len(token_ids)}")
# 解码:整数序列 → 文本
decoded_text = tokenizer.decode(token_ids)
print(f"解码文本: {decoded_text}")
print(f"编解码一致性: {text == decoded_text}")
2.5.2 查看分词结果
python
# 查看文本被切分为哪些 token
text = "深度学习DeepLearning"
tokens = tokenizer.tokenize(text) # 获取 token 字符串
print(f"分词结果: {tokens}")
# 预期输出类似: ['深', '度', '学', '习', 'Deep', 'Learning']
# 查看 token 对应的 ID
for token in tokens:
token_id = tokenizer.token_to_id(token)
print(f" '{token}' -> {token_id}")
2.5.3 特殊 Token 实验
python
# 查看所有特殊 Token
special_tokens = tokenizer.special_tokens
print(f"特殊 Token: {special_tokens}")
# 构造对话格式的输入
user_text = "你好,请介绍一下自己"
assistant_text = "我是MiniMind,一个小型语言模型"
# 模拟对话编码
conversation_ids = tokenizer.encode_conversation([
{"role": "user", "content": user_text},
{"role": "assistant", "content": assistant_text}
])
print(f"对话编码 IDs: {conversation_ids}")
print(f"Token 数量: {len(conversation_ids)}")
2.5.4 压缩比统计实验
python
import json
test_texts = {
"中文短句": "你好世界",
"中文长句": "Transformer通过自注意力机制建模上下文关系,是自然语言处理的核心架构",
"英文短句": "Hello World",
"英文长句": "Transformer models context relationships through self-attention mechanism",
"代码片段": "def quick_sort(arr):\n if len(arr) <= 1: return arr",
"中英混合": "使用PyTorch框架实现Transformer模型的训练",
"数学公式": "交叉熵损失函数 H(p,q) = -Σ p(x) log q(x)",
}
print(f"{'文本类型':<10} {'字符数':<8} {'Token数':<8} {'压缩比':<10}")
print("-" * 40)
for name, text in test_texts.items():
char_count = len(text)
token_count = len(tokenizer.encode(text))
ratio = char_count / token_count
print(f"{name:<10} {char_count:<8} {token_count:<8} {ratio:<10.2f}")
2.5.5 词表分析实验
python
# 加载词表
vocab = tokenizer.get_vocab() # 返回 {token: id} 字典
# 基本统计
total_tokens = len(vocab)
print(f"词表总大小: {total_tokens}")
# 分析 token 长度分布
from collections import Counter
length_dist = Counter(len(token) for token in vocab.keys())
print(f"\nToken 长度分布:")
for length in sorted(length_dist.keys()):
count = length_dist[length]
pct = count / total_tokens * 100
print(f" 长度 {length}: {count} ({pct:.1f}%)")
# 查找最长的 token
longest_tokens = sorted(vocab.keys(), key=len, reverse=True)[:10]
print(f"\n最长的 10 个 token:")
for token in longest_tokens:
print(f" '{token}' (id={vocab[token]})")
# 查看特殊 Token
print(f"\n特殊 Token:")
for token, token_id in sorted(vocab.items()):
if token.startswith('<') or token in ['__']:
print(f" '{token}' -> {token_id}")
2.6 Tokenizer 与训练的关系
2.6.1 Tokenizer 对模型的影响
| 影响维度 | 大词表(32K+) | 小词表(6400) |
|---|---|---|
| 序列长度 | 较短 | 较长(同样文本需要更多 token) |
| Embedding 层参数 | 多(32K × dim) | 少(6400 × dim) |
| 训练数据效率 | 高(每个 token 信息密度高) | 低 |
| 覆盖语言能力 | 广(多语言) | 窄(主要中文) |
| 推理速度 | 快(序列短) | 慢(序列长) |
2.6.2 MiniMind 词表对 Embedding 参数的影响
plain
Embedding 参数量 = vocab_size × dim
= 6400 × 768
= 4,915,200
≈ 4.9M
占总参数量的比例:
4.9M / 64M ≈ 7.7%
教学思考:如果词表从 6400 扩大到 32000,Embedding 层参数将从 4.9M 增加到 24.6M,增加约 19.7M 参数。这说明词表大小对模型总参数量有显著影响。
实践任务
任务 2.1:BPE 算法手写实现(必做)
根据 2.2.4 的伪代码,用 Python 从零实现一个简化版 BPE 训练器:
python
# 要求:
# 1. 输入:一个文本列表(如 ["low", "lower", "newest", "widest"])
# 2. 输出:训练后的词表和合并规则
# 3. 支持对新文本进行编码
# 4. 打印每一步合并的结果
# 提示步骤:
# - 将每个词拆分为字符 + 结尾标记
# - 统计字符对频率
# - 循环合并最高频对
# - 输出最终词表
任务 2.2:MiniMind Tokenizer 实验(必做)
完成 2.5 节中的所有实验,记录并分析结果:
- 中英文不同文本的编码/解码结果
- 不同类型文本的压缩比统计
- 词表长度分布分析
- 特殊 Token 的使用方式
思考题:
- 为什么中文的压缩比(字符/token)远低于英文?
- MiniMind 6400 的词表大小对中文处理有什么影响?会带来什么问题?
任务 2.3:词表覆盖率分析(选做)
python
# 使用 MiniMind 的预训练数据集,统计:
# 1. 数据集中出现的所有 token 的覆盖率
# 2. 是否有大量 OOV(未登录词)
# 3. 词表中哪些 token 的使用频率最高
# 4. 是否存在"死 token"(从未被使用的 token)
# 提示:读取 pretrain_t2t_mini.jsonl,逐行编码,统计频率
挑战任务(选做)
- 对比 MiniMind Tokenizer 与 LLaMA Tokenizer 对同一段中文文本的编码结果差异
- 尝试用 SentencePiece 训练一个 32000 词表的 Tokenizer,对比效果
知识拓展
BPE vs WordPiece vs SentencePiece
| 算法 | 使用者 | 核心区别 |
|---|---|---|
| BPE | GPT 系列、MiniMind | 基于频率贪心合并相邻对 |
| WordPiece | BERT 系列 | 基于语言模型似然度合并(而非频率) |
| SentencePiece | LLaMA、T5 | 将文本视为 Unicode 字节序列,直接训练(无需预分词) |
主流 LLM 的 Tokenizer 对比
| 模型 | 词表大小 | 编码方式 | 中文效率 |
|---|---|---|---|
| GPT-4 | ~100K | Byte-Level BPE (tiktoken) | 1.5 字符/token |
| LLaMA 2 | 32K | SentencePiece (BPE) | 1.5 字符/token |
| Qwen2 | 151K | tiktoken (BPE) | 1.5 字符/token |
| MiniMind | 6400 | Byte-Level BPE | 1.5-1.7 字符/token |
下一阶段预告
在阶段 3 中,我们将进入 MiniMind 的核心------Transformer 架构。从 RMSNorm 到 RoPE,从 GQA 到 SwiGLU,逐组件拆解 Transformer Decoder-Only 模型的每个细节,并尝试从零手写一个简化版的 Transformer Block。