你随手打一句“Hello world”交给 GPT-2,它先把文字变成 [15496, 995]。如果在前面多加一个空格,同样两个词变成 [18435, 995]——空格被算进了 token,所以句首的“Hello”和句中的“ Hello”是两个不同编号 1。

这里有两件事同时发生:文字变成了整数,而且整数并不是按“词”切的。后面所有层处理的都是浮点向量,没有任何一个部件认识“词”。所以进入第一层注意力之前,有两道转换必须先完成:文字先变成一串整数,整数再变成一串向量。这一篇只讲这两道。

词表是有限的抽屉,而语言不是

最自然的想法是按词切,一个词一个编号。问题出在预算。词表里每多一个词,就要多一行 768 个数(下面会讲),所以词表大小是训练前就得定死的几万格抽屉。而语言里可能出现的东西——专名、拼写变体、技术词、新造词——远超这个数量 3。

装不下的怎么办?词表外的文本统一映射成一个特殊符号 [UNK](unknown)2。这等于把“pytorch”、某个新人名和一个错字全部变成同一个符号:模型从此无法区分它们,生成时也永远写不出词表之外的字符串。还有一个不显眼但实在的损失——dog 和 dogs 拿到两个毫不相干的编号,模型得分别学一遍,学不到“加个 s”这件事。

另一个极端:一切退回字节

那就往反方向走到底:一个字节一个 token。UTF-8 能把任何文字表示成 0–255 的字节序列,基础词表只要 256 行,而且永远不会遇到表外的东西 3。

代价是序列变长。同一句话的两种切法:This is some text 在 UTF-8 下是 17 个字节,而 GPT-2 的分词器只用 4 个 token 3。序列长度不是小事——注意力要让每个位置和所有位置算关系,长度翻四倍,计算量大约翻十六倍。全退回字节换来了“覆盖一切”,付出的是每个字都要多算几倍。

子词:常见的整块,罕见的拆块

两个极端之间有个位置:常见词整块给一个编号,罕见词拆成几个常用的碎块。这就是 子词分词,其中最常用的一种叫 BPE(byte pair encoding,字节对编码)。

规则朴素得像一道统计题。拿一小份语料:hug×10、pug×5、pun×12、bun×4、hugs×5。先把所有词拆成字符,数哪两个相邻字符最常挨在一起。这里 u+g 出现在 hug、pug、hugs 中,次数最多,于是把 ug 合并成一个新符号加进词表;接着 u+n 最多,合并成 un。如此反复,直到词表长到目标大小 2。

于是词表大小有个很干净的公式:词表 = 基础符号数 + 合并次数。GPT-2 是 256 个字节 + 50000 次合并 + 1 个结束符 = 50257 2。现在的模型词表更大,OpenAI 的 cl100k_base 有 100,256 个常规 token,o200k_base 有 199,998 个 4。

合并完之后,常见词是一整块,罕见词自动被切碎。要留意:BPE 学的是频率,不是词根词缀,切出来的块常像巧合——annoyingly 可能被切成 annoy+ing+ly 2;antidisestablishmentarianism 在 cl100k_base 里是六块:ant idis establish ment arian ism 5。

词表外的词被拆成几个子词片段,每个片段再映射成对应的 token ID

这里最该记住的一点是:[UNK] 消失了。基础符号就是 UTF-8 的 256 个字节,任何合法文本都能一路退回单字节,所以你现编的词、别人的名字、新出的 emoji 都有编号可查。

但这个性质很容易被推过头。能表示不等于表示得经济。 主要用英文语料训出来的分词器,处理中文或代码时同一个意思往往要花更多 token:同样一段话,切出来的长度可以明显更长。上下文窗口按 token 计费、按 token 截断,所以“128k 上下文”对不同语言并不等长,API 账单也是按 token 算的。另外,分词器在模型训练之前就固定,训练过程中不再变动——它不在 Transformer 里,不是被梯度学出来的部分。

从 ID 到 768 个数:查表

现在每个位置有了一个整数。把它变成注意力层能吃的向量,靠的是 词嵌入表(token embedding)。

直觉上就是一张巨大的表格:行数是词表大小,列数是模型宽度。GPT-2 最基础那档宽度是 768,于是表是 50257 行 × 768 列,约 3860 万个数;nanoGPT 为了对齐把词表补到 50304,得到 3863 万个数,在它报出的 1.24 亿参数里占三成左右 6。查表这个动作本身不做任何计算:token id 就是行号,取出那一行,就是该位置的向量。

1wte = nn.Embedding(config.vocab_size, config.n_embd)   # 50257 × 768
2tok_emb = self.transformer.wte(idx)                     # (batch, 序列长度, 768)

两件事值得停下来想。

第一,这些数也是参数。 它们和 QKV 的权重一样,初始化时是一堆随机数(nanoGPT 用标准差 0.02 的正态分布 6),之后靠交叉熵损失给出的 p−yp-y 梯度一路调整。所谓“词向量的含义”是训练挪出来的,不是谁设计进去的。用法相近、上下文相近的词会被挪到一起——注意这个“相近”是训练的结果,不是分词保证的:dog 和 dogs 在空间里靠得近,是因为它们反复出现在相似的句子里、收到相似的梯度。

第二,同一个 id 永远查出同一个向量。 “苹果”在“吃苹果”和“苹果发布会”里,此刻拿到的是同一行 768 个数。让同一个词在不同句子里表示不同意思,是后面注意力层的事——它把周围的信息混进来,输出才随上下文变化。这一步只负责把编号翻译成初始状态,而查出的向量还要加上位置编码,才对第一层注意力有完整意义。

一次进门,四步

把链路串起来:原始文本 → 按规则预切分(空格通常粘在后面的词上 5)→ BPE 合并出子词 → 得到 token id 序列(长度 TT)→ 查表得到 T×768T \times 768 的矩阵 → 加位置编码 → 进第一层。

想自己验证,跑三行就够:

1import tiktoken
2enc = tiktoken.get_encoding("cl100k_base")
3ids = enc.encode("你写的那句话")
4print(len(ids), [enc.decode([i]) for i in ids])

看着自己的一句话被切成碎片,你会对“模型看到的不是字”有具体印象,也会立刻明白为什么数 token 必须用模型自带的分词器,而不是数字数或数词。