bobofamilyNIU CHUN
30/36

词表里的 Ġ 是什么:沿着 GPT-2 源码走完一次编解码

2026-10-098 分钟—阅读

GPT-2 的词表里有 Ġthe、ä½ 这样的字符串。它们不是乱码后的原文,也不是特殊 token。 这份代码把每个字节换成一个可逆的字符别名,再在别名字符串上执行 BPE。

整篇用同一个输入 "Hi 你!":

正则块 UTF-8 字节 字节别名
'Hi' [72,105] 'Hi'
' 你' [32,228,189,160] 'Ġä½ł'
'!' [33] '!'
最终模型 ID:[17250, 220, 19526, 254, 0]

这里「你」占两个 ID,前面的空格另占一个。不是一个字符固定对应一个 token。

Hi 你! 的完整往返:先初始化两套映射和规则表,逐块编码,再把所有 token 的字节拼齐解码

初始化:两份资源,几张职责不同的表

get_encoder() 加载的两个文件是分词器的训练成果,不是 Transformer 的权重:

资源存什么用在哪里
encoder.json完整符号字符串 → 模型 token IDBPE 完成后查编号;反过来查内容
vocab.bpe每行左右两个符号,行序给出 rank编码时决定先合并哪一对

源码读取规则时用 split('\n')[1:-1],跳过版本头和最后空行,再对剩下的规则 enumerate。 这依赖官方文件末尾有换行:换一份自制文件而不保留末尾换行,这种切片会把最后一条规则一起删掉。

加载后 Encoder 手里的对象:

对象方向 / 作用
encoder / decoder完整符号 ↔ 模型 ID
byte_encoder / byte_decoder原始单字节值 ↔ 一个字符别名
bpe_ranks相邻符号对 → 合并优先级
pat在原文上切正则块
cache原始别名块 → 已完成的 BPE 返回字符串
errorsUTF-8 解码错误策略,默认 'replace'

最容易混淆的是前两行:字节到别名和完整符号到 ID是两套映射。 ASCII 字节 97 的别名是 a,但 encoder['a'] 是 64;字节 33 的别名是 !,模型 ID 却是 0。

rank 与 ID 恰好相差 256,但用途不同

官方规则开头:

rank左 + 右合并内容的模型 ID
0Ġ + t256
1Ġ + a257
2h + e258
6Ġt + he262

rank 3—5 只是没有列在这张小表里。对本地官方表的全部 50,000 条规则核对,合并项都满足:

encoder[left + right] == 256 + rank

但 rank 回答的是「先用哪条规则」,ID 回答的是「模型里的哪个 token」。 基础条目没有对应的合并 rank,特殊 ID 也不从这条公式推出来。不能把两者当作同一概念。


bytes_to_unicode:名字容易误导的一张表

它不是拿原始字节做 UTF-8 解码。它建立一个 256 项的双射:一个字节值,恰好换成一个 Unicode 字符。 反表可以逐字符恢复原始字节。

先保留三个区间里可以直接显示的字符:

bs = list(range(ord("!"), ord("~") + 1)) # 33—126:94 个
bs += list(range(ord("¡"), ord("¬") + 1)) # 161—172:12 个
bs += list(range(ord("®"), ord("ÿ") + 1)) # 174—255:82 个
cs = bs[:]

共 94 + 12 + 82 = 188 个字节沿用同数值的码点。剩下 68 个是 0—32、127—160 和 173, 按字节值从小到大,依次映射到码点 256 + n。源码最后把 cs 变成 chr(c) 再 zip。

原始字节值别名为什么
0Ā(U+0100)第一个被挪走的字节
32Ġ(U+0120)第 33 个被挪走的字节,256 + 32
160ł(U+0142)在控制 / 空白区间里,继续占用新码点
189½落在保留区间,码点不变
228ä落在保留区间,码点不变

映射让空白和控制字节不必以原样出现在词表字符串里,也不会和 BPE 返回格式的分隔空格撞在一起。 无参数的 bytes_to_unicode() 用 lru_cache 缓存;它缓存的是映射表,和每个 Encoder 的 self.cache 不是一回事。

Ġ 不等于原文里真的写了 Ġ

原文一个空格:UTF-8 [32] → 别名 Ġ
原文字符 Ġ: UTF-8 [196,160] → 两个字节分别映射
原文字符 你: UTF-8 [228,189,160] → ä½ł

看 Ġthe 可以把 Ġ 读作「原始空格的别名」,但不能把整份词表当成经过乱码解码的原文。 这里用 Unicode 字符装字节;这些字符的语义不参与算法。


BPE 有两层循环,各自只管一件事

bpe(token) 的输入是一个正则块的别名字符串,不是整篇原文。 先命中缓存就直接返回;否则 word = tuple(token),get_pairs 列出相邻对。 单符号没有相邻对,直接返回原块,这条提前返回路径不会写缓存。

进入循环后:

  • 外层选当前相邻对中 rank 最小的一种对。
  • 内层固定这对,从左到右扫描整个 word,替换它的全部不重叠出现。
  • 整块扫描完才用 new_word 更新 word,重新列相邻对,再选下一轮规则。

下面是对应控制流的精简写法;完整历史源码出自 openai/gpt-2 的 src/encoder.py(MIT 许可):

while True: # 外层:选规则
pair = min(pairs, key=lambda p: bpe_ranks.get(p, float("inf")))
if pair not in bpe_ranks:
break
first, second = pair
new_word = []
i = 0
while i < len(word): # 内层:扫描整块
try:
j = word.index(first, i)
except ValueError:
new_word.extend(word[i:])
break
new_word.extend(word[i:j])
i = j
if i < len(word) - 1 and word[i + 1] == second:
new_word.append(first + second)
i += 2
else:
new_word.append(word[i])
i += 1
word = tuple(new_word)
if len(word) == 1:
break
pairs = get_pairs(word)

历史源码这里是裸 except;上面为了只表达「找不到 first」的分支,写成 except ValueError。

为什么不能合并完一处就换规则

人工规则 a+a 最早,输入 aaa:

word: a | a | a
本轮输出:aa | a

前两个旧位置已经被消费,第三个 a 不能再借用第二个位置。合出来的 aa 写在 new_word, 内层仍然读旧 word,所以新符号只能下一轮参与。

j = word.index(first, i) 找到的只是 first,不是整对。邻居不等于 second 时,当前符号必须原样留下。 word[i:j] 不包含 j,因此复制中间部分也没有把 first 提前消费掉。

三种「结束」不能混在一起

条件结束什么接下来
扫描到末尾,或找不到剩下的 first一轮内层扫描接收 new_word,再判断 / 选规则
当前相邻对都没有规则整个 BPE 合并循环返回现有多个符号
word 只剩一个符号整个 BPE 合并循环返回这个符号

每个有效外层轮次至少让符号数减少 1。起点 N 个符号,最多 N−1 个有效合并轮次; 一次可能同时合并多处,所以实际轮次更少。这是轮次数的上界,不是整个函数的时间复杂度:每轮还要扫描、列候选、查 rank。

从缓存与单符号提前返回,到外层选规则、内层扫描、更新序列与最终返回;两种循环的 break 含义不同

让每一步在动画里发生

BPE 两层循环:逐步播放、按轮跳转、五个执行例子新窗口打开 ↗

默认人工例子 aaababcabxyz 有九条规则,实际用了七轮。规则不是从这个字符串重新训练出来的:

轮次选中的对rank
1a + a0
2a + b1
3ab + c2
4x + y3
5xy + z4
6aa + ab5
7aaab + abc6

最后是 aaababc | ab | xyz。表里的 b+c、c+a 明明存在,后来却没机会被选中: 更早的规则先把它们的成员消费掉了。第 28 篇「表里有却用不上」在真实循环里就是这样发生的。

切到官方 internationalization:全表有 50,000 条,当前输入实际用的 rank 是 3, 5, 7, 9, 26, 39, 85, 97, 272, 503, 608, 1378, 22868, 45353,共十四轮。 最终 international | ization,编号 [45609,1634]。不是从 rank 0 开始把整张表依次扫十四行。

动画另外保留三个边界例子:

  • aaaaaaa 展示重叠候选如何只消费不重叠位置。
  • 单符号 a 不进入循环,也不写缓存。
  • 重复输入同一个块,直接命中之前真实执行留下的缓存。

动画使用源码实际运行的轨迹;教学例子与官方例子用的规则表不同,界面会明确标注。


bpe 返回的空格不是原始空格

合并结束后:

word = " ".join(word)
self.cache[token] = word
return word

这里返回值仍然是字符串。"Ġä½ł" 的最终符号是 Ġ、ä½、ł,所以 bpe 返回 "Ġ ä½ ł"。 encode 再 split(' '),查 encoder,得到 [220,19526,254]。

原文空格已经映射成 Ġ,返回格式里的普通空格只用来分隔符号,拆开后丢掉;它本身不会多产生一个模型 ID。

for token in re.findall(self.pat, text):
token = "".join(self.byte_encoder[b] for b in token.encode("utf-8"))
bpe_tokens.extend(self.encoder[s] for s in self.bpe(token).split(" "))

把 Hi、 你、! 的结果按块顺序摊平,得到开头那五个编号。


decode:先拼齐字节,最后只解码一次

text = "".join(self.decoder[token] for token in tokens)
raw = bytearray(self.byte_decoder[c] for c in text)
return raw.decode("utf-8", errors=self.errors)

先按 ID 查完整别名字符串并连起来;再逐别名字符恢复字节;最后统一 UTF-8 解码。 解码不执行 BPE,也不用反向重放合并规则。

19526 存的是 ä½,原始字节 [228,189];254 存的是 ł,原始字节 [160]。 二者分开解码都不是完整 UTF-8 字符,默认各变成 �。先拼成 [228,189,160] 才是 你。 所以不能逐 token 解成字符串,再把字符串拼起来。

错误也要区分:

  • ID 根本不在 decoder:查表抛 KeyError。
  • ID 有效,但整串原始字节不是合法 UTF-8:由 errors 决定替换或抛 UnicodeDecodeError。

历史源码不会自动识别特殊标记

encoder['<|endoftext|>'] 确实是 50256,但这份 encode 没有 allowed_special 分支。 原文直接写 "<|endoftext|>",结果是:

[27, 91, 437, 1659, 5239, 91, 29] # 七个普通 token

调用方若要专用标记,得显式插入 50256。直接 decode([50256]) 又能查表得到标记文字。 「词表里注册了它」「编码时把它当作特殊标记」「生成时遇到它停止」是三个不同环节。

下一篇的 minbpe 会用整数表示 BPE 符号,并增加特殊标记策略。 但不变的部分已经很清楚:块内按固定 rank 合并,编号查完整内容,所有字节拼齐以后才做 UTF-8 解码。