三种 Transformer 形态
上一篇 primer 里的 block,能装成三种架构,每种都为不同任务优化。 block 不变 —— 变的是 attention mask、token 能不能看另一个 token。5 个短主题:理解用的Encoder-only(BERT);生成用的Decoder-only(GPT,现代 LLM 都是这个);序列到序列的Encoder-Decoder 混合(T5、BART、原始 Transformer); 把 BERT 变成 GPT 的那条规则 —— 因果掩码;以及怎么认出一个模型用的是哪种形态。
Encoder-only —— BERT
一眼把整句话双向读全。为理解而生,不做生成。
encoder-only 模型是一摞 Transformer 块,完全不带 attention 掩码:任何位置都能看任何其他位置,左边右边都行。拖动高亮的 token,看它怎样连到这一行里的每一个其他 token,两个方向都通:
注意这里方向根本不重要:句子早处的一个 token,伸向后面的内容和句子晚处的一个 token 伸向前面的内容一样轻松。这种对称性就是 encoder 的全部定义 —— 每个位置的表示都是从完整句子里构建出来的,从来不是只看前缀。
BERT 训练时利用的正是这一点。把 15% 的 token 藏到一个 [MASK] 符号后面,让模型从两边的上下文把它们复原 —— 单向模型学不会这一招,因为它可以直接从还没走到的位置抄答案。一步步展开[MASK]位置能用到的信息:
看预测怎么等到两边都够到了才出现:第 1 步时这个位置已经关注了左边的「the cat」和右边的「on the mat」,直到才把它解成 sat。掩码语言建模把双向性从一个限制变成了一个训练信号。
这里什么都没被扔掉:查询行对键列的整个 n×n 网格始终开着,不管句子多长。改一改 n,看每一格都还亮着:
正因为每一格都留着,每个输出 token 都是同时以整个输入为条件的。这正是 encoder 表示在分类、抽取、检索上这么好用的原因 —— 情感标签、命名实体片段、语义搜索向量 —— 这类把序列映射到一个标签、或映射到它自身一部分的任务。多年来,「fine-tune 一个 BERT」一直是这类问题的默认第一步。
encoder 做不到的是生成:当每个位置已经看到整句话时,「下一个 token」这个概念根本不存在,自然也就没什么可以一步步预测的了。把同一行限制成只能往回看,和已经能双向看的版本并排比一比:
既然下面这一行不能再往前伸了,它扔掉的正好是一个生成器本来就不该拿到的信息。这个限制不是缺陷 —— 它正是下一种形态赖以建立的机制本身。
Decoder-only —— GPT
只往左看、预测下一个 token、接上、再来。所有现代 LLM 都是这个形态。
decoder-only 模型保留同样的块,但加了一条规则:一个token只能看自己和它 前面的 token,永远看不到后面的内容。拖动高亮 token 往句子后面走,看它的可视范围怎么变大:
注意这个范围只会变大,从不缩小 —— 能看到能看到的一切,再加上中间所有的、再加上自己。这一条单向规则就是这种形态和上一种的全部区别,它也让训练目标简单到近乎滑稽:在每个位置,预测下一个 token。
这条规则不是可选项。把掩码关掉,同一行立刻伸进了它本该去预测的未来:
由于形状和 loss 都没变,什么都不会报错 —— 模型只是学会了从一个生成时永远够不到的位置抄答案。训练 loss 会掉得异常快,而真到生成时,模型给出的续写却接近随机,因为它学到的那个捷径在训练之外根本不存在。训练曲线好得可疑、生成却是坏的,这个落差就是破绽所在。
因为一个 token 只能看到过去,生成就只能一个 token 一个 token 来:预测、把新 token接到输入后面、再把整个东西重新跑一遍。一步步走完一整个生成循环:
注意每一步都要把它之前的所有内容重新处理一遍,不只是新 token —— 模型在两次调用之间没有记忆,所以已经生成的 token在下一步只是当作普通输入再喂回去。
这种重新处理是有代价的:每一步都要在整个前缀上重算 attention,第 n 步的代价是 O(n²),把 N 步的整个过程加起来就是 O(N³) —— 一次又一次地把一整个 attention 的量算一遍。而一个把昨天的 key、value 都留着的缓存,每一步只需要 O(n) 的新增工作,总共是 O(N²)。看这两者的差距怎么随着步数拉开:
把同样这两个公式算到 N = 1,000,比例正好是 667 倍 —— 不是一个粗略的数量级,而是上面这套算法本身给出的结果,只是规模已经大到图里的柱子画不出来了。这就是为什么每个推理服务都会留一个KV 缓存:缓存并不会让某一步变便宜,它是在阻止模型在每新来一步时,把之前 N − 1 步的活儿重新干一遍。
不过缓存总得放在什么地方,它的体积也不是免费的。每个 token 在每一层都要占住自己的key 和 value,所以缓存会随着还开着的上下文长度线性增长。拖动上下文长度,看它怎么填满:
按 Llama-2-7B 公开的形状 —— 32 层、隐藏维度 4,096、以 fp16 存储 —— 算下来每个 token、把所有层加在一起,是 512 KiB。所以,在模型生成第一个新词之前,就已经占住了整整 2 GiB 内存,这也是为什么上下文长度既是一个计算问题,同样也是一个内存预算问题。
Encoder-Decoder —— T5、BART
先把输入读全,再生成输出 —— 中间用 cross-attention 搭桥。
encoder-decoder 形态就是 2017 年最初的 Transformer,字面意义上就是把前两种形态钉在了一起:一个双向的encoder栈把整个输入读进去,一个因果的decoder栈一次一个 token 地生成输出。并排看两个栈的层,一层一层走过去:
注意到目前为止,这两列之间什么都没连 —— 每个栈都还是它单独存在时的那个形态。唯一新增的部分,是它们之间的那座桥。
那座桥就是cross-attention:在每一个 decoder 层,正在生成的 token 都会回头伸进一份单一、固定的输入摘要 —— 也就是 encoder 已经算完的输出 —— 而不是 decoder 自己生成的任何东西。一层层走过 decoder,看这座桥每次落在哪里:
不管你查的是还是,这道弧线落的都是同一份memory,这里有个细节值得说精确:cross-attention 的 query 来自 decoder,但 key 和 value 来自 encoder —— 只算一次,被每一层复用,生成过程中再也不会重算。
一个 decoder 块其实是接连跑两个 attention 子层,而不是一个,它们取用的来源完全不同。把它们并排放在一个 decoder 位置上比一比:
既然上面那个面板和下面那个面板用的是同一个 query,唯一变的就是 key 和 value 来自哪里:上面是自己的过去,下面是encoder 的 memory。两个都在每一层跑,谁也代替不了谁。
而且cross-attention完全不带三角形 —— 生成还没开始,输入就已经全部可得,自然没什么要对 decoder 位置隐藏的。一行行走过 decoder,看每一个encoder 列是不是都还开着:
这个完整的矩形正是一对翻译需要的东西:源和目标是两个边界清楚的不同序列,而且整个源在一开始就已知。翻译、摘要、语音转文字都是这个形状,这也是为什么 T5 和 BART 在这类任务上依然是标准选择。
不过纯 decoder 也能做翻译 —— 喂它「French: … English: …」,让因果掩码在一个栈里而不是两个栈里处理两半。在两种布局之间切换看看:
两栈版本需要一个匹配的 encoder 和 decoder、一个固定的输入/输出划分,还要自己一套cross-attention布线;而一栈版本什么都不需要 —— 只要一个更长的 prompt。等 decoder 大到能在上下文里同时装下两半时,这个领域基本上就统一到了更简单的那种形态。T5 式的模型在聚焦的序列到序列任务上依然强,但前沿已经转移到了 decoder-only。
因果掩码
一条沿对角线的 −∞ 规则,就是 BERT 和 GPT 的全部区别。
上面三种形态里的每一个掩码,说到底都是同一个问题的两种答法:一个 query 最多能往对角线前面看多远?拖动这个窗口,看每一行的权重怎样在剩下还开着的 key 上重新分配:
注意每一行的份额加起来永远是 1,不管开着几个 key —— 开的 key 越少,每个分到的就越多。现在把窗口拖过正好因果的位置,一直拖到 :第 0 行连自己都没了,这条不变量已经没有东西可分了。真正的实现绝不能让窗口走到这一步,这也是为什么对角线永远要包含在内。
这种重新分配不是空口说说 —— 它是一次对真实分数的真实 softmax。这里是一个小型因果掩码的四个原始分数;一行行走过去,读出这一行真正落定的权重:
注意第 0 行:只有一个key被允许,不管它自己的分数是多少,权重都是 1.00 —— 第一个位置根本没得选。到,全部 key 都开着,权重就得摊成四份。
让一个不该被看到的 key 恰好保持零的机制,是在 softmax 之前把它的分数设成 −∞,因为 exp(−∞) = 0。经典的 bug 是把这个换成一个有限的替代品 —— 改用 0 去遮 —— 这看着人畜无害,直到那个不该被看到的 key 恰好有一个很大的原始分数。切换规则,看被遮的未来位置怎么不再是零:
用正确的掩码,那个位置每次都恰好占这一行的 0%。用有 bug 的那种,它占到了 64.4% —— 几乎和正确掩码下这一行里最该拿分的那个 key 一样多 —— 因为exp(0) = 1 依然完整地参与了求和。什么都不会抛异常,形状也没错;模型只是悄悄学会了去读一个它本不该看到的答案,而训练 loss 下降得稍微快了一点,快到不足以引起怀疑。
这本 primer 里的每一种形态,都是同一个三角矩阵,有或没有、出现在一处或两处:无掩码造出encoder,因果掩码造出decoder,因果掩码加上 cross-attention 造出encoder-decoder。在同一行上三种切换看看:
因为块本身、attention 的数学、feed-forward 层从来都不变,整个架构上的决定就落在这一个旋钮上。分词、embedding、把 token 跑过 N 个带掩码的块、做最终 norm、读出下一个 token 的 logits、采样、再循环 —— 掩码决定了你刚才造出来的,是这三台机器里的哪一台。
这是哪种形态?
三个问题就能定下来:这个模型是不是两边都看、是不是只往回看、有没有第二个栈在读第一个栈的输出。
从一个你已经认识的名字开始。挑一个模型,看同一个掩码样式怎么在这一行里亮起来,连同它对应的那些数字:
注意 BERT 和 GPT 的块数、参数量几乎一样 —— 都是 12 层,1.1 到 1.17 亿参数左右 —— 却落在了两种相反的掩码上:一个是encoder,一个是decoder,因为掩码从来都是一个架构上的选择,而不是规模带来的后果。T5 为它encoder-decoder式的第二个栈多付出了大约两倍的参数量,单层宽度却是一样的。
实践中,挑形态最快的办法是看输出长什么样,而不是数参数。选一个输出的样子,看哪种形态被选中:
既然一个标签或一个片段根本不需要一个 token 一个 token 地生成,它就完全不需要因果掩码 —— 一个encoder就够了,往往跑起来还更便宜。一旦输出必须一个 token 接一个 token 地产出,某种因果掩码就进场了,不管背后有没有第二个栈。
到这里,这篇和上一篇 primer 里的每一块,都装配成了同一条七阶段流水线 —— 每一个真正部署的模型跑的都是它。从头到尾走一遍:
注意只有一个阶段 —— —— 才是今天讲的这个决定真正发生的地方:换掉它的掩码,同样这七个阶段就能描述一个 BERT 分类器、一个 GPT 式的生成器,或者一个 T5 式的翻译器,其他任何一行都不用改。难的从来不是那个块,难的是那个掩码。