大模型的输入不是字符也不是词,而是 token。把文本变成 token 序列的组件叫 tokenizer,主流方案 BPE(Byte Pair Encoding)其实比神经网络老得多——它 1994 年作为数据压缩算法被提出,2016 年被借进机器翻译,GPT-2 把它改造成字节级之后沿用至今。理解 BPE 最好的姿势不是背算法步骤,而是认清它的本质:一个贪心的在线字典压缩器。
算法:反复合并最高频的相邻对
训练过程一句话就能说完:初始化词表为全部字节(256 个),然后循环——统计语料中所有相邻 token 对的频率,把最高频的一对合并成一个新 token,加入词表,重写给定语料,直到词表达到预定大小。
举个最小例子。语料是 aaabdaaabac,初始每个字符是独立 token。第一轮统计:aa 出现 4 次,最高频,合并成新 token Z(实际是新 id),语料变成 Zabd Zab ac。第二轮:Za 出现 3 次(Zab 里的 Za),合并为 Y,语料变成 Ybd Yb ac。到这一步,字符串从 11 个 token 压缩到 7 个,词表里多了两个"词"。
推理(编码)阶段用的是训练时保存下来的合并规则表,按合并发生的顺序依次应用:拿到新文本,先切成字节,然后按规则表的优先级贪心匹配合并。规则表是有序的,顺序就是优先级——先学到的合并先应用。这个细节在实现里很关键:tiktoken 用一张哈希表存合并规则,查表决定每一对是否合并。
还有一个常被忽略的预处理步骤:预切分(pre-tokenization)。GPT-2 在合并之前先用正则把文本切成"单词级"的片段(字母串、数字串、标点、空白各成一段),BPE 的合并不允许跨越片段边界。为什么需要它?一是防止 th e 这类跨词的高频对占据词表、把单词边界信息冲掉;二是让数字、单词这类语义单元保持完整,方便词表学习有意义的条目。这个正则是各家 tokenizer 差异最大的隐藏参数之一,同一个 BPE 算法配不同的预切分,词表面貌可以完全不同。
为什么从字节起步:byte-level BPE
GPT-2 之前的 BPE 从 Unicode 字符起步,遇到生僻字符就 OOV(词表外)。GPT-2 的改造简单有效:一切文本先按 UTF-8 编码成字节序列,初始词表固定 256 个字节,合并规则在此之上学习。这样任何字符串都可编码——最坏情况退化成字节序列,OOV 从机制上被消灭了。代价是多语言文本的 token 效率下降:一个汉字 UTF-8 占 3 字节,如果词表里没学到这个字的合并条目,它就要占 3 个 token。中文场景下"一个字几个 token"的成本问题,根源就在这里。
训练与编码的工程复杂度
算法一句话能说完,但能用的实现要解两道复杂度题。训练侧:朴素实现每一轮合并都全语料重扫统计相邻对,语料 $N$ 个 token、$V$ 轮合并就是 $O(NV)$——五万轮扫几十 GB 语料,不可接受。工程实现维护每个相邻对的计数和出现位置的索引,一次合并只影响被合并位置左右各一对邻居的计数,增量更新后每轮代价正比于该对的出现次数,摊还下来接近线性。tiktoken 仓库里有一份教学实现 tiktoken/_educational.py,四十来行的 bpe_train 把这个思路写得很白,值得读一遍对照。
编码侧是另一道题:给定合并规则表,对新文本要快。规则按合并顺序有优先级,朴素做法每步扫一遍当前序列找优先级最高的相邻对,又是反复全扫。HuggingFace tokenizers(Rust 实现,核心在 src/models/bpe/)把相邻对组织成优先队列,合并一对后只把新产生的邻居对入队,每个符号至多进出队常数次,编码长度 $L$ 的文本代价约 $O(L\log L)$。tiktoken 则更激进:预切分后按片段查一张"片段 → token 序列"的缓存表,命中就是 $O(1)$。分词处在每个请求的热路径上,这些优化不是锦上添花——大词表下编码慢 10 倍,会直接反映到首 token 延迟上。
与压缩理论的关系
BPE 的行为和 LZ78 系字典压缩高度相似:都是建字典、用短码替换高频子串。区别在于 LZ78 在线增量建码,BPE 用全局频率贪心地离线建码。这个视角能解释一个常见疑问:BPE 合并准则是频率,不是互信息也不是似然(那是 WordPiece 和 Unigram 的事),为什么效果够好?答案在压缩理论里——最大化压缩率本来就是最小化描述长度,和最小化语言的熵是同一件事的两种说法。频率贪心有次优性保证不了,但实践上和最优的差距不大,而实现简单得多。
值得把"贪频率"说得更准:合并一对出现 $c$ 次的相邻对,序列总 token 数恰好减少 $c$——所以"选最高频的对"就是"选压缩收益最大的对",这一步是精确的最大收益贪心,不是启发式拍脑袋。它的次优在另外两处:一是贪心不等于全局最优,先合并的对会改变后续对的频率分布;二是最小描述长度原则要求同时优化码本自身的存储成本,BPE 对码本成本完全不看。这也解释了为什么超大词表的边际收益崩塌得比纯压缩理论预言的更快——词表尾部条目自身的"描述成本"(嵌入参数、分摊到的训练样本数)开始超过它省下的那点儿 token。
顺带一提另外两条分词路线在这个框架里的位置:WordPiece 把合并准则从频率换成 $\frac{\mathrm{freq}(ab)}{\mathrm{freq}(a)\,\mathrm{freq}(b)}$,有点互信息的味道,偏向合并"粘性强"而非"出现多"的对;Unigram 则反过来从全词表出发做减法,按对数似然的损失逐个剔除。三者工程上都能用,BPE 胜出靠的是训练简单、编码确定(无采样),以及和压缩直觉的直接对应。
压缩视角还给出一个可验证的预测:BPE 词表的边际收益递减。第 1000 个合并条目砍掉大量 token,第 50000 个条目只覆盖极少数低频模式。实测 GPT-2 的 50257 词表在英文网页文本上的压缩率大约是 4 字符/token,再加一倍词表只能挤进几个百分点。词表不是越大越好,下面算账。
词表大小的账本
词表大小 $V$ 影响三笔账:
- 序列长度:$V$ 越大,同一段文本的 token 数越少,注意力成本(与长度平方相关)下降。
- 嵌入参数:嵌入矩阵加输出层是 $2Vd$ 个参数。$d=4096$、$V=32k$ 时是 2.7 亿参数;$V$ 翻倍到 256k 就是 21 亿——对 7B 模型这不是小数目。
- 低频 token 的训练充分性:词表尾部的大量 token 出现次数太少,嵌入学不充分,还可能产生 glitch token(嵌入接近随机初始化、行为异常的 token)。
LLaMA 用 32k,GPT-4 系(cl100k)用 100k,Llama 3 用 128k,各家都是在"压缩率收益递减"和"嵌入参数膨胀"之间选点。多语言模型倾向更大的词表,因为非英语文本的字节数多,需要更多合并条目才能达到同样的压缩率。
著名后遗症:数字与代码
BPE 按频率合并,没有数字概念。12345 可能被切成 123 + 45,也可能是 12 + 345,取决于语料里的频率。后果是大模型"看"不到数字的位值结构:对人类,12345 是 5 个有位置的数字;对模型,它可能是两个没有内部结构的任意 id。算术能力的短板有一部分要归到这里。Llama 3 之后的词表把单个数字都做成独立 token(0-999 合并成三位数条目也有采用),就是在修这个问题。
代码的情况类似:缩进四个空格、换行加缩进这类高频模式会被合并成单 token,但稍冷门的标识符就被切得七零八落,增加了模型记忆库内 API 名的负担。
动手验证的两个入口
理论到此为止,验证只要两行代码。tiktoken 里 enc.encode("文本") 直接给出 token id 序列,enc.decode([id]) 逐个还原,亲眼看看自己的名字、一段中文、一串数字各被切成什么样,比读十页文档都直观。想再深入一层,HuggingFace 的 tokenizers 库允许自己训练 BPE:准备几十 MB 语料、指定词表大小,十分钟就能训出一个自己的分词器,然后统计它的压缩率(字符数/token 数)随词表大小的增长曲线——那条边际递减曲线会帮你彻底理解词表大小的权衡。
小结
BPE 的全部机制:256 字节起步,按频率贪心合并,规则表按序应用。它够用、够快、足够接近压缩极限,同时把"语言结构"这个问题完全留给了后面的 Transformer。理解它的频率本质,才能预判它在数字、代码、低资源语言上的失败模式——这些失败不是 bug,而是贪心字典压缩的固有边界。