分词器:BPE、WordPiece、SentencePiece 本节摘要:你的 LLM 不读英文,它读整数。决定这些整数承载含义还是浪费空间的,是分词器(Tokenizer)。同一个词,一种切法是一个 token,另一种是四个 token——你的 128K 上下文可能因此缩水 75%。本节带你吃透三大子词(Subword)分词算法:BPE(贪心合并最高频字节对)、WordPiece(按似然比合并,BERT 用)、SentencePiece(语言无关,直接吃 Unicode 流)。你会看到字节级 BPE 如何用 256 个基础字节覆盖一切语言、为什么 GPT-4o 词表涨到 20 万、多语言要交「token 税」,以及如何从零训练一个 BPE 分词器并与 tiktoken 对比压缩比。
本节摘要:你的 LLM 不读英文,它读整数。决定这些整数承载含义还是浪费空间的,是分词器(Tokenizer)。同一个词,一种切法是一个 token,另一种是四个 token——你的 128K 上下文可能因此缩水 75%。本节带你吃透三大子词(Subword)分词算法:BPE(贪心合并最高频字节对)、WordPiece(按似然比合并,BERT 用)、SentencePiece(语言无关,直接吃 Unicode 流)。你会看到字节级 BPE 如何用 256 个基础字节覆盖一切语言、为什么 GPT-4o 词表涨到 20 万、多语言要交「token 税」,以及如何从零训练一个 BPE 分词器并与 tiktoken 对比压缩比。读懂分词器,才懂 LLM 的输入边界。
阅读完本节,你应当能够:
你的 LLM 不读英文,也不读任何自然语言。它读数字。
「Hello, world!」与 [15496, 11, 995, 0] 之间的鸿沟,就是分词器。每个单词、每个空格、每个标点,都必须先转成整数,模型才能处理。而这步转换不是中性的——它把一些假设固化进模型,且事后无法撤销。
分错了,模型会浪费容量用多个 token 去编码常见词:「unfortunately」可能被切成 4 个 token 而不是 1 个。你的 128K 上下文对多音节词密集的文本缩水 75%。分对了,同样窗口能装下两倍的含义。「这个模型擅长写代码」和「这个模型吃不下 Python」之间的差别,往往只取决于分词器是怎么训练的。
你每一次调用 GPT-4 或 Claude,都按 token 计费;模型每生成一个 token 都要花算力。表达同一输出所需的 token 越少,端到端推理越快。分词不是预处理,它是架构。
把文本转成数字有三种显而易见的路,其中两种在大规模下不可行。
词级(Word-level)分词:按空格和标点切分。「The cat sat」变成 ["The", "cat", "sat"]。简单。但「tokenization」呢?「GPT-4o」呢?德语复合词「Geschwindigkeitsbegrenzung」呢?词级要为每种语言的每个词都准备一个词表,漏掉一个就出现可怕的 [UNK](未知)token——模型的「我完全不知道这是什么」。光英语就有上百万词形,再加上代码、URL、科学记数法和上百种语言,你需要无穷大的词表。
字符级(Character-level)分词:走另一个极端。「hello」变成 ["h","e","l","l","o"]。词表极小(几百字符),永远没有未知 token。但序列变得极长。本来 10 个词级 token 的句子,变成 50 个字符级 token。模型还得自己学会「t、h、e 合起来是 the」——把三岁小孩就会的事耗在注意力容量上。
子词(Subword)分词:找到甜点。常见词保持完整:「the」是 1 个 token。罕见词拆成有意义的片段:「unhappiness」变成 ["un","happi","ness"]。词表可控(3 万到 12.8 万 token),序列短,未知 token 基本消失——任何词都能由子词拼出。每个现代 LLM 都用子词:GPT-2、GPT-4、BERT、Llama 3、Claude,无一例外。问题只剩用哪个算法。
BPE 是一种被改用作分词的贪心压缩算法。核心思路简单到一张索引卡就能写下:从单个字符开始,统计训练语料里所有相邻字符对,把出现最多的那对合并成新 token,重复直到达到目标词表大小。
下面是 BPE 在「lower」「lowest」「newest」这个小语料上的运行轨迹:
语料(含词频): "lower" x5 "lowest" x2 "newest" x6 第 0 步 —— 从字符开始: l o w e r (x5) l o w e s t (x2) n e w e s t (x6) 第 1 步 —— 统计相邻对: (w,e): 13 (e,s): 8 (s,t): 8 (l,o): 7 ... 第 2 步 —— 合并最高频对 (w,e) -> "we": l o we r (x5) l o we s t (x2) n e we s t (x6) 第 3 步 —— 重统计,合并 (we,s) -> "wes": ... 第 4 步 —— 合并 (wes,t) -> "west": l o we r (x5) l o west (x2) n e west (x6) ……直到达到目标词表大小。
合并表就是分词器。编码新文本时,按学习到的合并顺序依次套用。训练语料决定了存在哪些合并,而这个选择永久塑造了模型看到的东西。
标准 BPE 在 Unicode 字符上操作。字节级 BPE 直接在原始字节(0~255)上操作,基础词表恰好 256,能处理任何语言或编码,永不产生未知 token。GPT-2 引入此法,OpenAI 的 tiktoken 库就是字节级 BPE 实现,词表大小为:GPT-2 是 50,257;GPT-3.5/GPT-4 约 100,256(cl100k_base);GPT-4o 是 200,019(o200k_base)。
WordPiece 看着像 BPE,但合并标准不同。BPE 看原始频次,WordPiece 看似然比:
BPE 合并标准: count(A, B) WordPiece 合并标准: count(AB) / (count(A) * count(B))
BPE 问「哪对出现最多」,WordPiece 问「哪对比偶然预期更常一起出现」。WordPiece 还用「##」前缀标记续接子词(如 ["un","##happi","##ness"]),BERT 词表 30,522。
SentencePiece 把输入当作原始 Unicode 字符流,包括空白。没有预分词步骤,没有语言专用的词边界规则,因此真正语言无关——能处理中文、日文、泰文等不以空格分词的语言。它支持两种算法:BPE 模式(同标准 BPE,作用在原始字符序列上)和 Unigram 模式(从大词表出发,迭代删除对总体似然影响最小的 token,是 BPE 的反向——剪枝而非合并)。Llama 2 用 SentencePiece BPE(32K 词表),T5 用 SentencePiece Unigram(32K),Llama 3 已改用基于 tiktoken 的字节级 BPE(128,256 词表)。
这是一道有可度量后果的工程决策。
具体数字:128K 词表、4,096 维嵌入,光嵌入矩阵就是 128,000 × 4,096 = 5.24 亿参数;32K 词表则只有 1.31 亿。仅分词器选择就差了 4 亿参数。但大词表压缩文本更狠:同一段英文,32K 词表要 100 token,128K 可能只要 70——生成时少 30% 前向传播。对日服务百万请求的模型,这是算力成本的直接削减。
| 模型 | 词表大小 | 分词器类型 | 每英文词平均 token |
|---|---|---|---|
| BERT | 30,522 | WordPiece | ~1.4 |
| GPT-2 | 50,257 | 字节级 BPE | ~1.3 |
| Llama 2 | 32,000 | SentencePiece BPE | ~1.4 |
| GPT-4 | ~100,256 | 字节级 BPE | ~1.2 |
| Llama 3 | 128,256 | 字节级 BPE(tiktoken) | ~1.1 |
| GPT-4o | 200,019 | 字节级 BPE | ~1.0 |
主要用英语训练的分词器对其他语言是灾难性的。GPT-2 分词器里,韩文平均每词 2~3 个 token,中文更糟。这意味着韩语用户的有效上下文窗口只有英语用户的一半——付同样的钱,信息密度却更低。这正是 Llama 3 把词表从 32K 翻四倍到 128K 的原因:给非英文字符分配更多 token,跨语言压缩更公平。
💡 关键概念:每词平均 token 数(Fertility,生育率)衡量分词器效率。1.0 是完美,3.0 意味着模型要多干三倍的活。同一句话用多种语言编码数 token,就能量化「多语言税」。
字符级分词器把每个字符映射到其 Unicode 码点,无需训练,无未知 token。
class CharTokenizer: def encode(self, text): return [ord(c) for c in text] def decode(self, tokens): return "".join(chr(t) for t in tokens)
「hello」变成 [104, 101, 108, 108, 111]。这是我们改进的基线。
真刀真枪的实现:在原始字节上训练(像 GPT-2),统计字节对,合并最高频,按序记录每次合并。合并表就是分词器。
from collections import Counter class BPETokenizer: def __init__(self): self.merges = {} # (a, b) -> new_id self.vocab = {} # id -> bytes def _get_pairs(self, tokens): pairs = Counter() for i in range(len(tokens) - 1): pairs[(tokens[i], tokens[i + 1])] += 1 return pairs def _merge_pair(self, tokens, pair, new_token): # 在 tokens 中把 pair 替换为 new_token merged, i = [], 0 while i < len(tokens): if i < len(tokens) - 1 and (tokens[i], tokens[i+1]) == pair: merged.append(new_token); i += 2 else: merged.append(tokens[i]); i += 1 return merged def train(self, text, num_merges): tokens = list(text.encode("utf-8")) self.vocab = {i: bytes([i]) for i in range(256)} # 基础字节词表 for i in range(num_merges): pairs = self._get_pairs(tokens) if not pairs: break best = max(pairs, key=pairs.get) new_id = 256 + i tokens = self._merge_pair(tokens, best, new_id) self.merges[best] = new_id self.vocab[new_id] = self.vocab[best[0]] + self.vocab[best[1]] def encode(self, text): tokens = list(text.encode("utf-8")) for pair, new_id in self.merges.items(): # 必须按学习顺序应用 tokens = self._merge_pair(tokens, pair, new_id) return tokens def decode(self, tokens): return b"".join(self.vocab[t] for t in tokens).decode("utf-8", errors="replace")
设计要点:训练循环就是 BPE 的核心——数对、合并赢家、重复。每次合并都减少总 token 数。编码时必须按学习顺序应用合并:若第 1 步合出了「th」、第 5 步合出「the」,编码时必须先做第 1 步,「the」才能在第 5 步由「th + e」合成。
corpus = "The cat sat on the mat. ... " # 见原课程完整语料 tok = BPETokenizer(); tok.train(corpus, num_merges=40) encoded = tok.encode("The cat sat on the mat.") print(f"token 数:{len(encoded)},解码往返:{'PASS' if tok.decode(encoded)==text else 'FAIL'}")
压缩比(len(encoded)/原始字节数)告诉你分词器多有效。0.50 表示压到一半。在训练语料上压缩比好,在分布外文本(如未出现的「unhappiness」)上会退回字符级。
import tiktoken enc = tiktoken.get_encoding("cl100k_base") tokens = enc.encode("Tokenizers convert text to integers") print(enc.decode(tokens)) # 往返一致
tiktoken 用 Rust 编写、Python 绑定,每秒编码上百万 token。算法与我们的 BPE 完全相同,差别只在训练数据量和合并次数——你的在一段话上 40 次合并,自然打不过 tiktoken 在海量语料上 10 万次合并。
from tokenizers import Tokenizer from tokenizers.models import BPE from tokenizers.trainers import BpeTrainer from tokenizers.pre_tokenizers import ByteLevel tokenizer = Tokenizer(BPE()) tokenizer.pre_tokenizer = ByteLevel() trainer = BpeTrainer(vocab_size=1000, special_tokens=["<pad>","<eos>","<unk>"]) tokenizer.train(["corpus.txt"], trainer)
底层同样是 Rust,秒级在 GB 级语料上训练 BPE——自己训模型时用这个。
from transformers import AutoTokenizer tok = AutoTokenizer.from_pretrained("meta-llama/Llama-3.1-8B") print(tok.encode("Hello world")) # token ID 列表 print(tok.vocab_size) # 128256
Llama 3 的 128K 词表对非英文文本压缩明显好于 GPT-2 的 50K。可自行编码同一句多语言,数 token 验证。
本节产出 outputs/prompt-tokenizer-analyzer.md——一个可复用提示,接收任意文本与模型组合,分析分词效率,告诉你哪个模型的分词器处理得最好。Python 代码(code/ 相应文件)是独立分析工具,接口稳定,可投入生产。
(Easy) 修改 BPE 让它在每次合并后打印词表,观察「t + h」如何变成「th」,再「th + e」变成「the」,追踪常见英文词如何一块块拼出来。
(Medium) 给 BPE 加特殊 token(<pad>、<eos>、<unk>),分配 ID 0、1、2,其余 token 顺移;再实现按空白预分词的步骤。
(Medium) 实现 WordPiece 的合并标准(似然比而非频次),在同一语料、同样合并次数下训练 BPE 与 WordPiece,对比谁的词表更有语言学意义。
(Hard) 构建多语言分词效率基准:取英、西、中、韩、阿拉伯各 10 句,用 tiktoken(cl100k_base)分词,计算每字符平均 token,量化每种语言的「多语言税」。
(Hard) 在更大的语料(下载一篇维基百科)上训练 BPE,调参合并次数,使压缩比在该文本上达到 tiktoken 的 10% 以内,借此理解语料大小、合并次数与压缩质量的关系。
count(AB)/(count(A)*count(B)),偏爱「出人意料地常共现」,BERT 用之,带「##」续接前缀。下一节,我们将把本节的玩具 BPE 升级为生产级分词器:加上 Unicode 归一化、正则预分词、特殊 token 与聊天模板,让它能吞下 emoji、CJK、代码而不崩。