🦙 第1章 大模型的基础理论
📄 一句话概括

这一节把 Transformer 拆成三样能算的东西:注意力(谁该看谁)、位置编码(谁在第几个)、 多头(同时从几个角度看)。 三样都有公式,公式都能用几行 numpy 写出来。 写完你会发现,所谓「大模型」,不过是一堆这种小算子在几十层里重复。

📝 本章和前面几章的关系

上一节讲的是这条技术路线怎么走到今天,这一节讲它脚下那块砖。 41 课 图解深度学习 里有注意力的图示,这一节不重复画图, 只把数算出来——因为后面算显存、算 LoRA、算 KV 缓存,用的都是这一节的几个量。

一、Transformer 做的事,一句话说完

给一句话,每个词先变成一个向量; 然后过若干层,每层做两件事——让每个词去问其他词(注意力),再让每个词自己想一遍(前馈)。 最后拿最后一个位置的向量,去猜下一个词。 Llama 2 用的是解码器那半边:每个词只能看它前面的词,不能看后面。 这叫因果掩码,实现上就是把打分矩阵的右上角全设成负无穷。 一个词在模型里始终是一个 4096 维的向量,从第一层到第三十二层,形状不变。 每层做的是「加上一点东西」,不是「换成一个新东西」。这个残差结构是深层网络能训起来的关键。

二、注意力:三个矩阵乘出来

每个词的向量乘上三个矩阵,得到三样东西:查询(Q)、键(K)、值(V)。 可以理解成「我想找什么」「我这儿有什么」「真找到了给你什么」。 打分就是查询和键做内积:第 i 个词和第 j 个词有多配。 然后把打分过一遍 softmax 变成权重,再拿权重去加权求和所有的值。 打分要除以根号 d。 原因很实在:内积是 d 个数相加,d 越大绝对值越大,softmax 会被最亮的那一个吃掉, 输出接近 0 和 1 的两档,梯度就趋近于零。除以根号 d 把量级拉回来,这一层才学得动。

这个数是能算出来的。三个词、八维,不除的时候最大权重 0.981、平均熵 0.314 纳特; 除以根号 8 之后最大权重降到 0.775、熵升到 0.681。熵升了一倍多,说明权重摊开了。

三、位置编码:不加这个,句子就是一袋词

注意力有个性质:它对称。 把词序打乱,算出来的权重矩阵只是跟着换行换列,模型分不出「甲方欠乙方钱」和「乙方欠甲方钱」。 原始 Transformer 的解法是正弦位置编码:给每个位置算一组 sin 和 cos,维度越高频率越低。 第 0 维每隔一个位置就转过一大圈,第 6 维转四圈才走一小步。 快的那几维负责精确的「差几个」,慢的那几维负责大致的「在第几段」。 两层叠起来,得到一个能外推到比训练更长的序列的坐标。

Llama 2 换成了旋转位置编码(RoPE):不往向量上加东西,而是把向量按位置转一个角度。 两个向量做内积时,角度差会自然变成一个只跟「相隔几个位置」有关的项。 这样相对位置直接进了内积,比加法编码更干净,也更容易外推。

四、多头:把一个大向量切成几份,各看各的

一个 4096 维的向量只做一次注意力,只能学出一种「该看谁」的模式。 多头就是把它切成若干份,每份单独算注意力,最后拼回去。 Llama 2 7B 是 32 个头,每个头 128 维;70B 是 64 个头,每头 128 维。 头数乘以每头维度,正好等于隐藏维度,所以切分不增加参数。 跑出来的结果很有意思:四个头在同一批向量上,熵从 0.571 到 0.735,差 1.3 倍。 尖的那一头把大半权重压在少数位置上,平的那一头摊得比较开。

这个分工不是设计出来的。初始化时所有头几乎一样,是训练把它们推到了不同的关注点上。 所以「第几头管语法、第几头管指代」这种说法,只能当观察,不能当设计依据。

五、一层有多少参数,一次能算多长

这四样东西的参数量可以直接写出来:注意力四个矩阵,每个是隐藏维的平方; 前馈三个矩阵,每个是隐藏维乘中间维。 Llama 2 的中间维不是隐藏维的四倍,而是 11008,这是为了配合 SwiGLU 的门控结构做的取舍。 按这个算,7B 每层 2.02 亿,三十二层 64.76 亿,加上词嵌入和输出投影各 1.31 亿,总计 67.38 亿。 跟 Meta 公布的 67.39 亿只差 0.01%——差的就是没算进去的归一化层缩放系数。 另一笔账是推理时要占多少内存。权重按 fp16 存,一份 12.6 GiB; 但真正跑起来还要存 KV 缓存——每个位置、每层、每个头都要存一份键和值。 序列越长,这块越大,4096 上下文下能到好几个 GiB。这就是第 2 章算显存时容易漏掉的那一项。

六、动手 Demo:把位置编码、注意力、多头、参数量都算一遍

这一节的四段演示全部是 numpy,没有框架。 位置编码打印前四个位置、八个维度的数值; 注意力打印三乘三的打分与权重矩阵,并且把「除不除根号 d」的结果并排放; 多头把 32 维切成 4 份 8 维,看四个头的熵;最后按 Llama 2 的规格把一层的参数量算出来。

# 位置编码:偶数维 sin,奇数维 cos,频率随维度指数下降
def pos_enc(n, d):
    out = np.zeros((n, d))
    for pos in range(n):
        for i in range(0, d, 2):
            w = 1.0 / (10000.0 ** (i / float(d)))
            out[pos, i] = math.sin(pos * w)
            out[pos, i + 1] = math.cos(pos * w)
    return out

# 注意力:Q、K、V 三个矩阵,打分除以根号 d,再 softmax
Q, K, V = X @ Wq, X @ Wk, X @ Wv
S = Q @ K.T
A_raw = softmax(S)                  # 不除
A_sc = softmax(S / math.sqrt(d))    # 除

# 多头:把 32 维切 4 份,每份 8 维各算一次
for h in range(4):
    sl = slice(h * 8, (h + 1) * 8)
    Ah = softmax(Q2[:, sl] @ K2[:, sl].T / math.sqrt(8))

# 参数量:Llama 2 7B 的一层
per_attn = 4 * 4096 ** 2
per_ffn = 3 * 4096 * 11008
total = 32 * (per_attn + per_ffn) + 2 * (32000 * 4096)

实跑输出:

--------------------------------------------------------------------
注意力、位置编码、多头:三样都拿数字算一遍
--------------------------------------------------------------------


  ① 位置编码长什么样
  --------------------------------------------------------------------
| 位置 | d0 | d1 | d2 | d3 | d4 | d5 | d6 | d7 |
|---|---:|---:|---:|---:|---:|---:|---:|---:|
| 第 0 位 | +0.000 | +1.000 | +0.000 | +1.000 | +0.000 | +1.000 | +0.000 | +1.000 |
| 第 1 位 | +0.841 | +0.540 | +0.100 | +0.995 | +0.010 | +1.000 | +0.001 | +1.000 |
| 第 2 位 | +0.909 | -0.416 | +0.199 | +0.980 | +0.020 | +1.000 | +0.002 | +1.000 |
| 第 3 位 | +0.141 | -0.990 | +0.296 | +0.955 | +0.030 | +1.000 | +0.003 | +1.000 |

  第 0 列是 sin(0)=0,第 1 列是 cos(0)=1,所以第 0 位那一行是 0 与 1 交替。
  越靠后的维度变化越慢:d0 每换一位跳一大步,d6 换四位才走一小段。
  ! 慢的那几维管「大致在第几段」,
  快的那几维管「精确差几个」——合起来就是一个可以外推到更长句子的位置坐标。

  ② 注意力:三个矩阵乘出来
  --------------------------------------------------------------------
| 打分 | 对第 0 个 | 对第 1 个 | 对第 2 个 |
|---|---:|---:|---:|
| 第 0 个词 | +0.33 | -4.72 | +4.27 |
| 第 1 个词 | -8.55 | +0.09 | -3.17 |
| 第 2 个词 | -14.37 | -2.93 | -2.70 |

  这张表就是「每个词对每个词的原始打分」,还没归一化。

  ③ 为什么非要除以根号 d
  --------------------------------------------------------------------
  没除:最大权重 0.981,平均熵 0.314 纳特
  除了:最大权重 0.775,平均熵 0.681 纳特
| 归一化后 | 对第 0 个 | 对第 1 个 | 对第 2 个 |
|---|---:|---:|---:|
| 第 0 个词 | 0.192 | 0.032 | 0.775 |
| 第 1 个词 | 0.035 | 0.734 | 0.231 |
| 第 2 个词 | 0.008 | 0.476 | 0.516 |

  不除的时候最大权重 0.981、平均熵 0.314 纳特;除了之后最大权重降到 0.775、熵升到 0.681。
  熵越低说明注意力越集中在一处。
  集中到接近 0 与 1 两档,softmax 的梯度就趋近于零,那一层基本学不动了。
  ! 除以根号 d 就是为了把打分拉回差不多的量级——d 从 8 变成 4096 时,打分不会跟着放大 22 倍。

  ④ 多头:把 32 维切成 4 份 8 维
  --------------------------------------------------------------------
| 头 | 最大权重 | 平均熵 | 最尖的那一行 |
|---|---:|---:|---|
| 第 0 头 | 0.802 | 0.735 | 第 0 个词盯第 0 个 |
| 第 1 头 | 0.870 | 0.571 | 第 1 个词盯第 0 个 |
| 第 2 头 | 0.926 | 0.691 | 第 1 个词盯第 2 个 |
| 第 3 头 | 0.737 | 0.730 | 第 1 个词盯第 0 个 |

  四头的熵从 0.571 到 0.735,差 1.3 倍:尖的那一头把大半权重压在少数位置上,平的那一头摊得比较开。
  ! 这不是设计出来的分工,是训练出来的分工——初始化时四头几乎一模一样,
  跑起来之后各自被数据推到了不同的关注点上。

  ⑤ 一层到底有多少参数
  --------------------------------------------------------------------
| 部件 | 怎么算 | 参数量 |
|---|---|---:|
| 注意力(Q、K、V、输出四个矩阵) | 4 × 4096² | 0.67 亿 |
| 前馈(gate、up、down 三个矩阵) | 3 × 4096 × 11008 | 1.35 亿 |
| 一层合计 |  | 2.02 亿 |
| 32 层合计 |  | 64.76 亿 |
| 词嵌入(32000 × 4096) |  | 1.31 亿 |
| 输出投影(32000 × 4096,不与词嵌入共享) |  | 1.31 亿 |
| 总计 |  | 67.38 亿 |

  合计 67.38 亿,跟 Llama 2 7B 公布的 67.39 亿只差 0.01%,
  差的就是没算进去的归一化层缩放系数——32 层 × 2 个 × 4096,一共 262144 个参数,
  在这张表里四舍五入看不出来。
  按 fp16 存,一份权重 12.6 GiB;要微调还得再存梯度和优化器状态,那是好几倍。
  ! 这张表是理解后面所有部署与微调的地基:看到「7B 要十几 GB」,是从「67 亿 × 2 字节」来的,
  不是估的。

位置编码那张表先看第一行:0、1、0、1、0、1、0、1。 因为位置 0 时 sin(0) 是 0、cos(0) 是 1,所以每一对都是这个组合。 再看后面几行:第 0 维从 0 跳到 0.841 再跳到 0.909,很快; 第 6 维从 0.000 到 0.003,四个位置只挪了千分之三。高频与低频的分工,在这张表里一眼看得出来。 注意力打分表里有一处容易看错:对角线并不永远是最大值。 第 0 个词看自己只有 0.33,看第 2 个词是 4.27。这正常——权重矩阵是随机的,还没训练。 除根号 d 的对比是这一节最该记住的数: 最大权重 0.981 降到 0.775,平均熵 0.314 升到 0.681。熵升一倍多, 意味着权重从「接近独热」变成了「有所偏好但不极端」,梯度才有得传。 多头那张表里,最大权重 0.737 到 0.926,熵 0.571 到 0.735。 差距不算悬殊,但方向很清楚:有的头压得紧,有的头摊得平。真模型里这个差距会更大。 参数量那张表有一个数值得记住: 一层 2.02 亿,三十二层 64.76 亿。所以「7B 模型要 12.6 GiB」不是估的, 是 67.38 亿乘两个字节算出来的。第 2 章估显存、第 3 章算 LoRA、第 3.5 节算量化,起点都是这一行。

30 秒实验

  1. 把 pos_enc 的 10000 改成 100,重跑。 看第 6 维的变化是不是变快了——这个底数决定「最慢的那一维有多慢」,也就是模型能分辨多长的序列。
  2. 把注意力那段里的 d 从 8 改成 64,看「不除」那一列的最大权重会变成多少。 想想如果 d 是 4096,softmax 出来的矩阵会长什么样。
  3. 把 H 从 4 改成 8,每头 4 维,再跑一遍。 参数量没变,但四个头的熵分布变了——这说明「切多细」本身就是一个要调的参数。

常见误区

  1. 以为注意力权重就是「解释」。 权重高只说明这一步里它加权得多,不说明模型是「因为这个词才这么答」。 相关不等于因果,注意力尤其如此。
  2. 以为位置编码是可有可无的装饰。去掉它,模型连「甲欠乙」和「乙欠甲」都分不清。 它不是给模型加信息,它是把被注意力抹掉的顺序还回去。
  3. 以为多头是「多个模型投票」。它们共用同一个输入、同一个输出拼接,训的是一套参数。 分工是训出来的,不是分好的。
自测三问(点开对答案) 1. 为什么要除以根号 d,而不是除以 d? —— 因为内积是 d 项相乘再相加,方差随 d 线性增长,标准差随根号 d 增长。 要抵消的是标准差,所以除根号 d。除 d 会把打分压得太小,softmax 变成均匀分布,同样学不动。 2. KV 缓存为什么要单独算内存,权重不是已经算过了吗? —— 权重是固定的,跟输入多长无关;KV 缓存随序列长度线性增长,每个位置、每层、每个头都要存一份。 上下文 4096 时这块能到几个 GiB,长上下文推理时它常常比权重还占地方。 3. 七个十亿参数、三十二层、四千零九十六维——这三个数能推出什么? —— 能推出「一层两亿」,进而推出「fp16 存一份权重十二点六 GiB」, 再进而推出「一张 16 GB 的卡跑推理勉强够,微调不够」。 后面所有部署与微调的账,都是这么一层层推下来的。

下一节离开模型内部,去看喂给它的东西——数据从哪来,又脏在哪里。

相关笔记