卷 I · 一张表CH 02深度 2/20

它看不见字

上一章那张表有 50257 行。每一行是什么?不是字母,不是汉字,也不完全是单词——是一种叫 token 的东西,由一套「把最常挨在一起的两块粘成一块」的规则反复合并出来。这一章把这套规则拆开,然后你会明白一组看起来毫不相干的怪事:它为什么数不清 strawberry 里的 r,为什么做不好多位数加法,为什么同样的意思用中文说更贵,以及为什么有一个 Reddit 用户名能让一代模型当场「失语」。

strawberry = 1 个 token中文 86 对 35字节对编码
◉ 这一章在 GPT-2 的哪一段

▷ 先猜一下

句子中间出现的单词 strawberry(前面带一个空格,像 I like strawberry 里那样),在 GPT-2 的分词器里会被切成几个 token?

A 10 个。一个字母一个,模型逐个字母地读 B 3 个。像 straw + ber + ry 这样的音节块 C 2 个。straw 和 berry,正好是两个英文词 D 1 个。整个词就是一个符号

答案在本章末尾。猜完再想一下:如果答案是你选的那个,「strawberry 里有几个 r」这道题对模型来说难在哪?

它读的是编号,不是字

模型的输入不是一串字符,是一串整数。The capital of France is 进入模型之前,先被一个叫分词器(tokenizer)的程序变成 [464, 3139, 286, 4881, 318]。模型从头到尾只见过这些编号。

那这 50257 个编号各代表什么?GPT-2 用的方法叫字节对编码(Byte Pair Encoding,BPE),规则简单到可以用一段话讲完:

  1. 从最小的单位开始:256 个字节。任何文字——英文、中文、emoji、乱码——在电脑里都是一串字节,所以这 256 个块能拼出任何东西,永远不会遇到「不认识的字」。
  2. 在一大堆训练文本里,数一数哪两块最常挨在一起,把它们粘成一个新块,加进词表。
  3. 重复第 2 步。GPT-2 重复了 50000 次。

256 个字节 + 50000 次合并 + 1 个特殊的「文档结束」符号 <|endoftext|> = 50257。上一章那张表的每一行,就是这 50257 块中的一块。合并规则本身就是一个文件,merges.txt,五万行,每行一对:第 1 行是 Ġ t(空格 + t),第 2 行是 Ġ a……排得越靠前,说明这一对在语料里越常见。

所以一个常见的英文单词,会在合并过程中被反复粘合,最后整个变成一块;一个罕见的词,粘到一半就没有更高频的组合了,只能以几块碎片的形式存在。一个东西是不是「一个 token」,取决于它在训练语料里有多常见,不取决于它在语言学上是什么。

空格是 token 的一部分

BPE 有一个容易被忽略的细节:GPT-2 在合并之前先按一条正则表达式把文字切成小段,而单词前面的那个空格,会和单词粘在一起。于是同一个词有好几个「身份」:

GPT-2   │␣strawberry│  1 个
GPT-2   │st│raw│berry│  3 个

句子中间的 ␣strawberry 是一个完整的 token,编号 41236;放在句首、前面没有空格的 strawberry 却是三块:st、raw、berry。对模型来说,这是两个毫不相干的编号序列——它们「是同一个词」这件事,模型只能从训练数据里自己慢慢学出来。

GPT-2   │␣Paris│  1 个
GPT-2   │␣par│is│  2 个
GPT-2   │␣PAR│IS│  2 个

首字母大写的 ␣Paris 是一块,小写的 ␣paris 在 GPT-2 里是 ␣par + is,全大写是 ␣PAR + IS。同一座城市,三种拼法,三组完全不同的编号。

✎ 术语正名:token 不是「字」,也不是「词」

中文资料常把 token 译成「词元」或者干脆说「字」,两个都会误导。token 是统计意义上的高频片段:在英文里它大多恰好是一个单词或半个单词,平均一个 token 约 4.78 个字符(这是 GPT-2 在第 4 章那篇维基百科文章上的实测);在 GPT-2 的中文里,它常常连一个汉字都不到——只是一个汉字的两三个字节中的一个。说「这段话有多少字」和「这段话有多少 token」,是两个不同的问题,API 按后者收钱。

为什么它数不清 strawberry 里的 r

现在那道著名的题目就好理解了。问模型 How many r's are in strawberry?,它读到的是:

GPT-2   │How│␣many│␣r│'s│␣are│␣in│␣strawberry│?│  8 个

8 个编号。␣strawberry 就是其中一个:41236。模型没有任何一个输入通道能让它「看见」这个编号里面有哪些字母。它要回答这个问题,只能靠在训练中碰巧读到过「strawberry is spelled s-t-r-a-w……」这类文字,把「41236 号符号的拼写」当成一条知识背下来。这和让你闭着眼睛摸一块拼图,然后说出它背面印的条形码有几根黑线,是一个难度。

把这道题原样喂给 GPT-2,它接下来最想写的是:

问它 strawberry 里有几个 r,它接下来最想写的 … 's ␣are ␣in ␣strawberry ? ␣There ␣are 下一个 token 概率 意外 ' many' 6.54% 3.93 比特 ' about' 3.93% 4.67 比特 ' a' 3.81% 4.71 比特 ' two' 3.16% 4.98 比特 ' only' 2.83% 5.14 比特 ' three' 2.61% 5.26 比特 熵 7.63 比特 · 正确答案 three 排第 6

「two」(3.16%)排在「three」(2.61%)前面。它不是算错了,它根本没有在算——它在猜「There are」后面什么词最常见。

同一个结构解释了一整串类似的怪事:倒着拼一个单词、数一句话里有几个字母、判断两个词押不押韵、找出一个字符串里的拼写错误——所有需要「看进 token 里面」的任务,对它都是难题。今天的模型做得好一些,靠的是更大的规模(背下了更多 token 的拼写),或者干脆在内部写一段代码去数——那是在借用一个能看见字母的工具。

数字被切得乱七八糟

数字是另一个重灾区。GPT-2 的合并规则是从网页文本里统计出来的,哪些数字组合常见,就被粘成一块:

GPT-2   │123│45│67│  3 个
o200k   │123│456│7│  3 个
GPT-2   │123│45│␣+│␣6│78│90│␣=│  7 个
o200k   │123│45│␣+│␣│678│90│␣=│  7 个

在 GPT-2 里,1234567 被切成 123 | 45 | 67,而 67890 被切成 6 | 78 | 90(还连着前面的空格)。两个数的「个位」落在了形状不同的 token 里,「百位」也是。做竖式加法时最基本的一件事——把同一位对齐——在模型看到的输入里根本不成立。这就是早期模型多位数加法错得离谱的一大原因。

后来的分词器改了规则:GPT-4 那一代(cl100k)和 GPT-4o 那一代(o200k)在切分前先把数字按从左往右每三位一组切开,于是 1234567 稳定地变成 123 | 456 | 7;Meta 的 Llama 1、2 走得更远,把每一个数字单独切开。这些不是细节,是在给模型修一条看清数位的路。

同样的意思,中文更贵

GPT-2 的合并规则几乎全部来自英文网页。一个汉字在 UTF-8 里是 3 个字节,而这些字节组合在它的语料里太罕见,大多没被合并过:

GPT-2   │大│⟦e6⟧│⟦a8⟧│⟦a1⟧│⟦e5⟧│⟦9e⟧│⟦8b⟧│⟦e5 8f⟧│⟦aa⟧│⟦e4 bc⟧│⟦9a⟧│⟦e7⟧│⟦bb⟧│⟦ad⟧│⟦e5 86⟧│⟦99⟧│。│  17 个
cl100k  │大│模│型│只│会│⟦e7 bb⟧│⟦ad⟧│写│。│  9 个
o200k   │大│模型│只│会│续│写│。│  7 个

「大模型只会续写。」8 个字,在 GPT-2 里是 17 个 token,其中大部分是不完整的字节碎片(朱红色那些)——「模」被切成三个单字节,每一块单独看都不是任何字符。到了 GPT-4o 那代的 o200k 分词器,同一句话是 7 个 token,「模型」两个字合成了一块。

换一段更正式的文字对比。《世界人权宣言》第一条,联合国官方的英文本和中文本:

分词器英文(170 字符)中文(43 字)中文 / 英文
GPT-2(2019,词表 50257)33862.61 倍
cl100k(GPT-4 那代,词表 100277)33501.52 倍
o200k(GPT-4o 那代,词表 200019)33351.06 倍

同样的意思,用中文写在 GPT-2 里要多花 2.61 倍的 token。这不只是钱的问题:模型的「视野」——上下文窗口——也是按 token 算的。GPT-2 的窗口是 1024 个 token,装得下约 4900 个英文字符,却只装得下约 500 个汉字。在 GPT-2 眼里,中文读者说的每一句话都更长、更碎、更难懂。词表不是中立的技术细节,它决定了一门语言在模型里是「一等公民」还是「字节碎片」。

一个让模型失语的 Reddit 用户名

最后一个怪事,也是最能说明「token 是统计出来的」这一点的:

GPT-2   │␣SolidGoldMagikarp│  1 个
o200k   │␣Solid│Gold│Mag│ik│arp│  5 个

␣SolidGoldMagikarp 在 GPT-2 的词表里是一整个 token,编号 43453。一个看起来毫无意义的长串,凭什么独占一个编号?因为构建词表时用的语料里,这个 Reddit 用户名出现了极多次(据后来的分析,是一个数数版块里的活跃用户)。可是训练模型时用的语料经过了另一套清洗,这个名字几乎没出现过——于是这个 token 的向量几乎没被训练,停在一个奇怪的位置上。2023 年有人发现,让沿用这套词表的 GPT-3 复述这个词,它会答非所问、甚至给出别的词。新的分词器里,它被拆成了 5 块普通的碎片。

这个故事的教训是:分词器和模型是分开训练的两样东西。分词器在一份语料上数频率,模型在另一份语料上学预测;两份语料的差异,会在词表里留下「有编号、没意义」的空洞。

⌨ 自己跑一遍

用 OpenAI 开源的 tiktoken,几行就能把任何字符串切开看(它自带 GPT-2 和 GPT-4o 两代分词器,不用下载模型):

import tiktoken                                  # pip install tiktoken

for name in ["gpt2", "o200k_base"]:              # 2019 年的 GPT-2 / 2024 年 GPT-4o 那一代
    enc = tiktoken.get_encoding(name)
    print(f"== {name}(词表 {enc.n_vocab})")
    for s in [" strawberry", "strawberry", "1234567", "大模型只会续写。"]:
        ids = enc.encode(s)
        pieces = [enc.decode_single_token_bytes(i) for i in ids]
        print(f"{len(ids):3d} 个  {s!r:18} {pieces}")

本机输出(b'...' 是每个 token 的原始字节;GPT-2 那一行里一个汉字被拆成了好几段 \xe6 这样的单字节):

== gpt2(词表 50257)
  1 个  ' strawberry'      [b' strawberry']
  3 个  'strawberry'       [b'st', b'raw', b'berry']
  3 个  '1234567'          [b'123', b'45', b'67']
 17 个  '大模型只会续写。'         [b'\xe5\xa4\xa7', b'\xe6', b'\xa8', b'\xa1', b'\xe5', b'\x9e', b'\x8b', b'\xe5\x8f', b'\xaa', b'\xe4\xbc', b'\x9a', b'\xe7', b'\xbb', b'\xad', b'\xe5\x86', b'\x99', b'\xe3\x80\x82']
== o200k_base(词表 200019)
  1 个  ' strawberry'      [b' strawberry']
  3 个  'strawberry'       [b'st', b'raw', b'berry']
  3 个  '1234567'          [b'123', b'456', b'7']
  7 个  '大模型只会续写。'         [b'\xe5\xa4\xa7', b'\xe6\xa8\xa1\xe5\x9e\x8b', b'\xe5\x8f\xaa', b'\xe4\xbc\x9a', b'\xe7\xbb\xad', b'\xe5\x86\x99', b'\xe3\x80\x82']

pip install tiktoken && python 02.py

不想装:tiktokenizer.vercel.app 是一个在线分词可视化工具,选 gpt2 或 o200k_base,粘一段中文进去看它被切成什么样。

▸ 在现实里
  • API 账单按 token 算,不按字算。同样一份中文文档,换一代分词器,账单可以差一倍多。评估成本时,拿真实文本过一遍分词器,比任何「一个汉字约等于几个 token」的经验值都准。
  • 上下文窗口也按 token 算。「支持 128K 上下文」装得下多少中文,取决于那个模型的分词器对中文有多友好。
  • 代码的缩进也要花钱。四个空格的缩进,在 GPT-2 里前三个空格各占一个 token、第四个粘在 def 上;在新分词器里,前三个合成了 1 个 token。新一代分词器专门合并了连续空格,就是为了代码。
  • 让模型数字母、做精确算术时,给它一个工具。「写一段 Python 去数」比「直接告诉我」可靠得多——不是因为它数学不好,是因为它根本看不见那些字母和数位。
✗ 这个直觉是错的

「它数不清 strawberry 里有几个 r,说明它连数数都不会,所谓智能都是假的。」

这道题考的不是数数,是「看见符号内部」——而这恰好是它的输入通道给不了的东西。在它的世界里,␣strawberry 是一个不可再分的编号 41236,就像你看到「🍓」这个表情,也说不出它的 Unicode 编码里有几个 1。

反过来,这个失败也提醒你别高估它:它的世界是由分词器切好的。凡是需要跨越 token 边界去看字母、数位、押韵的任务,它都在用「背下来的拼写知识」硬撑,而不是在「看」。

◇ 揭晓

正确答案是 D:␣strawberry 在 GPT-2 里是 1 个 token,编号 41236。
放在句首、前面没有空格的 strawberry 才是 3 个:st · raw · berry。

A 「一个字母一个」——这是人读字的方式,也是最早的字符级模型的方式。可是那样的话,一段文字的 token 数会是现在的 4 到 5 倍,模型要花几倍的计算去处理同样长的内容。BPE 正是为了不这样做才发明的。 B 「音节块」——合并规则不懂音节,只懂频率。不过这个直觉在罕见词上是对的:句首的 strawberry 确实被切成了三块,只不过切口在 st | raw | berry,并不是任何语言学意义上的音节。 C 「两个英文词」——很合理,但 BPE 不知道 straw 和 berry 是词。它只知道「␣strawberry」作为一个整体在语料里足够常见,常见到被一路合并成了一块。

这一章的一句话

模型的世界是由分词器切好的:它读的是编号,不是字。凡是要「看进一个 token 里面」的事——数字母、对数位、押韵——它都看不见,只能靠背;而词表偏向哪种语言,哪种语言在它眼里就更短、更便宜、更清楚。

有了 token,下一章先用最笨的办法造一台续写机:只靠数数——数一数每段上下文后面各个字符出现了几次。它在莎士比亚上的成绩会先变好、再变坏:看前 4 个字符猜下一个最准,测试集上 2.55 比特/字符;看前 9 个字符时,有 63.6% 的上下文在一百万字的训练集里一次都没出现过。