问 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") 时会出错?
逐字节解码时,228 这样的字节只是三字节汉字的首字节,单独解码不构成合法 UTF-8,会抛出 UnicodeDecodeError。
其他三种都是把整个字节串一起解码,多字节字符的各个字节能拼回一个码点。errors="replace" 只影响真正非法的字节,这里字节串本身合法。
字节太长,词太稀疏:中间地带在哪里
现在有了两个极端。按字节切,词表 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=175 次合并,序列从 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 catdecode(encode(s)) == s 对任意字符串成立,这是字节级 BPE 的保证。反过来不成立:模型生成的 token 序列可能在一个汉字的中间停下,所以 decode 里要有 errors="replace"。
编码时如果不按 merge 的训练顺序,而是"每次合并当前序列里出现次数最多的一对",会怎样?
训练时的频次是整个语料上的统计,新文本里的局部频次完全不同。按局部频次合并,切出来的方式会和训练时模型见过的不一致,同一个词在两段文本里可能得到不同 ID 序列。
只要选的 pair 在 merges 里,产出的 token 一定在词表里,不会越界;但它改变了结果,不只是速度问题。
它在哪里坏掉:跨词合并与预分词
上面的第二次合并 '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 风格的预分词,哪些说法正确?
预分词把文本切成片段,BPE 只在片段内部合并,所以 dog 和后面的 . 永远不会合成一个 token。正则把前导空格和词绑在一起,句首的 the 没有空格、句中的 the 有,是两个不同的片段,也就学成两个不同的 token。
空格仍然是 token 的一部分,只是被规定放在词的前面;所以以空格开头的 token 恰恰最常见。
在真实语料上训练,以及为什么要增量更新
把上面两步合起来,在一份 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 计数?
一个片段里的相邻对只取决于它自己的 token 序列。合并 (a,b) 只改变含有 (a,b) 的片段,其他片段的序列没变,pair 计数也不变。
全量重数结果正确但正是慢的根源;只更新一个片段会漏掉其他含有该 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 解释?
中文在以英文为主的 BPE 里几乎按字节切,token 数是英文的 3 倍多。模型看到的是整块 token 而非字母,数字母只能靠记忆。尾随空格会被切成训练时罕见的孤立空格 token,偏离训练分布。
字节级 BPE 能表示任意 UTF-8 字节序列,模型可以逐个字节地生成任何字符,没有未知字符的问题。
能不能不要 tokenizer
tokenizer 是整个系统里唯一不经过梯度下降训练的部分,它独立训练、规则固定,带来上面那一串毛病。很自然的想法是直接在字节上建模。难点在于前面算过的账:字节序列比 BPE 长约 4 倍,注意力的代价是平方级的。研究上的方向是让模型自己学会”分组”:先用一个小网络在字节上动态地把相邻字节合并成块(比如按下一个字节的预测难度来决定块的边界),再让大模型在块上计算,这就是一类 byte-level / tokenizer-free 架构。它们在一些规模上已经能和 BPE 打平,但工业界的主流模型目前仍然使用 BPE。
另一个常见的问题是 SentencePiece。它是 Google 的一个 tokenizer 库,支持 BPE 和 Unigram 两种算法,直接在 Unicode 字符上训练(把空格替换成 ▁ 当普通字符处理),遇到没见过的字符时可以回退到字节(byte fallback)。LLaMA 早期版本用的就是它。算法思想和上面手写的是同一回事:从小单位开始,按频次合并出一个固定大小的词表。
分词之后得到一个整数序列,下一步是把每个整数查表变成向量,送进 Transformer 架构。
记住这三件事
- 字节级 BPE 从 256 个字节出发,反复合并最高频的相邻对;它永远没有未知字符,词表大小可以自己定,常见片段变成一个 token。
- 编码必须按训练时的合并顺序进行;预分词保证合并不跨越词和标点的边界,增量更新 pair 计数让训练从不可行变成几分钟。
- 很多”模型很笨”的现象(数字母、多位数运算、尾随空格、非英文贵)根源是 tokenizer 的切分,而不是模型本身。
延伸阅读
- Karpathy: Let’s build the GPT Tokenizer,以及 minbpe 仓库
- Stanford CS336: Language Modeling from Scratch,Lecture 1 与 Assignment 1
- Sennrich et al. 2016: Neural Machine Translation of Rare Words with Subword Units
- Radford et al. 2019: Language Models are Unsupervised Multitask Learners (GPT-2)
- tiktoken 与 SentencePiece