连续项目 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 提供。这样“看谁”和“拿什么”是两个明确的职责,后面才能分别调试。
上下文并不意味着所有位置都平均混合。查询“谁发布了这款手机”可能更关注“苹果”“发布”和“手机”,而忽略语气词;查询“它的价格”需要把注意力放到前一句提到的产品。模型通过训练学习在不同任务和层中形成不同的权重分布。注意力权重可以帮助观察行为,但不能简单当作唯一的因果解释,后面会专门讨论如何谨慎解读。
每个位置仍有自己的输出,但输出已经包含了对其他位置的选择性读取。
| 组件 | 输入 | 主要职责 | 不负责什么 |
|---|---|---|---|
| Embedding | Token ID | 查表得到初始向量 | 理解上下文关系 |
| 位置表示 | 位置索引/相对距离 | 告诉模型顺序和距离 | 决定语义相关性 |
| Query | 当前位置向量 | 提出“我需要什么” | 直接保存被读取内容 |
| Key | 候选位置向量 | 描述“我适合被谁关注” | 决定最终输出值 |
| Value | 候选位置向量 | 提供被加权汇总的信息 | 单独决定权重 |
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 是否逐位置独立计算?能回答这些问题,就比背“八层、十二头、四千维”更接近真正理解。
架构图中的箭头不是装饰。箭头表示张量流动、信息可见性和梯度路径;任何一个箭头被改动,都可能改变模型的训练稳定性或因果边界。读图时把张量形状写在箭头旁边,是初学者最值得养成的习惯。
写两组含“它”的中文句子:一组需要前文才能判断指代,一组不需要。标出理想情况下应关注的 Token,并说明纯词袋、固定窗口和注意力各自可能遇到的限制。
8.1.2 为什么需要注意力:让“读取信息”成为可学习操作
如果把上下文向量写成所有 Token 的平均值,每个位置得到的结果会非常相似;如果只取固定邻居,又可能错过长距离依赖。注意力的核心是为每个查询位置生成一组权重,权重总和通常为 1,再用它们加权候选 Value。权重越大,表示当前位置在这一次计算中读取该位置的信息越多。每个查询都有自己的一行权重,因此“谁关注谁”会随位置和输入变化。
Query、Key、Value 可以用图书馆来理解:Query 是读者提出的问题,Key 是书架上每本书的标签,Value 是书里的内容。读者先用问题和标签匹配,得到对各本书的关注程度,再把真正的内容按权重取回来。这个类比也提醒我们:Key 适合检索,Value 适合传递,二者可以来自同一个输入,也可以来自不同序列。
同一批输入可以产生不同 Query,因此每个位置的权重行不必相同。
| 对象 | 直观问题 | 矩阵角色 | 常见混淆 |
|---|---|---|---|
| Q | 我现在需要什么信息? | 产生查询行 | 误以为是最终内容 |
| K | 我是否适合被这个查询关注? | 参与打分 | 把 Key 当成标签文字 |
| V | 如果被关注,我提供什么? | 被权重加权 | 以为决定关注程度 |
| score | Q 与 K 有多匹配? | softmax 前的数值 | 直接当概率 |
| weight | 每个候选占多少比例? | 每行归一化 | 忽略 mask 后的归一化 |
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 后可见数量,才能把“注意力不工作”拆成具体问题。
用二维 Query、三个 Key 和三个 Value,手算点积、缩放、softmax 和 context。再把第二个候选遮掉,比较遮罩前后的权重和输出。
8.1.3 读懂 Transformer Block:注意力只是中间的一段
一个常见的 Transformer Block 可以概括为:输入表示先经过多头自注意力,再与原输入做残差相加和归一化;然后进入逐位置前馈网络,再做一次残差和归一化。不同论文和实现会在归一化放置、激活函数、偏置、位置编码和 dropout 上有所不同,但职责可以分成“跨位置混合”和“逐位置变换”两类。前者让 Token 交换信息,后者对每个位置的通道做更丰富的非线性变换。
残差连接把输入直接送到后面,给梯度提供较短路径,也让每个子层只需要学习“在已有表示上补什么”。归一化把每个位置的通道尺度控制在较稳定范围,减少不同层之间的数值漂移。前馈网络通常先把维度扩大,再经过激活函数压回隐藏维;它不直接混合不同位置,但会在每个位置独立地重写特征。
“堆叠很多层”不是把同一层复制后不加区别。每层有独立参数,注意力头可能关注不同关系,深层表示通常携带更复杂的上下文组合。层数、隐藏维度、头数和前馈维度一起决定参数量与计算量;学习时先用一层或两层的小模型验证形状和损失,再扩展规模。
| 步骤 | 典型输入/输出 | 是否混合位置 | 调试重点 |
|---|---|---|---|
| 输入 | [B,T,D] | 否 | Embedding/位置已对齐 |
| 自注意力 | [B,T,D]→[B,T,D] | 是 | Q/K/V、mask、head |
| 残差+Norm | [B,T,D] | 不新增位置 | shape、finite、顺序 |
| FFN | D→D_ff→D | 逐位置 | 激活、维度、参数量 |
| 堆叠 | 重复 N 层 | 每层都可混合 | 层数、缓存、梯度 |
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 只改变视图/排列,不会学习新的语义对齐。
给定输入 [2, 5, 16],设计一层隐藏维 16、前馈维 64、4 个头的 Block。标出每个算子的输入输出 shape、哪些步骤混合位置、哪些步骤只处理当前位置。
| 能力 | 最低证据 | 自测问题 |
|---|---|---|
| 上下文 | 能说明单个 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 乘大。
| 节点 | 典型 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 必须相同 |
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 和显存峰值。
问“信息有没有跨位置流动”是理解每个子层职责的快捷方法。
易错清单:看到结果先不要急着解释
第一类错误是把 batch、Token、hidden 三个轴混为一谈,导致模型“能跑”但把不同样本互相注意;第二类错误是把 head 维拼接顺序弄错,输出仍然是 [B,T,D],却失去预期能力;第三类错误是残差相加前维度不一致,通过隐式广播把错误藏起来;第四类错误是 LayerNorm 的归一化轴写错,把 Token 轴也混进去;第五类错误是训练和推理使用不同的 mask 或 dropout 状态。
遇到这些问题时,先用两个 batch、三个 Token、四个 head 的极小输入运行,打印每一步的完整 shape 和一个固定位置的数值。再把相同输入复制成两份,确认 batch 之间不会互相影响;把一个 Token 改动,确认允许的后续位置可能变化、被屏蔽的过去位置不违反因果约束。小输入比大日志更能说明问题。
为你实现的 Transformer 画出 [B,T,D] 到 logits 的节点表,至少列出 Q/K/V、scores、weights、attention output、FFN 中间层和残差输出。每行写 shape、是否跨 Token、一个可能的错误。最后说明当 T 翻倍时,哪个中间对象最容易造成显存压力。
| 问题 | 合格回答应包含 | 还不会时的动作 |
|---|---|---|
| 信息从哪来 | 说明注意力跨位置交换,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,可能无法复现。每个实验同时记录三者,才有解释空间。
| 陈述 | 类别 | 验证方法 |
|---|---|---|
| 残差前后 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”两个单词更重要,因为它决定了信息是否跨序列流动。
| 注意力类型 | Q 来源 | K/V 来源 | 允许的读取范围 |
|---|---|---|---|
| 编码器自注意力 | 编码器输入 | 同一编码器输入 | 非 PAD 的整个输入 |
| 解码器自注意力 | 已生成前缀 | 同一目标序列 | 当前位置及其左侧 |
| 交叉注意力 | 解码器隐藏状态 | 编码器输出 | 源序列的可见位置 |
| 检索式注意力 | 查询向量 | 文档向量 | 候选集合 |
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。
给定源序列长度 6、目标序列长度 4、隐藏维 16,分别写出自注意力和交叉注意力的 Q/K/V 来源与分数矩阵形状。
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 的影响。
mask 必须在 softmax 前应用,否则不可见位置仍可能分到概率。
| 表达式 | 形状 | 检查 | 常见错误 |
|---|---|---|---|
| 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 维度不匹配 |
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 的例子上运行,就还没有完成。
构造分数 [1000, 999, 998],分别直接计算 softmax 和先减最大值后的 softmax。再用 float32 与 float16(若设备支持)比较输出,说明为什么要关注 dtype。
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。
| Mask | 屏蔽对象 | 典型形状 | 使用阶段 |
|---|---|---|---|
| padding | PAD Token | [B,1,1,T] | 编码器/解码器自注意力 |
| causal | 当前之后的未来位置 | [1,1,T,T] | 解码器训练与生成 |
| cross | 源序列 PAD/不可见项 | [B,1,1,T_source] | 解码器交叉注意力 |
| loss mask | 不计损失的标签 | [B,T] | 训练目标汇总 |
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 生成和转换集中在一个模块,能减少不同层各自“猜语义”的风险。
上三角被屏蔽,训练可以并行,信息仍保持因果顺序。
Mask 的易错清单
第一,padding mask 与 causal mask 的逻辑与/或顺序;第二,batch 和 head 维度广播是否符合预期;第三,整行被屏蔽后是否产生 NaN;第四,loss mask 是否与 attention mask 混用;第五,推理缓存增长后 mask 的长度是否同步。每个错误都可以用长度 3~5 的小矩阵在 CPU 上复现。
调试时把 mask 画成 0/1 矩阵,并打印每行可见数量。看到未来位置出现非零注意力,先查 mask;看到 PAD 行有异常输出,再查池化、loss 和残差,而不是立即改学习率。
为 batch 中两条不同长度序列生成 padding mask,再与长度为 5 的 causal mask 合并。打印每个 Query 的可见 Key 数,确认 PAD 和未来位置都被屏蔽。
| 能力 | 最低证据 | 自测问题 |
|---|---|---|
| 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 的职责;也不是为了减少参数,它只改变分数尺度。
| 步骤 | 得到什么 | 检查问题 |
|---|---|---|
| 点积 | Query 与每个 Key 的相似度 | 行是 Query 还是 Key |
| 缩放 | 分数除以 √d | d 是否为每个 head 的维度 |
| 加 mask | 不允许位置变为极小值 | True/False 语义是什么 |
| softmax | 非负权重,行和约等于 1 | 是否有整行被屏蔽 |
| 加权和 | Value 的混合表示 | V 的最后两轴是否对齐 |
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] 表示每个样本的补齐位置。调用内置模块前必须查当前版本文档并写一个两行探针验证,不要根据变量名猜。
同一张矩阵也可叠加 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 更接近行为验收。
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 设计决定,但它不应贡献训练目标。把“允许读取”和“参与损失”分开写,是最值得保留的习惯。
用 3 个 Token、head_dim=4 的数值手算一行 attention,屏蔽最后一个 Key。再用代码输出权重,检查被屏蔽位置、行和和输出维度。
实现一个小 attention,比较修改未来 Token 和修改过去 Token 对当前位置输出的影响。固定 eval 模式,记录最大绝对差并解释它。
| 现象 | 先检查 | 不要先做 |
|---|---|---|
| 权重全相等 | 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 经过多层变换后可能已经把信息写入其他位置。用遮挡、替换和层间对照补充热图,避免把相关性直接说成因果。
| 字段 | 必须记录 | 缺失的后果 |
|---|---|---|
| 层/head | layer_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 头”更有信息。
“Head 1/2”只是可能的观察角度,不能预先断言某个头一定代表某种语言关系。
| 维度 | 单头 | 多头 | 学习时要问 |
|---|---|---|---|
| 关系图 | 一张注意力矩阵 | 每头一张矩阵 | 不同头是否互补 |
| 隐藏维 | 全部 D | 拆成 H 个 d_head | D/H 是否整除 |
| 输出 | 直接得到 context | 拼接后过输出投影 | Concat 顺序是否一致 |
| 参数 | 一组 Q/K/V | 可合并为大投影 | 权重布局如何保存 |
| 解释 | 关系较集中 | 可做头级对照 | 注意力权重不是充分因果解释 |
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 和权重提取位置,再决定是否剪枝或增加容量。
用 [2, 7, 24] 的 Tensor 测试 1、2、3、4、6 个头。记录每种 head_dim,并验证 merge(split(x)) 与原 Tensor 在数值上相同。
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 决定多头结果怎样回到模型空间。初始化、偏置、权重共享和量化都可能改变实际行为,因此实现复现时要完整记录配置。
| 张量 | 形状 | 轴的含义 | 下一步 |
|---|---|---|---|
| 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] | 加权 Value | transpose+concat |
| output | [B,T,D] | 回到模型空间 | 残差/Norm |
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=...),或者保持统一的四维表示。
为多头前向添加至少 8 个断言,覆盖 D 可被 H 整除、Q/K 最后一维一致、scores 的四维形状、mask 可广播、输出回到 [B,T,D] 和所有值为 finite。
8.3.3 位置表示:没有顺序,注意力看到的是一袋 Token
自注意力本身对输入位置做全连接匹配,如果只把 Token 向量集合交给它,交换序列中两个 Token 可能得到相同的集合关系。语言却高度依赖顺序:“狗咬人”和“人咬狗”包含相似词但意义不同。因此 Transformer 需要把位置或相对距离注入表示,使模型能区分第几个 Token、两个位置相隔多远以及方向。
绝对位置编码可以是固定的正弦/余弦函数,也可以是可学习的位置 Embedding;相对位置方法直接把位置差加入注意力分数或表示;RoPE 一类方法通过对 Q/K 做位置相关旋转,让相对位置信息体现在点积中。不同方案对上下文外推、缓存、长度上限和实现复杂度有不同影响,不能只按“更新”或“传统”评价。
| 方案 | 加入位置的地方 | 优点 | 注意事项 |
|---|---|---|---|
| 可学习绝对位置 | 加到 Token Embedding | 简单、可训练 | 通常有长度上限 |
| 正弦/余弦 | 加到输入表示 | 无需位置参数、规则明确 | 外推不等于一定有效 |
| 相对位置偏置 | 加到 attention score | 直接表达距离 | 实现和桶化要固定 |
| RoPE | 旋转 Q/K | 相对关系自然进入点积 | 频率、缩放和长度外推需匹配 |
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 和可用上下文。
构造两个只交换词序的句子,用无位置 Embedding 的简单自注意力和加入位置后的版本分别计算表示。观察哪些输出不变、哪些输出发生变化,并说明实验限制。
| 能力 | 最低证据 | 自测问题 |
|---|---|---|
| 多头 | 能拆分、计算、拼接并解释 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 更好。
| 比较方式 | 固定项 | 变化项 | 适合回答 |
|---|---|---|---|
| 固定 hidden | D、层数、FFN | H 与 d 反向变化 | 头的分工是否有益 |
| 固定 head_dim | d、层数 | H 和 D 同时变化 | 扩大模型的收益 |
| 固定参数预算 | 近似总参数 | H、D、FFN 协同调整 | 资源受限下的选择 |
| 固定训练预算 | steps、Token、seed | 结构 | 收敛/泛化差异 |
位置表示解决的是“同一组 Token 换序后,模型如何知道顺序”。可学习绝对位置把位置 ID 映射为向量,简单且能适应训练长度;固定正弦位置不需要学习额外参数,并能提供不同频率的相对变化;相对位置或 RoPE 更直接地让距离/旋转关系进入注意力。它们的细节不同,但共同目标是让注意力区分“谁在前、谁在后、相隔多远”。
位置方法必须与缓存和长度扩展一起考虑。自回归生成时,KV cache 中的旧 Key/Value 对应旧位置,新 Token 不能重新从 0 开始编号;如果位置索引重置,模型会把续写误认为新序列。扩展上下文时也要验证训练长度外的行为,不要只修改一个 max_length 配置就宣称支持更长输入。
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。
方法不同,验收仍要回到顺序敏感、长度和缓存。
RoPE 与相对关系的直觉
可以把 RoPE 想成按位置对 Q、K 的二维子空间做不同角度的旋转。两个位置的相对角度会影响它们的点积,因此注意力更容易感知相对距离;实现上必须保证 Q/K 的维度分组、偶奇索引、位置编号和缓存偏移一致。不要把“有旋转”理解成模型已经知道自然语言中的距离,训练数据仍决定它如何使用这种几何。
相对位置偏置则直接给某些相对距离的分数加一个可学习或固定的偏置。它容易画成 [T,T] 的矩阵,却会增加长度相关的内存或计算。任何位置方案都要记录最大训练长度、推理长度、外推策略和失败样例。
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 对照,不要手动复制一部分权重后假设等价。
设计 H=1、2、4 的三组实验,分别说明是否固定 hidden、head_dim、参数量和训练 Token。为每组写出 shape、参数预算和评价指标。
准备相同 Token 的三种排列,分别使用无位置、可学习位置和正弦位置。比较每种设置的输出差异,并解释训练前随机模型与训练后模型的区别。
| 问题 | 证据 | 高风险误解 |
|---|---|---|
| head 是否分工 | 权重+反事实+行为 | 凭一张热图命名角色 |
| 位置是否生效 | 排列探针和长度测试 | 只改配置不上实验 |
| cache 是否正确 | 全序列/增量输出对照 | 位置从 0 重置 |
| 迁移是否一致 | 权重键和 shape 映射 | 忽略 fused QKV |
位置方法的选择要写成工程约束
选择位置表示时,把训练长度、推理长度、缓存、批处理和迁移一起列出来。可学习绝对位置实现简单,但超出训练上限通常需要重新训练或外推策略;固定正弦位置参数少,却不自动解决长距离泛化;相对偏置和 RoPE 更贴近距离关系,但实现、量化和 cache 偏移有额外边界。没有一种方法在所有任务中无条件最好。
多头的调试还要检查 head_dim 的数值尺度。如果每个 head 太窄,QK 点积的表达空间不足;如果 head 太宽,单层参数和显存增加。使用统一的输入、初始化和随机种子,分别观察每个 head 的权重熵、有效位置数量和输出范数。权重熵很低可能表示尖锐关注,也可能表示 mask 或温度错误,必须结合反事实测试。
| 测试 | 预期 | 暴露的问题 |
|---|---|---|
| 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。输入序列完整可见时,句子中后面的信息可以帮助理解前面的词,这对分类和抽取很有用。
“输出维度不变”让层可以堆叠,但每个子层承担的计算职责不同。
| 阶段 | 输入→输出 | 位置可见性 | 主要目的 |
|---|---|---|---|
| 自注意力 | [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] | 不新增读取 | 形成下一层输入 |
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 和是否归一化。
构造同一序列的两个版本,只改变最后一个有效 Token。比较编码器第一、第二个位置的输出;再把最后一个位置标记为 PAD,观察差异如何变化。
8.4.2 残差与归一化:让深层网络有稳定的梯度路径
残差连接把子层输入与子层输出相加,形式是 x + F(x)。如果 F 学到的是一个小修正,网络仍能保留原表示;如果任务需要大变化,训练可以逐层累积。残差还为反向传播提供直接路径,减轻深层网络中梯度逐渐变小或爆炸的问题。前提是两条分支形状一致,且数值尺度可控。
LayerNorm 通常沿最后一个隐藏维度对每个样本、每个位置独立归一化,再用可学习的缩放和偏移恢复表达能力。它与 BatchNorm 不同,不依赖 batch 中其他样本的统计,因此更适合变长序列和小 batch。eps 防止方差接近零时除零,dtype 和精度会影响统计稳定性。
Pre-LN 与 Post-LN 是常见的两种层结构。Pre-LN 先归一化再进入子层,残差路径更直接;Post-LN 先相加再归一化,早期原始 Transformer 常见。两者的初始化、学习率、深度和训练稳定性可能不同。不要把论文里的公式和库里的类名直接等同,先读 forward 顺序。
| 问题 | 正确检查 | 常见误操作 |
|---|---|---|
| 残差相加 | 两侧 [B,T,D] 一致 | reshape 凑维度 |
| LayerNorm 轴 | 通常归一化最后一维 D | 把 Token 轴一起归一化 |
| eps | 小但非零,符合实现 | 为消除 NaN 随意调很大 |
| Pre/Post 顺序 | 按代码和配置记录 | 只看模块名字猜结构 |
| train/eval | dropout 和统计状态正确 | 验证时仍随机 |
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 输入方差是否异常、梯度范数是否过大、学习率是否改变。把每一步的最小复现保存下来,才能知道是数据还是数值路径。
用同一个随机输入和分支,分别实现 Pre-LN、Post-LN 两种结构。打印输出均值、标准差和输入梯度范数,并写下你观察到的差异。
8.4.3 前馈网络与堆叠:逐位置扩大通道,再逐层积累抽象
Transformer 中的前馈网络(FFN)通常对每个位置使用相同的两层线性变换:D → D_ff → D,中间经过 ReLU、GELU 或其他激活。它不会把位置 i 的输入与位置 j 混合,跨位置的信息交换已经由注意力完成;FFN 更像是对每个位置的通道进行“思考”和重编码。参数在所有 Token 位置共享,因此序列长度增加不会新增 FFN 参数。
D_ff 常是 D 的数倍,带来大量参数和计算。激活函数决定负值、平滑性和梯度形状;dropout 或门控结构可以改变正则化和表达方式。现代语言模型常使用门控 FFN 变体,例如把一个分支作为门控制另一个分支,但学习时先把两层线性 FFN 跑通,再分析门控带来的收益。
堆叠层数会增加有效感受野和变换深度,却也提高显存、延迟和训练难度。每层的注意力权重、激活和梯度都可能占内存;训练时可以使用梯度检查点、混合精度或分片,但优化前必须用小模型建立参考输出。层数不是越多越好,要与数据量、目标和可用资源匹配。
| 参数 | 示例 | 影响 | 检查 |
|---|---|---|---|
| d_model | 512 | 输入/输出隐藏维 | 残差维度 |
| d_ff | 2048 | 中间通道容量 | 显存与参数量 |
| activation | GELU | 非线性与梯度 | 实现/版本 |
| dropout | 0.1 | 训练正则化 | eval 是否关闭 |
| layers | 6 | 堆叠深度 | 层间输出与梯度 |
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 权重,逐个位置独立计算,批量输出应与循环版本一致。
同一 FFN 参数应用到所有 Token 位置,跨位置的交换由注意力完成。
层数增加前先做三项预算
先预算显存:参数、优化器状态、激活和梯度分别占多少;再预算时间:单 batch 前向、反向和数据等待各占多少;最后预算数据:训练 Token 数是否足以支撑新增参数。只有知道瓶颈,才有必要选择更深模型或优化内核。
堆叠测试可以让每层返回一个摘要:输出范数、均值、标准差、最大值和梯度范数。若某层开始出现异常,二分层数就能快速定位。不要把所有层的完整激活都打印到日志,这会让调试本身成为新的内存问题。
写一个循环版 FFN 和一个批量版 FFN,使用同一组权重处理 [2, 4, 8] 输入,验证每个位置输出一致。再交换两个 Token,观察 FFN 本身不会建立新的位置关系。
| 能力 | 最低证据 | 自测问题 |
|---|---|---|
| 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 的选择应由标签粒度决定。
| 任务 | 表示 | 聚合方式 | 易错点 |
|---|---|---|---|
| 句子分类 | [B,T,D] | masked mean 或特殊 Token | PAD 参与平均 |
| 句对匹配 | 两段或拼接序列 | 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 建立基线,再做单变量消融。
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],结果可能仍可广播到某个维度,因而必须在测试中检查两条样本的手算结果。
标签是句子级还是 Token 级,决定是否聚合。
堆叠层数与信息混合
第一层 Encoder 可能先捕捉局部邻近关系,后续层逐步组合更远的模式,但不能把固定层数直接对应成固定语义。每层的注意力权重、FFN 输出和残差流都依赖输入与训练状态。观察层间表示时,使用相同探针和相同归一化方式;不要用不同的 dropout 样本比较层差异。
层数增加会提高计算、显存和优化难度。先用两层验证数据和 mask,再逐步增加层数;每次保存参数量、训练速度、梯度范数和验证指标。若深层 loss 变 NaN,先检查 Pre/Post-LN、初始化、学习率、混合精度和 mask 空行,不要只把 batch 调小。
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 处理完整。测试越小越容易指出哪个边界被破坏。
为句子分类、序列标注和检索各选择一种 Encoder 输出处理方式,写出输入/输出 shape、mask 需求和一个失败案例。
在相同两层模型、数据、seed 和训练预算下比较两种 Norm 顺序,记录 loss 曲线、梯度范数和最终生成/分类结果,说明你观察到的限制。
| 能力 | 证据 | 常见误区 |
|---|---|---|
| 解释双向 | 说清 causal 与 padding 的区别 | 以为“全可见”没有边界 |
| 做 pooling | 能手算 masked mean | PAD 参与平均 |
| 读 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 仍然可以输出向量,但下游任务可能把跨句关系误当作句内关系。准备一个交换句子顺序的测试,观察模型是否按任务预期改变结果。
| 下游 head | 输入 shape | 标签 shape | mask 处理 |
|---|---|---|---|
| 分类 | [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 模型则有明确的源端和目标端。
并行计算不等于同时看未来;mask 维持了每个位置的因果合同。
| 模块 | Q 来源 | K/V 来源 | Mask |
|---|---|---|---|
| Masked self-attention | 目标序列 | 目标序列 | causal + target PAD |
| Cross-attention | 目标隐藏状态 | 源/编码器输出 | source PAD/权限 |
| LM head | 最终隐藏状态 | 词表权重 | loss mask |
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。
长度为 6 的目标序列,分别写出位置 0、2、5 可以看到的 Key 索引。再加入两个 PAD,说明 causal 与 padding mask 如何同时生效。
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、解码文本、策略、模型版本和停止原因。
| 策略 | 选择方式 | 优点 | 风险 |
|---|---|---|---|
| Greedy | argmax | 确定、快速 | 重复、缺少探索 |
| Temperature | 调整 logits 温度 | 控制随机性 | 高温引入噪声 |
| Top-k | 只从 k 个候选采样 | 限制极低概率词 | k 固定不适合所有步 |
| Top-p | 从累计概率集合采样 | 候选数随分布变化 | 实现边界需测试 |
| Beam search | 保留多条前缀 | 适合部分序列任务 | 成本高、未必更自然 |
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 和序列状态。
为同一组 logits 运行 greedy、temperature=0.7、top-k=2 三种策略,固定 seed 运行两次。记录 Token、停止条件和是否重复,并说明哪些差异来自随机采样。
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 和截断策略都属于训练合同,不能只在推理脚本里临时拼接。
| 位置 t | decoder 输入 | 训练标签 | 推理时看到 |
|---|---|---|---|
| 0 | BOS/提示起点 | 第一个目标 Token | 用户提供的前缀 |
| 1 | 真实 y₀ | y₁ | 模型自己生成的 y₀ |
| t | 真实 y_{t-1} | y_t | 自己生成的历史 |
| 结束 | 真实前缀 | EOS | EOS 或 max_new_tokens 停止 |
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 和策略,按链路定位。
给序列 [BOS, 我, 学, Python, EOS] 写出 decoder_input、labels 和 mask。再故意让 labels 不右移,观察模型在位置 0 实际被要求预测什么。
| 能力 | 最低证据 | 自测问题 |
|---|---|---|
| 因果边界 | 能生成并测试 causal mask | 训练如何并行却不偷看未来 |
| 生成 | 能实现停止条件和一种采样策略 | temperature 改变了什么 |
| 对齐 | 能写出右移输入、labels、loss mask | 推理为什么与 Teacher Forcing 不同 |
自回归生成是一个循环,不是一次分类
给定前缀后,模型输出最后一个位置的 logits,解码器把它变成下一个 Token,再把新 Token 接回输入继续计算。这个循环使推理长度和成本逐步增长,也使 EOS、最大新 Token、停止词和用户取消都成为服务合同的一部分。训练时可以并行计算整段 labels,推理时却必须按步生成;两者的 mask 和位置编号要保持一致。
贪心解码每次选最大概率,稳定但容易重复;temperature 改变分布尖锐程度,低温更确定,高温更发散;top-k 只在前 k 个候选中抽样,top-p 按累计概率截断候选集合。采样参数不是“越大越有创造力”的旋钮,而是质量、可控性、重复和安全风险的取舍。评估时先固定 greedy 基线,再一次只改变一个参数。
| 策略 | 特点 | 可复现性 | 风险/注意 |
|---|---|---|---|
| Greedy | 每步取最大 logit | 高 | 重复、局部最优 |
| Temperature | 缩放 logits 后采样 | 需 seed | 高温会放大尾部噪声 |
| Top-k | 固定数量候选 | 需 seed | k 不适合所有分布 |
| 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 重排时忘记同步索引。
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 很低”和“自由生成稳定”这两种证据。
训练快不代表生成一定稳定,必须单独验收。
停止条件是产品逻辑的一部分
生成 API 至少要支持 EOS、最大新 Token、最大总长度、停止字符串、超时和用户取消。停止词可能跨越两个 Token,不能只检查单个 ID;流式输出还要缓存少量尾部文本,确认停止词后不把它继续发送给用户。对批量生成,某个样本先结束时可以标记 finished 并停止更新它的 cache,但不能让它的 PAD 影响其他样本的 attention 或 loss。
重复控制(如 repetition penalty、no-repeat n-gram)要谨慎使用。它们可能降低机械重复,却也可能破坏代码、公式或必要的术语重复。先在固定样例上记录原始输出,再逐一加入规则,说明规则的目标和副作用。安全策略不要只靠解码惩罚,危险请求仍需模型/应用层的策略判断。
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 上手算平均分母。
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 基线,避免优化掩盖逻辑错误。
在同一个模型上实现 greedy 和 temperature=0.8 的采样,固定 prompt,记录三次输出、seed、重复率和停止原因。解释为什么随机输出不能只比较一次。
对长度不超过 8 的 prompt,分别用完整前缀和逐 Token cache 生成,逐步比较 logits 或选出的 Token。列出不一致时的排查顺序。
| 项目 | 最低证据 | 失败时先查 |
|---|---|---|
| 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,而不是凭感觉调参。
| 字段 | 用途 | 异常信号 |
|---|---|---|
| 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。
| 对象 | 形状/类型 | 含义 | 常见错误 |
|---|---|---|---|
| input_ids | Long [B,T] | decoder 输入 | 把 labels 原样输入 |
| logits | Float [B,T,V] | 每位置的词表分数 | 轴顺序错、先 softmax |
| labels | Long [B,T] | 下一 Token 目标 | 未右移、dtype 错 |
| loss_mask | Bool [B,T] | 有效损失位置 | PAD 仍贡献损失 |
| token_count | 标量 | 有效分母 | 按 batch 平均造成偏差 |
输入和目标只错开一位;PAD、系统提示或不训练区域由 loss mask 排除。
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 错误。
构造两个长度不同、含 PAD 的 batch,分别用 sum/有效 Token 和默认 mean 计算损失。说明两种口径何时会给出不同的训练权重。
8.6.2 优化器与学习率:控制参数更新的步幅和方向
反向传播得到的是损失对参数的梯度,优化器决定怎样使用梯度更新参数。AdamW 为每个参数维护一阶和二阶统计,能适应不同维度的梯度尺度;weight decay 以与梯度更新解耦的方式对参数施加衰减。它不是“自动找到最优答案”的黑盒,学习率、batch、初始化、数据质量和目标仍决定训练结果。
学习率过大可能使 loss 爆炸或在最优点附近震荡,过小则学习缓慢或停在差的区域。Transformer 训练常使用 warmup,让学习率从较小值逐步升高,再按步数衰减;warmup 比例、总步数和峰值学习率必须与有效 Token batch 相关。改变梯度累积或 batch 后,不要继续使用旧的步数计划而不解释。
梯度裁剪把过大的梯度范数限制在阈值内,能减少偶发爆炸,但不能修复错误的 mask、标签或学习率。混合精度训练需要 GradScaler 或框架提供的等价机制,检查梯度是否 finite;梯度累积要在若干 micro-batch 后再 step,并清楚损失是否除以 accumulation steps。
| 量 | 说明 | 异常信号 | 先查什么 |
|---|---|---|---|
| learning_rate | 当前步长 | 骤升、始终为零 | scheduler.step 顺序 |
| grad_norm | 梯度整体大小 | 突然爆炸/全零 | loss、mask、精度 |
| loss | 当前目标误差 | NaN、无下降 | labels、输入、分母 |
| tokens_per_step | 一次更新看到的有效 Token | 不同实验不一致 | batch/累积/mask |
| weight_norm | 参数尺度 | 持续异常增长 | lr、decay、初始化 |
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 步的实验,可能有很多步实际没有更新。
设置 warmup=5、总步数=30,打印每一步学习率。故意把 scheduler.step 放在 optimizer.step 前后各运行一次,比较第一步和峰值位置。
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 分离和按需释放中间张量。
| 预算项 | 测量方式 | 为什么重要 |
|---|---|---|
| 有效 Token/step | sum(loss_mask) | 比较不同 batch 的训练信号 |
| padding 比例 | 1−有效/总长度 | 识别无效计算 |
| 最大长度 | batch 内 max T | 显存峰值和截断 |
| tokens/sec | 有效 Token/耗时 | 衡量吞吐 |
| peak memory | 设备峰值统计 | 决定可运行规模 |
| checkpoint 间隔 | step 或 Token 数 | 恢复和评估频率 |
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 能接近很低;再跑小语料几百步,观察验证和生成;最后才扩大长度、层数和数据。每一步都有明确的退出条件,避免把长时间训练当成调试方法。
假设显存允许每步 4096 个有效 Token,设计两种变长 batch 和梯度累积方案。记录每步有效 Token、padding 比例、预计吞吐和 checkpoint 间隔。
| 能力 | 最低证据 | 自测问题 |
|---|---|---|
| 损失 | 能对齐 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,字段名固定,后续脚本才能绘图。
| 顺序 | 事件 | 核心检查 | 日志字段 |
|---|---|---|---|
| 1 | 取 batch/搬 device | shape、dtype、有效数 | batch_tokens、device |
| 2 | 前向 | logits finite、shape | logits_range |
| 3 | loss | shift、ignore_index、分母 | loss、valid_tokens |
| 4 | 反向 | 梯度是否存在/finite | grad_norm |
| 5 | 更新/调度 | step 是否按累积更新 | lr、update_count |
| 6 | 保存/评估 | 文件可读、指标独立 | checkpoint、val_loss |
交叉熵平均值有两个常见分母:按所有位置平均,或只按非 PAD/非 prompt 的有效目标平均。变长 batch 下,如果每个样本先平均再对样本求平均,短样本和长样本权重不同;按有效 Token 汇总通常更接近“每个预测位置同权”。选择不是绝对对错,但要写清楚,训练和验证必须使用相同规则。
困惑度 exp(loss) 只在 loss 使用自然对数且分母定义一致时才可解释。loss 很小不一定代表生成好:数据可能泄漏、Tokenizer 可能把常见词合并得更好,或模型只记住训练集。报告 ppl 时同时列 Tokenizer、评估集合、有效 Token 数和是否忽略 PAD。
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 要触发报警,而不是静默继续。
只看 loss 容易错过梯度爆炸、学习率跳变和数据问题。
有效 Token 与训练预算
显存预算至少由参数、梯度、优化器状态、激活和 attention 矩阵共同决定。参数量相同时,序列长度和 batch 变化仍会显著影响激活;长度翻倍通常比 batch 翻倍更容易触发注意力矩阵的二次增长。先用小 batch 测显存峰值,再通过梯度累积增加有效 batch,不要直接把每个维度都调大。
Token budget 比 epoch 更适合比较变长数据集。一个 epoch 在短样本很多时可能只有很少 Token,在长样本很多时则可能过大。报告用“看过多少有效 Token、更新多少次、验证间隔多少 Token”描述进度,必要时再补充 epoch。数据切分和采样权重也要写清楚。
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 六个可测试函数。
给出含 PAD 和 prompt mask 的 batch,分别计算位置平均、样本平均和有效 Token 平均。解释三种分母会如何改变长短样本的权重,并选择一种用于训练/验证。
让一个 batch 的 labels 全部为 -100 或让 logits 含 NaN,设计训练循环应抛出的错误、日志字段和恢复动作。说明为何不能只把 NaN loss 替换成 0。
| 异常 | 证据 | 第一行动 |
|---|---|---|
| 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 设置要查官方文档。自定义模块适合学习和插入诊断,内置模块适合建立可靠基线;两者都要用同一组小输入对照。
| 模块 | 输入 | 输出 | 主要状态 |
|---|---|---|---|
| TokenEmbedding | Long [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] | 词表投影/权重共享 |
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”更适合学习和维护。
创建 vocab=32、dim=24、heads=4、layers=2、length=10 的模型,打印每个模块输出 shape,传入一个含 PAD 的 batch 并检查 padding 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 或文本,方便回归。
| 断言 | 预期 | 定位的问题 |
|---|---|---|
| input_ids.dtype | torch.long | Embedding 索引错误 |
| 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 错 |
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 时,测试会提醒你哪些行为不能被悄悄改变。
分别构造 ID 越界、mask 全零、labels 错位和 NaN logits 的输入。为每种异常写出应在什么阶段失败,以及错误消息需要包含哪些上下文。
8.7.3 迷你 Transformer:先让一小批数据过拟合
学习 Transformer 最有效的实验之一,是准备几条极小的序列,让模型在固定 batch 上过拟合。目标不是得到有用的语言模型,而是验证 Token、位置、causal mask、logits、shift labels、loss 和 optimizer 能组成闭环。如果连 8 条样本都无法把损失降下来,扩大数据和模型只会把问题藏得更深。
过拟合实验应固定随机种子,关闭复杂增强,使用小词表和短长度。先检查初始 loss 是否接近 log(V),再运行若干步,记录 loss、有效 Token 数、学习率和梯度范数;最后用同一前缀生成,确认模型至少能复现训练样例中的模式。过拟合成功不等于泛化成功,但失败通常说明实现或数据合同有问题。
| 阶段 | 配置 | 通过条件 | 不通过先查 |
|---|---|---|---|
| 形状 smoke | 随机 ID、无反向 | logits [B,T,V] | 维度、mask、位置 |
| 单 batch 过拟合 | 8~32 条样本 | loss 明显下降 | shift、labels、optimizer |
| 短语生成 | 固定 prompt、greedy | 停止且可复现 | EOS、cache、位置 |
| 小验证集 | 固定 train/val | 报告 loss/ppl | 泄漏、过拟合 |
| 消融 | 一次改一个组件 | 结论有证据 | 版本和随机性 |
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,不要用新配置覆盖旧结果。
没有通过前一阶段,就不要用更大模型掩盖问题。
实现完成的最低证据
你应保存一份 shape probe、一份单批过拟合日志、一份固定 prompt 的生成结果和一份配置 manifest。它们共同证明模型不仅“实例化成功”,还完成了从输入到损失再到参数更新的闭环。把这些文件放进项目 artifacts,下一节微调时可以直接复用。
使用 8~16 条短序列,分别测试正确 shift、错误 shift、带 PAD mask 和不带 mask 的版本。记录最终 loss、生成样例和每个版本的失败原因。
| 能力 | 最低证据 | 自测问题 |
|---|---|---|
| 模块 | 能组合 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 容差。不要用“看起来差不多”替代测试,也不要因为微小浮点差异就放弃定位。
| 阶段 | 实现 | 对照 | 通过条件 |
|---|---|---|---|
| A | 内置 EncoderLayer | 随机输入 shape | 前向 finite |
| B | 独立 split/merge heads | 元素数和轴 | 往返 shape 一致 |
| C | 纯 Python attention | 手算三 Token | 权重行和/屏蔽正确 |
| D | Tensor 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(),并在保存/加载后再次检查。若有意取消共享,就在配置中显式写出。
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 没拿到参数的问题。
前一层未通过时,不要用性能优化掩盖基础错误。
性能测量也要有实验合同
吞吐要说明单位:每秒样本、每秒 Token 还是每秒有效 Token;测量前先 warm-up,排除首次编译和内存分配;同步 GPU 后再计时,否则异步执行会低估耗时。显存要分别记录 allocated、reserved 和峰值,区分模型初始化、前向、反向和生成 cache。批量大小、长度、dtype、设备和线程数都写进结果。
优化顺序建议从数据等待、padding 比例、batch、混合精度、梯度检查点和 cache 开始。不要在模型还无法通过 shape probe 时引入 fused kernel 或复杂编译。优化后的实现必须重新跑功能测试和短生成对照,速度提升若伴随未来泄漏或数值漂移,就不是成功。
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 运行混为“模型更快”。如果比较两个结构,至少重复三次并报告中位数或范围;同时保留功能指标。对学习者而言,最有价值的不是追求绝对峰值,而是知道哪个变量带来了多少变化。
为你的模型分别写 shape/finite 测试、梯度存在测试和 weight tying 测试。故意制造一个错误,确认测试能给出包含模块名和实际 shape 的消息。
固定输入长度、batch、dtype 和设备,warm-up 后测三次前向和一次生成,记录有效 Token/s、峰值显存与功能差异。比较一个优化前后版本,并写出限制。
| 证据 | 证明什么 | 缺失时的风险 |
|---|---|---|
| 模块测试 | 接口与数值基本正确 | 错误延迟到训练 |
| 梯度摘要 | 参数确实更新 | 训练假成功 |
| 单批日志 | 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、哪些超参数”。这份记录也是团队复现和安全审计的入口。
| 对象 | 作用 | 加载前检查 | 常见错配 |
|---|---|---|---|
| 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 的截断策略可能从左侧或右侧丢掉信息。对问答任务,要明确保留问题、系统约束还是答案尾部;对长文摘要,要记录窗口切分和重叠比例。任何截断都应进入样本日志,便于解释评估下降。
每一站都保存一条可检查的记录,出现问题时按顺序回退。
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 与权重加载。
模型卡片中的“已知限制”不是宣传文案,而是实验设计输入。它可能提示领域偏差、语言覆盖、训练截止时间、拒答边界或许可证限制。把这些限制转成评估用例,比在上线后才发现问题更便宜。
为任意一个公开 checkpoint 写一页验收表:来源、许可证、配置关键字段、Tokenizer 特殊 Token、哈希、随机前向结果和一个固定 prompt 输出。指出其中哪一项失败会阻止后续微调。
| 文本类型 | 要观察什么 | 记录字段 |
|---|---|---|
| 中文与全角标点 | 分词边界、解码空格 | tokens、ids、decoded |
| 中英数字混合 | 数字是否被拆分、大小写 | 长度、特殊 Token |
| 换行与多空格 | 格式是否丢失 | repr(text)、repr(decoded) |
| emoji/罕见符号 | UNK 或 byte fallback | UNK 数量、ID 范围 |
| 超长文本 | 截断方向和窗口 | 原长、截断长、策略 |
8.8.2 指令数据与参数高效微调:让监督信号落在正确位置
指令微调的关键不是把几段对话拼成一条长字符串,而是明确每个 Token 的职责。一个样本通常由 system、user、assistant 或 instruction、input、response 组成;模板负责把它们序列化,Tokenizer 负责编码,collator 负责补齐、生成 labels 和 loss mask。只有 assistant 回答部分参与损失时,模型才主要学习“怎样回答”,而不是机械复述提示词。
先定义数据契约:必需字段、允许为空的字段、最大字符/Token 长度、语言、敏感信息处理、去重键和错误样本策略。样本清洗要保留计数:原始条数、解析失败、空回答、超长截断、重复、训练/验证切分后的条数。没有这些数字,训练 loss 下降也无法证明数据真的符合预期。
| 片段 | 模型看到的文本 | labels | loss mask |
|---|---|---|---|
| 系统约束 | 角色、格式、安全边界 | 通常设为 -100 | 0 |
| 用户问题 | 任务输入和上下文 | 通常设为 -100 | 0 |
| 回答前缀 | assistant 标记 | 可设为 -100 | 0 或 1,需固定策略 |
| 回答正文 | 目标输出 | 下一个 Token 的目标 ID | 1 |
| PAD | 补齐位置 | -100 | 0 |
标签移位仍然遵循上一节的原则:模型输入是从当前位置预测下一个 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;目标层不匹配时,训练可能“正常跑完”但没有更新任何想更新的参数。
先检查一条样本的可视化标签,再扩大到整个数据集。
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 或截断残片。
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;第三闸门是分布:训练/验证按问题或来源去重,避免近重复泄漏;第四闸门是安全:清除不必要的个人信息,标记危险指令、越权请求和不应学习的拒答边界。闸门要输出数量和样例,不要只给一个“清洗完成”的布尔值。
构造含 system、user、assistant 的三段文本,打印 Token、角色和 labels。说明哪些位置为 -100,回答末尾 EOS 是否计算损失,以及截断回答时如何报警。
固定数据和随机种子,比较 rank=4/8/16 或不同 target modules。记录可训练参数比例、显存峰值、验证损失和一个定性输出,写出你的选择依据。
| 指标 | 为什么需要 | 最低记录 |
|---|---|---|
| 原始/有效条数 | 判断清洗损失 | 各阶段计数 |
| Token 长度分布 | 估算截断与预算 | p50/p90/max |
| 回答监督比例 | 确认 mask 没有全空 | 有效 label Token 数 |
| 重复率与切分 | 降低泄漏 | 去重键、split 规则 |
| 敏感/危险样本 | 控制风险 | 标记数量和处理 |
8.8.3 评估、安全与回滚:训练完成不等于可以上线
微调后的模型至少要经过三类评估。离线任务评估回答“目标能力有没有提升”;回归集回答“原来会的能力有没有退化”;安全集回答“面对越权、隐私、危险和提示注入时是否遵循边界”。三类集合应尽量独立,尤其不能把评估答案复制到训练数据。每次实验都保存基座版本、适配器版本、Tokenizer、模板和评估脚本的哈希。
| 集合 | 问题 | 例子 | 通过信号 |
|---|---|---|---|
| 能力集 | 目标任务是否提升 | 分类、抽取、格式化回答 | 指标和人工抽检改善 |
| 回归集 | 通用能力是否退化 | 基础问答、长文本、中文英文 | 与基座差异在阈值内 |
| 鲁棒集 | 输入扰动是否失控 | 错别字、空输入、超长、混合语言 | 不崩溃且行为可解释 |
| 安全集 | 是否越过安全边界 | 隐私、危险、越权、注入 | 拒答/安全替代符合策略 |
| 人工集 | 自动指标漏掉什么 | 事实性、语气、偏见、可用性 | 双人或抽样复核 |
安全评估不等于给模型贴一个“安全”标签。把风险拆成请求、上下文、工具权限和输出四层:恶意用户可能把指令藏在文档里,工具返回的文本可能要求泄露系统提示,模型可能生成看似合理但含个人信息的内容。应用层要做权限、敏感信息过滤、工具白名单和人工升级;模型训练只能改善概率,不能替代边界控制。
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、邮件或检索片段中夹带指令。模型看到的内容都应标注来源和不可信边界,工具调用前还要由策略层检查参数。不要把“模型应该知道这是恶意内容”当作唯一防线。
任何一扇闸门失败,都回到诊断,而不是用平均分掩盖。
回滚设计要在训练之前完成。保留上一版本基座、适配器、Tokenizer、模板、推理参数和评估报告;发布时用 manifest 绑定它们,而不是让服务自动读取某个“latest”目录。灰度期间比较错误率、拒答率、延迟、Token 消耗和用户反馈。一旦触发阈值,切回旧 manifest,并保留失败请求的脱敏样本供复盘。
事实性与安全的取舍
模型可能在能力集上分数变高,却因为更愿意“自信地回答”而事实性或安全性下降。评估报告要区分“拒答过多”和“越界回答”这两个方向,不能把所有拒答都当成坏事,也不能把所有流畅回答都当成好事。对关键场景设置硬性阻断,对低风险场景设置人工复核和用户反馈,形成分层策略。
当发现退化时先做定位:是模板变化、Tokenizer 变化、适配器注入、数据泄漏、解码参数还是评估脚本变化。只改一个因素并重新跑小型金丝雀集。把根因、影响范围和修复版本写入变更记录,下一次升级才能避免重复踩坑。
为你的任务各写 5 个能力、5 个回归、5 个安全用例。给每个用例定义通过条件、失败严重度和触发回滚的阈值,说明哪些结果必须人工复核。
定义 JSON 字段,至少包含 base_model、adapter、tokenizer、template、code_version、eval_report、license 和 rollback_to。模拟加载 manifest 并检查文件不存在、哈希不匹配时的行为。
| 领域 | 证据 | 停止条件 |
|---|---|---|
| 版本 | 基座、适配器、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。每一环都要有输入、输出和失败条件。
语料可以使用自己写的短文本或许可证清晰的公开材料。不要为了“看起来像大模型”而抓取不明来源网页,更不要把隐私、版权不明或包含秘密的文本放进仓库。样本量小并不是问题,关键是说明来源、许可、清洗规则、切分方式和为什么足以验证实现。
| 阶段 | 输入 | 输出 | 失败条件 |
|---|---|---|---|
| 采集/清洗 | 许可清晰的文本 | 干净行、统计报告 | 空行、敏感信息、来源不明 |
| 编码 | 文本与 Tokenizer | ids、长度分布 | UNK 过多、超长无策略 |
| 切分 | 编码序列 | train/val/test | 重复泄漏、切分不可复现 |
| 批处理 | 样本序列 | padded batch、mask | shape/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,还要记录设备和确定性设置。
文件名只是示例,核心是让每个结论都有来源和版本。
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 版本必须变化,即使模型代码没有变化。
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,避免只存在于某个终端历史里。
为自己的迷你模型写 5 条可验证的成功标准,分别覆盖数据、形状、训练、生成和复现。每条都要有证据文件和不通过时的下一步。
| 顺序 | 命令/动作 | 保存结果 | 停止条件 |
|---|---|---|---|
| 1 | 检查来源和配置 | source.json、config.json | 许可或字段缺失 |
| 2 | 编码与解码探针 | token_probe.json | 无法解释的乱码/UNK |
| 3 | batch shape smoke | shape_probe.txt | shape/dtype/mask 错 |
| 4 | 单批过拟合 | overfit.jsonl | loss 不降 |
| 5 | 短训练+验证 | metrics.jsonl | NaN/无验证 |
| 6 | 固定生成和报告 | report.md | 无法复现 |
8.9.2 消融与解释:用对照实验回答“为什么”
模型能生成文字并不代表你理解它。消融实验一次移除或替换一个组件,其余配置、数据、种子和训练预算尽量保持不变,然后比较训练/验证 loss、困惑度、生成、吞吐和显存。对照组至少包含完整模型和一个简化模型;如果只展示“最好的一次”,读者无法判断改进来自哪里。
适合本项目的消融包括:关闭位置表示或换成正弦位置;单头与多头;去掉 causal mask 的错误版本(只在受控诊断中运行,不能作为正确模型);Pre-LN 与 Post-LN;共享与不共享 Embedding/LM Head;不同上下文长度;greedy 与 temperature/top-k 解码。每个消融都要先写假设,例如“没有位置时,相同 Token 的不同顺序应更难区分”。
| 因素 | 基线 | 变体 | 观测 | 预期解释 |
|---|---|---|---|---|
| 位置 | 可学习位置 | 无位置/正弦 | 顺序敏感度、val loss | 位置提供序列顺序线索 |
| 头数 | 4 heads | 1 head/8 heads | 质量、吞吐、参数 | 头数影响子空间分工 |
| mask | causal | 全可见(诊断) | 泄漏与训练差距 | 全可见会破坏生成设定 |
| Norm | Pre-LN | Post-LN | 收敛、梯度范数 | 深层稳定性不同 |
| 权重 | 共享 | 独立 LM Head | 参数、loss、泛化 | 参数效率与表达能力取舍 |
注意力可视化也要谨慎解释。某个 head 对某个 Token 权重较高,只能说明在这一层、这一输入和这一方向上分配了较多注意力,不等于模型“只使用了这个词”,更不等于因果解释。把注意力图与输入、层号、head、mask、softmax 前后状态一起保存,必要时用输入改动或遮挡实验验证。
实验表格比“我感觉更好”更能支持结论。
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 可能暴露模型依赖顺序线索,而不是被悄悄从表格中删掉。
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 行和。数值摘要能帮助你区分“功能变化”和“训练不稳定”。保存少量可读样例即可,不必把整个激活矩阵提交到仓库。
选择位置、头数、mask、Norm 或权重共享中的三个因素,为每个因素写基线、变体、预期现象和反例。说明哪些指标可以支持或推翻假设。
选择一条短序列,保存某层某 head 的 attention 权重、Token 文本和 causal mask。标出至少两个允许与禁止的位置,解释这张图不能证明什么。
| 字段 | 示例 | 写作要求 |
|---|---|---|
| 研究问题 | 位置表示是否必要 | 一句话可检验 |
| 唯一变量 | position=none | 其余配置固定 |
| 训练预算 | 800 steps、同 seed | 记录实际值 |
| 定量结果 | loss/ppl/吞吐 | 含单位和集合 |
| 定性结果 | 固定 prompt 输出 | 原样保存并脱敏 |
| 结论与限制 | 支持/不支持假设 | 写出反例和下一步 |
8.9.3 交付与下一步:把项目变成可以复用的学习资产
交付物不只是一个 checkpoint。一个合格的学习项目应包含 README、许可证和来源说明、数据清洗脚本、Tokenizer 探针、模型配置、训练日志、验证报告、固定生成样例、消融表、已知限制和回滚说明。别人按照 README 在干净环境中运行,应该能得到相同的文件结构和大致结果;如果不能,先修复工程,再谈模型效果。
| 交付物 | 内容 | 验收问题 |
|---|---|---|
| README | 目标、环境、命令、预期输出 | 新环境能否从零运行 |
| 数据包 | 来源、许可证、清洗统计 | 是否可合法复用 |
| 模型包 | 权重、配置、Tokenizer | 能否独立加载 |
| 实验包 | 日志、消融、固定 seed | 结论是否可追溯 |
| 评估包 | 能力、回归、安全用例 | 是否覆盖失败模式 |
| 限制与回滚 | 已知问题、旧版本 manifest | 出错能否切回 |
README 建议按“为什么、准备什么、运行什么、看到什么、哪里会失败”组织。命令旁边写预期日志片段,读者才能知道是成功还是卡住。环境文件锁定关键库的大版本,记录 Python、PyTorch、CUDA(若使用)和硬件。不要承诺“任何机器都能复现”,而是诚实说明 CPU/GPU、随机性和浮点差异。
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。若需要分享权重,确认许可证允许,并单独提供模型卡片和联系方式。
后续章节会把这些能力延伸到大语言模型、检索、多模态与工程交付。
下一阶段先进入大语言模型:预训练目标、指令微调、对齐和推理会把本章的 Transformer 底层结构放进完整语言模型工作流;第 10 章再学习 Embedding 与 RAG,第 14 章系统处理训练、部署和评估。不要急着跳到“调用 API”;你已经有一套从 Token 到 logits 的底层心智模型,接下来要把它连接到不同模型分支的任务与工程约束。
本章总复盘
用一句话解释 Transformer:它用带掩码的多头注意力在序列位置之间交换信息,再经残差、归一化和前馈网络反复变换表示;训练时用移位标签预测下一个 Token;微调时用模板和 loss mask 指定监督范围;交付时用独立评估和 manifest 约束版本。若你能画出数据流并指出每个张量 shape,说明已经从概念进入可操作阶段。
把本章的三类证据保存下来:公式/形状推导,最小可运行代码,失败与修复记录。以后遇到新架构,先把它映射到这三类证据,再学习新增组件。这样学习不会被名词推着走,而是由可验证问题驱动。
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,也要保留数据和模型的限制。学习资料的价值不在于看起来完成,而在于下一次还能复用。
把项目复制到一个新目录,删除缓存和临时日志,按 README 从零运行验证脚本。记录首次失败、修复过程、最终 artifacts 树和与基线的差异。
不看代码,回答:为什么要缩放点积?causal mask 屏蔽谁?labels 为什么右移?padding mask 与 loss mask 有何不同?LoRA 为什么减少参数?每个回答配一个 shape 或小例子。
| 主题 | 我能否做到 | 证据 |
|---|---|---|
| 注意力 | 手算一个小矩阵并解释 mask | attention_probe.py |
| 结构 | 画出 encoder/decoder block | 结构图和 shape 表 |
| 训练 | 解释 shift、loss、ppl 和预算 | metrics.jsonl |
| 实现 | 让小批数据过拟合 | overfit 日志 |
| 微调 | 构造 label mask 并统计 LoRA | collator/参数表 |
| 治理 | 评估、安全、回滚 | report/manifest |
官方参考阅读
建议把本章内容与 Attention Is All You Need、PyTorch MultiheadAttention、TransformerEncoderLayer、Transformers 训练指南 和 PEFT 文档 对照阅读。官方文档的参数语义和版本会变化,实际编码时以当前版本为准,并把版本写进实验记录。
到这里,第 8 章的学习闭环完成:从注意力的一个分数,到一个可测试的 Transformer,再到微调、评估和可回滚交付。下一章将进入大语言模型主线,但你可以先用本章综合项目把每个环节亲手跑一遍。
把结果写成可复用的知识卡片
项目结束后,再用一页卡片记录:问题是什么、输入输出 shape 是什么、哪一个实验最能证明实现正确、哪一个失败最值得记住、下一次会先查哪里。把卡片与 manifest 和报告放在同一目录,未来学习新架构时可以直接对照。知识资产不只是一段成功代码,也包括失败的最小复现和已经排除的解释。
如果要向别人展示,先展示数据流图和验收表,再展示生成样例。样例可以吸引注意,但表格和脚本才能让别人检查。对外分享前删除路径、密钥、个人信息和不明来源文本;代码中的随机种子、版本和许可证信息要完整。这样一个小项目才真正成为可复用的学习材料。
| 栏目 | 要写什么 | 价值 |
|---|---|---|
| 问题 | 想验证的一个假设 | 避免目标漂移 |
| 最小证据 | shape、loss、行为或资源 | 结论可检查 |
| 失败复现 | 输入、错误、根因 | 未来快速定位 |
| 下一步 | 一个具体实验 | 持续学习 |