混合专家模型架构

混合专家模型用稀疏激活换取参数规模与算力的解耦。本文系统讲解 MoE 的门控路由与 Top-K 选择、辅助损失与容量因子等负载均衡机制、专家并行与数据并行的训练策略、All-to-All 通信开销与优化、推理时的专家调度与显存布局,以及从零实现 MoE 层与常见故障排查。

稠密模型的算力开销与参数量严格挂钩:想让模型更聪明,就得让每一次前向都更贵。混合专家(Mixture of Experts, MoE) 打破了这个绑定——把前馈层拆成几十上百个专家,每次只激活其中一两个。于是参数量可以涨到万亿级,而单 token 的实际计算量只相当于一个中等规模模型。这是一笔用显存与通信换算力的交易。

MoE 的本质不是「更聪明的模型」,而是「更划算的模型」。它没有提升单次计算的表达能力,而是把参数容量与推理成本解耦。理解这一点,就能明白为什么 MoE 的所有工程难点都集中在通信与负载均衡上。

从稠密到稀疏

稠密 FFN 的算力瓶颈

Transformer 的参数量大头在前馈网络(FFN)。以 d_model=4096、d_ff=14336 为例,单个 FFN 层就有约 1.2 亿参数。24 层堆下来,FFN 占了整个模型七成以上的参数,而每个 token 都要走完全部 FFN——参数利用率极低,大部分权重对当前 token 毫无贡献。

稀疏激活的核心思想

MoE 用一组专家替代单个 FFN,门控网络为每个 token 挑选少数专家:

y = Σ_{i ∈ TopK(g(x))} g(x)_i · E_i(x)

关键数字对比(以 Mixtral 8x7B 为例):

指标稠密 47BMixtral 8x7B(Top-2)
总参数量47B47B
激活参数量47B约 13B
单 token FLOPs100%约 28%
训练成本高低约 3 倍

总参数决定容量,激活参数决定成本。这就是 MoE 的全部价值主张。

专家的专业化是涌现的

一个常见的误解是「专家会自动分工成数学专家、语法专家」。实际观察表明,专家的分工往往按表层特征聚类——标点、语言、词性——而非高级语义。这并不妨碍效果,但它提醒我们:不要把 MoE 当成可解释的模块化系统来设计。

门控与路由机制

门控网络

门控本质上是一个小的线性层加 softmax:

import torch
import torch.nn as nn
import torch.nn.functional as F

class TopKGating(nn.Module):
    def __init__(self, d_model, n_experts, top_k=2):
        super().__init__()
        self.top_k = top_k
        self.n_experts = n_experts
        self.gate = nn.Linear(d_model, n_experts, bias=False)

    def forward(self, x):
        # x: (B*T, D)
        logits = self.gate(x)                      # (N, E)
        scores = F.softmax(logits, dim=-1)
        topk_val, topk_idx = scores.topk(self.top_k, dim=-1)
        # 归一化,让 Top-K 权重和为 1
        topk_val = topk_val / topk_val.sum(dim=-1, keepdim=True)
        return topk_idx, topk_val

路由粒度的选择

粒度说明代表
Token 级每个 token 独立路由Switch Transformer、Mixtral
专家级整个序列共享早期 MoE
组级专家分组,先选组再选专家DeepSeek-MoE

Token 级路由最灵活,但会导致同一序列内 token 被送到不同专家,通信模式不规则。组级路由(Grouped Routing) 是折中:先把专家分成若干组,token 先选组再在组内选专家,能显著降低 All-to-All 的碎片化。

共享专家:DeepSeek 的改进

DeepSeek-MoE 提出共享专家 + 路由专家的混合结构:

y = E_shared(x) + Σ_{i ∈ TopK} g_i · E_i(x)

共享专家对所有 token 都激活,负责通用知识;路由专家负责特化。这样做的好处是减少了路由专家的知识冗余——传统 MoE 里每个专家都得学会通用语言能力,浪费容量。实测在同等激活参数下效果更好。

Softmax 的位置有讲究

  • softmax 后再 Top-K:权重和为 1,但被选中的专家权重会被未选中专家的概率稀释。
  • Top-K 后再 softmax:保证选中权重和为 1,梯度更干净。

绝大多数实现选后者。这个细节看起来微不足道,但用错会让训练初期路由极不稳定。

负载均衡

MoE 最大的工程噩梦:路由塌陷。门控很容易学会「总是选同一个专家」,因为那个专家训练得更充分、输出更好,形成正反馈。最终只有一两个专家被训练,其余全废——MoE 退化成了稠密模型,还白白多占了显存。

辅助负载均衡损失

经典解法是在主损失上加一项辅助损失,惩罚专家使用率的不均衡:

def load_balancing_loss(gate_logits, topk_idx, n_experts):
    """Switch Transformer 风格的辅助损失"""
    # f_i: 分配给专家 i 的 token 比例
    one_hot = F.one_hot(topk_idx, n_experts).float()      # (N, K, E)
    f = one_hot.sum(dim=(0, 1)) / (topk_idx.shape[0] * topk_idx.shape[1])
    # P_i: 专家 i 的平均门控概率
    P = F.softmax(gate_logits, dim=-1).mean(dim=0)        # (E,)
    return n_experts * (f * P).sum()

损失乘上一个系数(通常 0.01)加进总损失。f 与 P 的乘积形式保证了两者都均衡才最小,只压一个不够。

容量因子

每个专家能处理的 token 数是有上限的,上限由**容量因子(Capacity Factor)**决定:

capacity = ceil(capacity_factor × tokens_per_batch / n_experts)
容量因子溢出率效果
1.0高大量 token 被丢弃
1.25中常用默认值
2.0低显存浪费,但几乎不丢 token

被丢弃的 token 直接跳过 MoE 层(残差直通)。这听起来很糟,但实测影响有限——因为丢的通常是「门控不确定」的 token。不过训练早期如果丢弃率超过 20%,就要提高容量因子或加强均衡损失。

无辅助损失的均衡:偏置调整

DeepSeek-V3 提出用动态偏置替代辅助损失:给每个专家维护一个偏置项 b_i,只用于路由决策,不参与权重计算:

def bias_update(bias, usage, gamma=0.001):
    """usage 过高则降低偏置,过低则提高,目标是均衡"""
    target = usage.mean()
    return bias + gamma * (target - usage)

这个方法的优势是不影响主损失——辅助损失本质上是在主任务上加了干扰项,会略微损害模型质量。偏置调整把均衡做成纯推理侧的决策,实测在同等均衡度下语言建模损失更低。

路由塌陷的早期信号

  • 专家使用率方差:某专家占比超过 1.5 / n_experts 就该警惕。
  • 门控熵:熵持续下降说明路由在收敛到少数专家。
  • 被丢弃 token 比例:持续高于 10%。
def routing_stats(topk_idx, n_experts):
    counts = torch.bincount(topk_idx.flatten(), minlength=n_experts).float()
    usage = counts / counts.sum()
    entropy = -(usage * (usage + 1e-9).log()).sum()
    return {
        "usage": usage.tolist(),
        "max_share": usage.max().item(),
        "entropy": entropy.item(),
        "ideal_share": 1.0 / n_experts,
    }

训练并行策略

MoE 的并行比稠密模型复杂一个维度:除了数据并行、张量并行、流水线并行,还要加专家并行(Expert Parallelism, EP)。

专家并行

把不同的专家放到不同的 GPU 上。一次 MoE 层的前向过程:

  1. 路由:门控为每个 token 选出专家。
  2. All-to-All 分发:把 token 按目标专家发送到对应 GPU。
  3. 专家计算:各 GPU 用本地专家处理收到的 token。
  4. All-to-All 回收:把结果送回原 GPU。
import torch.distributed as dist

def expert_parallel_forward(x, topk_idx, topk_val, experts_per_rank):
    rank = dist.get_rank()
    world = dist.get_world_size()
    # 1. 按目标 rank 分桶
    target_rank = topk_idx // experts_per_rank
    send_buckets = [x[target_rank == r] for r in range(world)]
    # 2. All-to-All 交换 token
    recv_buckets = [torch.empty_like(send_buckets[0]) for _ in range(world)]
    dist.all_to_all(recv_buckets, send_buckets)
    # 3. 本地专家计算
    local_out = run_local_experts(recv_buckets)
    # 4. 反向 All-to-All 回收
    back_buckets = [torch.empty_like(local_out[0]) for _ in range(world)]
    dist.all_to_all(back_buckets, local_out)
    return torch.cat(back_buckets, dim=0)

通信量与拓扑

All-to-All 的通信量正比于 token 数 × d_model × 2。当 EP 跨节点时,通信会走慢速网络,成为瓶颈。优化手段:

手段效果
组级路由减少碎片,提高单次传输效率
Token 去重/合并同一专家收到多个 token 时批量发送
通信与计算重叠用双缓冲隐藏延迟
高带宽互联(NVLink/RDMA)把 EP 限制在同节点内

经验法则:EP 尽量限制在单节点内(8 卡 NVLink),跨节点的部分用数据并行承担。这与 分布式训练 中张量并行的放置策略一致——都遵循「高通信量的并行维度放在快链路上」。

显存分布

MoE 的显存分布和稠密模型很不一样:

组件分布方式
注意力层每卡完整副本
专家 FFN按 EP 切分
门控每卡完整副本
优化器状态按 ZeRO 分片

这导致 MoE 的显存不均衡:EP 切分后每卡只存 n_experts / EP 个专家,但如果专家大小不一(比如 DeepSeek 的共享专家更大),需要精细的放置规划。

推理时的专家调度

显存是推理的主要约束

训练时显存可以靠 ZeRO 分摊,推理时权重必须常驻。8x7B 的 MoE 需要约 90GB 显存(BF16),单张 80GB 卡放不下,必须多卡。推理的专家调度核心问题是:每个 token 要经过跨卡通信才能找到它的专家。

批处理下的专家亲和性

推理时的优化重点是提高每个专家的批量大小。连续批处理(continuous batching)下,不同请求的 token 混在一个批次里,路由结果高度分散,导致每个专家只收到零星几个 token,GPU 利用率极低。

解法是按专家重排批次:先收集一个窗口内所有 token 的路由结果,按目标专家排序后再计算,让每个专家拿到连续的大批量:

def expert_sorted_batch(tokens, topk_idx):
    flat_experts = topk_idx.flatten()
    order = torch.argsort(flat_experts, stable=True)
    sorted_tokens = tokens.repeat_interleave(topk_idx.shape[1], dim=0)[order]
    sorted_experts = flat_experts[order]
    return sorted_tokens, sorted_experts, order

专家卸载与缓存

当显存实在不够时,可以把冷门专家放到 CPU 内存或 NVMe,按需换入。代价是换入延迟,通常几十毫秒。只在超大规模 MoE(数千专家)且访问极度倾斜时才划算。

量化与 MoE 的配合

MoE 的量化有个特殊之处:共享专家对量化更敏感,因为它服务所有 token,误差会被全局放大。实践中对共享专家用较高精度(INT8),路由专家可以压到 INT4。这与 模型压缩 中「关键路径保精度」的原则一致。

架构演进与代表模型

MoE 不是新概念,但近几年的几项改进让它真正可用。

关键演进节点

模型年份关键改进
Switch Transformer2021简化到 Top-1 路由,证明稀疏可行
GShard2020引入容量因子与专家并行
Mixtral 8x7B2023开源验证 8 专家 Top-2 的实用性
DeepSeek-MoE2024共享专家 + 细粒度专家切分
DeepSeek-V32024无辅助损失的偏置均衡、FP8 训练

细粒度专家

早期 MoE 用「少而大」的专家(8 个专家,每个和完整 FFN 一样大)。DeepSeek 转向「多而小」:把 FFN 切成 m 份,专家数量变成 m × n,激活的专家数也成比例增加。

这样做的好处是组合空间爆炸:n 选 k 的组合数是 C(n,k),专家越多越细,模型能表达的组合越多,而激活参数量保持不变。代价是路由与通信的碎片化更严重,需要组级路由来压制。

MoE 与稠密模型的公平对比

比较 MoE 和稠密模型时,必须对齐激活参数量而非总参数量:

def moe_vs_dense_cost(n_experts, top_k, d_model, d_ff, n_layers):
    dense_ffn = 2 * d_model * d_ff
    moe_total = n_experts * dense_ffn
    moe_active = top_k * dense_ffn + 2 * d_model * n_experts   # 含门控
    return {
        "dense_equivalent_params": n_layers * dense_ffn,
        "moe_total_params": n_layers * moe_total,
        "moe_active_params": n_layers * moe_active,
        "activation_ratio": moe_active / moe_total,
    }

正确的结论是:在相同激活算力下,MoE 通常优于同等大小的稠密模型,因为它的总容量更大;但 MoE 需要更多显存,且在 batch 很小、路由极度分散的场景下,通信开销可能吃掉全部收益。

什么时候不该用 MoE

  • 显存极度受限的边缘部署:MoE 的总参数必须全部驻留,稀疏激活省不了显存。
  • 单请求低并发服务:没有批处理来摊薄通信,All-to-All 开销占比过高。
  • 训练数据量小:专家需要足够数据才能分化,小数据下 MoE 容易塌陷。
  • 需要极致低延迟:通信引入的抖动难以消除。

从零实现一个 MoE 层

把上面的部件拼起来:

class MoELayer(nn.Module):
    def __init__(self, d_model, d_ff, n_experts=8, top_k=2, capacity_factor=1.25):
        super().__init__()
        self.n_experts = n_experts
        self.top_k = top_k
        self.capacity_factor = capacity_factor
        self.gating = TopKGating(d_model, n_experts, top_k)
        self.experts = nn.ModuleList([
            nn.Sequential(nn.Linear(d_model, d_ff), nn.GELU(),
                          nn.Linear(d_ff, d_model))
            for _ in range(n_experts)
        ])

    def forward(self, x):
        B, T, D = x.shape
        flat = x.reshape(-1, D)
        idx, val = self.gating(flat)                      # (N,K),(N,K)
        out = torch.zeros_like(flat)
        capacity = int(self.capacity_factor * flat.shape[0] / self.n_experts)

        for e in range(self.n_experts):
            # 找出路由到专家 e 的 token
            mask = (idx == e)
            token_pos, slot = mask.nonzero(as_tuple=True)
            if token_pos.numel() == 0:
                continue
            if token_pos.numel() > capacity:               # 容量溢出,丢弃
                token_pos = token_pos[:capacity]
                slot = slot[:capacity]
            expert_in = flat[token_pos]
            expert_out = self.experts[e](expert_in)
            out.index_add_(0, token_pos, expert_out * val[token_pos, slot].unsqueeze(-1))

        return out.reshape(B, T, D)

这段代码是教学版,真实实现会用 scatter/gather 与批量矩阵乘避免 Python 循环。但所有关键机制——路由、容量截断、加权求和——都在里面。

常见问题与排错

  • 只有少数专家被使用:均衡损失系数太小,或初始化导致门控偏向。提高系数,检查门控权重初始化。
  • 训练 loss 震荡:容量因子太小,大量 token 被丢弃。提高到 2.0 观察。
  • All-to-All 超时:EP 跨节点且网络拥塞。检查是否把 EP 限制在单节点内。
  • 推理吞吐远低于预期:批次路由分散,专家批量过小。启用专家重排。
  • 显存 OOM 但参数量算得下:专家放置不均或激活值峰值过高。检查放置规划与重计算策略。
  • 微调后路由漂移:微调数据分布与预训练差异大,路由被重塑。冻结门控或降低门控学习率。
  • 量化后质量骤降:共享专家被过度量化。对共享专家单独保精度。

小结

MoE 用稀疏激活把参数量与算力解耦,让「更大」不再等于「更贵」。它的代价集中在三处:路由的稳定性(负载均衡与塌陷防治)、通信的开销(All-to-All 与专家并行拓扑)、调度的效率(推理时的专家批量与显存布局)。把这三处做好,MoE 就是当下性价比最高的大模型扩展路径。它与 大模型推理优化 的显存与批处理策略、分布式训练 的并行拓扑,以及 Transformer 架构 的基础组件层层嵌套,构成了一条完整的技术栈。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「ai」更多文章

  1. 排序学习与搜索召回排序系统
  2. 数据版本控制与血缘:DVC 与 LakeFS
  3. 模型可解释性:SHAP、LIME 与注意力归因