GPT-2 的词表里没有 <UNK> 这个 token。翻开它那 50,257 个词条,你找不到任何表示"未知"的占位符。这在 2019 年非常反常——此前的神经机器翻译系统几乎都有 <UNK>,因为词表永远装不下真实世界的语言。

我最早以为,这是因为 GPT 收集了足够多的生僻字和 emoji。后来打开 OpenAI 的 encoder.py 才发现,它的思路根本不是"认识所有字符",而是"在编码时扔掉字符这个概念"。一切文本先拆成 UTF-8 字节,词表最底层是 256 个字节。
词表地狱:BPE 解决了罕见词,没解决新字符
故事要从 2016 年说起。Sennrich 等人把数据压缩里的 BPE(Byte Pair Encoding)引入神经机器翻译,用来处理罕见词。传统词表是词级的:transformers 没进词表,就只能变成 <UNK>。BPE 的思路是先以字符为单位,把语料里出现频率最高的相邻字符对合并成一个子词。transformers 可以被拆成 transform + ers 重新拼出来,这样不需要把每个词都收进词表。
但 BPE 有一个容易被忽略的前提:它需要一个有限的字符集合。英文没问题,26 个字母加上标点就够了;一旦放到 Unicode 场景,事情就变了。Unicode 15.0 收录了 149,186 个字符,其中汉字九万多,每年还往里加新 emoji。在字符层做 BPE,初始符号表就有十几万项;更致命的是,明年新发布的字符,你的系统依然不认识。BPE 解决了"罕见的词",却没有解决"没见过的字符"。
字符太多?那就别用字符
这里有一个很直接的算账:与其在字符层挣扎,不如往下钻一层。任何字符存进计算机,最终都是 UTF-8 字节序列:A 是 1 个字节,你 是 3 个字节,emoji 一般是 4 个字节。字节一共只有 256 种。
所以 GPT-2 论文直接把 BPE 跑在字节上:使用字节级编码作为基础,可以表示任何字符串,因此不需要担心未知字符。说人话就是——把字符表从 15 万压缩到 256,且任何文本都能被覆盖。
Byte-Level BPE 是怎么工作的
- 输入文本先编码成 UTF-8 字节序列。
- 初始化:每个字节单独作为一个 token,共 256 个。
- 扫描语料,统计相邻 token 对的出现频率。
- 把当前出现频率最高的 token 对合并成一个新 token,记录这条合并规则。
- 重复上一步固定次数。GPT-2 合并了 50,000 次,最终词表 = 256 个字节 token + 50,000 个合并 token + 1 个特殊 token = 50,257。
- 编码新文本时反复套用合并规则;解码时把 token 还原成字节,拼起来按 UTF-8 还原,原文无损。
这意味着什么?意味着任何合法 Unicode 文本,包括你现场造的新符号,都能被编码。它不是见过这个字符,而是任何字符最终都只是字节。这就是它不需要 <UNK> 的原因。
我第一次读 encoder.py 时,被一个细节卡了很久:OpenAI 在跑 BPE 之前,先把 256 个字节用 bytes_to_unicode() 映射成一批可见的 Unicode 字符。我当时想,不是说好字节级吗,怎么又绕回字符了?后来才明白,这是实现层面的迂回——BPE 的经典实现合并的是字符串,而字节值里有空格、换行这类会给字符串处理带来麻烦的边界。映射成可见字符后,空格不再是分隔符,而是普通字符,可以参与合并。
这个设计还有一个副产品:你会在 GPT-2 词表里看到大量以 Ġ 开头的 token。Ġ 不是神秘符号,它就是空格字节 0x20 的映射。有了它,带前导空格的单词可以整体成为一个 token,比如 Ġthe。
顺手纠正一个我最早也误解过的点:字节级 BPE 不是每个 token 一个字节。合并之后 token 可长可短。以"你"为例,它的 UTF-8 是 e4 bd a0 三个字节。如果三个字节在语料里高频相邻,BPE 会先合并 e4 和 bd,再把结果和 a0 合并,最终一个 token 就是一个汉字;如果生僻字出现太少,就一直以 2 到 3 个字节 token 存在。字节级描述的是初始原子,不是最终粒度。
一张表说清三种方案
| 方案 | 基础单元 | 基础数量 | 遇到新字符 | 代表 |
|---|---|---|---|---|
| 词级 | 空格切分得到的词 | 几十万至上百万 | <UNK> |
早期神经机器翻译 |
| 字符级 BPE | Unicode 字符 | 约 15 万 | <UNK> |
Sennrich 2016 |
| 字节级 BPE | UTF-8 字节 | 256 | 不存在 | GPT-2、GPT-3、GPT-4 |
代价:token 的世界里没有"字符"
代价是真实的。最直接的是序列长度:一个汉字在字符级方案里至少 1 个初始单元,到字节级变成 3 个字节。如果三字节未被合并,token 数增加,Transformer 自注意力开销是平方级增长。所以字节级 BPE 早期也被批评对非拉丁文字不友好。
但实际训练中 BPE 会尽量把高频多字节组合合并回来。常用汉字在语料里出现频率极高,通常会被合并成单个 token;英文全是单字节字母,几乎不受影响。真正吃亏的是低频字符和冷门语言。
还有一个反直觉的产物:词表里会出现"半个汉字"。合并发生在字节层,完全可能停在一个 UTF-8 字符的中间。比如某个 token 只包含"你"的前两个字节 e4 bd,单独解码它是非法字符。但这没关系——解码永远是把整个 token 序列的字节拼起来,再按 UTF-8 一次性还原,所以原文依然无损。模型看到的是一种抽象的字节块,它根本不需要知道哪几个 token 拼起来是一个汉字。
想亲手验证这件事,用 OpenAI 的 tiktoken 就够了:
import tiktoken; enc = tiktoken.get_encoding("cl100k_base"); tokens = enc.encode("你好 🤔"); print(tokens); print(enc.decode(tokens)) # 你好 🤔 无损还原
为什么 GPT 系列一直用这套方案
从 GPT-2 到 GPT-3,这个选择被直接继承。GPT-3 论文里写得很清楚:词表 50,257,和 GPT-2 用的是同一套字节级 BPE。到了 GPT-4 时代,OpenAI 的 cl100k_base 依然是字节级。一旦接受"任意文本都必须可编码"这一前提,字节就是最稳的底表。它不用跟着 Unicode 委员会更新,也不可能被新字符击穿。
这个思路也辐射到了整个开源生态:SentencePiece 提供了 byte fallback 选项,LLaMA 等模型的 tokenizer 遇到未知字符时,就以 <0xXX> 的字节形式兜底。多语言模型不再维护一个巨大的字符清单,而是让 256 个字节做最后一道防线。你很难再看到一个主流 LLM 的词表里还有 <UNK>。
但必须说清楚边界:没有 <UNK> 不等于模型真的懂那些字。字节级 BPE 解决的是表示层问题,不是语义问题。一个生僻字可以被编码成 token,模型也可以照常输出它,但生成质量取决于这个 token 在训练语料里出现过多少次。如果只出现过几次,模型依然会把它和其他低频 token 混为一谈。tokenizer 管的是"能不能表示",模型管的是"有没有见过"。这两件事经常被混淆。
回看这套方案,最值得学的不是 BPE 本身,而是"往下钻一层"的思路。字符太多枚举不完,那就退到编码层,用 256 个字节统一一切。表面的多样性,常常在更底层是统一的——这个解题方式,比维护一份永远收不完的字符清单优雅得多。
原创文章,作者:guanweilu,如若转载,请注明出处:https://guanweilu.cn/article/730.html