一句话导读:注意力(attention)不神秘,它就是"每个词按相关度,对其它词做一次加权平均":先算像不像,再把分数归一化成一组比例,最后按比例把历史信息混起来。
关键词:注意力、查询/键/值、分数、归一化(softmax)、加权平均
承接上一章:第 7 篇讲的是权重"怎么存得省",这一篇看模型怎么用这些权重去理解一句话,核心机制就是注意力。很多入门材料把它讲成一堆矩阵乘法,读者看完还是不知道它在干嘛;我们先不谈实现,先把它的"想法"讲清楚。
① 一句话概念
注意力就是按相关度做一次加权平均。
② 起点:会议室里的速记员
想象你在一个长会上做记录。会议室里每个人都在说话,你要一句一句写下来。关键的一点是:你写下新的一句时,并不是把前面所有话平等地过一遍。刚才财务同事报的那组数字,对你现在要写的这句最重要;开头那段闲聊停车位的话,几乎可以忽略。
你脑子里的动作其实是三步:先判断"前面哪些话跟现在这句有关"、给每句话一个重视程度;然后这个重视程度不是拍脑袋定的,它有高有低,加在一起正好是满满的一份;最后你落笔写的那句,是被前面最相关的那几句带偏的结果。
还有两个细节值得记住。第一,"相关"是相对当前这句而言的——同一段历史,你现在写的是总结,和你要写的是反驳,重点完全不同。第二,越相关的话,影响越大,但不是独占——最相关的那句可能占了七成影响,可剩下那几成还是来自其它句子。这种"按重视程度把信息搅在一起"的动作,就是下面要讲的机制。
③ 伪代码:像不像 → 归一化 → 加权平均
函数 注意力(查询q, 键列表K, 值列表V, 维度) -> 输出: 分数列表 = [] 对每个 键k 于 键列表K: 分数列表.追加(点积(q, k)) # 当前词与历史词逐个比"像不像" 比例 = 归一化(分数列表 / 开方(维度)) # 变成加起来等于 1 的一组比例 输出 = 0 对每个 位置i 于 比例: 输出 = 输出 + 比例[i] * 值列表V[i] # 按比例把历史信息混起来 -> 输出 # 代价: O(历史长度 × 维度)逐行要点:
- 第 2–4 行的分数就是"像不像"的量化。当前词会产出一个"查询"(query,我现在想找什么),每个历史词会产出一个"键"(key,我能提供什么)。两者做一次点积,得到一个数——越像,数越大。这一步是"打分"。
- 第 5 行先缩放再归一化。光有分数还不能用,因为分数有大有小、甚至可能是负数。先除以维度的开方把数值压一压,再"归一化"(softmax)成一组加起来正好等于 1 的比例。归一化不是耍花招,它保证后面是"平均"而不是"累加"。
- 第 6–8 行是加权平均。每个历史位置还有一个"值"(value),把它们的值按上面的比例加起来。注意输出的长度是固定的,和"历史有多长"无关——再多历史,混出来的也只是一份结果。
- 最后一行
# 代价。这一步要跟每一个历史词比一次,所以代价随历史长度线性增长。这个数字看着还好,但第 10 篇会告诉你它很快会变成平方级的麻烦。
分数怎么一步一步变成输出,如图 1 所示。
图 1注意力三步:像不像 → 归一化 → 加权平均:最左边是"当前词的 Q",下面竖排着"历史词 1~4 的 K",几条细箭头从它们汇进右边的"① 逐个点积 / 分数:像不像";再向右依次是"② 归一化 / 变成加起来等于 1 的比例"和"③ 按比例混合 / 把历史词的 V 加权求和",最后是"输出(长度与历史无关)";图下半部分画出归一化之后的四条比例条(0.45、0.30、0.18、0.07),长短不同而加起来正好是 1;红框提醒"分数一大,指数就会溢出"。
归一化这一步到底把分数变成了什么,如图 2 所示。
图 2softmax 在做什么:把任意分数变成加起来等于 1 的比例:左边一列是原始分数(2.0、0.5、−1.0、1.0,既有负数也有大数);中间一个方框写着"先取指数 / 再除以 / 总和";右边是四条比例长条(0.61、0.14、0.03、0.22),条越长表示原来的分数越高,右下角标着"总和 = 1.00";底部一句"取指数保证是正数;除以总和保证加起来是 1"。
④ 纸笔实验(必做)
设定(5 分钟):3 个历史词,它们跟当前词的相似度分数是[2, 1, 0]。手算一次归一化(这里假设 开方(维度) = 1,也就是分数不需要再缩放)。先备一张指数的小表:e⁰ = 1.00、e¹ = 2.72、e² = 7.39。
- 取指数:7.39、2.72、1.00
- 求和:7.39 + 2.72 + 1.00 = 11.11
- 逐个除以和:比例 = 0.665、0.245、0.090(加起来 ≈ 1.000)
- 按比例混合:假设三个历史词的值分别是 10、20、30,输出 = 0.665×10 + 0.245×20 + 0.090×30 = 6.65 + 4.90 + 2.70 =14.25
预期结果:输出 ≈ 14.25。同时你会看到一件有意思的事——最大的分数(2)只比第二名(1)大了 1,却占了 66.5% 的份量。取指数会把差距放大,这是 softmax 的性格:它让"明显更相关"的那一项很快占主导。
可选 REPL 版(Python 伪代码):
import math 分数 = [2, 1, 0] 指数 = [math.exp(s) for s in 分数] 比例 = [e / sum(指数) for e in 指数] 值 = [10, 20, 30] 输出 = sum(w * v for w, v in zip(比例, 值)) print([round(w, 3) for w in 比例], round(输出, 2)) # 预期 [0.665, 0.245, 0.09] 14.25⑤ 进阶锚点
进阶锚点:本篇概念在《30 天手搓推理引擎》Day 8里被真正实现——
8-1 自注意力数学:Q·K^T / softmax / V/8-2 在线 softmax:为什么不能先算完 e^x 再除/8-3 KV 缓存结构:按 token 还是按头存。入门版到这里就够了;进阶版会多出:真实源码里 Q、K、V 是怎么算出来又怎么相乘的、"在线 softmax"为什么要在数值上绕一圈(附踩坑实验),以及 KV 缓存到底按词(token)存还是按头存。
想动手 → 仓库 https://gitee.com/pei-xiaoguang/kestrel-llm,从 Day 1 开始。
下篇预告:第 9 篇我们回答一个很实际的问题——模型每吐出一个字,为什么不必把前面所有字重新想一遍?答案是:把中间结果存下来,也就是 KV 缓存(KV cache)。