Scaled dot-product + causal mask
B1(Day 1-10)把「评测方法论 + tokenization + 成本对齐」打底完毕——我们已经能数 token(1760 token 实测)、能把 cost 和 token×单价对齐。但那是把模型当黑箱看「外部行为」。从今天起 B2 进入模型「内部机理」:Day 11 拆开 decoder 的最小原子——单头 scaled dot-product attention + causal m
阶段: B2 · attention/decoder/KV + judge 校准(Day 11-20) 标签: #attention #causal-mask #softmax #decoder-only
今日导引(由浅入深)
B1(Day 1-10)把「评测方法论 + tokenization + 成本对齐」打底完毕——我们已经能数 token(1760 token 实测)、能把 cost 和 token×单价对齐。但那是把模型当黑箱看「外部行为」。从今天起 B2 进入模型「内部机理」:Day 11 拆开 decoder 的最小原子——单头 scaled dot-product attention + causal mask。这是整条 B1→B18 能力曲线第一次「不止做笔记、而是把机制写成可测试代码」的拐点:后续 Day 12(multi-head/decoder block)、Day 14(KV cache)、乃至 B12 的 MLA/FlashAttention,全部建立在今天这块代数上。今天的「最小可判定产出」是一句话:第 i 个 token 的输出对未来 token 的扰动必须不变(causal 性质可被单测证伪)。
1. 机理精读
Attention 的定义极简:给定 query 矩阵 Q、key 矩阵 K、value 矩阵 V(都是 [seq, d]),先算 scores = QKᵀ,把每个 query 与所有 key 做点积得到 [seq, seq] 的相关性矩阵;除以 √d 缩放;再对每一行做 softmax 得到归一化权重;最后 weights · V 对 value 加权求和。一行的语义是:「当前位置该从其他位置各借多少信息」。
更细地拆三个角色:
- query(问):当前 token「想找什么信息」的方向向量。
- key(答的索引):每个位置「我能提供什么」的标签向量。
- value(答的内容):每个位置实际被读出的内容向量。
scoreᵢⱼ = qᵢ·kⱼ 衡量「位置 i 的问」与「位置 j 的标签」有多匹配;softmax 把一行的匹配度归一化成「分配给各位置的注意力预算」(和为 1);weightsᵢ · V 是按预算把各位置的内容加权混合,得到位置 i 的输出。Q、K、V 本身是输入经三组可学习投影 Wq/Wk/Wv 得到的——投影矩阵才是「学到的知识」,attention 算子本身无参数。
为什么要除以 √d:Q、K 的分量若近似独立同分布、均值 0 方差 1,则点积 q·k = Σ qᵢkᵢ 是 d 个独立项之和,方差随 d 线性增长(≈ d),标准差 ≈ √d。d 大时(如 d=128),未缩放的 logits 会很大,softmax 进入饱和区——梯度趋近 0、权重退化成近似 one-hot,模型只盯一个位置。除以 √d 把 logits 方差拉回 ≈1,softmax 落在敏感区,梯度健康。这是 Vaswani 2017《Attention Is All You Need》里 §3.2.1 给出的原始动机。
为什么是 √d 而不是 d:我们要归一化的是点积的标准差(≈√d),不是方差(≈d)。除以 d 会过度压扁 logits,让分布过平、几乎均匀注意;除以 √d 恰好把标准差拉回 1 的量级,是「方差稳定化」的标准做法。
causal mask 为什么是上三角置 -∞:decoder-only 模型做的是「自回归语言建模」——预测第 i+1 个 token 时只能看 1..i,绝不能偷看未来,否则训练时泄露答案、推理时分布不一致。实现上,把 scores 矩阵中 j > i(列 j 在行 i 之后即未来)的元素置 -∞;softmax 时 exp(-∞)=0,未来列权重归零。注意是置 -∞ 而非置 0:若直接把权重置 0,softmax 的分母仍含未来项、归一化被污染;置 -∞ 在 softmax 之前做才正确。
causal 的工程价值:因为有 mask,训练时一条长度 n 的序列可以一次前向同时得到 n 个位置的「下一 token 预测」——位置 i 的损失只用到 1..i 的信息,互不串扰。没有 mask 就只能逐位置喂、训练慢 n 倍。这也是 decoder-only 训练高效的根因。
关键权衡:朴素 attention 的时间/空间都是 O(seq²·d)——scores 矩阵本身就是 seq×seq。短序列教学无碍,长上下文(128K)下这是显存与算力的主战场,催生了 FlashAttention(不显式落 seq×seq 矩阵、分块在 SRAM 里算)、MLA(压缩 KV)等。今天我们刻意用朴素版,因为手算可验证 + 单测可证伪比性能更重要。
与相邻概念的边界:今天是「单头」——一个 Q/K/V 投影、一组权重。Day 12 的 multi-head 是把 d 切成 h 份各自做一遍 attention 再拼回;Day 14 的 KV cache 是利用「历史 token 的 K/V 不随新 token 改变」省掉重算。三者共用今天这块 attention() 核。参考 3Blue1Brown《Attention in transformers, visually explained》(2024-04) 的几何直觉:query/key 是「问与答」的方向向量,点积大 = 语义匹配。
还需划清的边界:本日是 self-attention(Q/K/V 同源于一条序列)+ causal(只看过去)。它不同于 encoder 的双向 self-attention(无 mask,看全句),也不同于 encoder-decoder 的 cross-attention(Q 来自 decoder、K/V 来自 encoder)。现代生成式 LLM 用的就是「causal self-attention」这一种。三类对照:
| 类型 | Q/K/V 来源 | mask | 用在哪 |
|---|---|---|---|
| causal self-attn | 同序列 | 上三角 -∞ | decoder-only LLM(GPT/Llama) |
| bidirectional self-attn | 同序列 | 无 | encoder(BERT 类) |
| cross-attn | Q=decoder, K/V=encoder | 无(或 padding mask) | encoder-decoder(翻译、T5) |
本日只实现第一行——它是今天到 B12 全部生成式机理的根。
2. 推导 / 手算 / 代码走读
本仓已把单头 attention 实现并测试,落在 src/agent/transformer/transformer.ts(seed 里规划的 attnSingleHead.ts 文件名最终被合并进这一份统一实现,机制本身一字不差地存在;独立文件未单建)。真实函数 attention(Q, K, V, causal)(第 64-70 行)走读:
const dk = Q[0]!.length; const scale = 1 / Math.sqrt(dk)——取 key 维度dk算缩放因子1/√d,与机理一致。let scores = matmul(Q, transpose(K)).map(row => row.map(v => v * scale))——QKᵀ后逐元素乘scale,得[seq][seq]。transpose与matmul是同文件内自写的纯数值算子(无张量库)。if (causal) scores = scores.map((row, i) => row.map((v, j) => (j > i ? -Infinity : v)))——这一行就是 causal mask:列j > i即未来位置置-Infinity。逻辑与「上三角置 -∞」完全对应。return matmul(softmaxRows(scores), V)——softmaxRows(第 36-43 行)是逐行数值稳定 softmax(先减 row max 再 exp,避免溢出),再· V得加权输出。
手算一个最小 causal 例子(d 任意,只看 mask 行为,2 token,scores 已算好):
原始 scores(缩放后) causal mask 后 softmax 后(逐行)
[[2.0, 1.0], → [[2.0, -∞], → [[1.000, 0.000],
[0.5, 3.0]] [0.5, 3.0]] [0.076, 0.924]]
- 行 0(第一个 token)只能看自己:未来列被 -∞,softmax 后 =
[1, 0],行和 1.000。 - 行 1(第二个 token)能看 0 和 1:
softmax([0.5, 3.0]) ≈ [0.076, 0.924],行和 1.000。 - 上三角恒为 0 ✓,每行和恒为 1 ✓——这正是 seed 要求截图的「下三角非零、上三角为 0、行和=1.000」。
把第 1 行的 softmax 完整算一遍(验证数值稳定写法):
- 取 row max =
3.0,逐项减去:[0.5-3.0, 3.0-3.0] = [-2.5, 0]。 - exp:
[exp(-2.5), exp(0)] = [0.0821, 1.0]。 - 归一化:和 =
1.0821,[0.0821/1.0821, 1.0/1.0821] = [0.0759, 0.9241] ≈ [0.076, 0.924]。
「先减 max 再 exp」给出的结果与「直接 exp 再归一化」数学等价(分子分母同乘 exp(-max) 约掉),但避免了 exp(大数) 溢出——这正是本仓 softmaxRows 的写法。最终输出 out = weights · V:行 0 的输出 = 1.0·V[0] + 0·V[1] = V[0](只读到自己),行 1 = 0.076·V[0] + 0.924·V[1](主要读后者)。
verify the causal property by perturbation:把 V[1](未来位置的 value)改成任意值,行 0 的输出仍是 V[0],纹丝不动——因为行 0 给未来的权重恒为 0。这正是本仓单测 describe('causal attention') 的断言逻辑(扰动最后一行 V,前几行输出不变),也是今天「最小可判定产出」的可执行化身。
支撑算子也值得点名(同文件,被 attention 复用):
matmul(a, b)(第 8-25 行):朴素三重循环矩阵乘,含if (b.length !== k) throw的内维校验,并对aip === 0做跳过小优化。transpose(a)(第 27-33 行):K → Kᵀ,供QKᵀ用。softmaxRows(a)(第 36-43 行):逐行max → exp(x-max) → /sum,数值稳定。
这些都是纯函数、无副作用、无随机,因此 attention 的输出对固定输入完全确定——这是单测能用 toBeCloseTo(..., 10)(10 位精度)做断言的前提。
3. 今日实战
seed 原计划新建 src/agent/transformer/attnSingleHead.ts,对 4-token、d=4 的定值输入逐步 console.log(scores, masked scores, weights)。实际落地时机制被合并进 src/agent/transformer/transformer.ts 的 attention(),并配了真实单测 src/agent/__tests__/transformer.test.ts。可复现实战步骤:
- 在
transformer.test.ts的describe('causal attention')用例里,用mulberry32(11)这个确定性 RNG 生成4×4的 Q/K/V(randMatrix(4, 4, rng))。 - 跑
attention(Q, K, V, true)得out1;把 V 的最后一行整体+5得 V2,再跑out2。 - 断言:
out1与out2的前 3 行(行 0..2)逐元素相等(toBeCloseTo(..., 10))——证明扰动「未来 token」不影响「过去 token」的输出,即 causal 性质成立。 - 跑
pnpm vitest src/agent/__tests__/transformer.test.ts,观察 causal 用例绿。
补充可做的手算实验(复现 seed 要的截图产出):
- 取定值
Q=K=V为4×4单位相关矩阵,手工调attention(Q,K,V,true),打印中间scores、maskedscores、weights。 - 检查
weights是严格下三角非零、上三角全 0、每行和 = 1.000——即 seed 描述的「下三角非零、上三角为 0、行和=1.000」。 - 把
causal=false再跑一次对照:此时weights满阵、每行仍和为 1,但行 0 也会读到未来——直观看出 mask 的作用。 - 这一步纯离线、无 key、无网络,是今天「最小可判定产出」的可视化版本。
4. 今日实测 / 产出
- 状态(按 seed):seed 标注「待建/待跑」。规划产出为
attnSingleHead.ts+ 手算 4×4 weights 截图(下三角非零、上三角为 0、行和=1.000)。 - 实际进度(诚实标注):机制已落在统一文件
src/agent/transformer/transformer.ts的attention(),且transformer.test.ts的 causal 单测已绿(用mulberry32(11)固定种子,扰动未来 token 不改过去输出)。seed 计划的独立文件attnSingleHead.ts未单独创建——这是诚实差异,不能宣称已建独立文件。手算 4×4 weights 截图仍属待补产出。 - 该实现纯确定性、无 API key 依赖,可直接配 vitest 单测——这一条与 seed 一致。
5. 常见误区 / 陷阱
- 把 mask 在 softmax 之后做:若先 softmax 再把未来列置 0,分母已被未来项污染、行和不再为 1。必须在 softmax 之前把未来 logits 置 -∞。
- softmax 不减 max 直接 exp:
exp(大 logit)溢出成Inf。本仓softmaxRows先减 row max,是数值稳定的标准写法。 - 缩放因子用错维度:除的是 key/query 的特征维 d_k,不是 seq 长度。多头时是每个头的
d/h,不是全维 d——Day 12 会再踩到。 - 把朴素 attention 当生产方案:O(seq²) 显存在长上下文下爆炸,生产侧早已是 FlashAttention/MLA。教学手算用朴素版无妨,但别迁移结论到性能。
- 把 mask 用
-1e9而非-Infinity还能接受,但用0不行:-1e9经 softmax 后exp出几乎 0,可接受;置0则exp(0)=1会给未来位置巨大权重,彻底破坏 causal。本仓直接用-Infinity,最干净。 - 混淆
QKᵀ行列语义:scores[i][j]是「query i 对 key j」。mask 的是「j > i」(query 看不到它之后的 key),写成「i > j」就把过去 mask 掉了、方向反了。
6. 学习资源(每条带 YYYY-MM)
- Vaswani et al.《Attention Is All You Need》(2017-06, arXiv:1706.03762)——scaled dot-product attention 与 √d 缩放的原始出处。
- 3Blue1Brown《Attention in transformers, visually explained》(2024-04)——seed 指定复看,几何直觉最清晰。
- Karpathy《Let's build GPT: from scratch》/ nanoGPT (2023-01 起持续更新)——单头/多头 attention 的从零实现范式。
- FlashAttention-3 (2024-07, arXiv:2407.08608)——长序列下 attention 的当前推理 SOTA,作为「朴素版之上是什么」的参照。
- Jay Alammar《The Illustrated Transformer》(2018-06, 持续维护)——Q/K/V 与 multi-head 的图解经典,补几何直觉。
- 本仓
src/agent/transformer/transformer.ts+src/agent/__tests__/transformer.test.ts(AICAP-180, 2026-06)——本日attention()的真实实现与 causal 单测。
SOTA检查 (2026-06 更新)
- 当前主流:scaled dot-product attention(Vaswani 2017)仍是所有 decoder-only 模型的基座,机理不过时;causal mask 是自回归 LM 的硬约束。3Blue1Brown 2024-04 讲解仍准确。
- 是否仍 SOTA:作为「数学定义」是 SOTA;作为「推理实现」不是——生产侧已是 FlashAttention-3(2024-07)/ MLA(DeepSeek-V3,2024-12)等,目标是省掉
seq×seq显存与带宽。教学手算仍用朴素版。 - 过时黑名单:不要把 O(seq²) 朴素 attention 宣称为长上下文生产方案;不要用未缩放点积(softmax 饱和);不要在 softmax 后做 mask。
- 下次复查点:B2 末(Day 20)回看;B12 attention/KV 深水区时用真实 FlashAttention-3 / MLA 论文替换教学版基线,重验当周 SOTA。
衔接
- 昨天:Day 10 — 双数对齐收口(eval cost 与 token 报告对齐,B1 黑箱外部行为收尾)。
- 今天:把 decoder 最小原子——单头 scaled dot-product attention + causal mask——写成可被单测证伪的代码,确立「未来不影响过去」。
- 明天:Day 12 — Multi-head + decoder block 结构(把单头扩成多头,叠残差/LayerNorm/FFN 成完整 block)。