跳到主要内容

数据截至 (上游 commit 1acefe89412b)

03 · special tokens 与 GPT-4 精确复刻

这一章讲什么: 从「教学算法」到「生产复刻」的最后一公里,分两半。前半:special tokens(<|endoftext|> 这类控制符)怎么绕开 BPE 单独编解码,以及为什么默认要「见到就报错」。后半:GPT4Tokenizer 怎么把 tiktoken 手里那份「只存结果」的参数表,逆向成 minbpe 的合并表,做到与 GPT-4 分词逐 token 相等


第一部分:special tokens 全链路

1. 它要解决的小问题

训练对话模型时,语料里需要协议符号:<|endoftext|> 分隔文档、<|fim_prefix|> 标记填空位置。它们有三个特殊要求:

  • 必须是单 token:绝不能被 BPE 拆成 <|endoft……
  • 绝不进训练语料的统计:它们由框架插入,不是自然文本,不该参与合并学习。
  • 必须防伪造:如果用户输入里出现字面量 <|endoftext|>,默认不能让它静默变成控制 token——否则攻击者可以往 prompt 里注入文档边界,搅乱模型行为。

2. 直觉:走旁路,不进 BPE

处理方式是给 special tokens 修一条绕开整个 BPE 机器的旁路:

  • 编码:先按 special token 的字面出现把文本切成若干段;special 段直接查表映射成 id,普通段才走 encode_ordinary(02 章那把 regex 刀 + BPE)。
  • 解码:每个 id 先查 vocab(普通 token),查不到再查 inverse_special_tokens,都查不到就报错。
  • 默认防呆:encode(text) 的默认参数是 allowed_special="none_raise"——只要文本里出现任何已注册 special 的字面量,直接 assert 抛错。源码注释说这是 tiktoken 的同款默认,其他行为「either annoying, or a major footgun」(minbpe/regex.py:127-129)。

3. allowed_special 的四种语义

encode 的参数决定「哪些 special 字面量被识别」,四种取值(minbpe/regex.py:131-143):

取值语义文本里出现 special 字面量时
"all"全部识别切成对应的 special token id
"none"全部不识别当普通文本走 BPE
"none_raise"(默认)不识别,且禁止出现assert 报错(minbpe/regex.py:139)
set(自定义集合)只识别集合内的集合内的变 special id,集合外的当普通文本

识别结果为空集("none"、或通过断言的 "none_raise")时,直接短路到 encode_ordinary,旁路完全不启用(minbpe/regex.py:144-146)。

4. 原理演示(示意代码)

def encode(text, allowed_special): # 示意,非源码
special = resolve(allowed_special) # 上表四种取值 → 生效的 special 集合
if not special:
return encode_ordinary(text) # 无 special:走普通通道
pattern = "(" + "|".join(map(re.escape, special)) + ")"
ids = []
for part in re.split(pattern, text): # 捕获组让分隔符留在切分结果里
if part in special:
ids.append(special[part]) # special 段:查表,单 token
else:
ids.extend(encode_ordinary(part)) # 普通段:regex 切块 + BPE
return ids

5. 真实实现

  • 注册:register_special_tokens(minbpe/regex.py:72-76)存正查表 special_tokens(str→id),并顺手构建反查表 inverse_special_tokens(id→str)。注册时机在训练之后,id 规划要接在 merges 后面(约定见 README.md:107-115)。
  • 编码:encode(minbpe/regex.py:123-164)的关键两行是 special_pattern = "(" + "|".join(re.escape(k) for k in special) + ")"re.split(...)(minbpe/regex.py:152-153):re.escape 保证 <|...|> 里的符号按字面匹配;外层括号构成捕获组,让被切出的 special 段留在结果列表里,随后逐段分流(minbpe/regex.py:157-163)。
  • 解码:decode(minbpe/regex.py:78-90)逐 id 两级查找:先 self.vocab,再 self.inverse_special_tokens,两级都 miss 就 raise ValueError(f"invalid token id: {idx}")(minbpe/regex.py:87)——比 BasicTokenizer.decode 的裸 KeyError 友好。
  • 对拍:test_gpt4_tiktoken_equality_special_tokens 把一段混了五种 special 的文本,用 allowed_special="all" 在 minbpe 与 tiktoken 两边各编一遍,断言 id 序列相等(tests/test_tokenizer.py:72-77)。

第二部分:GPT4Tokenizer 精确复刻

6. 它要解决的小问题

想让 minbpe 编出和 GPT-4 一模一样的 token 序列,需要 tiktoken cl100k_base 的训练成果。但 enc._mergeable_ranks 给的是一张结果表:token 字节串 -> rank(id),比如 b"hello" -> 15339

minbpe 的引擎要的却是规则表:(左id, 右id) -> 新id(01 章的 merges)。问题是:tiktoken 根本不存「每个 token 是由哪两个子 token 焊起来的」——规则被刻意丢掉了(背景见源码注释引用的 tiktoken#60 与 minbpe#11,minbpe/gpt4.py:33-34)。

7. 直觉:对每个 token 做一次「限高 BPE」

逆向靠的是 BPE 的一个构造性质:每个 rank ≥ 256 的 token,恰好由两个 rank 更小的 token 合成(它出生那一刻,父母必然已经存在)。

于是对每个 token 重演它的出生过程:

  1. 把它的字节串拆成单字节序列。
  2. 反复合并「当前能合并、且 rank 小于该 token 自身 rank 的对中,rank 最小的那一对」——这就是 bpe()max_rank 参数的作用,限高防止「借道」比它晚出生的 token(minbpe/gpt4.py:22-23)。
  3. 合到最后必然只剩两个部件——这两个就是父母。记下 (父id, 母id) -> 该token的rank

遍历整张结果表,规则表就被完整重建出来。assert len(pair) == 2(minbpe/gpt4.py:40)是这套推理的自我校验:如果哪个 token 拆到最后不是两个部件,说明假设破了,立刻炸给你看。

8. 历史包袱:byte_shuffle

重建完 merges 还有最后一个坑:GPT-4 的前 256 个字节 token,id 顺序不是 0~255 的字节序,而是一次无规律的置换。源码注释原话:「completely non-sensical and probably historical」(minbpe/gpt4.py:72-75)。

处理方式是把它隔离成一层薄的「兼容层」,主算法一个字符都不改:

  • 构造时直接从结果表前 256 项读出置换:byte_shuffle = {i: mergeable_ranks[bytes([i])] for i in range(256)} 及其逆(minbpe/gpt4.py:76-77)。
  • 编码入口先置换字节:text_bytes = bytes(self.byte_shuffle[b] for b in text_bytes),再调父类的 _encode_chunk(minbpe/gpt4.py:81-85)。
  • 解码出口先按 vocab 拼出「置换空间」的字节,再逆置换回来(minbpe/gpt4.py:87-92)。

9. 装配顺序:GPT4Tokenizer.__init__

构造一个 GPT4Tokenizer 实际做了六件事(minbpe/gpt4.py:60-79):

① 拿 GPT-4 切分模式 super().__init__(pattern=GPT4_SPLIT_PATTERN)
② 拿 tiktoken 的结果表 tiktoken.get_encoding("cl100k_base")._mergeable_ranks
③ 反推规则表 recover_merges(mergeable_ranks)
④ 重建 vocab 按 merges 从 256 字节拼起(同 _build_vocab 思路)
⑤ 读字节置换 byte_shuffle / inverse_byte_shuffle
⑥ 注册 5 个 special tokens GPT4_SPECIAL_TOKENS(gpt4.py:49-55)

注意第 ③④ 步:这里 vocab 的 id 直接沿用 tiktoken 的 rank(merges[(ix0, ix1)] = rank,minbpe/gpt4.py:44),所以 minbpe 编出的 id 与 tiktoken 是同一套编号——这是对拍能逐 token 断言相等的前提。

10. 对拍与「不可存盘」的自觉

  • 对拍测试:test_gpt4_tiktoken_equality 对空串、单字符、中英 emoji 混合串、整篇维基文本,断言 minbpe 与 tiktoken 的 id 序列逐一相等(tests/test_tokenizer.py:62-69)。
  • 明确不支持的三个操作:train / save / load 全部 raise NotImplementedError(minbpe/gpt4.py:95-107)。注释解释了为什么:save/load 要支持 byte_shuffle 就得改基类格式,作者不愿「为 GPT-4 一家的怪癖弄脏漂亮的 Tokenizer」(minbpe/gpt4.py:98-102)。
  • 留了一扇窗:save_vocab 可以导出给人看的 vocab 文件(导出时顾及 byte_shuffle,minbpe/gpt4.py:109-130)——只读不写,与「不可存盘」不矛盾。

11. 全链路回顾:一次 GPT4Tokenizer().encode(text) 的完整旅程

text

▼ ① special 旁路(regex.py encode,allowed_special 分流)
[普通段 | special 段 | 普通段 …]
│ │
▼ ② 普通段 ▼ special 段
findall 切块 直接查 special_tokens 得 id
(GPT-4 模式) │
│ │
▼ ③ 逐块 │
byte_shuffle │
字节置换 │
│ │
▼ ④ │
_encode_chunk │
(min-rank BPE) │
│ │
▼ ⑤ ▼
各段 ids 按序拼接 ──► 最终 id 序列(与 tiktoken 逐 token 相等)

怎么读这张图: 从上往下是数据流;① 的旁路只被 special 字面量触发,②~④ 是普通文本的三级管道(切分 → 置换 → BPE 重演)。decode 就是沿同一管道反向走:查 vocab/反查表 → 逆置换 → UTF-8 解码。


12. 关键细节与坑

  1. none_raise 的断言是运行时检查,不是类型约束。 assert all(token not in text for token in self.special_tokens)(minbpe/regex.py:139)逐个 special 扫一遍文本——O(special 数 × 文本长)。生产里 tiktoken 用同一语义但在 Rust 里做,快得多。
  2. special 的 id 规划全手工。 注册接口不做任何连续性校验(minbpe/regex.py:72-76):id 撞了 merges、或两个 special 同 id,都要自己兜着。README 给的约定是「最后一个 merge 是 vocab_size-1,第一个 special 从 vocab_size 起」(README.md:107-115)。
  3. decode 只认注册过的 special。 GPT4Tokenizer 构造时只注册了 5 个(minbpe/gpt4.py:49-55);tiktoken 的 cl100k_base 里其他保留位 id 在 minbpe 这边解码会直接 ValueError(minbpe/regex.py:87)。
  4. recover_merges 的正确性押在「每个 token 恰有两个父母」上。 这条性质代码里无法直接证明,靠的是 BPE 训练过程的构造性质 + assert len(pair) == 2 运行时兜底(minbpe/gpt4.py:40)——对 tiktoken 的 cl100k_base 成立,换一个不遵守此性质的分词器就不保证。
  5. GPT4Tokenizer 是一次性装配品。 没有 save/load(minbpe/gpt4.py:103-107),每次构造都重新找 tiktoken 要参数、重跑一遍 recover_merges——把它当「tiktoken 的可读镜像」用,而不是一个可独立分发的模型。
  6. _encode_chunk 的置换发生在字节化之后、BPE 之前。 顺序错了全盘皆输:先 BPE 再置换,对 id 查 merges 就全乱了。GPT4Tokenizer._encode_chunk 先 shuffle 再 super()._encode_chunk(...) 的两行顺序是刻意的(minbpe/gpt4.py:81-85)。

上一章: 02 · regex 预切分与 GPT-4 模式 · 返回导读: index