分词器:BPE、WordPiece、SentencePiece


文档摘要

分词器:BPE、WordPiece、SentencePiece 本节摘要:你的 LLM 不读英文,它读整数。决定这些整数承载含义还是浪费空间的,是分词器(Tokeni­zer)。同一个词,一种切法是一个 token,另一种是四个 token——你的 128K 上下文可能因此缩水 75%。本节带你吃透三大子词(Subword)分词算法:BPE(贪心合并最高频字节对)、WordPiece(按似然比合并,BERT 用)、SentencePiece(语言无关,直接吃 Unicode 流)。你会看到字节级 BPE 如何用 256 个基础字节覆盖一切语言、为什么 GPT-4o 词表涨到 20 万、多语言要交「token 税」,以及如何从零训练一个 BPE 分词器并与 tiktoken 对比压缩比。

分词器:BPE、WordPiece、SentencePiece

本节摘要:你的 LLM 不读英文,它读整数。决定这些整数承载含义还是浪费空间的,是分词器(Tokeni­zer)。同一个词,一种切法是一个 token,另一种是四个 token——你的 128K 上下文可能因此缩水 75%。本节带你吃透三大子词(Subword)分词算法:BPE(贪心合并最高频字节对)、WordPiece(按似然比合并,BERT 用)、SentencePiece(语言无关,直接吃 Unicode 流)。你会看到字节级 BPE 如何用 256 个基础字节覆盖一切语言、为什么 GPT-4o 词表涨到 20 万、多语言要交「token 税」,以及如何从零训练一个 BPE 分词器并与 tiktoken 对比压缩比。读懂分词器,才懂 LLM 的输入边界。

学习目标

阅读完本节,你应当能够:

  1. 从零实现 BPE、WordPiece、Unigram 三种子词分词算法,并对比它们的合并策略。
  2. 解释词表大小如何影响模型效率:太小则序列变长,太大则浪费嵌入参数。
  3. 分析不同语言、代码场景下的分词产物,定位特定分词器在何处崩坏。
  4. 使用 tiktoken 与 sentencepiece 库对文本分词,并检视生成的 token ID。

一、问题与直觉

你的 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:字节对编码

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(GPT-2、GPT-3、GPT-4)

标准 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(BERT)

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(Llama、T5)

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,就能量化「多语言税」。

二、从零实现

Step 1:字符级分词器(基线)

字符级分词器把每个字符映射到其 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]。这是我们改进的基线。

Step 2:从零实现 BPE

真刀真枪的实现:在原始字节上训练(像 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」合成。

Step 3:编码解码往返与压缩比

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」)上会退回字符级。

三、框架对比

tiktoken(OpenAI)

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 万次合并。

Hugging Face tokenizers

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——自己训模型时用这个。

加载 Llama 分词器

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/ 相应文件)是独立分析工具,接口稳定,可投入生产。

五、练习

  1. (Easy) 修改 BPE 让它在每次合并后打印词表,观察「t + h」如何变成「th」,再「th + e」变成「the」,追踪常见英文词如何一块块拼出来。

  2. (Medium) 给 BPE 加特殊 token(<pad><eos><unk>),分配 ID 0、1、2,其余 token 顺移;再实现按空白预分词的步骤。

  3. (Medium) 实现 WordPiece 的合并标准(似然比而非频次),在同一语料、同样合并次数下训练 BPE 与 WordPiece,对比谁的词表更有语言学意义。

  4. (Hard) 构建多语言分词效率基准:取英、西、中、韩、阿拉伯各 10 句,用 tiktoken(cl100k_base)分词,计算每字符平均 token,量化每种语言的「多语言税」。

  5. (Hard) 在更大的语料(下载一篇维基百科)上训练 BPE,调参合并次数,使压缩比在该文本上达到 tiktoken 的 10% 以内,借此理解语料大小、合并次数与压缩质量的关系。

本节要点回顾

  1. LLM 不读语言,读整数:分词器决定这些整数承载含义还是浪费空间,它不是预处理,而是架构决策。
  2. 三种方案两败一胜:词级词表无穷大,字符级序列过长,子词(BPE/WordPiece/Unigram)找到甜点。
  3. BPE 即贪心压缩:从字符出发,反复合并最高频相邻对,合并表就是分词器;编码必须按学习顺序应用。
  4. 字节级 BPE 永无未知 token:以 256 个基础字节覆盖一切语言,GPT 系列、tiktoken、Llama 3 皆用此法。
  5. WordPiece 看似然比:count(AB)/(count(A)*count(B)),偏爱「出人意料地常共现」,BERT 用之,带「##」续接前缀。
  6. SentencePiece 语言无关:直接吃 Unicode 流(含空白),支持 BPE 与 Unigram(剪枝式),适合中韩日泰等不空格分词的语言。
  7. 词表大小是权衡:32K 序列长但嵌入小,128K+ 序列短推理快但嵌入大;趋势是越来越大(GPT-2 50K → GPT-4o 200K)。
  8. 多语言税真实存在:英为中心训练的分词器让韩中文用户的有效上下文减半,Llama 3 翻四倍词表正是为公平压缩。
  9. 生育率(Fertility)量化效率:每词平均 token 数,1.0 完美,3.0 意味模型多干三倍活。
  10. 生产用编译实现:tiktoken(Rust)、Hugging Face tokenizers(Rust)、SentencePiece(C++),比纯 Python 快 10~100 倍;算法相同,语言是差距。

下一节,我们将把本节的玩具 BPE 升级为生产级分词器:加上 Unicode 归一化、正则预分词、特殊 token 与聊天模板,让它能吞下 emoji、CJK、代码而不崩。


发布者: 作者: Rohit Gupta 转发
评论区 (0)
U