第 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. 完整链路:从一句话到下一个字
一个字符级语言模型,把一段文本变成“下一个字的概率”,要走这么一条流水线:
每生成一个字,就把它接回输入,再走一遍这条流水线——这就是“自回归”。
2. Tokenizer:把字符变成数字
模型只认数字,不认字符。Tokenizer(分词器)负责两件事: 先扫一遍语料,给每个出现过的字符编一个 id(建词表); 之后就能在“字符串”和“id 序列”之间来回翻译。本书用最简单的字符级分词(一个字符一个 token)。
// 建词表: 每遇到一个没见过的字符, 就分配下一个 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
- 词表就是“字符 ↔ id”的一一对应。比如 'h'→0, 'e'→1, 'l'→2……词表大小
vocab_size就是不同字符的个数。 - 编码:逐字符查表,得到一串整数。解码(
Decode)则反过来,把 id 还原成字符。
真实的 GPT 不是按单个字符,而是按子词(subword)切分(比如把 “playing” 切成 “play”+“ing”)。 原理完全一样,只是词表里的单位更大、更省。我们用字符级,是为了让原理一目了然。
3. Embedding:把 id 变成有含义的向量
id 只是个编号,本身没有含义(2 不比 1 “大”)。Embedding(词嵌入) 给每个 id 配一个可学习的向量,训练中这些向量会逐渐学到语义(相近的字符向量也相近)。 它就是一张大查找表:
for (int token_id : token_ids)
output.push_back(embedding_table_[token_id]); 1
embedding_table_是vocab_size × model_dim的表。拿 id 当行号查表,取出对应的向量。一串 id 就变成了一串向量,交给 Transformer。
在送进主干前,还会(可选地)给向量乘个缩放、加上第 18 章的位置编码:
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
- 查表得到词向量。
- 加上位置编码,让模型知道每个字在第几位。
- 送进 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。
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 模式该对应哪个字”:
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
- 词表有多大,就有多少行“候选字模板”;每行
model_dim维,和 Transformer 输出的向量长度对齐。
第 15 章的 embedding_table_ 是“id → 向量”(进网络);
LM Head 是“向量 → 在词表上打分”(出网络)。形状上 embedding 是 vocab×dim,LM Head 也是 vocab×dim——
很多大模型会把两者绑成同一张表(权重共享),本仓库为了教学清晰分开存,但数学形式一样,都是线性投影。
4.2 前向:每个位置对每个字算一个分
对序列里每一个位置 pos,拿它的 hidden 向量 h = encoded[pos],
和词表里每一个候选字 w 的模板行做点积,再加偏置:
output_weight_[w] 那一行;点积越大,模型越倾向选这个字
手算一个极简例子:词表只有 3 个字 h / e / l,model_dim = 2,某一位置的 hidden 是 h = [0.8, −0.2]:
| 候选字 w | 模板行 Ww | 偏置 bw | logit = b + h·W |
|---|---|---|---|
| h | [1.0, 0.5] | 0.1 | 0.1 + 0.8×1.0 + (−0.2)×0.5 = 0.8 |
| e | [0.2, 1.0] | 0.0 | 0.0 + 0.8×0.2 + (−0.2)×1.0 = −0.04 |
| l | [−0.5, 0.3] | 0.2 | 0.2 + 0.8×(−0.5) + (−0.2)×0.3 = −0.26 |
三个 logit → softmax 后 h 概率最高。直觉:当前 hidden 和 “h 的模板” 最合拍。
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
}
- 双重循环:每个位置 × 词表每个字 = 一次“模板匹配”打分。结果
logits[pos]是长度vocab_size的向量,再过Softmax就是该位置“下一个字是谁”的概率。
4.3 推理时为什么只取最后一行?
Forward 会对输入序列每个位置都算一遍 logits(训练时要用的,见第 6 节)。
但生成下一个字时,我们只关心“看完整个上文后,下一个该是谁”——这对应最后一个位置的预测:
Forward(*window, all_logits, use_causal_mask);
logits = all_logits.back(); 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
d、V、f 不变, 只有 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:
- 偏置:
grad_output_bias[w] += grad_logit_w - 权重行:
grad_output_weight[w][dim] += grad_logit_w × h[dim](和普通线性层一样) - 传回主干:
grad_h[dim] += output_weight_[w][dim] × grad_logit_w—— 于是损失能继续流进 decoder / embedding
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,得到 logits 上的梯度。
- 一边更新 LM Head,一边把梯度累加到
grad_encoded,交给decoder_.Backward。 - 先用优化器更新 LM Head,再反传进 Transformer——输出头是损失的“第一站”,也是梯度回流的“最后一站”。
MNIST:隐藏层向量 → 全连接 → 10 维 logits → softmax → 交叉熵。
语言模型:Transformer 向量 → LM Head → vocab_size 维 logits → softmax → 交叉熵。
差别只在前面用什么网络抽特征(MLP vs Transformer),最后一截分类头完全同构。
5. 训练数据:答案就藏在“下一个字”里
上面四步搭好了一个能吐出“下一字概率”的网络,但它一开始参数全是随机的,预测得一塌糊涂。 要让它变准,就得喂数据训练。可训练需要“标准答案”,难道要人一句句去标? 语言模型最妙的地方就在这里:答案根本不用人标,它就藏在文本自己里—— 每个位置“正确的下一个字”,不就是原文里紧跟着的那个字吗?
于是造训练样本只要拿一个滑动窗口在语料上滑:窗口里的一小段字符当输入, 紧跟其后的那个字符当目标(答案);窗口每右移一格,就多一条样本。
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
}
- 输入 = 从第
i个字符起、长度为context_size(上下文窗口)的一段。窗口越大,模型一次能“看到”的上文越长。 - 目标 = 紧跟这段之后的那一个字符。它天生就是答案,不需要任何人工标注。
这种从数据本身自动抠出答案、不用人工打标签的训练方式,叫自监督学习。 正因为答案免费,才敢拿“整个互联网”当训练集——这也是大模型能被喂那么多数据的根本原因(第 20 章细讲)。
6. 怎么学:一次训练 step 拆开看
有了“输入 → 目标”样本,训练就是第 5、6 章那条老流水线,一步(step)做四件事:
- 前向:把输入跑一遍第 1 节的链路,得到对“下一个字”的概率分布
probs。 - 算损失:看它给正确答案那个字打了多少概率,用交叉熵
−log(probs[答案])量出“错得多离谱”(第 4 章)。答案概率越低,损失越大。 - 反向传播:用链式法则把损失反着传回去,算出每个参数的梯度(第 6 章)。
- 更新:每个参数沿梯度反方向挪一小步(第 5 章)。
softmax + 交叉熵的梯度还是那个漂亮结果:预测概率分布,减去“正确答案的 one-hot”。
代码里就一句 grad_logits[答案] -= 1——你给答案的概率比 1 差多少,就往回补多少:
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
第 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、不参与训练(有的模型改成可学习的);其余全部都是要训练的参数。
更新方式完全相同:上面每一个参数,反向传播都会算出它的梯度,再用同一条
新值 = 旧值 − 学习率 × 梯度 挪一步(第 5 章;真实大模型换成 Adam,第 8 章,但也只是“挪得更聪明”)。
唯一的区别:MNIST 那张 784→128→64→10 的网只有“权重 + 偏置”两类参数;
语言模型多了“嵌入表、Q/K/V/O 矩阵、LayerNorm 系数”这些新种类,而且每一块 Block 都各有一整套,层数一多,参数量就滚雪球——这正是“大模型”之所以“大”的地方。但“算梯度 → 挪一步”这套学习机制,和 MNIST 一字不差。
这里有个值得留意的反差:训练时每条样本的上文用的都是语料里的真字符 (相当于让模型对着标准答案的前半段做题),这叫 teacher forcing,好处是每步互相独立、能高效并行。 而生成时没有答案可抄,只能把自己刚吐出的字接回去继续(见第 8 节的自回归)。 另外,真实大模型一次前向会同时预测所有位置的下一字(一遍算出一大批损失), 我们这个 mini 版为了好懂,一条样本只盯一个目标。
7. 怎么挑下一个字:从 greedy 到采样
有了概率分布,怎么挑出下一个字?最简单的是 greedy(贪心):永远挑概率最高的那个。
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
- 就是取 argmax——分数最高的字符。简单,但太死板:同样的开头永远生成同样的、往往很重复无聊的内容。
更好的办法是带点随机性地采样,由三个旋钮控制:
- Temperature 温度先把 logits 除以温度再 softmax。温度低 → 分布更尖锐、更保守(接近 greedy);温度高 → 分布更平、更大胆有创意(也更容易跑偏)。
- top-k只在概率最高的 k 个候选里抽,堵住长尾的奇怪字符。
- top-p(核采样)从高到低累加概率,只保留累积到 p(如 0.9)的那一小撮候选,再抽。
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
- 先按温度把 logits 变成概率。
- 按概率从高到低排序。
- 用 top-k / top-p 把候选裁剪成“最靠谱的一小撮”。
- 在这一小撮里按概率掷一次骰子,抽出下一个字。既有随机性,又不至于太离谱。
8. 自回归:把刚写的字接回去,再写下一个
生成一整段的诀窍叫自回归(autoregressive):预测出一个字后, 把它追加到输入末尾,再用更长的上文预测下一个字,如此循环。 你看到的 ChatGPT “一个字一个字往外蹦”,就是这个循环在跑。
generated = prompt;
for (int i = 0; i < generate_num; i++) {
PredictNextToken(generated, token_id); 1
generated.push_back(token_id); 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 个词的 ki、vi 只由第 i 个词自己算出 (ki = xiWK,vi = xiWV), 一旦算出来就不会再变。这正是第 17 章 causal mask那张下三角图的直接推论—— 后面新增的词落在下三角的右侧,对前面的词毫无影响。
因为注意力是“当前这个词的 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,每步只把新词那一份算出来追加进去——从“算一个三角”降到“算一条线”。
所以 KV cache 的做法一句话:每生成一个新词,就把它的 k、v 追加到缓存末尾; 下一步直接复用缓存里全部历史 K/V,只为最新的词算一次投影。前文再长,每步的新增计算都是固定的一小份。
9.3 它到底存成什么样、占多大
缓存的内容是每一层、每个注意力头、每个已经处理过的位置各自的一个 k 向量和一个 v 向量。 写成形状大致是:
它的代价是拿显存换计算:省掉了海量重复前向,但要一直把这些 K/V 驻留在显存里。 序列越长、并发用户越多,KV cache 就越吃显存,常常比模型权重本身还占地方—— 第 21 章会讲工程上怎么进一步分页管理、复用、压缩它 (以及 GQA/MQA 这类“让多个头共享一组 K/V”的省法)。
本仓库 src/demo/transformer_char 是教学向实现,生成时每步都把当前整段重新前向一遍
(就是上图左边那种“老实但浪费”的算法),没有实现增量 KV cache——因为序列短、图省事,
重点是把原理讲清楚。真实推理服务(如 vLLM、TensorRT-LLM)则一定会上 KV cache,否则长文本生成会慢到不可用。
如果你想动手,把生成循环改成“维护 K/V 缓存、每步只投影新 token”会是一个很好的练习。
10. 它学得好不好?用困惑度衡量
评估语言模型最常用的指标是困惑度(perplexity)。它是“平均交叉熵损失”取指数:
比如困惑度 = 5,大致意思是模型每一步平均在 5 个候选里犹豫。完美模型困惑度接近 1(每次都笃定);乱猜的模型困惑度接近词表大小。
loss_sum += -std::log(std::max(probs[target], 1e-12)); 1
average_loss = loss_sum / N;
perplexity = std::exp(average_loss); 2
- 对每个样本,取“正确的下一个字”被预测到的概率,算 −log(第 4 章的交叉熵)。
max(..., 1e-12)防止 log(0)。 - 平均损失取指数,就是困惑度。训练有效的话,你会看到它随训练稳步下降。
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、涌现……大模型的故事,就从这里开始。