问 GPT 类模型 “strawberry 里有几个 r”,它曾经经常答错;让它把一个长数字倒过来写,也经常出错。这些毛病多半不在 Transformer 本身,而在它之前的那一步:模型从来没有”看见”过字母,它看到的是一串整数 ID,每个 ID 代表一段长短不一的字节。把字符串切成这种 ID 序列的组件叫 tokenizer。这一篇从零写一个字节级 BPE,看清楚这些整数是怎么来的。

前置:信息的表示(字节与编码)、从线性回归到神经网络

第一步只能是数字:Unicode 和 UTF-8

神经网络的输入是张量,所以第一件事是把字符串变成整数。Python 里最直接的做法是 ord,它返回字符的 Unicode 码点:

s = "Hello, 世界! 🌍"
print([ord(c) for c in s])
b = s.encode("utf-8")
print(len(s), "chars ->", len(b), "bytes")
print(list("世".encode("utf-8")), list("🌍".encode("utf-8")))
print(len(s.encode("utf-16")), len(s.encode("utf-32")))
[72, 101, 108, 108, 111, 44, 32, 19990, 30028, 33, 32, 127757]
12 chars -> 19 bytes
[228, 184, 150] [240, 159, 140, 141]
28 52

直接拿码点当 token ID 行不通。Unicode 有约 15 万个已分配字符,码点上限是 0x10FFFF(一百多万),embedding 表要么巨大,要么大部分行几乎从未被训练到;而且一个字符一个 token,序列太长。

UTF-8 把每个码点编码成 1 到 4 个字节:ASCII 是 1 个,常用汉字 3 个,emoji 4 个。如果把字节当 token,词表正好是 256,任何字符串都能表示,永远不会遇到”未知字符”。代价是序列更长,上面 12 个字符变成了 19 个字节。为什么选 UTF-8 而不是 UTF-16 / UTF-32?同一个字符串在 UTF-16 下占 28 字节(含 2 字节 BOM,emoji 还要用一对代理项),UTF-32 占 52 字节,绝大多数字节是 0,把序列拉长却不带信息。互联网文本以 ASCII 为主,UTF-8 对它最省。

还有一个容易踩的坑:字节序列不能逐字节解码。"世".encode() 是 [228, 184, 150],单独拿 bytes([228]) 去 .decode("utf-8") 会报错,因为它只是一个多字节字符的开头。所以后面写 decode 时一定要先把所有 token 的字节拼起来,再整体解码。

停一下,检查理解单选

下面哪个解码函数在输入 "世界".encode("utf-8") 时会出错?

字节太长,词太稀疏:中间地带在哪里

现在有了两个极端。按字节切,词表 256,但序列长:注意力的计算量随序列长度平方增长,一段话变成三倍长的序列,就要付出将近九倍的注意力计算;而且每个 token 承载的信息太少,模型得在更深的层里把字节重新拼成”词”。按词切(空格分开),序列短,但词表爆炸:英文里的 run/runs/running/ran 各占一个 ID,拼写错误、新词、代码标识符全都没见过,只能映射成一个 <UNK>,信息直接丢掉。

我们要的是一种切法:常见的片段(the、ing、 of)合成一个 token,少见的拆成更小的片段,最坏情况退化到单个字节。这样词表大小可以自己定(GPT-2 是 50257,GPT-4o 的 o200k_base 约 20 万),且永远没有 <UNK>。衡量一个 tokenizer 切得好不好,最直观的数是压缩率:平均每个 token 对应多少字节。字节级是 1.0,好的英文 tokenizer 大约 4 字节/token。

这种”从字节开始、反复把最常见的相邻对合并”的算法就是 BPE(Byte Pair Encoding)。它原本是 1994 年的一种数据压缩算法,2016 年 Sennrich 等人把它用到机器翻译的子词切分,GPT-2 把它改成在字节上运行,于是有了今天几乎所有 LLM 用的字节级 BPE。

从零写 BPE:数对、合并、重复

算法只有三步:统计所有相邻 token 对出现的次数;把出现最多的那一对合并成一个新 token;重复,直到词表达到目标大小。在一句话上试一下:

from collections import Counter
def get_stats(ids):
    return Counter(zip(ids, ids[1:]))
def merge(ids, pair, new_id):
    out, i = [], 0
    while i < len(ids):
        if i + 1 < len(ids) and (ids[i], ids[i+1]) == pair:
            out.append(new_id); i += 2
        else:
            out.append(ids[i]); i += 1
    return out
text = "the cat in the hat sat on the mat"
ids = list(text.encode("utf-8"))
vocab = {i: bytes([i]) for i in range(256)}
merges = {}
print("start:", len(ids), "tokens")
for k in range(5):
    stats = get_stats(ids)
    # 频次相同时按字典序取较大的一对,保证训练结果确定可复现
    pair = max(stats, key=lambda p: (stats[p], p))
    new_id = 256 + k
    ids = merge(ids, pair, new_id)
    merges[pair] = new_id
    vocab[new_id] = vocab[pair[0]] + vocab[pair[1]]
    print(f"merge {k}: {vocab[pair[0]]!r}+{vocab[pair[1]]!r} -> {new_id} {vocab[new_id]!r}  count={stats[pair]}  len={len(ids)}")
start: 33 tokens
merge 0: b'a'+b't' -> 256 b'at'  count=4  len=29
merge 1: b'at'+b' ' -> 257 b'at '  count=3  len=26
merge 2: b't'+b'h' -> 258 b'th'  count=3  len=23
merge 3: b'th'+b'e' -> 259 b'the'  count=3  len=20
merge 4: b'the'+b' ' -> 260 b'the '  count=3  len=17

5 次合并,序列从 33 缩到 17。训练的产物有两样:vocab(ID 到字节串的表)和 merges(有序的合并规则列表)。注意新 token 是由旧 token 拼成的,'the ' 是 'the' 和 ' ' 拼的,'the' 又是 'th' 和 'e' 拼的,合并天然形成一棵树。

编码新文本时,必须按训练时的合并顺序来:每次在当前序列里找出”训练时最早学到”的那一对去合并,直到没有可用规则。顺序不能乱,因为后面的规则是建立在前面规则的产物之上的,('th','e') 要求 'th' 已经存在。

def encode(text):
    ids = list(text.encode("utf-8"))
    while len(ids) >= 2:
        stats = get_stats(ids)
        pair = min(stats, key=lambda p: merges.get(p, float("inf")))
        if pair not in merges: break   # 没有任何一对在规则表里,结束
        ids = merge(ids, pair, merges[pair])
    return ids
def decode(ids):
    # 先拼字节再整体解码;errors="replace" 防止模型生成出不完整的 UTF-8
    return b"".join(vocab[i] for i in ids).decode("utf-8", errors="replace")
e = encode("the hat on the cat")
print(e, [vocab[i] for i in e])
print(decode(e))
[260, 104, 257, 111, 110, 32, 260, 99, 256] [b'the ', b'h', b'at ', b'o', b'n', b' ', b'the ', b'c', b'at']
the hat on the cat

decode(encode(s)) == s 对任意字符串成立,这是字节级 BPE 的保证。反过来不成立:模型生成的 token 序列可能在一个汉字的中间停下,所以 decode 里要有 errors="replace"。

停一下,检查理解单选

编码时如果不按 merge 的训练顺序,而是"每次合并当前序列里出现次数最多的一对",会怎样?

它在哪里坏掉:跨词合并与预分词

上面的第二次合并 'at'+' ' 已经暴露了问题:空格被吸到了词的后面。如果在真实语料上跑下去,会出现 'dog.'、'dog!'、'dog?' 各占一个 token 的情况。同一个词因为后面跟的标点不同,占用了好几个词表位置,词表被浪费,模型还得分别学这几个 token 的意思。

GPT-2 的修法是在 BPE 之前加一步预分词(pre-tokenization):先用一个正则把文本切成”词、数字、标点、空白”这样的片段,BPE 只在片段内部合并,不跨片段。

import regex as re   # 第三方 regex 库,支持 \p{L} 这种 Unicode 类别
PAT = r"""'(?:[sdmt]|ll|ve|re)| ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+"""
print(re.findall(PAT, "I'll pay $1234.50 for the cat's   hat!!\n"))
['I', "'ll", ' pay', ' $', '1234', '.', '50', ' for', ' the', ' cat', "'s", '  ', ' hat', '!!', '\n']

读一下这个正则在做什么。 ?\p{L}+ 把一个可选的前导空格和一串字母绑在一起,所以英文词总是以 ' the'、' cat' 的形式出现,空格放在词前而不是词后。's、'll 这类缩写单独切。\s+(?!\S) 是个小技巧:三个连续空格里,前两个单独成段,最后一个留给后面的 hat,这样 hat 无论前面有几个空格都是同一个 token。数字和字母分开,标点和字母分开。

预分词还带来一个工程上的好处:语料可以先变成”片段 → 出现次数”的表。一份几 GB 的英文语料,不同的片段可能只有几十万种,BPE 只需要在这张表上做,而不是在几十亿个字节上做。

停一下,检查理解多选

关于 GPT-2 风格的预分词,哪些说法正确?

在真实语料上训练,以及为什么要增量更新

把上面两步合起来,在一份 133KB 的英文语料上训练 500 次合并。词用”字节 ID 元组 → 频次”的字典表示:

import time, regex as re
from collections import Counter
PAT = r"""'(?:[sdmt]|ll|ve|re)| ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+"""
text = open("corpus.en", encoding="utf-8").read()
words = Counter(tuple(m.encode()) for m in re.findall(PAT, text))
print(f"{len(text.encode())} bytes, {sum(words.values())} pre-tokens, {len(words)} unique")
 
def merge_word(w, pair, new):
    out, i = [], 0
    while i < len(w):
        if i + 1 < len(w) and (w[i], w[i+1]) == pair:
            out.append(new); i += 2
        else:
            out.append(w[i]); i += 1
    return tuple(out)
 
def train(words, n_merges):
    words = dict(words)
    vocab = {i: bytes([i]) for i in range(256)}
    merges = []
    for k in range(n_merges):
        stats = Counter()
        for w, f in words.items():          # 每轮全量重数:简单但慢
            for p in zip(w, w[1:]):
                stats[p] += f               # 一个片段出现 f 次,它里面的每个 pair 就贡献 f 次
        pair = max(stats, key=lambda p: (stats[p], vocab[p[0]], vocab[p[1]]))
        new = 256 + k
        vocab[new] = vocab[pair[0]] + vocab[pair[1]]
        merges.append(pair)
        words = {merge_word(w, pair, new): f for w, f in words.items()}
    return vocab, merges, words
 
t = time.time()
vocab, merges, final = train(words, 500)
print(f"500 merges in {time.time()-t:.1f}s")
print("first 10:", [vocab[256+i] for i in range(10)])
print("longest:", sorted(vocab.values(), key=len)[-5:])
n_tok = sum(len(w)*f for w, f in final.items())
print(f"tokens after: {n_tok}, bytes/token = {len(text.encode())/n_tok:.2f}")
133027 bytes, 27758 pre-tokens, 4763 unique
500 merges in 5.8s
first 10: [b' t', b' a', b'he', b'in', b' the', b're', b' o', b' ,', b'er', b' s']
longest: [b'formation', b' ecommerce', b' distribut', b' WordPress', b' information']
tokens after: 53636, bytes/token = 2.48

最先学到的是 t、 a、he、in,第 5 次就合成了 the,正是英文里最高频的东西。 , 排进前十,是因为这份语料习惯在逗号前加空格。500 次合并后压缩率到 2.48 字节/token;把合并次数加到 2000,压缩率升到 3.49,最长 token 变成 righteousness、 Geschäftsordnung(语料里混有德语)。

问题在速度。这段代码每一轮都把所有片段重新数一遍,500 次用了 5.8 秒,2000 次用了 28.9 秒。真实训练要在几 GB 语料上做几万次合并,这样根本跑不完。

观察一下:一次合并只影响包含这个 pair 的那些片段,其他片段的 pair 计数一个都没变。所以可以维护两个索引:pair_counts[pair] 记全局计数,containing[pair] 记哪些片段含有它。每次合并只遍历 containing[best] 里的片段:先把这个片段原来贡献的所有 pair 计数减掉,合并后再把新的 pair 计数加回去。同一份语料上,这样写的增量版本 500 次合并 0.35 秒,2000 次 1.32 秒,快了一个多数量级,而且差距随语料变大继续拉开。到这一步,瓶颈会从合并循环转移到正则预分词上,再往下优化就是把语料按文档边界切块,用多进程并行做预分词。

还有一个细节是 special token。训练语料里文档之间用 <|endoftext|> 隔开,它必须作为一个整体加进词表,并且在预分词之前先按它切开文本,否则 BPE 可能把 <| 和前一篇文章的结尾合并。编码时同理:先把 special token 原样抠出来分配固定 ID,剩下的部分再走正则和 BPE。由此可以算出词表大小的账:初始 256 个字节 + special token 个数 + 合并次数。目标词表 10000、一个 special token,就要做 10000 - 256 - 1 = 9743 次合并。

停一下,检查理解单选

增量更新的 BPE 训练,每次合并后需要更新哪些片段的 pair 计数?

现实中的 tokenizer:词表大小与各种怪现象

用 tiktoken 看两个真实 tokenizer:GPT-2 的 gpt2(词表 50257)和 GPT-4o 的 o200k_base(词表 200019)。

import tiktoken
g = tiktoken.get_encoding("gpt2"); o = tiktoken.get_encoding("o200k_base")
samples = {
 "en": "The quick brown fox jumps over the lazy dog.",
 "zh": "敏捷的棕色狐狸跳过了那只懒狗。",
 "code": "    for i in range(10):\n        print(i)",
 "num": "12345 + 67890 = 80235",
}
for k, s in samples.items():
    print(f"{k:5s} bytes={len(s.encode()):3d} gpt2={len(g.encode(s)):3d} o200k={len(o.encode(s)):3d}")
for s in ["hello", " hello", "Hello", " HELLO"]:
    print(repr(s), g.encode(s))
for s in ["12345", "123456789", "2024", "1999"]:
    print(s, [g.decode([t]) for t in g.encode(s)], [o.decode([t]) for t in o.encode(s)])
en    bytes= 44 gpt2= 10 o200k= 10
zh    bytes= 45 gpt2= 34 o200k= 16
code  bytes= 40 gpt2= 22 o200k= 12
num   bytes= 21 gpt2=  9 o200k= 10
'hello' [31373]
' hello' [23748]
'Hello' [15496]
' HELLO' [47899, 46]
12345 ['123', '45'] ['123', '45']
123456789 ['123', '45', '67', '89'] ['123', '456', '789']
2024 ['20', '24'] ['202', '4']
1999 ['1999'] ['199', '9']

这几行输出能解释开头的很多怪现象。

语言不公平。 同样一句话,英文两种 tokenizer 都是 10 个 token;中文 GPT-2 要 34 个,几乎是一个字节一个 token(GPT-2 的训练语料以英文为主,汉字的三字节组合很少被合并),o200k_base 降到 16 个。token 数直接决定了 API 计费、上下文窗口能装多少内容、生成要跑多少步,所以词表大、多语言数据充分的 tokenizer 对非英文用户实打实地便宜和快。

代码里的空白。 GPT-2 对 Python 的缩进很浪费,连续空格被切成好几个 token;后来的 tokenizer 专门让多个空格能合并成一个 token,同一段代码从 22 个降到 12 个。

大小写和空格让同一个词变成不同 ID。 hello、 hello、Hello 是三个毫不相干的整数, HELLO 还要拆成两块。模型得从数据里分别学会它们是同一个词,这就是为什么 prompt 末尾多一个空格有时会让输出变差:模型在训练中几乎总是看到 hello 这样空格和词连在一起的 token,一个孤零零的尾随空格 token 很少见。

数字切法随意。 GPT-2 把 123456789 切成 123|45|67|89,1999 是一个 token 而 2024 是两个,切法取决于训练语料里哪些数字串常见。模型做多位数加法时,同一位上的数字可能落在不同 token 的不同位置,对齐都难。o200k_base 改成预分词时把数字按最多 3 位一组从左切,规律多了。至于开头的 strawberry 问题,原因也一样:这个词只是几个 token,模型从没直接看到逐个字母,要数 r 只能靠记忆。

词表大小是一个权衡。 词表越大,序列越短(注意力和整体计算都省),但 embedding 和输出层参数变多,且长尾 token 训练得不充分。GPT-2 词表里有一个 SolidGoldMagikarp token,它在训练 tokenizer 的语料里高频出现(某论坛用户名),在模型的训练语料里却几乎没有,于是它的 embedding 基本没被训练过,早期模型遇到它会产生古怪输出。这类 “glitch token” 说明 tokenizer 和模型最好用分布一致的数据训练。现在主流英文为主的模型用 5 万到 10 万的词表,多语言模型更大,到 15 万、20 万甚至 25 万。词表对模型参数量的影响见 资源核算与 Scaling Laws。

停一下,检查理解多选

根据上面的实验,哪些现象可以由 tokenizer 解释?

能不能不要 tokenizer

tokenizer 是整个系统里唯一不经过梯度下降训练的部分,它独立训练、规则固定,带来上面那一串毛病。很自然的想法是直接在字节上建模。难点在于前面算过的账:字节序列比 BPE 长约 4 倍,注意力的代价是平方级的。研究上的方向是让模型自己学会”分组”:先用一个小网络在字节上动态地把相邻字节合并成块(比如按下一个字节的预测难度来决定块的边界),再让大模型在块上计算,这就是一类 byte-level / tokenizer-free 架构。它们在一些规模上已经能和 BPE 打平,但工业界的主流模型目前仍然使用 BPE。

另一个常见的问题是 SentencePiece。它是 Google 的一个 tokenizer 库,支持 BPE 和 Unigram 两种算法,直接在 Unicode 字符上训练(把空格替换成 ▁ 当普通字符处理),遇到没见过的字符时可以回退到字节(byte fallback)。LLaMA 早期版本用的就是它。算法思想和上面手写的是同一回事:从小单位开始,按频次合并出一个固定大小的词表。

分词之后得到一个整数序列,下一步是把每个整数查表变成向量,送进 Transformer 架构。

记住这三件事

  1. 字节级 BPE 从 256 个字节出发,反复合并最高频的相邻对;它永远没有未知字符,词表大小可以自己定,常见片段变成一个 token。
  2. 编码必须按训练时的合并顺序进行;预分词保证合并不跨越词和标点的边界,增量更新 pair 计数让训练从不可行变成几分钟。
  3. 很多”模型很笨”的现象(数字母、多位数运算、尾随空格、非英文贵)根源是 tokenizer 的切分,而不是模型本身。

延伸阅读