Transformer 解码器:大模型是怎么“一个字一个字”写出来的
上一篇《Transformer 编码器》回答的是”读”的问题:一个词,怎么吸收整句话的上下文。今天翻到另一半,回答一个你天天见、却可能从没往深里想过的问题——
你问大模型一个问题,它为什么要”一个字一个字”往外蹦?在”写”的过程里,它脑子里到底在一步步算什么?
先立一句话贯穿全文,后面所有内容都在给这句话做注脚:
解码器 = 戴着一副”只能看左边”的眼罩,一遍遍地猜”下一个词该是谁”,猜出来就拼回输入、再猜下一个,直到猜出一个”该停了”的信号。
你可能觉得”自回归”“预测下一个词”这些词都听腻了。放心,这篇不重复《向量数据库原理》 §13 已经讲过的概念,而是把那里”一句话带过”、以及前几篇从没展开过的三样东西讲透:
因果掩码的代码长什么样、位置编码(RoPE)到底在干嘛、以及同一个解码器为什么训练时能”并行”、推理时却只能”串行”。
一、先定一个锚:大模型用的只是 Transformer 的一半——Decoder
上一篇编码器篇的锚是”吸收 = 句内 RAG”。解码器篇的锚更简单,一句话:
你天天调的 GPT / DeepSeek / Claude,本质上是 Transformer 的”右半边”——只留下解码器(Decoder),把左边那个编码器(Encoder)整个扔掉了。
这句话在《向量数据库原理》 §13 已经给了完整对比表,这里不重抄,只把最关键的一句钉死:
| 半边 | 任务 | 注意力方向 | 输出 | 代表 |
|---|---|---|---|---|
| 编码器 Encoder | 读懂、压缩一句话 | 双向(看全部) | 一个语义向量 | BERT、BGE、embedding |
| 解码器 Decoder | 预测、生成下一个词 | 单向 + 掩码(只看前面) | 下一个 token | GPT、DeepSeek、Qwen |
所以上一篇讲的”吸收上下文”是编码器的活儿,你博客里的 embedding 模型才需要它;你天天调 chat 接口的大模型,压根不用编码器——它是个 Decoder-only 模型,一辈子只干一件事:接着前面的话,猜下一个词。
把这件事重复几百万次,就是你屏幕上那段流畅的回答。下面把它拆开看。
二、解码器的完整流水线:一张全景图
先把整条流水线摆出来,让你知道后面每一节在讲哪一格:
你输入的一句话(已经切成 token)
↓
① 查嵌入表 → 每个 token 变成一个向量
↓
② 位置编码(RoPE)→ 给向量注入"我排第几"的信息 ★ 编码器篇没讲
↓
③ 堆 N 层 解码器层(每层两件事):
┌─ masked 多头自注意力(戴眼罩的"句内 RAG") ★ 掩码是核心区别
└─ FFN(消化)
↓
④ LayerNorm + 残差(稳定器,和编码器一样)
↓
⑤ 输出头(LM head)→ 把最后一个向量放大成"词表大小"的分数 ★ 编码器篇没讲
↓
⑥ softmax → 一张概率表 → 采样挑出一个 token
↓
⑦ 把这个 token 拼回输入,回到 ① 再来一遍 …… 直到吐出 <eos>
对照上一篇编码器篇,你会发现零件几乎全是同一批——embedding、多头注意力、FFN、LayerNorm、残差。解码器真正”多出来”的只有三样:
- 位置编码(②)——编码器其实也有,只是上一篇为了主线没展开;
- 因果掩码(③)——Decoder 和 Encoder 唯一本质上的区别;
- 输出头 LM head(⑤)——把”向量”翻译成”下一个词”的翻译官。
三、五、六节就分别讲这三样。先把最关键的那一样——掩码——讲透。
三、因果掩码:Decoder 与 Encoder 唯一的分水岭
3.1 为什么必须”只能看左边”
解码器的任务是预测下一个词。生成的时候,”下一个词”还不存在,所以当模型在写第 5 个词时,它只能看前 4 个,绝不能偷看第 6 个——因为第 6 个还没生出来,偷看就等于作弊。
这就是因果掩码(causal mask)的由来:每个位置,只允许它和”自己及左边”的词做注意力,右边(未来)全部挡住。
3.2 那行代码长什么样
上一篇编码器篇 §十二 给了自注意力的 Java 骨架,其中有一行 scores = matMul(Q, transpose(K)) 算出了 scores[i][j](词 i 对词 j 的相关性分数)。解码器要做的,就是在 softmax 之前,把”未来”的分数全部砸成负无穷:
// scores[i][j] = 词 i 对词 j 的相关性分数,形状 [n][n]
// 因果掩码:位置 i 只能看到 j <= i(自己及左边)
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
if (j > i) scores[i][j] = Float.NEGATIVE_INFINITY; // 挡住右边(未来)
}
}
float[][] weights = softmaxRow(scores); // -∞ 进 softmax 后,变成权重 0
为什么砸负无穷就能”挡住”?因为 softmax 是 e^x 再归一化,而 e^(-∞) = 0。所以被砸成负无穷的那一格,softmax 之后权重严格等于 0——这个词对”未来词”的注意力,被一刀切没了。
画成矩阵,就是 §13 那个三角形(✓ 表示能看,✗ 表示被挡):
词1 词2 词3 词4
词1 ✓ ✗ ✗ ✗
词2 ✓ ✓ ✗ ✗
词3 ✓ ✓ ✓ ✗
词4 ✓ ✓ ✓ ✓ ← 每行只看自己及左边
这就是 Decoder 和 Encoder 唯一本质上的区别:编码器这张表是全填满的(双向看),解码器这张表是下三角(只看左边)。 除此之外,Q/K/V、多头、除以 √d、残差、FFN……全都一模一样,上一篇编码器篇 §六、§七 讲过的都不变。
3.3 埋一个伏笔
看到这里你可能会想:既然每一步只能看前面,那是不是永远只能一个一个算?
答案会让你意外:训练的时候,其实是一次性并行算完的。 这背后的关键叫 Teacher Forcing,第五节专门讲。先把下一个”多出来”的零件讲完。
四、位置编码:没有顺序,注意力就是个”词袋”
4.1 注意力天生对”顺序”无感
这是全站从没展开过、但极其重要的一点。回头想上一篇编码器篇 §四 那个”吸收”的动作:
新向量(它) = 0.82·V(苹果) + 0.07·V(它) + 0.05·V(很) + ...
它做的是按权重把一堆 V 加起来。而”加法”有个特性:跟顺序无关。你把”苹果、很、红”按任意顺序排,只要权重跟着变,加起来的结果可以完全一样。
翻译成大白话:纯注意力模型分不清”我打他”和”他打我”。 因为这两个句子里,三个词的向量一模一样,注意力只是把”我、打、他”三个 V 按不同权重加一遍——而”谁在谁前面”这个信息,压根没被喂进去。
打个后端的比方:这就好比一个 Map<String, Vector>,你把它序列化发给下游,下游拿到的是一堆 key-value,但 key 的插入顺序丢了。而”顺序”对语言来说,往往是致命的——”我打他”和”他打我”完全是两件事。
4.2 解决方案:给每个向量注入”我排第几”
于是有了位置编码(positional encoding):在向量进注意力之前,先给每个位置的向量”加”或”乘”上一个和它位置有关的信号,让”排第 1 个”和”排第 3 个”的向量变得不一样。
最早的做法是绝对位置编码:给位置 0、1、2、3… 各配一个固定的向量,直接加到对应位置的词向量上。够用,但有个毛病——它学的是”绝对位置”,训练时最多见过位置 4095,推理时你给它位置 10000,它就没学过,容易翻车。
4.3 RoPE:不是”加位置”,是”转角度”
现在主流大模型(LLaMA、Qwen、DeepSeek 等)用的是旋转位置编码(RoPE)。它的思路很漂亮,一句话:
不给向量”加”一个位置编号,而是把每个词的向量,按”和它位置成正比的角”旋转一下。
伪代码长这样(只为讲清”旋转”这个动作,非生产实现):
// 位置 pos 的向量,按维对 (2k, 2k+1) 各旋转一个角
float[] rope(float[] x, int pos) {
float[] out = new float[x.length];
for (int k = 0; k < x.length; k += 2) {
float theta = pos * freq(k); // 角度和位置成正比,频率随维度衰减
float c = (float) Math.cos(theta);
float s = (float) Math.sin(theta);
float a = x[k], b = x[k + 1];
out[k] = a * c - b * s; // 二维旋转公式
out[k + 1] = a * s + b * c;
}
return out;
}
为什么”旋转”比”加编号”高明?关键在于一个几何性质:两个被旋转过的向量做内积,结果只和它们的”相对距离”(pos1 - pos2)有关,和绝对位置无关。
- “加编号”:模型学的是”第 4095 格长什么样”——超出训练范围就抓瞎。
- “旋转”:模型学的是”隔多远的两个词该怎么关联”——这是一个可以平移的规律。
所以 RoPE 才能”外推“:训练时只见过 4K 长度的文本,推理时却能处理 32K、128K——因为它学会的是”相对距离”这种普适规律,而不是死记”绝对位置表”。这正好把《大模型无状态》里那一句”需要额外的长上下文训练(如位置编码外推)”收口了。
一句话记住第四节:注意力本身是”顺序失明”的,位置编码就是给它戴上”序号眼镜”;RoPE 用”转角度”替代”加编号”,于是能把学到的相对位置规律,推广到训练时没见过的长度。
五、训练 vs 推理:同一个 Decoder,两种跑法
这是全篇最核心、也最反直觉的一节。回到 3.3 埋的那个伏笔:为什么解码器戴着”只能看左边”的眼罩,训练时却能一次性并行算完?
5.1 训练时:Teacher Forcing——喂真实答案,于是能并行
训练阶段,模型手里有标准答案。你喂它一段文字,它的任务是”预测下一个词”,而”下一个词”的正确答案就在你手里。
关键点来了:因为掩码只允许”看左边”,而训练时”左边”全是真实文本(已经给定),所以每一个位置的”下一个词”可以同时算。 位置 1 只看它自己,预测位置 2;位置 3 看位置 1、2、3(都是真实词),预测位置 4……所有位置的预测,在一次前向计算里并行完成,谁也不等谁。
这个”训练时用真实答案替代模型自己的预测”的技巧,叫 Teacher Forcing(教师强制)——就像老师把标准答案摆在每个空旁边,学生所有空可以一起填。
5.2 推理时:自回归——没有答案,只能一个一个来
推理时,没有标准答案了。你要生成”异常”这个词:
第 1 步:输入 [库存, 不足, 会, 抛, 什么] → 采样出 "异"
第 2 步:输入 [库存, 不足, 会, 抛, 什么, 异] → 采样出 "常" ← 必须等第 1 步的 "异"
第 3 步:……
每一个新 token 的输入里,都包含了上一步刚采样出来的那个 token。 所以第 2 步必须等第 1 步出结果——这是硬性的串行依赖,GPU 再多核也并行不起来。
5.3 一张表把这件事钉死
| 维度 | 训练(Teacher Forcing) | 推理(自回归) |
|---|---|---|
| 有没有标准答案 | 有 | 没有 |
| “左边”是谁 | 全是真实文本 | 是模型自己一步步猜出来的 |
| 计算方式 | 并行:所有位置一次算完 | 串行:一个词一个词挤 |
| 为什么 | 左边已知,谁也不依赖谁 | 每个词都依赖上一步的采样结果 |
| 类比 | 考试时旁边摆着标准答案,所有空一起填 | 闭卷考试,做第 3 题必须等第 2 题的结果 |
打个你熟的比方:训练 = 全量批处理(Batch),推理 = 流式串行(一条依赖上一条)。 训练时所有数据都在手上,一把梭并行算;推理时只能来一个处理一个,还带上下游依赖。
这一节直接回答了开头的问题——”为什么大模型回答总是一个字一个字蹦出来”。 不是它故意”打字慢”给你看,而是自回归的硬性约束:第 n 个词依赖第 n-1 个词的采样结果,天生就只能串行。你在界面上看到的 SSE 流式输出,不过是服务器把这个”本来就一个字一个字产生”的过程,实时转发给你罢了。
顺带澄清一个常见误解:掩码不是只有推理才戴。 训练时也必须戴——否则模型会偷看”未来的真实词”直接抄答案,训练出来的东西一到推理(没有未来可偷看)就全线崩盘。区别只在:训练时”戴掩码 + 并行”,推理时”戴掩码 + 串行”。掩码一直在,变的是能不能并行。
六、从概率表到那个字:logits → softmax → 采样
讲完了”多出来”的三样里最难的两样,剩下最后一样最简单,但它是整个流水线的”出口”,值得单独立一节。
6.1 输出头 LM head:把向量翻译成”词表大小的分数”
经过 N 层解码器,你得到的是每个位置的向量(比如 4096 维)。但你要的不是向量,是”下一个词是谁”。中间缺一步翻译,由输出头(LM head)完成:
输出头就是一层矩阵乘法,把最后一个位置的 4096 维向量,映射成一个词表大小(比如 10 万个 token)的向量。这个向量里的每个数,叫 logit——大致可以理解为”下一个词是这个 token 的原始分数”。
// 取最后一个位置的向量,映射成词表大小的 logits
float[] lastHidden = x[n - 1]; // [4096]
float[] logits = matMul(lastHidden, W_lm); // [vocabSize],比如 100000
为什么取最后一个位置?因为解码器的掩码是”只看左边”,最后一个位置是唯一”看到了整段输入”的位置——它的向量里,浓缩了到目前为止的全部信息。这就是”下一个词”该由它来预测的原因。
6.2 softmax:把分数变成概率
logits 是一堆可正可负、大小不一的数,不能直接当”概率”用。过一遍 softmax(就是《模型怎么跑起来》 §3.3 讲的那个”分数换概率”动作),变成一张加起来恰好等于 1 的概率表。
6.3 采样:从概率表里”抽”一个出来
有了概率表,最后一步是采样——挑一个 token 作为”下一个词”。这里就是工程师天天见的两个旋钮 temperature 和 top-p,以及那个特殊的”终止符” <eos>(模型输出它,就代表”这句话写完了,停”)。
这一截《模型怎么跑起来》 §3.3 和《LLM 应用后端接入工程》都讲透了,不重复。你只需要记住这条链:
最后一个位置的向量 → (LM head 矩阵乘法) → logits → (softmax) → 概率表 → (采样) → 下一个 token
“下一个词”从来不是模型”想”出来的,而是从一张概率表里”抽”出来的。 这就是为什么同一个问题、同样参数,每次答案都略有不同——你看到的”重新生成”,本质是又一次采样。
七、KV Cache:让”串行”不必”全量重算”
第五节说了,推理天生串行。但串行里还藏着一个巨大的浪费:
算第 2 个词时,第 1 个词的所有 K/V 被原封不动重算了一遍;算第 100 个词时,前 99 个词的 K/V 被重算了 99 遍。
好消息是,在因果掩码下,历史 token 的 K/V 是”算出来就不变”的——因为权重只读,前面词的 K/V 不会因为后面来了新词而改变。所以工程上把它们缓存进显存,每步只算新增那一个 token,历史直接读缓存。这就是 KV Cache,《模型怎么跑起来》 §5 叫它”模型推理的 Redis“。
这里只补一句和本篇主线的关联:KV Cache 之所以能成立,恰恰是因为解码器”只能看左边”——历史永远只影响未来,永远不会被未来改写。如果是编码器那种”双向看”,后面来了新词,前面词的表示会变,缓存就失效了。所以 KV Cache 是因果掩码送给我们的工程红利。
八、Java 骨架代码串一遍
把前面所有零件拼成一段 Java(伪代码,只为讲清结构,非生产实现):
// 解码器单层:和编码器几乎一样,只是注意力换成了 masked
float[][] decoderLayer(float[][] x, float[][] mask) {
// ① masked 多头自注意力:先算 Q/K/V,再在 softmax 前砸负无穷挡住右边
float[][] attended = maskedMultiHead(x, mask); // 见第三节
float[][] y = layerNorm(add(x, attended)); // 残差 + 归一化
// ② FFN:消化
float[][] z = ffn(y);
return layerNorm(add(y, z));
}
// 一次前向:预测"下一个词"的概率分布
float[] forward(int[] tokens) {
float[][] x = embed(tokens); // 查表 → 向量
x = applyRoPE(x); // 位置编码:转角度(第四节)
float[][] mask = causalMask(tokens.length); // 三角掩码(第三节)
for (int l = 0; l < L; l++) {
x = decoderLayer(x, mask); // 堆 L 层
}
float[] logits = lmHead(x[x.length - 1]); // 只取最后一个位置(第六节)
return softmax(logits); // → 词表大小的概率表
}
// 自回归生成循环(第五节:推理侧的"串行")
int[] generate(int[] prompt, int maxTokens) {
int[] seq = prompt.clone();
for (int i = 0; i < maxTokens; i++) {
float[] probs = forward(seq);
int next = sample(probs); // 贪心 / 温度 / top-p(第六节)
seq = append(seq, next);
if (next == EOS) break; // 吐出终止符就停
}
return seq;
}
对照着看,整篇就五句话:
causalMask是”只能看左边”的眼罩(第三节);applyRoPE是”给词排座位”(第四节);lmHead+softmax是”把向量翻译成概率表”(第六节);generate里的 for 循环是”一个字一个字挤”的自回归(第五节);- 剩下的多头、FFN、残差、LayerNorm,和上一篇编码器篇完全相同。
九、为什么”猜下一个词”能长出智能
讲完解码器”怎么写”,最后点一下那个最玄的问题:就凭”猜下一个词”这么个弱智任务,凭什么能练出会写代码、会推理的大模型?
答案在《模型怎么跑起来》 §7 已经给了,这里从解码器的视角再点一次:
“猜下一个词”逼着模型去理解前面所有内容的规律。 要猜中”资产负债表里的下一项”,它就得学会财务;要猜中一段 Java 里下一个该出现什么,它就得学会语法和逻辑。而解码器手里唯一的武器,就是那副”只能看左边”的眼罩 + 位置编码 + 几层注意力——它必须用”看到的前文”,去推断”最合理的下一个词”。
世界的一切知识,就被这个看似弱智的任务,压缩进了解码器的几十亿个权重里。你惊叹它”居然懂”,本质是:它为了”猜对下一个词”,被迫先”读懂”了前文。
十、一句话收束
回答你最初的问题——”大模型是怎么一个字一个字写出来的”:
解码器戴着一副”只能看左边”的因果掩码,用位置编码给词排好座次,一遍遍做”预测下一个词”的前向计算:最后一个位置看全前文,经输出头映射成词表大小的 logits,softmax 成概率表,采样出一个 token,再把它拼回输入、猜下一个——这个自回归循环,训练时靠 Teacher Forcing 能并行,推理时只能串行,靠 KV Cache 省掉历史重算。它写出的每一个字,都是”读懂了前文之后,猜出的最合理的下一个字”。
没有任何一步超出”矩阵乘法 + 掩码 + 采样”的范畴。你之前觉得”大模型会写作”玄乎,是因为它被包装成了”生成式 AI”;拆到代码级,它就是一个戴着下三角眼罩、反复猜下一个词、猜完拼回去再猜的循环。
系列导航(这一篇在其中的位置)
| 篇 | 主题 | 回答的问题 |
|---|---|---|
| 《大模型无状态》 | 客户端反复重发历史、上下文窗口 | 它为什么”记得”又不”记得” |
| 《模型怎么跑起来、怎么变聪明》 | 前向计算 + 反向传播 | 它内部怎么算、怎么被练出来 |
| 《Transformer 编码器》 | 双向注意力吸收上下文 | 它怎么”读懂”一句话、怎么变成 embedding |
| (本篇)《Transformer 解码器》 | 因果掩码 + 自回归生成 | 它怎么”一个字一个字写出来” |
四篇连起来是一条完整的内核链:无状态(壳怎么调)→ 训练/推理(权重怎么算、怎么练)→ 编码器(怎么读)→ 解码器(怎么写)。 而《向量数据库原理》和《企业级记忆知识库》讲的是另一条”知识怎么存、怎么检索”的链路——编码器负责把知识压成向量存进去,解码器负责把检索回来的东西读进前文、再续写下去。 两条链在”RAG”这个点上接在一起,你就从”大模型”这个词,看到了它完整的一张可拆开的图。