乐于分享
好东西不私藏

逐行拆解:MiniMind 模型架构源码精读

逐行拆解:MiniMind 模型架构源码精读

逐行拆解:MiniMind 模型架构源码精读

打开 model_minimind.py,我们来一个组件一个组件地拆。看完这篇,你就掌握了大模型架构的所有核心积木。


前言:一个文件,一个完整的大模型

MiniMind 的全部模型代码都在 model/model_minimind.py 这一个文件里。大约 300 多行 Python,定义了从配置类到生成函数的所有组件。本篇会按数据流顺序,逐个拆解以下核心组件:

MiniMindConfig          → 超参数定义RMSNorm                 → 归一化层precompute_freqs_cis    → RoPE 频率预计算apply_rotary_pos_emb    → 旋转位置编码应用Attention               → GQA 分组查询注意力FeedForward             → SwiGLU 前馈网络MiniMindBlock           → Transformer Block 组装MiniMindModel           → 模型主干MiniMindForCausalLM     → 完整因果语言模型

每个组件,我们都会回答三个问题:它是什么、为什么用它、代码怎么写的


一、MiniMindConfig:超参数蓝图

一切的起点是配置类。它定义了模型的所有超参数:

classMiniMindConfig(PretrainedConfig):    model_type = "minimind"def__init__(self, hidden_size=768, num_hidden_layers=8, use_moe=False, **kwargs):self.hidden_size = hidden_size            # 隐藏维度self.num_hidden_layers = num_hidden_layers # Transformer 层数self.vocab_size = 6400# 词表大小self.num_attention_heads = 8# Q 头数self.num_key_value_heads = 4# KV 头数(GQA)self.head_dim = hidden_size // num_attention_heads  # 每头维度self.intermediate_size = ceil(hidden_size * pi / 64) * 64# FFN 中间维度self.max_position_embeddings = 32768# 最大序列长度self.rope_theta = 1e6# RoPE 基频self.rms_norm_eps = 1e-6# RMSNorm epsilonself.hidden_act = 'silu'# 激活函数# MoE 相关self.num_experts = 4# 专家数量self.num_experts_per_tok = 1# 每 token 激活专家数self.router_aux_loss_coef = 5e-4# 辅助损失系数

注意 intermediate_size 的计算公式:ceil(hidden_size * pi / 64) * 64。用圆周率作为膨胀系数(约 3.14 倍),再对齐到 64 的倍数——对齐是为了 GPU 的 Tensor Core 高效计算。当 hidden_size=512 时,FFN 中间维度约为 ceil(512 * 3.14 / 64) * 64 = ceil(25.13) * 64 = 26 * 64 = 1664


二、RMSNorm:更快的归一化

为什么不用 LayerNorm?

LayerNorm 的公式是:先减均值(中心化),再除以标准差(缩放),最后乘以可学习参数 gamma 加上 beta。

LayerNorm(x) = gamma * (x - mean) / sqrt(var + eps) + beta

RMSNorm 的洞察是:中心化不重要,去掉它。只保留缩放操作,用均方根(Root Mean Square)替代标准差:

RMSNorm(x) = gamma * x / sqrt(mean(x^2) + eps)

好处:少了一次均值计算和减法操作,训练速度提升约 5-10%,效果几乎无损。LLaMA、Qwen、DeepSeek 等现代模型全都用 RMSNorm。

MiniMind 源码

classRMSNorm(torch.nn.Module):def__init__(self, dim: int, eps: float = 1e-5):super().__init__()self.eps = epsself.weight = nn.Parameter(torch.ones(dim))  # 可学习的缩放参数 gammadefnorm(self, x):# x.pow(2).mean(-1, keepdim=True) 就是 mean(x^2)# rsqrt = 1 / sqrt(...)return x * torch.rsqrt(x.pow(2).mean(-1, keepdim=True) + self.eps)defforward(self, x):# 先转 float32 做归一化(数值稳定),再转回原精度return (self.weight * self.norm(x.float())).type_as(x)

关键细节:x.float() 确保在 float32 精度下做归一化——如果在 float16/bfloat16 下直接计算 x.pow(2).mean(),很容易溢出。这是工程上的标准做法。参数量:dim 个 float,即 hidden_size 个参数。对于 512 维模型,一个 RMSNorm 只有 512 个参数。


三、RoPE 旋转位置编码:让模型感知位置

直觉解释

RoPE 的核心思想:把向量在复数平面上旋转。位置 m 处的向量被旋转 m 个基本角度。当计算位置 m 和位置 n 的注意力分数时,旋转角度的差 (m-n) 就自然编码了相对位置。可以这样理解——想象一个时钟。12 点钟方向是”起始位置”。每个 token 按固定角速度旋转——第 1 个 token 转了一小格,第 10 个 token 转了十格。两个 token 之间的”距离”就是它们转过的角度差。

频率预计算

defprecompute_freqs_cis(dim, end=32768, rope_base=1e6, rope_scaling=None):# 基础频率:每个维度对有不同的旋转速度# freq_i = rope_base^(-2i/d), i = 0, 1, ..., d/2-1    freqs = 1.0 / (rope_base ** (torch.arange(0, dim, 2)[:(dim // 2)].float() / dim))# YaRN 长度外推(可选)if rope_scaling isnotNoneand end / orig_max > 1.0:# 对不同频率分量施加不同缩放因子# 低频(捕捉长程依赖)→ 缩放更多# 高频(捕捉短程模式)→ 几乎不变        ramp = clamp((arange(dim//2) - low) / (high - low), 01)        freqs = freqs * (1 - ramp + ramp / factor)# 外积:position × frequency → 角度矩阵    t = torch.arange(end)            # [0, 1, 2, ..., 32767]    freqs = torch.outer(t, freqs)    # shape: (32768, dim//2)# 预计算 cos 和 sin,拼接成完整维度    freqs_cos = torch.cat([cos(freqs), cos(freqs)], dim=-1)  # (32768, dim)    freqs_sin = torch.cat([sin(freqs), sin(freqs)], dim=-1)  # (32768, dim)return freqs_cos, freqs_sin

为什么 rope_base 设成 100 万(1e6)?因为更大的基频意味着更慢的旋转速度,从而支持更长的序列。原始 LLaMA 用的是 10000,后来逐步加大到 1e5、1e6,以支持更长的上下文窗口。

旋转应用

defapply_rotary_pos_emb(q, k, cos, sin, unsqueeze_dim=1):defrotate_half(x):# 把向量的前半部分和后半部分交换并取反# [x1, x2, x3, x4] → [-x3, -x4, x1, x2]return torch.cat((-x[..., x.shape[-1]//2:], x[..., :x.shape[-1]//2]), dim=-1)# 旋转公式:q' = q * cos + rotate_half(q) * sin    q_embed = q * cos + rotate_half(q) * sin    k_embed = k * cos + rotate_half(k) * sinreturn q_embed, k_embed

这就是二维旋转矩阵的向量化实现。对于每一对相邻维度 (x_2i, x_2i+1),应用旋转:

[x'_2i  ]   [cos(m*theta_i)  -sin(m*theta_i)] [x_2i  ][x'_2i+1] = [sin(m*theta_i)   cos(m*theta_i)] [x_2i+1]

四、GQA 分组查询注意力:用更少的 KV 头

为什么不让 Q/K/V 头数相同

标准多头注意力(MHA)中,Q、K、V 的头数相同。如果有 8 个头,那就有 8 组 Q、8 组 K、8 组 V。但在推理时,K 和 V 需要被缓存(KV Cache)以避免重复计算。如果 8 个头各自有独立的 KV,显存消耗是 8 * seq_len * head_dim * 2(K 和 V 各一份)。GQA(Grouped Query Attention)的方案:让多个 Q 头共享同一组 KV 头。MiniMind2-small 的配置是 Q=8 头、KV=2 头——每 4 个 Q 头共享 1 组 KV。KV Cache 显存减少了 75%(从 8 组降到 2 组),但效果损失很小,因为 K 和 V 的”索引”功能本来就不需要那么细的粒度。

repeat_kv:把 KV 头”广播”到 Q 头数

defrepeat_kv(x, n_rep):# x shape: (batch, seq_len, num_kv_heads, head_dim)# 把每个 KV 头复制 n_rep 次,匹配 Q 的头数    bs, slen, num_kv_heads, head_dim = x.shapeif n_rep == 1:return xreturn (x[:, :, :, None, :]            .expand(bs, slen, num_kv_heads, n_rep, head_dim)            .reshape(bs, slen, num_kv_heads * n_rep, head_dim))

当 Q=8、KV=2 时,n_rep = 8 / 2 = 4,每个 KV 头被复制 4 次。

Attention 核心 forward

classAttention(nn.Module):def__init__(self, config):self.q_proj = nn.Linear(hidden_size, n_heads * head_dim, bias=False)self.k_proj = nn.Linear(hidden_size, n_kv_heads * head_dim, bias=False)self.v_proj = nn.Linear(hidden_size, n_kv_heads * head_dim, bias=False)self.o_proj = nn.Linear(n_heads * head_dim, hidden_size, bias=False)self.q_norm = RMSNorm(head_dim)  # QK-Norm,稳定训练self.k_norm = RMSNorm(head_dim)defforward(self, x, position_embeddings, past_key_value=None, ...):        bsz, seq_len, _ = x.shape# 1. 线性投影        xq = self.q_proj(x).view(bsz, seq_len, n_heads, head_dim)        xk = self.k_proj(x).view(bsz, seq_len, n_kv_heads, head_dim)        xv = self.v_proj(x).view(bsz, seq_len, n_kv_heads, head_dim)# 2. QK-Norm(Qwen3 引入的稳定训练 trick)        xq, xk = self.q_norm(xq), self.k_norm(xk)# 3. 应用 RoPE 旋转位置编码        cos, sin = position_embeddings        xq, xk = apply_rotary_pos_emb(xq, xk, cos, sin)# 4. KV Cache 拼接(推理加速)if past_key_value isnotNone:            xk = torch.cat([past_key_value[0], xk], dim=1)            xv = torch.cat([past_key_value[1], xv], dim=1)# 5. GQA:把 KV 头复制到匹配 Q 头数        xk = repeat_kv(xk, self.n_rep).transpose(12)        xv = repeat_kv(xv, self.n_rep).transpose(12)# 6. 注意力计算(优先用 Flash Attention)ifself.flash:            output = F.scaled_dot_product_attention(xq, xk, xv, is_causal=True)else:            scores = (xq @ xk.transpose(-2, -1)) / sqrt(head_dim)            scores += causal_mask  # 因果掩码:只能看到前面的 token            output = softmax(scores) @ xv# 7. 输出投影        output = self.o_proj(output.reshape(bsz, seq_len, -1))return output, past_kv

注意第 2 步的 QK-Norm——这是 Qwen3 引入的 trick,在 Q 和 K 投影之后分别做 RMSNorm,可以显著稳定训练过程中的注意力分数分布,防止大维度模型训练时出现 loss spike。


五、SwiGLU 前馈网络:占模型 65% 的参数

架构

标准 Transformer 的 FFN 是两层线性变换中间夹一个激活函数:FFN(x) = W2 * ReLU(W1 * x)SwiGLU 在此基础上加了一个门控机制(Gated Linear Unit):

SwiGLU(x) = down_proj(SiLU(gate_proj(x)) * up_proj(x))
  • gate_proj(x)
    :生成门控信号,经过 SiLU 激活
  • up_proj(x)
    :生成候选特征
  • 两者逐元素相乘:门控信号决定哪些特征通过
  • down_proj
    :投影回原始维度

SiLU(也叫 Swish)激活函数:SiLU(x) = x * sigmoid(x),比 ReLU 更平滑,梯度更好。

MiniMind 源码

classFeedForward(nn.Module):def__init__(self, config):        intermediate_size = config.intermediate_size  # ~1664 for hidden=512self.gate_proj = nn.Linear(hidden_size, intermediate_size, bias=False)self.down_proj = nn.Linear(intermediate_size, hidden_size, bias=False)self.up_proj   = nn.Linear(hidden_size, intermediate_size, bias=False)self.act_fn = siludefforward(self, x):returnself.down_proj(self.act_fn(self.gate_proj(x)) * self.up_proj(x))

为什么说它占 65% 参数

以 MiniMind2-small(hidden=512, intermediate=1664)为例:

gate_proj: 512 * 1664 = 852,480up_proj:   512 * 1664 = 852,480down_proj: 1664 * 512 = 852,480FFN 总计:  2,557,440 参数/层对比 Attention:q_proj: 512 * 512 = 262,144k_proj: 512 * 128 = 65,536   (KV=2头, 2*64=128)v_proj: 512 * 128 = 65,536o_proj: 512 * 512 = 262,144Attention 总计: 655,360 参数/层FFN 占比: 2,557,440 / (2,557,440 + 655,360) ≈ 79.6%

实际上 FFN 的参数比例比想象的还要高!这是因为 SwiGLU 有三个权重矩阵(gate、up、down),而注意力因为用了 GQA 减少了 KV 投影参数。


六、Transformer Block 组装:Pre-Norm + 残差连接

MiniMind 源码

classMiniMindBlock(nn.Module):def__init__(self, layer_id, config):self.self_attn = Attention(config)self.input_layernorm = RMSNorm(config.hidden_size)self.post_attention_layernorm = RMSNorm(config.hidden_size)self.mlp = FeedForward(config) ifnot config.use_moe else MOEFeedForward(config)defforward(self, hidden_states, position_embeddings, ...):# 残差 + Pre-Norm 注意力        residual = hidden_states        hidden_states = self.self_attn(self.input_layernorm(hidden_states),  # 先 Norm 再进注意力            position_embeddings, ...        )        hidden_states = hidden_states + residual  # 残差连接# 残差 + Pre-Norm FFN        hidden_states = hidden_states + self.mlp(self.post_attention_layernorm(hidden_states)  # 先 Norm 再进 FFN        )return hidden_states

Pre-Norm vs Post-Norm

原始 Transformer 用 Post-Norm(先计算子层,再 Norm),但现代大模型几乎全用 Pre-Norm(先 Norm,再进子层)。原因很实际:Pre-Norm 让残差路径上的梯度更稳定,训练深层模型(几十层甚至上百层)时不容易梯度爆炸或消失。代价是理论上每层的表达能力略弱,但可以通过加深来弥补。


七、参数计算实战

掌握了每个组件,我们来手算 MiniMind 的参数量。

MiniMind2-small (hidden=512, layers=8, heads=8, kv_heads=2, inter=1664)

每层参数:

组件
计算
参数量
q_proj
512 * 512
262,144
k_proj
512 * (2*64)
65,536
v_proj
512 * 128
65,536
o_proj
512 * 512
262,144
q_norm + k_norm
64 + 64
128
input_layernorm
512
512
post_attn_layernorm
512
512
gate_proj
512 * 1664
852,480
up_proj
512 * 1664
852,480
down_proj
1664 * 512
852,480
每层合计 3,213,952

全局参数:

组件
计算
参数量
embed_tokens
6400 * 512
3,276,800
lm_head
与 embed_tokens 共享
0(权重共享)
final norm
512
512
8 层 Transformer
8 * 3,213,952
25,711,616
总计 ~28.99M

与官方宣称的 26M 有小幅差异,因为实际的 intermediate_size 和 head_dim 配置可能略有不同。但数量级完全吻合。

MiniMind2 (hidden=768, layers=16, heads=8, kv_heads=4)

用同样的方法计算:head_dim=96, intermediate_size=ceil(768*pi/64)*64=2432

组件
参数量
每层 Attention
768768 + 768384 + 768384 + 768768 = ~1.47M
每层 FFN
76824323 = ~5.60M
每层合计
~7.07M
16 层
~113.2M
Embedding
6400*768 = ~4.9M
总计 ~118M

官方标称 104M,差异来自 intermediate_size 和 kv_heads 的具体配置。核心方法一致:参数量 = Embedding + N * (Attention + FFN + Norms)


八、完整数据流回顾

最后,把所有组件串起来,回顾完整的前向传播路径:

classMiniMindForCausalLM:defforward(self, input_ids, labels=None, ...):# 1. Token Embedding        hidden = self.model.embed_tokens(input_ids)  # (batch, seq) → (batch, seq, 512)# 2. 位置编码(预计算好的 cos/sin)        position_embeddings = (freqs_cos[start:start+seq], freqs_sin[start:start+seq])# 3. N 层 Transformer Blockfor layer inself.model.layers:            hidden = layer(hidden, position_embeddings, ...)# 每层内部:RMSNorm → Attention → 残差 → RMSNorm → FFN → 残差# 4. 最终归一化        hidden = self.model.norm(hidden)# 5. 语言模型头:投影到词表        logits = self.lm_head(hidden)  # (batch, seq, 512) → (batch, seq, 6400)# 6. 计算损失(训练时)if labels isnotNone:            loss = cross_entropy(logits[:-1], labels[1:])  # 预测下一个 tokenreturn logits, loss

注意第 5 步:lm_head 和 embed_tokens共享权重self.model.embed_tokens.weight = self.lm_head.weight)。这是一个经典 trick——输入的 Embedding 矩阵把 token 映射到语义空间,输出的 lm_head 把语义空间映射回 token。这两个操作是对称的,共享权重可以减少参数量且提升效果。


结语

300 多行代码,7 个核心类,一个完整的 Decoder-Only 大模型。现在你应该能理解:大模型不是黑魔法。它是 RMSNorm、RoPE、GQA、SwiGLU 这些明确的、可理解的组件按照固定模式组装起来的工程产物。每一个组件都有清晰的数学定义,每一行代码都有确定的计算语义。下一篇,我们会聚焦 MoE 混合专家架构——看看 MiniMind 是怎么用 4 个专家、145M 参数实现比 Dense 模型更好的效果的。


本文是「AI开源纪」大模型学习合集 · 认知篇的第 3 篇。我们以 MiniMind 开源项目为实战主线,从架构认知到源码精读,带你真正看懂大模型。

AI开源纪 — 解码前沿技术,连接开源世界。 关注我们,一起见证AI开源时代。