LLM 中的文本 入门

模型是一个作用在浮点数矩形上的函数。字符串不是矩形。这篇 primer 讲的就是两者之间那趟路,以及文本有而矩形没有的三条性质:长度不固定、顺序本身就承载意义、以及每个 token 的意思由它的邻居决定。这里每一个数字都由旁边那张图算出来。

01

模型从来看不见文本

它看见的是一个数字矩形。所有有意思的事,都发生在通往那个矩形的路上。

Transformer 里的每一个操作 —— 矩阵乘法、softmax、残差相加 —— 都是在一个固定矩形里对浮点数做算术。字符串完全不是这种东西。两者之间隔着五次转换,而哪几次可逆、哪几次悄悄丢掉了东西,值得弄清楚。

所以我们拿一个短串 café ☕ 走完全程,滑块每推一格走一站,你所在那一站的计数就画在旁边:

你打出来的东西 — 6 个字符

注意计数几乎每一站都在变,而且两站之间从来不是同一个意思。6 个字符变成 6 个码点,再变成 9 个字节,再变成 3 个token,再变成 3 个 id,最后是 3 行浮点数。模型真正吃进去的只有最后那一样,而到那时,原来那个字符串只剩下那几个 id。

先看最底下。UTF-8 给一个码点花 1 到 4 个字节,而这张图把这件事画实了:每个字符方块的宽度,正好等于它下面那些字节。拖动滑块走过六种文字:

英语 — 5 个字符 · 5 字节

看着方块不再等宽。英语每个字符 1.00 个字节,、、表情符号 4.00。四比一,而且没有任何东西会提醒你正在付哪一种。ASCII 被刻意留成 1 字节,所以旧文本原封不动就是合法的 UTF-8。

上面那段里的「字符」两个字承担得有点多。读者眼里的一个字符,可能是好几个码点粘在一起,而你的编程语言报出来的那个数,三者都不是。滑过六个例子:

1 个字形 · 1 个码点 · 1 字节

因为 len() 数的是码点, ——一个字形、18 个字节—— 报出来是 5。在任意下标处切一刀,你就切进了一个字符的中间:对那个表情做 s[:1] 会得到一个孤零零的男人,改切字节则连一个合法字符都得不到。

更糟的是,同一个字形可以有两种都正确的写法。NFC 把重音预先合进字母里; NFD 保留字母,另外挂一个组合记号。切换形式,看着渲染结果纹丝不动,而字节在变:

NFC · 5 字节

两种形式规范等价、都合法,而且什么异常都不会抛。合成形式下这个词是 5 个字节,分解形式下是 6 个,从第 3 个字节起就不同 —— 所以 "café" == "café" 是 False,下游每一个哈希、索引和精确匹配过滤器,都在读者只看见一个词的地方存了两串字节。

这种失败从不吭声。它表现为一次什么都没查到的查找,而那个键就明晃晃地摆在页面上。把规范化往写入路径上推,看着那个没人看得见的桶慢慢排空:

8 个里有 0 个规范化过 — 2 种不同的字节串

一旦每个键在写入时都做了规范化,就只剩一个桶而不是两个,查询能找全 8 个。在那之前,存储里躺着读者根本分不出来的重复项,唯一的症状是召回率比应有的低一点。规范化要放在边界上做:等到分词之后就太晚了,因为那时两种形式已经是不同的 id 了。

02

长度不是形状

「好的。」是 2 个 token,一篇维基百科文章是一百万个。同一个矩阵乘法得把两者都吃下去。

GPU 要的是一个矩形:B 行、T 列,每一行一样长。文本既没有天然的上限也没有天然的下限,所以总得有人把它弄成矩形 —— 而每一种弄法,账单都不一样。

通常的答案是填充:取这一批里最长的那篇文档,用填充格把其余每一行补齐到它。把第十二篇文档拖长:

第十二篇文档是 9 个 token. 左右拖动改变最长的那篇文档;方向键每次移动一个 token,Home 回到初始状态
第十二篇文档是 9 个 token — 张量里 32% 是填充

看着整个矩形因为一行而长大。最短时这一批浪费掉 32% 的格子;把那个异常值拖到,张量里 69%什么都不装。而这些格子每一个都会被乘、被 softmax、被相加,和一个真 token完全一样。

只要模型知道要忽略结果,白算一遍还能忍。这正是掩码的全部职责,而经典的 bug 就是某个池化步骤压根没去问它。把文档改短,再切换均值除以什么:

按全部格子求均值 = 0.300

不带掩码的均值不是有噪声,而是错的,而且只往一个方向错: 12 格宽的一行里有 5 个真 token 时,它读出0.300,而真值是0.720。什么都不会抛。模型照训,损失照降,某个指标就是低几个点,谁也找不出原因。

浪费同样不是自然规律 —— 它取决于谁和谁共享一个矩形。把语料按长度排序,切成若干个桶,每个桶只补齐到自己最长的那篇:

1 个桶 · 55% 填充

一个桶就是最朴素的那一批:99 个真 token用掉 220 个格子,55% 是填充。把它压到 110 个格子、10%。代价在另一头 —— 一篇文档一个桶意味着零浪费和批量为 1,而批量正是你当初要这个矩形的原因。

另一种答案是干脆不要尾巴:定一个最大长度,把超出的全部丢掉。把那一刀拖过一篇四千 token 的文档:

这一刀切在第 512 个 token. 左右拖动这一刀;方向键每次移动 64 个 token,Home 回到初始状态
4,096 个里留下 512 个 — 丢掉 88%

在 下,模型读到 4,096 里的 512 个,而且永远不知道另外 88% 存在过。它不会报错,它会回答。这一节里每一种失败都是这个形状:张量格式良好、算术照跑,唯一错的是这些数字的意思。

03

数不到二的词袋

三个词的六种排列,一个向量。只看向量的东西,永远分不出它们。

把文本弄成定长形状最省事的办法,是干脆不管词在哪儿:数一数词表里每一项出现了几次,把计数交出去。一趟扫描、任意长度,而且在垃圾邮件和主题分类上至今仍是像样的基线。

它同时也扔掉了让语言成其为语言的那个东西。用滑块走遍一个三词句子的每一种排列,看着下面那个计数向量纹丝不动:

第 1 种排列,共 6 种 — 向量没有变

六种排列,一个向量—— 而并不是「狗咬猫」。这不是一个会随数据变好的近似:从序列到词袋的映射不是单射,所以任何只看词袋的函数都分不开它们,多大规模都不行。

经典的补丁是不数单个词,改数连续的短串。把窗口加宽,看看模型能数到什么:

n = 1 · 5 个窗口

到 时,模型终于能把「狗咬」和「咬狗」分开,下面那些元组就是证据。但看右边那个数:特征空间是词表的 n 次方,所以窗口每宽一个词,可能性就乘上 50,257。

这笔交易值得画在坐标轴上。纵轴是对数的 —— 每一条网格线是下面一条的十万倍 —— 所以指数级的爆炸会画成一条直线:

n = 1 · 50,257 个特征

正因为纵轴是对数的,那条直线就是爆炸本身:在 处有1.27×10¹⁴ 种可能的三元组,比任何语料能填满的都多。几乎每一个特征都是零,剩下的大多也只见过一次。

而且它还是够不着远处。把那么从如果旁边拖开,看着能同时装下两者的窗口一个一个熄掉:

相隔 1 个词。左右拖动把两个词拉开;方向键每次移动一个词,Home 回到初始状态
距离 1 · 窗口 3 — 2 个窗口能同时装下

一旦距离追上窗口宽度,就没有任何窗口同时装下这两个词 —— 于是整个模型里没有一个特征提到这一对。数得再狠也没用,因为这一对压根不在特征空间里。这就是那堵墙,也是这篇 primer 剩下的部分谈距离而不是谈计数的原因。

04

单个 token 本身没有意思

河边那个 bank 和定利率那个 bank,是同一个 id、同一行表。

在所有算术开始之前,每个 id 都得先变成一个向量,而最省事的办法是查表:id 31 就是第 31 行,永远如此、处处如此,不管周围那句话说了什么。

下面这句里两次出现的 bank 是同一个 token,所以两次读的是同一行。用滑块在两者之间切换,看箭头落在哪里:

第 1 个位置和第 7 个位置 — 都读第 31 行

动的只有箭头。Word2Vec 和 GloVe 就是这张表,比 one-hot 是巨大的进步 —— 但向量是在读句子之前就选定的,所以真正区分这两个 bank 的东西,只能靠下游的别的部件补回来。

补回来的办法就是把邻居混进去。把 α 调大,看着一行变成两行:

α = 0.00 — 两行相差 0.00

α = 0 时两行完全相同,读数说它们相距 0.00。到 时相距 1.12,模型终于能回答「哪个 bank」。这个混合就是注意力在算的东西;权重是学出来的,每个 token、每一层都不一样。

循环网络是一步一步地混,这就给「能往回带多远」压了一个硬顶。设好门,再拖动距离:

回看 5 · 还剩 59.0%

门每步保留 90% 时,的那个 token 在状态里只剩不到 1%。把 γ 调高能换来更远的记忆,但也更容易让状态饱和;调到 0.80,20 个 token 就只剩一点点过 1%。没有哪个设置能既记住一整段又保持稳定 —— 这就是梯度消失,只是从正向看。

05

两个 token 之间有多远

一个数就能给这里所有架构打分:信号从一个位置走到另一个位置的最短路径。

路径短,就能学到长程结构。路径长就学不到,因为信号一路上经过的每一样东西,都有机会把它覆盖掉。

挑一个架构,再把两者拖开。阶梯的每一级就是信号必须跳的一次:

循环网络 · 距离 12 — 12 跳

词袋根本没有路径。3-gram 只在距离 2 以内有一跳,再远就没有了。核宽为 3 的卷积需要 ⌈d/2⌉ 层。需要 12 个顺序步骤 —— 又是那张衰减图,一级台阶就是一次乘法。注意力在任何距离上都只要一跳。

处处一跳并不免费。要让每个 token 都能到达其他每个 token,你就得给每一对打分。拖动 n,看那个正方形,而不是那一行:

6 个 token. 左右拖动把序列拉长;方向键每次移动 64 个 token,Home 回到初始状态
n = 6 · 36 对

序列翻一倍,那一行翻一倍,正方形却翻四倍:在 处是 12 个 token 对应 144 个分数,每一个都要算、都要过 softmax、都要乘进去。路径长度是常数;付账的是面积。

06

这个矩形要花多少钱

在序列长过模型宽度之前,注意力都是更便宜的那一层。这个交叉点并不神秘。

宽度为 d 的自注意力层做 2n²d 次乘加;同样宽度的循环层做 2nd² 次。两者相除,比值是 n/d,所以它们恰好在 n = d 处一样贵。

下面两个轴都是对数的,所以幂律会画成直线,而交叉点就是两条线相遇的地方。把序列长度拖过去:

n = 256 · 注意力 1.01×10⁸

在 GPT-2 的宽度 处,注意力和循环网络两条曲线都落在 9.06×10⁸ 次运算上。在它左边,注意力既更便宜也更浅;在它右边,注意力是用一条陡两倍的线,买下自己那一跳的路径。

算力只是大家常引的那一半。另一半是:softmax 跑的时候,这些分数得存在某个地方。拖动上下文窗口,读一下内存:

n = 512 · 3.77×10⁷ 个分数 — fp16 下 72.0 MiB

在 处,一个 12 层、12 头的模型握着 6.04×10⁸ 个分数 ——fp16 下 1.13 GiB,这还只是一条序列,一个权重都还没算。这个数就是 FlashAttention 存在的原因:它分块算出同一个 softmax,从不把矩阵写下来。那个二次方,早在成为算力问题之前,先是一个内存问题。

07

四种不声不响的失败

形状错了会抛异常,这四种不会。

规范化。两串字节,一个字形;索引把两个都留着,召回率掉的那一点谁也归因不到。忘了掩码。按填充后的宽度求出来的均值有限、像样,并按填充比例往零偏。截断。模型只回答前 L 个 token。

第四种是先切片再分词,而它有一个你能亲手感受的机制。拿 §01 开头那个句子,在任意位置切开它的字节,看看把前缀解码回来会得到什么:

这一刀切在第 5 个字节。左右拖动这一刀;方向键每次移动一个字节,Home 回到初始状态
切在第 5 个字节 · 取回 4 个字符 — 正好落在字符边界上

注意这个格式赖以成立的规则:一个字节开启一个字符,当且仅当它最高两位不是 10。这 9 个位置里有 3 个落在,而在纯 ASCII 上一个都不会 —— 所以用英文写、用英文测的切块器能顺利上线,再对同一篇文档悄悄给出另一串 token。