Skip to content

第 23 章 相同的 BPE 算法,为什么还编码不出相同的 ID ​

实现完基础 BPE,很容易以为复制一份词表就能成为 GPT tokenizer。真正对齐时还会遇到三层细节:文本先怎样分块,特殊字符串是否作为控制标记,字节编号和合并排名怎样对应。只要其中一层不同,编码结果就可能不同,尽管 decode 后看起来都是同一句话。

本章对应 minbpe 练习第二到第四阶段。兼容目标明确固定为 tiktoken 0.9.0 的 cl100k_base;课程把它称作 GPT-4 tokenizer,这是课程所指模型时期的说法。本书不把这个名字推广到所有后来带有 GPT-4 名称的模型和 API。

23.1 先用正则分块,再在块内合并 ​

完全自由的 BPE 可能合并一个词末尾、空格和下一个词开头,也可能把数字与字母混在一起。GPT 系列通过正则表达式先把文本切成片段,BPE 只能在单个片段内部合并。最终把每个片段的 token ID 依序拼接。

训练时也遵守相同边界:每个片段内部统计相邻对,再把计数加总;不能先把所有片段的字节串重新接起来统计,否则又会学到跨边界的规则。推理时用不同的正则也会改变切分,即使 merges 文件没变。

本书的 ByteBPE(pattern=GPT4_PATTERN) 使用固定 minbpe 中的模式,第三方 regex 包支持其中的 Unicode 属性,例如 \p{L} 表示字母类别。Python 标准库 re 不能直接代替它。模式包含大小写不敏感的英语缩写、字母段、1 到 3 位数字段、标点以及不同空白情况。完整常量在代码文件中保留,不把复杂正则拆成几条近似规则冒充等价实现。minbpe regex.py

真实测试字符串 hello1234!\n中文 被分成:

text
[' hello', '123', '4', '!\n', '中文']

数字 1234 在预切分阶段已经分成 123 和 4,BPE 不允许把它们重新合成一个 token;而 中文 作为一个字母类别片段进入字节 BPE,最后可以拆成多个 token。预切分片段不是最终 token,两个概念要分开。

本书额外验证所有片段拼回去恰好等于原字符串。自定义正则若没覆盖某些字符,findall 可能默默漏掉它们,roundtrip 就已经在 BPE 之前失败。

23.2 空格既可能跟着词,也可能自己成片 ​

hello 与 hello 可能编码不同,因为前导空格可以与字母一起进入一个片段,训练后也可能属于同一个 token。尾随空格还可能在后面接入新文本时与新词组成片段。因此一般不能认为:

encode(a+b)=encode(a)+encode(b).

例如先独立编码一个末尾带空格的提示,再拼接单独编码的答案,未必等于编码完整文本。做补全任务的数据整理时,应先明确最终字符串和边界策略,再生成标签;否则你认为固定的提示 token 序列可能不是模型训练时看到的切分。

GPT-2 和 cl100k_base 的正则与合并表都存在差异。原 notebook 用多个空格的例子展示压缩行为变化。这里需要避免一句过宽的结论:“GPT-2 不合并空格”应当限定到具体的已发布词表和相应例子,不是所有使用同一正则训练的 BPE 都绝不可能学出空白片段。能够合并什么,同时由预切分边界和实际合并表决定。tiktoken 0.9.0 官方编码配置

补充实测:inspect_tokenizers.py 在 tiktoken 0.9.0 中将四个前导空格的 hello world!!! 编码为 GPT-2 的 6 项、cl100k_base 的 4 项。对 cl100k_base,完整编码 hello world 得到 [15339,1917],分别编码 hello 和 world 再拼接却得到 [15339,220,14957]。这直接验证了编码不对字符串拼接可加。详细记录在 qa/part3/tokenizer_comparison.json。

23.3 GPT-2 的字节映射不是把原始文本重新改了编码 ​

OpenAI 的 GPT-2 encoder.py 为每个原始字节分配一个便于处理的可见 Unicode 符号,再在这些符号上应用 BPE。它是一层可逆的字节到符号映射,便于把词条存到 JSON 和文本合并表中,不等于放弃 UTF-8 改成按原始 Unicode 码点训练。

原 notebook 下载的 encoder.json 保存 token 表,vocab.bpe 保存有序合并规则。发布的 GPT-2 词表有 50,257 项,通常可理解为 256 个基础字节项、50,000 次合并与一个特殊项。这里的数字指该发布词表,不是 BPE 算法要求。读文件时还要处理头部版本行、词条转义和合并顺序,不能把文件中显示的“字符”直接当作原始输入字符。OpenAI GPT-2 encoder.py

23.4 特殊 token 是协议的一部分,不是常见词的缩写 ​

普通 BPE token 来自字节片段合并;特殊 token 则由系统明确注册,常表示文档结束、填空边界或其他结构。字符串 <|endoftext|> 既可以作为用户输入的一段普通文字,也可以被识别为一个保留 ID。两种处理结果不同,应用必须明确谁有权把它当作协议标记。

本书提供三种行为。默认 allowed_special=None:输入包含任何已注册保留字面串就报错,让调用方作决定。传入空集合:全部按普通文本编码。传入明确集合或 all:只把获准标记转换成特殊 ID。特殊 ID 必须与普通词表分离,且彼此唯一;相互重叠的标记按长者优先匹配。

python
tok.register_special({'<|end|>': 1000})
tok.encode('x<|end|>')                       # 抛错
tok.encode('x<|end|>', allowed_special=set()) # 当作普通文字
tok.encode('x<|end|>', allowed_special='all') # 含特殊 ID 1000

把来自不可信内容的任意文本用 allowed_special='all' 编码,可能把普通内容提升成协议控制标记。实际影响取决于下游如何解释该 ID,并非每个标记都会自动终止生成,但边界不能交给输入者随意改变。稳妥做法是让应用程序显式插入协议 token,把用户内容按普通文本编码;特殊 token 检查也不等于解决了语义层面的提示注入。tiktoken encode 的处理约定

23.5 cl100k_base 的 rank 和基础字节 ID 也有历史约定 ​

minbpe 的 GPT4Tokenizer 用 tiktoken 已发布的 _mergeable_ranks 恢复合并结构。映射的 key 是完整字节片段,value 是其 rank/编号。对每个较长片段,只应用比它更早的合并,直到剩下两个子片段,再记录它由谁合成。这是恢复既有规则的过程,不是在自己的语料上重新训练 GPT 词表。

另一个陷阱是前 256 个单字节 token 的 ID 顺序并非 id == byte_value。我们实际读取到空格字节 b' ' 的 ID 为 220,而不是 ASCII 值 32。minbpe 用 byte_shuffle 处理这层置换;如果直接沿用自己的 0–255 编号,得到的最终 ID 会错。固定 minbpe gpt4.py

本书兼容实验采取另一条更短的实现路线:始终把当前片段表示为 bytes,直接查询 ranks[left+right],每次合并最小 rank 的相邻片段,最后通过 ranks 取 ID。这样字节 ID 置换自然包含在最后查表里,不必先恢复整棵合并图。代码是教学用慢速实现,会反复扫描相邻对,不用于追求吞吐量。

依赖 _mergeable_ranks 和 _pat_str 属于读取私有属性,未来版本可变化,因此脚本固定了 tiktoken 0.9.0。它不应被包装成永远稳定的公开 API。

23.6 真正的兼容验证,要比较 ID 而不只比较文字 ​

examples/part3/check_cl100k.py 检查 9 条固定文本和 100 条 seed=1729 生成的混合字符串,包含中韩文、emoji、数字、重音、空白和特殊 token 的普通字面表示。109 条全部与 enc.encode_ordinary 的 ID 列表精确一致,decode 也全部还原原输入。

额外检查表明,<|endoftext|> 在允许特殊模式下是 ID 100257;按普通文本编码时是 [27,91,8862,728,428,91,29]。默认 enc.encode 拒绝未获允许的该标记,脚本检查这一行为。

词表通过 tiktoken 官方配置引用的 cl100k_base.tiktoken 下载,缓存大小 1,681,126 字节,SHA-256 为 223921b76ee99bde995b7ff738513eef100fb51d18c93597a113bcffe865b2a7。它含 100,256 个可合并词条;特殊 token 单独管理,因此词条数、最大 ID 加一和全部有效 ID 数不一定相同。结果文件为 qa/part3/cl100k.json。

这些是有限测试集上的精确匹配,不是数学意义上的全输入证明。我们没有比较非法代理码点修复、全部特殊 token 组合、所有 Unicode 版本差异或聊天 API 的消息封装。聊天消息的角色、工具参数和边界格式是另一层协议,不能把普通文本 tokenizer 的一致性说成完整请求计费或上下文行为一致。

练习与答案 ​

  1. 课程第二阶段: 正则分块后,为什么训练统计要跨块累加,却不能跨块统计相邻对?
  2. 课程第三阶段: 如果自己的 decode 与 tiktoken 都返回同一句话,能证明 encode 一致吗?
  3. 课程第四阶段: 特殊标记来自用户上传文档时,何时应允许它变成特殊 ID?

答案。 第一题每个候选对的总频率应反映整个训练集,但块边界代表禁止合并的位置;直接拼接会学到原本不存在的允许邻接关系。第二题不能,同一字节串可由多种 token 序列表示,必须逐项比较 ID。第三题只有应用明确授权把该片段作为协议结构时才应转换,不能仅因字面串恰好匹配就授权;普通文档内容通常应按普通文本编码或按明确策略拒绝。

本章来源:主要实现与版本范围已在相应小节给出;兼容实验只使用 tiktoken 官方发布词表,未训练或下载 GPT 模型参数。

保留各书来源与许可 · 示例运行条件以正文为准