Tokenization 分词器深度解析:BPE vs WordPiece vs SentencePiece
一、为什么Tokenization值得你花30分钟深入理解?
如果你曾好奇过这些问题,这篇文章就是为你写的:
- 为什么 ChatGPT 数数永远数不对(连 “strawberry 里有几个 r” 都会错)?
- 为什么同一个中文句子,用不同模型分词后的 token 数能差一倍?
- 为什么有些模型对英文支持完美,中文却频频产出乱码?
- 那个神秘的
<|endoftext|>标记到底是什么,为什么删掉它模型就疯了?
答案都藏在Tokenization(分词)这个看似"无聊"的预处理步骤里。它处于自然语言与大模型之间的最底层——决定了一句话如何被切割、编码、以及模型如何"看见"文本。Andrej Karpathy 在 2023 年的一期演讲中直言:“Tokenization 是我在大模型课程中最想强调的部分,因为几乎所有的诡异行为都可以追溯到它。”
本文将带你从零开始,深入掌握三大主流子词分词算法的原理、实现与实战权衡。读完你会理解:
- BPE 如何用"合并操作"从零构建词汇表
- WordPiece 如何在 BPE 基础上引入概率语言模型视角
- SentencePiece 如何解决多语言空格歧义问题
- 这三者分别在哪些顶流模型中使用(GPT / BERT / LLaMA)
- 亲手实现一个最小化 BPE tokenizer 并获得深刻直觉
二、Tokenization 的本质问题
2.1 从字符到子词的进化
给定一段文本,我们需要将其转换为模型可以处理的数字序列。历史上经历过三种范式:
| 阶段 | 粒度 | 示例 “我喜欢NLP” | 问题 |
|---|---|---|---|
| 字符级 | 每个字 | 我 / 喜 / 欢 / N / L / P | 序列太长,丢失语义 |
| 词级 | 每个词 | 我 / 喜欢 / NLP | 词汇表爆炸,OOV(未登录词)灾难 |
| 子词级 | 词根+词缀 | 我 / 喜欢 / N / LP | ✅ 最优平衡 |
子词分词**(subword tokenization)** 的核心理念:将高频词保持完整,将低频词拆分为可重用的子词单元。这样:
- 词汇表大小可控(通常 30k–256k)
- 任意新词都能通过子词组合表示(消灭 OOV)
- 模型可以利用子词间的形态学规律(如
play,playing,played共享play词干)
2.2 大模型中 Tokenizer 的关键角色
在大模型训练中,tokenizer 的影响被严重低估。它直接决定了:
- 上下文窗口利用率:同样一段话语,不同 tokenizer 产出的 token 数可能相差 2–3 倍。这对有 4K/8K/128K 窗口限制的模型至关重要。
- 推理成本:按 token 计费的 API(如 OpenAI GPT-4)中,tokenizer 的压缩效率直接转化为你的账单数字。
- 多语言公平性:英文通常每个词 ~1.3 token,而中文一个汉字可能就是 1-2 token,导致同等语义信息的中文 Token 成本远高于英文。
- 特殊能力边界:模型的拼写检查、数学计算、代码生成能力,都受限于 tokenizer 的粒度设计。
三、BPE(Byte Pair Encoding)—— GPT 系列的基石
3.1 直觉理解
BPE 最初是 1994 年提出的一种数据压缩算法,2016 年被 Sennrich 等人引入 NLP 用于解决机器翻译中的罕见词问题。其思路异常朴素:
从字符级开始,反复合并出现频率最高的相邻 token 对,直到达到目标词汇表大小。
想象你在压缩字符串 “aaabdaaabac”:
- 最常见相邻对是
aa(出现 4 次)→ 用Z替换 → “ZabdZabac” - 现在最常见相邻对是
ab(出现 2 次)→ 用Y替换 → “ZYdZYac” - …
在 NLP 中,“字符"被替换为"字节"或"Unicode 字符”,合并操作持续进行直到词汇表达到预设大小(如 GPT-2 的 50,257)。
3.2 训练算法(伪代码)
输入:语料库 C,目标词汇表大小 V 输出:合并规则列表 merges 1. 初始化:vocab = 所有唯一字符(或字节) 2. 将语料库表示为字符序列:corpus = [list(word) for word in C] 3. while |vocab| < V: a. 统计所有相邻 token 对的频率 b. 选出频率最高的 pair (A, B) c. 将 (A, B) 加入 merges d. 向 vocab 添加新 token "AB" e. 在 corpus 中将所有 A B 替换为 AB 4. 返回 merges3.3 手把手 Python 实现
下面是一个可运行的 BPE 训练器,约 100 行代码覆盖核心逻辑:
importrefromcollectionsimportCounter,defaultdictclassSimpleBPETokenizer:"""最小化 BPE tokenizer,展示核心训练和编码逻辑"""def__init__(self,vocab_size=300):self.vocab_size=vocab_size self.merges={}# (token_a, token_b) -> merged_tokenself.vocab=set()def_get_stats(self,corpus):"""统计语料库中所有相邻 token 对的频率"""pairs=defaultdict(int)forwordincorpus:foriinrange(len(word)-1):pairs[(word[i],word[i+1])]+=1returnpairsdef_merge_pair(self,pair,corpus):"""将指定 token 对在语料库中全部合并"""a,b=pair new_token=a+b new_corpus=[]forwordincorpus:new_word=[]i=0whilei<len(word):ifi<len(word)-1andword[i]==aandword[i+1]==b:new_word.append(new_token)i+=2else:new_word.append(word[i])i+=1new_corpus.append(new_word)returnnew_corpusdeftrain(self,texts):"""在文本列表上训练 BPE"""# 分词 + 在每个词后添加结束符words=[]fortextintexts:forwordintext