开场:我和《Attention Is All You Need》的三次相遇
第一次是 2019 年,同事在群里转了这篇论文,说"这是现在所有大模型的基础"。我打开 PDF,第三页出现了一个带着 Q、K、V、softmax 和根号的公式,我默默地关掉了。
第二次是 2023 年,ChatGPT 火了,我觉得作为程序员不懂原理有点说不过去,又打开了一次。这次坚持到了第五页,看到了一张画满箭头和方块的架构图,然后再次关掉了。
第三次是看 3Blue1Brown 讲 Transformer 的视频。他用动画一点一点地讲,向量在空间里移动,注意力像一束光在句子里扫来扫去。看到第二遍,我突然意识到:这东西的核心思想,其实用一句话就能说清楚。
这一篇就是写给当年那个关掉 PDF 的我的。不推公式,只讲直觉;唯一用到的数学是"两组数字对应位置相乘再加起来",小学就会。读完本篇,你应该能回答这几个问题:
- 一个 token 进入模型之后,经历了什么?
- embedding 是什么?为什么要把词变成一串数字?
- attention 到底在"注意"什么?Q、K、V 分别是什么意思?
- 模型为什么要堆几十层?知识存在哪里?
- 这些原理,和 Agent 的成本、上下文长度有什么关系?
还是那句话:看不懂可以直接跳到第 10 篇,第 10 篇开头会用一句话交代你需要的结论。
一、先看全景:一个 token 的旅程
第 07 篇说,模型每一步的工作是"输入一串 token,输出下一个 token 的 logits"。中间发生了什么?画出来是这样的:
[18947, 22551, ……]"] --> E["embedding
每个编号变成一个向量"] E --> L1["第 1 层
attention + MLP"] L1 --> L2["第 2 层
attention + MLP"] L2 --> LD["……"] LD --> LN["第 N 层
attention + MLP"] LN --> O["输出层
最后一个位置的向量 → logits"] style E fill:#e3f2fd,stroke:#1565c0 style L1 fill:#fff3e0,stroke:#e65100 style L2 fill:#fff3e0,stroke:#e65100 style LN fill:#fff3e0,stroke:#e65100
三个关键环节:
- embedding:把每个 token 编号换成一个向量(一长串数字);
- 一层又一层的处理:每一层里有两个部件,attention 负责"让 token 之间交换信息",MLP 负责"每个 token 自己消化一下";
- 输出:取最后一个位置的向量,变成词表上每个 token 的分数,就是第 07 篇的 logits。
这个架构叫 Transformer,2017 年由 Google 的一篇论文提出,论文名字就是那句很嚣张的《Attention Is All You Need》。今天几乎所有的大模型,都是在它的基础上改出来的。
二、embedding:把词变成坐标
2.1 为什么要变成数字
模型是一堆矩阵乘法,它只能处理数字。token 编号虽然是数字,但编号本身没有意义:编号 18947 和 18948 可能毫无关系。
所以第一步,模型有一张大表,给每个 token 编号分配一个向量,一串几千个浮点数。这个向量就叫 embedding。
2.2 向量的意义:意思相近的词,坐标也相近
可以把 embedding 想象成一个很高维空间里的坐标。训练的过程,会把意思相近、用法相近的 token 放到相近的位置:
- "猫"和"狗"离得很近,它们都是宠物、都是动物;
- "猫"和"桌子"离得比较远;
- 方向也有意义:经典的例子是,"国王"的向量减去"男人"再加上"女人",结果会落在"女王"附近。
真实模型里,每个维度代表什么是训练出来的,人看不懂。配套代码 examples/ch09-attention/main.go 为了让人看懂,手写了一组只有 4 维的 embedding,并且人为规定了每一维的含义:
// 一句话(已切成 token):小猫 / 喝 / 牛奶 / 因为 / 它 / 渴
// 每个 token 的 embedding 是手写的 4 维向量,维度含义(真实模型里是学出来的,这里人为指定):
// [是动物, 是食物/饮品, 是动作, 是代词]
var tokens = []string{"小猫", "喝", "牛奶", "因为", "它", "渴"}
var emb = [][]float64{
{1.0, 0.0, 0.1, 0.0}, // 小猫
{0.1, 0.3, 1.0, 0.0}, // 喝
{0.0, 1.0, 0.1, 0.0}, // 牛奶
{0.0, 0.0, 0.0, 0.0}, // 因为
{0.6, 0.1, 0.0, 1.0}, // 它
{0.5, 0.2, 0.4, 0.0}, // 渴
}"小猫"在"是动物"这一维上是 1,"牛奶"在"是食物/饮品"这一维上是 1,"它"是代词,同时带一点"动物"的味道。
2.3 位置也要告诉模型
还有一个细节:后面要讲的 attention 本身不关心顺序,"猫追狗"和"狗追猫"在它眼里是一样的。所以模型还要想办法把"这个 token 在第几个位置"的信息加进去,这叫位置编码。现代模型常用一种叫 RoPE 的方法,把位置信息"转"进向量里。知道有这么回事就够了,它和 Agent 的关系不大。
三、attention:每个 token 都能看见别人
3.1 要解决的问题:"它"是谁?
看这句话:
小猫喝牛奶,因为它渴。
人一眼就知道"它"指的是小猫,不是牛奶。但对模型来说,"它"这个 token 的 embedding 只是一个泛泛的"代词"向量,它本身并不知道自己指的是谁。
要搞清楚,"它"需要回头看看句子里前面都有谁,找到最相关的那个,把它的信息吸收过来。这就是 attention 做的事情。
3.2 Q、K、V:一次"相亲"
attention 给每个 token 准备了三个向量,名字取得很像数据库查询:
- Query(Q):"我在找什么?"
- Key(K):"我是什么?"
- Value(V):"如果你选中我,我能提供什么信息?"
三个向量都是从 token 自己的 embedding 变换出来的,变换用的矩阵是训练出来的参数。
过程就像一场相亲:
- "它"拿着自己的 Query("我在找一个动物"),去和前面每个 token 的 Key("我是动物""我是动作""我是饮品"……)对一对;
- 对得越上,分数越高。"对一对"的方法就是点积:两个向量对应位置相乘,再加起来;
- 分数经过 softmax,变成一组加起来等于 1 的权重(又是 softmax,第 07 篇的老朋友);
- 按权重,把各个 token 的 Value 加权混合起来,作为"它"这一层的新信息。
我在找:动物"] --> S1["和「小猫」的 Key 对一对:高分"] Q --> S2["和「喝」的 Key 对一对:低分"] Q --> S3["和「牛奶」的 Key 对一对:低分"] S1 & S2 & S3 --> SM["softmax
变成权重"] SM --> W["小猫 0.40 / 喝 0.13 / 牛奶 0.12 / ……"] W --> V["按权重混合各自的 Value
「它」吸收了「小猫」的信息"] style S1 fill:#e8f5e9,stroke:#2e7d32 style V fill:#fff3e0,stroke:#e65100
经过这一步,"它"的向量里就混进了"小猫"的信息。到了后面的层,模型处理"它"的时候,等于在处理"一个指代小猫的它"。
3.3 亲手算一遍
配套代码手写了 Query 和 Key 的变换矩阵,规则是:"代词"会去找"动物","动作"会去找"食物":
// Query 和 Key 的投影矩阵。真实模型里这些都是训练出来的参数,这里手写成:
// "代词"会去找"动物","动作"会去找"食物"。
var wq = [][]float64{
{1, 0, 0, 0},
{0, 1, 0, 0},
{0, 1, 0.5, 0},
{2, 0, 0, 0},
}看第 4 行 {2, 0, 0, 0}:它的意思是,embedding 里"是代词"那一维,会被变换成 Query 里"找动物"那一维,而且放大两倍。
我们手算"它"那一行(只用加法和乘法):
- "它"的 embedding 是
[0.6, 0.1, 0, 1.0],乘以wq,得到它的 Query:[2.6, 0.1, 0, 0]。2.6 这个大数,就是"我在找动物"; - 拿这个 Query 和前面每个 token 的 Key 做点积。"小猫"的 Key 是
[1, 0, 0.1, 0],点积是 2.6 × 1 = 2.6;"喝"的 Key 是[0.1, 0.3, 1.0, 0],点积是 0.26 + 0.03 = 0.29;"牛奶"是 0.1;"因为"是 0;"它"自己是 1.57; - 每个分数除以 √4 = 2(这一步是为了防止维度很高时分数太大,softmax 变得太尖),得到 1.3、0.145、0.05、0、0.785;
- softmax 之后:
| 小猫 | 喝 | 牛奶 | 因为 | 它 | |
|---|---|---|---|---|---|
| 权重 | 0.40 | 0.13 | 0.12 | 0.11 | 0.24 |
"它"最关注的是"小猫",而不是离它更近的"牛奶"。 attention 不看距离,只看相关性。
3.4 只能往前看:因果掩码
你可能注意到了,上面"它"那一行只算了它和前面的 token,没有算后面的"渴"。这是故意的。配套代码里有这么一段:
if j > i { // 因果掩码:只能看见前面的 token(生成模型就是这么"一个字一个字猜"的)
scores[j] = math.Inf(-1)
continue
}把后面 token 的分数设成负无穷,softmax 之后权重就是 0。为什么?回想第 07 篇:生成模型是一个字一个字往外猜的,生成第 N 个 token 的时候,第 N+1 个还不存在,当然不能看。训练的时候也要遵守同样的规矩,否则模型会学会"偷看答案"。
3.5 多头:同时注意好几件事
一句话里的关系不止一种:"它"要找它指代的名词,"喝"要找它的宾语,"因为"要连接前后两个分句。一组 Q、K、V 只能学会一种"找法"。
所以真实模型里,每一层都有几十组 Q、K、V 并行地算,叫多头注意力(multi-head attention)。每个头可以学会一种不同的关系。研究者观察过,有的头专门盯着上一个 token,有的头专门处理指代关系,有的头专门匹配括号。最后把所有头的结果拼起来,一起交给下一步。
四、几十层堆起来:逐步提炼
4.1 每一层都在"加工"
一层 attention 之后,每个 token 都吸收了一点别人的信息。然后是同一层里的另一个部件:MLP(多层感知机,就是几个矩阵乘法加一个非线性函数)。它不在 token 之间传递信息,只是对每个位置的向量单独再加工一下。
attention 加 MLP,构成一层。真实的大模型要堆几十层。可以粗略地这样理解:
「它」只是一个代词"] --> L1["较浅的层
认出词性、句法、谁和谁挨着"] L1 --> L2["中间的层
「它」指的是小猫"] L2 --> L3["较深的层
小猫渴了,所以喝牛奶——因果关系"] L3 --> OUT["最后一层
预测下一个 token"] style L0 fill:#e3f2fd,stroke:#1565c0 style OUT fill:#fff3e0,stroke:#e65100
每过一层,向量里的信息就更"抽象"一点。这种"浅层管语法、深层管语义"的分工是研究者观察到的大致规律,不是严格的设计。
4.2 每一层都是"加"上去的
上面那张图容易让人以为,每一层都把向量"替换"成了一个新的向量。其实不是。Transformer 里有一个看起来很不起眼、但非常关键的设计,叫残差连接:每一层算出来的结果,是加到原来的向量上的,而不是取代它。
x = x + Attention(x)
x = x + MLP(x)可以把每个位置的向量想象成一条从底层流到顶层的"信息流"。每一层都从流里读一点东西,算一算,再往流里写回一点东西。"它"这个位置的向量,一开始只写着"我是代词";经过某一层的 attention,流里被加上了"指代的是小猫";再经过某一层的 MLP,又被加上了"小猫是一种会口渴的动物"。原来的信息不会被冲掉,新的信息一层层叠上去。
这个设计有两个好处:
- 好训练:几十层堆起来,如果每层都完全替换,信号很难从顶层一路传回底层,训练会非常困难。加法让每一层都有一条"直通"的捷径;
- 分工自然:某一层如果对某个位置没什么可做的,它往流里加一个接近零的东西就行,不会破坏别人已经写好的信息。
研究者分析模型内部时,经常就是在这条信息流的不同深度上"截一刀",看看此刻流里写着什么。第 4.1 节那句"浅层管语法、深层管语义",就是这么观察出来的。
4.3 知识存在哪里
"法国的首都是巴黎"这种知识,存在模型的哪里?
目前的研究一般认为:MLP 层是存储知识的主要地方。可以把 MLP 想象成一个巨大的、模糊的键值对表:输入一个"法国 + 首都"的向量,它输出一个偏向"巴黎"的向量。attention 负责把相关的信息搬到同一个位置,MLP 负责在那个位置上"查表"。
这也解释了第 11 篇要讲的一个说法:模型的知识是对训练数据的有损压缩,被压进了几百亿、几千亿个参数里。压缩就会有损失,记得模糊的地方,它就会编。
4.4 参数都花在了哪里
一个大模型的几百亿、几千亿参数,大头其实在 MLP 里:粗略地说,标准的 Transformer 每一层里,MLP 的参数是 attention 的两倍左右。
这就引出了一个很重要的工程改进,叫混合专家(Mixture of Experts,MoE):既然 MLP 是存知识的地方,那就准备很多个 MLP,每个叫一个"专家";每个 token 进来时,由一个小小的"路由器"挑出其中几个专家来处理它,其他专家这一次不参与计算。
DeepSeek 是 MoE 路线的代表之一。以它 2024 年底发布的 V3 为例:总参数 6710 亿,但每个 token 只激活其中大约 370 亿。模型"知道"的东西很多,但每一步只动用一小部分。 这是它能把价格做得那么低的原因之一:计算成本主要取决于每一步激活了多少参数,而不是总共有多少参数。
五、这些原理和 Agent 有什么关系
讲到这里,你可能会问:我是来学 Agent 的,知道这些有什么用?有用,而且很实际。
5.1 上下文越长越贵:每个 token 都要看所有前面的 token
attention 的计算方式决定了:每个 token 都要和它前面的每个 token 算一次分数。序列长度是 n,就要算大约 n² / 2 次。长度翻一倍,attention 的计算量大约翻四倍。
这就是上下文越长、成本越高的根本原因。第 10 篇会展开讲。
为了让一百万 token 的上下文变得可用,厂商在 attention 上下了很多功夫。一类常见的思路是稀疏注意力:既然大部分 token 和当前位置关系不大,那就别和每个都算一遍分数,先用一个便宜的办法挑出最可能相关的一部分,只在这部分里认真算。DeepSeek 在 2025 年的 V3.2 里就引入了这样一种机制。这些优化能大幅降低成本,但它们改变不了一个事实:模型终究要决定把注意力花在哪里,无关的内容越多,挑错的机会就越多。
5.2 为什么缓存能省钱:前面的 K 和 V 不会变
再看一眼因果掩码:每个 token 只看前面的 token。这意味着,一个 token 的 K 和 V,只取决于它自己和它前面的内容。前面的内容不变,它的 K、V 就不变。
所以生成新 token 时,前面所有 token 的 K 和 V 都可以存起来直接复用,只需要为新 token 算一次 Q,再去和存好的 K、V 打交道。这个存起来的东西叫 KV cache。
更进一步:如果两次请求的开头部分一模一样(比如同样的 system prompt、同样的工具定义、同样的前几轮历史),那这部分的 K、V 也一模一样,服务端完全可以复用上次算好的结果。这就是 prompt caching 能便宜那么多的原因。还记得第 06 篇那张价目表吗?DeepSeek 缓存命中的输入,单价只有未命中的五十分之一。
(和上次一样)"] --> N2["新的一轮"] end P1 -.->|"K、V 算好存起来"| C[("KV cache")] C -.->|"直接复用,便宜"| P2 style C fill:#fff3e0,stroke:#e65100
这条原理会直接影响我们写 Agent 的方式:尽量让请求的前缀保持稳定。第 37 篇会讲具体怎么做。
5.3 注意力是有限的预算
softmax 的权重加起来永远等于 1。这意味着:上下文里的东西越多,每样东西平均分到的注意力就越少。
配套代码的输出里,"因为"那一行特别有意思:它的 embedding 是全零,和谁都"对不上",于是 softmax 给了前面每个 token 一模一样的 0.25。没有信号的时候,注意力就平均摊开了。 想象一下,如果上下文里塞了几万行无关的日志,关键的那一行能分到多少注意力?
这就是第 14 篇要讲的"注意力是有限的预算",也是第 16 篇"越聊越笨"的原因之一。上下文工程的很大一部分工作,就是别让无关的东西稀释了注意力。
六、动手:手算一遍注意力
实验 1:跑一遍
go run ./examples/ch09-attention这个程序不调用任何模型,纯粹是 100 来行的矩阵计算,也可以在 Playground 的第 09 篇里直接跑。输出:
注意力权重:每一行是一个 token 在"看"其他 token 时的分配比例(行和为 1)
小猫 喝 牛奶 因为 它 渴
小猫 1.00 · · · · · ← 最关注「小猫」
喝 0.41 0.59 · · · · ← 最关注「喝」
牛奶 0.25 0.31 0.44 · · · ← 最关注「牛奶」
因为 0.25 0.25 0.25 0.25 · · ← 最关注「小猫」
它 0.40 0.13 0.12 0.11 0.24 · ← 最关注「小猫」
渴 0.18 0.17 0.19 0.14 0.16 0.17 ← 最关注「牛奶」对着表看几件事:
- 右上角全是点:那是因果掩码,每个 token 只能看见前面的;
- "小猫"那一行是 1.00:它是第一个 token,只能看见自己;
- "它"那一行:最大的权重落在"小猫"上,0.40,和我们 3.3 节手算的一模一样;
- "因为"那一行:全是 0.25。embedding 是全零,和谁都对不上,注意力就平均摊开了;
- "渴"那一行:最关注的竟然是"牛奶",而且六个权重都差不多,非常"犹豫"。
最后一条值得多说一句。人读这句话,"渴"显然是在说小猫。为什么这里不是?因为我手写的矩阵里,根本没有给"渴"设计任何"找法"。我只写了"代词找动物""动作找食物"两条规则,"渴"不是代词也不是纯粹的动作,它的 Query 是几条规则胡乱混合的结果。
这正好说明了为什么真实模型的参数要训练出来:语言里的关系千千万万,人手写不过来,顾此失彼。训练做的事情,就是在几千维的空间里,自动找到能让"预测下一个 token"最准的那一组矩阵。
实验 2:动手改矩阵
- 想办法让"渴"最关注"小猫"。提示:
wq的第 3 行决定了"是动作"那一维会变成什么样的 Query,试着在第 1 列加一点数字,让"动作感"也带上"找动物"的意思。改完之后看看其他行有没有被你改坏,体会一下"顾此失彼"; - 把因果掩码那个
if j > i删掉,看看每个 token 能看见后面的 token 之后,表格变成了什么样。有些模型(比如早年用来做文本理解的 BERT)就是双向的,但它们没法用来一个字一个字地生成; - 把"它"的 embedding 改成
{0, 0, 0, 1.0},让它变成一个"纯粹"的代词,看看它找"小猫"的权重是变高还是变低了。
推荐资料
- 3Blue1Brown 深度学习系列的第 5 到第 7 集:用动画讲 Transformer、attention 和 MLP,是我见过最好的直觉版讲解;
- 《Attention Is All You Need》(Vaswani 等,2017):看完本篇和上面的视频之后再去读,你会发现第三页那个公式其实就是 3.3 节的手算过程。
七、踩坑记录:关于 Transformer 的六个误解
误解 1:模型"理解"语言的方式和人一样。
它做的是在高维空间里移动向量,让下一个 token 预测得更准。这个过程能产生看起来非常像"理解"的行为,但它的失败方式和人完全不同,比如数不清字母(第 06 篇)。用人的直觉去猜它会在哪里犯错,经常猜不准。
误解 2:上下文里的每个字都被同等重视。
attention 是按相关性分配的,而且总量有限。和当前要生成的内容相关的部分会被重点关注,无关的部分几乎被忽略,而被大量无关内容包围的关键信息,可能分不到足够的注意力。
误解 3:模型能"看到"整段话。
生成的时候,它只能看见前面的。它不知道自己接下来要写什么,只是一个 token 一个 token 地决定。这也是第 07 篇"先推理后结论"那个现象的根源。
误解 4:长上下文是免费的。
上下文窗口从几千涨到了一百万,但 attention 的计算量随长度增长得很快,KV cache 也要占显存。厂商用了很多工程手段让长上下文变得可用,但它依然更慢、更贵,而且质量会随长度下降(第 10、16 篇)。
误解 5:参数越多,每一步就越慢、越贵。
对 MoE 模型来说不一定。总参数决定了它能"装"多少知识,每一步激活的参数才决定了计算成本。看到一个总参数很大、价格却很低的模型,不用奇怪,多半就是这个原因。
误解 6:这篇看不懂,后面也看不懂。
完全不会。后面所有关于 Agent 的内容,只需要你记住本篇的三个结论:上下文越长越贵,前缀稳定就能缓存,注意力是有限的预算。这三条结论,第 10、14、16、28、37 篇都会反复用到,到时候你会发现它们比公式好用得多。其余的细节,看不懂就先放着,等哪天好奇了再回来。
一句话总结
Transformer 把每个 token 变成向量,再用几十层 attention 和 MLP 反复加工:attention 让每个 token 按相关性去"看"前面的 token、吸收它们的信息,MLP 在每个位置上存储和调取知识;对 Agent 来说,记住三件事:上下文越长越贵,前缀稳定就能缓存,注意力是有限的预算。
变成向量"] E --> A["attention
互相看"] A --> M["MLP
各自加工"] M -->|"重复几十层"| A M --> O["下一个 token"] style A fill:#fff3e0,stroke:#e65100
评论
还没有评论,来说点什么吧。