学习一个技术最好的方式就是能够写一片文章把这个技术的原理解释清楚,本文记录了我在阅读 《Build a Large Language Model (From Scratch)》一书以及和 Claude Code 对话过程中的笔记,仅供参考。

术语解释

向量点积

定义:向量点积为标量

a = (a1, a2, a3)
b = (b1, b2, b3)

a · b = a1*b1 + a2*b2 + a3*b3

几何意义:

a · b = |a| |b| cos(theta)

其中 theta 是两个向量的夹角。

用途:衡量向量相似度

矩阵转置

符号表示:Kᵀ

定义:

A =
1 2 3
4 5 6

Aᵀ =
1 4
2 5
3 6

线性层

假设某个词的向量是 2 个数字:x = [2, 5]。我要 3 个输出,就准备 3 个配方(权重和偏置都是训练学出来的数字):

配方1:  1×x₁ +  0×x₂ + 0
配方2: 0.5×x₁ + 2×x₂ + 1
配方3: −1×x₁ + 1×x₂ + 0

代入 x = [2, 5]:

输出₁ =  1×2 +  0×5 + 0 = 2
输出₂ = 0.5×2 + 2×5 + 1 = 1 + 10 + 1 = 12
输出₃ = −1×2 + 1×5 + 0 = 3

结果:[2, 5] → [2, 12, 3]。 一个线性层的全部工作。

用途:对向量升维或者降维。

Softmax

softmax 把"一排任意分数"变成"一个概率分布":每个值都在 0~1 之间,且全部加起来 = 1。

               e^(xᵢ)
softmax(xᵢ) = ─────────────
               Σ e^(xⱼ)

LayerNorm

LayerNorm 是 Transformer Block 的常见操作。向量在几十层里被反复加加乘乘(注意力、MLP、残差……),里面的数字很容易漂移——有的飙得很大、有的接近 0、整组忽大忽小。这会让训练不稳定(梯度爆炸/消失、loss 乱跳、训不动)。

LayerNorm 的作用:每过一个子层前,把这组数字"重新校准"回一个稳定、统一的范围。

算法:对一个 token 的数字(n_embd 个),做三步:

  1. 去均值:算出这组数的平均值,每个数减掉它 → 平均值变成 0(居中)
  2. 除标准差:算出这组数的标准差,每个数除以它 → 波动幅度变成 1(统一尺度)(为防止除 0,分母会加一个极小的 ε)。 做完①②,向量被"标准化"成:均值≈0,波动≈1
  3. 乘可学习的缩放 γ、加可学习的偏移 β:让模型自己学要不要、以及怎么把尺度调回去——不强制死守0/1,而是"先归一化,再让模型自己微调到最合适"。

同一个 LayerNorm 会对不同 token 都套用一遍。

文本编码

Tokenize

与常规的分词不同的是,对于例如空格之类的特殊字符,需要取决于特殊场景决定是否过滤。比如对 Python 代码,空格有独特语意,不应该被剔除。

"This is a example." => ["This", "is", "a", "example", "."]

Token ID

训练集中的每个词都需要有一个 Token ID 来数值化。有多种不同方法可以来数值化训练集词汇。

简单统计

遍历训练集分词后的词表,排序后,给每个词设置一个 token id。

一般还需要在不相关的文本之间插入一个 <|endoftext|> 标记。这有助于让 LLM 理解文本间是无关的。

- "This"      => 40134
- "is"        => 2052
- "a"         => 133
- "example"   => 389
- "."         => 12
...
- "<|endoftext|>"  => 50000

Byte pair encoding (BPE)

简单基于训练集分词结果建立词表有一个缺点是无法处理例如 “xasdasd” 这类未知词汇。BPE 编码方法可以解决这个问题。

假设语料库只有:

see   出现 3 次
set   出现 1 次

基础的字符集合为:[’e’, ’s’, ’t’],所以初始词表为:

"e"  →  1
"s"  →  2
"t"  →  3

第 1 轮:把所有挨着的字母对,数一数(要乘以这个词出现的次数):

┌────────────┬────────────────────────────┬───────────┐
│ 挨着的一对   │          在哪出现           │  总次数    │
├────────────┼────────────────────────────┼───────────┤
│ s e        │ see 里有(×3)、set 里有(×1)   │ 4 ✅ 最多  │
├────────────┼────────────────────────────┼───────────┤
│ e e        │ see 里有(×3)                │ 3         │
├────────────┼────────────────────────────┼───────────┤
│ e t        │ set 里有(×1)                │ 1         │
└────────────┴────────────────────────────┴───────────┘

s e 出现 4 次最多 → 把 s e 粘成一个新块,叫它 se。

文字变成:

see  →  [se] e      ×3
set  →  [se] t      ×1

第 2 轮:再数一次

┌────────────┬───────────┐
│ 挨着的一对   │  总次数    │
├────────────┼───────────┤
│ se e       │ 3 ✅ 最多  │
├────────────┼───────────┤
│ se t       │ 1         │
└────────────┴───────────┘

文字变成:

see  →  [see]       ×3   ← 整个词变成 1 个块了!
set  →  [se] t      ×1   ← 2 个块

于是我们有了一张完整的对照表(词表 vocab):

┌──────────┬────────────┐
│ token id │ 代表的文字   │
├──────────┼────────────┤
│ 1        │ e          │
├──────────┼────────────┤
│ 2        │ s          │
├──────────┼────────────┤
│ 3        │ t          │
├──────────┼────────────┤
│ 4        │ se         │
├──────────┼────────────┤
│ 5        │ see        │
└──────────┴────────────┘

这个词表能够 encode 未知的单词,比如 “tee” 对应的是 [3,1,1] 。

Token Embedding

有了 Token ID 后,还需要把一个 Token ID 转成一个向量,因为向量间有距离,距离便可以表示两个词间词意的相似度。比如 ‘dog’ 和 ‘cat’ 虽然字符差别很大但是距离就很近,但是 ‘cat’ 和 ‘car’ 虽然字符相似但是距离很远。这是 Token ID 所做不到的。

一开始词表都是随机数,预训练时,这个词表也会被作为参数训练。

┌──────────┬────────────┐
│ token id │ 向量        │
├──────────┼────────────┤
│ 1        │ vector_A   │
├──────────┼────────────┤
│ 2        │ vector_B   │
├──────────┼────────────┤
│ 3        │ vector_C   │
├──────────┼────────────┤
│ ...      │ ...        │
└──────────┴────────────┘

Positional Embedding

上面训练的向量没有位置相关的信息,然而词的意思和位置有直接的关系。

例如两句话:

狗  咬  人
人  咬  狗

在词向量嵌入后得到:

"狗"→[0.8, 0.9]   "咬"→[0.1,-0.9]   "人"→[0.5, 0.3]

两次话词嵌入得到的向量相同,只是顺序不同。而 Transformer 是并行处理每一个向量,所以会丢失位置信息。所以位置信息也必须嵌入到词向量中去。

建立一个位置向量表,注意,这个位置表一开始数字随机,也是作为参数被训练出来的:

┌─────────────┬────────────┐
│    位置      │  位置向量   │
├─────────────┼────────────┤
│ 第 1 个位置  │ [0.0, 0.1] │
├─────────────┼────────────┤
│ 第 2 个位置  │ [0.0, 0.5] │
├─────────────┼────────────┤
│ 第 3 个位置  │ [0.0, 0.9] │
└─────────────┴────────────┘

位置向量表的行数等于最大上下文长度。

新向量 = 词向量 + 它所在位置的向量

句子 A:「狗 咬 人」

  • 狗(第1位): [0.8, 0.9] + [0.0, 0.1] = [0.8, 1.0]
  • 咬(第2位): [0.1,-0.9] + [0.0, 0.5] = [0.1,-0.4]
  • 人(第3位): [0.5, 0.3] + [0.0, 0.9] = [0.5, 1.2]

句子 B:「人 咬 狗」

  • 人(第1位): [0.5, 0.3] + [0.0, 0.1] = [0.5, 0.4]
  • 咬(第2位): [0.1,-0.9] + [0.0, 0.5] = [0.1,-0.4]
  • 狗(第3位): [0.8, 0.9] + [0.0, 0.9] = [0.8, 1.8]

前向推理

自注意力

上一部分,我们最终得到了每个词自己的意思和位置信息,但是不知道上下文信息。自注意力机制就是为了解决上下文的问题。

例如:

"我吃了一个苹果"     → 这里苹果是水果
"我买了一台苹果"     → 这里苹果是手机/公司

为了解决这个问题,我们需要引入 Q(Query)/K(Key)/V(Value) 三个向量,每个词的向量(n_embd 维),分别乘以三个权重矩阵 Wq、Wk、Wv(所有 token 用的是同一套矩阵,形状为 n_embd × n_embd ),就得到它自己的 Q、K、V (形状 n_embd × n_embd)。Wq、Wk、Wv 一开始随机,也是作为参数被训练的。

处理一个词时,做这 5 步:

  1. 算相关度分数:用我的 Q 去和每个词的 K 做点积(点积大 = 方向接近 = 相关)。

    分数(我, 某词) = 我的Q · 某词的K

  2. 缩放:除以 √d(d 是向量长度)。 目的:向量越长,点积数值越大,会让后面 Softmax 变得极端(几乎只剩一个词 100%)。除一下把数值压回合理范围,训练更稳。

  3. 加因果掩码:把"后面的词"的分数设成 −∞。 因为生成时后面的词还没出现,不能偷看未来。设成 −∞,下一步 Softmax 后它的占比就是 0。

  4. Softmax:把这一行分数变成加起来 = 1 的占比(注意力权重)。

  5. 按占比加权求和各词的 V:得到这个词最终吸收到的信息。

整个过程就是这一个著名公式:

Attention(Q, K, V) = softmax( Q·Kᵀ / √d ) · V
                     └─ 谁多重要(占比) ───┘ └─取走对应内容─┘

Kᵀ 为矩阵转置

KV Cache

在 LLM 推理的过程中,当仅新增了 Token 时,前面 Token 所有的 K/V 以及对应的 Attention 输出都不需要重新算。原因是因为因果掩码的存在:第 i 个 token 只能看 ≤ i 的位置。后面新增第 j 个 token(j > i),第 i 个 token 根本看不到它,所以第 i 个词的注意力结果不会因为后面追加了新词而改变。

每个 Transformer Block 各有一份独立的缓存(因为每层 Wk/Wv 不同,K/V也不同)。

多头自注意力

单个注意力,做一次"打分→softmax→加权混合",只能捕捉一种关系。但一句话里,词之间的关系是好几种同时存在的。例如:“小明 把 书 给 了 她 , 因为 它 很 有趣”

要真正理解它,模型得同时关注好几条线:

  • 语法关系:“给"要关注 谁给(小明)、给什么(书)、给谁(她)
  • 指代关系:“它"指的是"书”;“她"指某个人
  • 话题关系:“有趣"和"书"是相关的话题词

一个注意力头忙不过来——它做一次加权混合,只能照顾到其中一种。这就是瓶颈。

多头注意力把每个词的向量切成 h 段,每一段交给一个"头”,每个头独立、并行地做一遍完整的 Q/K/V 注意力,最后把 h 个头的结果拼回来,再融合一下。

举例说明:

假设 n_embd = 8(每个词 8 个数字),n_head = 4(4 个头)。则每个头分到 8 / 4 = 2 个数字(这叫 head_size)。

第1步:把每个词的 8 维向量,切成 4 段、每段 2 维

词向量:  [ a₁ a₂ | b₁ b₂ | c₁ c₂ | d₁ d₂ ]
            头1     头2     头3     头4

第2步:每个头,在自己那 2 维上,独立做完整的注意力(各自算各自的 Q·K → 各自的 softmax 占比 → 各自加权混合 V)

  • 头1(比如学到"语法”):算出它自己的一套注意力占比和结果
  • 头2(比如学到"指代”):算出另一套完全不同的占比和结果
  • 头3、头4:各管各的关系

→ 每个头都吐出一个 2 维的结果。

第3步:把 4 个头的结果拼回成 8 维

[ 头1结果(2) | 头2结果(2) | 头3结果(2) | 头4结果(2) ]  →  8 维

第4步:再过一个线性层(输出投影)把这 8 维"融合"一下,让各头信息互通,得到这个词最终的注意力输出。输出投影是一个可学习的线性层(一个权重矩阵 Wo 做矩阵乘法,Wo 是训练学出来的参数)。它的作用:让输出的每一个数字,都能用上"所有头"的信息——把各头的结果重新打散、加权、混合成一个连贯的整体向量。输出投影就来决定每个头各占多少权重。

残差连接

注意力算出来的结果,不是直接替换原向量,而是加回到原向量上:

新向量 = 原向量 + 注意力的输出

把注意力的产出,看成是"根据上下文,给这个词的一点修改建议/补充信息"。

举例来说:

苹果的原向量:               [0.8,  0.9]
多头注意力算出来的结果:       [0.1, -0.4]
                          ─────────────  ← 对应位置相加
残差相加后的新向量:          [0.9,  0.5]

MLP (前馈网络)

MLP = 两个线性层,中间夹一个 GELU。注意力是"词之间交流";MLP 是"每个词,自己单独,再深加工一遍"。它内部就三步:升维 → GELU → 降维。

  1. 初始向量

    某个词(注意力+残差之后)的向量,2 个数字:

    x = [2, 1]
    
  2. 第一步:升维线性层(一组线性层,2 个输入 → 4 个输出)

    4 个配方(权重+偏置,都是训练学来的):

    配方h₁:  1×x₁ +  0×x₂ +  0
    配方h₂:  0×x₁ +  3×x₂ + (−1)
    配方h₃: −2×x₁ +  1×x₂ +  0
    配方h₄:  1×x₁ +  1×x₂ + (−5)
    

    代入 x = [2, 1]:

    h₁ =  1×2 + 0×1 + 0  =  2
    h₂ =  0×2 + 3×1 − 1  =  2
    h₃ = −2×2 + 1×1 + 0  = −3
    h₄ =  1×2 + 1×1 − 5  = −2
    

    升维结果:[2, 2, −3, −2](2 个数字 → 变 4 个,每个是输入的一种加权组合)

  3. 第二步:GELU(对每个数字单独套,软开关)

    正数基本保留,负数压向 0。对照表:

    ┌──────┬─────────┐
    │ 输入 │ GELU 后 │
    ├──────┼─────────┤
    │ 2    │ ≈ 2.0   │
    ├──────┼─────────┤
    │ −2   │ ≈ 0.0   │
    ├──────┼─────────┤
    │ −3   │ ≈ 0.0   │
    └──────┴─────────┘
    

    逐个套 [2, 2, −3, −2]:

    [2, 2, −3, −2]  →  [2.0, 2.0, 0.0, 0.0]
    

    → h₃、h₄ 是负的,被 GELU “关掉"了(变 ~0);h₁、h₂ 是正的,“放行”。这就是"软开关”,也是MLP 里唯一带非线性、让模型能学复杂规律的地方(没它,两个线性层会塌缩成一个)。

  4. 第三步:降维线性层(另一组"配方",4 个输入 → 回到 2 个输出)

    2 个配方:

    配方y₁:  1×g₁ + 1×g₂ + 1×g₃ + 1×g₄ + 0
    配方y₂:  2×g₁ + (−1)×g₂ + 0×g₃ + 0×g₄ + 0
    

    代入 g = [2.0, 2.0, 0.0, 0.0]:

    y₁ = 1×2.0 + 1×2.0 + 1×0 + 1×0 = 4.0
    y₂ = 2×2.0 + (−1)×2.0 + 0 + 0  = 2.0
    

    MLP 的最终输出:[4.0, 2.0]

    整条路: [2,1] → 升维 [2,2,−3,−2] → GELU [2,2,0,0] → 降维 [4.0, 2.0]。

    (然后这个 [4.0, 2.0] 会被残差加回 MLP 的输入 [2,1] → [6.0, 3.0])

Transformer Block 完整循环

输入 x
   ├──────────────┐ (残差①)
   ▼              │
LayerNorm         │
   ▼              │
多头自注意力        │
   ▼              │
(+)◄─────────────┘     x = x + 注意力(LN(x))
   ├──────────────┐ (残差②)
   ▼              │
LayerNorm         │
   ▼              │
MLP               │
   ▼              │
(+)◄─────────────┘     x = x + MLP(LN(x))
输出(形状同输入,可堆下一个 Block)

预测下一个词

上面过完所有 Block,最后一个词的最终向量 = [1.2, -0.4]。开始预测下一个词:

  1. 最后做一次 LayerNorm(数字校准)

    把 [1.2, -0.4] 再规整一下数字范围(和之前一样,只是"校准",不改变意义)。假设校准后 ≈ [1.0, -0.5]。

  2. 输出头 – 把向量变成"每个词的分数"

    用一组线性层,把这 2 个数字,算成词表里每一个词的分数。

    假设词表只有 4 个词:

    人   : 3.1
    猫   : 2.0
    苹果 : 0.4
    跑   : -1.5
    
  3. Softmax——分数变概率

    人   : 68%
    猫   : 23%
    苹果 :  7%
    跑   :  2%
    

    按概率抽一个(大概率抽到"人")→ 这就是模型预测的下一个词 → 解码成文字。

PS:为什么这里时按概率抽取而不是选择概率最高的词?

因为基于不可解释的原因,实践中发现如果永远选概率最高的词,得到的结果平淡无奇,而有时选取概率低的词会产生奇妙的结果。这也是 LLM API 里 temperature 参数的作用,如果 temperature 为 0 ,则永远会选择概率最高的词。

模型训练

Loss 函数

loss = − log( 模型给"正确答案"的那个概率 )

┌─────────────────────┬──────────────────────┐
│ 模型给正确词的概率     │  loss = −log(概率)    │
├─────────────────────┼──────────────────────┤
│ 1.0(完全自信且对)    │ 0     ← 完美,零惩罚   │
├─────────────────────┼──────────────────────┤
│ 0.5                 │ ≈ 0.69               │
├─────────────────────┼──────────────────────┤
│ 0.1                 │ ≈ 2.3                │
├─────────────────────┼──────────────────────┤
│ 0.01(基本没押中)     │ ≈ 4.6  ← 惩罚很大     │
└─────────────────────┴──────────────────────┘

反向传播

我们手上有一个 loss 数字(比如 2.30),我们想让它变小。模型有几百万个参数(各种"配方"的权重)。问题是:每一个参数,到底该调大还是调小?

核心思想:把 loss 想成一座山

想象 loss 是一座山的高度,你站在山上,目标是往山下走(loss 变小)。

对某一个参数,关键就问一句话:

▎ “我把这个参数动一点点,loss 是变高还是变低?变多少?”

这个"动一点点会怎样"的量,就叫 梯度(gradient)。

反向传播就是一个数学技巧(链式法则):只需从 loss 出发,沿着网络反着扫一遍(输出层 → 往前一层 → 再往前……一直回到输入),就能一次性把所有参数的梯度全部算出来,不用一个个试。

梯度下降

反向传播跑完,每个参数都有了梯度(坡度:哪边是上坡、多陡)。

梯度下降公式:

新参数 = 老参数 − 学习率 × 梯度

梯度永远和参数同形状,如果参数是矩阵,梯度也会是矩阵。更新参数时,也是所有参数一同更新。

训练流程图

┌─────────────────────────────┐
│   拿一小批数据(题目)         │
└──────────────┬──────────────┘
①  模型做题(预测下一个词)
②  对答案,打一个"错误分"(loss)
③  找出每个参数对这次错误的"责任"(梯度)
④  按责任,每个参数各调一点点(更新)
错误分降一点,模型变好一点
└──── 再拿下一批,重复很多很多次 ───┐
┌──────────────────────────────────┘
练够了 → 得到训练好的模型(一堆调好的数字)

强化训练主要在这个过程中修改了第二步,不再是对答案,而是用奖励函数评分。

模型运行

推理流程

模型文件分为三个部分:

  1. 模型结构 (config.json)
  2. 模型本体(参数)
  3. 分词器 (tokenizer.json)

要运行模型,本质上就是按照模型结构搭建出模型的前向流程,然后自回归跑前向循环,直到遇到结束符。

输入「我 爱」
   → 前向#1(过完全部N层)→ 预测出「北」    ← 跑了一整遍
拼成「我 爱 北」
   → 前向#2(又过完全部N层)→ 预测出「京」  ← 又跑了一整遍
拼成「我 爱 北 京」
   → 前向#3 → 预测出「。」
   → 前向#4 → 预测出 <结束符>  → 停止