TL;DR
- BPE:自底向上合并,每一步只看相邻 token 对的频率,谁出现得多就合并谁,简单高效,GPT 系常用。
- WordPiece:同样自底向上合并,但决策依据换成似然增益(
P(A,B) / (P(A)·P(B))),更看重合并后片段是否“语义凝聚”,BERT 系常用。 - Unigram:方向相反,自顶向下删减,先建一个超大词表,再逐步删掉“删了之后似然损失最小”的 token,T5/Gemma 系常用。
- SentencePiece:不是算法而是框架,内部可跑 BPE 或 Unigram,把空格转义为
▁直接在原始文本上训练,做到语言无关、无损可逆。 - 一句话:BPE / WordPiece / Unigram 是三种词表构建策略(频率贪心 / 似然贪心 / 概率删减),SentencePiece 是把它们工程化的容器。
目录
- BPE:自底向上,按“频率”贪心合并
- WordPiece:自底向上,按“似然增益”择优合并
- Unigram:自顶向下,按“概率贡献”逐步删减
- SentencePiece:不是算法,而是承载算法的“端到端框架”
- 四者关系一句话总结
- 训练产物
- (补充)字符、ASCII、Unicode、UTF-8、字节、Token ID 的关系
- 4种常用分词器对比表
分词器的任务不是简单地“切词”,而是在字符与完整词之间,找到一种可复用、可组合的中间单元。DIY-LLM 教程用“快递分拣中心”来比喻这个过程,理想的切分像incredible → [in, credible]这样,既不过于碎片化,也不过于笼统。BPE、WordPiece、Unigram 本质上都是在用不同的“迭代策略”寻找这种平衡,而 SentencePiece 则是承载这些策略的一个工程框架。下面结合具体例子,逐一拆解它们的迭代逻辑。
tokenizer 与 LLM
四种分词算法
BPE:自底向上,按“频率”贪心合并
核心迭代逻辑
BPE 从字符(或字节)级别开始,把每个字符视为一个独立 token。然后反复扫描语料,统计相邻 token 对的出现频次,每次合并频次最高的那一对,生成一个新 token,直到词表达到预设大小或不再有可合并的对。
例子:合并low/lower/newest/widest
假设语料中newest出现 6 次,widest出现 3 次。初始时所有单词都被拆成字符,词边界用</w>标记。BPE 统计相邻字符对,发现e和s的组合出现了 9 次(来自newest的es和widest的es),是所有相邻对中频率最高的。于是第一次迭代将e + s → es合并。下一次迭代再统计时,es和t的组合频率最高(同样是 9 次),继续合并为est。如此迭代,高频片段会逐渐“长”成更大的子词单元。
Python 实现:手写一遍 BPE 合并过程
下面用一段可运行的代码,把上面的迭代逻辑完整走一遍:初始化词表 → 统计相邻对频次 → 合并最高频对 → 循环迭代。
fromcollectionsimportCounter# 1. 初始语料:词 -> 出现次数(词边界用 </w> 标记)corpus={"low":5,"lower":2,"newest":6,"widest":3,}# 2. 初始词表:把每个词拆成字符序列,末尾追加 </w>definit_vocab(corpus):vocab={}forword,freqincorpus.items():chars=list(word)+["</w>"]vocab[tuple(chars)]=freqreturnvocab# 3. 统计所有相邻 token 对的频次defget_pair_stats(vocab):pairs=Counter()fortokens,freqinvocab.items():foriinrange(len(tokens)-1):pairs[(tokens[i],tokens[i+1])]+=freqreturnpairs# 4. 把词中所有出现的 best_pair 合并成一个新 tokendefmerge_vocab(best_pair,vocab):new_vocab={}bigram="".join(best_pair)# 例如 ("e", "s") -> "es"fortokens,freqinvocab.items():new_tokens,i=[],0whilei<len(tokens):ifi<len(tokens)-1and(tokens[i],tokens[i+1])==best_pair:new_tokens.append(bigram)i+=2else:new_tokens.append(tokens[i])i+=1new_vocab[tuple(new_tokens)]=freqreturnnew_vocab# 5. 主循环:反复合并最高频对vocab=init_vocab(corpus)print("初始词表:")fortokens,freqinvocab.items():print(f"{' '.join(tokens):<30}freq={freq}")num_merges=5forstepinrange(1,num_merges+1):pairs=get_pair_stats(vocab)ifnotpairs:breakbest_pair=max(pairs,key=pairs.get)print(f"\n第{step}次迭代:最高频对{best_pair},频次{pairs[best_pair]}")vocab=merge_vocab(best_pair,vocab)fortokens,freqinvocab.items():print(f"{' '.join(tokens):<30}freq={freq}")运行后的输出(词表变化示例)
初始词表: l o w </w> freq=5 l o w e r </w> freq=2 n e w e s t </w> freq=6 w i d e s t </w> freq=3 第 1 次迭代:最高频对 ('e', 's'),频次 9 l o w </w> freq=5 l o w e r </w> freq=2 n e w es t </w> freq=6 w i d es t </w> freq=3 第 2 次迭代:最高频对 ('es', 't'),频次 9 l o w </w> freq=5 l o w e r </w> freq=2 n e w est </w> freq=6 w i d est </w> freq=3 第 3 次迭代:最高频对 ('est', '</w>'),频次 9 l o w </w> freq=5 l o w e r </w> freq=2 n e w est</w> freq=6 w i d est</w> freq=3 第 4 次迭代:最高频对 ('l', 'o'),频次 7 lo w </w> freq=5 lo w e r </w> freq=2 n e w est</w> freq=6 w i d est</w> freq=3 第 5 次迭代:最高频对 ('lo', 'w'),频次 7 low </w> freq=5 low e r </w> freq=2 n e w est</w> freq=6 w i d est</w> freq=3可以看到:前三次迭代把e + s → es → est → est</w>一路“长”成完整后缀,这正是频率驱动的直观体现;随后low也被合并成一个整体。每一步的合并规则(e, s)、(es, t)、(est, </w>)会被依次写入merges文件,与vocab一起构成训练产物。
特点
BPE 的决策完全由频率驱动,不涉及概率模型,因此实现简单、训练高效。代价是它可能合并出纯粹因为“常挨在一起”但语义关联并不强的片段。GPT 系列(GPT-2/3/4)的 byte-level BPE 是这一思路的典型工程化实现。
WordPiece:自底向上,按“似然增益”择优合并
核心迭代逻辑
WordPiece 的迭代方向与 BPE 相同(自底向上合并),但选择合并哪一对的标准不同。BPE 只看频次,WordPiece 则评估:合并这一对之后,语言模型对语料的整体似然能提升多少。一个常用的近似评分是:
score ( A , B ) = P ( A , B ) P ( A ) × P ( B ) \text{score}(A,B) = \frac{P(A,B)}{P(A) \times P(B)}score(A,B)=P(A)×P(B)P(A,B)
这个比值衡量的是:(A) 和 (B) 一起出现的概率,比它们“各自独立随机出现”的期望高出多少。比值越大,说明 (A) 和 (B) 的共现越“有意义”,越值得合并。
例子:同一个语料,选择可能不同
在语料low:5, lower:2, newest:6, widest:3中,BPE 会优先合并频次最高的e + s(9 次)。但 WordPiece 可能会计算后发现,合并low和est能带来更大的似然增益(因为lowest作为整体在语言中的搭配概率远高于low和est随机拼在一起的概率),从而优先选择不同的合并路径。也就是说,频次最高的对不一定是 WordPiece 会选的对;WordPiece 更在意合并后的片段是否构成一个“语义上更凝聚”的单元。
特点
WordPiece 用“似然增益”替代了单纯的频率,能更好地控制词表质量,减少 OOV(未登录词),也因此成为BERT系列模型的标准分词方案。代价是计算量比 BPE 更大,每一步都需要评估概率。
Unigram:自顶向下,按“概率贡献”逐步删减
核心迭代逻辑
Unigram 的迭代方向与前两者相反:它不是从小词表往大词表“长”,而是先构造一个过大的初始词表(包含语料中所有可能出现的子串或高频片段),初始化每个 token 的概率,然后反复删除那些“删掉之后对语料整体似然损失增加最小”的 token,直到词表收缩到目标大小。
例子:hug / pug / pun / bun / hugs
假设语料中各词频为hug:10, pug:5, pun:12, bun:4, hugs:5。Unigram 的初始词表可能包含所有子串:h, u, g, hu, ug, p, pu, n, un, b, bu, s, hug, gs, ugs等。然后它为每个子串计算在语料中的概率(例如ug出现在hug、pug、hugs中,总频次 20,总频次之和 210,所以 (\text{P}(\text{ug}) = 20/210))。接下来,算法评估:如果从词表中删掉ugs这个片段,语料库的似然会下降多少?如果下降很小,说明ugs“不太必要”,就可以删除。每一步删除一批贡献最小的 token,迭代收缩,直到词表达到目标大小。
特点
Unigram 基于概率语言模型,对低频词和多语言场景更友好,因为它不像 BPE/WordPiece 那样依赖贪心合并路径,而是从全局概率视角筛选词表。T5、mT5、Gemma等 Google 系模型常使用 Unigram 模式。代价是训练计算开销通常更高。
SentencePiece:不是算法,而是承载算法的“端到端框架”
关键定位
严格来说,SentencePiece 与前三种不在同一层级:BPE、WordPiece、Unigram 是“分词算法”;SentencePiece 是一个实现框架/工具,它内部可以选择运行 BPE 或 Unigram,但额外解决了前几种算法在工程上遗留的一个问题——预分词依赖。
核心差异:把空格也当作 token 来学习
传统 BPE/WordPiece 通常需要先按空格把句子切成“词”,再在每个词内部做子词切分。这对中文、日文等无空格语言不友好,也会让空格本身游离在建模之外。SentencePiece直接在原始文本上训练,把空格转义为特殊字符▁(U+2581),让空格也成为一个可学习的 token,从而做到语言无关且可逆解码。
例子
- 英文
"Hello World"→ SentencePiece(BPE 模式)可能输出["▁Hello", "▁World"],▁标记了词首的空格。解码时把▁还原为空格,即可无损恢复原文。 - 中文
"你好世界"不需要先做“词”级别的预分词,SentencePiece 直接把这个字符串当作 Unicode 序列,在其上运行 BPE 或 Unigram 进行子词构建。
特点
SentencePiece 的价值在于端到端、语言无关、无损可逆。LLaMA使用的是 SentencePiece 的 BPE 模式,T5使用的是 SentencePiece 的 Unigram 模式,DeepSeek系列也基于这一框架。它让同一个工程流程可以服务于中、英、日、韩等多种文字体系,而不需要为每种语言单独设计预分词规则。
四者关系一句话总结
| 算法 / 框架 | 迭代方向 | 每一步的决策依据 | 工程定位 |
|---|---|---|---|
| BPE | 自底向上合并 | 相邻对的频率最高 | 算法,简单高效,GPT 系常用 |
| WordPiece | 自底向上合并 | 似然增益最大 | 算法,BERT 系常用 |
| Unigram | 自顶向下删减 | 删除后似然损失增加最小 | 算法,T5/Gemma 系常用 |
| SentencePiece | 取决于内部算法 | 取决于内部选 BPE 或 Unigram | 框架,解决预分词与空格建模,语言无关 |
所以,理解这四者的关键不在于把它们并列成“四种同类的算法”,而在于看清:BPE、WordPiece、Unigram 是三种不同的词表构建策略(频率贪心 / 似然贪心 / 概率删减),SentencePiece 则是把其中某些策略工程化、语言无关化的容器。
训练产物
Step1导出核心产物,不管是使用哪一种分词算法,训练完成后都需要导出至少两个关键文件:
- vocab文件:记录所有token及其对应的id,是编码器和解码器的核心索引。
- merges文件:按顺序记录所有子词合并规则或概率模型。二者共同决定tokenizer的编码与解码逻辑,并确保编码的可逆。
Step2下游使用前的验证与评估
将tokenizer应用于一部分验证集后,建议统计以下关键指标:
- 平均token数与最大长度分布 直接影响显存占用、训练速度和推理效率。
- 碎片化情况 检查关键实体、专业术语是否被拆得过碎,避免影响模型理解。
- 跨语言token平衡度 多语言任务中需确保不同语言的常见模式都有足够的token支持。
如果后续需要扩表如加入新领域术语、专业词或品牌名等,建议优先采用这些方式而非完全重训tokenizer:增量训练、加入新的merges项、清理极低频token。并且,扩表后应进行一次回归测试,确保——与旧模型保持兼容且根据数字化编码可以还原回到最开始的输入文本、不发生token分配冲突或token耗尽问题。
(补充)字符、ASCII、Unicode、UTF-8、字节、Token ID 的关系
ASCII
128 个字符,编号 0~127;
只覆盖英文、数字、常见标点;
中文、emoji 没有位置。
Unicode
给全世界字符统一编号,即码点;
只规定“字符 → 编号”;
不规定编号如何存成字节。
UTF-8
规定“码点 → 字节序列”;
“A” → 41,1 字节;
“注” → E6 B3 A8,3 字节;
“🚀” → F0 9F 9A 80,4 字节。
字节
8 bit,范围 0~255;
计算机存储的基本单位。
提问:为什么 tokenizer 又在操作“字符”?
DeepSeek 使用 byte-level BPE:
语义字符 → Unicode 码点 → UTF-8 字节 → 字节代理符号 → BPE token → Token ID
例如 “注”:
“注” → Unicode 码点 U+6CE8 → UTF-8 字节 E6 B3 A8 → 字节映射为 ‘æ’ ‘³’ ‘¨’ → BPE 可能合并为 ‘注’ → 查表得到 ID 7421
因为 BPE 需要一个可遍历、可比较、可拼接的符号序列,但这里的“字符”不是原始语义字符,而是字节的代理符号:
‘æ’ 在这里不是字母 æ,而是字节 0xE6 的显示代号。BPE 合并出的 ‘注’ 也不是语义字符“注”,而是字节组合的 token。
按照原来教程中的解释:
为什么DeepSeek要用latin1编解码?
在 DeepSeek 的分词流程中最终处理得到的是数字化的 token,但在 BPE 分词器训练阶段需要按“字符”操作。如果直接用 UTF-8 编解码,汉字或 emoji 等多字节字符在拆分为单字节时会出现不完整序列,Python 会报错或替换,导致信息丢失。而 latin-1 是单字节编码,它把每个字节(0–255)机械映射为一个 Unicode 字符,保证任意字节序列都能完整、可逆地保存,从而让 BPE 或其他子词算法能把字节当作字符合并而不丢数据。简单来说,使用 latin-1 是为了在分词器中安全地把原始字节当作字符处理,确保编码器阶段信息完整。
4种常用分词器对比表
| 分词器类型 | 粒度 | 词表大小 | 词表外(OOV) | 序列长度 | 代表模型 |
|---|---|---|---|---|---|
| 字符级 | 细 | 小(100–5k) | 无 | 非常长 | Char-RNN |
| 字节级 | 更细 (字节) | 很小 (~256–1k) | 无 | 很长 | GPT-2 |
| 词级 | 粗 | 极大 (>100k) | 严重 | 短 | Word2Vec, GloVe |
| BPE | 中 (自适应) | 适中 (30k–100k) | 极少 | 适中 | GPT-4, Llama 3 |