本文是 Andrej Karpathy 在「Let's build the GPT Tokenizer」课程的学习总结,涵盖 Tokenization 的核心原理、BPE 算法实现、主流库对比、工程实践及前沿方向。
一、为什么 Tokenization 如此重要又"讨厌"?
Tokenization 是大语言模型(LLM)中最不优雅但又必不可少的一步,很多模型的奇怪行为都源于它。它是文本到数字序列的桥梁,是所有大模型 pipeline 的第一步。
- 字符级分词:简单直接(如 65 个字符),但序列过长,效率低下。
- 词级分词:能缩短序列,但会遇到 OOV(未登录词)问题。
- 子词分词(BPE):折中方案,通过合并频繁出现的字符序列,在压缩序列长度的同时,有效处理未登录词。
老师吐槽:"希望未来能有一天,Tokenization 不再是 LLM 的必需步骤。"
二、Unicode、码点与字节级处理
在深入 BPE 之前,我们需要理解文本在计算机中的表示方式。
- 码点(Code Point) :每个 Unicode 字符的唯一数字标识,如字母
A是U+0041,汉字「你」是U+4F60。 - UTF-8 编码:将码点编码为 1-4 个字节,是文本在计算机中的实际存储方式。
- 字节级 BPE:GPT 系列分词器从字节(256 个)开始,而不是从字符开始。这样能处理任何 Unicode 字符,从根本上避免了 OOV 问题。
1. UTF-8 字节编码
这是最底层的编码方式,将任何文本都转换为 0~255 的字节序列。
- 优点:词汇表极小(仅 256 个 token),嵌入表非常小。
- 缺点 :
- 序列超级长:一个中文字符会被拆成 3 个字节,导致序列长度爆炸。
- 单个字节无意义:模型很难从零散的字节中学习到语言规律。
python
# 示例:将字符串编码为 UTF-8 字节
text = "안녕하세요 👋 (hello in Korean!)"
bytes_list = list(text.encode("utf-8"))
# 输出: [236, 149, 136, 235, 133, 149, ..., 101, 41]
2. 字符级 Tokenization
将每个可见字符(如 'a'、'你'、'!')作为一个独立的 token。
- 优点:序列比字节短,语义更清晰,实现简单。
- 缺点 :
- 词汇表大:多语言场景下可能达到数万甚至数十万。
- 生僻字处理困难:容易出现大量 OOV(Out-of-Vocabulary)问题。
python
# 字符级编码示例
chars = sorted(list(set(text)))
stoi = {ch:i for i, ch in enumerate(chars)} # 字符到数字的映射
itos = {i:ch for i, ch in enumerate(chars)} # 数字到字符的映射
encode = lambda s: [stoi[c] for c in s]
decode = lambda l: ''.join([itos[i] for i in l])
三、BPE 算法核心原理与手动实现
BPE(Byte Pair Encoding)是 GPT 分词器的核心算法,其本质是一种数据压缩技术,通过合并高频相邻 token 对,在字节和字符之间找到了极佳的平衡点。
1. 算法步骤
- 初始化词汇表:从所有单个字节(256 个)开始。
- 统计频率:遍历文本,统计所有相邻字节对的出现频率。
- 合并最频繁对:将出现次数最多的字节对合并为一个新的 token,并加入词汇表。
- 迭代:重复步骤 2-3,直到词汇表达到预设大小(如 50257)。
2. 核心代码实现
以下是老师在课上实现的极简 BPE 算法核心函数,以及完整的训练、编码、解码流程:
python
from collections import defaultdict
def get_stats(ids):
"""统计相邻 token 对的出现频率"""
counts = {}
for pair in zip(ids, ids[1:]):
counts[pair] = counts.get(pair, 0) + 1
return counts
def merge(ids, pair, idx):
"""将文本中的指定 token 对合并为新的 token idx"""
newids = []
i = 0
while i < len(ids):
if i < len(ids) - 1 and ids[i] == pair[0] and ids[i+1] == pair[1]:
newids.append(idx)
i += 2
else:
newids.append(ids[i])
i += 1
return newids
# 训练 BPE
def train_bpe(text, vocab_size=512):
tokens = list(text.encode("utf-8"))
merges = {}
id_to_token = {i: bytes([i]) for i in range(256)}
for new_id in range(256, vocab_size):
stats = get_stats(tokens)
if not stats:
break
pair = max(stats, key=stats.get)
merges[pair] = new_id
tokens = merge(tokens, pair, new_id)
id_to_token[new_id] = id_to_token[pair[0]] + id_to_token[pair[1]]
return merges, id_to_token
# BPE 编码
def encode_bpe(text, merges):
tokens = list(text.encode("utf-8"))
while True:
stats = get_stats(tokens)
# 找到下一个要合并的对
candidates = [p for p in stats if p in merges]
if not candidates:
break
# 选择合并顺序最早的对
pair = min(candidates, key=lambda p: merges[p])
new_id = merges[pair]
tokens = merge(tokens, pair, new_id)
return tokens
# BPE 解码
def decode_bpe(tokens, id_to_token):
byte_parts = [id_to_token[t] for t in tokens]
full_bytes = b''.join(byte_parts)
return full_bytes.decode("utf-8")
四、GPT-2 / GPT-4 分词器的工程实现
工业级的分词器并非简单的 BPE,而是在其基础上增加了许多优化和规则,以适应复杂的真实世界文本。
- 正则预分割 :在 BPE 之前,使用正则表达式
(?i:'s|'t|'re|'ve|'m|'ll|'d)|[^\r\n\p{L}\p{N}]?\p{L}+|\p{N}{1,3}| ?[^\s\p{L}\p{N}]+[\r\n]*|\s*[\r\n]+|\s+(?!\S)|\s+将文本分割成不同类别(字母、数字、标点等),防止跨类别合并,使分词结果更符合人类直觉。 - 特殊 Token :如
<|endoftext|>等特殊符号被直接加入词汇表,用于控制模型生成。 - 字节回退(Byte fallback):当遇到词汇表外的字符时,分词器会将其拆分为单个字节进行编码,确保任何文本都能被处理。
五、主流分词库对比
在实际应用中,我们通常不会从零实现分词器,而是选择成熟的开源库。
| 特性 | Tiktoken (OpenAI) | SentencePiece (Google) | minBPE (Karpathy) |
|---|---|---|---|
| 开发者 | OpenAI | Andrej Karpathy | |
| 核心优势 | 专为 GPT 系列优化,速度极快 | 支持多种算法,语言无关,可训练 | 极简实现,代码清晰,适合教学 |
| 算法支持 | BPE | BPE, Unigram, Word, Char | BPE |
| 训练能力 | 不支持,仅使用预训练词汇表 | 支持在原始文本上训练 | 支持 |
| 配置复杂度 | 低 | 高(配置项多,如 character_coverage) |
低 |
SentencePiece 配置示例(来自课程)
python
import sentencepiece as spm
import os
options = {
# output spec
"model_prefix": "tok400",
# algorithm spec
# BPE alg
"model_type": "bpe",
"vocab_size": 400,
# normalization
"normalization_rule_name": "identity",
"remove_extra_whitespaces": False,
"input_sentence_size": 200000000,
"max_sentence_length": 4192,
"seed_sentencepiece_size": 1000000,
"shuffle_input_sentence": True,
# rare word treatment
"character_coverage": 0.99995,
"byte_fallback": True,
# merge rules
"split_digits": True,
"split_by_unicode_script": True,
"split_by_whitespace": True,
"split_by_number": True,
"max_sentencepiece_length": 16,
"add_dummy_prefix": True,
"allow_whitespace_only_pieces": True,
# special tokens
"unk_id": 0,
"bos_id": 1,
"eos_id": 2,
"pad_id": -1,
# systems
"num_threads": os.cpu_count(),
}
spm.SentencePieceTrainer.train(**options)
六、词汇表大小(vocab_size)的选择
词汇表大小是一个重要的超参数,需要在效率和性能之间权衡。
- 常见经验值 :
- 小模型(<10B):3万-5万
- 中大型模型(10B-70B):20万左右
- GPT-4:约10万
- 权衡 :
- 词汇表大 → 序列短 → 模型效率高,但 embedding 层参数也会变多。
- 词汇表小 → 序列长 → 模型效率低,但 embedding 层更紧凑。
- 如何增大词汇表:重新在更大的语料上训练 BPE,或在现有词汇表基础上继续合并。
七、Tokenization 带来的"坑"与案例
Tokenization 有很多隐藏的陷阱,稍不注意就会导致模型行为异常。
- Python 代码分词问题:GPT-2 对 Python 代码的分词很糟糕,因为代码中的符号和结构在训练语料中不常见。
- 特殊字符串陷阱 :如
<|endoftext|>会被直接识别为特殊 token,导致文本截断。 - 尾随空格 :分词器对空格很敏感,
"hello"和"hello "可能被分成不同的 token。 - "SolidGoldMagikarp":这个奇怪的字符串在 GPT-2 词汇表中,是训练数据中的噪声,导致模型对它有奇怪的反应。
八、前沿方向:无分词 (Tokenization-Free)
尽管 BPE 是目前的主流,但研究者们一直在探索摆脱分词器的方法。
1. 为什么要无分词?
- 避开分词器的坑:分词器存在多语言偏见、安全隐患和实现复杂性。
- 真正端到端:让模型自己学习如何最优地切分和理解文本。
- 大一统建模:可以直接处理文本、音频、图像等所有形式的字节流。
2. 代表工作:MEGABYTE
- 提出了一种多尺度 Transformer 架构,直接对百万级字节序列进行建模。
- 通过将长序列分块(patch),使用局部模型处理块内字节,全局模型处理块间关系,解决了序列过长的问题。
- 证明了大规模无分词自回归序列建模的可行性。
九、最终建议与总结
1. 不要轻视 Tokenization
它有很多隐藏的陷阱,涉及安全和性能问题。
2. 应用建议
- 优先复用:在自己的应用中,优先复用 GPT-4 的 tiktoken,避免自己造轮子。
- 训练词汇表:如果必须训练,使用 SentencePiece + BPE,小心配置参数。
- 关注发展:期待 minBPE 未来能达到 SentencePiece 的效率,成为更简洁的选择。
老师总结:"Tokenization 是 LLM 中最不优雅但又必不可少的一步,很多模型的奇怪行为都源于它。希望未来能有一天,Tokenization 不再是必需步骤。"