模型架构
阶段 2 · 打开 model_minimind.py,288 行代码浓缩现代 LLM 全部精华
文本进了模型
上一章你学会了分词——一段文字变成了一串数字 ID。但然后呢?这些 ID 丢进模型之后,经历了什么变换,才能"预测下一个字"?
答案全在一个文件里:model/model_minimind.py,一共 288 行。GPT、Llama 的核心架构,就在这里。
这一章的目标:搞清楚 LLM 内部的每一个零件为什么这样设计。我们不急着读代码——先理解每个组件要解决什么问题、设计者为什么这么做,再去看代码实现。读完之后,你看任何大模型论文的架构图都不会再感到陌生。
知识地图
在深入每个组件之前,先建立全局视野。这张思维导图展示了 MiniMind 模型包含的所有核心组件——接下来的每一页都会围绕它展开。
配置 MiniMindConfig
定义模型所有超参数的"基因",控制模型大小和行为
归一化 RMSNorm
比 LayerNorm 更高效的归一化方式,防止数值爆炸或消失
位置编码 RoPE
旋转位置编码,通过旋转向量编码相对位置信息
注意力 GQA + KV-Cache
分组查询注意力减少 KV 缓存量,KV-Cache 加速自回归生成
前馈网络 SwiGLU
门控机制的前馈网络,升维后激活再降维,增强表达能力
MoE 混合专家
多个专家 FFN 由 Router 分配,参数量大但计算量不变
Transformer Block
Pre-Norm 残差结构,堆叠 8 层构建模型深度
forward 与 generate
前向推理输出 logits,自回归生成逐 token 采样
Part 1:全景
先看全貌,再看零件。这一部分建立数据从输入到输出的完整直觉。
在拆解每一个组件之前,我们需要先搞清楚 MiniMind 的整体长什么样——数据从进入模型到输出预测,中间经历了哪些环节?模型由哪些类组成?每个超参数的物理含义是什么?这部分的目标就是建立这张"全景地图",有了它,后面每学一个模块都能找到自己的位置。
- 数据流水线 —— 从一段文本到最终预测输出,完整走一遍 forward 的全流程,建立端到端的数据直觉
- 类的组成关系 —— 搞清楚 MiniMindForCausalLM 里面嵌套了哪些子模块,谁负责什么
- Config 设计图纸 —— 看懂每一个配置参数的含义和默认值,理解模型的"蓝图"
- 参数一览表 —— 快速预览模型各组件的参数量,对整体规模有个数
数据流水线
文字进去,经过层层处理,最后预测下一个字
"你好世"这 3 个字进到模型里,到底经历了什么?看下面这条流水线——每个箭头都是一个关键变换步骤。
图:MiniMind 模型从文本输入到预测输出的完整数据流水线
【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:从左到右的水平流水线图,展示文本"你好世"经过模型各组件最终预测出"界"的完整过程 - 各元素关系:输入"你好世" → Tokenizer(分词)→ Token IDs [34,89,502] → Embedding(6400到768,查表变成向量)→ x8 Transformer Blocks(多层注意力+前馈)→ RMSNorm(最终归一化)→ lm_head(768到6400,映射回词表)→ Softmax(转概率)→ 预测"界" 概率0.92,用粗箭头依次连接 - 关键标注:Embedding 旁标注"6400→768 离散变连续";Transformer Blocks 旁标注"8层,维度不变 768";lm_head 旁标注"768→6400 连续变回离散";Softmax 旁标注"概率分布" - 配色建议:浅蓝/浅灰背景,输入文本用绿色,中间处理节点用蓝色,输出预测用紫色,维度标注用深灰色小字,关键箭头用橙色 - 画面比例:16:9 - 避免:过度装饰、3D 效果、杂乱的颜色
6400 是词表大小(模型认识多少个字),768 是隐藏维度(模型内部用多长的向量表示每个字)。从 6400 压缩到 768,是"把离散的 ID 变成连续的稠密向量"——这正是 Embedding 做的事。
类的组成
MiniMind 用了哪些类,谁包含谁
288 行代码分成了 7 个类/函数。理解它们的包含关系,就像拿到了一张地图。
MiniMindForCausalLM # 顶层模型
└── MiniMindModel # Transformer 主体
├── embed_tokens # 词嵌入层
├── layers × N # N 个 Transformer Block
│ └── MiniMindBlock
│ ├── self_attn # GQA 注意力
│ ├── mlp # SwiGLU 前馈 / MoE
│ ├── input_layernorm
│ └── post_attention_layernorm
└── norm # 最终 RMSNorm
└── lm_head # 输出投影层 (768 → 6400)
最外层是 MiniMindForCausalLM,它包含一个 MiniMindModel(主体)和一个 lm_head(输出头)。Model 内部有 Embedding + 8 层 Block + 最终 Norm。
Config 图纸
所有超参数都在这里定义——模型的"基因"
在盖楼之前先看图纸。MiniMindConfig 定义了模型的所有超参数。以下代码对应源文件第 10-45 行。
# model_minimind.py 第 10-45 行
class MiniMindConfig(PretrainedConfig):
model_type = "minimind"
def __init__(self, hidden_size=768, num_hidden_layers=8,
use_moe=False, **kwargs):
super().__init__(**kwargs)
self.hidden_size = hidden_size # 模型"宽度"
self.num_hidden_layers = num_hidden_layers # 模型"深度"
self.vocab_size = kwargs.get("vocab_size", 6400)
self.num_attention_heads = kwargs.get("num_attention_heads", 8)
self.num_key_value_heads = kwargs.get("num_key_value_heads", 4)
self.head_dim = kwargs.get("head_dim",
self.hidden_size // self.num_attention_heads) # = 96
self.intermediate_size = kwargs.get("intermediate_size",
math.ceil(hidden_size * math.pi / 64) * 64) # = 2432
intermediate_size 的计算看起来奇怪:ceil(768 * π / 64) * 64 = 2432。先按 π 倍放大约 2.4 倍(Transformer 的经验值),再向上取 64 的整数倍——GPU 的张量核按 64 对齐计算最快,所以很多 LLM 项目(如 Llama、Mistrel)都这么做。
Config 参数
每个参数控制模型的哪个方面
| 参数 | 默认值 | 含义 |
|---|---|---|
hidden_size | 768 | 隐藏层维度(模型"宽度") |
num_hidden_layers | 8 | Transformer 层数("深度") |
num_attention_heads | 8 | Query 头数 |
num_key_value_heads | 4 | KV 头数(GQA 分组) |
head_dim | 96 | 每个头的维度 = 768/8 |
vocab_size | 6400 | 词汇表大小 |
intermediate_size | 2432 | FFN 中间升维维度 |
use_moe | False | 是否启用混合专家 |
rope_theta | 1e6 | RoPE 位置编码的基频 |
tie_word_embeddings | True | Embedding 和 lm_head 共享权重 |
一个关键的尺寸关系:hidden_size = num_attention_heads × head_dim,即 768 = 8 × 96。这保证 Q 投影后能刚好拆成 8 个头。如果这个关系不成立,代码会在 view() 时报 shape 错误。
Part 2:归一化
Transformer 的两大基础设施:让数值稳定、让模型知道位置。
在进入注意力机制之前,有两件事必须先搞定:第一,深层网络中数值容易爆炸或消失,归一化是训练稳定的基本保障;第二,注意力机制本身是"排列不变"的——打乱输入顺序它完全不在乎,所以必须想办法告诉模型每个 token 在哪里。这一部分就来搞定这两个基础设施。
- RMSNorm 归一化 —— 比 LayerNorm 更轻量的归一化方案,只缩放不偏移,计算更快
- 为什么需要位置编码 —— 从注意力的排列不变性出发,理解位置信息的必要性
- RoPE 旋转位置编码原理 —— 用复数旋转巧妙地将绝对位置编码为相对位置关系
- 预计算 RoPE 缓冲 —— 提前算好 cos/sin 表,避免重复计算,提升效率
- 应用旋转位置编码到 Q 和 K —— 将 RoPE 真正融入注意力计算的前向过程
RMSNorm
比 LayerNorm 更快,效果几乎相同
定义:对每个向量的所有维度求均方根(RMS),然后做逐元素除法,将向量归一化到单位长度附近。目的是防止训练时数值爆炸或消失。
白话:就像调节音响音量——不管输入多大多小,输出都保持在合理区间。
这个比喻只是为了帮你建立直观感受。在计算机内部,RMSNorm 是对每个向量做纯数值运算——求平方均值、取倒数平方根、逐元素除——没有"音量"这种物理概念。
# model_minimind.py 第 50-60 行
class RMSNorm(torch.nn.Module):
def __init__(self, dim: int, eps: float = 1e-5):
super().__init__()
self.eps = eps
self.weight = nn.Parameter(torch.ones(dim)) # 可学习的缩放参数
def norm(self, x):
return x * torch.rsqrt(x.pow(2).mean(-1, keepdim=True) + self.eps)
def forward(self, x):
return (self.weight * self.norm(x.float())).type_as(x)
RMSNorm 解读
每一步在算什么,为什么要这么做
# 核心公式:RMSNorm(x) = weight * (x / sqrt(mean(x²) + eps))
#
# self.norm(x) 做了什么?
# 1. x.pow(2) → 每个元素平方
# 2. .mean(-1) → 沿最后一维求均值(得到每个向量的 RMS²)
# 3. + self.eps → 加一个小数防止除以 0
# 4. torch.rsqrt → 取倒数平方根(= 1 / sqrt(...))
# 5. x * ... → 逐元素相乘,归一化到单位长度附近
#
# forward 做了什么?
# self.weight * self.norm(x.float()) → 归一化后乘以可学习缩放系数
# .type_as(x) → 转回原始精度(bf16/fp16)
# 维度追踪(假设输入 (2, 5, 768)):
# x.pow(2) → (2, 5, 768) 每个元素平方
# .mean(-1, keepdim=True) → (2, 5, 1) 每个向量一个均值
# torch.rsqrt(...) → (2, 5, 1) 标量因子
# x * ... → (2, 5, 768) 逐元素除法(广播)
LayerNorm
# (x - mean) / std * gamma + beta
# 减均值、有偏移项 beta
# 计算两次求和(均值+方差)
RMSNorm
# x / rms * weight
# 不减均值、无偏移项
# 只计算一次求和(平方均值)
# 快 10-15%,效果相当
为何要位置编码
注意力机制本身不知道词的顺序
"我爱你"和"你爱我"在注意力看来完全一样——都是三个向量。自注意力是排列不变的:它计算的是向量之间的相关性,和位置无关。
如果模型不知道"我"在第 1 个位置、"你"在第 3 个位置,它就分不清主语和宾语。位置编码就是给每个位置打上"标签",让模型能区分顺序。
为什么选择 RoPE:早期的正弦位置编码是绝对位置——只知道"我在第 3 个位置",不知道和其他 token 的相对距离。RoPE 编码的是相对位置——两个位置的 Q 和 K 做点积时,结果只和它们的相对距离有关,和绝对位置无关。这让模型能更好地泛化到未见过的序列长度。
RoPE 原理
通过旋转角度编码位置,点积自动包含相对距离信息
白话:想象一个指南针,不同位置的向量旋转不同角度。位置 0 转 θ 度,位置 1 转 2θ 度。两个位置做点积时,角度差就自然编码了相对距离。
这个比喻只是为了帮你建立直观感受。在计算机内部,RoPE 通过在 cos/sin 频率矩阵上做外积运算,将位置信息转化为旋转变换的三角函数值,然后对 Q 和 K 向量施加这个旋转变换——没有真正的"指南针"在转,只有矩阵运算。
图:RoPE 旋转位置编码原理
【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:左侧三个位置(pos=0,1,2)的二维向量箭头,角度递增(0°、45°、90°),背景浅灰同心圆 - 右上方放大镜展示 Q·K 点积 = cos(θ_q - θ_k),角度差编码相对位置 - 标注:pos=0绿色、pos=1橙色、pos=2紫色;"实际是对96维向量每两个维度做2D旋转" - 配色建议:浅蓝背景,向量不同颜色,角度差红色 - 画面比例:16:9 - 避免:过度装饰、3D 效果
预计算缓冲
cos/sin 值只需算一次,之后反复使用
# model_minimind.py 第 62-78 行(简化版)
def precompute_freqs_cis(dim, end=32768, rope_base=1e6, rope_scaling=None):
# 第1步:计算频率 θ_i = 1 / (base^(2i/d))
# dim=96 时,产生 dim/2=48 个不同频率
freqs = 1.0 / (rope_base ** (torch.arange(0, dim, 2).float() / dim))
# 第2步:如果需要长度外推(YaRN),调整频率
if rope_scaling is not None:
# 低频维度缩放 1/factor,高频保持不变,中间线性过渡
ramp = torch.clamp(...)
freqs = freqs * (1 - ramp + ramp / factor)
# 第3步:位置 t × 频率 θ → 外积矩阵
t = torch.arange(end)
freqs = torch.outer(t, freqs).float() # (32768, 48)
# 第4步:计算 cos/sin 并各复制一份拼接
freqs_cos = torch.cat([torch.cos(freqs), torch.cos(freqs)], dim=-1) # (32768, 96)
freqs_sin = torch.cat([torch.sin(freqs), torch.sin(freqs)], dim=-1) # (32768, 96)
return freqs_cos, freqs_sin
维度追踪:freqs 初始形状 (32768, 48),拼接后变成 (32768, 96),和 head_dim 一致。推理时用 freqs_cos[start:start+seq_len] 切片取出当前序列需要的那几行。
深度边界:YaRN 外推(第 64-73 行)涉及频率域上的复杂插值逻辑,用于推理时序列超长(如 32768 token)的场景。初次学习时只需要知道它存在即可。训练时序列长度在 2048 以内,不需要 YaRN。
应用 RoPE
把预计算的 cos/sin 应用到 Q 和 K 上
# model_minimind.py 第 80-84 行
def apply_rotary_pos_emb(q, k, cos, sin, unsqueeze_dim=1):
def rotate_half(x):
# 把向量切成两半,交叉互换:[a,b] → [-b,a]
# 这等价于二维旋转矩阵 [cos -sin; sin cos] 的一半
return torch.cat((-x[..., x.shape[-1]//2:],
x[..., :x.shape[-1]//2]), dim=-1)
# q_embed = q * cos + rotate_half(q) * sin
q_embed = (q * cos.unsqueeze(unsqueeze_dim)
+ rotate_half(q) * sin.unsqueeze(unsqueeze_dim))
k_embed = (k * cos.unsqueeze(unsqueeze_dim)
+ rotate_half(k) * sin.unsqueeze(unsqueeze_dim))
return q_embed.to(q.dtype), k_embed.to(k.dtype)
rotate_half 实现了二维旋转的矩阵形式。数学上等价于把每对相邻维度当作一个复数做旋转变换。你不需要手推这个等式——只要知道:不同位置的 Q/K 会旋转不同角度,点积结果自然包含相对位置信息。
Part 3:注意力
Transformer 最核心的组件——决定"我该关注谁"。
注意力机制是 Transformer 的灵魂——它决定了模型在处理当前 token 时应该"看向"序列中的哪些其他位置。这一部分我们会从 Q、K、V 的角色讲起,逐步深入到 GQA 分组查询、因果掩码、KV-Cache 加速,以及 Flash Attention 等工程优化。理解了注意力,你就理解了 Transformer 一半以上的设计动机。
- 注意力全景 —— 搞清楚 Q(查询)、K(键)、V(值)各自的角色和直觉含义
- 投影与 QK-Norm —— 四个线性投影矩阵如何将输入变换为 QKV,以及 QK-Norm 稳定训练的技巧
- GQA 分组查询注意力 —— 在 MHA 和 MQA 之间取折中,多头共享 KV 以节省显存
- 因果掩码与 KV-Cache —— 防止"偷看未来"的掩码机制,以及推理阶段用 KV-Cache 大幅加速
- Flash Attention —— 不需要显式存储注意力矩阵的 IO 优化方案,训练速度的关键
注意力全景
Query、Key、Value 三个角色如何协作
想象你去图书馆找书。Query 是"你想找什么",Key 是每本书的"标签",Value 是"书的内容"。注意力机制就是:用你的 Query 和所有 Key 比对(点积),找出最相关的几本,然后把对应的 Value 加权求和。
比喻到此为止。在数学上,注意力就是 Q·K^T 得到权重矩阵,softmax 归一化后乘以 V。纯粹的矩阵运算。
图:注意力机制完整计算流程
【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:从左到右的数据流,展示注意力机制的完整计算过程 - 流程节点:① 输入 x (B,S,768) 同时经过 q_proj、k_proj、v_proj 三个线性投影 → ② 拆分为多头(Q: 8头, KV: 4头 GQA) → ③ 对 Q 和 K 应用 RoPE 旋转位置编码 → ④ 计算 Q·K^T 点积得到注意力分数矩阵 → ⑤ 除以 sqrt(head_dim=96) 缩放 → ⑥ 应用因果掩码(上三角填 -∞) → ⑦ Softmax 归一化得到权重 → ⑧ 权重矩阵乘以 V 得到加权输出 → ⑨ 合并多头后经 o_proj 输出投影 - 各元素关系:q_proj/k_proj/v_proj 三路并行分叉;GQA 中 4个KV头复制为8个与Q头匹配;因果掩码作用在 softmax 之前 - 关键标注:每步标注维度变化,如 Q·K^T 得到 (B,8,S,S) 注意力矩阵;GQA 复用箭头用虚线表示 - 配色建议:浅蓝/浅灰背景,深色文字,Q/K/V 三路分别用蓝/绿/紫色,关键箭头用橙色标注,因果掩码区域用浅红色 - 画面比例:16:9 - 避免:过度装饰、3D 效果、杂乱的颜色
注意力初始化
四个投影矩阵和 QK-Norm
# model_minimind.py 第 91-109 行
class Attention(nn.Module):
def __init__(self, config):
super().__init__()
self.n_local_heads = config.num_attention_heads # 8 个 Q 头
self.n_local_kv_heads = config.num_key_value_heads # 4 个 KV 头
self.n_rep = self.n_local_heads // self.n_local_kv_heads # = 2
self.head_dim = config.head_dim # 96
# 四个投影矩阵(无偏置)
self.q_proj = nn.Linear(768, 8 * 96, bias=False) # 768 → 768
self.k_proj = nn.Linear(768, 4 * 96, bias=False) # 768 → 384
self.v_proj = nn.Linear(768, 4 * 96, bias=False) # 768 → 384
self.o_proj = nn.Linear(8 * 96, 768, bias=False) # 768 → 768
# QK-Norm:对每个头的表示做归一化(提升训练稳定性)
self.q_norm = RMSNorm(96)
self.k_norm = RMSNorm(96)
为什么 k_proj 输出 384 而不是 768?因为 MiniMind 使用了 GQA(分组查询注意力)。8 个 Q 头共享 4 组 KV 头。每个 KV 头被 2 个 Q 头复用。这样 KV 缓存只需要存储一半的数据,推理更省显存。
投影与 QK-Norm
Attention forward 的前半段(第 111-123 行)
def forward(self, x, position_embeddings, past_key_value=None,
use_cache=False, attention_mask=None):
bsz, seq_len, _ = x.shape
# ① 线性投影:x → Q, K, V
xq, xk, xv = self.q_proj(x), self.k_proj(x), self.v_proj(x)
xq = xq.view(bsz, seq_len, 8, 96) # 拆成 8 个 Q 头
xk = xk.view(bsz, seq_len, 4, 96) # 拆成 4 个 KV 头
xv = xv.view(bsz, seq_len, 4, 96)
# ② QK-Norm:稳定训练(防止 Q/K 幅度过大导致注意力退化)
xq, xk = self.q_norm(xq), self.k_norm(xk)
# ③ 应用 RoPE 旋转位置编码
cos, sin = position_embeddings
xq, xk = apply_rotary_pos_emb(xq, xk, cos, sin)
# ④ KV-Cache:拼接历史 KV
if past_key_value is not None:
xk = torch.cat([past_key_value[0], xk], dim=1) # 沿序列维拼接
xv = torch.cat([past_key_value[1], xv], dim=1)
past_kv = (xk, xv) if use_cache else None
维度追踪:假设输入 (2, 5, 768),经过 q_proj → (2, 5, 768),view → (2, 5, 8, 96)。如果有 KV-Cache 且已缓存 3 个 token,xk 拼接后变为 (2, 8, 96)。这 8 = 3(历史) + 5(当前)。
注意力与 GQA
4 个 KV 头复制成 8 个,和 Q 头匹配后计算注意力
# model_minimind.py 第 124-134 行
# ④ repeat_kv:4 个 KV 头复制成 8 个,和 Q 头匹配
xq = xq.transpose(1, 2) # (B, 8, S, 96)
xk = repeat_kv(xk, self.n_rep).transpose(1, 2) # (B, 8, S, 96)
xv = repeat_kv(xv, self.n_rep).transpose(1, 2) # (B, 8, S, 96)
# ⑤ 计算注意力(Flash Attention 或手动实现)
if self.flash and seq_len > 1:
output = F.scaled_dot_product_attention(
xq, xk, xv, dropout_p=self.dropout, is_causal=True)
else:
scores = (xq @ xk.transpose(-2, -1)) / math.sqrt(96)
# 因果掩码:上三角填 -∞
scores[:, :, :, -seq_len:] += torch.full(
(seq_len, seq_len), float("-inf")).triu(1)
output = F.softmax(scores.float(), dim=-1).type_as(xq) @ xv
# ⑥ 合并多头,投影输出
output = output.transpose(1, 2).reshape(bsz, seq_len, -1)
output = self.resid_dropout(self.o_proj(output))
return output, past_kv
维度追踪:xq (2,8,5,96) @ xk^T (2,8,96,5) → scores (2,8,5,5)。Softmax 后 @ xv (2,8,5,96) → output (2,8,5,96)。Reshape → (2,5,768)。
GQA 分组查询
8 个 Q 头共享 4 组 KV——用更少显存获得几乎相同的效果
图:GQA 分组查询注意力的头分配关系
【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:左侧 8 个 Q 头(Q头0~Q头7),右侧 4 个 KV 头(KV头0~KV头3),用连线展示分组共享关系 - 各元素关系:Q头0 和 Q头1 共享 KV头0;Q头2 和 Q头3 共享 KV头1;Q头4 和 Q头5 共享 KV头2;Q头6 和 Q头7 共享 KV头3。每组用不同的背景色区分 - 关键标注:每组 KV 头旁标注"2个Q头共享1个KV头";底部总结"8Q : 4KV = 2:1,KV缓存减半";标注"n_rep = 8/4 = 2" - 配色建议:浅灰背景,4 组分别用蓝/绿/橙/紫四种颜色,Q 头用圆形,KV 头用方形,连线与组同色 - 画面比例:16:9 - 避免:过度装饰、3D 效果、杂乱的颜色
# model_minimind.py 第 86-89 行
# repeat_kv:把 4 个 KV 头复制成 8 个
def repeat_kv(x, n_rep):
# x: (batch, seq, 4, 96) → (batch, seq, 8, 96)
bs, slen, num_kv_heads, head_dim = x.shape
if n_rep == 1: return x
return (x[:, :, :, None, :]
.expand(bs, slen, num_kv_heads, n_rep, head_dim)
.reshape(bs, slen, num_kv_heads * n_rep, head_dim))
repeat_kv 不是真正复制数据(expand 是视图操作,不占额外内存)。只是在计算时让每个 Q 头"看到"对应的 KV 头。4 组 KV 被 8 个 Q 头共享,显存减半。
因果掩码
当前 token 只能看到它之前的 token,不能"偷看"未来
语言模型是逐字预测的——预测第 3 个字时,不能偷看第 4 个字。因果掩码就是把"未来"的注意力权重强制归零。
你 好 世 界
你 [ 0 -∞ -∞ -∞ ] softmax(-∞) = 0,完全忽略
好 [ 0 0 -∞ -∞ ] "好"能看到"你"和自己
世 [ 0 0 0 -∞ ] "世"能看到"你""好"和自己
界 [ 0 0 0 0 ] "界"能看到所有之前的字
# 源码第 129 行:
scores[:, :, :, -seq_len:] += torch.full(
(seq_len, seq_len), float("-inf")).triu(1)
# triu(1) = 上三角矩阵(对角线以上为 1)
# 加到 scores 上,上三角全变 -∞
# 注意 triu(1) 中的 1:对角线保留为 0(自己看自己),
# 如果写成 triu(0) 则对角线也变 -∞,模型连自己都看不到
图:因果掩码矩阵
【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:5x5网格矩阵,行列为"你""好""世""界""!" - 对角线及以下浅绿色"可见",对角线以上灰色"-∞不可见" - 标注:"softmax(-∞)=0""每个token只能看到自己和之前的token" - 配色建议:白色背景,可见浅绿,不可见浅灰 - 画面比例:16:9 - 避免:过度装饰、3D 效果
KV-Cache
缓存历史 KV,每步只算新 token
图:KV-Cache 在自回归生成中的工作流程
【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:一个时序图(从上到下),展示 KV-Cache 在生成"你好世界"时逐步缓存和复用 KV 的过程 - 时序步骤(三个阶段):第1步:输入"你好"(2个token),模型计算 K1V1 并存入 KV-Cache → 第2步:只算"世"1个新token的 KV,从缓存取出 K1V1 拼接得到完整 KV 序列,然后缓存 K2V2 → 第3步:只算"界"1个新 token,取出全部历史 KV,缓存 K3V3 - 各元素关系:左侧"模型"竖条,右侧"KV-Cache"竖条;每步模型向缓存写入新 KV(实线箭头),缓存向模型返回历史 KV(虚线箭头);缓存中的 KV 数量逐步递增 - 关键标注:第1步旁标注"缓存 2 个 token 的 KV";第2步旁标注"只需计算 1 个新 token";第3步旁标注"历史 KV 直接复用";底部标注"总计算量 O(N),无 Cache 则 O(N^2)" - 配色建议:浅灰背景,模型竖条用蓝色,缓存竖条用橙色,写入箭头实线深色,读取箭头虚线浅色 - 画面比例:16:9 - 避免:过度装饰、3D 效果、杂乱的颜色
无 KV-Cache
生成第 N 个 token 时,重算前 N-1 个。
总计算量 O(N²)。
有 KV-Cache
每步只算 1 个新 token,拼接历史 KV。
总计算量 O(N),快 N 倍。
GQA 让 KV-Cache 效果更显著:4 个 KV 头(vs 8 个 Q 头)意味着缓存量直接减半。生成长序列时,显存节省非常可观。
Flash Attention
分块计算,显存从 O(S²) 降到 O(S)
普通注意力
完整存储注意力矩阵 (B, H, S, S)
显存 O(seq²)
S=2048 时矩阵约 32MB/头
Flash Attention
分块计算,不存储中间矩阵
显存 O(seq),速度 2-4x
S=2048 时几乎零额外显存
# 源码第 109 行:自动判断是否可用
self.flash = hasattr(torch.nn.functional,
'scaled_dot_product_attention') and config.flash_attn
# 源码第 125-126 行:满足条件就用 Flash Attention
# 条件:可用 + 序列长度>1 + 首次前向(无KV-Cache)+ 无attention_mask
if self.flash and seq_len > 1 and past_key_value is None:
output = F.scaled_dot_product_attention(
xq, xk, xv, dropout_p=self.dropout, is_causal=True)
scaled_dot_product_attention。如果你的 PyTorch 版本较旧,会自动走手动实现路径,结果一样但更慢。
Part 4:前馈
注意力决定"关注谁",前馈网络决定"怎么处理"。MoE 让不同 token 由不同专家处理。
注意力负责收集信息,前馈网络(FFN)负责处理信息——这是 Transformer 中参数量最大的部分。MiniMind 没有用普通的 FFN,而是用了 SwiGLU 门控激活函数来提升表达能力。更酷的是,它还引入了 MoE(混合专家)机制:不再是一个 FFN 处理所有 token,而是由多个"专家"各自擅长不同领域,每个 token 动态选择最合适的专家来处理。这一部分我们来拆解这两个关键设计。
- FeedForward 与 SwiGLU —— 理解门控激活函数的结构:gate 路和 up 路双路控制,比 ReLU 更强
- MoE 混合专家架构 —— 多个 FFN 并行存在,Router 动态决定每个 token 走哪条路
- MoE 路由过程 —— 从 Router 打分、Top-1 选择、专家计算到加权合并的完整流程
- 负载均衡损失 —— 防止所有 token 都涌向同一个专家的辅助训练目标
- Dense vs MoE 对比 —— 直观比较传统稠密 FFN 与稀疏 MoE 在结构和计算上的差异
FeedForward
SwiGLU 结构——Llama 系列使用的 FFN
定义:对每个位置独立做非线性变换,增强模型的表达能力。输入先升维(768→2432),经过激活函数,再降维回来(2432→768)。
白话:注意力决定"看谁",FFN 决定"怎么想"。注意力是"看"的动作,FFN 是"思考"的动作。
比喻卸甲:在数学上,FFN 就是两个线性变换中间夹一个非线性激活函数。"看"和"思考"只是拟人化的辅助理解。
# model_minimind.py 第 136-146 行——整个 FFN 只有 6 行!
class FeedForward(nn.Module):
def __init__(self, config):
super().__init__()
self.gate_proj = nn.Linear(768, 2432, bias=False) # 门控投影
self.down_proj = nn.Linear(2432, 768, bias=False) # 降回原维度
self.up_proj = nn.Linear(768, 2432, bias=False) # 升维投影
self.act_fn = ACT2FN[config.hidden_act] # SiLU 激活
def forward(self, x):
# SwiGLU = SiLU(gate_proj(x)) * up_proj(x),然后 down 投影回去
return self.down_proj(self.act_fn(self.gate_proj(x)) * self.up_proj(x))
SwiGLU 计算图
gate 和 up 两条路,门控机制让模型"选择性地通过信息"
图:SwiGLU 前馈网络数据流
【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:从左到右的 Y 形数据流,展示 SwiGLU 的门控计算过程 - 流程节点:① 输入 (2,5,768) 在左侧分叉为上下两条并行路径 → ② 上路:gate_proj 线性投影 (768→2432) → SiLU 激活函数 (x * sigmoid(x)),输出 0~1 范围的门控信号 → ③ 下路:up_proj 线性投影 (768→2432),保持原始内容 → ④ 两路逐元素相乘(门控信号过滤内容) → ⑤ down_proj 线性投影 (2432→768) 降回原维度 → ⑥ 输出 (2,5,768) - 各元素关系:gate_proj 和 up_proj 从同一个输入并行出发;SiLU 只作用在 gate 路上;逐元素乘法节点是两条路径的汇合点 - 关键标注:gate 路标注"选择性通过"(橙色),up 路标注"原始内容"(蓝色);乘法节点标注"门控 = 过滤";每步标注维度变化 - 配色建议:浅蓝/浅灰背景,深色文字,gate 路用橙色系,up 路用蓝色系,汇合后用紫色,down_proj 用绿色 - 画面比例:16:9 - 避免:过度装饰、3D 效果、杂乱的颜色
SiLU:SiLU(x) = x × sigmoid(x)。比 ReLU 更平滑——ReLU 在 x<0 时梯度直接为 0,SiLU 在 x<0 时仍有微小梯度,训练更稳定。
为什么用 gate + up 两条路? gate 路经过 SiLU 激活后输出 0~1 范围的"门控信号",控制"哪些信息通过";up 路提供原始内容。两者相乘实现"选择性过滤"。这比简单的 ReLU(Linear(x)) 表达能力更强。
参数量:gate_proj (768×2432) + up_proj (768×2432) + down_proj (2432×768) = 3 × 768 × 2432 ≈ 5.6M 参数。这是单层 FFN 的参数量,占了 Transformer Block 的大部分。
MoE 混合专家
不同 token 由不同"专家"处理
定义:MoE(Mixture of Experts)把一个大的 FFN 拆成多个小的"专家"FFN,每次只激活少数几个。参数量增大了,但计算量几乎不变。
白话:像一个医院,全科医生什么都能看(Dense FFN),但专科医生更专业(MoE)。每个 token 来了先挂号(Router),然后被分到对应科室(Expert)。
比喻卸甲:在数学上,MoE 就是多个并行的 FFN,一个线性层(Router)输出每个 token 对各专家的偏好分数,只取 top-k 个专家计算并加权求和。
# model_minimind.py 第 148-154 行
class MOEFeedForward(nn.Module):
def __init__(self, config):
super().__init__()
self.gate = nn.Linear(768, config.num_experts, bias=False) # 路由器
self.experts = nn.ModuleList([
FeedForward(config, config.moe_intermediate_size)
for _ in range(config.num_experts) # 默认 4 个专家
])
MoE 路由过程
Router 打分 → Top-1 选择 → 专家计算 → 加权合并
# model_minimind.py 第 156-176 行(简化版)
def forward(self, x):
batch_size, seq_len, hidden_dim = x.shape
x_flat = x.view(-1, hidden_dim) # 展平: (N, 768)
# ① 路由器给每个 token 对 4 个专家打分
scores = F.softmax(self.gate(x_flat), dim=-1) # (N, 4)
# ② 选得分最高的 1 个专家(Top-1)
topk_weight, topk_idx = torch.topk(scores, k=1, dim=-1)
# ③ 只让选中的专家处理对应的 token
y = torch.zeros_like(x_flat)
for i, expert in enumerate(self.experts):
mask = (topk_idx == i) # 哪些 token 选了专家 i
if mask.any():
token_idx = mask.any(dim=-1).nonzero().flatten()
weight = topk_weight[mask].view(-1, 1)
y.index_add_(0, token_idx, expert(x_flat[token_idx]) * weight)
# ④ 计算负载均衡损失
load = F.one_hot(topk_idx, 4).float().mean(0)
self.aux_loss = (load * scores.mean(0)).sum() * 4 * 5e-4
return y.view(batch_size, seq_len, hidden_dim)
图:MoE 路由全景
【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:左侧6个token方块,中间Router方块,右侧4个专家FFN - token通过Router连向被选中的专家(高亮),未选中专家半透明 - 标注:"Top-1选择""4个专家=4倍参数,每次只激活1个" - 配色建议:浅灰背景,Router橙色,Expert不同颜色 - 画面比例:16:9 - 避免:过度装饰、3D 效果
负载均衡损失
防止所有 token 涌向同一个专家
解决方案:aux_loss 惩罚负载不均。如果某个专家被选中太多(load 高)或得分太高(score 高),损失就变大。训练会自动纠正这种不均衡。
# aux_loss = N * sum(load_i * score_i) * coef
# load_i = 专家 i 被选中的比例
# score_i = 专家 i 的平均得分
# coef = 0.0005(很小,不干扰主损失)
#
# 为什么乘 load * score 而不是只看 load?
# 只看 load:专家被均分了但每个都不太确定 → 没解决问题
# load * score:惩罚"既被频繁选中又自信"的专家
# 这迫使 Router 的决策更分散、更均匀
4 个专家 = 4 倍参数量,但每次只激活 1 个,计算量几乎不变。这就是 MoE 的魅力——用少量计算获得大模型的效果。
深度边界:aux_loss 的梯度如何反向影响 Router 的 gate 层、Top-K 选择在反向传播中的梯度处理(直通估计器)、以及源码第 169-170 行的"空操作占位"技巧(确保所有专家参数参与计算图,避免梯度为 None)——这些属于 MoE 实现的高级细节,初次学习时先理解"为什么需要"和"公式是什么"就够了。
Dense vs MoE
Dense FFN
# 所有 token 经过同一个 FFN
# 参数量:~5.6M
# 每个 token 激活全部参数
# 优点:简单稳定
# 缺点:参数利用率低
MoE FFN
# token 被 Router 分配给不同专家
# 参数量:~5.6M x 4 = 22.4M
# 每个 token 只激活 1/4 参数
# 优点:参数量大、计算量小
# 缺点:需要 aux_loss 均衡
MiniMind 默认 use_moe=False,使用 Dense FFN。在配置中把 use_moe 设为 True 即可启用 MoE。两种模式在 MiniMindBlock 中通过一个 if-else 切换,其他代码完全不变。
Part 5:组装
把所有零件组装成完整的 Transformer Block,堆叠 8 层,加上输出头,实现自回归生成。
前面我们一个个拆解了归一化、位置编码、注意力、前馈网络这些零件。现在到了最激动人心的环节——把它们全部组装起来!这一部分会看到 Transformer Block 如何用 Pre-Norm 残差结构把注意力和 FFN 串在一起,8 层 Block 如何堆叠成完整的 MiniMindModel,最后加上语言模型头和损失函数,实现从输入到输出的完整闭环。我们还会深入生成阶段的各种采样策略,理解 Temperature、Top-K、Top-P 这些参数到底在控制什么。
- Transformer Block —— Pre-Norm 残差结构:先归一化再计算,残差连接保证梯度流通
- MiniMindModel 组装 —— Embedding + 8 层 Block + 最终 Norm,拼出完整的模型骨架
- lm_head 与损失计算 —— 输出投影到词表大小,用交叉熵损失训练,理解 shift 操作的奥秘
- 自回归生成 generate —— 逐 token 生成文本的完整推理循环
- 采样策略详解 —— Temperature 调温度、Top-K 截断、Top-P 核采样、Repetition Penalty 惩罚重复
Transformer块
Pre-Norm 残差结构——信息层层叠加
# model_minimind.py 第 178-194 行
class MiniMindBlock(nn.Module):
def __init__(self, layer_id, config):
super().__init__()
self.self_attn = Attention(config)
self.input_layernorm = RMSNorm(config.hidden_size)
self.post_attention_layernorm = RMSNorm(config.hidden_size)
self.mlp = FeedForward(config) if not config.use_moe \
else MOEFeedForward(config)
def forward(self, hidden_states, position_embeddings, ...):
residual = hidden_states # 保存副本
hidden_states, present_kv = self.self_attn(
self.input_layernorm(hidden_states), # 先归一化再注意力
position_embeddings, ...)
hidden_states += residual # 残差连接
hidden_states = hidden_states + self.mlp(
self.post_attention_layernorm(hidden_states)) # 归一化+FFN+残差
return hidden_states, present_kv
Pre-Norm vs Post-Norm:MiniMind 用的是 Pre-Norm——先归一化再进 Attention/FFN。早期 Transformer 用 Post-Norm(先进 Attention 再归一化),但训练不稳定。现代 LLM 基本都用 Pre-Norm。
图:Transformer Block 内部架构
【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:从上到下数据流。输入(B,S,768)→左侧残差虚线→右侧主路径:RMSNorm→Attention(GQA,RoPE)→残差加法→RMSNorm→FFN/MoE→残差加法→输出(B,S,768) - 每个组件旁标注维度;残差连接橙色虚线;加法节点红色"+" - 配色建议:浅蓝背景,RMSNorm淡黄,Attention淡蓝,FFN淡绿 - 画面比例:16:9 - 避免:过度装饰、3D 效果
残差连接的意义
输出 = 输入 + 变化量——让信息有"捷径"传递
图:Transformer Block 中的残差连接数据流
【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:从上到下的数据流图,展示一个 Transformer Block 内部的 Pre-Norm 残差结构,包含两条并行路径 - 主路径(右侧):输入 x → RMSNorm → Attention(GQA + RoPE)→ 残差加法 "+" → RMSNorm → FFN 或 MoE → 残差加法 "+" → 输出 - 残差路径(左侧虚线):输入 x 分出一条虚线直接连到第一个"+"号(保存为 residual);第一个加法结果再分出一条虚线连到第二个"+"号(保存为 residual) - 关键标注:残差虚线旁标注"捷径:信息直达,不丢失";每个 RMSNorm 旁标注"Pre-Norm 先归一化";Attention 旁标注"决定看谁";FFN 旁标注"决定怎么想";加法节点用红色圆圈标注"+" - 配色建议:浅灰背景,主路径节点用蓝色,残差虚线用橙色,加法节点用红色,RMSNorm 用淡黄色,Attention 用浅蓝色,FFN 用浅绿色 - 画面比例:16:9 - 避免:过度装饰、3D 效果、杂乱的颜色
即使某一层没学到有用的东西(变化量 = 0),信息也不会丢失——直接从残差路径传过去。这就是恒等映射。
为什么现代 LLM 能堆叠几十层甚至上百层?因为残差连接让梯度有"捷径"回传。没有残差连接时,梯度经过十几层连乘后会趋近于 0(梯度消失)。有了残差连接,梯度至少有一条路径可以直接回传到浅层。
+= 而不是 =。少一个加号,信息就断了。
模型组装
Embedding + 8 层 Block + 最终 Norm(第 196-232 行)
# model_minimind.py 第 196-207 行
class MiniMindModel(nn.Module):
def __init__(self, config):
super().__init__()
self.embed_tokens = nn.Embedding(6400, 768) # 词嵌入
self.dropout = nn.Dropout(config.dropout)
self.layers = nn.ModuleList([
MiniMindBlock(l, config) for l in range(8)]) # 8 层 Block
self.norm = RMSNorm(768) # 最终归一化
# 预计算 RoPE 的 cos/sin 缓冲区(不参与梯度更新)
freqs_cos, freqs_sin = precompute_freqs_cis(
dim=96, end=32768, rope_base=1e6, rope_scaling=config.rope_scaling)
self.register_buffer("freqs_cos", freqs_cos, persistent=False)
self.register_buffer("freqs_sin", freqs_sin, persistent=False)
register_buffer 把 cos/sin 注册为模型的"状态"但不作为可训练参数。它们会随模型一起保存/加载(persistent=False 时不保存),但不参与梯度计算。
forward 流程
数据从 Embedding 到最终 hidden_states 的完整旅程
# model_minimind.py 第 209-232 行(简化版)
def forward(self, input_ids, attention_mask=None,
past_key_values=None, use_cache=False, **kwargs):
batch_size, seq_length = input_ids.shape
# ① 词嵌入:离散 ID → 连续向量
hidden_states = self.dropout(self.embed_tokens(input_ids)) # (B, S, 768)
# ② 取出当前位置的 RoPE cos/sin(支持 KV-Cache 偏移)
start_pos = past_key_values[0][0].shape[1] if past_key_values[0] is not None else 0
position_embeddings = (
self.freqs_cos[start_pos:start_pos + seq_length],
self.freqs_sin[start_pos:start_pos + seq_length])
# ③ 逐层通过 Transformer Block
presents = []
for layer, past_kv in zip(self.layers, past_key_values):
hidden_states, present = layer(
hidden_states, position_embeddings,
past_key_value=past_kv, use_cache=use_cache)
presents.append(present)
# ④ 最终归一化 + 收集 MoE 的 aux_loss
hidden_states = self.norm(hidden_states)
aux_loss = sum([l.mlp.aux_loss for l in self.layers
if isinstance(l.mlp, MOEFeedForward)])
return hidden_states, presents, aux_loss
维度全程追踪:input_ids (2,5) → embed (2,5,768) → 8 层 Block(维度不变)→ norm (2,5,768)。整个过程中 hidden_size 始终是 768,形状不变。
CausalLM 头
在 MiniMindModel 之上加 lm_head 做词汇预测
# model_minimind.py 第 234-253 行
class MiniMindForCausalLM(PreTrainedModel, GenerationMixin):
def __init__(self, config):
self.model = MiniMindModel(config)
self.lm_head = nn.Linear(768, 6400, bias=False) # 768 → 词汇表大小
# 关键:embed 和 lm_head 共享权重!
if config.tie_word_embeddings:
self.model.embed_tokens.weight = self.lm_head.weight
def forward(self, input_ids, labels=None, ...):
hidden_states, past_kv, aux_loss = self.model(input_ids, ...)
logits = self.lm_head(hidden_states) # (B, S, 6400)
if labels is not None:
# 预测左移一位,和真实标签右移一位对齐
loss = F.cross_entropy(
logits[..., :-1, :].contiguous().view(-1, 6400),
labels[..., 1:].contiguous().view(-1),
ignore_index=-100)
return MoeCausalLMOutputWithPast(
loss=loss, aux_loss=aux_loss, logits=logits, ...)
权重共享:tie_word_embeddings=True 意味着 embed_tokens 和 lm_head 用同一套权重。嵌入是把 token ID 变成向量,lm_head 是把向量变回 token 概率——本质上是同一个映射的正反方向。共享权重可以节省 6400×768 = 4.9M 参数。
损失计算的秘密
为什么 logits 取 [:-1] 而 labels 取 [1:]?
输入: [你, 好, 世, 界] (input_ids)
↓ embed → ×8层 → lm_head
logits:[好', 世', 界', ?' ] 模型对每个位置的预测
取[:-1] ↑ ↑ ↑
labels:[好, 世, 界, ! ] 真实的下一个字
取[1:] ↑ ↑ ↑
对齐后:
logits[:-1] = [好', 世', 界'] vs labels[1:] = [好, 世, 界]
→ 交叉熵衡量每个位置的预测与真实的差距
本质就是:模型在每个位置预测下一个字,损失衡量预测和真实下一个字的差距。ignore_index=-100 让 padding 和不需要计算损失的位置被忽略。
图:权重共享
【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:中央大矩阵"共享权重 6400x768",左上embed_tokens箭头取行(橙色高亮),右下lm_head箭头矩阵乘法 - 标注:"同一块内存""tie_word_embeddings=True 节省 4.9M 参数" - 配色建议:白色背景,矩阵浅蓝网格,高亮行橙色 - 画面比例:16:9 - 避免:过度装饰、3D 效果
自回归生成
逐步生成 token 直到遇到 EOS(第 256-288 行)
自回归生成是模型推理的核心过程:每次前向传播只预测一个 token,把它拼到输入后面,再预测下一个。如此循环直到遇到结束符或达到最大长度。
# model_minimind.py 第 256-288 行(简化版)
@torch.inference_mode()
def generate(self, input_ids, max_new_tokens=8192,
temperature=0.85, top_p=0.85, top_k=50,
eos_token_id=2, repetition_penalty=1.0, ...):
for _ in range(max_new_tokens):
# ① 前向传播,只取最后一个 token 的 logits
past_len = past_key_values[0][0].shape[1] if past_key_values else 0
outputs = self.forward(input_ids[:, past_len:], ...) # 只算新 token
logits = outputs.logits[:, -1, :] / temperature # 温度缩放
# ② Repetition Penalty:惩罚已出现的 token
if repetition_penalty != 1.0:
for i in range(input_ids.shape[0]):
seen = torch.unique(input_ids[i])
logits[i, seen] = torch.where(
logits[i, seen] > 0,
logits[i, seen] / repetition_penalty,
logits[i, seen] * repetition_penalty)
# ③ Top-K:只保留概率最高的 K 个
# ④ Top-P:累积概率超过 P 后面的都去掉
# ⑤ 从过滤后的分布中采样
next_token = torch.multinomial(torch.softmax(logits, dim=-1), 1)
input_ids = torch.cat([input_ids, next_token], dim=-1)
if (next_token == eos_token_id).all(): break # 遇到结束符就停
return input_ids
采样策略详解
Temperature / Top-K / Top-P 三重过滤
Temperature
logits / temperature
< 1 → 更确定(选最高概率的)
> 1 → 更随机(趋向均匀分布)
= 1 → 原始分布不变
默认 0.85,略偏确定
Top-K + Top-P
Top-K=50:只看概率最高的 50 个候选,其余设为 -∞
Top-P=0.85:把候选按概率从高到低排序,累积概率到 85% 后面的都去掉
两者叠加:先 Top-K 截断,再 Top-P 精筛
# Top-K 实现(第 271-272 行)
logits[logits < torch.topk(logits, top_k)[0][..., -1, None]] = -float('inf')
# Top-P 实现(第 273-277 行)
sorted_logits, sorted_idx = torch.sort(logits, descending=True)
mask = torch.cumsum(torch.softmax(sorted_logits, dim=-1), dim=-1) > top_p
mask[..., 1:], mask[..., 0] = mask[..., :-1].clone(), 0 # 保留第一个
logits[mask.scatter(1, sorted_idx, mask)] = -float('inf')
MiniMind 的默认值(T=0.85, K=50, P=0.85)是实践中效果不错的组合。想更"有创意"就调高 T,想要更稳定就降低 T。Top-P 的那个 mask[..., 0] = 0 很关键——确保概率最高的那个 token 永远不会被过滤掉。
图:采样策略对比
【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:三行柱状图。"原始logits"高低不一;"Temperature=0.5"两极分化;"Top-K=3+Top-P"只保留3个其余灰色 - 标注:"T<1更确定""被过滤" - 配色建议:浅灰背景,保留蓝色,被过滤灰色,过滤箭头红色 - 画面比例:16:9 - 避免:过度装饰、3D 效果
重复惩罚
惩罚已生成的 token,防止重复循环
# model_minimind.py 第 268-270 行
if repetition_penalty != 1.0:
for i in range(input_ids.shape[0]):
seen = torch.unique(input_ids[i])
score = logits[i, seen]
# 正分数除以 penalty(变小),负分数乘以 penalty(变更负)
logits[i, seen] = torch.where(
score > 0, score / repetition_penalty,
score * repetition_penalty)
为什么正负分数要分别处理?如果统一除以 penalty:正分数变小(好),但负分数会变大(变接近 0,反而增加被选中的概率)。所以正的除、负的乘,确保已出现的 token 在两个方向都被"推开"。
Part 6:总结
回顾全景,清点参数,动手验证。
学完了所有组件,最后这一部分做三件事:用参数量追踪表和维度变化总览帮你把零散的知识串成体系;用常见错误排查表帮你提前埋好"地雷探测器",避免在动手实践时踩坑;最后留几道练习题,从维度追踪到代码修改,真正动手验证自己是否理解了每个环节。
- 参数量追踪表 —— 逐层清点每个组件的参数量,搞清楚模型的总参数是怎么算出来的
- 维度变化总览 —— 从输入到输出,跟踪张量形状在每一步的变化
- 常见错误排查 —— shape 不匹配、CUDA OOM、RoPE 偏移错误、因果掩码 triu 错误等高频坑
- 动手练习 —— 维度追踪练习、代码理解题、修改实验建议,在实战中加深理解
- 延伸阅读 —— 推荐进一步学习的论文和资源,继续深入 Transformer 的世界
参数量追踪
每一层有多少参数,它们花在哪了
| 组件 | 计算 | 参数量 |
|---|---|---|
| embed_tokens | 6400 × 768 | 4.92M |
| q_proj | 768 × (8×96) | 0.59M |
| k_proj | 768 × (4×96) | 0.29M |
| v_proj | 768 × (4×96) | 0.29M |
| o_proj | (8×96) × 768 | 0.59M |
| QK-Norm (2个) | 96 × 2 | 0.0002M |
| gate + up + down | 768×2432 × 3 | 5.60M |
| 单层 Block | Attn 1.77M + FFN 5.60M + 2×Norm | ~7.37M |
| 8 层 Block | 7.37M × 8 | ~59.00M |
| lm_head | 与 embed 共享 (tied) | 0 |
| 总参数 | embed 4.92M + 8 层 59.00M + final norm | ~63.9M |
对账:embed 4.92M + 8 × 7.37M + final norm 768 ≈ 63.9M,这就是当前源码默认配置(minimind-3)的真实参数量。两个关键点:(1) tie_word_embeddings=True——lm_head 的 4.92M 参数和 embed 共享同一份权重,不重复计算,所以总表里 lm_head 是 0。(2) 历史版本的 minimind2-small(hidden=512)约 26M、minimind2-moe 约 145M;如果你看到旧文档写"26M",那是更早的小尺寸版本,不是这里的默认配置。
维度变化总览
数据在模型中每一步的形状
# 假设输入 input_ids: (2, 5),即 batch=2, seq_len=5
# 假设已缓存 past_len=3 个 token 的 KV-Cache
# ===== MiniMindModel.forward =====
input_ids # (2, 5)
embed_tokens(input_ids) # (2, 5, 768)
# ===== Attention.forward(单层)=====
q_proj(x) # (2, 5, 768)
view → xq # (2, 5, 8, 96) 8个Q头
k_proj(x) → view # (2, 5, 4, 96) 4个KV头
QK-Norm # (2, 5, 8, 96) / (2, 5, 4, 96)
RoPE # 维度不变
KV-Cache 拼接后 xk # (2, 8, 4, 96) seq=3+5=8
repeat_kv(xk, 2) # (2, 8, 8, 96) 复制成8个头
xq @ xk^T # (2, 8, 5, 8) 注意力分数
softmax @ xv # (2, 8, 5, 96)
reshape → o_proj # (2, 5, 768)
# ===== FeedForward.forward =====
gate_proj(x) # (2, 5, 2432)
up_proj(x) # (2, 5, 2432)
SiLU(gate) * up # (2, 5, 2432)
down_proj # (2, 5, 768)
# ===== MiniMindForCausalLM.forward =====
lm_head(hidden_states) # (2, 5, 6400) logits
logits[:, -1, :] # (2, 6400) 最后一个token的预测
常见错误排查
xq @ xk.transpose(-2, -1) 要求最后两个维度是 (96) @ (96) → (seq, seq)。如果中间某个 view 或 transpose 写错了维度,这里就会报错。
排查方法:在 forward 的每一步加 print(x.shape),和上面的维度追踪表对照。最常见的错误是把 view(bsz, seq_len, n_heads, head_dim) 中的 n_heads 和 head_dim 写反。
max_new_tokens 设太大,KV-Cache 会撑爆显存。KV-Cache 大小 = batch × num_kv_heads × seq_len × head_dim × 2(K和V) × dtype_size。MiniMind 在 24G 显卡上大约能生成 4000+ token。
start_pos 开始取 cos/sin,而不是从 0。如果忘了偏移,模型会认为所有新 token 都在位置 0,导致输出乱码。
triu(0) 会把对角线也设为 -∞,模型连自己都看不到。症状:loss 不下降或 NaN。
练习:维度
任务
打开 model_minimind.py,在 Attention.forward 的每一行后面标注 tensor 的 shape。假设输入是 (2, 5, 768),KV-Cache 中已有 3 个 token。
参考答案
x = input # (2, 5, 768)
xq = self.q_proj(x) # (2, 5, 768)
xq = xq.view(2, 5, 8, 96) # (2, 5, 8, 96)
xk = self.k_proj(x).view(2, 5, 4, 96) # (2, 5, 4, 96)
xk = cat([past_k, xk], dim=1) # (2, 8, 4, 96) 3+5=8
xq = xq.transpose(1, 2) # (2, 8, 5, 96)
xk = repeat_kv(xk, 2).transpose(1, 2) # (2, 8, 8, 96)
scores = xq @ xk.transpose(-2, -1) # (2, 8, 5, 8)
output = softmax(scores) @ xv # (2, 8, 5, 96)
output = output.transpose(1,2).reshape()# (2, 5, 768)
练习:理解
问题 1
为什么 k_proj 输出维度是 384(4×96)而不是 768(8×96)?如果把 num_key_value_heads 改成 8 会怎样?
提示:想想 GQA 和 KV-Cache 的关系。
问题 2
repeat_kv 做了什么?为什么 Q 不需要 repeat,只有 KV 需要?
提示:看看 repeat 的方向——是把少的复制成多的。
问题 3
因果掩码中,triu(1) 的 1 代表什么?如果是 triu(0) 会怎样?
提示:triu(1) 保留对角线,triu(0) 不保留。
问题 4
如果去掉残差连接(hidden_states += residual 改为直接赋值),训练会发生什么?为什么?
提示:想想梯度如何回传到浅层。
练习:修改
任务 A:调整模型大小
把 hidden_size 从 768 改成 512,num_hidden_layers 从 8 改成 4。手动计算新的 head_dim、intermediate_size、每层参数量。
参考答案
# head_dim = 512 // 8 = 64
# intermediate_size = ceil(512 * pi / 64) * 64 = ceil(25.13) * 64 = 26 * 64 = 1664
# q_proj: 512 * 512 = 0.26M
# k_proj: 512 * 256 = 0.13M
# FFN: 3 * 512 * 1664 = 2.56M
# 单层: ~3.1M, 4 层: ~12.4M + embed 3.3M ≈ 15.7M
任务 B:启用 MoE
在配置中设置 use_moe=True,num_experts=4。计算启用 MoE 后的总参数量。思考:为什么计算量几乎不变?
参考答案
# MoE 参数 = Router(768*4) + 4 * FFN(5.6M) ≈ 22.4M
# 每层从 ~7.37M 变为 ~24.2M(约 3 倍)
# 但每次只激活 1 个专家,计算量 ≈ 1 * FFN + Router ≈ 不变
# 这就是 MoE 的核心优势:参数多、计算少
延伸阅读
Attention Is All You Need(2017)——Transformer 原始论文。读完后你会发现 MiniMind 的架构和它几乎一样,只是把 LayerNorm 换成了 RMSNorm,加了 GQA 和 RoPE。
LLaMA: Open and Efficient Foundation Language Models(2023)——Meta 的开源 LLM。MiniMind 的架构基本就是小号 LLaMA:RMSNorm + RoPE + SwiGLU + GQA。读这篇论文可以了解每个设计选择的动机。
Mixture of Experts——Mixtral 论文(2024)展示了 MoE 在生产级 LLM 中的实际效果。4.5x 参数量,1x 计算量,性能接近更大的 Dense 模型。
阶段 2 完成
你刚刚理解了一个完整的 LLM 架构——从"为什么需要归一化"到"模型如何一个字一个字地生成"。
你学会了:RMSNorm 归一化、RoPE 位置编码、GQA 注意力、SwiGLU 前馈网络、MoE 混合专家、Pre-Norm 残差连接、自回归生成与采样策略。
下一章:预训练——让这些代码跑起来,学会"说人话"。