LLM 中的文本 入门
模型是一个作用在浮点数矩形上的函数。字符串不是矩形。这篇 primer 讲的就是两者之间那趟路,以及文本有而矩形没有的三条性质:长度不固定、顺序本身就承载意义、以及每个 token 的意思由它的邻居决定。这里每一个数字都由旁边那张图算出来。
模型从来看不见文本
它看见的是一个数字矩形。所有有意思的事,都发生在通往那个矩形的路上。
Transformer 里的每一个操作 —— 矩阵乘法、softmax、残差相加 —— 都是在一个固定矩形里对浮点数做算术。字符串完全不是这种东西。两者之间隔着五次转换,而哪几次可逆、哪几次悄悄丢掉了东西,值得弄清楚。
所以我们拿一个短串 café ☕ 走完全程,滑块每推一格走一站,你所在那一站的计数就画在旁边:
注意计数几乎每一站都在变,而且两站之间从来不是同一个意思。6 个字符变成 6 个码点,再变成 9 个字节,再变成 3 个token,再变成 3 个 id,最后是 3 行浮点数。模型真正吃进去的只有最后那一样,而到那时,原来那个字符串只剩下那几个 id。
先看最底下。UTF-8 给一个码点花 1 到 4 个字节,而这张图把这件事画实了:每个字符方块的宽度,正好等于它下面那些字节。拖动滑块走过六种文字:
看着方块不再等宽。英语每个字符 1.00 个字节,、、表情符号 4.00。四比一,而且没有任何东西会提醒你正在付哪一种。ASCII 被刻意留成 1 字节,所以旧文本原封不动就是合法的 UTF-8。
上面那段里的「字符」两个字承担得有点多。读者眼里的一个字符,可能是好几个码点粘在一起,而你的编程语言报出来的那个数,三者都不是。滑过六个例子:
因为 len() 数的是码点, ——一个字形、18 个字节—— 报出来是 5。在任意下标处切一刀,你就切进了一个字符的中间:对那个表情做 s[:1] 会得到一个孤零零的男人,改切字节则连一个合法字符都得不到。
更糟的是,同一个字形可以有两种都正确的写法。NFC 把重音预先合进字母里; NFD 保留字母,另外挂一个组合记号。切换形式,看着渲染结果纹丝不动,而字节在变:
两种形式规范等价、都合法,而且什么异常都不会抛。合成形式下这个词是 5 个字节,分解形式下是 6 个,从第 3 个字节起就不同 —— 所以 "café" == "café" 是 False,下游每一个哈希、索引和精确匹配过滤器,都在读者只看见一个词的地方存了两串字节。
这种失败从不吭声。它表现为一次什么都没查到的查找,而那个键就明晃晃地摆在页面上。把规范化往写入路径上推,看着那个没人看得见的桶慢慢排空:
一旦每个键在写入时都做了规范化,就只剩一个桶而不是两个,查询能找全 8 个。在那之前,存储里躺着读者根本分不出来的重复项,唯一的症状是召回率比应有的低一点。规范化要放在边界上做:等到分词之后就太晚了,因为那时两种形式已经是不同的 id 了。
长度不是形状
「好的。」是 2 个 token,一篇维基百科文章是一百万个。同一个矩阵乘法得把两者都吃下去。
GPU 要的是一个矩形:B 行、T 列,每一行一样长。文本既没有天然的上限也没有天然的下限,所以总得有人把它弄成矩形 —— 而每一种弄法,账单都不一样。
通常的答案是填充:取这一批里最长的那篇文档,用填充格把其余每一行补齐到它。把第十二篇文档拖长:
看着整个矩形因为一行而长大。最短时这一批浪费掉 32% 的格子;把那个异常值拖到,张量里 69%什么都不装。而这些格子每一个都会被乘、被 softmax、被相加,和一个真 token完全一样。
只要模型知道要忽略结果,白算一遍还能忍。这正是掩码的全部职责,而经典的 bug 就是某个池化步骤压根没去问它。把文档改短,再切换均值除以什么:
不带掩码的均值不是有噪声,而是错的,而且只往一个方向错: 12 格宽的一行里有 5 个真 token 时,它读出0.300,而真值是0.720。什么都不会抛。模型照训,损失照降,某个指标就是低几个点,谁也找不出原因。
浪费同样不是自然规律 —— 它取决于谁和谁共享一个矩形。把语料按长度排序,切成若干个桶,每个桶只补齐到自己最长的那篇:
一个桶就是最朴素的那一批:99 个真 token用掉 220 个格子,55% 是填充。把它压到 110 个格子、10%。代价在另一头 —— 一篇文档一个桶意味着零浪费和批量为 1,而批量正是你当初要这个矩形的原因。
另一种答案是干脆不要尾巴:定一个最大长度,把超出的全部丢掉。把那一刀拖过一篇四千 token 的文档:
在 下,模型读到 4,096 里的 512 个,而且永远不知道另外 88% 存在过。它不会报错,它会回答。这一节里每一种失败都是这个形状:张量格式良好、算术照跑,唯一错的是这些数字的意思。
数不到二的词袋
三个词的六种排列,一个向量。只看向量的东西,永远分不出它们。
把文本弄成定长形状最省事的办法,是干脆不管词在哪儿:数一数词表里每一项出现了几次,把计数交出去。一趟扫描、任意长度,而且在垃圾邮件和主题分类上至今仍是像样的基线。
它同时也扔掉了让语言成其为语言的那个东西。用滑块走遍一个三词句子的每一种排列,看着下面那个计数向量纹丝不动:
六种排列,一个向量—— 而并不是「狗咬猫」。这不是一个会随数据变好的近似:从序列到词袋的映射不是单射,所以任何只看词袋的函数都分不开它们,多大规模都不行。
经典的补丁是不数单个词,改数连续的短串。把窗口加宽,看看模型能数到什么:
到 时,模型终于能把「狗咬」和「咬狗」分开,下面那些元组就是证据。但看右边那个数:特征空间是词表的 n 次方,所以窗口每宽一个词,可能性就乘上 50,257。
这笔交易值得画在坐标轴上。纵轴是对数的 —— 每一条网格线是下面一条的十万倍 —— 所以指数级的爆炸会画成一条直线:
正因为纵轴是对数的,那条直线就是爆炸本身:在 处有1.27×10¹⁴ 种可能的三元组,比任何语料能填满的都多。几乎每一个特征都是零,剩下的大多也只见过一次。
而且它还是够不着远处。把那么从如果旁边拖开,看着能同时装下两者的窗口一个一个熄掉:
一旦距离追上窗口宽度,就没有任何窗口同时装下这两个词 —— 于是整个模型里没有一个特征提到这一对。数得再狠也没用,因为这一对压根不在特征空间里。这就是那堵墙,也是这篇 primer 剩下的部分谈距离而不是谈计数的原因。
单个 token 本身没有意思
河边那个 bank 和定利率那个 bank,是同一个 id、同一行表。
在所有算术开始之前,每个 id 都得先变成一个向量,而最省事的办法是查表:id 31 就是第 31 行,永远如此、处处如此,不管周围那句话说了什么。
下面这句里两次出现的 bank 是同一个 token,所以两次读的是同一行。用滑块在两者之间切换,看箭头落在哪里:
动的只有箭头。Word2Vec 和 GloVe 就是这张表,比 one-hot 是巨大的进步 —— 但向量是在读句子之前就选定的,所以真正区分这两个 bank 的东西,只能靠下游的别的部件补回来。
补回来的办法就是把邻居混进去。把 α 调大,看着一行变成两行:
α = 0 时两行完全相同,读数说它们相距 0.00。到 时相距 1.12,模型终于能回答「哪个 bank」。这个混合就是注意力在算的东西;权重是学出来的,每个 token、每一层都不一样。
循环网络是一步一步地混,这就给「能往回带多远」压了一个硬顶。设好门,再拖动距离:
门每步保留 90% 时,的那个 token 在状态里只剩不到 1%。把 γ 调高能换来更远的记忆,但也更容易让状态饱和;调到 0.80,20 个 token 就只剩一点点过 1%。没有哪个设置能既记住一整段又保持稳定 —— 这就是梯度消失,只是从正向看。
两个 token 之间有多远
一个数就能给这里所有架构打分:信号从一个位置走到另一个位置的最短路径。
路径短,就能学到长程结构。路径长就学不到,因为信号一路上经过的每一样东西,都有机会把它覆盖掉。
挑一个架构,再把两者拖开。阶梯的每一级就是信号必须跳的一次:
词袋根本没有路径。3-gram 只在距离 2 以内有一跳,再远就没有了。核宽为 3 的卷积需要 ⌈d/2⌉ 层。需要 12 个顺序步骤 —— 又是那张衰减图,一级台阶就是一次乘法。注意力在任何距离上都只要一跳。
处处一跳并不免费。要让每个 token 都能到达其他每个 token,你就得给每一对打分。拖动 n,看那个正方形,而不是那一行:
序列翻一倍,那一行翻一倍,正方形却翻四倍:在 处是 12 个 token 对应 144 个分数,每一个都要算、都要过 softmax、都要乘进去。路径长度是常数;付账的是面积。
这个矩形要花多少钱
在序列长过模型宽度之前,注意力都是更便宜的那一层。这个交叉点并不神秘。
宽度为 d 的自注意力层做 2n²d 次乘加;同样宽度的循环层做 2nd² 次。两者相除,比值是 n/d,所以它们恰好在 n = d 处一样贵。
下面两个轴都是对数的,所以幂律会画成直线,而交叉点就是两条线相遇的地方。把序列长度拖过去:
在 GPT-2 的宽度 处,注意力和循环网络两条曲线都落在 9.06×10⁸ 次运算上。在它左边,注意力既更便宜也更浅;在它右边,注意力是用一条陡两倍的线,买下自己那一跳的路径。
算力只是大家常引的那一半。另一半是:softmax 跑的时候,这些分数得存在某个地方。拖动上下文窗口,读一下内存:
在 处,一个 12 层、12 头的模型握着 6.04×10⁸ 个分数 ——fp16 下 1.13 GiB,这还只是一条序列,一个权重都还没算。这个数就是 FlashAttention 存在的原因:它分块算出同一个 softmax,从不把矩阵写下来。那个二次方,早在成为算力问题之前,先是一个内存问题。
四种不声不响的失败
形状错了会抛异常,这四种不会。
规范化。两串字节,一个字形;索引把两个都留着,召回率掉的那一点谁也归因不到。忘了掩码。按填充后的宽度求出来的均值有限、像样,并按填充比例往零偏。截断。模型只回答前 L 个 token。
第四种是先切片再分词,而它有一个你能亲手感受的机制。拿 §01 开头那个句子,在任意位置切开它的字节,看看把前缀解码回来会得到什么:
注意这个格式赖以成立的规则:一个字节开启一个字符,当且仅当它最高两位不是 10。这 9 个位置里有 3 个落在,而在纯 ASCII 上一个都不会 —— 所以用英文写、用英文测的切块器能顺利上线,再对同一篇文档悄悄给出另一串 token。