Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

3.7 基础模型架构深度解析

🏗️ "了解模型的工作原理让你做出更好的判断,而了解模型的架构演进,能让你理解整个行业在朝哪里走。"

在 3.1 节中,我们用直觉理解了 LLM 的基本原理——Transformer、注意力机制、Token 预测。本节将深入一层,带你看看模型的"骨架"长什么样——从 Decoder-Only 架构到注意力机制的变体、归一化方案、位置编码、MoE 路由,再到 2024—2026 年各大开源模型的具体架构选择。

这些知识不是"学术装饰"——当你要为 Agent 选择部署模型、评估推理成本、或理解为什么某个模型在长文本上表现更好时,对架构的理解就是你的底层判断力

现代 LLM 的标准"骨架":Decoder-Only Transformer

2023 年以来,几乎所有主流 LLM 都采用了 Decoder-Only 架构。这与最初 Transformer 的 Encoder-Decoder 结构不同——它只保留了解码器部分,通过**因果注意力掩码(Causal Mask)**保证每个 Token 只能"看到"它左边的内容。

# 因果注意力掩码的直觉
# 在生成 "我 喜欢 吃 苹果" 时:
#
#        我  喜欢  吃  苹果
#  我    [✓]  [✗]  [✗]  [✗]     ← "我" 只能看到自己
#  喜欢  [✓]  [✓]  [✗]  [✗]     ← "喜欢" 能看到 "我" 和自己
#  吃    [✓]  [✓]  [✓]  [✗]     ← "吃" 能看到前面所有词
#  苹果  [✓]  [✓]  [✓]  [✓]     ← "苹果" 能看到整个序列
#
# 对角线以上的 [✗] 就是因果掩码——防止"偷看未来"

为什么选 Decoder-Only?

架构代表模型适合任务为何 LLM 不用
Encoder-OnlyBERT理解类(分类、NER)不能自回归生成
Encoder-DecoderT5, BART翻译、摘要复杂度高,不利于超大规模扩展
Decoder-OnlyGPT, Llama, DeepSeek所有生成任务✅ 架构简洁,易扩展,训练高效

一个标准的 Decoder-Only Transformer 层长这样:

Decoder-Only Transformer一层的标准结构

class TransformerDecoderLayer:
    """现代 LLM 一层的标准结构(2024+ 共识版)"""
    
    def forward(self, x):
        # 1. Pre-Norm + 注意力
        residual = x
        x = self.norm1(x)                  # RMSNorm (Pre-Normalization)
        x = self.attention(x)              # 因果自注意力 (GQA/MLA)
        x = residual + x                   # 残差连接
        
        # 2. Pre-Norm + FFN
        residual = x
        x = self.norm2(x)                  # RMSNorm
        x = self.ffn(x)                    # SwiGLU 前馈网络
        x = residual + x                   # 残差连接
        
        return x

接下来,我们逐个拆解每个组件的技术演进。

Tokenizer:文本进入模型之前发生了什么

在讲 Transformer 内部之前,有一个常被忽视但至关重要的前置步骤:Tokenizer

LLM 不直接处理字符或单词,而是处理 Token——文本被切割成的基本单元,每个 Token 对应词表中的一个整数 ID。

BPE:字节对编码

在 BPE 之前,有两种朴素的分词方式,但都有明显缺陷:

字符级(Character-level):
  "unbelievable" → [u, n, b, e, l, i, e, v, a, b, l, e]  ← 12 个 Token
  优点:词表极小(~256),没有 OOV(未登录词)问题
  缺点:序列太长,模型需要学习更多步才能理解语义

词级(Word-level):
  "unbelievable" → ["unbelievable"]  ← 1 个 Token
  优点:序列短
  缺点:词表巨大(数百万),"running"/"runs"/"ran" 是完全不同的 Token,
        且遇到新词(如专有名词、代码变量)就变成 [UNK]

BPE 是两者的折中——从字符出发,通过统计合并高频子词,最终得到一个大小可控、覆盖面广的词表:

BPE 分词:三种方案对比与合并过程

现代 LLM 普遍使用 BPE(Byte Pair Encoding) 或其变体(如 SentencePiece、tiktoken)。算法思路:

初始词表 = 所有单个字节(256 个,覆盖任意语言)
重复执行:
  1. 统计语料中所有相邻 Token 对的频次
  2. 合并频次最高的那对 → 形成新 Token
  3. 直到词表大小达到目标(如 32000、128256)

直觉示例(简化):

原始文本:  "unbelievable"
初始切分:  [u, n, b, e, l, i, e, v, a, b, l, e]
第1次合并:  "ab" 高频 → [u, n, b, e, l, i, e, v, ab, l, e]
第2次合并:  "le" 高频 → [u, n, b, e, l, i, e, v, ab, le]
...
最终结果:  ["un", "believ", "able"]   ← 3 个 Token

BPE 的关键优势:子词共享。"running"、"runner"、"runs" 都包含 "run" 这个子词 Token,模型可以学到它们的语义关联;遇到从未见过的新词,也能拆成已知子词来处理,不会出现 [UNK]。

import tiktoken  # OpenAI 的 BPE 实现

enc = tiktoken.get_encoding("cl100k_base")  # GPT-4 使用的分词器

# 一句话的 Token 化
text = "Hello, 你好!Agent development is fascinating."
tokens = enc.encode(text)
print(f"Token 数量: {len(tokens)}")          # 约 15 个 Token
print(f"Token IDs: {tokens[:8]}...")          # [9906, 11, 220, ...]

# Token 还原
decoded = enc.decode(tokens)
print(f"还原文本: {decoded}")               # 完全一致

# 不同语言的 Token 效率差异
print(enc.encode("Hello"))          # [15339] → 1 个 Token
print(enc.encode("你好"))           # [57668, 53901] → 2 个 Token  ← 中文效率较低
print(enc.encode("مرحبا"))          # 阿拉伯文 → 更多 Token

词表大小的权衡

词表大小代表模型Token 效率(中文)内存开销
32,000Llama 2低(中文 3-4 字/Token)
65,536Qwen 2中(约 2 字/Token)
128,256Llama 3/4中高较高
150,000+DeepSeek V3高(接近 1 字/Token)

💡 为什么词表大小重要? 更大的词表意味着中文等非英语语言能用更少的 Token 表达相同内容,直接降低推理成本。DeepSeek 和 Qwen 系列专门为中文扩充了词表。

Token 经过 Embedding 层

Token ID → 词嵌入向量(Embedding),这是模型的第一层:

class TokenEmbedding:
    """将 Token ID 转为稠密向量"""
    def __init__(self, vocab_size=128256, d_model=4096):
        # Embedding 矩阵:形状 [vocab_size × d_model]
        # 每个 Token 对应一行 → 一个 4096 维向量
        self.embed = nn.Embedding(vocab_size, d_model)
    
    def forward(self, token_ids):
        # 输入: [batch_size, seq_len] 的整数 ID
        # 输出: [batch_size, seq_len, d_model] 的浮点向量
        return self.embed(token_ids)

# 示例
token_ids = torch.tensor([[9906, 11, 220, 57668]])  # "Hello, 你..."
embeddings = embedding(token_ids)
# shape: [1, 4, 4096] → 每个 Token 变成一个 4096 维向量

Embedding 矩阵本身就是模型参数的重要组成部分。对于词表 128K、维度 4096 的模型,Embedding 层就有 128K × 4096 ≈ 5 亿参数

Embedding 的本质是什么? 它是一个可学习的查找表——每个 Token ID 对应一行向量,这个向量在训练过程中被优化,使得语义相近的 Token 在向量空间中距离更近:

训练后的 Embedding 空间(直觉示意):

  "king" ──────────────────────────────────────────────────────────
  "queen" ─────────────────────────────────────────────────────────
  (两者距离很近,因为语义相近)

  "king" - "man" + "woman" ≈ "queen"  ← 著名的词向量类比关系

  "Python" 和 "JavaScript" 的向量比 "Python" 和 "苹果" 更接近
  (因为训练语料中它们经常出现在相似的上下文里)

💡 为什么用稠密向量而不是 one-hot? one-hot 向量(词表大小的稀疏向量)无法表达词语之间的关系——"cat" 和 "dog" 的 one-hot 向量正交,看不出任何相似性。稠密 Embedding 向量通过训练自动学到语义关系,是 LLM 理解语言的第一步。


Scaled Dot-Product Attention:注意力的核心计算

在讲注意力变体之前,我们先把最核心的计算讲透。

Q、K、V 从哪来?

Scaled Dot-Product Attention 完整计算流程

输入序列的每个 Token 向量 ,通过三个独立的线性投影得到 Q、K、V:

直觉上:

  • Q(Query):"我想找什么信息?"
  • K(Key):"我能提供什么信息的'标签'?"
  • V(Value):"我实际携带的信息内容"
class SelfAttention:
    """最简单的自注意力实现"""
    def __init__(self, d_model=512, d_k=64):
        # 三个投影矩阵(可学习参数)
        self.W_Q = nn.Linear(d_model, d_k, bias=False)
        self.W_K = nn.Linear(d_model, d_k, bias=False)
        self.W_V = nn.Linear(d_model, d_k, bias=False)
    
    def forward(self, x):
        # x shape: [batch, seq_len, d_model]
        Q = self.W_Q(x)  # [batch, seq_len, d_k]
        K = self.W_K(x)  # [batch, seq_len, d_k]
        V = self.W_V(x)  # [batch, seq_len, d_k]
        return scaled_dot_product(Q, K, V)

Scaled Dot-Product Attention 完整推导

逐步拆解:

步骤 1: QKᵀ → 相似度矩阵
  Q:  [seq_len, d_k]   → 每行是一个 Token 的"查询向量"
  Kᵀ: [d_k, seq_len]  → 每列是一个 Token 的"键向量"
  QKᵀ: [seq_len, seq_len] → 矩阵 (i,j) 项 = Token_i 和 Token_j 的点积相似度

步骤 2: ÷ √d_k → 缩放(防止点积过大)
  为什么要缩放?
  - d_k 维度下,随机初始化的点积方差是 d_k
  - 不缩放时,softmax 输入过大 → 梯度消失(softmax 饱和区)
  - ÷ √d_k 将方差归一化为 1

步骤 3: softmax → 注意力权重(概率分布)
  每行变成 0-1 之间的权重,和为 1
  "Token_i 应该把多少注意力分配给 Token_j"

步骤 4: × V → 加权求和
  用注意力权重对 V 做加权平均
  输出: [seq_len, d_k] → 每个 Token 的新表示(融合了它关注的信息)
import math

def scaled_dot_product_attention(Q, K, V, mask=None):
    """
    Q, K, V: [batch, heads, seq_len, d_k]
    mask:    [batch, 1, seq_len, seq_len](因果掩码)
    """
    d_k = Q.size(-1)
    
    # 步骤 1+2: 计算缩放后的相似度
    scores = torch.matmul(Q, K.transpose(-2, -1)) / math.sqrt(d_k)
    # shape: [batch, heads, seq_len, seq_len]
    
    # 步骤 3: 因果掩码(把未来位置设为 -∞,softmax 后变 0)
    if mask is not None:
        scores = scores.masked_fill(mask == 0, float('-inf'))
    
    # 步骤 4: softmax 得到注意力权重
    attn_weights = torch.softmax(scores, dim=-1)
    # shape: [batch, heads, seq_len, seq_len]
    
    # 步骤 5: 加权求和 V
    output = torch.matmul(attn_weights, V)
    # shape: [batch, heads, seq_len, d_k]
    
    return output, attn_weights

# 一个具体的例子:
# seq_len=4 ("I love AI deeply"), d_k=64, n_heads=8
Q = torch.randn(1, 8, 4, 64)
K = torch.randn(1, 8, 4, 64)
V = torch.randn(1, 8, 4, 64)

# 创建因果掩码(下三角矩阵)
mask = torch.tril(torch.ones(4, 4))
output, weights = scaled_dot_product_attention(Q, K, V, mask)
# weights[0, 0] 大致是这样:
# "I"     → [1.00, 0.00, 0.00, 0.00]  只看自己
# "love"  → [0.55, 0.45, 0.00, 0.00]  看"I"和自己
# "AI"    → [0.30, 0.35, 0.35, 0.00]  看前两个词
# "deeply"→ [0.20, 0.25, 0.30, 0.25]  看所有词

为什么需要"多头"?

单头注意力只能关注一种"关系"(如主谓关系)。多头并行计算,每个头可以学习不同的关系模式:

class MultiHeadAttentionFull:
    """多头注意力的完整实现"""
    def __init__(self, d_model=512, n_heads=8):
        self.n_heads = n_heads
        self.d_k = d_model // n_heads  # 每头维度 = 64
        
        # 所有头共用一个大矩阵,实际上等价于 n_heads 个独立的 W_Q/W_K/W_V
        self.W_Q = nn.Linear(d_model, d_model)
        self.W_K = nn.Linear(d_model, d_model)
        self.W_V = nn.Linear(d_model, d_model)
        self.W_O = nn.Linear(d_model, d_model)  # 输出投影
    
    def forward(self, x, mask=None):
        batch, seq, d = x.shape
        
        # 1. 投影并拆分为多头
        Q = self.W_Q(x).view(batch, seq, self.n_heads, self.d_k).transpose(1, 2)
        K = self.W_K(x).view(batch, seq, self.n_heads, self.d_k).transpose(1, 2)
        V = self.W_V(x).view(batch, seq, self.n_heads, self.d_k).transpose(1, 2)
        # 形状: [batch, n_heads, seq, d_k]
        
        # 2. 每头独立计算注意力
        attn_out, _ = scaled_dot_product_attention(Q, K, V, mask)
        # 形状: [batch, n_heads, seq, d_k]
        
        # 3. 拼接所有头的输出
        concat = attn_out.transpose(1, 2).contiguous().view(batch, seq, d)
        # 形状: [batch, seq, d_model]
        
        # 4. 最终线性投影
        return self.W_O(concat)

🎯 直觉理解

  • 头1 可能学到:语法依存关系(主语 → 动词)
  • 头2 可能学到:共指关系("它" → 指代哪个名词)
  • 头3 可能学到:局部位置关系(相邻词的搭配)
  • 头8 可能学到:长距离语义关联

KV Cache:推理加速的关键

KV Cache 是 LLM 推理效率最重要的机制,但它也是显存的最大消耗者之一。要理解 KV Cache,我们需要先回顾一个关键问题:LLM 是怎么一个字一个字生成文本的?

KV Cache:Prefill vs Decode 两阶段对比

先理解自回归生成:一次只出一个字

LLM 生成文本的方式叫自回归(Autoregressive)——每次只预测下一个 Token,然后把这个新 Token 拼回输入,再预测下下一个,如此循环:

用户输入: "今天天气"

第1步: 模型输入 ["今天天气"]           → 预测出 "真"
第2步: 模型输入 ["今天天气真"]          → 预测出 "好"
第3步: 模型输入 ["今天天气真好"]        → 预测出 "!"
第4步: 模型输入 ["今天天气真好!"]      → 预测出 [结束]

最终输出: "真好!"

注意关键点:每一步,模型都要对"整个输入序列"做一次完整的注意力计算。这意味着:

  • 第1步:对 4 个 Token 计算注意力("今天天气")
  • 第2步:对 5 个 Token 计算注意力("今天天气真")
  • 第3步:对 6 个 Token 计算注意力("今天天气真好")

每一步都要从头算所有 Token 的 Q、K、V,然后做注意力。

没有 KV Cache 时:大量重复计算

回忆注意力的计算过程:每个 Token 需要先通过 三个矩阵投影得到 Q、K、V 向量,然后用 算注意力分数,最后加权求和 V。

问题在于:第2步计算时,"今天天气" 这 4 个 Token 的 K 和 V 向量,在第1步已经算过了!但如果不缓存,第2步又要重新算一遍。第3步又要把前 5 个 Token 的 K、V 全部重新算一遍……

没有 KV Cache 时,每一步的计算量:

第1步 输入 "今天天气"(4个Token):
  ├── 对 4 个 Token 计算 Q、K、V(4 次线性投影 × 3)
  └── 计算 4×4 的注意力矩阵

第2步 输入 "今天天气真"(5个Token):
  ├── 对 5 个 Token 计算 Q、K、V(5 次线性投影 × 3)← "今天天气"的 K/V 又算了一遍!
  └── 计算 5×5 的注意力矩阵

第3步 输入 "今天天气真好"(6个Token):
  ├── 对 6 个 Token 计算 Q、K、V(6 次线性投影 × 3)← 前5个的 K/V 又又算了一遍!
  └── 计算 6×6 的注意力矩阵

...

生成 N 个新 Token 的总 K/V 投影计算量:
  = L + (L+1) + (L+2) + ... + (L+N)    (L = prompt 长度)
  ≈ O(N × L + N²)
  当 N 很大时 → O(N²) 级别的重复计算!

这就像每次考试都要把之前所有课本重新抄一遍——明明之前已经抄过了,但因为没保存,只能重来。

KV Cache 的核心思想:算过的就存起来

KV Cache 的思路非常直觉——历史 Token 的 K 和 V 向量不会变,算一次就缓存起来,后面直接复用

为什么 K 和 V 不会变?因为在因果注意力下,历史 Token 的 K 和 V 只取决于它自身的输入向量和固定的权重矩阵 ,与后续新生成的 Token 无关。

有 KV Cache 时,每一步的计算量:

第1步 输入 "今天天气"(4个Token):
  ├── 对 4 个 Token 计算 Q、K、V
  ├── 计算 4×4 的注意力矩阵
  └── 💾 把 4 个 Token 的 K、V 存入缓存

第2步 输入 "真"(只有1个新Token!):
  ├── 只对 "真" 计算 Q、K、V(1 次投影 × 3)
  ├── 💾 把 "真" 的 K、V 追加到缓存(现在缓存有 5 个 Token 的 K/V)
  └── 用 "真" 的 Q 对缓存中 5 个 K/V 计算注意力(1×5 而非 5×5!)

第3步 输入 "好"(只有1个新Token!):
  ├── 只对 "好" 计算 Q、K、V(1 次投影 × 3)
  ├── 💾 把 "好" 的 K、V 追加到缓存(现在缓存有 6 个 Token 的 K/V)
  └── 用 "好" 的 Q 对缓存中 6 个 K/V 计算注意力(1×6 而非 6×6!)

对比一目了然

无 KV Cache有 KV Cache
每步 K/V 投影所有 Token 重新计算只对 1 个新 Token 计算
每步注意力矩阵N × N(完整矩阵)1 × N(只算新 Token 那一行)
生成 N 个 Token 的总投影量O(N²)O(N)
代价无额外显存需要存储所有历史 K/V

🎯 一句话总结:KV Cache 是用显存换计算——花一些显存存储历史 K/V,省去大量重复的矩阵投影和注意力计算。

代码实现:带 KV Cache 的注意力

class KVCacheAttention:
    """带 KV Cache 的推理注意力"""
    
    def __init__(self):
        self.k_cache = None  # 存储历史 Key,形状 [batch, cached_len, d_k]
        self.v_cache = None  # 存储历史 Value
    
    def forward(self, x_new, is_prefill=False):
        """
        is_prefill=True:  处理完整 prompt(一次性并行计算)
        is_prefill=False: 每次只处理 1 个新 Token(逐步生成)
        """
        # 对新输入计算 Q/K/V
        Q_new = self.W_Q(x_new)  # prefill: [batch, prompt_len, d_k]
        K_new = self.W_K(x_new)  # decode:  [batch, 1, d_k](只有1个新Token)
        V_new = self.W_V(x_new)
        
        if is_prefill:
            # ── Prefill 阶段 ──
            # 首次处理完整 prompt,初始化缓存
            self.k_cache = K_new  # 存下 prompt 所有 Token 的 K
            self.v_cache = V_new  # 存下 prompt 所有 Token 的 V
            # Q 也是完整的,做标准的全序列注意力
            K_all = K_new
            V_all = V_new
        else:
            # ── Decode 阶段 ──
            # 把新 Token 的 K/V 追加到缓存末尾
            self.k_cache = torch.cat([self.k_cache, K_new], dim=1)
            self.v_cache = torch.cat([self.v_cache, V_new], dim=1)
            # K_all/V_all 是完整的历史 + 新 Token
            K_all = self.k_cache  # [batch, cached_len+1, d_k]
            V_all = self.v_cache
        
        # 注意力计算:Q_new × K_all^T → 注意力权重 → 加权 V_all
        # Decode 时:Q_new 只有 1 行,所以注意力矩阵是 [1 × (cached_len+1)]
        # 而不是 [(cached_len+1) × (cached_len+1)]!
        output = scaled_dot_product_attention(Q_new, K_all, V_all)
        return output

用一个具体例子走一遍完整流程:

# 假设 prompt = "写一首诗"(4 个 Token),模型要生成 "春 风 来"

# ── 第0步:Prefill ──
# 输入: ["写", "一", "首", "诗"](4 个 Token 并行处理)
# 计算: 4 个 Token 的 Q/K/V,做 4×4 注意力
# 缓存: k_cache = [K_写, K_一, K_首, K_诗]  ← 4 个 K 向量
#        v_cache = [V_写, V_一, V_首, V_诗]  ← 4 个 V 向量
# 输出: 预测下一个 Token → "春"

# ── 第1步:Decode ──
# 输入: ["春"](只有 1 个新 Token!)
# 计算: 1 个 Token 的 Q/K/V
# 缓存: k_cache = [K_写, K_一, K_首, K_诗, K_春]  ← 追加 K_春
#        v_cache = [V_写, V_一, V_首, V_诗, V_春]  ← 追加 V_春
# 注意力: Q_春 × [K_写, K_一, K_首, K_诗, K_春]^T → 1×5 的注意力权重
# 输出: 预测下一个 Token → "风"

# ── 第2步:Decode ──
# 输入: ["风"](只有 1 个新 Token!)
# 计算: 1 个 Token 的 Q/K/V
# 缓存: k_cache = [K_写, K_一, K_首, K_诗, K_春, K_风]  ← 追加 K_风
# 注意力: Q_风 × [K_写, K_一, K_首, K_诗, K_春, K_风]^T → 1×6 的注意力权重
# 输出: 预测下一个 Token → "来"

# 注意:每步只做了 1 次 K/V 投影(新 Token),而不是对所有 Token 重新投影!

为什么只缓存 K 和 V,不缓存 Q?

这是一个常见的疑问。原因是:

在 Decode 阶段,注意力计算是:

  Attention = softmax(Q_new × K_all^T / √d_k) × V_all

  Q_new:  只有当前新 Token 的查询向量  → 形状 [1, d_k]
  K_all:  所有历史 + 新 Token 的键向量  → 形状 [seq_len, d_k]
  V_all:  所有历史 + 新 Token 的值向量  → 形状 [seq_len, d_k]

关键洞察:
  - Q 只需要当前 Token 的 → 不需要缓存历史 Q
  - K 需要所有 Token 的(因为新 Token 要和所有历史 Token 算相似度)→ 必须缓存
  - V 需要所有 Token 的(因为要对所有 Token 的 Value 加权求和)→ 必须缓存

所以:只缓存 K 和 V,Q 每步实时计算即可。

Prefill vs Decode:推理的两个阶段

理解了 KV Cache 之后,LLM 推理自然分为两个截然不同的阶段:

Prefill 与 Decode 阶段对比

为什么 Decode 阶段是"内存带宽密集型"? 因为每步只做 1 个 Token 的计算(计算量很小),但需要从显存中读取整个 KV Cache(数据量很大)。GPU 的算力没有被充分利用,瓶颈在于"数据搬运速度"。这也是为什么 Decode 阶段的吞吐量远低于 Prefill 阶段。

KV Cache 的显存计算

KV Cache 的显存开销可以精确计算——这对模型部署和成本估算非常重要:

# KV Cache 显存公式:
# 
# 显存(字节) = 2 × n_layers × n_kv_heads × head_dim × seq_len × dtype_bytes
#              ↑      ↑            ↑           ↑         ↑          ↑
#             K+V   模型层数    KV头数      每头维度   序列长度   数据类型字节数
#
# 为什么是 2?因为 K 和 V 各存一份。
# 为什么有 n_layers?因为每一层都有独立的 KV Cache!

def kv_cache_memory_gb(n_layers, n_kv_heads, head_dim, 
                        seq_len, dtype_bytes=2):
    """计算 KV Cache 占用的显存(GB)"""
    total_bytes = 2 * n_layers * n_kv_heads * head_dim * seq_len * dtype_bytes
    return total_bytes / (1024 ** 3)

# ── 实际模型的 KV Cache 对比(每 1K Token,bfloat16 精度)──

# Llama 2-70B (MHA): 80层, 64个KV头, 每头128维
print(kv_cache_memory_gb(80, 64, 128, 1024))  # ≈ 2.5 GB / 1K Token ← 巨大!
# 如果对话长度 8K Token → KV Cache 就要 20 GB 显存

# Llama 3-70B (GQA): 80层, 只有8个KV头, 每头128维  
print(kv_cache_memory_gb(80, 8, 128, 1024))   # ≈ 0.32 GB / 1K Token ← 8倍优化!
# 同样 8K Token → 只需 2.5 GB

# DeepSeek-V2 (MLA): 不存完整 KV,只缓存 512 维的压缩潜在向量
# 等效 KV Cache ≈ 0.04 GB / 1K Token ← 约 70 倍优化!

为什么 KV Cache 显存这么重要? 以一个实际场景为例:

场景:用 Llama 2-70B 服务 100 个并发用户,每人对话 4K Token

KV Cache 总显存 = 2.5 GB/1K × 4 × 100 = 1000 GB ← 需要 12+ 张 A100!
模型参数本身只需要 ~140 GB(bfloat16)

结论:KV Cache 的显存开销可以远超模型参数本身!
这就是为什么 GQA、MLA 等减少 KV Cache 的技术如此重要。

💡 对 Agent 开发的启示

  • KV Cache 按 Token 数量 × 层数 线性增长,长对话 = 高显存 = 高成本
  • 这就是为什么许多 Agent 框架需要做上下文截断/压缩(见第8章)
  • 服务商按 Input Token 收费更贵,部分原因是 Prefill 阶段要一次性计算并缓存所有 Token 的 K/V
  • 选择 GQA/MLA 模型可以显著降低长对话场景的部署成本

注意力机制的演进:MHA → GQA → MLA

注意力机制演进:MHA→GQA→MLA

注意力机制是 Transformer 的"心脏"。从 2017 到 2025 年,它经历了三代关键变体——驱动力是推理效率,尤其是 KV-Cache 的显存压力

MHA:经典多头注意力

最初 Transformer 使用的多头注意力(Multi-Head Attention),每个头都有独立的 Query、Key、Value 投影:

class MultiHeadAttention:
    """MHA: 每个头都有独立的 Q、K、V"""
    def __init__(self, d_model=4096, n_heads=32):
        self.n_heads = n_heads
        self.head_dim = d_model // n_heads  # 128
        
        # 每个头独立的 Q、K、V 投影
        self.wq = Linear(d_model, n_heads * self.head_dim)  # 32 组 Q
        self.wk = Linear(d_model, n_heads * self.head_dim)  # 32 组 K ← 这很多!
        self.wv = Linear(d_model, n_heads * self.head_dim)  # 32 组 V ← 这也很多!
    
    # KV Cache 大小 = n_layers × n_heads × seq_len × head_dim × 2
    # 对于 Llama-2-70B (80层, 64头, 128维): 
    # 每 1K token 的 KV Cache ≈ 2.5 GB!

问题:推理时要缓存所有层、所有头的 K 和 V——当序列长度增长时,显存爆炸。

GQA:分组查询注意力

Llama 2(2023)引入了 Grouped-Query Attention——让多个 Query 头共享一组 Key-Value。

直觉理解:MHA 中每个 Q 头都有自己专属的 K/V,就像 32 个人各自维护一份完整的"记忆"。GQA 的思路是:这 32 份记忆大部分是重复的,不如让 4 个 Q 头共享 1 份 K/V——查询方式各不相同(32 组 Q),但查的是同一份"记忆库"(8 组 KV):

MHA(32 Q 头,32 KV 头):
  Q₁ → K₁,V₁    Q₂ → K₂,V₂    ...    Q₃₂ → K₃₂,V₃₂
  每个 Q 头有独立的 KV → 缓存 32 份 KV

GQA(32 Q 头,8 KV 头,每组 4 个 Q 共享 1 个 KV):
  Q₁  ─┐
  Q₂  ─┤→ K₁,V₁    Q₅  ─┐
  Q₃  ─┤              Q₆  ─┤→ K₂,V₂    ...(共 8 组)
  Q₄  ─┘              Q₇  ─┤
                       Q₈  ─┘
  只缓存 8 份 KV → KV Cache 缩小 4 倍!
class GroupedQueryAttention:
    """GQA: 多个 Q 头共享一组 KV,大幅减少 KV Cache"""
    def __init__(self, d_model=4096, n_q_heads=32, n_kv_heads=8):
        self.n_q_heads = n_q_heads     # 32 个 Query 头
        self.n_kv_heads = n_kv_heads   # 8 个 KV 头(每 4 个 Q 共享 1 个 KV)
        self.n_groups = n_q_heads // n_kv_heads  # 每组 4 个 Q 头
        self.head_dim = d_model // n_q_heads
        
        self.wq = Linear(d_model, n_q_heads * self.head_dim)   # 32 组 Q
        self.wk = Linear(d_model, n_kv_heads * self.head_dim)  # 只有 8 组 K!
        self.wv = Linear(d_model, n_kv_heads * self.head_dim)  # 只有 8 组 V!
    
    def forward(self, x):
        B, S, D = x.shape
        Q = self.wq(x).view(B, S, self.n_q_heads, self.head_dim).transpose(1, 2)
        K = self.wk(x).view(B, S, self.n_kv_heads, self.head_dim).transpose(1, 2)
        V = self.wv(x).view(B, S, self.n_kv_heads, self.head_dim).transpose(1, 2)
        
        # 关键步骤:把 K/V 复制 n_groups 次,让每个 Q 头都能找到对应的 KV
        # [batch, n_kv_heads, seq, head_dim] → [batch, n_q_heads, seq, head_dim]
        K = K.repeat_interleave(self.n_groups, dim=1)  # 每个 KV 头复制 4 份
        V = V.repeat_interleave(self.n_groups, dim=1)
        
        # 之后和 MHA 完全一样的注意力计算
        return scaled_dot_product_attention(Q, K, V)
    
    # KV Cache 缩小为 MHA 的 1/4(32→8 头)
    # Llama 3 70B: KV Cache 从 2.5GB/1K → ~0.6GB/1K

GQA 几乎不损失模型质量(大量消融实验验证),却将 KV Cache 减少了 4~8 倍。这就是为什么 2023 年之后几乎所有主流模型都采用了 GQA。

哪些模型用了 GQA?

  • Llama 2/3/4、Qwen 2/2.5/3、Gemma 2/3、Mistral/Mixtral、Phi-3/4

MLA:多头潜在注意力(DeepSeek 创新)

DeepSeek-V2(2024)提出了更激进的方案——Multi-head Latent Attention。它不是减少 KV 头的数量,而是把整个 KV 压缩到一个低维潜在空间

核心思想:GQA 是减少 KV 头的数量(从 32 减到 8),但每个 KV 头的维度不变。MLA 换了一个思路——不管有多少头,先把所有 KV 信息压缩到一个极低维的"潜在向量"(512 维),缓存这个压缩向量;需要计算注意力时,再实时解压回完整的 KV:

GQA 的思路:
  原始 KV → 减少头数 → 缓存(头数少但每头维度不变)
  缓存大小 = n_kv_heads × head_dim × seq_len

MLA 的思路:
  原始 KV → 压缩到低维潜在向量 → 缓存(维度极小)
                                    ↓ 需要时
                               实时解压 → 完整 KV → 注意力计算
  缓存大小 = kv_lora_rank × seq_len  (512 维 vs 原来的 128×128=16384 维)

这本质上是把 KV 的存储问题转化为一个低秩分解问题——用一个低维向量来近似表示高维的 KV 信息:

class MultiHeadLatentAttention:
    """
    MLA: DeepSeek 的核心创新
    不是"共享头",而是"压缩 KV 到低维空间"
    
    DeepSeek-V3 参数:
    - d_model = 7168
    - n_heads = 128,head_dim = 128
    - 完整 KV 维度 = 128 × 128 × 2 = 32768
    - MLA 压缩后 = 512 维  → 压缩比 64:1!
    """
    def __init__(self, d_model=7168, n_heads=128, kv_lora_rank=512, qk_rope_dim=64):
        self.n_heads = n_heads
        self.head_dim = 128
        self.kv_lora_rank = kv_lora_rank
        
        # ── KV 压缩路径 ──
        # 下投影:把输入压缩到 512 维潜在向量(这个向量就是缓存的内容!)
        self.kv_down_proj = Linear(d_model, kv_lora_rank)          # 7168 → 512
        # 上投影:推理时从 512 维解压回完整 KV
        self.kv_up_proj = Linear(kv_lora_rank, n_heads * self.head_dim * 2)  # 512 → 32768
        
        # ── Q 的处理(MLA 也对 Q 做了压缩,但 Q 不需要缓存)──
        self.q_down_proj = Linear(d_model, 1536)                    # Q 也先压缩
        self.q_up_proj = Linear(1536, n_heads * self.head_dim)      # 再解压
        
        # ── RoPE 解耦:单独处理位置编码部分 ──
        # MLA 的一个关键技巧:把 Q/K 拆成"内容部分"和"位置部分"
        # 只对位置部分应用 RoPE,内容部分不旋转
        # 这样缓存的潜在向量就不含位置信息,可以在不同位置复用
        self.q_rope_proj = Linear(1536, n_heads * qk_rope_dim)      # Q 的位置部分
        self.k_rope_proj = Linear(d_model, qk_rope_dim)             # K 的位置部分
    
    def forward(self, x, kv_cache=None):
        # ── 步骤1:压缩 KV,存入缓存 ──
        compressed_kv = self.kv_down_proj(x)  # [batch, seq, 512] ← 只缓存这个!
        
        if kv_cache is not None:
            kv_cache.append(compressed_kv)    # 每个 Token 只存 512 维
            all_compressed_kv = kv_cache.get_all()  # 取出所有历史压缩 KV
        else:
            all_compressed_kv = compressed_kv
        
        # ── 步骤2:实时解压 KV(计算时才解压,不提前存完整 KV)──
        full_kv = self.kv_up_proj(all_compressed_kv)  # 512 → 32768
        k_content, v = full_kv.chunk(2, dim=-1)        # 拆成 K 和 V
        
        # ── 步骤3:处理 Q ──
        q_compressed = self.q_down_proj(x)
        q_content = self.q_up_proj(q_compressed)
        
        # ── 步骤4:RoPE 解耦(位置编码只加在专门的维度上)──
        q_rope = apply_rope(self.q_rope_proj(q_compressed))  # Q 的位置部分
        k_rope = apply_rope(self.k_rope_proj(x))              # K 的位置部分
        
        # 拼接内容部分 + 位置部分
        q = torch.cat([q_content, q_rope], dim=-1)
        k = torch.cat([k_content, k_rope], dim=-1)
        
        # ── 步骤5:标准注意力计算 ──
        return scaled_dot_product_attention(q, k, v)

为什么 MLA 需要"RoPE 解耦"? 这是一个微妙但关键的设计。如果直接对压缩后的潜在向量应用 RoPE,那么同一个 Token 在不同位置的潜在向量就会不同(因为 RoPE 依赖位置),缓存就失去了意义。MLA 的解决方案是把 K 拆成两部分:不含位置信息的"内容 K"(可以安全缓存)和含位置信息的"位置 K"(实时计算),两者拼接后再做注意力。

效果有多惊人?

注意力类型KV Cache / Token对比 MHA
MHA(Llama 2 级别)~2.5 GB / 1K tokens基准
GQA(Llama 3 级别)~0.6 GB / 1K tokens减少 75%
MLA(DeepSeek-V3)~0.04 GB / 1K tokens减少 98.6%

MLA 让 DeepSeek-V3(671B 参数)可以在相对有限的硬件上处理极长上下文——这是 GQA 无法做到的。

三代注意力机制对比

MHA:    Q₁ → K₁,V₁    Q₂ → K₂,V₂    Q₃ → K₃,V₃    Q₄ → K₄,V₄
        每个 Q 对应独立 KV                 → KV Cache 最大

GQA:    Q₁ ─┐          Q₃ ─┐
        Q₂ ─┤→ K₁,V₁   Q₄ ─┤→ K₂,V₂
        多个 Q 共享一组 KV                  → KV Cache 缩小 4~8x

MLA:    Q₁ ─┐
        Q₂ ─┤→ [压缩向量 c] → 实时解压 → K,V
        Q₃ ─┤   (512 维)
        Q₄ ─┘
        所有 KV 压缩为低维潜在表示           → KV Cache 缩小 ~70x

归一化的演进:LayerNorm → RMSNorm + Pre-Norm

从 Post-Norm 到 Pre-Norm

最初的 Transformer 使用 Post-Normalization——先计算注意力/FFN,再做归一化。GPT-2(2019)发现把归一化放在注意力/FFN之前(Pre-Normalization)能显著改善深层网络的训练稳定性:

# Post-Norm (原始 Transformer,已淘汰)
x = x + Attention(x)
x = LayerNorm(x)        # 归一化在后面

# Pre-Norm (现代标准)
x = x + Attention(RMSNorm(x))  # 归一化在前面
# 梯度可以通过残差连接直接回传,不会被归一化层"阻断"

从 LayerNorm 到 RMSNorm

标准 LayerNorm 需要计算均值和方差:

# LayerNorm: 减均值,除标准差
def layer_norm(x, gamma, beta):
    mean = x.mean(dim=-1, keepdim=True)
    var = x.var(dim=-1, keepdim=True)
    return gamma * (x - mean) / sqrt(var + eps) + beta

# RMSNorm: 只除 RMS(均方根),去掉均值中心化
def rms_norm(x, gamma):
    rms = sqrt(mean(x ** 2) + eps)
    return gamma * x / rms
    # 没有 mean 计算,没有 beta 偏置 → 更快!

RMSNorm 的优势:

  • 更快:省去了均值计算和偏置参数
  • 效果相当:大量实验证明在 LLM 训练中与 LayerNorm 表现持平
  • 硬件友好:更简单的计算 → 更好的 GPU 核优化

为什么去掉均值中心化还能 work? 这是一个合理的疑问。LayerNorm 做两件事:①减均值(中心化)②除标准差(缩放)。RMSNorm 只做②。

原始 LayerNorm 论文认为均值中心化是必要的,但后来的研究发现:在 Transformer 的 Pre-Norm 设置下,残差连接已经在隐式地做某种"中心化"——每一层的输出都会加回到残差流上,使得激活值的均值自然趋于稳定。因此,显式的均值中心化是冗余的。

更直觉地说:归一化的核心目的是控制激活值的尺度(防止梯度爆炸/消失),而不是强制均值为零。RMS 归一化完全能达到这个目的,而且计算更简单。

📊 行业共识:在 53 个被分析的 Transformer 模型中,77.4% 采用了 RMSNorm。2023 年后发布的主流模型几乎 100% 使用 Pre-Norm + RMSNorm。

位置编码的演进:绝对编码 → 相对编码 → RoPE

Transformer 架构本身对 Token 的顺序是"无感"的——注意力机制只看 Q 和 K 的点积,不知道"苹果"在"吃"的前面还是后面。位置编码就是告诉模型"Token 在序列中的位置"。

三代位置编码的演进

位置编码三代演进

第一代:Sinusoidal 编码(原始 Transformer)

为位置 ,维度 ,原始论文定义:

def sinusoidal_encoding(max_len, d_model):
    """原始 Transformer 的正弦位置编码"""
    pe = torch.zeros(max_len, d_model)
    position = torch.arange(0, max_len).unsqueeze(1).float()
    
    # 频率:从低频(长波长)到高频(短波长)
    div_term = 10000 ** (torch.arange(0, d_model, 2).float() / d_model)
    
    pe[:, 0::2] = torch.sin(position / div_term)   # 偶数维:sin
    pe[:, 1::2] = torch.cos(position / div_term)   # 奇数维:cos
    
    return pe  # [max_len, d_model]

# 直觉:
# 低维(第0,1维):波长很长,变化慢 → 区分"大致位置"(前、中、后)
# 高维(最后几维):波长很短,变化快 → 区分相邻位置

为什么用正弦/余弦? 任意相对偏移 对应的位置向量 可以用 的线性变换表示——这就是"位置差可以被感知"的几何基础。

第二代:ALiBi(2021,推理时外推的先驱)

ALiBi 不给 Token 加位置向量,而是直接在注意力分数上减去一个随距离增大的惩罚项

# ALiBi 的核心:在注意力分数上加"距离惩罚"
def alibi_bias(seq_len, n_heads):
    """为每个注意力头生成 ALiBi 偏置矩阵"""
    # 不同头的惩罚斜率 m 不同(几何级数)
    slopes = 2 ** (-8 * torch.arange(1, n_heads+1) / n_heads)
    
    # 构建距离矩阵:[i-j] 对每对 Token
    positions = torch.arange(seq_len)
    distance = positions.unsqueeze(0) - positions.unsqueeze(1)  # [seq, seq]
    
    # 每个头用自己的斜率缩放距离
    bias = slopes.unsqueeze(-1).unsqueeze(-1) * distance.abs()
    return -bias  # 加到 attention scores 上(负数,越远惩罚越大)

ALiBi 的优点是外推能力:它在短序列上训练,推理时能泛化到更长序列。但相比 RoPE,它缺乏对精确相对位置的编码,在超长上下文上仍有精度损失。

第三代:RoPE 旋转位置编码——完整推导

RoPE 旋转位置编码:核心思想与三代对比

RoPE(Rotary Position Embeddings) 由 Su 等人提出(2021),2023 年后成为事实标准。

核心思想:用旋转编码相对位置

如果能设计一个函数 ,使得:

两个向量的内积(注意力分数)只依赖于它们的相对位置差 ,那么模型就自然具备了相对位置感知能力。

二维情形的推导(直觉基础)

考虑二维向量 ,给位置 加一个旋转角

计算两个旋转后向量的内积:

最终只含 完美满足相对位置条件

推广到高维(实际实现)

对于 维向量( 为偶数),将维度两两分组,每组做一次二维旋转,旋转角度按不同频率设置:

完整的 RoPE 变换(对 的向量,分 4 组):

import torch

def precompute_rope_freqs(d_k: int, max_seq_len: int, base: float = 10000.0):
    """预计算 RoPE 的 cos/sin 频率矩阵"""
    # 每组的旋转频率(从低频到高频)
    # θ_i = 1 / 10000^(2i/d_k),i = 0, 1, ..., d_k/2-1
    theta = 1.0 / (base ** (torch.arange(0, d_k, 2).float() / d_k))
    # shape: [d_k/2],例如 d_k=128 → 64 个频率
    
    # 每个位置对应的角度 = 位置 × 频率
    positions = torch.arange(max_seq_len).float()       # [max_seq_len]
    freqs = torch.outer(positions, theta)               # [max_seq_len, d_k/2]
    # freqs[m, i] = m * θ_i
    
    # 预计算 cos 和 sin
    cos = torch.cos(freqs)   # [max_seq_len, d_k/2]
    sin = torch.sin(freqs)   # [max_seq_len, d_k/2]
    return cos, sin


def apply_rope(x: torch.Tensor, cos: torch.Tensor, sin: torch.Tensor):
    """
    将 RoPE 应用到 Q 或 K
    x:   [batch, n_heads, seq_len, d_k]
    cos: [seq_len, d_k/2]
    sin: [seq_len, d_k/2]
    """
    # 将 d_k 维度拆成两半:[x1, x2] = [前半, 后半]
    x1 = x[..., :x.shape[-1] // 2]   # 偶数维
    x2 = x[..., x.shape[-1] // 2:]   # 奇数维
    
    # 广播 cos/sin 到 batch 和 heads 维度
    cos = cos[:x.shape[2], :].unsqueeze(0).unsqueeze(0)  # [1, 1, seq, d_k/2]
    sin = sin[:x.shape[2], :].unsqueeze(0).unsqueeze(0)
    
    # 旋转变换:(x1, x2) → (x1·cos - x2·sin, x1·sin + x2·cos)
    rotated = torch.cat([
        x1 * cos - x2 * sin,
        x1 * sin + x2 * cos,
    ], dim=-1)
    return rotated


# 在注意力计算中使用 RoPE
class RoPEAttention:
    def __init__(self, d_model=4096, n_heads=32):
        self.n_heads = n_heads
        self.d_k = d_model // n_heads
        self.W_Q = nn.Linear(d_model, d_model, bias=False)
        self.W_K = nn.Linear(d_model, d_model, bias=False)
        self.W_V = nn.Linear(d_model, d_model, bias=False)
        
        # 预计算频率(只算一次)
        self.cos, self.sin = precompute_rope_freqs(
            self.d_k, max_seq_len=131072  # 128K 上下文
        )
    
    def forward(self, x):
        B, S, D = x.shape
        Q = self.W_Q(x).view(B, S, self.n_heads, self.d_k).transpose(1, 2)
        K = self.W_K(x).view(B, S, self.n_heads, self.d_k).transpose(1, 2)
        V = self.W_V(x).view(B, S, self.n_heads, self.d_k).transpose(1, 2)
        
        # ← 关键:只对 Q 和 K 应用 RoPE(不对 V 应用)
        Q = apply_rope(Q, self.cos, self.sin)
        K = apply_rope(K, self.cos, self.sin)
        
        # 之后正常计算注意力
        return scaled_dot_product_attention(Q, K, V)

为什么 RoPE 能外推?

频率与波长的关系:
  低频维度(i=0):θ ≈ 1/10000,波长 ≈ 62831 → 区分很远的位置
  中频维度(i=32):θ ≈ 0.01,波长 ≈ 628 → 区分中等距离
  高频维度(i=63):θ ≈ 1.0,波长 ≈ 6 → 区分相邻位置

训练长度 8192 时,高频维度旋转了 ~1365 圈,低频维度旋转了 ~0.13 圈
外推到 131072 时,低频维度旋转了 ~2 圈 → 模型见过类似的模式

高频维度处理相邻关系(已充分训练),低频维度编码大局位置(圈数少,需要外推辅助)。

上下文扩展:YaRN 与 NTK-aware 缩放

RoPE 的一个关键实践问题是如何将模型推理到训练时没见过的更长序列

# 模型训练时 max_seq_len = 8192
# 但你想在 128K 甚至 1M 上下文下使用它

# 方法 1: NTK-aware 缩放(调整频率基数)
def ntk_scaled_rope(dim, max_position, base=10000, scaling_factor=16):
    """NTK-aware 缩放:提高 base,让高频分量保持精度"""
    new_base = base * (scaling_factor ** (dim / (dim - 2)))
    freqs = 1.0 / (new_base ** (torch.arange(0, dim, 2) / dim))
    return freqs

# 方法 2: YaRN(Yet another RoPE extensioN)
# 结合了 NTK 缩放 + 注意力分数温度修正
# Llama 4 Scout 用 YaRN 实现了 10M token 上下文!

三代位置编码对比总结

方法来源位置信息外推能力训练参数代表模型
Sinusoidal2017 原版绝对位置有限❌ 无原始 Transformer
Learned AbsBERT/GPT绝对位置❌ 无✅ 有BERT, GPT-2
ALiBiPaLM, MPT相对距离惩罚✅ 较好❌ 无MPT-7B
RoPELLaMA+相对位置(旋转)✅ 很好❌ 无Llama/Qwen/DeepSeek 全系

📊 行业共识:在被分析的 53 个模型中,69.8% 采用了 RoPE。2022 年后的 Decoder-Only LLM 中,RoPE 是绝对的主流选择。

激活函数与 FFN:SwiGLU 的统治

Transformer 中每一层除了注意力之外,还有一个前馈网络(FFN/MLP)。它的激活函数经历了显著演进:

# 经典 FFN:两层线性变换 + ReLU
class ClassicFFN:
    def forward(self, x):
        return self.w2(F.relu(self.w1(x)))
        # 参数量: 2 × d_model × d_ff (通常 d_ff = 4 × d_model)

# 现代 FFN:SwiGLU (Swish-Gated Linear Unit)
class SwiGLU_FFN:
    def forward(self, x):
        gate = F.silu(self.w_gate(x))    # Swish 激活 = x * sigmoid(x)
        up = self.w_up(x)                 # 上投影
        return self.w_down(gate * up)      # 门控 × 上投影 → 下投影
        # 参数量: 3 × d_model × d_ff (多了一个 gate 投影)
        # 但通常 d_ff 从 4d 缩减到 ~2.67d 来保持总参数量

SwiGLU 门控机制:从 ReLU 到智能门控

SwiGLU 的核心是门控机制——它让网络自己决定"哪些信息通过、哪些被抑制",比简单的 ReLU 有更强的表达能力。

ReLU:     max(0, x)              → 简单截断,负数全部归零
GeLU:     x · Φ(x)              → 概率性门控,平滑版 ReLU
SwiGLU:   Swish(Wx) ⊙ (Vx)     → 学习到的门控 × 内容
          ↑ 门控信号(0~1)      ↑ 内容信号

SwiGLU 的直觉:把 FFN 的输入 投影两次,得到两路信号:

  • 门控路(gate):经过 Swish 激活,输出 0~1 之间的"开关信号"
  • 内容路(up):线性变换,携带实际的信息内容

两路相乘(逐元素):门控信号决定内容信号的哪些维度"放行"、哪些"压制"。这比 ReLU 的"硬截断"更灵活——门控信号是输入相关的、可学习的,而不是固定的阈值。

类比:
ReLU FFN  = 固定的过滤网(负数全过滤)
SwiGLU FFN = 智能阀门(根据输入内容动态调节每个维度的通过量)

为什么需要 3 个投影矩阵(w_gate、w_up、w_down)而不是 2 个? 经典 FFN 是 w2(relu(w1(x))),只有 2 个矩阵。SwiGLU 多了一个 gate 投影,但为了保持总参数量不变,通常把隐藏维度从 缩减到

经典 FFN:  2 × d × 4d = 8d²  参数
SwiGLU:   3 × d × (8/3)d = 8d²  参数  ← 参数量相同,但表达能力更强

📊 行业共识71.7% 的被分析模型使用 SwiGLU 或 GeGLU。LLaMA 之后,这几乎成了不成文的标准。

MoE 架构:稀疏的力量

我们在 3.6 节从"趋势"角度介绍了 MoE。这里深入看看它的架构细节

MoE 的基本结构

MoE 将标准 FFN 层替换为多个"专家"网络 + 一个"路由器":

class MoELayer:
    """混合专家层:替代标准 FFN"""
    def __init__(self, d_model, n_experts=64, n_active=8):
        # 64 个专家,但每个 token 只激活 8 个
        self.experts = [SwiGLU_FFN(d_model) for _ in range(n_experts)]
        self.router = Linear(d_model, n_experts)  # 路由器:决定激活哪些专家
    
    def forward(self, x):
        # 1. 路由决策:每个 token 独立选择专家
        router_logits = self.router(x)              # [batch, seq, n_experts]
        weights, indices = router_logits.topk(k=8)  # 选 top-8 专家
        weights = F.softmax(weights, dim=-1)         # 归一化权重
        
        # 2. 专家计算:只激活被选中的专家
        output = 0
        for i, (expert_idx, w) in enumerate(zip(indices, weights)):
            output += w * self.experts[expert_idx](x)
        
        return output

MoE 的核心挑战:路由坍塌(Router Collapse)

MoE 路由机制:理想路由 vs 路由坍塌

听起来很美好,但 MoE 训练有一个严重的问题——路由器很容易"偷懒",把所有 Token 都发给同一个或少数几个专家:

理想的路由(负载均衡):
  Token₁ → 专家 3, 7, 12, ...
  Token₂ → 专家 1, 5, 9, ...
  Token₃ → 专家 2, 6, 11, ...
  每个专家都被均匀使用 → 所有专家都能充分训练

路由坍塌(实际训练中容易出现):
  Token₁ → 专家 1, 2, 3, ...
  Token₂ → 专家 1, 2, 3, ...
  Token₃ → 专家 1, 2, 3, ...
  专家 1~8 被过度使用,专家 9~64 几乎从不被选中 → 大量参数浪费!

为什么会坍塌? 路由器是一个线性层,它的输出(logits)决定哪些专家被选中。训练初期,某些专家偶然被选中更多次,得到更多梯度更新,变得更强;更强的专家被选中的概率更高,形成正反馈循环——"强者愈强",最终少数专家垄断所有 Token。

这就是为什么负载均衡机制是 MoE 设计的核心难题,各家模型的解决方案也各不相同(见下表)。

不同模型的 MoE 配置差异很大

模型总专家数激活专家数共享专家路由方式负载均衡
Mixtral 8×22B82Top-2 softmax辅助损失
DeepSeek-V325681 个共享Top-8 sigmoid无辅助损失(偏差项)
DeepSeek V425681 个共享Top-8 sigmoid无辅助损失 + mHC 超连接
Kimi K2128+~8Top-KMuonClip 优化器稳定训练
Llama 4 Scout161Top-1辅助损失
Llama 4 Maverick1281Token-choice辅助损失
Qwen 3 (235B)1288Top-8辅助损失
Qwen3.5-Plus1288Top-8优化辅助损失
MiniMax M2.5Lightning Attention 混合

DeepSeek 的两个关键创新

1. 共享专家(Shared Expert)

DeepSeek 指定一部分专家为"始终激活",提供稳定的通用知识基底:

class DeepSeekMoE:
    """DeepSeek 的 MoE:共享专家 + 路由专家"""
    def __init__(self):
        self.shared_expert = SwiGLU_FFN()     # 始终参与计算
        self.routed_experts = [SwiGLU_FFN() for _ in range(256)]
        self.router = Linear(d_model, 256)
    
    def forward(self, x):
        # 共享专家:所有 token 都经过
        shared_out = self.shared_expert(x)
        
        # 路由专家:每个 token 选 top-8
        indices, weights = self.route(x)
        routed_out = weighted_sum(self.routed_experts, indices, weights)
        
        return shared_out + routed_out

2. 无辅助损失的负载均衡

传统 MoE 的一个难题是"路由坍塌"——所有 token 都涌向少数几个专家。通常的解决方案是添加辅助损失函数来惩罚不均衡,但这会干扰主要训练目标。

DeepSeek-V3 引入了一个简洁的替代方案——给每个专家添加一个可学习的偏差项

# 传统方式:辅助损失(会干扰主训练目标)
loss = main_loss + alpha * load_balance_loss

# DeepSeek 方式:偏差项(不干扰主训练目标)
router_logits = self.router(x) + self.expert_bias
# expert_bias 不参与梯度更新,而是通过规则调整:
# 如果某专家负载过高 → 降低其 bias
# 如果某专家负载过低 → 提高其 bias

开源模型架构全景对比

现在我们把所有技术模块放在一起,看看 2024—2026 年主流开源模型的完整架构选择:

架构组件Llama 3 (2024)Llama 4 (2025)DeepSeek-V3DeepSeek V4Qwen 3Qwen3.5Kimi K2Kimi K2.5
基本架构DenseMoEMoEMoEDense/MoEMoEMoEMoE
注意力GQAGQAMLAMLA + DSA 2.0GQAGated DeltaNet 混合GQAKimi Linear 混合
归一化RMSNormRMSNormRMSNormRMSNormRMSNormRMSNormRMSNormRMSNorm
残差连接标准加性标准加性标准加性mHC 超连接标准加性标准加性标准加性Attention Residuals
位置编码RoPERoPE+YaRNRoPERoPERoPE+YaRNRoPE+YaRNRoPERoPE
激活函数SwiGLUSwiGLUSwiGLUSwiGLUSwiGLUSwiGLUSwiGLUSwiGLU
优化器AdamWAdamWAdamWAdamWAdamWAdamWMuonClipMuonClip
MoE 专家数16/128256+1256+1128128128+
总参/激活8B~405B109B~400B671B/~37B671B/~37B0.6B~235B397B/17B1T/32B48B/3B
上下文128K10M128K1M+32K~128K262K128K256K

一个关键观察:架构正在"分化"

如果说 2024—2025 年的主题是架构收敛(共识堆栈),那 2026 年的主题是架构分化——在共识堆栈的基础上,各大模型开始探索截然不同的创新路径:

LLM 架构共识堆栈与分化前沿

差异化的竞争正从"训练数据和规模"向架构创新回归:

  1. 混合注意力设计(线性注意力 + 全注意力的混合比例和方式)
  2. 信息流优化(残差连接、超连接等层间信息传递机制)
  3. 训练效率(优化器创新、多 Token 预测等)
  4. 推理效率(知识卸载、稀疏注意力、KV-Cache 优化)
  5. MoE 的具体设计(专家数量、路由策略、负载均衡)
  6. 长上下文扩展技术(YaRN、NTK 缩放、线性注意力)

FlashAttention:让长上下文成为可能的硬件魔法

以上都是"模型架构"层面的创新。但有一个计算层面的技术突破对 LLM 的实际能力影响巨大——FlashAttention

FlashAttention:IO-Aware 分块计算原理

标准注意力的问题在于需要实例化整个注意力矩阵(N×N),当 N 到百万级时,显存直接爆炸:

# 标准注意力:O(N²) 内存
def standard_attention(Q, K, V):
    scores = Q @ K.T / sqrt(d)  # [N, N] ← N=1M 时需要 4TB 内存(fp32)!
    weights = softmax(scores)    # 这个矩阵必须完整存在显存里
    return weights @ V

FlashAttention 的核心洞察:分块计算 + 在线 softmax

FlashAttention 的关键问题是:softmax 需要看到所有分数才能归一化(分母是所有 的和),怎么能分块计算?

答案是在线 softmax(Online Softmax)——一种可以增量更新的 softmax 算法:

普通 softmax 需要两遍扫描:
  第1遍:找最大值 max_s(数值稳定性)
  第2遍:计算 exp(s_i - max_s) / sum(exp(s_j - max_s))
  → 必须把所有分数都存在内存里

在线 softmax 只需一遍扫描:
  维护两个状态:当前最大值 m,当前指数和 d
  每来一个新分数 s_new:
    if s_new > m:
        d = d * exp(m - s_new) + exp(0)  # 更新指数和(修正旧的)
        m = s_new                          # 更新最大值
    else:
        d = d + exp(s_new - m)             # 直接累加
  → 不需要存储所有分数,O(1) 内存!

有了在线 softmax,FlashAttention 就可以把 Q、K、V 分成小块,逐块处理,每次只把一小块加载到 GPU 的 SRAM(快速缓存)中:

# FlashAttention:分块计算,O(N) 内存
def flash_attention(Q, K, V, block_size=256):
    """
    核心思想:不实例化完整的 N×N 矩阵
    而是分块计算 + 在线 softmax 更新
    
    GPU 内存层次:
    HBM(高带宽内存,慢但大): 存放完整的 Q/K/V
    SRAM(片上缓存,快但小):  每次只加载一个小块
    """
    N = Q.shape[0]
    output = zeros_like(Q)          # 最终输出
    m = full(N, -inf)               # 在线 softmax:当前最大值
    d = zeros(N)                    # 在线 softmax:当前指数和
    
    for i in range(0, N, block_size):           # 遍历 Q 的块
        q_block = Q[i:i+block_size]             # 从 HBM 加载到 SRAM
        
        for j in range(0, N, block_size):       # 遍历 K/V 的块
            k_block = K[j:j+block_size]         # 从 HBM 加载到 SRAM
            v_block = V[j:j+block_size]
            
            # 只计算这个小块的注意力分数(在 SRAM 中完成,不写回 HBM)
            block_score = q_block @ k_block.T / sqrt(d)
            
            # 在线 softmax 更新(增量更新 m 和 d,不需要完整矩阵)
            m_new = maximum(m[i:i+block_size], block_score.max(dim=-1))
            d[i:i+block_size] = d[i:i+block_size] * exp(m[i:i+block_size] - m_new) \
                                 + exp(block_score - m_new).sum(dim=-1)
            
            # 增量更新输出(修正旧的输出 + 加入新块的贡献)
            output[i:i+block_size] = output[i:i+block_size] * exp(m[i:i+block_size] - m_new).unsqueeze(-1) \
                                     + exp(block_score - m_new.unsqueeze(-1)) @ v_block
            m[i:i+block_size] = m_new
        
        # 最终归一化
        output[i:i+block_size] /= d[i:i+block_size].unsqueeze(-1)
    
    return output
    # 内存从 O(N²) 降到 O(N)  ← 不需要存 N×N 的注意力矩阵
    # 速度提升 2~4 倍  ← 减少了 HBM 读写次数(IO-aware)

为什么更快? 不只是因为内存少了,更重要的是减少了 HBM 读写次数。GPU 的计算速度远快于内存带宽,标准注意力需要把 N×N 矩阵写入 HBM 再读回来,这个 IO 操作是瓶颈。FlashAttention 把整个注意力计算都在 SRAM 中完成,大幅减少了 HBM 访问。

FlashAttention 的三代演进:

版本年份关键改进
FlashAttention-12022IO-aware 分块计算,O(N²) → O(N) 内存
FlashAttention-22023更好的并行化,速度再提升 2x
FlashAttention-32024利用 Tensor Core 异步执行,接近硬件理论峰值

💡 对 Agent 的影响:FlashAttention 是支撑百万级上下文窗口的底层功臣。没有它,Gemini 2.5 Pro 的 2M 上下文和 Llama 4 Scout 的 10M 上下文都不可能实现。作为 Agent 开发者,你不需要自己实现它(各大推理框架已经内置),但了解它有助于理解模型的能力边界。

2026 年架构新突破

2025 年底到 2026 年初,基座模型架构迎来了一波重要创新——打破了此前"架构已结晶"的判断,多个组件被重新设计。以下是最值得关注的四个方向。

混合注意力:线性 + 全注意力

2026 年最重要的架构趋势是混合注意力——用线性复杂度的注意力变体替代大部分全注意力层,仅保留少量全注意力层处理需要全局信息的场景。

# 混合注意力的核心思想
class HybridAttentionBlock:
    """
    2026 年主流设计:每 4 层中 3 层用线性注意力,1 层用全注意力
    
    Qwen3.5:  Gated DeltaNet : Gated Attention = 3:1
    Kimi K2.5: KDA (Kimi Delta Attention) : Full Attention = 3:1
    MiniMax M2.5: Lightning Attention : Full Attention = 混合
    """
    def __init__(self, layer_idx, d_model):
        if layer_idx % 4 == 3:  # 每 4 层一个全注意力层
            self.attn = FullAttention(d_model)      # O(N²) 但保留全局建模能力
        else:
            self.attn = GatedDeltaNet(d_model)       # O(N) 线性复杂度
    
    def forward(self, x):
        return self.attn(x)

Gated DeltaNet(Qwen3.5 采用):结合了 Delta Rule(增量学习规则)和门控机制,既有线性注意力的 O(N) 复杂度,又通过门控保留了对重要信息的选择性记忆:

class GatedDeltaNet:
    """
    Gated DeltaNet:Qwen3.5 的线性注意力变体
    核心思想:用"增量更新"替代"全局注意力矩阵"
    
    对比:
    - 全注意力:每个 token 都和所有 token 计算注意力 → O(N²)
    - Gated DeltaNet:维护一个压缩状态,增量更新 → O(N)
    """
    def forward(self, x):
        # 1. 计算查询、键、值
        q, k, v = self.qkv_proj(x).split(3)
        
        # 2. 门控:决定"记住多少旧信息,接收多少新信息"
        gate = torch.sigmoid(self.gate_proj(x))  # 门控信号
        
        # 3. Delta Rule 增量更新状态矩阵
        # S_{t} = gate * S_{t-1} + (1 - gate) * k_t ⊗ v_t
        state = gate * prev_state + (1 - gate) * torch.outer(k, v)
        
        # 4. 用查询向量从状态中提取信息
        output = q @ state
        return output
    
    # 关键优势:
    # - 推理时不需要 KV-Cache(状态矩阵固定大小)
    # - 128K~1M 上下文下,解码速度提升 5~6 倍
    # - 通过门控保留了对重要信息的选择性注意

Kimi Linear(Kimi K2.5 采用):Moonshot AI 提出的 KDA(Kimi Delta Attention),以 3:1 比例混合线性注意力和全局注意力,在 128K~1M 范围内实现 5~6 倍解码加速。

效果对比

注意力类型复杂度128K 解码速度1M 解码速度质量损失
全注意力(标准 Transformer)O(N²)基准基准
GQAO(N²)(KV 更小)~1.2x~1.2x几乎无
Gated DeltaNet 混合 3:1O(N)(大部分层)~4x~5x极低
Kimi Linear 混合 3:1O(N)(大部分层)~5x~6x极低

💡 对 Agent 的影响:混合注意力让长上下文 Agent 变得经济可行。之前在 1M 上下文下运行 Agent 的推理成本极高,现在推理延迟降低 5~6 倍意味着成本也大幅降低。这对需要处理整个代码仓库、长文档的 Agent 场景至关重要。

Attention Residuals:重写残差连接

Kimi K2.5 在 GTC 2026 上提出了一个大胆的架构修改——Attention Residuals(AttnRes),重写了自 ResNet 以来沿用 10 年的标准残差连接。

# 标准残差连接(2015 年至今的默认设计)
class StandardResidual:
    """
    x_{l+1} = x_l + F_l(x_l)
    所有前序层的输出以固定权重 1 累加 → 深层网络中信号会"稀释"
    """
    def forward(self, x, layer_output):
        return x + layer_output  # 简单加法,权重固定为 1

# Attention Residuals(Kimi K2.5 提出)
class AttentionResiduals:
    """
    用 Softmax 注意力替代固定权重的残差累加
    每一层可以"主动选择"从哪些前序层获取信息
    
    效果:等效于 1.25 倍计算量的标准训练,但几乎零额外开销
    """
    def forward(self, x, all_previous_outputs):
        # 计算当前层对所有前序层输出的注意力权重
        # (而不是固定权重 1 的累加)
        scores = self.query(x) @ self.key(all_previous_outputs).T
        weights = F.softmax(scores, dim=-1)
        
        # 有选择地组合前序层的表示
        aggregated = weights @ all_previous_outputs
        return aggregated

# Block AttnRes(实用变体,减少内存开销)
class BlockAttentionResiduals:
    """
    将层划分为块,在块级别进行注意力聚合
    结合缓存流水线通信,几乎零额外开销
    """
    pass

为什么重要? 标准残差连接的"加法累积"会导致深层网络的隐藏状态不可控增长,稀释每一层的贡献。AttnRes 让每一层用学习到的、依赖于输入的权重有选择地组合前序信息,训练更稳定,下游任务表现更好。

MuonClip:优化器革新

Kimi K2 引入的 MuonClip 优化器是 2025—2026 年训练层面最重要的创新。它挑战了 AdamW 长达 11 年的统治地位:

# AdamW(2014 年至今的行业标准)
# 基于一阶梯度 + 动量 + 自适应学习率

# MuonClip(Kimi K2 提出)
# 基于 Muon 动量 + Newton-Schulz 迭代 + QK-Clip 稳定机制
class MuonClipOptimizer:
    """
    核心创新:
    1. 将 Muon 优化器扩展到万亿参数规模
    2. Newton-Schulz 迭代 + QK-Clip 解决 logits 爆炸
    3. 分布式 Muon 适配大规模 GPU 集群
    
    效果:token 训练效率比 AdamW 提升 2 倍
    含义:同等算力预算下,模型能力翻倍
    """
    def __init__(self, params, lr, max_logit=100):
        self.max_logit = max_logit  # QK-Clip:限制最大 logits
    
    def step(self):
        # 1. Muon 动量更新
        momentum = self.compute_muon_momentum()
        
        # 2. Newton-Schulz 迭代(解决大规模训练不稳定性)
        update = self.newton_schulz_iterate(momentum)
        
        # 3. QK-Clip:将 logits 严格限制在 100 以内
        # 防止万亿参数训练中的 logits 爆炸
        update = self.clip_qk(update, self.max_logit)
        
        # 4. 应用更新
        self.apply_update(update)

影响:MuonClip 的成功意味着 AdamW 不再是唯一选择。如果这种训练效率提升能泛化到其他架构,可能从根本上改变整个行业的训练经济学——用一半的计算量达到相同的模型能力。

Engram 内存架构(DeepSeek V4)

DeepSeek V4 提出了一个全新概念——Engram 内存,将知识存储与推理计算解耦:

class EngramMemory:
    """
    DeepSeek V4 的 Engram 内存架构
    核心思想:静态知识不应该占用昂贵的 GPU 显存
    
    传统方式:所有知识编码在模型参数中 → 全部加载到 GPU
    Engram 方式:静态知识存储在 CPU 内存 → GPU 专注推理计算
    """
    def __init__(self, vocab_size, embedding_dim):
        # N-gram 嵌入存储在 CPU 内存中
        self.ngram_embeddings = CPUStorage(vocab_size, embedding_dim)
        # O(1) 哈希查找,不占用 GPU 显存
        self.hash_table = HashIndex()
    
    def lookup(self, input_tokens):
        """从 CPU 内存中 O(1) 查找知识嵌入"""
        hashed = self.hash_table(input_tokens)
        knowledge = self.ngram_embeddings[hashed]  # CPU → GPU 传输
        return knowledge
    
    def forward(self, x, input_tokens):
        # 1. 从 Engram 获取静态知识
        knowledge = self.lookup(input_tokens)
        
        # 2. GPU 上进行推理计算
        reasoning_output = self.transformer_layers(x + knowledge)
        
        return reasoning_output
    
    # 效果:
    # - 释放 GPU 显存用于推理 → 更长上下文、更大批次
    # - 知识基准测试显著提升
    # - 推理和知识存储可独立扩展

**mHC(Manifold-Constrained Hyper-Connections)**是 DeepSeek V4 的另一个创新——用 Sinkhorn-Knopp 算法约束残差混合矩阵,在仅增加 6.7% 训练开销的情况下,维持数百层网络的信号稳定性。

💡 对 Agent 的影响:Engram 内存的"知识-推理分离"范式特别适合 Agent 场景——Agent 需要大量的领域知识(存在 CPU 内存中),同时需要强大的推理能力(GPU 专注计算)。这让在受限硬件上运行知识密集型 Agent 成为可能。


本节小结

架构组件演进方向现代共识前沿突破(2026)
整体架构Encoder-Decoder → Decoder-OnlyDecoder-OnlyMoE 成为大模型标配
注意力机制MHA → GQA → MLAGQA / MLA混合注意力:Gated DeltaNet / Kimi Linear(延迟降 5~6x)
归一化Post-Norm → Pre-Norm + RMSNormPre-Norm + RMSNorm收敛完成,几乎无争议
残差连接固定加性残差标准残差Attention Residuals(Kimi K2.5)/ mHC(DeepSeek V4)
位置编码绝对编码 → RoPERoPEYaRN/NTK 扩展到 10M+
激活函数ReLU → GeLU → SwiGLUSwiGLU门控机制成为标准
MoE密集 → 稀疏混合专家Top-K 路由 + 共享专家万亿参数级开源 MoE(Kimi K2)
优化器SGD → Adam → AdamWAdamWMuonClip(训练效率翻倍)
知识存储全部编码在参数中参数化存储Engram 内存(知识-推理分离)
KV Cache全量存储GQA 减少 8xTurboQuant(2026.04,6x 内存压缩,无精度损失)🆕
推理加速标准注意力 → FlashAttentionFA-2/3分块 + IO 优化接近硬件极限

📖 理解这些架构组件不是为了让你去训练模型——而是为了在模型选型、推理优化、成本估算时有底层判断力。当有人说"这个模型用了 Gated DeltaNet 混合注意力"时,你就知道它在长文本场景下的推理延迟会非常低;当有人说"用了 Engram 内存"时,你就知道它可以在更小的 GPU 上处理知识密集型任务;当有人说"用了 TurboQuant"时,你就知道部署成本会大幅下降。2026 年,架构创新重新回到了竞争的前沿。


下一节:3.8 SFT 与强化学习训练数据准备