第 19 章 · 通往大模型

字符级语言模型

现在把前面所有积木接起来,造一个真能一个字一个字写下去的模型。 它的任务朴素得出奇:看着前文,预测下一个字符。 但你会惊讶地发现——今天那些会聊天、会写代码的大模型,做的本质上就是这一件事,只是规模大得多。

路线图 · 你在这一段的哪一站

上一站(第 18 章)搭好了 Transformer 积木;本站把 tokenizer → 嵌入 → Transformer → 输出层接成完整链路,并讲清它怎么训练、怎么推理——就是第 16 章那句诗例子的“正式版”;下一站(第 20 章)把它放大成大模型。

读完这一章,你会明白

  • 从文本到预测,数据要经过 tokenizer → embedding → Transformer → 输出层这一条完整链路;
  • LM Head 如何把 Transformer 向量变成词表上的 logits / 概率,和 MNIST 输出层 有何同异;
  • 每一步矩阵有多大:逐步写出谁×谁、乘完多大(含 n=2 完整手算,见 §4.4);
  • 模型到底怎么“学”:训练数据怎么造、损失怎么算、反向传播怎么一路更新到第 17 章那三个矩阵;
  • “预测下一个字”怎么变成“写出一整段”(自回归生成),以及 KV cache 为什么能让每一步不必重算前文;
  • greedy、temperature、top-k、top-p 这些采样策略各自的脾气;
  • 怎么用困惑度(perplexity)评估一个语言模型的好坏。

1. 完整链路:从一句话到下一个字

一个字符级语言模型,把一段文本变成“下一个字的概率”,要走这么一条流水线:

文本"hello wor"
Tokenizer字符 → id
Embeddingid → 向量(+位置)
Transformer融合上下文
LM Head投影到词表
下一字概率挑一个:'l'

每生成一个字,就把它接回输入,再走一遍这条流水线——这就是“自回归”。

2. Tokenizer:把字符变成数字

模型只认数字,不认字符。Tokenizer(分词器)负责两件事: 先扫一遍语料,给每个出现过的字符编一个 id(建词表); 之后就能在“字符串”和“id 序列”之间来回翻译。本书用最简单的字符级分词(一个字符一个 token)。

src/deeplearning/transformer/character_tokenizer.cpp(精简)
// 建词表: 每遇到一个没见过的字符, 就分配下一个 id
for (char ch : text)
  if (char_to_id.count(ch) == 0) {
    char_to_id[ch] = vocabulary.size();
    vocabulary.push_back(ch);                 1
  }

// 编码: 把字符串翻译成 id 序列
for (char ch : text)
  token_ids.push_back(char_to_id[ch]);        2
  1. 词表就是“字符 ↔ id”的一一对应。比如 'h'→0, 'e'→1, 'l'→2……词表大小 vocab_size 就是不同字符的个数。
  2. 编码:逐字符查表,得到一串整数。解码(Decode)则反过来,把 id 还原成字符。
真实大模型用的是“子词”

真实的 GPT 不是按单个字符,而是按子词(subword)切分(比如把 “playing” 切成 “play”+“ing”)。 原理完全一样,只是词表里的单位更大、更省。我们用字符级,是为了让原理一目了然。

3. Embedding:把 id 变成有含义的向量

id 只是个编号,本身没有含义(2 不比 1 “大”)。Embedding(词嵌入) 给每个 id 配一个可学习的向量,训练中这些向量会逐渐学到语义(相近的字符向量也相近)。 它就是一张大查找表:

src/deeplearning/transformer/token_embedding.cpp · Encode
for (int token_id : token_ids)
  output.push_back(embedding_table_[token_id]);   1
  1. embedding_table_vocab_size × model_dim 的表。拿 id 当行号查表,取出对应的向量。一串 id 就变成了一串向量,交给 Transformer。

在送进主干前,还会(可选地)给向量乘个缩放、加上第 18 章的位置编码:

src/deeplearning/transformer/mini_transformer_lm.cpp · EncodeSequence(精简)
token_embedding_.Encode(token_ids, hidden);              1
if (scale_embedding_) hidden *= std::sqrt(model_dim_);
PositionalEncoding::Apply(hidden);                       2
decoder_.Forward(hidden, encoded);  // 或 encoder_       3
  1. 查表得到词向量。
  2. 加上位置编码,让模型知道每个字在第几位。
  3. 送进 Transformer 主干(decoder 带 causal mask),得到每个位置“看过上文”的表示 encoded

4. LM Head:把表示投影成“下一字概率”

Transformer 主干吐出的 encoded[pos] 仍是长度为 model_dim 的向量——它表示“看过上文后,这个位置对下一个字有什么判断”, 但还不是概率。LM Head(Language Model Head, 语言模型输出头) 就是整条链路的最后一层线性分类器: 把向量映射成词表上每个候选字的打分(logits),再经 softmax 变成概率。 结构和 MNIST 最后一层 64→10 一模一样,只是输入维是 model_dim、输出维是 vocab_size

encoded[pos]model_dim 维
LM Head线性: W·h + b
logitsvocab_size 个分数
softmax概率分布

LM Head 后面不再接 ReLU,直接 softmax——和 MNIST 输出层相同,分类任务的标准收尾。

4.1 参数长什么样:一张“候选字打分表”

在本项目 MiniTransformerLM 里,LM Head 就是两组可训练参数 output_weight_output_bias_(注意:这是输出头的名字, 和注意力里把多头拼回去的 WO 不是一回事,后者藏在 Transformer Block 内部)。

参数形状含义
output_weight_vocab_size × model_dim词表里每个字一行,这一行是和 hidden 做点积的“模板向量”
output_bias_vocab_size每个候选字一个偏置,没上下文时也有的先验偏好

初始化时随机填小数(和 embedding 类似,用 sqrt(6/(vocab+dim)) 缩放),训练后再慢慢长出“哪个 hidden 模式该对应哪个字”:

src/deeplearning/transformer/mini_transformer_lm.cpp · Init 里建 LM Head(精简)
output_weight_.assign(vocab_size_, vector<double>(model_dim_, 0));
output_bias_.assign(vocab_size_, 0);
const double limit = sqrt(6.0 / (vocab_size_ + model_dim_));
// 对 output_weight_ 每行随机初始化 …                              1
  1. 词表有多大,就有多少行“候选字模板”;每行 model_dim 维,和 Transformer 输出的向量长度对齐。
和输入侧 embedding 的对称关系

第 15 章embedding_table_ 是“id → 向量”(进网络); LM Head 是“向量 → 在词表上打分”(出网络)。形状上 embedding 是 vocab×dim,LM Head 也是 vocab×dim—— 很多大模型会把两者绑成同一张表(权重共享),本仓库为了教学清晰分开存,但数学形式一样,都是线性投影。

4.2 前向:每个位置对每个字算一个分

对序列里每一个位置 pos,拿它的 hidden 向量 h = encoded[pos], 和词表里每一个候选字 w 的模板行做点积,再加偏置:

logitw = bw + h · Ww Ww 就是 output_weight_[w] 那一行;点积越大,模型越倾向选这个字

手算一个极简例子:词表只有 3 个字 h / e / l,model_dim = 2,某一位置的 hidden 是 h = [0.8, −0.2]:

候选字 w模板行 Ww偏置 bwlogit = b + h·W
h[1.0, 0.5]0.10.1 + 0.8×1.0 + (−0.2)×0.5 = 0.8
e[0.2, 1.0]0.00.0 + 0.8×0.2 + (−0.2)×1.0 = −0.04
l[−0.5, 0.3]0.20.2 + 0.8×(−0.5) + (−0.2)×0.3 = −0.26

三个 logit → softmax 后 h 概率最高。直觉:当前 hidden 和 “h 的模板” 最合拍。

src/deeplearning/transformer/mini_transformer_lm.cpp · Forward(精简)
for (int pos = 0; pos < encoded.size(); pos++)
  for (int token_id = 0; token_id < vocab_size_; token_id++) {
    logits[pos][token_id] = output_bias_[token_id];
    for (int dim = 0; dim < model_dim_; dim++)
      logits[pos][token_id] +=
          output_weight_[token_id][dim] * encoded[pos][dim];   1
  }
  1. 双重循环:每个位置 × 词表每个字 = 一次“模板匹配”打分。结果 logits[pos] 是长度 vocab_size 的向量,再过 Softmax 就是该位置“下一个字是谁”的概率。

4.3 推理时为什么只取最后一行?

Forward 会对输入序列每个位置都算一遍 logits(训练时要用的,见第 6 节)。 但生成下一个字时,我们只关心“看完整个上文后,下一个该是谁”——这对应最后一个位置的预测:

src/deeplearning/transformer/mini_transformer_lm.cpp · CalcNextTokenLogits(精简)
Forward(*window, all_logits, use_causal_mask);
logits = all_logits.back();                              1
  1. .back() 只取最后一个位置的 logits。前面位置的 logits 对应“若上下文只到那里,下一个字是谁”——生成时不需要,训练时用来多学几条监督信号。

例子:输入 "hel",三个位置都会出 logits,但只有第三个位置(刚看过 l) 的分布才是“下一个字该是啥”。 若上下文超过 max_context_size,代码会只保留最近一段再前向,避免位置编码越界。

4.4 每一步矩阵有多大:n=2 走一遍

下面不用表格,就按你前向真实发生的顺序:每一步写出谁乘谁、乘完多大。 例子:输入 2 个 token(「床」「前」),每个 token 4 个数(d=4),词表 5 个字(V=5),FFN 中间 8 维(f=8),只算 1 层 Block。 context_size=1024 只是上限;只喂 2 个 token,矩阵就是 2 行,不会撑满 1024 行。 第 18 章 §8用另一组维度(n=3, d=2 的「床前明」)把同一条流水线再走了一遍——两处维度特意取得不一样,对着看能帮你把“哪个数字是 n、哪个是 d”彻底分清。

记号: n=行数(几个 token) · d=列数(每个 token 多宽) · V=词表大小 · f=FFN 中间多宽

第 0 步:输入还不是矩阵

ids = [id_床, id_前]     长度 2, 两个整数

第 1 步:Embedding 查表

Embedding 表:  [5 × 4]        5 个字, 每个字 4 个数
查 2 个 id  →   X₀:  [2 × 4]    2 行, 每行 4 个数

(没有矩阵乘, 是查表取出 2 行)

第 2 步:加位置编码

PE:  [2 × 4]

X = X₀ + PE
    [2×4] + [2×4] = [2×4]

形状不变, 每个数加上「第几位」的信息

第 3 步:Attention — 投影 Q、K、V

三个权重表, 每个都是 [4 × 4]:

Q = X · W_Q     [2×4] × [4×4] = [2×4]
K = X · W_K     [2×4] × [4×4] = [2×4]
V = X · W_V     [2×4] × [4×4] = [2×4]

第 4 步:Attention — 打分(词和词多相关)

S = Q · K转置 / √4

    [2×4] × [4×2] = [2×2]

变成 2×2: 第 i 行第 j 列 = 第 i 个词看第 j 个词的分数

        床    前
床  [ s00, s01 ]
前  [ s10, s11 ]

第 5 步:因果 mask + softmax

上三角抹掉(不能偷看未来) → 每行 softmax → M

M:  [2×2]     每行加起来 = 1

第 6 步:Attention — 用权重混 Value, 再乘 W_O

à = M · V           [2×2] × [2×4] = [2×4]
A = Ã · W_O         [2×4] × [4×4] = [2×4]

Attention 输出 A 和输入 X 一样大: [2×4]

第 7 步:残差 + LayerNorm

X + A               [2×4] + [2×4] = [2×4]
LayerNorm 逐行      →  H₁:  [2×4]

(没有大矩阵乘, 每行自己减均值除标准差)

第 8 步:FFN — 升维

W₁:  [4 × 8]
b₁:  长度 8

Z = H₁ · W₁ + b₁    [2×4] × [4×8] = [2×8]

从 2×4 变成 2×8  ← 这一步变宽

第 9 步:ReLU

H_ff = ReLU(Z)      [2×8]     形状不变, 负数变 0

第 10 步:FFN — 压回 d 维

W₂:  [8 × 4]
b₂:  长度 4

F = H_ff · W₂ + b₂  [2×8] × [8×4] = [2×4]

FFN 输出 F: [2×4]

第 11 步:残差 + LayerNorm(Block 结束)

H₁ + F              [2×4] + [2×4] = [2×4]
LayerNorm           →  H_final:  [2×4]

若堆 N 层 Block, 从第 3 步到第 11 步重复 N 遍, 每次进出都是 [2×4]

第 12 步:LM Head

W_lm:  [4 × 5]
b_lm:  长度 5

logits = H_final · W_lm + b_lm    [2×4] × [4×5] = [2×5]

变成 2×5: 每一行是「这个位置, 词表里 5 个字各多少分」

第 13 步:生成 — 只取最后一行

logits[0]  →  只看第 1 个字时, 下一个字是谁?  (训练用)
logits[1]  →  看完 2 个字时, 下一个字是谁?    (生成用这个)

取 logits[1]  或  all_logits.back()  →  长度 5
softmax       →  5 个概率, 挑最大的当下一个字

注意: 是从 [2×5] 里取最后一行, 不是从 [2×4] 里取

整张乘法链(一眼扫)

[2×4]  查表+加位置
  ↓ ×[4×4] ×3              Q,K,V      [2×4]
  ↓ [2×4]×[4×2]            S,M        [2×2]
  ↓ [2×2]×[2×4]  ×[4×4]    A          [2×4]
  ↓ +残差Norm               H₁         [2×4]
  ↓ ×[4×8]                  Z          [2×8]   ← 变宽
  ↓ ×[8×4]                  F          [2×4]   ← 压回
  ↓ +残差Norm               H_final    [2×4]
  ↓ ×[4×5]                  logits     [2×5]   ← 变到词表
  ↓ 取第 2 行               下一个字    [5]

换输入长度:只改行数 n

dVf 不变, 只有 n 跟着你喂了几个 token 变:

输入 2 个 token   主矩阵 [2×4]    注意力 [2×2]      logits [2×5]   生成取第 2 行
输入 4 个 token   主矩阵 [4×4]    注意力 [4×4]      logits [4×5]   生成取第 4 行
输入 100 个       主矩阵 [100×4]  注意力 [100×100]  logits [100×5] 生成取第 100 行

注意力是 n×n, 句子一长按 n² 涨 → 长上下文吃算力(第 21 章)

4.5 反向:LM Head 的梯度往哪流

训练时,softmax + 交叉熵对 logits 的梯度仍是 probs − one-hot(答案)(第 6 章)。 这条梯度会同时更新 LM Head 和前面的 Transformer:

src/deeplearning/transformer/mini_transformer_lm.cpp · TrainNextToken 里 LM Head 反传(精简)
grad_logits = probs;  grad_logits[target] -= 1.0;       1
for (token_id …) {
  grad_output_bias[token_id] += grad;
  grad_output_weight[token_id][dim] += grad * encoded[pos][dim];
  grad_encoded[pos][dim] += output_weight_[token_id][dim] * grad;  2
}
output_weight_optimizer_.Apply(output_weight_, …);       3
  1. 对正确答案那一维减 1,得到 logits 上的梯度。
  2. 一边更新 LM Head,一边把梯度累加到 grad_encoded,交给 decoder_.Backward
  3. 先用优化器更新 LM Head,再反传进 Transformer——输出头是损失的“第一站”,也是梯度回流的“最后一站”。
和 MNIST 对照一眼

MNIST:隐藏层向量 → 全连接 → 10 维 logits → softmax → 交叉熵。
语言模型:Transformer 向量 → LM Head → vocab_size 维 logits → softmax → 交叉熵。
差别只在前面用什么网络抽特征(MLP vs Transformer),最后一截分类头完全同构

5. 训练数据:答案就藏在“下一个字”里

上面四步搭好了一个能吐出“下一字概率”的网络,但它一开始参数全是随机的,预测得一塌糊涂。 要让它变准,就得喂数据训练。可训练需要“标准答案”,难道要人一句句去标? 语言模型最妙的地方就在这里:答案根本不用人标,它就藏在文本自己里—— 每个位置“正确的下一个字”,不就是原文里紧跟着的那个字吗?

于是造训练样本只要拿一个滑动窗口在语料上滑:窗口里的一小段字符当输入, 紧跟其后的那个字符当目标(答案);窗口每右移一格,就多一条样本。

src/deeplearning/transformer/character_dataset.cpp · BuildNextTokenSamples(精简)
for (int i = 0; i + context_size < token_ids.size(); i++) {
  input_samples.push_back( token_ids[i .. i+context_size) );   1
  target_tokens.push_back( token_ids[i + context_size] );      2
}
  1. 输入 = 从第 i 个字符起、长度为 context_size(上下文窗口)的一段。窗口越大,模型一次能“看到”的上文越长。
  2. 目标 = 紧跟这段之后的那一个字符。它天生就是答案,不需要任何人工标注。
“答案自带”有个名字,叫自监督

这种从数据本身自动抠出答案、不用人工打标签的训练方式,叫自监督学习。 正因为答案免费,才敢拿“整个互联网”当训练集——这也是大模型能被喂那么多数据的根本原因(第 20 章细讲)。

6. 怎么学:一次训练 step 拆开看

有了“输入 → 目标”样本,训练就是第 5、6 章那条老流水线,一步(step)做四件事:

  1. 前向:把输入跑一遍第 1 节的链路,得到对“下一个字”的概率分布 probs
  2. 算损失:看它给正确答案那个字打了多少概率,用交叉熵 −log(probs[答案]) 量出“错得多离谱”(第 4 章)。答案概率越低,损失越大。
  3. 反向传播:用链式法则把损失反着传回去,算出每个参数的梯度(第 6 章)。
  4. 更新:每个参数沿梯度反方向挪一小步(第 5 章)。

softmax + 交叉熵的梯度还是那个漂亮结果:预测概率分布,减去“正确答案的 one-hot”。 代码里就一句 grad_logits[答案] -= 1——你给答案的概率比 1 差多少,就往回补多少:

src/deeplearning/transformer/mini_transformer_lm.cpp · TrainNextToken(精简)
auto probs = Softmax(logits);
loss_sum += -std::log(probs[target_token]);        1

std::vector<double> grad_logits = probs;
grad_logits[target_token] -= 1.0;                  2

// 把梯度依次反传, 并就地更新每一层的参数:
decoder_.Backward(grad_encoded, grad_hidden, lr);  // 或 encoder_    3
// ... 最后再更新 embedding_table ...                                4
  1. 前向拿到概率,算这条样本的交叉熵损失。
  2. softmax + 交叉熵的梯度 = probs − one-hot(答案),和第 6 章、MNIST 里用的是同一个式子
  3. 关键一步:把梯度传进 Transformer 主干。decoder_.Backward 会一层层调用每块 Block 的反向,其中就包含第 17 章那段 self-attention 的 Backward——WQ/WK/WV 正是在这里被更新的!
  4. 最后连词嵌入表也一起更新。于是从输出层、到每块 Transformer、到 embedding,全网参数都被这同一个损失“拧”了一下
这就接上了第 17 章留的扣子

第 17 章问“那三个矩阵怎么训练出来的”,完整答案就在这里:损失从“预测下一个字对不对”产生, 顺着反向传播的“电流”流过输出层、流过每一块 Transformer,最后流进 WQ/WK/WV, 把它们朝“让预测更准”的方向挪一点点。练一次挪一点,练亿万次,矩阵就学会了该关注谁。

那“全网参数”到底有哪些?——一张清单

上面反复说“更新每一个参数”“全网参数被拧一下”,可到底有哪些参数?很多人到这儿就糊了,尤其觉得它和 MNIST “好像不太一样”。其实本质一模一样,只是种类更多。把这个 mini LM 里所有“可训练的数”列全:

参数(代码里的名字)是什么 / 干嘛的形状(量级)相当于 MNIST 里的谁
词嵌入表 embedding_table_每个字一行向量,查表得到词向量(第 3 节)vocab_size × model_dim新种类:一大堆权重
Q/K/V 矩阵 query/key/value_weight_把每个词投影成查询 / 键 / 值(第 17 章),每块 Block 各一套model_dim × model_dim就是权重矩阵
注意力输出投影 output_weight_(Block 内 WO)把多头拼接的结果融合回去(第 17 章)model_dim × model_dim权重矩阵
前馈网络 FFN 的 W₁/b₁/W₂/b₂逐词再加工的两层小网络(第 18 章)model_dim×ff_dim 等就是个小 MLP(第 2、3 章)
LayerNorm 的 γ、β每层的缩放 / 平移系数(第 18 章)model_dim新种类:类似偏置
LM Head output_weight_ / output_bias_(MiniTransformerLM 顶层)把向量投影成词表 logits(第 4 节)vocab_size × model_dim和 MNIST 输出层一模一样

位置编码在本项目是固定的 sin/cos、不参与训练(有的模型改成可学习的);其余全部都是要训练的参数。

和 MNIST 到底差在哪?其实只差“种类”

更新方式完全相同:上面每一个参数,反向传播都会算出它的梯度,再用同一条 新值 = 旧值 − 学习率 × 梯度 挪一步(第 5 章;真实大模型换成 Adam,第 8 章,但也只是“挪得更聪明”)。
唯一的区别:MNIST 那张 784→128→64→10 的网只有“权重 + 偏置”两类参数; 语言模型多了“嵌入表、Q/K/V/O 矩阵、LayerNorm 系数”这些新种类,而且每一块 Block 都各有一整套,层数一多,参数量就滚雪球——这正是“大模型”之所以“大”的地方。但“算梯度 → 挪一步”这套学习机制,和 MNIST 一字不差。

训练时“抄着答案做题”,生成时“自己接龙”——teacher forcing

这里有个值得留意的反差:训练时每条样本的上文用的都是语料里的真字符 (相当于让模型对着标准答案的前半段做题),这叫 teacher forcing,好处是每步互相独立、能高效并行。 而生成时没有答案可抄,只能把自己刚吐出的字接回去继续(见第 8 节的自回归)。 另外,真实大模型一次前向会同时预测所有位置的下一字(一遍算出一大批损失), 我们这个 mini 版为了好懂,一条样本只盯一个目标。

7. 怎么挑下一个字:从 greedy 到采样

有了概率分布,怎么挑出下一个字?最简单的是 greedy(贪心):永远挑概率最高的那个。

src/deeplearning/transformer/mini_transformer_lm.cpp · PredictNextToken(精简)
token_id = 0;
double best = logits[0];
for (int i = 1; i < logits.size(); i++)
  if (logits[i] > best) { best = logits[i]; token_id = i; }   1
  1. 就是取 argmax——分数最高的字符。简单,但太死板:同样的开头永远生成同样的、往往很重复无聊的内容。

更好的办法是带点随机性地采样,由三个旋钮控制:

src/deeplearning/transformer/mini_transformer_lm.cpp · SampleNextToken(精简)
auto probs = SoftmaxWithTemperature(logits, temperature);   1
sort(order by probs desc);                                  2
// top-k: 最多保留前 k 个; top-p: 累积概率达到 p 就截断
keep_count = ...;                                           3
// 在保留的候选里重新归一化, 按概率随机抽一个
token_id = sample_from(filtered_probs);                     4
  1. 先按温度把 logits 变成概率。
  2. 按概率从高到低排序。
  3. 用 top-k / top-p 把候选裁剪成“最靠谱的一小撮”。
  4. 在这一小撮里按概率掷一次骰子,抽出下一个字。既有随机性,又不至于太离谱。

8. 自回归:把刚写的字接回去,再写下一个

生成一整段的诀窍叫自回归(autoregressive):预测出一个字后, 把它追加到输入末尾,再用更长的上文预测下一个字,如此循环。 你看到的 ChatGPT “一个字一个字往外蹦”,就是这个循环在跑。

src/deeplearning/transformer/mini_transformer_lm.cpp · Generate(精简)
generated = prompt;
for (int i = 0; i < generate_num; i++) {
  PredictNextToken(generated, token_id);   1
  generated.push_back(token_id);           2
}
  1. 用目前为止的全部内容,预测下一个字。
  2. 把它接到末尾,然后回到第 1 步——下一轮的“上文”就多了一个字。这就是自回归生成。

你可能会顺手追问一句:那它怎么知道“该停了”?真实大模型通常会把“结束”也当成一个普通 token 来学, 比如 <eos> (end of sequence) 或一轮对话结束标记。训练时,样本末尾会显式放上这个结束 token, 于是模型学到:当前文已经完整时,下一个最合理的输出就该是“结束”。推理程序一旦看到它,就停止生成。 本书配套的这个最小字符级 demo 为了把主线讲清楚,没有额外引入 <eos> 机制,所以命令行用 generate_num 作为一个外部“硬停”上限。真实系统里通常是“结束 token + 最大长度上限”双保险。

9. KV cache:别让自回归每步都重算前文

上一节的自回归有个让人不安的细节。生成第 100 个字时,输入是前 99 个字;生成第 101 个字时,输入变成前 100 个字…… 每多写一个字,就要把越来越长的前文整段重新跑一遍模型。 写到第 1000 字,前面 999 个字已经被反复前向了上千次。这不是浪费吗?

KV cache(KV 缓存)就是消除这个浪费的关键一招,也是今天几乎所有大模型推理都在用的标配。 要讲清它到底缓存了什么,得先回到第 17 章的 Q、K、V。

9.1 先问:每一步计算里,哪些东西其实没变?

回忆注意力:每个词进来,都要乘三个矩阵,算出自己的 Query(q)Key(k)Value(v)。 生成第 t+1 个字时,当前这个词的 q 要和前面所有词的 k 打分, 再用得到的权重去加权前面所有词的 v

关键在于:第 i 个词的 kivi 只由第 i 个词自己算出 (ki = xiWK,vi = xiWV), 一旦算出来就不会再变。这正是第 17 章 causal mask那张下三角图的直接推论—— 后面新增的词落在下三角的右侧,对前面的词毫无影响。

那为什么不缓存 Q?

因为注意力是“当前这个词的 Query,去查历史所有词的 Key/Value”。 历史某个词的 q,在它当时那一步用完就再也用不到了——新词不会去用旧词的 Query。 所以只需要把每个词的 K 和 V 存下来,Q 用完即弃。名字叫“KV cache”而不是“QKV cache”,原因就在这。

9.2 缓存前后:计算量的对比

把“每一步要算哪些词的 K/V”画成方格(行 = 第几步,列 = 第几个词的 K/V,亮 = 这一步真的现算了它):

无 KV cache(每步重算整段)

每一步都把前面所有词的 K/V 重算一遍:总量是整个下三角(n² 量级)

有 KV cache(只算新词)

每一步只算新增那一个词的 K/V,历史的直接从缓存里取:总量只剩对角线(n 量级)

这一步现算的 K/V 只算新词(其余从 cache 读)

左边每步重算整段,做了大量重复功;右边把历史 K/V 存进 cache,每步只把新词那一份算出来追加进去——从“算一个三角”降到“算一条线”。

所以 KV cache 的做法一句话:每生成一个新词,就把它的 kv 追加到缓存末尾; 下一步直接复用缓存里全部历史 K/V,只为最新的词算一次投影。前文再长,每步的新增计算都是固定的一小份。

9.3 它到底存成什么样、占多大

缓存的内容是每一层、每个注意力头、每个已经处理过的位置各自的一个 k 向量和一个 v 向量。 写成形状大致是:

KV cache ≈ [层数 L] × [K 和 V 两份] × [头数 h] × [已生成长度 t] × [每头维度 dhead] 生成越长,t 这一维就越长,缓存越大——这就是“长上下文吃显存”的直接来源

它的代价是拿显存换计算:省掉了海量重复前向,但要一直把这些 K/V 驻留在显存里。 序列越长、并发用户越多,KV cache 就越吃显存,常常比模型权重本身还占地方—— 第 21 章会讲工程上怎么进一步分页管理、复用、压缩它 (以及 GQA/MQA 这类“让多个头共享一组 K/V”的省法)。

对照本书的 mini demo

本仓库 src/demo/transformer_char教学向实现,生成时每步都把当前整段重新前向一遍 (就是上图左边那种“老实但浪费”的算法),没有实现增量 KV cache——因为序列短、图省事, 重点是把原理讲清楚。真实推理服务(如 vLLM、TensorRT-LLM)则一定会上 KV cache,否则长文本生成会慢到不可用。 如果你想动手,把生成循环改成“维护 K/V 缓存、每步只投影新 token”会是一个很好的练习。

10. 它学得好不好?用困惑度衡量

评估语言模型最常用的指标是困惑度(perplexity)。它是“平均交叉熵损失”取指数:

困惑度 = e平均交叉熵损失 直觉:模型预测下一个字时,平均像在“多少个等可能选项”里纠结。越小越好

比如困惑度 = 5,大致意思是模型每一步平均在 5 个候选里犹豫。完美模型困惑度接近 1(每次都笃定);乱猜的模型困惑度接近词表大小。

src/deeplearning/transformer/mini_transformer_lm.cpp · 损失与困惑度(精简)
loss_sum += -std::log(std::max(probs[target], 1e-12));   1
average_loss = loss_sum / N;
perplexity = std::exp(average_loss);                     2
  1. 对每个样本,取“正确的下一个字”被预测到的概率,算 −log(第 4 章的交叉熵)。max(..., 1e-12) 防止 log(0)。
  2. 平均损失取指数,就是困惑度。训练有效的话,你会看到它随训练稳步下降。

11. 看真实模型的注意力(回放)

下面这个工具能读取本项目真实导出的 attention 权重(不是教学示意), 逐步回放生成时每一层、每个头的注意力。点“读取内置样例”,再用“上一步/下一步”观察: 模型每要写下一个字时,到底在“看”前文的哪些位置。

若直接双击打开本页(file://)读取样例失败,请用本地静态服务器,或手动选择导出的 JSON 文件。

自己跑一个

配套项目里 src/demo/transformer_char 就是一个能训练、能生成的完整字符级语言模型。 编译后运行 ./bin/transformer_char --prompt "hello" --generate-num 100 之类, 就能看到它根据你的提示往下写。各种采样参数(--temperature--top-k--top-p)都能在命令行调。

小结

  • 语言模型的任务:看前文,预测下一个字。链路 = Tokenizer → Embedding(+位置)→ Transformer → LM Head → 概率。
  • LM Head = 线性层 output_weight_[vocab×dim] + bias,hidden 与每行模板点积得 logits;[n×d]→[n×V];推理取 all_logits.back()(§4.4)。
  • Tokenizer 把字符 ↔ id;Embedding 把 id 查表成可学习向量。
  • 训练:滑动窗口造“输入 → 下一字”样本(自监督);交叉熵损失反向传播,一路更新到第 17 章的 Q/K/V 矩阵和词嵌入。
  • 挑字:greedy(取最高)死板;采样(temperature / top-k / top-p)更自然有创意。
  • 自回归:把生成的字接回输入,循环往下写——这就是大模型逐字输出的原理。
  • KV cache:每个词的 K/V 只由它自己决定、算出就不变,于是缓存历史 K/V、每步只算新词,把 n² 的重复计算降到 n;代价是显存驻留(长上下文/高并发的开销来源)。
  • “停下来”通常也是预测出来的:模型学会输出一个结束 token;推理程序看到它就停止生成。
  • 困惑度 = e^(平均交叉熵),衡量模型“平均在几个选项里纠结”,越低越好。

动手与思考

问题 1:为什么 greedy 解码常常生成重复、无聊的内容?

因为它每一步都只挑概率最高的那个字,毫无随机性。相同的上文必然得到相同的输出,容易陷入“高概率但单调”的循环。引入温度和 top-k/top-p 采样能带来多样性。

问题 2:把 temperature 调得很高会怎样?调得很低呢?

很高:概率分布被抹平,采样更随机、更有创意,但也更容易语无伦次。很低:分布更尖锐,趋近 greedy,稳定但单调。常用值在 0.7–1.0 附近折中。

问题 3:困惑度从 20 降到 4 说明了什么?

说明模型对“下一个字”的预测从“平均在约 20 个候选里纠结”进步到“约 4 个里纠结”,即预测准了很多、更有把握。困惑度越低,语言模型通常越好。

问题 4:生成时为什么用 all_logits.back(),而不是第一个位置?

我们要预测的是“看完整段当前上文后,下一个字是谁”,这对应序列最后一个位置的 hidden 经 LM Head 得到的分布。前面位置的 logits 对应更短的上下文,训练时当额外监督用,生成时不取。

问题 5:KV cache 缓存的到底是什么?为什么缓存的是 K、V,而不是 Q?

缓存的是每一层、每个头、每个已处理位置各自的 Key 向量和 Value 向量。因为第 i 个词的 k/v 只由它自己算出、算完就不再变(causal mask 决定后面的词影响不到它),所以历史 K/V 可以存起来复用,每步只为新词算一份。而 Q 是“当前词拿去查历史”的,历史词的 Q 用完即弃、新词不会用到,所以不缓存。效果:把每步“重算整段前文”的 n² 计算降到只算新词的 n,代价是 K/V 要一直驻留显存。

你已经亲手把一个完整的语言模型从零拆到底了。最后一章,我们站远一点看: 把这个小模型放大一万倍会发生什么?预训练、微调、RLHF、涌现……大模型的故事,就从这里开始。