当你观察 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。 在训练过程中,算法统计的是相邻字节出现的频率。如果 E4B8 经常连在一起出现,算法就会将它们合并为一个全新的 Token E4 B8,并加入词表。

这就引出了一个存储难题:如何在一个文本文件中安全地存储这些任意字节组合?

  • E4 B8 96 是一个合法的 Unicode 字符(“世”)。
  • 但合并后的 E4 B8 并不是一个合法的 UTF-8 序列,它无法直接显示为文本。
  • 如果直接用十六进制数字(如 0xE4 0xB8)存储,虽然可行,但对人类极不友好,且会大幅增加序列长度。

解决方案:字节到可打印字符的映射

为了解决上述问题,工程师们想出了一个巧妙的办法:将所有可能的字节值(0-255),一一映射到 Unicode 字符集中的一段“可打印字符”区域。

这样做的目的是:

  1. 兼容性:确保生成的 Token 序列是合法的 UTF-8 文本,可以被任何文本编辑器保存和传输。
  2. 可读性:尽量让人类能看出一点端倪,而不是看到一堆乱码控制符。

映射规则是如何制定的?

一个字节有 256 种可能(0x00 到 0xFF)。我们需要在 Unicode 中找到 256 个连续的、可打印的字符来对应它们。

可以参见 这张表这张表中的 Unicode字符,我们这里总结一下前 256 个字符,:

  • 0x00 - 0x1F:控制字符(不可打印)。
  • 0x20:空格(虽然可打印,但在分词中通常需要特殊处理以区分词边界)。
  • 0x21 - 0x7E:标准 ASCII 可打印字符(数字、字母、标点)。
  • 0x7F - 0xA0:部分控制字符及特殊空白。
  • …以此类推。

由于前 256 个字符中包含大量不可打印的控制符,直接使用它们会导致文件损坏或解析错误。因此,主流的字节级 BPE 实现(如 GPT-2 采用的方案)采取了以下策略:

  1. 保留基础 ASCII:将 0x210x7E 这些常见的可打印字符直接保留原样(即字节值等于字符编码值)。
  2. 映射剩余字节:对于剩下的字节(包括控制符 0x00-0x1F、空格 0x20 以及高位字节 0x80-0xFF),我们将它们映射到 Unicode 中后续的一段连续可打印区域。

通常,这段“备用区域”从 0x100 (256)开始。

揭晓谜底:为什么空格变成了 Ġ

现在我们可以计算空格(Space)的映射位置了。

  • 空格的字节值:0x20 (十进制 32)。
  • 映射起始点:由于 0x000x1F (0-31) 需要先被映射,它们占据了映射区的前 32 个位置。

计算偏移:

  • 字节 0x00 -> 映射到 Unicode 0x100 (Ā)
  • 字节 0x01 -> 映射到 Unicode 0x101 (ā)
  • 字节 0x20 (空格) -> 映射到 Unicode 0x100 + 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 是当前字节在“不可打印列表”中的索引。

  • 字节 0x000x1F (共 32 个控制符) 最先被放入列表,占据了索引 031
  • 字节 0x20 (空格) 是第 33 个进入列表的(索引 32)。
  • 因此,空格的映射目标正是 chr(256 + 32),即 chr(288),也就是 Ġ

Ġ 的出现没有任何深奥的语义含义,它纯粹是字节到字符映射表(Vocab Mapping)中的一个数学巧合。

这种设计体现了工程上的优雅:

  1. 它允许模型在字节级别进行精确的模式匹配。
  2. 它确保了整个词表都可以用标准的 UTF-8 文本格式存储。
  3. 它利用了 Unicode 丰富的字符空间,避开了那些会导致系统错误的控制字符。

启示:为什么全是“英文”的词表能懂中文?

这个问题让我困惑了很久:

GPT 的词汇表(Vocab)里看起来全是英文字母、数字和一些奇怪的重音符号(如 Ġ, ĉ, ň),完全没有中文字符。那它是怎么学会中文的?

研究完映射机制后,真相令人豁然开朗:模型根本“不认识”中文,也不认识英文。它只认识“字节”。

当我们看到词表中的 ABĠ 甚至 Ķ 时,请不要把它们当作人类语言中的字母。

  • 对模型而言,A 仅仅是字节 0x41 的代号。
  • Ġ 仅仅是字节 0x20 (空格) 的代号。
  • Ķ (U+0136) 仅仅是字节 0x56 (或者其他映射值) 的代号。

这些符号没有任何语言学意义,它们只是一个容器,用来安全地存储 0-255 之间的任意数值。

既然词表里没有“世”这个字,中文是怎么处理的?答案是:拆解为字节序列。

  1. 编码:中文汉字“世”在 UTF-8 中被编码为三个字节:[0xE4, 0xB8, 0x96]
  2. 映射:Tokenizer 将这三个字节分别映射为三个可见字符(假设映射后为 世 这样的形式,具体取决于映射表)。
  3. 学习:在训练过程中,模型发现 0xE40xB80x96 这三个“符号”经常连在一起出现。于是,BPE 算法会将它们合并,形成一个代表“世”的新 Token(或者由几个子词字节组合而成)。
  4. 结果:模型虽然没见过“世”这个图形,但它学会了 [0xE4, 0xB8, 0x96] 这个字节模式代表某种语义。

这种设计带来了两个惊人的优势:

  • 真正的多语言通用性:不需要为中文、日文、俄文或 Emoji 单独设计词表。只要一种语言能转换成字节(当然可以),模型就能处理它。这就是为什么 GPT 能处理几乎所有人类语言,甚至包括编程代码和二进制数据。
  • 没有“未登录词”(OOV):传统的分词器遇到生僻字可能会报 <UNK>(未知 token)。但字节级 BPE 永远不会遇到未知输入,因为任何输入最终都能拆解为那 256 个基础字节。哪怕是一个从未见过的生僻汉字,甚至是一串乱码,模型都能将其分解为字节序列进行处理。

所以,当你再次看到 GPT 输出的 Token 中包含 Ġ 或其他奇怪的拉丁字母时,请记住: 那不是英文,那是宇宙的基石——字节(Byte),穿上了 Unicode 的外衣。