←返回大模型学习资料
学习资料第 8 章 · Transformer

CHAPTER 08 · TRANSFORMER

注意力
与上下文

Transformer 把上一章的 Token 和 Embedding 组织成能读懂上下文的计算模块。本章从“为什么一个词需要看见别的词”开始,逐步拆解 Query、Key、Value、缩放点积注意力、多头注意力、位置编码、编码器、解码器、因果掩码、训练目标、微调与交付。每一步都用小矩阵、形状契约和可运行代码验证。

难度:基础进阶建议:先手算小矩阵代码:Python 3.11+ / PyTorch小节:27 张独立学习卡片
TEXTBOOK MAP · TRANSFORMER

本章学习地图

从一个 Token 的上下文到可复现的微调项目。

阅读方式:先按大节顺序学习;需要回看时从地图跳到具体小节。页面把每个大节显示为一张独立“书页”,底部的上一节/下一节只在大节之间翻页。

本章的判断标准:不是能调用一个 Transformer 类就算完成,而是能用小矩阵解释 Q/K/V、mask、softmax、残差、位置和 logits 的关系;能写出形状清楚的前向与训练循环;能说明预训练、微调、数据泄漏和安全回滚的边界。

连续项目 2/3 · 从因果注意力搭建 mini-GPT

08_build_minigpt.ipynb 把 embedding、位置编码、多头因果注意力、残差块和语言模型损失接成可训练模型,并保存第 9 章继续使用的基础 checkpoint。

查看运行与验收说明

01 · ARCHITECTURE OVERVIEW

架构总览:Transformer 为什么能读懂上下文

上一章把文本变成了 Token、ID 和 Embedding。本节先不急着背公式,而是回答三个问题:一个词为什么需要看别的词?注意力怎样把别处的信息搬过来?一个 Transformer Block 到底由哪些职责清楚的组件组成?

8.1.1 从序列到上下文:一个 Token 不应只代表自己

Embedding 查表得到的是“这个 Token 在词表里的初始向量”,它还没有看到同一句话中的其他 Token。同一个词在“苹果发布手机”和“我买了一个苹果”里可能需要不同解释;同一个“它”要结合前文才知道指代谁。上下文表示的任务,就是让位置 i 的向量不只携带自身信息,还能根据序列中的其他位置更新。Transformer 用注意力完成这件事,不需要像传统循环网络那样按时间一步一步传递。

可以先把序列想成一排卡片。每张卡片有 Token、位置和一个向量;处理某张卡片时,它会给其他卡片打分,再按分数加权读取信息。打分不是人工规则,而是由 Query 与 Key 的相似关系计算出来;被读取的内容由 Value 提供。这样“看谁”和“拿什么”是两个明确的职责,后面才能分别调试。

上下文并不意味着所有位置都平均混合。查询“谁发布了这款手机”可能更关注“苹果”“发布”和“手机”,而忽略语气词;查询“它的价格”需要把注意力放到前一句提到的产品。模型通过训练学习在不同任务和层中形成不同的权重分布。注意力权重可以帮助观察行为,但不能简单当作唯一的因果解释,后面会专门讨论如何谨慎解读。

图 8.1 · 从 Token 到上下文表示自制示意:信息沿横向流动
Token ID[B,T]
→
Embedding[B,T,D]
→
位置注入顺序信息
→
Attention跨位置读取
→
上下文向量[B,T,D]

每个位置仍有自己的输出,但输出已经包含了对其他位置的选择性读取。

表 8.1A · 表示层的职责边界
组件输入主要职责不负责什么
EmbeddingToken ID查表得到初始向量理解上下文关系
位置表示位置索引/相对距离告诉模型顺序和距离决定语义相关性
Query当前位置向量提出“我需要什么”直接保存被读取内容
Key候选位置向量描述“我适合被谁关注”决定最终输出值
Value候选位置向量提供被加权汇总的信息单独决定权重
PYTHON · context_window.py
tokens = ["小王", "把", "书", "放在", "桌上"]
vectors = {
    "小王": [1.0, 0.0],
    "把": [0.0, 1.0],
    "书": [0.8, 0.2],
    "放在": [0.4, 0.9],
    "桌上": [0.2, 1.0],
}
for index, token in enumerate(tokens):
    left = tokens[max(0, index - 2):index]
    right = tokens[index + 1:index + 3]
    print(token, "can inspect:", left + right)

这个窗口脚本不是 Transformer,只是把“当前位置需要上下文”变成肉眼可见的列表。真正的注意力不会固定只看左右两个位置,也不会把所有上下文等权相加;它会根据 Query、Key 的关系动态产生权重,并在不同层学习不同范围的依赖。

与循环网络相比,Transformer 可以在训练时并行处理同一序列的多个位置,因为每个位置的注意力矩阵可以一次计算出来;与纯词袋相比,它保留位置并允许不同 Token 互相读取。代价是序列长度增加时,完整注意力矩阵的计算和内存通常按长度的平方增长。因此上下文窗口、分块、稀疏注意力和缓存都会成为工程问题。

先画数据流,再记名词

面对一张 Transformer 结构图,沿着一个位置追踪:它从哪里拿到初始向量?位置从哪里加入?Q/K/V 是从哪个张量投影出来的?注意力输出是否经过线性层?残差相加的两条分支 shape 是否一致?归一化放在相加前还是相加后?最后 FFN 是否逐位置独立计算?能回答这些问题,就比背“八层、十二头、四千维”更接近真正理解。

架构图中的箭头不是装饰。箭头表示张量流动、信息可见性和梯度路径;任何一个箭头被改动,都可能改变模型的训练稳定性或因果边界。读图时把张量形状写在箭头旁边,是初学者最值得养成的习惯。

ARCH LAB 8.1.1为一个代词找上下文

写两组含“它”的中文句子:一组需要前文才能判断指代,一组不需要。标出理想情况下应关注的 Token,并说明纯词袋、固定窗口和注意力各自可能遇到的限制。

答案要指出指代对象和证据位置;固定窗口可能漏掉远处信息,词袋丢失顺序,注意力理论上可以选择相关位置,但实际是否学会要用任务样例验证。

8.1.2 为什么需要注意力:让“读取信息”成为可学习操作

如果把上下文向量写成所有 Token 的平均值,每个位置得到的结果会非常相似;如果只取固定邻居,又可能错过长距离依赖。注意力的核心是为每个查询位置生成一组权重,权重总和通常为 1,再用它们加权候选 Value。权重越大,表示当前位置在这一次计算中读取该位置的信息越多。每个查询都有自己的一行权重,因此“谁关注谁”会随位置和输入变化。

Query、Key、Value 可以用图书馆来理解:Query 是读者提出的问题,Key 是书架上每本书的标签,Value 是书里的内容。读者先用问题和标签匹配,得到对各本书的关注程度,再把真正的内容按权重取回来。这个类比也提醒我们:Key 适合检索,Value 适合传递,二者可以来自同一个输入,也可以来自不同序列。

图 8.2 · Q/K/V 的三步职责自制示意:先匹配,再读取
Query当前位置需求
×
Key候选标签
→
分数兼容程度
→
softmax权重
×
Value候选内容
→
context加权结果

同一批输入可以产生不同 Query,因此每个位置的权重行不必相同。

表 8.1B · 注意力里的三个问题
对象直观问题矩阵角色常见混淆
Q我现在需要什么信息?产生查询行误以为是最终内容
K我是否适合被这个查询关注?参与打分把 Key 当成标签文字
V如果被关注,我提供什么?被权重加权以为决定关注程度
scoreQ 与 K 有多匹配?softmax 前的数值直接当概率
weight每个候选占多少比例?每行归一化忽略 mask 后的归一化
PYTHON · manual_attention.py
import math
import numpy as np

query = np.array([1.0, 0.0])
keys = np.array([[1.0, 0.0], [0.0, 1.0], [0.6, 0.8]])
values = np.array([[10.0, 0.0], [0.0, 10.0], [4.0, 4.0]])

scores = keys @ query / math.sqrt(query.size)
scores = scores - scores.max()
weights = np.exp(scores) / np.exp(scores).sum()
context = weights @ values

print("scores:", scores)
print("weights:", weights, "sum:", weights.sum())
print("context:", context)

示例使用 NumPy 让每一步都能打印出来。分数先除以维度平方根,稍后会解释这是为了控制数值尺度;减去最大值是稳定 softmax 的常见做法,不改变归一化结果。最终 context 是 Value 的加权和,而不是把 Query 或 Key 原样返回。

注意力权重通常是非负且行和为 1,但这不代表模型只能做“平均”。softmax 会把相对差异转成竞争关系:一个候选分数提高,其他候选的比例可能下降。温度、缩放、mask 和精度都会影响分布的尖锐程度。权重很平均可能表示不确定,也可能表示该层在整合广泛上下文;权重很尖锐也不一定意味着模型真正理解。

用极端输入检查直觉

把一个 Key 设成与 Query 完全相同,把另一个设成相反方向,再观察 softmax 权重;把所有 Value 设成相同向量,验证无论权重如何,context 都不会改变;把某个候选加上 mask,验证它的权重变成 0 且剩余权重重新归一化。三组极端实验能同时检查打分、读取和掩码,而不需要训练模型。

如果你在日志里只打印最终 context,遇到错误时很难判断是哪一步错了。保留 Q/K/V 的 shape、score 的范围、权重行和、mask 后可见数量,才能把“注意力不工作”拆成具体问题。

ARCH LAB 8.1.2手算一行注意力

用二维 Query、三个 Key 和三个 Value,手算点积、缩放、softmax 和 context。再把第二个候选遮掉,比较遮罩前后的权重和输出。

要展示每一步的中间值;遮罩后的不可见位置权重应为 0,其余位置重新归一化。不要只报最终向量。

8.1.3 读懂 Transformer Block:注意力只是中间的一段

一个常见的 Transformer Block 可以概括为:输入表示先经过多头自注意力,再与原输入做残差相加和归一化;然后进入逐位置前馈网络,再做一次残差和归一化。不同论文和实现会在归一化放置、激活函数、偏置、位置编码和 dropout 上有所不同,但职责可以分成“跨位置混合”和“逐位置变换”两类。前者让 Token 交换信息,后者对每个位置的通道做更丰富的非线性变换。

残差连接把输入直接送到后面,给梯度提供较短路径,也让每个子层只需要学习“在已有表示上补什么”。归一化把每个位置的通道尺度控制在较稳定范围,减少不同层之间的数值漂移。前馈网络通常先把维度扩大,再经过激活函数压回隐藏维;它不直接混合不同位置,但会在每个位置独立地重写特征。

“堆叠很多层”不是把同一层复制后不加区别。每层有独立参数,注意力头可能关注不同关系,深层表示通常携带更复杂的上下文组合。层数、隐藏维度、头数和前馈维度一起决定参数量与计算量;学习时先用一层或两层的小模型验证形状和损失,再扩展规模。

表 8.1C · Transformer Block 的数据流
步骤典型输入/输出是否混合位置调试重点
输入[B,T,D]否Embedding/位置已对齐
自注意力[B,T,D]→[B,T,D]是Q/K/V、mask、head
残差+Norm[B,T,D]不新增位置shape、finite、顺序
FFND→D_ff→D逐位置激活、维度、参数量
堆叠重复 N 层每层都可混合层数、缓存、梯度
PYTHON · block_blueprint.py
def transformer_block(x, self_attention, feed_forward, norm1, norm2):
    attended = self_attention(x)
    x = norm1(x + attended)
    transformed = feed_forward(x)
    x = norm2(x + transformed)
    return x

# x: [batch, tokens, hidden]
# self_attention: mixes tokens
# feed_forward: transforms each token independently

伪代码把关键顺序写出来,但真正实现还要传递 attention mask、dropout、训练模式和可能的缓存。Pre-LN 结构常把归一化放到子层输入,Post-LN 则放在残差相加之后;两种结构不能只改一行名字就假设训练行为完全一样。阅读现有模型时先查它的实现和配置,再根据代码画自己的数据流图。

形状不变不代表语义不变

注意力、残差和 FFN 结束后通常仍是 [B,T,D],这让模块可以堆叠,但每一步对内容的改变不同。只打印输入输出 shape 会漏掉 mask 是否生效、残差是否重复相加、Norm 是否出现 NaN 等问题。建议在单层探针中同时检查范数、均值、标准差、非有限值和一个固定位置的向量快照。

如果残差两侧维度不同,必须使用明确的投影层或重新设计配置;不能用 reshape 伪装成相同维度。reshape 只改变视图/排列,不会学习新的语义对齐。

ARCH LAB 8.1.3画出一层 Block

给定输入 [2, 5, 16],设计一层隐藏维 16、前馈维 64、4 个头的 Block。标出每个算子的输入输出 shape、哪些步骤混合位置、哪些步骤只处理当前位置。

注意力的内部会出现 [B,H,T,T] 分数矩阵,输出回到 [B,T,16];FFN 是 [B,T,16]→[B,T,64]→[B,T,16]。残差相加前后的最后一维必须相同。
表 8.1D · 本大节完成标准
能力最低证据自测问题
上下文能说明单个 Token 为什么需要其他位置代词、长距离依赖例子
Q/K/V能手算一行注意力并解释三者职责遮罩后权重如何变化
Block能画出注意力、残差、Norm、FFN 顺序哪一步混合位置

把一层 Transformer 当成一条数据管线

阅读一层结构时,可以固定使用同一套提问顺序。第一问是“这一步的输入是什么”,例如残差流 x 的 shape 为 [B,T,D];第二问是“它混合了哪些位置”,注意力会在 Token 轴上交换信息,而逐位置 FFN 不会直接把第 3 个 Token 的值拿给第 7 个 Token;第三问是“哪一个参数改变了什么”,Q/K/V 的投影改变匹配方式,FFN 的两层线性改变特征,Norm 改变数值尺度;第四问是“如果去掉它会怎样”,这就自然引出消融实验。

这种阅读顺序能避免只看模块名字。比如两个实现都叫 Attention,可能一个先转成 [B,H,T,d],另一个先转成 [T,B,D];一个把布尔 mask 的 True 当作屏蔽,另一个把 True 当作允许;一个在 softmax 前加负无穷,另一个使用浮点偏置。名字相同不意味着契约相同。学习时请把每个模块的 shape、dtype、mask 语义写在旁边,直到可以不看源码复述。

复杂度也值得在基础阶段建立直觉。序列长度从 T 变成 2T 时,注意力分数矩阵的元素数量从 T² 变成 (2T)²,也就是约四倍;Q/K/V 投影和 FFN 的工作量通常更接近线性随 T 增长。正因为注意力的 Token×Token 关系昂贵,长上下文需要窗口、稀疏注意力、缓存或其他结构,而不是简单把 max_length 乘大。

表 8.1E · 一层结构的 shape 账本
节点典型 shape是否混合 Token排查重点
输入残差流[B,T,D]上一层已完成dtype/device/长度
Q、K、V[B,H,T,d]投影后待匹配D=H×d
注意力权重[B,H,T,T]是行和、mask、finite
注意力输出[B,T,D]是合并 head 顺序
FFN 中间层[B,T,Dff]否激活和显存
残差输出[B,T,D]通过加法shape 必须相同
PYTHON · shape_ledger.py
def split_heads(x, heads):
    batch, tokens, dim = x.shape
    if dim % heads:
        raise ValueError("hidden 维度必须能被 heads 整除")
    head_dim = dim // heads
    # [B,T,D] -> [B,H,T,d]
    return x.reshape(batch, tokens, heads, head_dim).transpose(1, 2)

def merge_heads(x):
    batch, heads, tokens, head_dim = x.shape
    # [B,H,T,d] -> [B,T,D]
    return x.transpose(1, 2).contiguous().reshape(
        batch, tokens, heads * head_dim
    )

class FakeTensor:
    def __init__(self, shape):
        self.shape = shape

q = FakeTensor((2, 6, 32))
print("输入:", q.shape, "预期 head_dim:", q.shape[-1] // 4)

代码中的 FakeTensor 只是让读者关注 shape;真正实现时使用 PyTorch Tensor,并在 reshape 前后断言元素总数不变。transpose 后的内存布局可能不是连续的,随后调用 view 可能报错或产生误解,通常应使用 contiguous() 后再 reshape。这个细节看似琐碎,却是手写多头注意力时经常遇到的第一个工程问题。

还要区分参数量与计算量。一个 [D,D] 的投影矩阵有 D² 个参数,但对长度 T 的输入会处理 B×T 个向量;增加长度主要增加激活和注意力矩阵,增加 D 主要增加投影和 FFN 的计算。显存不只是模型权重,反向传播需要保留中间激活,训练时优化器还会维护额外状态。做实验记录时同时写参数量、序列长度、batch 和显存峰值。

图 8.2 · Token 混合与逐位置变换自制对照:注意力跨位置,FFN 逐位置
位置 1…T[B,T,D]
→
注意力T×T 关系
→
残差+Norm保留路径
→
FFN每个位置独立
→
下一层[B,T,D]

问“信息有没有跨位置流动”是理解每个子层职责的快捷方法。

易错清单:看到结果先不要急着解释

第一类错误是把 batch、Token、hidden 三个轴混为一谈,导致模型“能跑”但把不同样本互相注意;第二类错误是把 head 维拼接顺序弄错,输出仍然是 [B,T,D],却失去预期能力;第三类错误是残差相加前维度不一致,通过隐式广播把错误藏起来;第四类错误是 LayerNorm 的归一化轴写错,把 Token 轴也混进去;第五类错误是训练和推理使用不同的 mask 或 dropout 状态。

遇到这些问题时,先用两个 batch、三个 Token、四个 head 的极小输入运行,打印每一步的完整 shape 和一个固定位置的数值。再把相同输入复制成两份,确认 batch 之间不会互相影响;把一个 Token 改动,确认允许的后续位置可能变化、被屏蔽的过去位置不违反因果约束。小输入比大日志更能说明问题。

ARCH LAB 8.1.4做一页结构账本

为你实现的 Transformer 画出 [B,T,D] 到 logits 的节点表,至少列出 Q/K/V、scores、weights、attention output、FFN 中间层和残差输出。每行写 shape、是否跨 Token、一个可能的错误。最后说明当 T 翻倍时,哪个中间对象最容易造成显存压力。

scores/weights 的 Token×Token 矩阵通常最敏感;账本应体现 D=H×d,残差前后 shape 相同,FFN 只在每个位置变换。错误示例可以是 head 轴丢失、mask 广播错或 LayerNorm 轴错。
表 8.1F · 架构阅读自测
问题合格回答应包含还不会时的动作
信息从哪来说明注意力跨位置交换,FFN 逐位置画两条箭头并标轴
长度为何昂贵指出 T×T 分数/权重矩阵用 T=3、6 数元素
残差做什么保留旧表示并改善梯度路径比较去掉残差的 loss
如何定位错先查 shape/mask/dtype/finite写四个断言

从“能说出组件”到“能解释取舍”

架构学习的下一层不是再背一遍模块名,而是能说明取舍。为什么残差流要保持 D 不变?因为注意力和 FFN 的输出需要回到同一条信息通道,才能直接相加;为什么 FFN 往往先扩维再压回?因为中间维提供更大的逐位置变换空间;为什么注意力不直接使用原始 embedding?因为 Q/K/V 投影让匹配、被读取的内容和输出表示可以分别学习;为什么层数增加不能无限提升?因为激活、优化难度、数据量和误差累积都会成为限制。

请把“结构事实”和“经验倾向”分开写。结构事实包括 scores 的 shape、causal mask 的可见边界、残差相加的维度要求;经验倾向包括某种 Norm 更稳定、某些 head 可能出现分工、某个 FFN 比例常用。前者可以用断言验证,后者需要实验和数据支持。这样读论文或官方实现时,不会把一个配置习惯误认为数学定律。

还要建立参数、激活和数据三者的联系。模型参数决定可学习的函数族,激活决定一次 batch 的中间状态,数据决定梯度在这些参数上留下什么痕迹。只增加参数而不增加有效 Token,可能只是记忆小语料;只增加数据而不检查 mask,可能让错误更稳定;只看最终输出而不保存中间 shape,可能无法复现。每个实验同时记录三者,才有解释空间。

表 8.1G · 结构事实与实验假设
陈述类别验证方法
残差前后 shape 相同结构事实运行时断言
因果 mask 不允许未来 Key结构事实反事实 Token 探针
Pre-LN 更易稳定经验倾向同预算多 seed
不同 head 会分工实验假设权重+行为对照
更大 FFN 一定更好待证假设固定参数/Token 消融

用一个最小反例检查架构理解

给模型输入两个几乎相同的序列,只交换一个 Token 的位置,再分别关闭位置表示、关闭残差或替换 causal mask。你不需要期待某个固定数值,而要写出“哪些变化理论上应该出现、哪些不应该出现”。例如关闭位置后,顺序差异可能减小;关闭残差后,深层信号路径变短;mask 方向错误时,未来 Token 会影响当前位置。反例把抽象概念变成可观察行为,也为后续章节的调试习惯打基础。

每个反例都要记录输入、配置、seed、输出摘要和结论。若结果与预期相反,不要立即把它归咎于“模型太小”,先检查实验是否真的改变了目标组件。一个好的架构学习记录,会同时包含理论预期、实际结果和无法由当前实验回答的问题。

最后,把架构图和代码中的名字对齐:图里的“残差流”对应哪个变量,图里的“注意力权重”对应哪一次 softmax,图里的“下一层”是否真的接收了更新后的 x。对齐后再读官方实现,遇到新参数先问它改变了哪一个合同。能完成这次映射,才算为后续训练与部署建立了稳固的底座。

如果你能在纸上从 [B,T,D] 推到 [B,T,V],再解释每次跨 Token 的位置和每次只改特征的位置,就已经掌握了本章最重要的结构语言。后面的优化、量化、并行和部署,都会继续围绕这些轴、这些边界和这些证据展开。

架构笔记的最后一道检查

把每个公式旁边写上最小可运行例子:点积对应哪一行代码,缩放对应哪个除法,mask 对应哪一个布尔矩阵,残差对应哪一次加法,FFN 对应哪两个线性层。公式、shape、代码三者能够互相指认时,遇到新的注意力变体也能从已知结构推导,而不是只能记名词。

把这份账本留在项目目录中,后续的微调和部署都会继续引用它。

02 · ATTENTION MECHANICS

注意力机制:Q、K、V 如何把相关信息搬到当前位置

注意力是 Transformer 的计算核心,但它不是一句“计算相似度”就结束了。本节从线性投影、缩放点积、softmax 和三种常见 mask 讲起,把每个张量的形状与信息边界写清楚。

8.2.1 Query、Key、Value:把“寻找”和“提供”拆开

自注意力的输入通常是同一个序列 X,但它会经过三组不同的线性投影得到 Q、K、V。Q 代表每个位置当前的需求,K 代表每个位置可被匹配的特征,V 代表真正要传递的内容。虽然三者来自同一个 X,参数矩阵不同意味着它们承担不同角色。对一个位置 i,Q_i 会与所有 K_j 计算匹配分数,再用得到的权重加权所有 V_j。

线性投影不会改变 Token 数量,只改变最后一维。例如 X 形状是 [B,T,D],Q/K/V 可以是 [B,T,d_k]、[B,T,d_k] 和 [B,T,d_v]。注意力矩阵 QKᵀ 的形状是 [B,T,T],第 i 行第 j 列表示位置 i 读取位置 j 的倾向。多头时会再增加一个 head 维度,后面单独展开。

自注意力和交叉注意力的区别在于 Q 与 K/V 是否来自同一序列。编码器自注意力让输入 Token 互相读取;解码器的交叉注意力让目标序列的 Query 读取编码器输出的 Key/Value。理解来源比背“self/cross”两个单词更重要,因为它决定了信息是否跨序列流动。

表 8.2A · Q/K/V 的形状与来源
注意力类型Q 来源K/V 来源允许的读取范围
编码器自注意力编码器输入同一编码器输入非 PAD 的整个输入
解码器自注意力已生成前缀同一目标序列当前位置及其左侧
交叉注意力解码器隐藏状态编码器输出源序列的可见位置
检索式注意力查询向量文档向量候选集合
PYTHON · qkv_projection.py
import torch
from torch import nn

batch, tokens, hidden, head_dim = 2, 5, 16, 4
x = torch.randn(batch, tokens, hidden)
q_proj = nn.Linear(hidden, head_dim, bias=False)
k_proj = nn.Linear(hidden, head_dim, bias=False)
v_proj = nn.Linear(hidden, head_dim, bias=False)

q, k, v = q_proj(x), k_proj(x), v_proj(x)
scores = q @ k.transpose(-2, -1)
print("q/k/v:", q.shape, k.shape, v.shape)
print("scores:", scores.shape)

代码中 q @ k.transpose(-2, -1) 让每个查询位置与每个候选位置配对。最后两个维度分别是 Token 轴,因此转置是必要的;如果误写成 k.transpose(0, 1),批次维度会被混入矩阵乘法,通常会在更晚的位置才报出难以理解的错误。

不要把 Q/K/V 当成三份可读文本

投影后的向量是模型内部特征,不能直接解码成“问题、标签、答案”。它们的可解释性来自对照实验和表示变化,而不是某一个维度的中文含义。调试时优先看形状、范数、分数分布和 mask;要解释内容,则回到输入 Token、位置和最终输出。

Q/K/V 的参数可以共享,也可以独立;标准实现通常使用三个线性层或一个线性层一次生成三倍维度再切分。两种写法在数学上可以等价,但权重布局、state_dict 键和性能不同。迁移模型时要确认切分顺序,避免把 Q 的权重当成 V。

ATTN LAB 8.2.1画出自注意力和交叉注意力

给定源序列长度 6、目标序列长度 4、隐藏维 16,分别写出自注意力和交叉注意力的 Q/K/V 来源与分数矩阵形状。

源自注意力分数为 [B,6,6],目标自注意力为 [B,4,4],交叉注意力为 [B,4,6];交叉注意力的 Query 来自目标,Key/Value 来自源。

8.2.2 缩放点积注意力:从分数到概率再到上下文

最常见的注意力公式是 Attention(Q,K,V)=softmax(QKᵀ/√d_k)V。点积把每个查询和候选配对,除以 √d_k 控制维度增大带来的数值尺度,softmax 把同一查询的一行分数转成权重,最后与 V 相乘得到上下文向量。公式看起来短,但每一步都有形状和数值假设。

为什么要缩放?如果 Q 和 K 的每个维度近似独立、方差接近 1,d_k 增大时点积的方差会随维度变大。softmax 输入过大时会变得非常尖锐,梯度在大多数位置接近 0;缩放让不同头、不同层的分数更容易落在可训练范围。缩放不是为了改变相对排序,而是为了改善数值和梯度。

softmax 前做减最大值是稳定计算的常见技巧:exp(x−max(x)) 与 exp(x) 在归一化后比例相同,却避免指数溢出。实际框架可能使用融合内核、float32 累积或更稳定的实现;学习时把中间分数和权重打印出来,能直观看见温度和 mask 的影响。

图 8.3 · 缩放点积注意力的四步自制示意:每一行对应一个 Query 位置
QKᵀ[B,H,T,T]
→
/√dₖ控制尺度
→
+ mask屏蔽不可见
→
softmax行权重
→
× V读出内容

mask 必须在 softmax 前应用,否则不可见位置仍可能分到概率。

表 8.2B · 公式每一项的检查点
表达式形状检查常见错误
Q[B,H,T_q,d_k]最后一维与 K 相同把 H、T 位置弄反
Kᵀ[B,H,d_k,T_k]只转置最后两维转置批次或 head
scores[B,H,T_q,T_k]每行对应一个查询把距离轴当查询轴
weights[B,H,T_q,T_k]沿 T_k 归一化沿错误维度 softmax
context[B,H,T_q,d_v]回到查询长度与 V 维度不匹配
PYTHON · scaled_dot_product.py
import math
import torch

def attention(q, k, v, mask=None):
    depth = q.size(-1)
    scores = q @ k.transpose(-2, -1) / math.sqrt(depth)
    if mask is not None:
        scores = scores.masked_fill(~mask, torch.finfo(scores.dtype).min)
    weights = torch.softmax(scores, dim=-1)
    context = weights @ v
    return context, weights

q = torch.tensor([[[[1.0, 0.0]]]])
k = torch.tensor([[[[1.0, 0.0], [0.0, 1.0]]]])
v = torch.tensor([[[[10.0, 0.0], [0.0, 10.0]]]])
mask = torch.tensor([[[[True, False]]]])
context, weights = attention(q, k, v, mask)
print(context, weights, weights.sum(dim=-1))

这里的 mask 是布尔张量,True 表示允许读取。把不可见位置填成 dtype 能表示的极小值后,softmax 会将其权重压到接近 0;如果一整行都被屏蔽,softmax 可能产生 NaN,因此调用前应确保每个有效 Query 至少有一个可见 Key,或者对空行做显式降级。

注意力输出的 Value 维度可以与 Key 维度不同。多头实现中常让 d_k=d_v=d_head,但交叉注意力或压缩模块可以采用不同维度。只要矩阵乘法和输出投影的形状一致,公式本身不要求它们相等。配置文件应写出 d_model、num_heads、head_dim 和 d_v 的关系。

手算 softmax 的小技巧

先把每行减去最大分数,再计算指数;如果只需要比较趋势,可以先观察差值而不是直接算大指数。两项分数相等时权重相等;一个分数比另一个大很多时,权重会接近 1 和 0,但不会在有限数值下真正等于 1 和 0。温度越低分布越尖,温度越高分布越平。

把权重和为 1 当成单元测试,而不是语义证明。还要检查不可见位置权重、输出范围、零/空输入和多批次独立性。一个注意力函数如果只能在特定 batch=1 的例子上运行,就还没有完成。

ATTN LAB 8.2.2做一次数值稳定性对照

构造分数 [1000, 999, 998],分别直接计算 softmax 和先减最大值后的 softmax。再用 float32 与 float16(若设备支持)比较输出,说明为什么要关注 dtype。

直接 exp 可能溢出;减最大值不改变概率比例。低精度下极端分数更容易下溢/溢出,实际实现常在关键归一化处使用更高精度或融合内核。

8.2.3 Mask 与信息边界:模型能看见什么,决定它学会什么

Mask 是注意力中的信息门。Padding mask 屏蔽补齐位置,避免无效 Token 影响表示;causal mask 屏蔽未来位置,保证自回归训练时第 t 个位置不能偷看答案;cross-attention mask 屏蔽源序列中的 PAD 或不可访问内容。三种 mask 解决不同问题,不能用一个全是 0/1 的张量却不说明语义。

布尔 mask 通常形状可以是 [B,1,1,T_k]、[B,1,T_q,T_k] 或 [B,H,T_q,T_k],依赖实现的广播规则。加性 mask 则直接加到 score 上,允许位置加 0,不允许位置加一个很小的负数。转换时要确认 True/False 的方向;很多错误正是因为一个函数把 True 定义为“屏蔽”,另一个函数把 True 定义为“可见”。

causal mask 的主对角线和下三角通常为 True,表示当前位置可以看见自己和左侧;上三角为 False。训练时所有位置可以并行计算,只是用 mask 让每个位置的感受野符合生成顺序。推理时逐 Token 生成,还会使用 KV cache 避免每一步重复计算历史 Key/Value。

表 8.2C · 三类 Mask 的差异
Mask屏蔽对象典型形状使用阶段
paddingPAD Token[B,1,1,T]编码器/解码器自注意力
causal当前之后的未来位置[1,1,T,T]解码器训练与生成
cross源序列 PAD/不可见项[B,1,1,T_source]解码器交叉注意力
loss mask不计损失的标签[B,T]训练目标汇总
PYTHON · mask_factory.py
import torch

def causal_mask(length):
    return torch.tril(torch.ones(length, length, dtype=torch.bool))

def padding_mask(input_ids, pad_id):
    # [B,T] -> [B,1,1,T],用于广播到查询和 head
    return (input_ids != pad_id)[:, None, None, :]

ids = torch.tensor([[2, 4, 5, 0], [2, 6, 0, 0]])
print("causal:\n", causal_mask(4))
print("padding:\n", padding_mask(ids, pad_id=0).squeeze(1).squeeze(1))

代码展示了常见的 key padding mask 形状。某些 PyTorch API 接收 [B,T] 的 key_padding_mask,某些自定义实现接收可广播到 score 的四维 mask;不要只看变量名,先查调用方要求。把 mask 生成和转换集中在一个模块,能减少不同层各自“猜语义”的风险。

图 8.4 · 因果 Mask 的可见三角形自制示意:行是 Query,列是 Key
位置 0只能看 0
→
位置 1可看 0、1
→
位置 2可看 0、1、2
→
位置 3可看 0~3

上三角被屏蔽,训练可以并行,信息仍保持因果顺序。

Mask 的易错清单

第一,padding mask 与 causal mask 的逻辑与/或顺序;第二,batch 和 head 维度广播是否符合预期;第三,整行被屏蔽后是否产生 NaN;第四,loss mask 是否与 attention mask 混用;第五,推理缓存增长后 mask 的长度是否同步。每个错误都可以用长度 3~5 的小矩阵在 CPU 上复现。

调试时把 mask 画成 0/1 矩阵,并打印每行可见数量。看到未来位置出现非零注意力,先查 mask;看到 PAD 行有异常输出,再查池化、loss 和残差,而不是立即改学习率。

ATTN LAB 8.2.3组合两种 Mask

为 batch 中两条不同长度序列生成 padding mask,再与长度为 5 的 causal mask 合并。打印每个 Query 的可见 Key 数,确认 PAD 和未来位置都被屏蔽。

组合通常是逐元素逻辑与;padding mask 要广播到 [B,1,T,T],causal mask 广播到 batch/head。每条有效 Query 至少保留自己或一个有效 Key。
表 8.2D · 本大节完成标准
能力最低证据自测问题
Q/K/V能写来源、形状和职责自注意力与交叉注意力有什么不同
公式能手算缩放、softmax 和加权 Value为什么沿 Key 轴归一化
Mask能生成并组合 padding/causal mask未来位置为什么不能看

手算一次小注意力,比背公式更可靠

准备三个 Token、二维 Query/Key/Value,就可以手算完整流程。先把每个 Query 与所有 Key 做点积,得到一行 scores;再除以 √d,观察维度增大时数值为什么需要缩放;加入 mask 后,把不允许位置改成一个极小值;对剩余分数做稳定 softmax,得到和为 1 的权重;最后用权重对 Value 加权求和。手算的重点不是得到漂亮的小数,而是确认“每一行只从允许的列取信息”。

例如在因果场景,位置 2 可以读取位置 0、1、2,却不能读取位置 3。若把矩阵写成行是 Query、列是 Key,那么上三角是未来位置;若你的实现采用相反的轴,图案会转置。先在纸上标明行列含义,再写代码。很多“模型学不会”的根因,其实只是把上三角和下三角屏蔽反了。

缩放因子 1/√d 的直觉来自点积方差:独立、方差近似固定的 d 维分量相加后,点积的尺度会随 d 增大。分数过大时 softmax 会变得尖锐,梯度集中在少数位置;缩放让不同 head_dim 下的温度更可控。它不是为了让每行和为 1,那是 softmax 的职责;也不是为了减少参数,它只改变分数尺度。

表 8.2E · 一个注意力行的手算步骤
步骤得到什么检查问题
点积Query 与每个 Key 的相似度行是 Query 还是 Key
缩放分数除以 √dd 是否为每个 head 的维度
加 mask不允许位置变为极小值True/False 语义是什么
softmax非负权重,行和约等于 1是否有整行被屏蔽
加权和Value 的混合表示V 的最后两轴是否对齐
PYTHON · manual_attention.py
import math

def softmax(row):
    top = max(row)
    exps = [math.exp(value - top) for value in row]
    total = sum(exps)
    return [value / total for value in exps]

scores = [1.0, 2.0, -1e9]  # 第三个 Key 被 mask
weights = softmax([value / math.sqrt(4) for value in scores])
values = [[10, 0], [0, 10], [99, 99]]
output = [
    sum(weight * values[index][dim] for index, weight in enumerate(weights))
    for dim in range(2)
]
print(weights, output, sum(weights))

这个纯 Python 例子把“先减最大值”写出来,避免直接对大分数调用指数导致溢出。生产实现通常使用框架的稳定 softmax,并在混合精度下检查 dtype;教学手算则适合观察 mask 后的权重。第三个位置的权重应接近 0,输出只由前两个 Value 组成。

布尔 mask、加性 mask 和 padding mask 常被混在一起。布尔 mask 可能由 API 定义 True 表示禁止,也可能表示允许;加性 mask 通常在 softmax 前加 0 或负大值;padding mask 常按 [B,T] 表示每个样本的补齐位置。调用内置模块前必须查当前版本文档并写一个两行探针验证,不要根据变量名猜。

图 8.3 · 掩码决定信息边界自制矩阵:行是 Query,列是可读取的 Key

同一张矩阵也可叠加 padding 或跨序列边界,关键是先声明行列含义。

三种注意力场景如何区分

双向 self-attention 的 Query、Key、Value 来自同一序列,通常允许读取有效上下文;因果 self-attention 仍来自同一序列,但屏蔽未来位置,适合自回归生成;cross-attention 的 Query 来自目标序列,Key/Value 来自源序列,例如解码器读取编码器输出。三者的公式几乎相同,差别主要在输入来源和 mask。

padding 不是未来信息。一个 batch 里短样本被 PAD 补长,模型不应把 PAD 当作真实词;但某些实现只在 Key 侧屏蔽,Query 侧的 PAD 仍会产生输出,损失层还要另外忽略它。遇到“padding mask 已经有了,为什么 loss 还要 mask”的问题,答案是它们保护的阶段不同。

为了验证 mask 真正生效,可以使用“改动被屏蔽 Token”的反事实测试:固定 Query 位置,修改一个理论上不可见的未来 Token,比较输出是否保持不变;再修改一个允许读取的过去 Token,输出应可能发生变化。注意 dropout 会引入噪声,测试时使用 eval 或固定随机性。这个方法比只打印 mask 更接近行为验收。

PYTHON · mask_probe.py
def causal_allowed(query, key):
    return key <= query

def visible_columns(query, length, padding=None):
    padding = padding or set()
    return [
        key for key in range(length)
        if causal_allowed(query, key) and key not in padding
    ]

for query in range(4):
    print(query, visible_columns(query, 4, padding={3}))

当你切换到框架 API 时,把这个纯 Python 结果作为参考。比如长度 4、最后一列是 PAD 时,Query=2 的可见列应是 0、1、2;Query=3 若本身是 PAD,是否参与后续计算由模型与 loss 设计决定,但它不应贡献训练目标。把“允许读取”和“参与损失”分开写,是最值得保留的习惯。

ATTN LAB 8.2.4手算并验证一个 mask

用 3 个 Token、head_dim=4 的数值手算一行 attention,屏蔽最后一个 Key。再用代码输出权重,检查被屏蔽位置、行和和输出维度。

先缩放再加 mask 再 softmax;被屏蔽位置权重应接近零,其他权重和约为 1,输出最后一维等于 Value 的维度。若出现 NaN,检查是否整行都被屏蔽。
ATTN LAB 8.2.5做反事实可见性测试

实现一个小 attention,比较修改未来 Token 和修改过去 Token 对当前位置输出的影响。固定 eval 模式,记录最大绝对差并解释它。

未来 Token 的影响应接近 0(允许浮点误差),过去 Token 可能改变输出。若两者都不变,可能 mask 全屏蔽或权重没有接入;若未来也明显影响,检查三角方向。
表 8.2F · 注意力故障的优先排查
现象先检查不要先做
权重全相等Q/K 是否有变化、缩放和 softmax 轴立刻增大模型
出现 NaN输入 finite、整行 mask、指数稳定性只降低学习率
未来泄漏三角方向、行列含义、API 语义相信变量名
padding 影响输出Key mask、Query 处理、loss mask只删掉短样本

从注意力矩阵读出一个可验证问题

看到一张 attention heatmap 时,先问四个问题:它来自哪一层、哪一个 head、哪一个输入;横轴是 Key 还是 Query;颜色是 softmax 后权重还是加 mask 前分数;是否包含 padding、特殊 Token 和生成 cache。没有这些元数据,颜色深浅只能当作装饰。把热图转换成一个数值问题,例如“位置 4 的权重行和是否为 1”“未来列最大值是否低于容差”,解释才有落点。

注意力权重还会受到重复 Token、标点和模板的影响。一个分隔符被多个位置关注,不代表它具有抽象语义;一个词的权重低,也不代表它没有贡献,Value 经过多层变换后可能已经把信息写入其他位置。用遮挡、替换和层间对照补充热图,避免把相关性直接说成因果。

表 8.2G · 注意力图的元数据
字段必须记录缺失的后果
层/headlayer_index、head_index无法复现实验
Token文本、ID、位置颜色无法对应输入
mask因果/padding/cross误判可见性
数值阶段scores 或 softmax 权重把分数当概率

把 softmax 前后的数值分开记录

调试 attention 时同时打印 scores 的最小/最大值、mask 后的分数、权重行和以及输出范数。scores 是未归一化的匹配程度,可能为负或大于 1;weights 才是非负且按行归一化的概率量。若把二者混用,温度、mask 和梯度的诊断都会失真。对混合精度,额外记录 dtype 和是否出现下溢。

注意力输出也不必与输入大小相同的数值范围。经过 Value 加权、head 拼接和输出投影后,范数可能变大或变小;应观察残差相加前后的统计,而不是要求每个位置“看起来像原向量”。

03 · MULTI-HEAD & POSITION

多头与位置:让不同关系在同一层并行表达

单头注意力只有一张关系图,多头注意力把隐藏维拆成多个子空间,让不同头同时学习不同的匹配方式。本节进一步解释投影与拼接的形状,以及为什么没有位置表示时,模型很难区分“先后顺序”。

8.3.1 多头注意力:同一序列可以有多种“看法”

单个注意力头会生成一张 [T,T] 的权重矩阵,它可以关注主题、指代、修饰关系或局部相邻关系中的一种或多种模式,但容量有限。多头注意力把每个 Token 的 D 维表示投影到 H 个较小的子空间,在每个子空间中独立计算 Q/K/V,再把 H 个输出拼接并投影回 D。不同头不一定自动对应人类能命名的关系,却提供了并行表达多种匹配的机会。

头数增加不等于能力线性增加。若 D 固定,头数 H 增加时每个 head_dim=D/H 变小;过小的 head_dim 可能限制单头表达,过多的头也会增加管理和计算开销。配置必须满足 D 能被 H 整除,或者实现明确支持不等维度。报告里同时写 d_model、num_heads、head_dim,比只写“8 头”更有信息。

图 8.5 · 多头的并行关系视角自制示意:拆开计算,再合并表示
X[B,T,D]
→
线性投影Q/K/V
→
Head 1局部/词法
+
Head 2指代/主题
+
Head H另一子空间
→
Concat+Wₒ[B,T,D]

“Head 1/2”只是可能的观察角度,不能预先断言某个头一定代表某种语言关系。

表 8.3A · 单头与多头的比较
维度单头多头学习时要问
关系图一张注意力矩阵每头一张矩阵不同头是否互补
隐藏维全部 D拆成 H 个 d_headD/H 是否整除
输出直接得到 context拼接后过输出投影Concat 顺序是否一致
参数一组 Q/K/V可合并为大投影权重布局如何保存
解释关系较集中可做头级对照注意力权重不是充分因果解释
PYTHON · split_heads.py
import torch

def split_heads(x, num_heads):
    batch, tokens, model_dim = x.shape
    if model_dim % num_heads:
        raise ValueError("model_dim 必须能被 num_heads 整除")
    head_dim = model_dim // num_heads
    x = x.view(batch, tokens, num_heads, head_dim)
    return x.transpose(1, 2)

def merge_heads(x):
    batch, heads, tokens, head_dim = x.shape
    return x.transpose(1, 2).contiguous().view(batch, tokens, heads * head_dim)

x = torch.randn(2, 5, 16)
heads = split_heads(x, 4)
print(heads.shape, merge_heads(heads).shape)
assert torch.allclose(x, merge_heads(heads))

拆头把 [B,T,D] 变成 [B,H,T,d_head],这样 Q 与 K 的矩阵乘法可以在 head 维度上并行。合并前使用 contiguous() 是因为转置后张量的内存布局可能不连续,直接 view 会报错或产生误解。reshape 有时能自动处理布局,但仍要明确你想要的维度顺序。

头之间共享输入但不必共享投影参数。常见实现把 Q/K/V 的投影合并成一次 Linear,输出最后一维是 3D,再按顺序切成三块;也有实现使用三个独立 Linear。迁移权重时要对照 state_dict 和 forward,确认切分是 QKV 还是 Q、K、V 的其他排列。

头级分析要做对照而不是贴标签

可以统计每个头的注意力熵、局部/远距离比例、对不同 Token 类型的平均权重,再用遮掉一个头的消融实验观察任务指标变化。某个头关注标点,不代表它“只负责标点”;某个头权重很集中,也不代表它一定重要。头级可视化是诊断线索,结论仍需干预和任务评估。

当不同头输出几乎相同,可能是头冗余、数据不足、训练未收敛或可视化取错层。先固定输入和模型版本,检查 head 维度、mask 和权重提取位置,再决定是否剪枝或增加容量。

HEAD LAB 8.3.1验证拆头与合并

用 [2, 7, 24] 的 Tensor 测试 1、2、3、4、6 个头。记录每种 head_dim,并验证 merge(split(x)) 与原 Tensor 在数值上相同。

24 可以被 1、2、3、4、6 整除;每次输出应为 [2,H,7,24/H],合并回 [2,7,24]。还要测试 5 个头时是否给出明确错误。

8.3.2 维度、投影与拼接:形状是多头实现的主线

设模型维度为 D,头数为 H,单头维度 d=D/H。输入 X 经过 W_Q、W_K、W_V 得到三组 [B,T,D],再拆成 [B,H,T,d]。每个头产生 [B,H,T,d_v] 的上下文,沿最后一个维度拼接回 [B,T,H·d_v],最后通过 W_O 投影回 [B,T,D]。这个过程的目的不是改变 Token 数,而是把多个子空间的结果重新混合。

矩阵投影会把同一输入的不同线性组合送入不同的匹配空间。W_Q 决定当前位置怎样提问,W_K 决定候选怎样被匹配,W_V 决定传递哪些特征,W_O 决定多头结果怎样回到模型空间。初始化、偏置、权重共享和量化都可能改变实际行为,因此实现复现时要完整记录配置。

表 8.3B · 多头形状账本
张量形状轴的含义下一步
X[B,T,D]批次、Token、模型维Q/K/V 投影
Q/K/V[B,T,H·d]尚未拆头view+transpose
Q_head[B,H,T,d]每个 head 的查询QKᵀ
scores[B,H,T_q,T_k]每个头的关系图mask+softmax
context[B,H,T,d_v]加权 Valuetranspose+concat
output[B,T,D]回到模型空间残差/Norm
PYTHON · multihead_shapes.py
import torch
from torch import nn

class ShapeProbe(nn.Module):
    def __init__(self, model_dim=16, heads=4):
        super().__init__()
        self.heads = heads
        self.head_dim = model_dim // heads
        self.qkv = nn.Linear(model_dim, 3 * model_dim)
        self.out = nn.Linear(model_dim, model_dim)

    def forward(self, x):
        batch, tokens, dim = x.shape
        q, k, v = self.qkv(x).chunk(3, dim=-1)
        q = q.view(batch, tokens, self.heads, self.head_dim).transpose(1, 2)
        k = k.view(batch, tokens, self.heads, self.head_dim).transpose(1, 2)
        v = v.view(batch, tokens, self.heads, self.head_dim).transpose(1, 2)
        scores = q @ k.transpose(-2, -1)
        context = scores.softmax(dim=-1) @ v
        merged = context.transpose(1, 2).contiguous().view(batch, tokens, dim)
        return self.out(merged), scores

probe = ShapeProbe()
output, scores = probe(torch.randn(2, 5, 16))
print(output.shape, scores.shape)

这个探针故意省略缩放和 mask,只为了展示形状流。真实代码必须加入 / sqrt(head_dim) 和 mask;否则序列变长或低精度时容易出现过尖的分布。先让 shape probe 通过,再把数值细节逐项加回,是比直接复制完整模块更容易定位错误的做法。

多头的分数矩阵是内存大户,尤其在长序列和大 batch 下。训练时可以使用 fused attention、梯度检查点、混合精度或 FlashAttention 等优化,但优化前应先保留一个小规模、易读的参考实现,用它对照输出和 mask。参考实现不是生产速度方案,却是判断优化是否保持语义的基准。

把形状写进配置和日志

每次运行开头打印 model_dim、num_heads、head_dim、batch、query length、key length、dtype 和 mask shape。遇到矩阵乘法报错时,按最后两个维度从右往左对照:Q 的 d 必须等于 K 的 d,scores 的最后一维必须等于 V 的 Token 轴,context 的最后一维必须等于 V 的通道维。这样排错比看一大段 traceback 更快。

不要在中间随意 squeeze。batch=1 或 head=1 时,squeeze() 可能删除你仍然需要的维度,导致代码只在单样本时“看起来能跑”。优先使用带维度编号的 squeeze(dim=...),或者保持统一的四维表示。

HEAD LAB 8.3.2补全形状断言

为多头前向添加至少 8 个断言,覆盖 D 可被 H 整除、Q/K 最后一维一致、scores 的四维形状、mask 可广播、输出回到 [B,T,D] 和所有值为 finite。

断言应在 forward 早期失败,并包含实际 shape;不要只写一个最终输出断言。特别测试 B=1、H=1、Tq≠Tk 和空/全屏蔽边界。

8.3.3 位置表示:没有顺序,注意力看到的是一袋 Token

自注意力本身对输入位置做全连接匹配,如果只把 Token 向量集合交给它,交换序列中两个 Token 可能得到相同的集合关系。语言却高度依赖顺序:“狗咬人”和“人咬狗”包含相似词但意义不同。因此 Transformer 需要把位置或相对距离注入表示,使模型能区分第几个 Token、两个位置相隔多远以及方向。

绝对位置编码可以是固定的正弦/余弦函数,也可以是可学习的位置 Embedding;相对位置方法直接把位置差加入注意力分数或表示;RoPE 一类方法通过对 Q/K 做位置相关旋转,让相对位置信息体现在点积中。不同方案对上下文外推、缓存、长度上限和实现复杂度有不同影响,不能只按“更新”或“传统”评价。

表 8.3C · 位置表示方案
方案加入位置的地方优点注意事项
可学习绝对位置加到 Token Embedding简单、可训练通常有长度上限
正弦/余弦加到输入表示无需位置参数、规则明确外推不等于一定有效
相对位置偏置加到 attention score直接表达距离实现和桶化要固定
RoPE旋转 Q/K相对关系自然进入点积频率、缩放和长度外推需匹配
PYTHON · sinusoidal_position.py
import math
import torch

def sinusoidal_positions(length, dim):
    position = torch.arange(length, dtype=torch.float32).unsqueeze(1)
    index = torch.arange(0, dim, 2, dtype=torch.float32)
    rates = torch.exp(-math.log(10000.0) * index / dim)
    table = torch.zeros(length, dim)
    table[:, 0::2] = torch.sin(position * rates)
    table[:, 1::2] = torch.cos(position * rates)
    return table

pos = sinusoidal_positions(6, 8)
print(pos.shape)
print("distance 0/1:", torch.cosine_similarity(pos[0], pos[1], dim=0))

位置表的数值相似度不是“位置距离”的唯一含义;模型会结合它与 Token 表示以及后续投影共同学习。对于可学习位置 Embedding,长度超过初始化上限会直接越界;对于固定位置函数,能计算更长位置也不代表模型在长上下文上训练过。长度外推必须用专门的验证集测试。

位置表示的三个边界

第一是起点:位置从 0 还是 1 开始,BOS/EOS 是否占位置。第二是 padding:左填充和右填充会改变有效 Token 的位置,位置 ID 是否需要根据 mask 重排。第三是缓存:自回归生成时新 Token 的位置应接着历史长度增加,不能每一步从 0 重新编码。把位置 ID 打印出来,常能发现“模型突然重复”其实是位置错位。

比较位置方案时固定词表、数据、模型维度和训练预算,至少报告短序列与长序列的指标、长度截断率和推理显存。位置编码并非独立插件,它会影响注意力分数、KV cache 和可用上下文。

HEAD LAB 8.3.3交换 Token 的实验

构造两个只交换词序的句子,用无位置 Embedding 的简单自注意力和加入位置后的版本分别计算表示。观察哪些输出不变、哪些输出发生变化,并说明实验限制。

无位置的纯自注意力对输入排列具有置换等变/集合式特征,聚合后可能难以区分顺序;加入位置后表示包含位置信号。但小随机模型的数值差异不能证明语言理解,需要任务训练验证。
表 8.3D · 本大节完成标准
能力最低证据自测问题
多头能拆分、计算、拼接并解释 head_dim为什么头数增加不等于能力翻倍
形状能追踪 [B,T,D] 到 [B,H,T,d]scores 哪两个轴对应 Q/K
位置能比较至少两种位置方案为什么没有位置会丢顺序

多头不是把同一件事复制几遍

单头注意力把所有关系压进一个子空间,可能同时承担近邻、指代、分隔符和句法线索。多头做的是把 hidden 维切成多个较小子空间,每个 head 使用自己的 Q/K/V 投影,再把结果拼回去。不同 head 是否真的学出清晰“角色”不能预先保证,要通过权重、反事实输入和下游行为观察;“第 2 个 head 专门看动词”常常是方便的故事,而不是自动成立的事实。

头数增加时,若总维度 D 固定,每个 head_dim=d=D/H 变小,单个 head 的表达空间减少;若保持 d 不变,D 和参数量会一起增加。比较 head 数时要说明你固定的是 D、d、参数量还是计算预算,否则结论不公平。对小模型,8 个很窄的 head 不一定比 2 个较宽的 head 更好。

表 8.3E · 多头配置的公平比较
比较方式固定项变化项适合回答
固定 hiddenD、层数、FFNH 与 d 反向变化头的分工是否有益
固定 head_dimd、层数H 和 D 同时变化扩大模型的收益
固定参数预算近似总参数H、D、FFN 协同调整资源受限下的选择
固定训练预算steps、Token、seed结构收敛/泛化差异

位置表示解决的是“同一组 Token 换序后,模型如何知道顺序”。可学习绝对位置把位置 ID 映射为向量,简单且能适应训练长度;固定正弦位置不需要学习额外参数,并能提供不同频率的相对变化;相对位置或 RoPE 更直接地让距离/旋转关系进入注意力。它们的细节不同,但共同目标是让注意力区分“谁在前、谁在后、相隔多远”。

位置方法必须与缓存和长度扩展一起考虑。自回归生成时,KV cache 中的旧 Key/Value 对应旧位置,新 Token 不能重新从 0 开始编号;如果位置索引重置,模型会把续写误认为新序列。扩展上下文时也要验证训练长度外的行为,不要只修改一个 max_length 配置就宣称支持更长输入。

PYTHON · position_probe.py
import math

def sinusoidal(position, dim):
    values = []
    for index in range(dim):
        rate = 1 / (10000 ** (2 * (index // 2) / dim))
        value = math.sin(position * rate) if index % 2 == 0 else math.cos(position * rate)
        values.append(value)
    return values

for position in (0, 1, 8):
    vector = sinusoidal(position, 6)
    print(position, [round(value, 4) for value in vector])

探针可以比较相同 Token 在不同位置的 embedding,或把两段相同 Token 序列交换位置后比较输出。没有位置时,若 mask 和输入集合相同,模型更容易表现出置换不敏感;有位置后,顺序变化应能影响表示。实验不能只看一个输出,至少使用多条排列和固定 seed。

图 8.4 · 位置表示的三种注入方式自制示意:加到输入、加到分数或改变 Q/K 几何
Token 表示E(token)
+
绝对位置E(position)
→
输入残差流加法注入
或
相对偏置/RoPE注意力几何

方法不同,验收仍要回到顺序敏感、长度和缓存。

RoPE 与相对关系的直觉

可以把 RoPE 想成按位置对 Q、K 的二维子空间做不同角度的旋转。两个位置的相对角度会影响它们的点积,因此注意力更容易感知相对距离;实现上必须保证 Q/K 的维度分组、偶奇索引、位置编号和缓存偏移一致。不要把“有旋转”理解成模型已经知道自然语言中的距离,训练数据仍决定它如何使用这种几何。

相对位置偏置则直接给某些相对距离的分数加一个可学习或固定的偏置。它容易画成 [T,T] 的矩阵,却会增加长度相关的内存或计算。任何位置方案都要记录最大训练长度、推理长度、外推策略和失败样例。

PYTHON · head_shapes.py
def head_shapes(batch, tokens, hidden, heads):
    if hidden % heads != 0:
        raise ValueError("hidden 必须整除 heads")
    head_dim = hidden // heads
    return {
        "qkv": (batch, heads, tokens, head_dim),
        "scores": (batch, heads, tokens, tokens),
        "merged": (batch, tokens, hidden),
    }

for heads in (1, 2, 4, 8):
    print(heads, head_shapes(2, 16, 64, heads))

输出表能让你直观看到:heads 改变时 scores 的总元素数量在总 D 固定、T 固定时不变(B×H×T×T 中 H 仍会变,因此总 head 分数数目随 H 增长;但每个 head 的向量维度变小)。这就是为什么多头会改变计算布局和参数,而不只是一个显示上的拆分。

工程上还要注意 fused QKV。某些实现用一个 [D,3D] 线性层一次得到 Q/K/V,再 reshape 为三份;另一些实现使用三个独立线性层。两者在初始化、权重加载和 LoRA target modules 上的名字都不同。迁移 checkpoint 时先写映射脚本和 shape 对照,不要手动复制一部分权重后假设等价。

HEAD LAB 8.3.4公平比较头数

设计 H=1、2、4 的三组实验,分别说明是否固定 hidden、head_dim、参数量和训练 Token。为每组写出 shape、参数预算和评价指标。

必须明确公平标准;如果 hidden 固定,head_dim 会变化,不能把结果归因于头数一个因素。报告至少含验证 loss、生成、吞吐和显存。
POSITION LAB 8.3.5验证顺序敏感性

准备相同 Token 的三种排列,分别使用无位置、可学习位置和正弦位置。比较每种设置的输出差异,并解释训练前随机模型与训练后模型的区别。

无位置更接近置换不敏感,但 mask、边界和初始化仍可能造成差异;训练后位置表示是否被使用要用固定评估验证。不能只看一条样本。
表 8.3F · 位置与多头的验收问题
问题证据高风险误解
head 是否分工权重+反事实+行为凭一张热图命名角色
位置是否生效排列探针和长度测试只改配置不上实验
cache 是否正确全序列/增量输出对照位置从 0 重置
迁移是否一致权重键和 shape 映射忽略 fused QKV

位置方法的选择要写成工程约束

选择位置表示时,把训练长度、推理长度、缓存、批处理和迁移一起列出来。可学习绝对位置实现简单,但超出训练上限通常需要重新训练或外推策略;固定正弦位置参数少,却不自动解决长距离泛化;相对偏置和 RoPE 更贴近距离关系,但实现、量化和 cache 偏移有额外边界。没有一种方法在所有任务中无条件最好。

多头的调试还要检查 head_dim 的数值尺度。如果每个 head 太窄,QK 点积的表达空间不足;如果 head 太宽,单层参数和显存增加。使用统一的输入、初始化和随机种子,分别观察每个 head 的权重熵、有效位置数量和输出范数。权重熵很低可能表示尖锐关注,也可能表示 mask 或温度错误,必须结合反事实测试。

表 8.3G · 多头与位置的边界测试
测试预期暴露的问题
T=1无未来位置,输出 finite三角 mask 越界
T=max_length位置索引合法Embedding 上限
换序有位置时表示变化位置未接入
H 不整除 D初始化直接报错隐式截断/广播

头之间的比较要避免伪差异

比较两个 head 时,先对齐 Token 化、mask、层号和输入,再看权重熵、top attention 位置和输出对下游的影响。不同 head 的编号没有跨模型的固定语义,甚至同一模型在不同训练种子下也可能交换分工。报告使用“该 head 在此探针上更集中”这样的限定语句,比宣称“它就是语法 head”更准确。

04 · ENCODER

编码器:把整段输入变成上下文丰富的表示

编码器适合需要理解完整输入的任务:分类、抽取、相似度和作为生成模型的源端。它的自注意力通常允许位置之间双向读取,但仍会屏蔽 PAD。本节拆解一层 Encoder、残差与 LayerNorm、前馈网络和多层堆叠。

8.4.1 编码器层的顺序:先混合 Token,再重写特征

一个典型编码器层接收 [B,T,D],先进行多头自注意力。每个位置可以读取同一序列中其他非 PAD 位置的表示,得到新的上下文向量;随后通过残差连接与归一化稳定表示,再经过前馈网络对每个位置独立地进行两层线性变换和非线性激活,最后再次残差和归一化。输出形状仍是 [B,T,D],因此可以把多层串联。

编码器的“双向”不是说它忽略顺序,也不是说每个 Token 一定平均看遍整句。位置表示仍告诉模型顺序,padding mask 仍限制无效位置;双向只表示没有像解码器那样屏蔽未来 Token。输入序列完整可见时,句子中后面的信息可以帮助理解前面的词,这对分类和抽取很有用。

图 8.6 · Encoder Layer 的主路径自制示意:两个子层、两次残差
X[B,T,D]
→
Self-Attention双向、除 PAD
+
Residual + Norm稳定梯度
→
FFN逐位置非线性
+
Residual + Norm输出 [B,T,D]

“输出维度不变”让层可以堆叠,但每个子层承担的计算职责不同。

表 8.4A · Encoder Layer 的计算步骤
阶段输入→输出位置可见性主要目的
自注意力[B,T,D]→[B,T,D]所有有效位置跨 Token 交换信息
第一次残差/Norm[B,T,D]→[B,T,D]不新增读取保留输入并稳定尺度
前馈网络D→D_ff→D逐位置独立扩展通道并做非线性变换
第二次残差/Norm[B,T,D]→[B,T,D]不新增读取形成下一层输入
PYTHON · encoder_layer_probe.py
import torch
from torch import nn

class EncoderLayerProbe(nn.Module):
    def __init__(self, dim=16, heads=4, ff_dim=64):
        super().__init__()
        self.attn = nn.MultiheadAttention(dim, heads, batch_first=True)
        self.norm1 = nn.LayerNorm(dim)
        self.norm2 = nn.LayerNorm(dim)
        self.ffn = nn.Sequential(
            nn.Linear(dim, ff_dim),
            nn.GELU(),
            nn.Linear(ff_dim, dim),
        )

    def forward(self, x, key_padding_mask=None):
        attended, weights = self.attn(
            x, x, x, key_padding_mask=key_padding_mask,
            need_weights=True,
        )
        x = self.norm1(x + attended)
        x = self.norm2(x + self.ffn(x))
        return x, weights

x = torch.randn(2, 5, 16)
pad = torch.tensor([[False, False, False, True, True],
                    [False, False, False, False, True]])
y, weights = EncoderLayerProbe()(x, pad)
print("output:", y.shape, "weights:", weights.shape)

PyTorch 的 key_padding_mask 通常用 True 表示对应 Key 位置应被忽略,这与本章自定义布尔 mask 的“True 可见”约定相反。使用 API 时必须查清参数语义,并在适配层统一转换。示例返回注意力权重只是为了观察,生产推理通常关闭它以节省内存。

编码器可以在输入端加入 dropout,也可以在 attention 和 FFN 的输出上加入。训练模式下 dropout 随机丢弃部分激活,评估模式下关闭;如果忘记 model.eval(),相同输入多次推理可能不同。先用固定输入检查评估确定性,再讨论泛化。

把“双向”解释成一条可测试的规则

取一个长度为 5 的输入,在位置 1 改变位置 4 的 Token,比较编码器位置 1 的输出;如果 mask 允许双向读取,输出有机会变化。再给位置 4 加 padding mask,改变它不应影响有效位置的注意力读取。这个实验不能证明模型学会了语义,但能验证 mask 和信息边界。

编码器输出可以取首 Token、平均池化或所有位置交给任务头。选择哪一种取决于训练目标,不能默认某个位置天然代表整句。输出契约应写清池化、mask 和是否归一化。

ENC LAB 8.4.1验证双向可见性

构造同一序列的两个版本,只改变最后一个有效 Token。比较编码器第一、第二个位置的输出;再把最后一个位置标记为 PAD,观察差异如何变化。

未屏蔽时前面位置可能受到后面 Token 影响;屏蔽后这种影响应显著减弱。由于权重随机和层结构不同,重点是检查 mask 逻辑,不是追求某个固定数值。

8.4.2 残差与归一化:让深层网络有稳定的梯度路径

残差连接把子层输入与子层输出相加,形式是 x + F(x)。如果 F 学到的是一个小修正,网络仍能保留原表示;如果任务需要大变化,训练可以逐层累积。残差还为反向传播提供直接路径,减轻深层网络中梯度逐渐变小或爆炸的问题。前提是两条分支形状一致,且数值尺度可控。

LayerNorm 通常沿最后一个隐藏维度对每个样本、每个位置独立归一化,再用可学习的缩放和偏移恢复表达能力。它与 BatchNorm 不同,不依赖 batch 中其他样本的统计,因此更适合变长序列和小 batch。eps 防止方差接近零时除零,dtype 和精度会影响统计稳定性。

Pre-LN 与 Post-LN 是常见的两种层结构。Pre-LN 先归一化再进入子层,残差路径更直接;Post-LN 先相加再归一化,早期原始 Transformer 常见。两者的初始化、学习率、深度和训练稳定性可能不同。不要把论文里的公式和库里的类名直接等同,先读 forward 顺序。

表 8.4B · 残差与 Norm 的易错点
问题正确检查常见误操作
残差相加两侧 [B,T,D] 一致reshape 凑维度
LayerNorm 轴通常归一化最后一维 D把 Token 轴一起归一化
eps小但非零,符合实现为消除 NaN 随意调很大
Pre/Post 顺序按代码和配置记录只看模块名字猜结构
train/evaldropout 和统计状态正确验证时仍随机
PYTHON · norm_residual_probe.py
import torch
from torch import nn

x = torch.randn(2, 4, 8)
branch = torch.randn_like(x) * 0.1
pre_norm = nn.LayerNorm(8)
post_norm = nn.LayerNorm(8)

pre = x + branch
pre = pre_norm(pre)
post = post_norm(x + branch)

for name, value in [("input", x), ("pre", pre), ("post", post)]:
    print(name, "mean", value.mean().item(),
          "std", value.std(unbiased=False).item())
assert pre.shape == post.shape == x.shape

对照实验显示归一化后的每个位置通道均值和标准差更受控制,但不要把全张量的总体统计误当成每个位置都完全相同。LayerNorm 的可学习参数、eps 和输入分布都会影响实际结果。调试时可按 batch、Token 和 hidden 轴分别查看统计。

梯度路径的纸笔推导

对 y=x+F(x),反向梯度包含来自 y 对 x 的直接项和经过 F 的项;即使 F 的梯度暂时很小,直接项仍能传递信号。这个简单推导解释了残差为什么有助于深层训练,但不代表任何深度都稳定。注意力分数、FFN 激活、归一化和学习率仍可能造成爆炸或 NaN。

看到损失突然变成 NaN 时,检查顺序可以是:输入和标签是否 finite、mask 是否整行屏蔽、attention score 是否溢出、LayerNorm 输入方差是否异常、梯度范数是否过大、学习率是否改变。把每一步的最小复现保存下来,才能知道是数据还是数值路径。

ENC LAB 8.4.2比较 Pre-LN 与 Post-LN

用同一个随机输入和分支,分别实现 Pre-LN、Post-LN 两种结构。打印输出均值、标准差和输入梯度范数,并写下你观察到的差异。

两种结构的 shape 应相同,统计量可能不同;实验重点是理解顺序和梯度路径,不要仅凭一次随机运行判断哪种结构绝对更好。

8.4.3 前馈网络与堆叠:逐位置扩大通道,再逐层积累抽象

Transformer 中的前馈网络(FFN)通常对每个位置使用相同的两层线性变换:D → D_ff → D,中间经过 ReLU、GELU 或其他激活。它不会把位置 i 的输入与位置 j 混合,跨位置的信息交换已经由注意力完成;FFN 更像是对每个位置的通道进行“思考”和重编码。参数在所有 Token 位置共享,因此序列长度增加不会新增 FFN 参数。

D_ff 常是 D 的数倍,带来大量参数和计算。激活函数决定负值、平滑性和梯度形状;dropout 或门控结构可以改变正则化和表达方式。现代语言模型常使用门控 FFN 变体,例如把一个分支作为门控制另一个分支,但学习时先把两层线性 FFN 跑通,再分析门控带来的收益。

堆叠层数会增加有效感受野和变换深度,却也提高显存、延迟和训练难度。每层的注意力权重、激活和梯度都可能占内存;训练时可以使用梯度检查点、混合精度或分片,但优化前必须用小模型建立参考输出。层数不是越多越好,要与数据量、目标和可用资源匹配。

表 8.4C · FFN 的配置关系
参数示例影响检查
d_model512输入/输出隐藏维残差维度
d_ff2048中间通道容量显存与参数量
activationGELU非线性与梯度实现/版本
dropout0.1训练正则化eval 是否关闭
layers6堆叠深度层间输出与梯度
PYTHON · ffn_probe.py
import torch
from torch import nn

class PositionwiseFFN(nn.Module):
    def __init__(self, dim, hidden_dim, dropout=0.0):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(dim, hidden_dim),
            nn.GELU(),
            nn.Dropout(dropout),
            nn.Linear(hidden_dim, dim),
        )

    def forward(self, x):
        return self.net(x)

x = torch.randn(2, 6, 16)
ffn = PositionwiseFFN(16, 64)
y = ffn(x)
print(x.shape, y.shape)
assert y.shape == x.shape

如果把序列中的两个位置交换,单独的 FFN 会对交换后的向量逐位置做同样的变换,因此它本身不提供顺序信息;注意力和位置表示才负责跨位置关系。这个性质可以用来设计单元测试:固定 FFN 权重,逐个位置独立计算,批量输出应与循环版本一致。

图 8.7 · FFN 与层堆叠的角色自制示意:注意力混合,FFN 变换
每个位置 x_t[D]
→
LinearD→D_ff
→
GELU/门控非线性
→
LinearD_ff→D
→
残差输出[D]

同一 FFN 参数应用到所有 Token 位置,跨位置的交换由注意力完成。

层数增加前先做三项预算

先预算显存:参数、优化器状态、激活和梯度分别占多少;再预算时间:单 batch 前向、反向和数据等待各占多少;最后预算数据:训练 Token 数是否足以支撑新增参数。只有知道瓶颈,才有必要选择更深模型或优化内核。

堆叠测试可以让每层返回一个摘要:输出范数、均值、标准差、最大值和梯度范数。若某层开始出现异常,二分层数就能快速定位。不要把所有层的完整激活都打印到日志,这会让调试本身成为新的内存问题。

ENC LAB 8.4.3证明 FFN 逐位置共享

写一个循环版 FFN 和一个批量版 FFN,使用同一组权重处理 [2, 4, 8] 输入,验证每个位置输出一致。再交换两个 Token,观察 FFN 本身不会建立新的位置关系。

循环版可以对 x[b,t] 调用同一个模块,批量版直接传整个 Tensor;在无 dropout 或 eval 模式下两者应接近。交换位置后输出也按相同位置交换,说明 FFN 不负责跨位置混合。
表 8.4D · 本大节完成标准
能力最低证据自测问题
Encoder能按顺序解释注意力、残差、Norm、FFN哪一步允许双向读取
稳定训练能说明 Pre/Post-LN 和残差作用NaN 时先查什么
FFN能证明逐位置共享且维度回到 D为什么需要扩大 d_ff

Encoder 的“看全局”要有边界

Encoder 常被概括为双向上下文,但“全局”不意味着无条件看见所有字节。padding、段边界、文档权限和局部窗口都可能限制可见范围;在分类、检索或表征学习任务中,输入里的每个有效 Token 可以读取其他有效 Token,而 PAD 仍应被屏蔽。把双向理解为“没有 causal mask”还不够,还要写清楚哪些非因果 mask 仍然存在。

Encoder 输出一般保留每个位置的向量 [B,T,D],下游任务再选择 pooling、特殊 Token 或逐位置 head。Mean pooling 必须用 attention mask 排除 PAD,否则短样本会被补齐向量稀释;取首 Token 则要求模板在开头放置稳定的聚合标记;逐 Token 标注则不能把所有位置压成一个向量。下游 head 的选择应由标签粒度决定。

表 8.4E · Encoder 输出接下游任务
任务表示聚合方式易错点
句子分类[B,T,D]masked mean 或特殊 TokenPAD 参与平均
句对匹配两段或拼接序列pool 后拼接/差值段边界丢失
序列标注每个位置向量逐位置线性层标签与 Token 错位
检索向量归一化 embedding固定 pooling训练/推理 pooling 不同

Pre-LN 和 Post-LN 不只是代码位置不同。Pre-LN 先归一化再进入子层,残差路径更直接,深层训练通常更容易稳定;Post-LN 在子层输出与残差相加后归一化,原始论文风格常见,但初始化和学习率更敏感。选择一种后,配置、权重加载和测试都要一致。迁移 checkpoint 时若只改了一行 Norm 顺序,模型可能仍能运行,却已经不是同一个函数。

FFN 的“逐位置”可以写成 W2 σ(W1 x + b1)+b2。同一组 W1/W2 作用于每个 Token,但不同位置的 x 不同,所以输出不同;它不是把所有 Token 独立训练成不同参数,也不是卷积。扩展维度 Dff 提供中间表达空间,激活函数影响梯度和稀疏性。对小模型,先固定 Dff≈4D 建立基线,再做单变量消融。

PYTHON · masked_pooling.py
import torch

def masked_mean(hidden, attention_mask):
    # hidden: [B,T,D], mask: [B,T]
    weights = attention_mask.to(hidden.dtype).unsqueeze(-1)
    total = (hidden * weights).sum(dim=1)
    count = weights.sum(dim=1).clamp_min(1)
    return total / count

hidden = torch.arange(2 * 4 * 3, dtype=torch.float32).reshape(2, 4, 3)
mask = torch.tensor([[1, 1, 0, 0], [1, 1, 1, 0]])
print(masked_mean(hidden, mask).shape)

平均池化示例里的 clamp_min(1) 是为了防止全 PAD 样本除零,但更好的数据层策略是拒绝空样本并记录原因。pooling 的 mask shape 要能广播到 [B,T,D];如果不小心使用 [T,B],结果可能仍可广播到某个维度,因而必须在测试中检查两条样本的手算结果。

图 8.5 · Encoder 表征的两条出口自制示意:句子级与 Token 级任务
Encoder 输出[B,T,D]
→
masked pooling[B,D]
→
句子 head分类/检索
或
逐位置 head[B,T,C]
→
序列标签NER/抽取

标签是句子级还是 Token 级,决定是否聚合。

堆叠层数与信息混合

第一层 Encoder 可能先捕捉局部邻近关系,后续层逐步组合更远的模式,但不能把固定层数直接对应成固定语义。每层的注意力权重、FFN 输出和残差流都依赖输入与训练状态。观察层间表示时,使用相同探针和相同归一化方式;不要用不同的 dropout 样本比较层差异。

层数增加会提高计算、显存和优化难度。先用两层验证数据和 mask,再逐步增加层数;每次保存参数量、训练速度、梯度范数和验证指标。若深层 loss 变 NaN,先检查 Pre/Post-LN、初始化、学习率、混合精度和 mask 空行,不要只把 batch 调小。

PYTHON · encoder_contract.py
def check_encoder_contract(hidden, mask, num_classes):
    if hidden.ndim != 3:
        raise ValueError("Encoder 输出必须是 [B,T,D]")
    batch, tokens, dim = hidden.shape
    if mask.shape != (batch, tokens):
        raise ValueError("attention_mask 必须是 [B,T]")
    if not hidden.isfinite().all():
        raise ValueError("Encoder 输出含 NaN/Inf")
    logits = hidden.new_zeros(batch, tokens, num_classes)
    return logits.shape

# 真实调用时把 hidden 换成 Encoder 的 Tensor。
print("contract: [B,T,D] -> [B,T,C]")

契约测试让下游 head 不必了解 Encoder 内部。可以再加入“交换 batch 顺序,输出也按同样顺序交换”的测试,排除跨样本注意力;加入“PAD 的值任意改变,masked pooling 不变”的测试,确认 padding 处理完整。测试越小越容易指出哪个边界被破坏。

ENC LAB 8.4.4选择正确的 pooling

为句子分类、序列标注和检索各选择一种 Encoder 输出处理方式,写出输入/输出 shape、mask 需求和一个失败案例。

句子分类可用 masked mean 或特殊 Token;序列标注保留 [B,T,D];检索要固定 pooling 并归一化。所有变长任务都要说明 PAD 如何排除。
ENC LAB 8.4.5比较 Pre-LN 与 Post-LN

在相同两层模型、数据、seed 和训练预算下比较两种 Norm 顺序,记录 loss 曲线、梯度范数和最终生成/分类结果,说明你观察到的限制。

要确认实现确实只改变 Norm 顺序;结果可能因小模型和初始化而不同。报告不要把一次实验概括成普遍规律,写出重复种子或后续验证计划。
表 8.4F · Encoder 章节自测
能力证据常见误区
解释双向说清 causal 与 padding 的区别以为“全可见”没有边界
做 pooling能手算 masked meanPAD 参与平均
读 FFN解释逐位置与参数共享把 FFN 当成 Token 混合
调深层能按顺序查 Norm/梯度/mask一味缩小 batch

Encoder 与下游 head 的接口要固定

当 Encoder 用于分类时,head 只应接收一个明确的 [B,D] 表示;用于序列标注时,head 接收 [B,T,D],标签也应是 [B,T] 并对 PAD 使用 ignore_index;用于检索时,归一化位置、批内负样本和相似度函数都要固定。训练时用 mean pooling、推理时却取首 Token,会造成指标下降却难以解释。把 pooling 写进配置和模型 forward,而不是散落在脚本里。

句对输入常用段 Token、分隔符或不同的 position/segment 标记,目的都是让模型知道边界。若把两个句子直接拼起来而不记录边界,Encoder 仍然可以输出向量,但下游任务可能把跨句关系误当作句内关系。准备一个交换句子顺序的测试,观察模型是否按任务预期改变结果。

表 8.4G · 下游接口的 shape 合同
下游 head输入 shape标签 shapemask 处理
分类[B,D][B]pool 前排除 PAD
序列标注[B,T,D][B,T]PAD label=-100
检索[B,D]配对/对比固定 pooling+normalize
问答 span[B,T,D]start/end [B]截断边界可追溯

Encoder 练习的输出要可视化

至少输出一条样本的 Token、mask、pooling 权重和最终向量范数;再把同一文本增加 PAD 后重复测试,确认 masked pooling 不变。可视化不是替代断言,而是让初学者看到“补齐位置没有监督,却可能影响错误实现”的具体证据。

05 · DECODER

解码器:在因果边界内一步步生成 Token

解码器负责把已有前缀变成下一个 Token 的分布。它既要保证不能偷看未来,又可能通过交叉注意力读取编码器或检索到的源信息。本节从 causal mask、自回归循环和 Teacher Forcing 开始,建立训练与生成之间的联系。

8.5.1 因果掩码:训练可以并行,信息却必须按时间可见

语言模型的目标通常是根据前面的 Token 预测下一个 Token。训练时为了效率,会把一整段目标序列一次送入模型,计算所有位置的 logits;如果没有因果 mask,第 t 个位置可以读取真实的未来 Token,损失会虚高,模型学到的不是生成规则。causal mask 把 score 矩阵的上三角屏蔽,让位置 t 只能看见 0 到 t 的输入。

解码器层通常先做带 causal mask 的自注意力,再做交叉注意力(若有源序列),最后是 FFN。自注意力维护目标前缀内部的依赖,交叉注意力让目标位置读取源序列表示。纯 decoder-only 模型没有编码器,通常把提示和已生成内容放在同一条因果序列中;encoder-decoder 模型则有明确的源端和目标端。

图 8.8 · 解码器的可见性边界自制示意:每一步只允许读取过去
目标前缀y₀…y_t
→
Causal Self-Attn遮掉未来
→
Cross-Attn可选源序列
→
FFN + Norm上下文输出
→
LM Headlogits for y_{t+1}

并行计算不等于同时看未来;mask 维持了每个位置的因果合同。

表 8.5A · Decoder 的两种注意力
模块Q 来源K/V 来源Mask
Masked self-attention目标序列目标序列causal + target PAD
Cross-attention目标隐藏状态源/编码器输出source PAD/权限
LM head最终隐藏状态词表权重loss mask
PYTHON · causal_decoder_mask.py
import torch

def make_causal(length, device=None):
    rows = torch.arange(length, device=device)[:, None]
    cols = torch.arange(length, device=device)[None, :]
    return cols <= rows

mask = make_causal(5)
print(mask.to(torch.int8))
assert mask[3, 0] and mask[3, 3]
assert not mask[3, 4]

注意行列约定:这里行是 Query,列是 Key,因此第 3 行允许列 0~3。若你的实现把矩阵轴交换,三角方向也要随之改变。最可靠的测试不是看一张图,而是为每个位置列出可见索引,验证位置 t 绝不会看到 t+1。

交叉注意力的边界

交叉注意力没有目标侧的 causal 约束,因为目标当前位置可以读取完整源序列;它仍需屏蔽源端 PAD、无权文档或不存在的 Token。若把 source mask 错当成 target mask,模型可能把注意力集中在空白源位置,或者在不同长度样本之间泄漏信息。

decoder-only 模型也会遇到“源信息”问题:系统提示、用户问题、检索片段和答案都在同一条序列中,依靠段落边界和因果 mask 控制可见性。数据模板变化会改变训练目标,必须记录角色 Token 和 label mask。

DEC LAB 8.5.1列出每个位置的可见范围

长度为 6 的目标序列,分别写出位置 0、2、5 可以看到的 Key 索引。再加入两个 PAD,说明 causal 与 padding mask 如何同时生效。

位置 0 只能看 0,位置 2 看 0~2,位置 5 看 0~5;PAD 位置无论是否在未来,都不应作为有效 Key。组合 mask 需要明确 batch 广播。

8.5.2 自回归生成:从 logits 选择下一个 Token

训练好的 decoder 在推理时没有完整答案,只能从起始 Token 开始循环:把当前前缀送入模型,取最后一个位置的 logits,根据解码策略选择下一个 ID,追加到前缀,直到遇到 EOS 或达到最大新 Token 数。每一步都依赖前一步的选择,因此一个早期错误可能改变后续分布,这也是训练和生成存在差异的原因。

最简单的策略是 greedy decoding,每次选择概率最高的 Token,速度快但容易重复或陷入局部最优。temperature 在 softmax 前缩放 logits:温度低,分布更尖锐;温度高,探索更多。Top-k 只保留概率最高的 k 个候选,Top-p(nucleus)保留累计概率达到 p 的最小集合。采样策略必须记录参数和随机种子,否则同一提示可能得到不同结果,无法复盘。

停止条件至少包括 EOS、最大新 Token 数和异常/空候选。不要把最大长度写成无限循环;如果模型一直输出重复 Token,可以检测 n-gram 重复、无效标点或长度预算并返回明确原因。生成结果要同时保存 prompt、生成 IDs、解码文本、策略、模型版本和停止原因。

表 8.5B · 常见解码策略
策略选择方式优点风险
Greedyargmax确定、快速重复、缺少探索
Temperature调整 logits 温度控制随机性高温引入噪声
Top-k只从 k 个候选采样限制极低概率词k 固定不适合所有步
Top-p从累计概率集合采样候选数随分布变化实现边界需测试
Beam search保留多条前缀适合部分序列任务成本高、未必更自然
PYTHON · sample_next_token.py
import torch

def choose_token(logits, temperature=1.0, top_k=None):
    logits = logits.float()
    if temperature <= 0:
        raise ValueError("temperature 必须为正")
    scores = logits / temperature
    if top_k is not None:
        values, indices = torch.topk(scores, min(top_k, scores.numel()))
        filtered = torch.full_like(scores, torch.finfo(scores.dtype).min)
        filtered.scatter_(0, indices, values)
        scores = filtered
    probs = torch.softmax(scores, dim=-1)
    return torch.multinomial(probs, num_samples=1).item()

torch.manual_seed(7)
logits = torch.tensor([2.0, 1.5, 0.2, -1.0])
print(choose_token(logits, temperature=0.8, top_k=3))

采样函数应接收一维最后位置 logits,不能把整个 [B,T,V] 张量直接 flatten 后采样。批量生成时每条样本可能在不同时间结束,需要维护 finished mask,并对已结束样本停止追加或填充 PAD。温度、Top-k、Top-p 的参数不能同时被隐式覆盖;配置中写出优先级或互斥关系。

KV Cache 为什么能加速生成

如果每一步都把完整前缀重新送入 decoder,历史 Token 的 Key 和 Value 会被重复计算。KV cache 保存已经计算过的 K/V,新 Token 只计算自己的 Q/K/V,并与缓存的历史 K/V 做注意力。缓存让每一步的新增计算更小,但需要同步位置、mask、batch 中已结束样本和显存预算。

缓存改变的是计算路径,不应改变参考实现的结果(允许精度微小差异)。用短序列对照“无 cache”和“有 cache”的 logits,检查每一步相同,再测试长度、batch 和 EOS 边界。缓存键必须包含模型、位置编码、dtype 和序列状态。

DEC LAB 8.5.2比较三种生成策略

为同一组 logits 运行 greedy、temperature=0.7、top-k=2 三种策略,固定 seed 运行两次。记录 Token、停止条件和是否重复,并说明哪些差异来自随机采样。

greedy 两次应相同;采样策略需固定 seed 才能复现。比较时同时记录 logits 版本和过滤参数,不能只截一张生成文本截图。

8.5.3 Teacher Forcing:训练输入和目标输出要错开一位

自回归训练通常把目标序列右移一位:输入是 [BOS, y₀, y₁, …, y_{T-2}],标签是 [y₀, y₁, …, y_{T-1}, EOS]。模型在每个位置看到真实的前缀并预测下一个 Token,这叫 Teacher Forcing。它允许整段并行训练,但推理时模型看到的是自己生成的前缀,二者分布不同,可能出现 exposure bias。

标签对齐是最常见的 decoder bug 之一。logits [B,T,V] 应与 labels [B,T] 配对,CrossEntropyLoss 通常把 logits 展平为 [B·T,V]、labels 展平为 [B·T];PAD 和 prompt 中不需要学习的部分要用 ignore_index 或 label mask 排除。若错一位,损失仍可能下降,却学成“复制当前 Token”或在错误位置预测。

Encoder-decoder 训练时,decoder 输入是右移后的目标,cross-attention 读取源端;decoder-only 指令数据则要为用户输入、助手输出和分隔符设计 label mask,通常只对助手答案计算损失。模板、角色 Token、EOS 和截断策略都属于训练合同,不能只在推理脚本里临时拼接。

表 8.5C · Teacher Forcing 的对齐关系
位置 tdecoder 输入训练标签推理时看到
0BOS/提示起点第一个目标 Token用户提供的前缀
1真实 y₀y₁模型自己生成的 y₀
t真实 y_{t-1}y_t自己生成的历史
结束真实前缀EOSEOS 或 max_new_tokens 停止
PYTHON · shift_labels.py
import torch

def make_teacher_forcing(batch_ids, pad_id, ignore_index=-100):
    decoder_input = batch_ids[:, :-1]
    labels = batch_ids[:, 1:].clone()
    labels[labels == pad_id] = ignore_index
    loss_mask = labels != ignore_index
    return decoder_input, labels, loss_mask

ids = torch.tensor([[2, 8, 9, 3, 0],
                    [2, 7, 3, 0, 0]])
decoder_input, labels, mask = make_teacher_forcing(ids, pad_id=0)
print(decoder_input)
print(labels, mask)
assert decoder_input.shape == labels.shape == mask.shape

示例只展示右移和 PAD 忽略。正式训练还要决定 BOS/EOS 是否进入 loss、prompt 哪些位置 ignore、标签是否含有未知 Token、截断后是否仍保留 EOS。把一条样本的输入、标签、loss mask 和 decode 文本打印出来,通常可以在训练前发现错位。

减少训练—推理差异的思路

可以在训练后期逐步减少 Teacher Forcing、使用 scheduled sampling,或直接用更接近推理的偏好/强化学习目标,但这些方法有额外复杂度和稳定性风险。基础阶段先把标准 Teacher Forcing、因果 mask、label mask 和生成循环实现正确,再用固定评估集观察 exposure bias 的实际影响。

生成质量还受解码策略、重复惩罚、停止规则和上下文长度影响。不要把一次生成失败归因给 Teacher Forcing;先复现 prompt、Token、mask、logits 和策略,按链路定位。

DEC LAB 8.5.3找出一位错位

给序列 [BOS, 我, 学, Python, EOS] 写出 decoder_input、labels 和 mask。再故意让 labels 不右移,观察模型在位置 0 实际被要求预测什么。

正确输入是 [BOS, 我, 学, Python],标签是 [我, 学, Python, EOS];PAD 或不计损失的位置用 ignore_index。未右移时会要求位置 0 预测 BOS/自身,训练目标错位。
表 8.5D · 本大节完成标准
能力最低证据自测问题
因果边界能生成并测试 causal mask训练如何并行却不偷看未来
生成能实现停止条件和一种采样策略temperature 改变了什么
对齐能写出右移输入、labels、loss mask推理为什么与 Teacher Forcing 不同

自回归生成是一个循环,不是一次分类

给定前缀后,模型输出最后一个位置的 logits,解码器把它变成下一个 Token,再把新 Token 接回输入继续计算。这个循环使推理长度和成本逐步增长,也使 EOS、最大新 Token、停止词和用户取消都成为服务合同的一部分。训练时可以并行计算整段 labels,推理时却必须按步生成;两者的 mask 和位置编号要保持一致。

贪心解码每次选最大概率,稳定但容易重复;temperature 改变分布尖锐程度,低温更确定,高温更发散;top-k 只在前 k 个候选中抽样,top-p 按累计概率截断候选集合。采样参数不是“越大越有创造力”的旋钮,而是质量、可控性、重复和安全风险的取舍。评估时先固定 greedy 基线,再一次只改变一个参数。

表 8.5E · 解码策略与适用场景
策略特点可复现性风险/注意
Greedy每步取最大 logit高重复、局部最优
Temperature缩放 logits 后采样需 seed高温会放大尾部噪声
Top-k固定数量候选需 seedk 不适合所有分布
Top-p按累计概率动态截断需 seed候选数随上下文变化
Beam search保留多条候选路径较高成本高,可能重复

KV cache 的作用是保存已经计算过的 Key/Value。没有 cache 时,每生成一个 Token 都把整个前缀重新送入注意力;有 cache 时,新 Query 只与历史 K/V 拼接后计算。cache 的 shape 通常包含 batch、heads、past_length、head_dim,长度随生成增长,显存预算必须计入。cache 不能保存 Query,因为下一步需要新的 Query;也不能在 beam 或 batch 重排时忘记同步索引。

PYTHON · decode_loop.py
import torch

def greedy_decode(model, input_ids, eos_id, max_new_tokens):
    model.eval()
    generated = input_ids
    for _ in range(max_new_tokens):
        logits = model(generated)
        next_id = logits[:, -1, :].argmax(dim=-1, keepdim=True)
        generated = torch.cat([generated, next_id], dim=1)
        if bool((next_id == eos_id).all()):
            break
    return generated

这个最小循环故意没有 cache,便于验证逻辑。先确认它与训练时的位置、EOS 和 tokenizer 一致,再优化。优化后的 cache 版本应与无 cache 版本在 eval、greedy、短序列上逐 Token 对照;若数值不完全相同,设置容差并检查位置偏移、拼接顺序和 dropout。

Teacher Forcing 在训练时把真实前一个 Token 提供给模型,因而可以并行处理序列;推理时模型只能看到自己生成的历史。训练/推理差距会导致 exposure bias:模型一旦生成错误 Token,后续上下文也被污染。小项目不必立刻解决所有问题,但要在报告中区分“训练 loss 很低”和“自由生成稳定”这两种证据。

图 8.6 · 训练并行与推理循环自制对照:同一模型面对两种数据流
训练序列真实前缀
→
并行 logits[B,T,V]
→
shift loss一次统计
对比
推理前缀生成 Token
→
拼回循环逐步到 EOS

训练快不代表生成一定稳定,必须单独验收。

停止条件是产品逻辑的一部分

生成 API 至少要支持 EOS、最大新 Token、最大总长度、停止字符串、超时和用户取消。停止词可能跨越两个 Token,不能只检查单个 ID;流式输出还要缓存少量尾部文本,确认停止词后不把它继续发送给用户。对批量生成,某个样本先结束时可以标记 finished 并停止更新它的 cache,但不能让它的 PAD 影响其他样本的 attention 或 loss。

重复控制(如 repetition penalty、no-repeat n-gram)要谨慎使用。它们可能降低机械重复,却也可能破坏代码、公式或必要的术语重复。先在固定样例上记录原始输出,再逐一加入规则,说明规则的目标和副作用。安全策略不要只靠解码惩罚,危险请求仍需模型/应用层的策略判断。

PYTHON · shift_labels.py
def make_lm_batch(ids, pad_id=0):
    input_ids = ids[:, :-1]
    labels = ids[:, 1:].clone()
    labels[labels == pad_id] = -100
    loss_mask = labels.ne(-100)
    if not bool(loss_mask.any()):
        raise ValueError("batch 没有有效目标")
    return input_ids, labels, loss_mask

# 真实 Tensor 示例:最后一列 PAD 不应产生损失。
print("labels 的位置必须与 logits[:, :-1] 对齐")

标签移位要把“输入看见什么”和“目标计算什么”写在同一张表里。若序列为 [BOS, 我, 学, Python, EOS, PAD],输入可为前五个,目标为后五个;PAD 目标改为 -100。不同框架的 ignore_index 默认值可能不同,显式传入并在一个小 batch 上手算平均分母。

PYTHON · cache_contract.py
import torch

def append_kv(past_key, past_value, new_key, new_value):
    if past_key is None:
        return new_key, new_value
    if past_key.shape[:2] != new_key.shape[:2]:
        raise ValueError("batch/head 维度不一致")
    key = torch.cat([past_key, new_key], dim=-2)
    value = torch.cat([past_value, new_value], dim=-2)
    return key, value

# key/value 约定为 [B,H,T,d],沿 T 轴增长。

cache 合同把增长轴明确写成倒数第二维。服务化时还要考虑不同请求的前缀长度、batch 合并、显存回收和最大 cache 长度;学习阶段只需验证“全序列与增量输出一致”。一旦加入 beam search 或 speculative decoding,先保留无 cache 基线,避免优化掩盖逻辑错误。

DEC LAB 8.5.4实现并比较两种解码

在同一个模型上实现 greedy 和 temperature=0.8 的采样,固定 prompt,记录三次输出、seed、重复率和停止原因。解释为什么随机输出不能只比较一次。

greedy 在相同 eval 下应高度稳定;采样需记录 seed 并看多次分布。报告停止是 EOS、长度、停止词还是超时,不能只贴文本。
DEC LAB 8.5.5验证 cache 等价性

对长度不超过 8 的 prompt,分别用完整前缀和逐 Token cache 生成,逐步比较 logits 或选出的 Token。列出不一致时的排查顺序。

先查位置索引和 mask,再查 K/V 拼接轴、cache 重排、eval/dropout 和 dtype。保留无 cache 版本作为基线,不要一开始就删除。
表 8.5F · 生成验收清单
项目最低证据失败时先查
shift手算一条序列并对齐 logits输入/标签错位
mask未来 Token 反事实不影响输出三角方向
停止记录 EOS/长度/超时原因EOS ID、缓存尾部
采样固定 seed 的多次报告温度/top-k 顺序
cache与全序列短样本对照位置偏移、拼接轴

生成质量要和成本一起报告

同一模型在不同 max_new_tokens 下,延迟、cache 显存和输出风险都会变化;同一 prompt 在 greedy 与采样下,重复率和事实性也可能不同。记录首 Token 延迟(TTFT)、后续 Token 速度(TPS)、总时延、输入/输出 Token 数和停止原因,才能判断“快”到底快在哪里。批量请求还要记录最长序列对其他样本的影响。

解码策略的顺序也会改变结果:通常先处理禁止 Token 和温度,再做 top-k/top-p,最后归一化和采样;重复惩罚、最小长度和停止规则要明确插入点。不同库的实现顺序可能不同,迁移时用固定 logits 向量做单元测试,比较候选集合和最终 Token,而不是凭感觉调参。

表 8.5G · 生成服务的观测字段
字段用途异常信号
TTFT首 Token 体验前缀过长/cache 未命中
TPS持续输出速度cache、同步或 batch 问题
input/output tokens成本与预算截断/无限生成
stop_reason解释结束超时或硬截断增加

06 · TRAINING OBJECTIVE

训练目标:把“预测下一个 Token”变成可优化的程序

Transformer 的结构只是计算图,训练目标才告诉它要学什么。本节从 next-token prediction、CrossEntropyLoss 和 perplexity 开始,再讨论 AdamW、学习率、梯度裁剪、有效 Token 数、显存预算和检查点。

8.6.1 语言建模损失:每个位置都在预测下一个 Token

自回归语言模型把一段序列分解成多个条件概率:给定前面的 Token,预测当前下一个 Token。模型输出每个位置对整个词表的 logits,形状通常是 [B,T,V];标签是右移后的 [B,T] ID。交叉熵会在正确标签对应的 logit 上施加惩罚,训练通过反向传播调整参数,让正确 Token 的概率变高。

训练时 logits 不应先 softmax 再传给 CrossEntropyLoss,因为损失函数内部已经包含稳定的 log-softmax。标签必须是 LongTensor 的类别索引,不能是浮点概率;需要忽略的 PAD 或 prompt 位置使用 ignore_index。平均损失的分母要明确,是所有位置、有效 Token,还是 batch 数量。不同分母会产生不同的学习率感受和报告数字。

Perplexity 常用 exp(平均负对数似然)表示模型对下一个 Token 的平均不确定程度。它只在相同词表、Token 化、数据分布和分母定义下适合比较;不同 Tokenizer 的 Token 粒度不同,直接比较 perplexity 可能误导。对于指令微调,训练只计算助手回答区域的损失时,报告要写清 mask。

表 8.6A · 语言建模张量合同
对象形状/类型含义常见错误
input_idsLong [B,T]decoder 输入把 labels 原样输入
logitsFloat [B,T,V]每位置的词表分数轴顺序错、先 softmax
labelsLong [B,T]下一 Token 目标未右移、dtype 错
loss_maskBool [B,T]有效损失位置PAD 仍贡献损失
token_count标量有效分母按 batch 平均造成偏差
图 8.9 · 从序列到语言模型损失自制示意:每个位置错开一格
输入BOS 我 学
→
Transformer[B,T,D]
→
LM Head[B,T,V]
→
目标我 学 Python
→
masked CE有效 Token

输入和目标只错开一位;PAD、系统提示或不训练区域由 loss mask 排除。

PYTHON · lm_loss.py
import torch
from torch import nn

batch, length, vocab = 2, 4, 10
logits = torch.randn(batch, length, vocab)
labels = torch.tensor([[2, 4, 5, -100],
                       [3, 1, 7, 8]])
loss_fn = nn.CrossEntropyLoss(ignore_index=-100, reduction="sum")
loss_sum = loss_fn(logits.reshape(-1, vocab), labels.reshape(-1))
valid = (labels != -100).sum()
loss = loss_sum / valid.clamp_min(1)
perplexity = loss.detach().exp()

print("valid tokens:", valid.item())
print("loss:", loss.item(), "ppl:", perplexity.item())

示例使用 sum 后除以有效 Token 数,把不同长度 batch 的损失放到同一口径。若直接用默认 mean,每个 batch 会先平均自己的有效位置,再对 batch 平均,长短差异可能影响总体权重。两种做法都可以,但必须固定并写进报告。

不要用训练损失替代任务指标

语言模型 loss 下降说明在给定数据和目标下,平均预测更接近标签;它不直接保证事实正确、指令遵循、长文本记忆或安全行为。微调一个分类头时,accuracy/F1 可能更重要;生成任务还要看人工评估、引用正确性、拒答边界和重复率。把训练目标与最终任务分开写,避免把一个数字解释成所有能力。

检查 logits 时可以用一个极端例子:把正确标签的 logit 调高,损失应下降;把所有词表分数设相同,loss 接近 log(V);把 ignore_index 位置改动,loss 不应改变。性质测试比记住公式更能发现轴和 mask 错误。

TRAIN LAB 8.6.1验证有效 Token 分母

构造两个长度不同、含 PAD 的 batch,分别用 sum/有效 Token 和默认 mean 计算损失。说明两种口径何时会给出不同的训练权重。

默认 mean 是按当前输入中未忽略的位置平均,批次之间再平均可能让短批次权重偏大;按全局有效 Token 汇总更容易与 Token 级目标一致。关键是报告和训练保持同一口径。

8.6.2 优化器与学习率:控制参数更新的步幅和方向

反向传播得到的是损失对参数的梯度,优化器决定怎样使用梯度更新参数。AdamW 为每个参数维护一阶和二阶统计,能适应不同维度的梯度尺度;weight decay 以与梯度更新解耦的方式对参数施加衰减。它不是“自动找到最优答案”的黑盒,学习率、batch、初始化、数据质量和目标仍决定训练结果。

学习率过大可能使 loss 爆炸或在最优点附近震荡,过小则学习缓慢或停在差的区域。Transformer 训练常使用 warmup,让学习率从较小值逐步升高,再按步数衰减;warmup 比例、总步数和峰值学习率必须与有效 Token batch 相关。改变梯度累积或 batch 后,不要继续使用旧的步数计划而不解释。

梯度裁剪把过大的梯度范数限制在阈值内,能减少偶发爆炸,但不能修复错误的 mask、标签或学习率。混合精度训练需要 GradScaler 或框架提供的等价机制,检查梯度是否 finite;梯度累积要在若干 micro-batch 后再 step,并清楚损失是否除以 accumulation steps。

表 8.6B · 优化训练的可观测量
量说明异常信号先查什么
learning_rate当前步长骤升、始终为零scheduler.step 顺序
grad_norm梯度整体大小突然爆炸/全零loss、mask、精度
loss当前目标误差NaN、无下降labels、输入、分母
tokens_per_step一次更新看到的有效 Token不同实验不一致batch/累积/mask
weight_norm参数尺度持续异常增长lr、decay、初始化
PYTHON · optimizer_schedule.py
import torch
from torch import nn

model = nn.Linear(8, 3)
optimizer = torch.optim.AdamW(model.parameters(), lr=3e-4, weight_decay=0.01)
warmup = torch.optim.lr_scheduler.LinearLR(
    optimizer, start_factor=0.1, end_factor=1.0, total_iters=10
)
decay = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=90)
scheduler = torch.optim.lr_scheduler.SequentialLR(
    optimizer, schedulers=[warmup, decay], milestones=[10]
)

for step in range(15):
    optimizer.zero_grad(set_to_none=True)
    loss = model(torch.randn(4, 8)).square().mean()
    loss.backward()
    torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
    optimizer.step()
    scheduler.step()
    print(step, scheduler.get_last_lr()[0], loss.item())

调度器的 step 时机要固定:有的按每个 batch 更新,有的按 epoch 更新;在 optimizer.step 前后调用也会影响第一步的学习率。把前 20 步的学习率打印出来,确认 warmup 和衰减与配置一致。恢复 checkpoint 时同时恢复 optimizer、scheduler 和 scaler 状态,否则继续训练的步幅会跳变。

有效 batch size 的计算

如果每张设备每步有 B 条样本、平均有效 Token 数为 L、梯度累积 A 次、设备数为 W,则近似有效 Token batch 是 B×L×A×W;变长序列时应使用真实有效 Token 数统计。这个量决定一次参数更新接触多少训练信号,也影响学习率和总步数的解释。记录它比只写 batch_size 更准确。

训练日志要能回答“这一步更新了没有”。除了 loss 和 lr,记录是否跳过了 NaN step、梯度裁剪前后范数、optimizer step 计数和 checkpoint step。否则一个看似训练了 10,000 步的实验,可能有很多步实际没有更新。

TRAIN LAB 8.6.2画出学习率曲线

设置 warmup=5、总步数=30,打印每一步学习率。故意把 scheduler.step 放在 optimizer.step 前后各运行一次,比较第一步和峰值位置。

两种调用顺序可能让第一步跳过初始学习率或产生警告;应按当前 PyTorch API 和训练目标固定顺序,并把实际曲线写入报告。

8.6.3 批次、长度与显存:用 Token 预算设计训练实验

Transformer 的计算和激活内存会随序列长度明显增加,不能只按样本条数估算 batch。两批各 8 条样本,如果一批平均 32 Token、另一批平均 512 Token,计算量和显存完全不同。数据加载器可以按长度分桶、动态 padding 或 packing,提高有效 Token 占比;但改变 batch 形状也要保持 labels、mask 和位置一致。

梯度累积允许用较小的显存模拟更大的有效 batch:每个 micro-batch 计算 loss/A,反向累积 A 次后更新一次。若 loss 使用 sum 或 mean,缩放方式要统一;通常将每个 micro-batch 的 loss 除以累积步数,确保总梯度尺度与一次大 batch 相近。最后不足 A 个 batch 时,要明确是否更新和如何计算统计。

混合精度可以减少显存和提高吞吐,但不是所有算子都适合低精度。LayerNorm、softmax、loss 和梯度统计常需要更稳定的精度;使用 autocast 时仍要检查非有限值。显存优化还包括梯度检查点、减少返回 attention weights、KV cache 分离和按需释放中间张量。

表 8.6C · Token 级训练预算
预算项测量方式为什么重要
有效 Token/stepsum(loss_mask)比较不同 batch 的训练信号
padding 比例1−有效/总长度识别无效计算
最大长度batch 内 max T显存峰值和截断
tokens/sec有效 Token/耗时衡量吞吐
peak memory设备峰值统计决定可运行规模
checkpoint 间隔step 或 Token 数恢复和评估频率
PYTHON · token_batch_budget.py
def batch_stats(attention_mask, elapsed_seconds):
    valid_tokens = sum(sum(row) for row in attention_mask)
    total_tokens = sum(len(row) for row in attention_mask)
    padding_ratio = 1 - valid_tokens / max(total_tokens, 1)
    return {
        "valid_tokens": valid_tokens,
        "total_tokens": total_tokens,
        "padding_ratio": padding_ratio,
        "tokens_per_second": valid_tokens / max(elapsed_seconds, 1e-9),
        "max_length": max((len(row) for row in attention_mask), default=0),
    }

mask = [[1, 1, 1, 0], [1, 1, 0, 0]]
print(batch_stats(mask, elapsed_seconds=0.02))

示例中的 attention_mask 是 Python 列表,真实项目可以传 Tensor 并使用 sum()。统计函数不应依赖模型内部变量,方便在训练、验证和 benchmark 中复用。记录 padding 比例后,你可以判断“模型很慢”是因为序列太长、batch 太大,还是大量 PAD 浪费。

检查点不仅保存模型权重

可恢复训练的 checkpoint 通常包括 model state_dict、optimizer、scheduler、scaler、当前 step、随机状态、词表/Tokenizer 版本、数据游标和配置。只保存模型权重适合推理发布,却无法保证继续训练的轨迹一致。每次保存后立即从磁盘加载并做一次小 batch 前向,检查文件确实可读。

实验规模要从最小可验证单元逐步扩大:先让一批数据过拟合,确认 loss 能接近很低;再跑小语料几百步,观察验证和生成;最后才扩大长度、层数和数据。每一步都有明确的退出条件,避免把长时间训练当成调试方法。

TRAIN LAB 8.6.3制定 Token 预算

假设显存允许每步 4096 个有效 Token,设计两种变长 batch 和梯度累积方案。记录每步有效 Token、padding 比例、预计吞吐和 checkpoint 间隔。

方案可以是小 batch+累积或动态按 Token 组 batch;关键是计算真实有效 Token,而不是只写样本数。要说明不足一个累积周期、超长截断和显存超限的处理。
表 8.6D · 本大节完成标准
能力最低证据自测问题
损失能对齐 logits、labels、mask 和分母为什么不能先 softmax
优化能解释 AdamW、warmup、裁剪和恢复状态学习率曲线如何验证
预算能按有效 Token 估算 batch、显存和吞吐padding 比例说明什么

先把“训练一步”写成可审计事件

一个训练 step 不只是 loss.backward() 和 optimizer.step() 两行。它包含取 batch、搬移 device、前向、构造有效目标、计算分母、反向、梯度裁剪、参数更新、学习率更新、日志和可能的 checkpoint。把这些事件按固定顺序写进训练循环,遇到异常时才知道最后成功的是哪一步。

日志至少记录 step、epoch 或数据游标、loss、有效 Token 数、学习率、梯度范数、吞吐、显存峰值和是否发生 overflow。不要只每 1000 步打印一次平均 loss;保留原始 step 级记录,才能发现某一批超长样本、某次恢复或某个学习率边界导致的异常。日志文件使用 JSONL 或 CSV,字段名固定,后续脚本才能绘图。

表 8.6E · 训练 step 的事件顺序
顺序事件核心检查日志字段
1取 batch/搬 deviceshape、dtype、有效数batch_tokens、device
2前向logits finite、shapelogits_range
3lossshift、ignore_index、分母loss、valid_tokens
4反向梯度是否存在/finitegrad_norm
5更新/调度step 是否按累积更新lr、update_count
6保存/评估文件可读、指标独立checkpoint、val_loss

交叉熵平均值有两个常见分母:按所有位置平均,或只按非 PAD/非 prompt 的有效目标平均。变长 batch 下,如果每个样本先平均再对样本求平均,短样本和长样本权重不同;按有效 Token 汇总通常更接近“每个预测位置同权”。选择不是绝对对错,但要写清楚,训练和验证必须使用相同规则。

困惑度 exp(loss) 只在 loss 使用自然对数且分母定义一致时才可解释。loss 很小不一定代表生成好:数据可能泄漏、Tokenizer 可能把常见词合并得更好,或模型只记住训练集。报告 ppl 时同时列 Tokenizer、评估集合、有效 Token 数和是否忽略 PAD。

PYTHON · metric_logger.py
import json
from pathlib import Path

class JsonlLogger:
    def __init__(self, path):
        self.path = Path(path)
        self.path.parent.mkdir(parents=True, exist_ok=True)

    def write(self, record):
        required = {"step", "loss", "valid_tokens", "learning_rate"}
        missing = required - record.keys()
        if missing:
            raise ValueError(f"日志缺少字段: {sorted(missing)}")
        with self.path.open("a", encoding="utf-8") as handle:
            handle.write(json.dumps(record, ensure_ascii=False) + "\n")

logger = JsonlLogger("artifacts/metrics.jsonl")
logger.write({"step": 1, "loss": 3.2, "valid_tokens": 120,
              "learning_rate": 0.001, "grad_norm": 1.8})

学习率策略需要和更新次数对齐。梯度累积时,optimizer.step 的次数比 forward 次数少;warmup 应按 optimizer update 或有效 Token 设计,不能误把 micro-batch 次数当作更新次数。恢复 checkpoint 时保存 scheduler 状态,否则学习率会跳变,造成难以解释的 loss 曲线。

梯度裁剪的阈值要记录裁剪前后的范数。若每次都被强烈裁剪,可能是学习率、初始化、loss 分母或数据异常;若从不裁剪,不能因此认为训练稳定。混合精度的 scaler 发生 overflow 时应跳过该次更新并记录计数,连续 overflow 要触发报警,而不是静默继续。

图 8.7 · 训练监控的三条曲线自制示意:loss、梯度和学习率要一起看
loss目标是否学习
+
grad norm更新是否健康
+
learning rate步长是否合理
→
诊断异常定位

只看 loss 容易错过梯度爆炸、学习率跳变和数据问题。

有效 Token 与训练预算

显存预算至少由参数、梯度、优化器状态、激活和 attention 矩阵共同决定。参数量相同时,序列长度和 batch 变化仍会显著影响激活;长度翻倍通常比 batch 翻倍更容易触发注意力矩阵的二次增长。先用小 batch 测显存峰值,再通过梯度累积增加有效 batch,不要直接把每个维度都调大。

Token budget 比 epoch 更适合比较变长数据集。一个 epoch 在短样本很多时可能只有很少 Token,在长样本很多时则可能过大。报告用“看过多少有效 Token、更新多少次、验证间隔多少 Token”描述进度,必要时再补充 epoch。数据切分和采样权重也要写清楚。

PYTHON · train_step_contract.py
def train_step(model, batch, optimizer, loss_fn, update=True):
    input_ids = batch["input_ids"]
    labels = batch["labels"]
    logits = model(input_ids)
    if logits.shape[:2] != labels.shape:
        raise ValueError("logits 与 labels 的 Token 轴不一致")
    loss = loss_fn(logits.reshape(-1, logits.shape[-1]), labels.reshape(-1))
    if not loss.isfinite():
        raise FloatingPointError("loss 为 NaN/Inf")
    loss.backward()
    if update:
        optimizer.step()
        optimizer.zero_grad(set_to_none=True)
    valid_tokens = int(labels.ne(-100).sum())
    return {"loss": float(loss.detach()), "valid_tokens": valid_tokens}

这个合同把返回值限制在可记录的摘要,不返回完整 logits,避免日志和内存失控。真实训练中还要加入 autocast、scaler、梯度累积和裁剪,但每加一个机制都应保留原始小版本作为对照。复杂训练器最好拆成 data、forward、loss、backward、update、log 六个可测试函数。

TRAIN LAB 8.6.4定义一个有效 Token 指标

给出含 PAD 和 prompt mask 的 batch,分别计算位置平均、样本平均和有效 Token 平均。解释三种分母会如何改变长短样本的权重,并选择一种用于训练/验证。

先列每个样本有效目标数,再分别求分母;推荐在报告中使用有效 Token 汇总并保持训练/验证一致,但要说明选择和适用场景。
TRAIN LAB 8.6.5模拟一次异常 step

让一个 batch 的 labels 全部为 -100 或让 logits 含 NaN,设计训练循环应抛出的错误、日志字段和恢复动作。说明为何不能只把 NaN loss 替换成 0。

全 -100 应在 loss 前阻止;NaN 要保留上下文并停止/回滚到最近 checkpoint。替换成 0 会污染平均值和学习率调度,掩盖根因。
表 8.6F · 训练异常与行动
异常证据第一行动
loss NaN输入、logits、梯度、overflow停止更新并保存上下文
loss 不动参数梯度、optimizer 参数组单 batch 过拟合
验证突降/突升切分、学习率、恢复状态对照最近 checkpoint
吞吐下降长度、padding、显存、数据等待拆分计算与输入瓶颈
恢复后曲线断裂optimizer/scheduler/scaler 状态校验 checkpoint 完整性

07 · PYTORCH IMPLEMENTATION

PyTorch 实现:把公式变成可测试的模块

实现 Transformer 时,最容易迷失在一大段 forward 里。本节采用“模块—形状探针—最小训练”的顺序,先使用 PyTorch 提供的注意力组件,再逐步写出一个可以过拟合小批数据的迷你语言模型。

8.7.1 模块化实现:把 Embedding、位置、Block 和 LM Head 分开

一个 decoder-only 语言模型的最小模块通常包括 Token Embedding、位置表示、若干 Transformer Block、最终 LayerNorm 和词表投影(LM Head)。输入是 [B,T] 的 ID,输出是 [B,T,V] 的 logits。若输入和输出词表相同,可以共享 Token Embedding 与 LM Head 的权重(weight tying),但共享关系要明确记录,不能在加载权重时靠名字猜测。

PyTorch 的 nn.MultiheadAttention、nn.TransformerEncoderLayer 等组件可以减少手写错误,但不同版本的参数、mask 语义和 batch_first 设置要查官方文档。自定义模块适合学习和插入诊断,内置模块适合建立可靠基线;两者都要用同一组小输入对照。

表 8.7A · 迷你语言模型的模块接口
模块输入输出主要状态
TokenEmbeddingLong [B,T]Float [B,T,D]词表、维度、padding
Position长度 T[B,T,D] 或偏置最大长度/频率
DecoderBlock[B,T,D] + mask[B,T,D]QKV、Norm、FFN
FinalNorm[B,T,D][B,T,D]eps、可学习仿射
LMHead[B,T,D][B,T,V]词表投影/权重共享
PYTHON · tiny_components.py
import torch
from torch import nn

class TinyLanguageModel(nn.Module):
    def __init__(self, vocab_size, dim=32, heads=4, layers=2, max_length=64):
        super().__init__()
        self.token = nn.Embedding(vocab_size, dim)
        self.position = nn.Embedding(max_length, dim)
        block = nn.TransformerEncoderLayer(
            d_model=dim, nhead=heads, dim_feedforward=4 * dim,
            batch_first=True, norm_first=True,
        )
        self.blocks = nn.TransformerEncoder(block, num_layers=layers)
        self.norm = nn.LayerNorm(dim)
        self.lm_head = nn.Linear(dim, vocab_size, bias=False)
        self.lm_head.weight = self.token.weight

    def forward(self, input_ids, padding_mask=None):
        batch, length = input_ids.shape
        positions = torch.arange(length, device=input_ids.device)
        x = self.token(input_ids) + self.position(positions)[None, :, :]
        causal = torch.triu(torch.ones(length, length, device=x.device), 1).bool()
        x = self.blocks(x, mask=causal, src_key_padding_mask=padding_mask)
        return self.lm_head(self.norm(x))

model = TinyLanguageModel(vocab_size=50)
logits = model(torch.randint(0, 50, (2, 8)))
print(logits.shape)

示例使用 Encoder 层堆叠配合 causal mask 构造一个教学用 decoder-only 模型,重点是接口和形状,不是生产级性能。src_key_padding_mask 的 True 语义与本章自定义 mask 不同;位置 Embedding 需要保证输入长度不超过 max_length。模型初始化后先运行随机 ID 前向,确认 logits 的最后一维等于 vocab_size。

权重共享会让 LM Head 与 Token Embedding 指向同一 Parameter,减少参数并常被语言模型采用。保存和加载时检查两者是否仍共享;如果只复制数值而不共享对象,训练行为会改变。做消融时可以比较共享与不共享,但要保持其他配置固定。

模块化的三个原则

第一,单一职责:位置模块不负责 mask,注意力模块不负责标签移位,训练器不负责解析文本。第二,显式契约:每个 forward 写出输入 shape、dtype、device 和可选 mask。第三,可替换:把编码器、pooling 或 LM Head 换掉时,其他模块仍能用同一测试。模块边界清楚,才能在下一章微调时替换预训练模型。

不要为了短而把所有逻辑压进一个函数。十几行重复的 shape 检查,往往比一百行无法定位的“智能 forward”更适合学习和维护。

TORCH LAB 8.7.1让迷你模型过一次前向

创建 vocab=32、dim=24、heads=4、layers=2、length=10 的模型,打印每个模块输出 shape,传入一个含 PAD 的 batch 并检查 padding mask。

logits 应为 [B,10,32];dim 必须能被 heads 整除;causal mask 是 [10,10],padding mask 通常是 [B,10]。确认 mask 语义后再比较输出。

8.7.2 形状与掩码调试:让第一个错误尽早暴露

Transformer 的错误常发生在最后两个维度:Q/K 的 head_dim 不一致、scores 的 Token 轴弄反、mask 无法广播、labels 与 logits 错位、padding mask 与 loss mask 混淆。最小调试器应在每个关键节点打印或断言 shape、dtype、device、finite 和有效位置数量,而不是等最终 loss 变成 NaN 才开始猜。

推荐把输入分成四类探针:全相同 Token、单个位置改动、全 PAD/空序列和极端长度。全相同输入可以检查位置编码是否生效;单个改动可以检查信息能否沿允许的 mask 传播;全 PAD 暴露分母和 softmax 空行;极端长度暴露位置上限和显存问题。探针输出保存成 JSON 或文本,方便回归。

表 8.7B · Transformer 调试断言
断言预期定位的问题
input_ids.dtypetorch.longEmbedding 索引错误
0≤ids<V全部在词表范围词表/迁移不一致
mask shape可广播或符合 API批次/head/Token 轴错
scores.isfinite()无 NaN/Inf数值溢出、整行屏蔽
logits.shape[B,T,V]LM Head/标签错位
valid_tokens>0有可计算损失的位置全 PAD 或 prompt mask 错
PYTHON · transformer_probe.py
import torch

def check_batch(input_ids, mask, vocab_size, name="batch"):
    if input_ids.dtype != torch.long:
        raise TypeError(f"{name}: ids 必须是 long")
    if input_ids.ndim != 2 or mask.shape != input_ids.shape:
        raise ValueError(f"{name}: ids/mask shape 不匹配")
    if input_ids.numel() and (input_ids.min() < 0 or input_ids.max() >= vocab_size):
        raise ValueError(f"{name}: ID 越界")
    valid = int(mask.sum())
    if valid == 0:
        raise ValueError(f"{name}: 没有有效 Token")
    return {"shape": tuple(input_ids.shape), "valid_tokens": valid}

# 实际使用时把 dtype 检查改为 input_ids.dtype == torch.long
print(check_batch(torch.tensor([[1, 2, 0]]),
                  torch.tensor([[1, 1, 0]], dtype=torch.int64), 10))

示例直接使用 input_ids.dtype != torch.long 检查索引类型,并把越界、shape 和有效 Token 数放在同一个入口。教学代码也要明确哪些行依赖真实 Tensor;如果改用 NumPy 或纯 Python 列表,应同步调整 dtype、numel() 和比较操作。把实现假设写出来,比让读者复制后遇到属性错误更负责。形状探针本身也应被测试。

调试时避免在每个 batch 打印完整矩阵。打印摘要:shape、min/max、mean/std、finite、有效 Token 数和前几个 ID。需要深入时再保存一个固定 batch 的完整 Q/K/V 或权重。日志过大不仅难读,还会拖慢训练并占用磁盘。

从 loss 异常回到源头

loss 为 NaN:先查输入/labels finite,再查 ignore_index 后有效位置数,再查 logits 范围和 attention scores,再查梯度范数与学习率。loss 一直不变:先让一批数据过拟合,确认标签、shift 和 optimizer.step;若仍不变,检查参数是否 requires_grad、optimizer 是否拿到正确参数。生成重复:查位置、cache、EOS、采样策略和训练数据模板。每种现象都有优先顺序,别同时改十个变量。

把一个修复案例写进回归测试:输入、旧错误、根因、修改、断言和版本。未来重构模块或升级 PyTorch 时,测试会提醒你哪些行为不能被悄悄改变。

TORCH LAB 8.7.2设计四个异常探针

分别构造 ID 越界、mask 全零、labels 错位和 NaN logits 的输入。为每种异常写出应在什么阶段失败,以及错误消息需要包含哪些上下文。

应在进入 Embedding、softmax、loss 或 optimizer 前尽早失败;错误消息至少包含样本/批次、shape、有效 Token 数、实际范围或非有限位置。

8.7.3 迷你 Transformer:先让一小批数据过拟合

学习 Transformer 最有效的实验之一,是准备几条极小的序列,让模型在固定 batch 上过拟合。目标不是得到有用的语言模型,而是验证 Token、位置、causal mask、logits、shift labels、loss 和 optimizer 能组成闭环。如果连 8 条样本都无法把损失降下来,扩大数据和模型只会把问题藏得更深。

过拟合实验应固定随机种子,关闭复杂增强,使用小词表和短长度。先检查初始 loss 是否接近 log(V),再运行若干步,记录 loss、有效 Token 数、学习率和梯度范数;最后用同一前缀生成,确认模型至少能复现训练样例中的模式。过拟合成功不等于泛化成功,但失败通常说明实现或数据合同有问题。

表 8.7C · 迷你模型实验阶段
阶段配置通过条件不通过先查
形状 smoke随机 ID、无反向logits [B,T,V]维度、mask、位置
单 batch 过拟合8~32 条样本loss 明显下降shift、labels、optimizer
短语生成固定 prompt、greedy停止且可复现EOS、cache、位置
小验证集固定 train/val报告 loss/ppl泄漏、过拟合
消融一次改一个组件结论有证据版本和随机性
PYTHON · overfit_one_batch.py
import torch
from torch import nn

torch.manual_seed(7)
model = TinyLanguageModel(vocab_size=20, dim=32, heads=4, layers=2, max_length=12)
optimizer = torch.optim.AdamW(model.parameters(), lr=2e-3)
loss_fn = nn.CrossEntropyLoss()
ids = torch.tensor([[2, 5, 6, 7, 3, 0],
                    [2, 5, 6, 8, 3, 0]])
inputs, labels = ids[:, :-1], ids[:, 1:]
for step in range(200):
    optimizer.zero_grad(set_to_none=True)
    logits = model(inputs)
    loss = loss_fn(logits.reshape(-1, 20), labels.reshape(-1))
    loss.backward()
    optimizer.step()
    if step % 40 == 0:
        print(step, loss.item())
print("final:", loss.item())

这个示例为了突出流程,没有对 PAD 使用 ignore_index,也没有传 padding mask;正式版本需要把 PAD 排除并核对每个有效位置。过拟合的学习率和步数只是起点,不应直接迁移到大语料训练。观察 loss 曲线比迷信某个数字更重要。

如果单 batch 过拟合,下一步加入验证集和更真实的长度分布;如果验证损失很快上升,说明模型记住了训练样例或数据切分有问题;如果生成只输出 EOS 或同一个 Token,检查标签、词表、位置和解码策略。每一步都保留一份 baseline,不要用新配置覆盖旧结果。

图 8.10 · 从单批过拟合走向训练自制示意:每一步都有退出条件
形状 smoke无反向
→
单批过拟合loss 下降
→
短验证集看泛化
→
小语料训练记录版本
→
消融报告一次改一项

没有通过前一阶段,就不要用更大模型掩盖问题。

实现完成的最低证据

你应保存一份 shape probe、一份单批过拟合日志、一份固定 prompt 的生成结果和一份配置 manifest。它们共同证明模型不仅“实例化成功”,还完成了从输入到损失再到参数更新的闭环。把这些文件放进项目 artifacts,下一节微调时可以直接复用。

TORCH LAB 8.7.3让单批数据过拟合

使用 8~16 条短序列,分别测试正确 shift、错误 shift、带 PAD mask 和不带 mask 的版本。记录最终 loss、生成样例和每个版本的失败原因。

正确版本应在固定 batch 上明显下降;错误 shift 可能学成复制/错位,未屏蔽 PAD 会改变损失。对照时一次只改变一个因素并保存配置。
表 8.7D · 本大节完成标准
能力最低证据自测问题
模块能组合 Embedding、位置、Block、Norm、LM Head每个模块的输入输出是什么
调试能用探针定位 shape/mask/loss 错误NaN 时先查哪里
训练能让固定小批数据过拟合并生成过拟合证明了什么、没有证明什么

从内置层到手写 attention 的迁移顺序

实现练习不要一开始就追求“完全手写”。建议先用 PyTorch 内置 TransformerEncoderLayer 跑通 shape 和单批过拟合,再单独写 Q/K/V、softmax、mask 和 head merge 的小函数,用同一组随机输入与内置模块对照。最后才把自定义 attention 接回完整模型。每一步都保留旧版本和差异容差,任何差异都能定位到一个组件。

对照时要区分数值等价和接口等价。不同实现的 dropout、初始化、LayerNorm 顺序和 mask 类型可能导致逐元素不完全相同;在 eval 模式、固定权重和小输入下,先比较 shape、finite、mask 后的零位置和输出范围,再设置合理的 allclose 容差。不要用“看起来差不多”替代测试,也不要因为微小浮点差异就放弃定位。

表 8.7E · 实现迁移的逐步基线
阶段实现对照通过条件
A内置 EncoderLayer随机输入 shape前向 finite
B独立 split/merge heads元素数和轴往返 shape 一致
C纯 Python attention手算三 Token权重行和/屏蔽正确
DTensor attention内置模块或固定权重输出误差在容差内
E接入 LM Head单批过拟合loss 下降并生成

模型代码还要考虑参数初始化和权重共享的生命周期。把 self.lm_head.weight = self.token.weight 写在构造函数里会共享同一个 Parameter;若之后加载 state dict、复制模型或做量化,关系可能改变。写一个断言 model.lm_head.weight.data_ptr() == model.token.weight.data_ptr(),并在保存/加载后再次检查。若有意取消共享,就在配置中显式写出。

PYTHON · module_tests.py
import torch

def assert_model_contract(model, vocab_size, batch=2, tokens=5):
    ids = torch.randint(0, vocab_size, (batch, tokens), dtype=torch.long)
    with torch.inference_mode():
        logits = model(ids)
    assert logits.shape == (batch, tokens, vocab_size), logits.shape
    assert torch.isfinite(logits).all()
    return {"input": tuple(ids.shape), "output": tuple(logits.shape)}

def assert_weight_tying(model):
    tied = model.lm_head.weight.data_ptr() == model.token.weight.data_ptr()
    print("weight_tying:", tied)
    return tied

# 使用 TinyLanguageModel 实例调用:
# print(assert_model_contract(model, vocab_size=50))
# assert_weight_tying(model)

测试函数返回摘要,适合接入 pytest 或脚本;失败时保留实际 shape 和配置。还可以加入梯度测试:随机 loss 反向后,检查至少一个可训练参数的梯度非零;冻结参数应没有梯度。这个测试能在微调前发现“模型全部被冻结”或 optimizer 没拿到参数的问题。

图 8.11 · 调试从外到内自制层级:先合同,再数值,最后性能
接口shape/dtype
→
数值finite/mask
→
梯度requires_grad
→
行为过拟合/生成
→
性能吞吐/显存

前一层未通过时,不要用性能优化掩盖基础错误。

性能测量也要有实验合同

吞吐要说明单位:每秒样本、每秒 Token 还是每秒有效 Token;测量前先 warm-up,排除首次编译和内存分配;同步 GPU 后再计时,否则异步执行会低估耗时。显存要分别记录 allocated、reserved 和峰值,区分模型初始化、前向、反向和生成 cache。批量大小、长度、dtype、设备和线程数都写进结果。

优化顺序建议从数据等待、padding 比例、batch、混合精度、梯度检查点和 cache 开始。不要在模型还无法通过 shape probe 时引入 fused kernel 或复杂编译。优化后的实现必须重新跑功能测试和短生成对照,速度提升若伴随未来泄漏或数值漂移,就不是成功。

PYTHON · profile_summary.py
def throughput(valid_tokens, elapsed_seconds):
    if elapsed_seconds <= 0:
        raise ValueError("耗时必须为正")
    return valid_tokens / elapsed_seconds

runs = [
    {"name": "fp32", "tokens": 4096, "seconds": 0.42, "peak_mb": 812},
    {"name": "amp", "tokens": 4096, "seconds": 0.24, "peak_mb": 506},
]
for run in runs:
    print(run["name"], {
        "tokens_per_second": round(throughput(run["tokens"], run["seconds"])),
        "peak_mb": run["peak_mb"],
    })

性能表中的数字只对对应环境有效,不能把一次 CPU 运行和一次 GPU 运行混为“模型更快”。如果比较两个结构,至少重复三次并报告中位数或范围;同时保留功能指标。对学习者而言,最有价值的不是追求绝对峰值,而是知道哪个变量带来了多少变化。

TORCH LAB 8.7.4写三个模块测试

为你的模型分别写 shape/finite 测试、梯度存在测试和 weight tying 测试。故意制造一个错误,确认测试能给出包含模块名和实际 shape 的消息。

测试要在 forward、backward 和保存/加载后分别运行;错误信息应包含期望/实际 shape、dtype 或指针关系。不要只用一个最终 loss 判断所有模块。
PERF LAB 8.7.5做一次可重复 benchmark

固定输入长度、batch、dtype 和设备,warm-up 后测三次前向和一次生成,记录有效 Token/s、峰值显存与功能差异。比较一个优化前后版本,并写出限制。

要说明计时是否同步设备、是否排除 warm-up、吞吐单位和重复范围。任何功能差异都要先解释,再决定是否接受速度收益。
表 8.7E · 实现交付的证据
证据证明什么缺失时的风险
模块测试接口与数值基本正确错误延迟到训练
梯度摘要参数确实更新训练假成功
单批日志loss/shift/optimizer 闭环无法定位不收敛
benchmark资源数字可比较性能结论失真
配置 manifest可复现组合无法恢复或迁移

08 · PRETRAINED MODEL & FINETUNING

预训练模型与微调:把通用能力变成可交付能力

Transformer 结构会写只是起点。真实项目通常从一个已有 checkpoint 出发,配合匹配的 Tokenizer、清晰的指令数据和可回滚的评估流程,把通用语言能力调整到具体任务。本节把“加载、组织、训练、评估、安全”连成一条证据链。

8.8.1 预训练模型与分词器:先确认你拿到的东西

预训练模型不是一个孤立的 model.bin 文件。一个可用 checkpoint 至少要和配置、词表、特殊 Token、模型代码或架构名称一起理解。配置决定隐藏维度、层数、头数、最大上下文和归一化方式;Tokenizer 决定字符串如何变成 ID;权重决定这些 ID 在模型中如何传播。三者只要有一项错配,就会出现“能加载但输出完全不对”的隐蔽故障。

拿到模型时先建立 provenance(来源记录):仓库或对象存储地址、版本提交、下载时间、许可证、SHA-256、训练数据说明、已知限制和允许用途。不要只把文件复制到某个目录就开始微调。后续若输出异常,你需要能回答“使用的是哪个版本、哪个词表、哪个特殊 Token、哪些超参数”。这份记录也是团队复现和安全审计的入口。

表 8.8A · Checkpoint 的组成与检查方式
对象作用加载前检查常见错配
config描述架构和尺寸层数、头数、hidden、max length用错模型类或位置上限
权重存储可学习参数键名、shape、dtype、文件哈希缺键、额外键、维度不一致
Tokenizer文本与 ID 的映射vocab size、特殊 Token、版本PAD/EOS/UNK ID 不同
代码/架构解释权重如何计算库版本、远程代码策略类名相同但实现不同
许可证与卡片限定使用边界商用、地域、数据和署名要求忽略限制直接上线

Tokenizer 的第一项验收不是“能编码”,而是“编码后能稳定解码”。准备一组包含中文、英文、数字、换行、emoji、URL、空格和特殊符号的探针,记录 tokens、input_ids、attention_mask,并检查 decode(encode(text)) 是否在可接受范围内。解码不一定逐字相等,但换行、空格和特殊 Token 的行为必须能解释。中文文本还要特别注意全角标点和 Unicode 规范化,不能凭肉眼猜测 ID 是否合理。

上下文长度也要用实验确认。模型配置写的最大长度是上限,不代表每次请求都应该填满;Tokenizer 的截断策略可能从左侧或右侧丢掉信息。对问答任务,要明确保留问题、系统约束还是答案尾部;对长文摘要,要记录窗口切分和重叠比例。任何截断都应进入样本日志,便于解释评估下降。

图 8.11 · 加载 checkpoint 的证据链自制流程图:文件可读不等于模型可用
来源与许可证版本/哈希
→
配置与权重shape/键名
→
Tokenizer 探针编码/解码
→
随机前向finite/shape
→
固定样例可复现输出

每一站都保存一条可检查的记录,出现问题时按顺序回退。

PYTHON · load_checkpoint.py
from pathlib import Path
import hashlib
import json
import torch

def sha256(path):
    digest = hashlib.sha256()
    with open(path, "rb") as handle:
        for chunk in iter(lambda: handle.read(1024 * 1024), b""):
            digest.update(chunk)
    return digest.hexdigest()

def load_manifest(root):
    root = Path(root)
    manifest = json.loads((root / "config.json").read_text(encoding="utf-8"))
    required = {"vocab_size", "hidden_size", "num_hidden_layers"}
    missing = required - manifest.keys()
    if missing:
        raise ValueError(f"config 缺少字段: {sorted(missing)}")
    weight_file = root / "model.pt"
    if not weight_file.exists():
        raise FileNotFoundError(weight_file)
    print({"sha256": sha256(weight_file), "config": manifest})
    # 仅加载可信来源;torch.load 可能反序列化对象,不要接收未知文件。
    state = torch.load(weight_file, map_location="cpu", weights_only=True)
    return manifest, state

config, state_dict = load_manifest("checkpoints/demo")
print(len(state_dict), config["hidden_size"])

示例把“文件存在、配置字段、哈希、权重键”拆开检查。weights_only=True 是降低不可信反序列化风险的一个选择,但具体参数支持取决于 PyTorch 版本;升级库时应重新阅读官方 API。加载后还要把 state dict 映射到明确的模型类,再运行 shape probe。遇到 missing/unexpected keys 时不要盲目 strict=False,先解释每个键为什么缺失或新增。

推理前调用 model.eval() 并包裹 torch.inference_mode(),训练前调用 model.train()。Dropout、BatchNorm(若架构使用)和梯度开关都会改变结果。多卡或 CPU/GPU 迁移时,输入、模型、缓存和 mask 必须在兼容 device;不要只把模型搬到 GPU,却把位置索引或 attention mask 留在 CPU。

预训练模型验收清单

用四条固定命令形成最小验收:打印配置摘要;对探针文本编码并解码;输入一批随机合法 ID 检查 logits shape 和 finite;用固定 prompt 在 eval 模式下生成并保存输出。四条都通过后才进入数据准备。若输出乱码,先查 Tokenizer;若 shape 错,查配置和模型类;若数值异常,查 dtype、device 与权重加载。

模型卡片中的“已知限制”不是宣传文案,而是实验设计输入。它可能提示领域偏差、语言覆盖、训练截止时间、拒答边界或许可证限制。把这些限制转成评估用例,比在上线后才发现问题更便宜。

MODEL LAB 8.8.1制作 checkpoint 验收表

为任意一个公开 checkpoint 写一页验收表:来源、许可证、配置关键字段、Tokenizer 特殊 Token、哈希、随机前向结果和一个固定 prompt 输出。指出其中哪一项失败会阻止后续微调。

至少要有可追溯版本、可解释许可证、配置/权重 shape 一致、编码解码可解释、logits finite 且维度正确。许可证不清、词表错配或权重缺键时应停止,而不是用静默兼容继续。
表 8.8B · Tokenizer 探针样本
文本类型要观察什么记录字段
中文与全角标点分词边界、解码空格tokens、ids、decoded
中英数字混合数字是否被拆分、大小写长度、特殊 Token
换行与多空格格式是否丢失repr(text)、repr(decoded)
emoji/罕见符号UNK 或 byte fallbackUNK 数量、ID 范围
超长文本截断方向和窗口原长、截断长、策略

8.8.2 指令数据与参数高效微调:让监督信号落在正确位置

指令微调的关键不是把几段对话拼成一条长字符串,而是明确每个 Token 的职责。一个样本通常由 system、user、assistant 或 instruction、input、response 组成;模板负责把它们序列化,Tokenizer 负责编码,collator 负责补齐、生成 labels 和 loss mask。只有 assistant 回答部分参与损失时,模型才主要学习“怎样回答”,而不是机械复述提示词。

先定义数据契约:必需字段、允许为空的字段、最大字符/Token 长度、语言、敏感信息处理、去重键和错误样本策略。样本清洗要保留计数:原始条数、解析失败、空回答、超长截断、重复、训练/验证切分后的条数。没有这些数字,训练 loss 下降也无法证明数据真的符合预期。

表 8.8C · 一条指令样本的角色与标签
片段模型看到的文本labelsloss mask
系统约束角色、格式、安全边界通常设为 -1000
用户问题任务输入和上下文通常设为 -1000
回答前缀assistant 标记可设为 -1000 或 1,需固定策略
回答正文目标输出下一个 Token 的目标 ID1
PAD补齐位置-1000

标签移位仍然遵循上一节的原则:模型输入是从当前位置预测下一个 Token,labels 通常是输入右移一位的结果。对话模板中的 EOS 要有明确策略:每条回答末尾是否加入 EOS,多个轮次是否都计算损失,截断时是否可能把 EOS 截掉。一个简单可靠的做法是先在字符串层打印模板,再在 ID 层打印角色边界和 mask,最后才交给 DataLoader。

参数高效微调(PEFT)的核心是冻结大部分基座参数,只训练少量可插拔参数。LoRA 可以把某个线性层的更新近似为低秩矩阵乘积,常见写法是 W' = W + BA,其中基座 W 不更新,A、B 是训练参数。低秩并不自动等于效果好:目标模块、rank、缩放、dropout、学习率、数据量和任务难度共同决定结果。训练前后都要统计可训练参数数量与比例。

选择 LoRA 目标模块时不要照抄别人的层名。先打印模型的线性层名称,确认注意力的 q/k/v/o 和 FFN 是否存在,再选择一组基线。不同架构的命名可能是 q_proj、query 或融合的 qkv;目标层不匹配时,训练可能“正常跑完”但没有更新任何想更新的参数。

图 8.12 · 指令微调的数据流自制示意:mask 决定监督信号落点
原始样本角色字段
→
模板渲染边界可见
→
Tokenizerids/offset
→
Collatorlabels/mask
→
LoRA/PEFT只更新适配器

先检查一条样本的可视化标签,再扩大到整个数据集。

PYTHON · instruction_collator.py
def build_example(tokenizer, prompt, answer, max_length=512):
    prompt_text = f"用户:{prompt}\n助手:"
    full_text = prompt_text + answer + tokenizer.eos_token
    prompt_ids = tokenizer(prompt_text, add_special_tokens=False)["input_ids"]
    full = tokenizer(
        full_text, add_special_tokens=True, truncation=True,
        max_length=max_length, return_attention_mask=True,
    )
    input_ids = full["input_ids"]
    labels = [-100] * len(input_ids)
    answer_start = min(len(prompt_ids), len(input_ids))
    for index in range(answer_start, len(input_ids)):
        labels[index] = input_ids[index]
    # 只有回答区域产生损失;PAD 在 batch padding 后仍设为 -100。
    return {
        "input_ids": input_ids,
        "attention_mask": full["attention_mask"],
        "labels": labels,
    }

sample = build_example(tokenizer, "解释什么是向量", "向量是有序的数值集合。")
print(len(sample["input_ids"]), sample["labels"])

示例使用字符模板推导回答起点,真实多轮模板应使用 Tokenizer 提供的 chat template 或明确的角色 Token,并处理特殊 Token 被自动插入的情况。最重要的验收是把 labels 中不等于 -100 的位置反解码出来,确认它们确实对应回答,而不是提示词、PAD 或截断残片。

PYTHON · lora_parameter_count.py
def lora_params(in_features, out_features, rank, targets):
    base = in_features * out_features
    adapter = rank * (in_features + out_features)
    return {"base": base * targets, "lora": adapter * targets}

for rank in (4, 8, 16):
    result = lora_params(4096, 4096, rank, targets=8)
    ratio = result["lora"] / (result["base"] + result["lora"])
    print(rank, result["lora"], f"{ratio:.3%}")

参数量估算能帮助你解释显存变化:全量更新保存梯度和优化器状态,开销远大于只保存适配器。LoRA 适配器也不是“免费”的,rank 增大、目标层增多会提高训练量和文件体积。报告中同时写 trainable params、总 params、显存峰值、吞吐和验证指标,才能比较不同方案。

数据质量的四个闸门

第一闸门是格式:所有样本都能渲染并编码;第二闸门是语义:回答真的回答问题且没有把答案放进 prompt;第三闸门是分布:训练/验证按问题或来源去重,避免近重复泄漏;第四闸门是安全:清除不必要的个人信息,标记危险指令、越权请求和不应学习的拒答边界。闸门要输出数量和样例,不要只给一个“清洗完成”的布尔值。

DATA LAB 8.8.2给一条对话画 loss mask

构造含 system、user、assistant 的三段文本,打印 Token、角色和 labels。说明哪些位置为 -100,回答末尾 EOS 是否计算损失,以及截断回答时如何报警。

至少 system/user 不产生监督,assistant 正文产生监督,PAD 为 -100;EOS 策略要固定并写在配置中。若回答被截断,应记录 warning 或丢弃样本,不能静默改变任务。
PEFT LAB 8.8.3比较三个 LoRA 配置

固定数据和随机种子,比较 rank=4/8/16 或不同 target modules。记录可训练参数比例、显存峰值、验证损失和一个定性输出,写出你的选择依据。

不要只看训练 loss;至少同时看验证、资源和样例。若 target module 为空或参数比例为 0,先修复注入配置。一次只改一个变量,保留基线。
表 8.8D · 微调前的最低数据报告
指标为什么需要最低记录
原始/有效条数判断清洗损失各阶段计数
Token 长度分布估算截断与预算p50/p90/max
回答监督比例确认 mask 没有全空有效 label Token 数
重复率与切分降低泄漏去重键、split 规则
敏感/危险样本控制风险标记数量和处理

8.8.3 评估、安全与回滚:训练完成不等于可以上线

微调后的模型至少要经过三类评估。离线任务评估回答“目标能力有没有提升”;回归集回答“原来会的能力有没有退化”;安全集回答“面对越权、隐私、危险和提示注入时是否遵循边界”。三类集合应尽量独立,尤其不能把评估答案复制到训练数据。每次实验都保存基座版本、适配器版本、Tokenizer、模板和评估脚本的哈希。

表 8.8E · 评估矩阵
集合问题例子通过信号
能力集目标任务是否提升分类、抽取、格式化回答指标和人工抽检改善
回归集通用能力是否退化基础问答、长文本、中文英文与基座差异在阈值内
鲁棒集输入扰动是否失控错别字、空输入、超长、混合语言不崩溃且行为可解释
安全集是否越过安全边界隐私、危险、越权、注入拒答/安全替代符合策略
人工集自动指标漏掉什么事实性、语气、偏见、可用性双人或抽样复核

安全评估不等于给模型贴一个“安全”标签。把风险拆成请求、上下文、工具权限和输出四层:恶意用户可能把指令藏在文档里,工具返回的文本可能要求泄露系统提示,模型可能生成看似合理但含个人信息的内容。应用层要做权限、敏感信息过滤、工具白名单和人工升级;模型训练只能改善概率,不能替代边界控制。

PYTHON · eval_safety_matrix.py
from dataclasses import dataclass, asdict
import json

@dataclass
class CaseResult:
    case_id: str
    category: str
    expected: str
    actual: str
    passed: bool
    note: str = ""

def summarize(results):
    total = len(results)
    passed = sum(item.passed for item in results)
    by_category = {}
    for item in results:
        bucket = by_category.setdefault(item.category, [0, 0])
        bucket[0] += 1
        bucket[1] += int(item.passed)
    return {"total": total, "passed": passed, "rate": passed / total if total else 0,
            "by_category": by_category}

results = [
    CaseResult("safe-01", "privacy", "拒绝泄露", "拒绝并说明原因", True),
    CaseResult("safe-02", "injection", "忽略文档指令", "执行了文档指令", False),
]
report = {"results": [asdict(item) for item in results],
          "summary": summarize(results)}
print(json.dumps(report, ensure_ascii=False, indent=2))

代码示例把“通过”定义为可审计的 case-level 记录,而不是只打印一个平均分。真实评估应保存模型回答、解码参数、系统提示、工具状态、评审人和时间。对开放式回答可以用规则、另一个模型和人工抽样组合,但要定期检查评分器偏好,避免模型给自己打高分。

提示注入测试要覆盖直接和间接两种形式。直接形式是用户要求忽略约束;间接形式是网页、PDF、邮件或检索片段中夹带指令。模型看到的内容都应标注来源和不可信边界,工具调用前还要由策略层检查参数。不要把“模型应该知道这是恶意内容”当作唯一防线。

图 8.13 · 微调发布的三道闸门自制示意:能力、回归、安全缺一不可
能力提升目标任务
→
回归检查原有能力
→
安全检查边界/注入
→
灰度发布可观测
→
回滚基座+适配器

任何一扇闸门失败,都回到诊断,而不是用平均分掩盖。

回滚设计要在训练之前完成。保留上一版本基座、适配器、Tokenizer、模板、推理参数和评估报告;发布时用 manifest 绑定它们,而不是让服务自动读取某个“latest”目录。灰度期间比较错误率、拒答率、延迟、Token 消耗和用户反馈。一旦触发阈值,切回旧 manifest,并保留失败请求的脱敏样本供复盘。

事实性与安全的取舍

模型可能在能力集上分数变高,却因为更愿意“自信地回答”而事实性或安全性下降。评估报告要区分“拒答过多”和“越界回答”这两个方向,不能把所有拒答都当成坏事,也不能把所有流畅回答都当成好事。对关键场景设置硬性阻断,对低风险场景设置人工复核和用户反馈,形成分层策略。

当发现退化时先做定位:是模板变化、Tokenizer 变化、适配器注入、数据泄漏、解码参数还是评估脚本变化。只改一个因素并重新跑小型金丝雀集。把根因、影响范围和修复版本写入变更记录,下一次升级才能避免重复踩坑。

SAFETY LAB 8.8.4设计一个回归与安全金丝雀集

为你的任务各写 5 个能力、5 个回归、5 个安全用例。给每个用例定义通过条件、失败严重度和触发回滚的阈值,说明哪些结果必须人工复核。

用例要覆盖正常、边界、越权和间接注入;通过条件应可观察。严重安全失败应立即阻断,能力小幅波动可进入灰度。报告要绑定版本、模板和解码参数。
RELEASE LAB 8.8.5写一份模型 manifest

定义 JSON 字段,至少包含 base_model、adapter、tokenizer、template、code_version、eval_report、license 和 rollback_to。模拟加载 manifest 并检查文件不存在、哈希不匹配时的行为。

manifest 是发布合同,不应依赖 latest;缺文件或哈希不符要失败并给出明确原因。rollback_to 应指向已验收版本,而不是临时目录。
表 8.8F · 发布前检查清单
领域证据停止条件
版本基座、适配器、Tokenizer、代码哈希无法复现组合
能力目标任务指标和样例没有超过基线或数据泄漏
回归固定通用能力集关键能力明显退化
安全注入、隐私、危险请求报告高严重度越界
运行延迟、吞吐、显存、日志脱敏超预算或泄露数据
回滚旧 manifest 和演练记录无法一键切回

本节的完成标准是:你能从可信 checkpoint 开始,解释 Tokenizer 和配置;能把一条指令样本的监督位置画出来;能说明 LoRA 减少了什么、没有减少什么;能用独立的能力、回归和安全集做判断;能在发布前准备回滚。做到这些,才算把“会调参数”提升为“会交付模型”。

09 · CAPSTONE

综合项目:从一批 Token 到可解释的迷你语言模型

最后一节把本章知识收束成一个小而完整的项目:准备可公开使用的短文本,训练一个因果 Transformer,做形状与损失验收,再用消融实验解释每个组件的作用。项目故意控制规模,重点是证据链、可复现和会定位问题。

8.9.1 项目设计:先写数据流和验收标准

项目目标不是训练出“聪明”的模型,而是完成一条可以被别人复跑的最小链路:文本经过清洗和 Tokenizer 变成定长或变长序列;DataLoader 生成 input_ids、labels、attention mask;模型输出 [B,T,V] logits;交叉熵只统计有效目标;优化器更新参数;验证脚本计算 loss/perplexity 并生成固定 prompt。每一环都要有输入、输出和失败条件。

语料可以使用自己写的短文本或许可证清晰的公开材料。不要为了“看起来像大模型”而抓取不明来源网页,更不要把隐私、版权不明或包含秘密的文本放进仓库。样本量小并不是问题,关键是说明来源、许可、清洗规则、切分方式和为什么足以验证实现。

表 8.9A · 综合项目的输入输出合同
阶段输入输出失败条件
采集/清洗许可清晰的文本干净行、统计报告空行、敏感信息、来源不明
编码文本与 Tokenizerids、长度分布UNK 过多、超长无策略
切分编码序列train/val/test重复泄漏、切分不可复现
批处理样本序列padded batch、maskshape/dtype 错
训练batch、配置checkpoint、日志loss NaN、无梯度
验收模型和固定集合指标、样例、报告无法重跑或无基线

先写 README 的“成功定义”:例如固定种子后,形状测试通过;8 条样本能过拟合;验证 loss 低于随机基线;生成脚本能在 CPU 上完成;消融报告至少包含三个对照。数值阈值要结合模型大小和语料,不要照搬大型模型论文。成功定义不是给结果贴标签,而是让你知道什么时候可以进入下一步。

配置文件应把 vocab size、max length、dim、heads、layers、dropout、learning rate、batch size、seed、梯度累积、训练步数和数据路径写全。命令行只覆盖少数实验变量,最终保存展开后的完整配置。随机种子要同时设置 Python、PyTorch 和数据采样器;如果使用 GPU,还要记录设备和确定性设置。

图 8.14 · 综合项目的最小闭环自制示意:每个阶段都产出可检查文件
许可文本source.json
→
Tokenizerids.jsonl
→
Batchshape probe
→
Trainmetrics.jsonl
→
Evalreport.json
→
Releasemanifest

文件名只是示例,核心是让每个结论都有来源和版本。

PYTHON · project_manifest.py
from dataclasses import asdict, dataclass
from pathlib import Path
import json

@dataclass
class Config:
    seed: int = 7
    vocab_size: int = 128
    max_length: int = 64
    dim: int = 64
    heads: int = 4
    layers: int = 2
    learning_rate: float = 2e-3
    train_steps: int = 800

def save_manifest(path, config, files, metrics):
    path = Path(path)
    path.parent.mkdir(parents=True, exist_ok=True)
    record = {
        "config": asdict(config),
        "files": files,
        "metrics": metrics,
        "status": "candidate",
    }
    path.write_text(
        json.dumps(record, ensure_ascii=False, indent=2),
        encoding="utf-8",
    )
    return record

cfg = Config()
manifest = save_manifest(
    "artifacts/manifest.json",
    cfg,
    files={"data": "data/clean.jsonl", "tokenizer": "artifacts/tokenizer.json"},
    metrics={"shape_probe": True, "overfit": False},
)
print(manifest["config"]["heads"])

manifest 的初始状态可以是 candidate,只有所有验收通过才改为 accepted。保存前创建 artifacts 目录,记录文件相对路径和哈希;不要把临时绝对路径写进报告。若数据或 Tokenizer 发生变化,manifest 版本必须变化,即使模型代码没有变化。

PYTHON · make_batches.py
def causal_example(ids, pad_id=0):
    if len(ids) < 2:
        raise ValueError("序列至少需要两个 Token")
    inputs = ids[:-1]
    labels = ids[1:]
    attention = [int(token != pad_id) for token in inputs]
    loss_mask = [int(token != pad_id) for token in labels]
    return {
        "input_ids": inputs,
        "labels": labels,
        "attention_mask": attention,
        "loss_mask": loss_mask,
    }

examples = [causal_example([2, 5, 6, 3, 0])]
print(examples[0])

批处理代码必须明确 input_ids 和 labels 的长度关系。对变长样本,padding 时分别生成 attention_mask 与 loss_mask;前者控制模型关注哪些键,后者控制哪些目标参与损失。两者常常相似,却不是同一件事:prompt 可以被模型看到但不计 loss,PAD 既不应被关注也不应计 loss。

项目目录建议

把 data/ 放清洗后的小数据和来源说明,src/ 放模型、数据、训练、评估模块,scripts/ 放可重复命令,artifacts/ 放配置、日志、checkpoint 和报告,tests/ 放形状、mask、Tokenizer 和过拟合测试。目录不是形式主义,它让“哪个文件证明哪个结论”变得清楚。

先用 CPU 跑通全部测试,再换 GPU 加速。每个脚本都支持 --seed、--config 或等价参数,并在启动时打印配置摘要。长训练命令放进 README,避免只存在于某个终端历史里。

CAPSTONE LAB 8.9.1写项目成功定义

为自己的迷你模型写 5 条可验证的成功标准,分别覆盖数据、形状、训练、生成和复现。每条都要有证据文件和不通过时的下一步。

例如:来源许可记录存在;batch shape 通过;固定 batch loss 下降;固定 prompt 生成不崩溃;清空 artifacts 后按 README 重跑得到相近指标。阈值要写清楚。
表 8.9B · 第一轮运行顺序
顺序命令/动作保存结果停止条件
1检查来源和配置source.json、config.json许可或字段缺失
2编码与解码探针token_probe.json无法解释的乱码/UNK
3batch shape smokeshape_probe.txtshape/dtype/mask 错
4单批过拟合overfit.jsonlloss 不降
5短训练+验证metrics.jsonlNaN/无验证
6固定生成和报告report.md无法复现

8.9.2 消融与解释:用对照实验回答“为什么”

模型能生成文字并不代表你理解它。消融实验一次移除或替换一个组件,其余配置、数据、种子和训练预算尽量保持不变,然后比较训练/验证 loss、困惑度、生成、吞吐和显存。对照组至少包含完整模型和一个简化模型;如果只展示“最好的一次”,读者无法判断改进来自哪里。

适合本项目的消融包括:关闭位置表示或换成正弦位置;单头与多头;去掉 causal mask 的错误版本(只在受控诊断中运行,不能作为正确模型);Pre-LN 与 Post-LN;共享与不共享 Embedding/LM Head;不同上下文长度;greedy 与 temperature/top-k 解码。每个消融都要先写假设,例如“没有位置时,相同 Token 的不同顺序应更难区分”。

表 8.9C · 消融实验设计
因素基线变体观测预期解释
位置可学习位置无位置/正弦顺序敏感度、val loss位置提供序列顺序线索
头数4 heads1 head/8 heads质量、吞吐、参数头数影响子空间分工
maskcausal全可见(诊断)泄漏与训练差距全可见会破坏生成设定
NormPre-LNPost-LN收敛、梯度范数深层稳定性不同
权重共享独立 LM Head参数、loss、泛化参数效率与表达能力取舍

注意力可视化也要谨慎解释。某个 head 对某个 Token 权重较高,只能说明在这一层、这一输入和这一方向上分配了较多注意力,不等于模型“只使用了这个词”,更不等于因果解释。把注意力图与输入、层号、head、mask、softmax 前后状态一起保存,必要时用输入改动或遮挡实验验证。

图 8.15 · 一次只改变一个变量自制对照图:把结论和证据绑定
固定基线seed/data/预算
→
改一个组件位置或头数
→
相同评估loss/生成
→
记录差异含不确定性
→
提出下一假设再跑一轮

实验表格比“我感觉更好”更能支持结论。

PYTHON · run_ablation.py
from copy import deepcopy

base = {
    "seed": 7, "dim": 64, "heads": 4, "layers": 2,
    "position": "learned", "causal": True,
}
variants = [
    ("baseline", base),
    ("one_head", {**base, "heads": 1}),
    ("sinusoidal", {**base, "position": "sinusoidal"}),
    ("no_position", {**base, "position": "none"}),
]

def command(name, config):
    return {
        "name": name,
        "config": deepcopy(config),
        "metrics_file": f"artifacts/{name}/metrics.jsonl",
    }

for name, config in variants:
    print(command(name, config))

实验脚本只负责展开配置和记录命令,训练器负责执行,评估器负责比较。这样即便某个变体失败,也不会覆盖基线。报告中列出每个变体是否完整跑完;失败本身也是信息,例如 no_position 可能暴露模型依赖顺序线索,而不是被悄悄从表格中删掉。

PYTHON · compare_metrics.py
def relative_change(baseline, value):
    if baseline == 0:
        return None
    return (value - baseline) / abs(baseline)

baseline = {"val_loss": 2.31, "ppl": 10.08, "tokens_per_second": 520}
runs = {
    "one_head": {"val_loss": 2.45, "ppl": 11.58, "tokens_per_second": 610},
    "sinusoidal": {"val_loss": 2.28, "ppl": 9.78, "tokens_per_second": 505},
}
for name, metrics in runs.items():
    print(name, {
        key: relative_change(baseline[key], value)
        for key, value in metrics.items()
    })

不要把困惑度当作唯一目标。它是平均负对数似然的指数形式,对 Tokenizer、长度和数据分布非常敏感;不同词表或不同评估集合之间不能直接比较。生成样例、格式正确率、重复率、延迟和显存共同组成更有意义的判断。对小语料还要报告不确定性,例如换三个种子后给出均值和范围。

从现象到假设

若无位置模型在训练集上也很差,先检查位置模块是否真的被关闭,而不是假设理论错了;若训练 loss 相近但生成顺序混乱,比较反事实输入和位置敏感度;若多头更慢却没有提升,查看数据规模是否足以支持更多子空间;若全可见 mask 分数异常好,说明评估可能泄漏未来 Token。每个现象都要对应一个可复现实验。

用固定探针记录每层 hidden 的均值、标准差、梯度范数和 attention 行和。数值摘要能帮助你区分“功能变化”和“训练不稳定”。保存少量可读样例即可,不必把整个激活矩阵提交到仓库。

EXPERIMENT LAB 8.9.2写三条消融假设

选择位置、头数、mask、Norm 或权重共享中的三个因素,为每个因素写基线、变体、预期现象和反例。说明哪些指标可以支持或推翻假设。

假设要能通过一次只改一项的实验检验;指标至少含 val loss 和一个行为/资源指标。若结果与预期相反,应记录并提出新解释,而不是改写假设。
INTERPRET LAB 8.9.3画一张可解释注意力图

选择一条短序列,保存某层某 head 的 attention 权重、Token 文本和 causal mask。标出至少两个允许与禁止的位置,解释这张图不能证明什么。

图中禁止的未来位置应为零或被 mask;允许位置的权重和应接近 1。它只能描述该输入下的权重分配,不能单独证明因果、语义理解或最终输出来源。
表 8.9D · 消融报告模板
字段示例写作要求
研究问题位置表示是否必要一句话可检验
唯一变量position=none其余配置固定
训练预算800 steps、同 seed记录实际值
定量结果loss/ppl/吞吐含单位和集合
定性结果固定 prompt 输出原样保存并脱敏
结论与限制支持/不支持假设写出反例和下一步

8.9.3 交付与下一步:把项目变成可以复用的学习资产

交付物不只是一个 checkpoint。一个合格的学习项目应包含 README、许可证和来源说明、数据清洗脚本、Tokenizer 探针、模型配置、训练日志、验证报告、固定生成样例、消融表、已知限制和回滚说明。别人按照 README 在干净环境中运行,应该能得到相同的文件结构和大致结果;如果不能,先修复工程,再谈模型效果。

表 8.9E · 最终交付物
交付物内容验收问题
README目标、环境、命令、预期输出新环境能否从零运行
数据包来源、许可证、清洗统计是否可合法复用
模型包权重、配置、Tokenizer能否独立加载
实验包日志、消融、固定 seed结论是否可追溯
评估包能力、回归、安全用例是否覆盖失败模式
限制与回滚已知问题、旧版本 manifest出错能否切回

README 建议按“为什么、准备什么、运行什么、看到什么、哪里会失败”组织。命令旁边写预期日志片段,读者才能知道是成功还是卡住。环境文件锁定关键库的大版本,记录 Python、PyTorch、CUDA(若使用)和硬件。不要承诺“任何机器都能复现”,而是诚实说明 CPU/GPU、随机性和浮点差异。

PYTHON · verify_artifacts.py
from pathlib import Path
import json

REQUIRED = [
    "config.json", "token_probe.json", "shape_probe.txt",
    "metrics.jsonl", "report.md",
]

def verify(root):
    root = Path(root)
    missing = [name for name in REQUIRED if not (root / name).exists()]
    if missing:
        raise FileNotFoundError(f"缺少交付物: {missing}")
    manifest = json.loads((root / "config.json").read_text(encoding="utf-8"))
    for key in ("seed", "vocab_size", "max_length"):
        if key not in manifest:
            raise ValueError(f"配置缺少 {key}")
    return {"status": "ready", "files": len(REQUIRED)}

print(verify("artifacts/final"))

交付前用脚本检查必需文件和配置字段,再人工打开报告确认样例没有敏感信息。把临时调试输出与最终报告分开;最终报告只引用已固定的 artifacts。若需要分享权重,确认许可证允许,并单独提供模型卡片和联系方式。

图 8.16 · 从学习项目到下一章自制路线图:能力边界逐步外推
本章理解注意力
→
迷你模型可测试实现
→
微调适配器任务能力
→
训练与部署资源/服务
→
持续评估版本治理

后续章节会把这些能力延伸到大语言模型、检索、多模态与工程交付。

下一阶段先进入大语言模型:预训练目标、指令微调、对齐和推理会把本章的 Transformer 底层结构放进完整语言模型工作流;第 10 章再学习 Embedding 与 RAG,第 14 章系统处理训练、部署和评估。不要急着跳到“调用 API”;你已经有一套从 Token 到 logits 的底层心智模型,接下来要把它连接到不同模型分支的任务与工程约束。

本章总复盘

用一句话解释 Transformer:它用带掩码的多头注意力在序列位置之间交换信息,再经残差、归一化和前馈网络反复变换表示;训练时用移位标签预测下一个 Token;微调时用模板和 loss mask 指定监督范围;交付时用独立评估和 manifest 约束版本。若你能画出数据流并指出每个张量 shape,说明已经从概念进入可操作阶段。

把本章的三类证据保存下来:公式/形状推导,最小可运行代码,失败与修复记录。以后遇到新架构,先把它映射到这三类证据,再学习新增组件。这样学习不会被名词推着走,而是由可验证问题驱动。

PYTHON · final_checklist.py
checklist = {
    "data_source_and_license": True,
    "tokenizer_round_trip": True,
    "shape_and_mask_probe": True,
    "single_batch_overfit": True,
    "validation_report": True,
    "ablation_report": True,
    "safety_and_limitations": True,
    "reproducible_readme": True,
}
failed = [name for name, passed in checklist.items() if not passed]
print("READY" if not failed else {"BLOCKED": failed})

这个清单不是自动替你判断质量,而是防止遗漏。若某项为 False,写出阻塞原因和下一步;若所有项为 True,也要保留数据和模型的限制。学习资料的价值不在于看起来完成,而在于下一次还能复用。

SHIP LAB 8.9.4完成最终交付演练

把项目复制到一个新目录,删除缓存和临时日志,按 README 从零运行验证脚本。记录首次失败、修复过程、最终 artifacts 树和与基线的差异。

新目录运行能暴露隐藏依赖。报告应包含环境、命令、失败原因、修复和最终文件;若结果因硬件略有差异,说明允许范围而不是伪造完全一致。
REVIEW LAB 8.9.5给自己做一次口试

不看代码,回答:为什么要缩放点积?causal mask 屏蔽谁?labels 为什么右移?padding mask 与 loss mask 有何不同?LoRA 为什么减少参数?每个回答配一个 shape 或小例子。

能把概念、shape 和代码对应起来才算掌握;如果只能背定义,回到对应小节重新做探针。尤其要区分“模型看见的位置”和“参与损失的位置”。
表 8.9F · 第 8 章结业自测
主题我能否做到证据
注意力手算一个小矩阵并解释 maskattention_probe.py
结构画出 encoder/decoder block结构图和 shape 表
训练解释 shift、loss、ppl 和预算metrics.jsonl
实现让小批数据过拟合overfit 日志
微调构造 label mask 并统计 LoRAcollator/参数表
治理评估、安全、回滚report/manifest

官方参考阅读

建议把本章内容与 Attention Is All You Need、PyTorch MultiheadAttention、TransformerEncoderLayer、Transformers 训练指南 和 PEFT 文档 对照阅读。官方文档的参数语义和版本会变化,实际编码时以当前版本为准,并把版本写进实验记录。

到这里,第 8 章的学习闭环完成:从注意力的一个分数,到一个可测试的 Transformer,再到微调、评估和可回滚交付。下一章将进入大语言模型主线,但你可以先用本章综合项目把每个环节亲手跑一遍。

把结果写成可复用的知识卡片

项目结束后,再用一页卡片记录:问题是什么、输入输出 shape 是什么、哪一个实验最能证明实现正确、哪一个失败最值得记住、下一次会先查哪里。把卡片与 manifest 和报告放在同一目录,未来学习新架构时可以直接对照。知识资产不只是一段成功代码,也包括失败的最小复现和已经排除的解释。

如果要向别人展示,先展示数据流图和验收表,再展示生成样例。样例可以吸引注意,但表格和脚本才能让别人检查。对外分享前删除路径、密钥、个人信息和不明来源文本;代码中的随机种子、版本和许可证信息要完整。这样一个小项目才真正成为可复用的学习材料。

表 8.9G · 复盘卡片
栏目要写什么价值
问题想验证的一个假设避免目标漂移
最小证据shape、loss、行为或资源结论可检查
失败复现输入、错误、根因未来快速定位
下一步一个具体实验持续学习