Transformer 与 Attention,不推公式的直觉版

outlier 15 分钟 5984 字 1 0
系列教程Agent从入门到放弃 第 10 / 19 章
  1. 01Agent 从入门到放弃:教程大纲
  2. 02①大模型、Chatbot、Agent,一次把名字理清楚
  3. 03②Agent 的最小定义,一个 while 循环
  4. 04③Agent 简史,从 ReAct 到 Claude Code
  5. 05④Workflow 还是 Agent?大部分场景你不需要 Agent
  6. 06Agent 能力地图,为什么编码 Agent 最先成熟
  7. 07Token,模型眼里的世界
  8. 08Next-token prediction,一个字一个字地猜
  9. 09采样,temperature、top-p 与"同题不同答"
  10. 10Transformer 与 Attention,不推公式的直觉版
  11. 11Context Window,模型的内存条
  12. 12训练三阶段,能力是怎么被"装"进去的
  13. 13推理模型,让模型先想再答
  14. 14用原理解释怪行为,原理篇结业考
  15. 15从 Prompt 工程到 Context 工程
  16. 16System Prompt 写作,角色、约束与示例
  17. 17Context Rot,为什么长对话越聊越笨
  18. 18项目记忆,CLAUDE.md 与 AGENTS.md
  19. 19检索,RAG 还是 Agentic Search
查看教程目录

开场:我和《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"。中间发生了什么?画出来是这样的:

flowchart TB T["token 编号
[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

三个关键环节:

  1. embedding:把每个 token 编号换成一个向量(一长串数字);
  2. 一层又一层的处理:每一层里有两个部件,attention 负责"让 token 之间交换信息",MLP 负责"每个 token 自己消化一下";
  3. 输出:取最后一个位置的向量,变成词表上每个 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 变换出来的,变换用的矩阵是训练出来的参数。

过程就像一场相亲:

  1. "它"拿着自己的 Query("我在找一个动物"),去和前面每个 token 的 Key("我是动物""我是动作""我是饮品"……)对一对;
  2. 对得越上,分数越高。"对一对"的方法就是点积:两个向量对应位置相乘,再加起来;
  3. 分数经过 softmax,变成一组加起来等于 1 的权重(又是 softmax,第 07 篇的老朋友);
  4. 按权重,把各个 token 的 Value 加权混合起来,作为"它"这一层的新信息。
flowchart LR Q["「它」的 Query
我在找:动物"] --> 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 里"找动物"那一维,而且放大两倍。

我们手算"它"那一行(只用加法和乘法):

  1. "它"的 embedding 是 [0.6, 0.1, 0, 1.0],乘以 wq,得到它的 Query:[2.6, 0.1, 0, 0]。2.6 这个大数,就是"我在找动物";
  2. 拿这个 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;
  3. 每个分数除以 √4 = 2(这一步是为了防止维度很高时分数太大,softmax 变得太尖),得到 1.3、0.145、0.05、0、0.785;
  4. softmax 之后:
小猫喝牛奶因为它
权重0.400.130.120.110.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,构成一层。真实的大模型要堆几十层。可以粗略地这样理解:

flowchart TB L0["embedding
「它」只是一个代词"] --> 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 亿。模型"知道"的东西很多,但每一步只动用一小部分。 这是它能把价格做得那么低的原因之一:计算成本主要取决于每一步激活了多少参数,而不是总共有多少参数。

flowchart LR T["一个 token 的向量"] --> R{"路由器"} R -->|"选中"| E1["专家 3"] R -->|"选中"| E2["专家 17"] R -.->|"这次不用"| E3["其他几百个专家"] E1 & E2 --> O["加回信息流"] style R fill:#fff9c4 style E3 fill:#f5f5f5

五、这些原理和 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 缓存命中的输入,单价只有未命中的五十分之一。

flowchart LR subgraph R1["第 1 次请求"] P1["system + 工具 + 历史"] --> N1["新问题"] end subgraph R2["第 2 次请求"] P2["system + 工具 + 历史
(和上次一样)"] --> 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:动手改矩阵

  1. 想办法让"渴"最关注"小猫"。提示:wq 的第 3 行决定了"是动作"那一维会变成什么样的 Query,试着在第 1 列加一点数字,让"动作感"也带上"找动物"的意思。改完之后看看其他行有没有被你改坏,体会一下"顾此失彼";
  2. 把因果掩码那个 if j > i 删掉,看看每个 token 能看见后面的 token 之后,表格变成了什么样。有些模型(比如早年用来做文本理解的 BERT)就是双向的,但它们没法用来一个字一个字地生成;
  3. 把"它"的 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 来说,记住三件事:上下文越长越贵,前缀稳定就能缓存,注意力是有限的预算。

flowchart LR T["token"] --> E["embedding
变成向量"] E --> A["attention
互相看"] A --> M["MLP
各自加工"] M -->|"重复几十层"| A M --> O["下一个 token"] style A fill:#fff3e0,stroke:#e65100

评论

还没有评论,来说点什么吧。

发表评论

Ctrl + Enter 快速提交
Enter 搜索Esc 关闭