当你观察 ChatGPT 或其他基于 Transformer 的大模型输出的 Token 时,可能会发现一个奇怪的现象:单词前的空格往往被表示为一个特殊的字符 Ġ(U+0120)。
例如:
"Hello world"可能被分词为["Hello", "Ġworld"]。
这并非某种神秘的魔法,也不是算法刻意选择的“特殊符号”。要理解这一点,我们需要深入 Byte-level BPE(字节级字节对编码)的实现细节。
BPE 算法的实现可以参考 slp3-py。
BPE 操作的是“字节”,而非“字符”
首先,我们需要明确一个核心概念:现代大模型的 BPE 算法通常直接操作字节(Bytes),而不是人类可读的字符。
在 UTF-8 编码中,一个字符可能由 1 到 4 个字节组成。例如,中文汉字“世”的 UTF-8 编码是三个字节:E4 B8 96。
在训练过程中,算法统计的是相邻字节出现的频率。如果 E4 和 B8 经常连在一起出现,算法就会将它们合并为一个全新的 Token E4 B8,并加入词表。
这就引出了一个存储难题:如何在一个文本文件中安全地存储这些任意字节组合?
E4 B8 96是一个合法的 Unicode 字符(“世”)。- 但合并后的
E4 B8并不是一个合法的 UTF-8 序列,它无法直接显示为文本。 - 如果直接用十六进制数字(如
0xE4 0xB8)存储,虽然可行,但对人类极不友好,且会大幅增加序列长度。
解决方案:字节到可打印字符的映射
为了解决上述问题,工程师们想出了一个巧妙的办法:将所有可能的字节值(0-255),一一映射到 Unicode 字符集中的一段“可打印字符”区域。
这样做的目的是:
- 兼容性:确保生成的 Token 序列是合法的 UTF-8 文本,可以被任何文本编辑器保存和传输。
- 可读性:尽量让人类能看出一点端倪,而不是看到一堆乱码控制符。
映射规则是如何制定的?
一个字节有 256 种可能(0x00 到 0xFF)。我们需要在 Unicode 中找到 256 个连续的、可打印的字符来对应它们。
可以参见 这张表这张表中的 Unicode字符,我们这里总结一下前 256 个字符,:
0x00-0x1F:控制字符(不可打印)。0x20:空格(虽然可打印,但在分词中通常需要特殊处理以区分词边界)。0x21-0x7E:标准 ASCII 可打印字符(数字、字母、标点)。0x7F-0xA0:部分控制字符及特殊空白。- …以此类推。
由于前 256 个字符中包含大量不可打印的控制符,直接使用它们会导致文件损坏或解析错误。因此,主流的字节级 BPE 实现(如 GPT-2 采用的方案)采取了以下策略:
- 保留基础 ASCII:将
0x21到0x7E这些常见的可打印字符直接保留原样(即字节值等于字符编码值)。 - 映射剩余字节:对于剩下的字节(包括控制符
0x00-0x1F、空格0x20以及高位字节0x80-0xFF),我们将它们映射到 Unicode 中后续的一段连续可打印区域。
通常,这段“备用区域”从 0x100 (256)开始。
揭晓谜底:为什么空格变成了 Ġ?
现在我们可以计算空格(Space)的映射位置了。
- 空格的字节值:
0x20(十进制 32)。 - 映射起始点:由于
0x00到0x1F(0-31) 需要先被映射,它们占据了映射区的前 32 个位置。
计算偏移:
- 字节
0x00-> 映射到 Unicode0x100(Ā) - 字节
0x01-> 映射到 Unicode0x101(ā) - …
- 字节
0x20(空格) -> 映射到 Unicode0x100 + 0x20=0x120
查一下 Unicode 表,U+0120 对应的字符正是 Ġ。
所以,当你在 Token 中看到 Ġ 时,解码器知道:“哦,这代表原始字节流中的 0x20,也就是一个空格。”
代码验证
我们可以参考 OpenAI 官方 tiktoken 库的逻辑,用几行 Python 代码来复现这个映射过程。
核心逻辑非常简洁:遍历 0-255 的所有字节,如果是可打印字符且不是空格,则保留原样;否则,从 Unicode 第 256 号字符开始依次映射。
from typing import List, Tuple
def create_byte_mapping() -> List[Tuple[int, str]]:
"""
模拟 tiktoken 的字节到字符映射逻辑
"""
mapping = []
non_printable_bytes = []
# 1. 收集所有可打印字符 (排除空格)
for b in range(256):
char = chr(b)
# isprintable() 会过滤掉控制字符,我们额外排除空格 ' '
if char.isprintable() and char != " ":
mapping.append((b, char))
else:
non_printable_bytes.append(b)
# 2. 将不可打印字符 (包括空格) 映射到 256 之后的区域
# 这里的逻辑就是:target_code = 256 + index_in_non_printable_list
for i, b in enumerate(non_printable_bytes):
target_char = chr(256 + i)
mapping.append((b, target_char))
return mapping
# 执行映射并查找空格
mapping = create_byte_mapping()
# 找到空格 (字节值 32) 对应的字符
space_byte = 32
target_char = None
for src, tgt in mapping:
if src == space_byte:
target_char = tgt
break
print(f"字节 0x{space_byte:02X} (空格) 被映射为: '{target_char}'")
print(f"其 Unicode 编码为: U+{ord(target_char):04X}")
# 验证一下前几个不可打印字符的映射
print("\n部分映射示例 (不可打印字符 -> 新字符):")
# 假设 0x00 是 non_printable_bytes 的第一个
first_few = [b for b in range(10)]
for b in first_few:
for src, tgt in mapping:
if src == b:
print(f"0x{b:02X} -> {tgt} (U+{ord(tgt):04X})")
break
运行结果
当你运行这段代码时,会得到如下输出:
字节 0x20 (空格) 被映射为: 'Ġ'
其 Unicode 编码为: U+0120
部分映射示例 (不可打印字符 -> 新字符):
0x00 -> Ā (U+0100)
0x01 -> ā (U+0101)
0x02 -> Ă (U+0102)
...
0x1F -> ſ (U+017F)
0x20 -> Ġ (U+0120) <-- 注意这里,0x20 排在 0x00-0x1F 之后,所以是 256 + 32
代码解读
请注意代码中的关键一行:
target_char = chr(256 + i)
这里的 i 是当前字节在“不可打印列表”中的索引。
- 字节
0x00到0x1F(共 32 个控制符) 最先被放入列表,占据了索引0到31。 - 字节
0x20(空格) 是第 33 个进入列表的(索引32)。 - 因此,空格的映射目标正是
chr(256 + 32),即chr(288),也就是Ġ。
Ġ 的出现没有任何深奥的语义含义,它纯粹是字节到字符映射表(Vocab Mapping)中的一个数学巧合。
这种设计体现了工程上的优雅:
- 它允许模型在字节级别进行精确的模式匹配。
- 它确保了整个词表都可以用标准的 UTF-8 文本格式存储。
- 它利用了 Unicode 丰富的字符空间,避开了那些会导致系统错误的控制字符。
启示:为什么全是“英文”的词表能懂中文?
这个问题让我困惑了很久:
GPT 的词汇表(Vocab)里看起来全是英文字母、数字和一些奇怪的重音符号(如
Ġ,ĉ,ň),完全没有中文字符。那它是怎么学会中文的?
研究完映射机制后,真相令人豁然开朗:模型根本“不认识”中文,也不认识英文。它只认识“字节”。
当我们看到词表中的 A、B、Ġ 甚至 Ķ 时,请不要把它们当作人类语言中的字母。
- 对模型而言,
A仅仅是字节0x41的代号。 Ġ仅仅是字节0x20(空格) 的代号。Ķ(U+0136) 仅仅是字节0x56(或者其他映射值) 的代号。
这些符号没有任何语言学意义,它们只是一个容器,用来安全地存储 0-255 之间的任意数值。
既然词表里没有“世”这个字,中文是怎么处理的?答案是:拆解为字节序列。
- 编码:中文汉字“世”在 UTF-8 中被编码为三个字节:
[0xE4, 0xB8, 0x96]。 - 映射:Tokenizer 将这三个字节分别映射为三个可见字符(假设映射后为
世这样的形式,具体取决于映射表)。 - 学习:在训练过程中,模型发现
0xE4、0xB8、0x96这三个“符号”经常连在一起出现。于是,BPE 算法会将它们合并,形成一个代表“世”的新 Token(或者由几个子词字节组合而成)。 - 结果:模型虽然没见过“世”这个图形,但它学会了
[0xE4, 0xB8, 0x96]这个字节模式代表某种语义。
这种设计带来了两个惊人的优势:
- 真正的多语言通用性:不需要为中文、日文、俄文或 Emoji 单独设计词表。只要一种语言能转换成字节(当然可以),模型就能处理它。这就是为什么 GPT 能处理几乎所有人类语言,甚至包括编程代码和二进制数据。
- 没有“未登录词”(OOV):传统的分词器遇到生僻字可能会报
<UNK>(未知 token)。但字节级 BPE 永远不会遇到未知输入,因为任何输入最终都能拆解为那 256 个基础字节。哪怕是一个从未见过的生僻汉字,甚至是一串乱码,模型都能将其分解为字节序列进行处理。
所以,当你再次看到 GPT 输出的 Token 中包含 Ġ 或其他奇怪的拉丁字母时,请记住:
那不是英文,那是宇宙的基石——字节(Byte),穿上了 Unicode 的外衣。