写在前面:MoE 已经从"省算力"变成了"改通信拓扑"

如果把混合专家模型(Mixture of Experts,MoE)简单理解成"参数多、每次只用一小部分",那这个理解在 2020 年之前大致成立,在今天已经严重过时。今天真正让 MoE 成为主流大模型默认架构的,不是它便宜,而是它顺手把三个原本纠缠在一起的问题拆开了:总参数量可以独立于每 token 激活计算量继续往上堆;专家权重可以放在不同设备上,靠 all-to-all 通信换来近乎线性的容量扩展;而共享部分(注意力、embedding、共享专家)仍然保持稠密,保留通用能力的稳定性。这三点凑在一起,才有了 DeepSeek-V3 的 671B 总参数 / 37B 激活、Qwen3 系列的 A3B 与 A22B、Mixtral 8x7B 这类在公开配置里就能直接读到的结构。

但硬币的另一面同样清楚:MoE 把一个"纯计算问题"变成了一个"通信 + 调度 + 路由稳定性"的复合问题。训练时你必须处理 all-to-all 阻塞、容量溢出、token dropping、辅助损失和主任务损失的梯度打架;推理时你必须处理路由抖动、批次大小对路由分布的干扰、量化误差对门控 logits 的放大,以及同一段文本在训练和推理阶段选到不同专家所造成的不一致。这些问题里任意一个处理不好,MoE 的收益就会被吃掉一大半。

所以这篇文章不打算复述 MoE 的历史,也不重复任何具体模型的架构解析。我要做的是把 MoE 拆成六个工程上真正会遇到的决策点:稀疏激活的本质收益从哪来、top-k 路由的数学细节和那些必须做的小动作、容量因子与 token dropping 带来的隐形税、负载均衡损失与主任务的梯度冲突以及三种替代路线、共享专家为什么被越来越多地采用、专家并行与 all-to-all 的通信账,最后落到推理侧的稳定性问题和一组工程权衡判断。每个小节我都会给出一个明确判断,而不是罗列可能性。

需要提前说明一点边界:本文讨论的"MoE"特指 Transformer 中的稀疏 MoE 前馈层(Sparse MoE FFN),也就是把每个 block 里的 FFN 换成若干个并行 FFN 加一个路由器。而注意力部分保持稠密、不做 token 维度的稀疏化——这条稀疏化路线(动态头选择、头剪枝、块级稀疏)在工程上更接近另一类问题,本文不展开。

稠密与稀疏:本质差别不在"参数少",在梯度更新的密度

先做一个最容易搞混的区分。稠密模型和稀疏激活模型的差别,从来不是"参数总量谁大谁小",而是每一个参数每一步能拿到多少梯度信号。稠密模型里所有参数都参与每个 token 的前向和反向,参数量 P 对应每 token 约 2P 量级的浮点运算,显存里 P 的权重必须常驻;MoE 里总参数 P 远大于激活参数 A,每 token 只走 k 个专家,参数量 P 对应的只是权重存储,而计算量按 A 算。

这带来的第一个直接后果是:MoE 的"性价比"来自把容量扩容和计算扩容解耦。想在同样每 token FLOPs 的约束下提升能力,稠密路线只能加宽加深,参数和计算同步增长;MoE 路线可以只加专家数量,让总参数膨胀而激活计算几乎不动。这就是为什么 2024 年之后几乎所有追求"大参数、低延迟"的模型都选了 MoE。

第二个后果容易被忽略:MoE 的算力节省是真实的,但显存和通信开销会增加。这意味着"MoE 更便宜"这句话在训练和推理两个阶段的含义完全不同。训练时 MoE 的目标是吞吐(tokens/s)和通信效率,因为总计算量基本按激活参数走,MoE 赢在你能用更多参数达到更好的收敛;推理时 MoE 的目标变成延迟和显存,而 MoE 在这两项上都要额外付钱:专家权重要全部常驻显存,还要在运行时做 all-to-all 通信。所以一个常见的结果是,MoE 模型的单请求延迟通常不优于同激活量的稠密模型,优势体现在大吞吐、长上下文、批处理场景。

三本账要分开算

维度稠密模型MoE 模型
权重显存约等于总参数量等于总参数量(含未激活专家)
每 token 激活参数等于总参数量注意力参数 + 路由专家中 k 份 + 共享专家
每 token 计算量随总参数量线性增长随激活参数量增长,可独立扩展
优化器状态显存随总参数量增长同样随总参数量增长,MoE 反而更贵
跨卡通信张量并行为主,模式固定专家并行引入 all-to-all,对拓扑敏感

这张表里最容易被低估的是优化器状态那一行。用 AdamW 时,每个参数要额外常驻 fp32 主权重加一阶加二阶动量,粗略按每参数十几字节算,一个 671B 的 MoE 在训练阶段光是优化器状态就是几十 TB 量级,这就是为什么 MoE 训练必须配合专家并行 + 优化器状态分片 + 可能的 CPU offload,没有第三条路。相比之下推理阶段没有优化器,只需要 bf16 权重加 KV cache,显存账要干净得多。

另一个直觉上的差异是:MoE 的容量增长存在收益递减拐点。当专家数量从 8 涨到 64 时,每专家的输入 token 数快速下降,专家内的数据分布变得稀疏,路由器的学习信号变弱;而总参数同步膨胀,通信成本线性上升。所以"多加专家"不是一个可以无脑继续的动作,这是后面要专门展开的粒度问题。

路由的数学形式:softmax、top-k 与那些必须做的小动作

MoE 层的前向可以拆成四步:路由器算 logits、softmax 得到门控概率、top-k 选择专家、把被选中的概率重新归一化作为专家输出的加权系数。这四步里每一步都有陷阱,但真正决定成败的其实是两个容易被写错的细节:top-k 之后必须重新归一化,以及路由 logits 的尺度控制。

先说归一化。如果直接用 softmax 后的前 k 大概率当权重而不归一化,那么每个 token 送进专家的实际信号强度会在不同 token 之间剧烈波动:k=2 时,两个概率可能是 (0.6, 0.25) 也可能是 (0.31, 0.28),后者两个专家收到的信号都被削弱到接近三成。训练早期这种波动会让每个专家的有效学习率不稳定,训练后期又会造成某些专家"欠训练"。标准做法是把这 k 个概率重新归一化,让它们和为 1,保证每个 token 总共仍然贡献一份完整大小的信号。

再说 logits 尺度。路由器是��个线性层,输出直接进 softmax。随着训练推进,门控 logits 的绝对值往往会增大(模型自发地让路由决策更确定),softmax 随之接近 one-hot,梯度从"哪些专家更可能"变成"只有被选中的那个专家能收到路由梯度"。这会带来两个后果:一是训练早期探索不足,二是被选中的专家一旦被选中就更容易继续被选中,形成自我强化的马太效应。这也是为什么很多实现在训练时给 logits 加一点高斯噪声(jitter),把探索显式地打开。

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


class TopKRouter(nn.Module):
    """top-k 路由器:门控 -> 加噪 -> softmax -> 选专家 -> 重归一化 -> 容量裁剪。"""

    def __init__(self, d_model, n_experts, top_k=2, capacity_factor=1.25,
                 jitter_noise=0.0):
        super().__init__()
        self.n_experts = n_experts
        self.top_k = top_k
        self.capacity_factor = capacity_factor
        self.jitter_noise = jitter_noise
        # 刻意不加 bias:常数偏置会让少数专家凭空拿到系统性优势
        self.gate = nn.Linear(d_model, n_experts, bias=False)

    def forward(self, x, drop_tokens=True):
        # x 形状 [T, D],T 是 batch 与 seq_len 展平后的 token 总数,
        # 这样容量、派发、回收三步都只跟 T 有关,与 batch 切分无关
        logits = self.gate(x)                                     # [T, E]

        # 训练期噪声,压制 softmax 过早饱和,保证早期能探索到冷门专家
        if self.training and self.jitter_noise > 0.0:
            logits = logits + torch.randn_like(logits) * self.jitter_noise

        probs = logits.softmax(dim=-1)                            # [T, E]
        topk_w, topk_i = probs.topk(self.top_k, dim=-1)           # [T, k]

        # 最关键的一步。不做重归一化,不同 token 送进专家的总信号
        # 会在 0.2 到 1.0 之间乱跳,每个专家的有效学习率跟着抖动
        topk_w = topk_w / topk_w.sum(dim=-1, keepdim=True)

        # 容量按期望负载定,而不是按实际最大负载定,
        # 这样相邻两批之间容量保持确定,不会因运气好坏而变
        expected = self.top_k * x.shape[0] / float(self.n_experts)
        capacity = max(int(expected * self.capacity_factor), self.top_k)

        # rank[i, e] 表示 token i 是第几个把专家 e 选进来的,
        # 只有 rank 为 0 的那条记录拿到处理权,其余进入丢弃集合
        one_hot = F.one_hot(topk_i, num_classes=self.n_experts)    # [T, k, E]
        rank = one_hot.cumsum(dim=1) - 1                          # [T, k, E]
        keep = (rank.squeeze(1) == 0).gather(1, topk_i)           # [T, k]

        return topk_i, topk_w, keep, capacity

这段代码里有三个工程细节值得单独说。第一,路由器不带 bias,不是随手决定的:bias 会给每个专家一个恒定的 logit 偏置,训练早期如果某个专家的 bias 偶然偏大,它会持续被选中并持续更新,bias 自己也被更新,形成正反馈,路由分布很难拉平。第二,容量用期望负载而不是实际最大负载来算,也就是 k * T / n_experts 再乘容量因子;这样在不同 batch 之间容量是确定性的,不会因为上一批的运气好坏而抖动。第三,丢弃用 rank == 0 判断而不是随机丢,保证同一个专家里被保留的是"最早占位"的那批 token,行为可复现。

关于 top-k 的选择,k=1 和 k=2 是两个不同的东西。k=1 就是 Switch Transformer 的做法,路由是纯 argmax,没有权重混合,每个专家输出直接求和,结构最简单、路由最容易均衡,但每个 token 只能获得一个专家的表达,表达多样性受限。k=2 允许两个专家各出一份加权结果,表达力更强,Mixtral 8x7B 用的是这个设置,也是现在更常见的默认。k=4 及以上在实践中收益递减明显,因为可用容量被摊薄,且每个专家看到的数据太少。工程上还有一个折中是 Expert Choice Routing:不再让 token 选专家,而是让每个专家按自己的容量去 top-C 个 token。它的好处是天然完美均衡、完全没有 token dropping,坏处是路由不再满足因果性——token 的分配依赖整个 batch,在自回归推理和流式场景里无法直接用,所以它基本只出现在 encoder 类或训练期的探索性工作里。

还有一种更激进的变体值得知道:路由器不放在原位,而是下沉一层。DeepSeekMoE 提出把一个标准 FFN 按中间维切成若干段,每段配一个共享输入的路由器与一个细粒度专家。DeepSeek-V3 在此基础上把路由专家数设到 256、加一个共享专家、每个专家只接收 8 个 routed token,同时用序列级的偏置项替代辅助损失。这些是公开配置里的结构事实,不涉及任何跑分。工程上的含义是:专家数可以开得很大而每个专家很小,因为路由的均衡不再靠损失项硬掰,而靠偏置项在每步训练后做无梯度的均衡修正。

容量因子、token dropping 与一笔算不清的隐形税

容量因子的存在是为了处理一个统计事实:路由器不是完美均衡的。即使训练目标明确要求各专家负载一致,实际的 token 分布仍然会有波动,热门专家在某一批里会超额接收 token。工程上的做法是给每个专家设一个硬上限 C = capacity_factor * k * T / n_experts,超过上限的 token 直接丢弃。这就是 token dropping。

token dropping 的代价经常被写成"损失一点算力",这个说法低估了它。首先,被丢的 token 并不是简单地"少算一个 FFN"——它仍然走完了路由器、仍然占用了 all-to-all 的发送、仍然参与 embedding 与注意力的所有计算,只是不再产生 FFN 输出。也就是说,每步训练里 drop_ratio 比例的 token 是纯浪费:它们付了路由和通信的全额成本,却没有换来任何 FFN 的贡献。其次,被丢的 token 在损失上不是简单忽略:在 Switch Transformer 的实现里,被丢 token 的 FFN 分支输出会被置零,这些 token 的监督信号等于被削弱,主任务的梯度也跟着缩水。

容量因子不能开太大,这个直觉是对的,但常见的解释不够准确。真正的理由是:容量因子越大,系统越依赖路由器在训练中把它自己训练好。如果你把容量因子开到 1.5,意味着你容忍 50% 的超额分配也不丢 token,那么当某个专家因为梯度异常或数据分布偏移而突然热门时,你用更多的显存和算力去承接它,而不是让均衡损失去纠正它。表面上容量因子越大越"无损",实际上是把负载不均从"被强制纠正"变成"被算力掩盖",掩盖到某个 batch 路由分布偏移超出余量,token dropping 就会突然从接近零跳到一个明显比例,训练曲线出现很难解释的抖动。

实际工程里的取值范围大致是这样:k=1 的场景(Switch 风格)因为只有一个专家接收,波动更大,容量因子往往需要 1.25 到 1.5;k=2 的场景 1.0 到 1.25 就够;细粒度专家 + 每专家容量很小的场景(比如每专家只接 8 个 token),容量因子接近 1.0 甚至必须为 1.0,因为单专家的统计波动在低计数下本身就大,容量必须放宽才能保证不丢,但同时专家数足够多使得全局仍然均衡。这几组数字不是定理,是常见配置的取值带,真正的做法是在自己的训练日志里监控 drop 比例,把 drop 稳定压在 1% 以下,再往下压容量因子去逼近真实最优。

还有一个容易被忽略的收益:丢掉一部分 token 其实是一种正则。它迫使路由器学会把某些 token 路由给"次优但可接受"的专家,而不是所有 token 都死磕唯一最优专家,这在一定程度上缓解了过拟合和专家坍缩。观察训练轨迹时,如果 drop 比例被压到几乎为零,同时专家负载分布方差也几乎为零,这未必是好事,可能意味着任务分布过于单一,或者均衡约束强到路由器已经不再做任何有意义的判别。

负载均衡损失:它和主任务存在真实的梯度冲突

负载均衡的思想很朴素:在主损失之外加一个辅助项,惩罚各专家收到的 token 数量偏离均值的程度。Switch Transformer 用的是 load balancing loss,对每个专家 i 定义 P_i 为该专家在当前 batch 内被分配的 token 比例、f_i 为实际被路由到该专家的 token 比例,辅助损失就是所有专家 n_experts * sum(P_i * f_i),理想均分时每项是 1/n_experts、总值为 1,所以只需要最小化它。它的好处是简单、无额外通信、几乎所有实现都有。

问题在于这个项和主任务的目标函数在不同的空间里做优化。主任务要的是"对当前输入拟合得好",辅助损失要的是"路由器把 token 均分给所有专家"。这两个诉求在很多情况下是冲突的:某个专家成为某个语义领域的专家、专门处理某类 token,恰恰意味着它收到的 token 不均衡——而这正是我们想要的专门化。辅助损失会持续地、不知疲倦地把这种专门化往回拉,表现为收敛变慢、专家之间的输入分布越来越相似、最终所有专家趋同。

更精细一点说,冲突体现在两个层面。第一个层面是方向冲突:辅助损失的梯度只会作用在路由器的 gate 参数上,而主任务的 FFN 梯度同时作用在 gate 和专家权重上;当两者方向相反时,gate 就在做梯度层面的拉锯,专家权重跟着一起震荡。第二个层面是幅度冲突:辅助损失的系数 alpha 需要手工调,而且它必须大到能压住主任务的均衡诉求,又小到不破坏专门化。alpha 大了,专家同质化,MoE 退化成"多个副本的同一个 FFN";alpha 小了,负载方差变大,drop 比例上升,实际吞吐掉下来。调参成本高、可复现性差,是这条路最实际的痛点。

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


def load_balance_loss(probs, indices, top_k, n_experts):
    """Switch 风格的均衡损失,理想均分时返回值恰好等于 1。"""
    # P 是期望份额:全体 token 的门控概率在专家维度上的均值
    P = probs.mean(dim=0)                        # [E]
    # f 是实际份额:top-k 硬分配之后每个专家真正收到的比例
    mask = torch.zeros_like(probs)
    mask.scatter_(1, indices, 1.0)
    f = mask.mean(dim=0)                         # [E]

    lb = n_experts * torch.sum(P * f)
    # 额外加一份零方差项,防止少数专家被彻底饿死而不报警
    lb = lb + n_experts * torch.sum(P * P)
    return lb


class BiasBalancedRouter(nn.Module):
    """辅助损失-free 的均衡:给每个专家一个不参与反向的标量偏置。

    核心洞察是:偏置只改变选中谁,不改变选中时的概率与权重,
    因此它能修正负载分布,却不会污染主任务在门控上的梯度方向。
    """

    def __init__(self, d_model, n_experts, top_k):
        super().__init__()
        self.gate = nn.Linear(d_model, n_experts, bias=False)
        self.correction_bias = nn.Parameter(
            torch.zeros(n_experts), requires_grad=False)
        self.top_k = top_k

    @torch.no_grad()
    def update_bias(self, per_seq_counts):
        """按序列统计的负载来修正偏置,避免长序列主导整个 batch。"""
        score = per_seq_counts.float().mean(dim=0)          # [E]
        delta = score.mean() - score                        # 超载压低,欠载抬高
        self.correction_bias += torch.sign(delta) * 1e-3
        self.correction_bias.clamp_(-1e-2, 1e-2)

    def forward(self, x):
        logits = self.gate(x)
        probs = logits.softmax(dim=-1)                      # [T, E]
        # 选专家看加偏置的分布,定权重仍看原始分布,两者解耦
        biased = probs + self.correction_bias.view(1, -1)
        topk_i = biased.topk(self.top_k, dim=-1).indices   # [T, k]
        topk_w = probs.gather(1, topk_i)                   # [T, k]
        topk_w = topk_w / topk_w.sum(dim=-1, keepdim=True)
        return topk_i, topk_w

这段代码展示的是目前最有代表性的替代路线:用无梯度的偏置项替代辅助损失。它的核心洞察是,路由决策和路由权重这两件事可以拆开——偏置只加在"选谁"这一步,softmax 概率和输出加权仍用原始 logits。这样均衡机制就能在不污染主任务梯度方向的前提下生效。DeepSeek-V3 公开描述的正是这个机制,它还额外在序列维度上做偏置更新,避免 batch 内长文本主导统计。

除偏置法之外,还有几条替代路线值得知道。z-loss 惩罚的是路由 logits 的绝对值大小而不是分布,它的作用是抑制 softmax 饱和,与均衡目标正交,常常和辅助损失一起用,属于低成本的稳定化手段。熵正则直接鼓励门控分布保持足够熵,本质是"别太早确定",适合训练早期。Expert Choice Routing用硬分配换掉概率均衡,是最彻底的方案但有因果性问题,只能用在非自回归场景。限制路由范围是一个拓扑层面的技巧:在节点内先做一次局部均衡,只在节点之间传递均衡后的 token 分布,把 all-to-all 的全局均衡问题降维成节点内的局部问题——DeepSeek-V3 公开描述的节点受限路由就是这个思路。选哪条路,工程上主要看你的自回归/流式约束:encoder 类模型可以考虑 Expert Choice,自回归模型基本只能在偏置法和其他不改分布统计的技巧里选。

共享专家:一个"允许不均衡"的逃生通道

共享专家的思路非常直接:除了 n_routed 个路由专家之外,再挂一个(或少量几个)永远激活、接收所有 token 的普通 FFN。它的输出和路由专家的加权输出相加或拼接。DeepSeekMoE 首次系统性地论证了这个结构,DeepSeek-V2、V3 都采用了,公开配置里常见的是 256 个路由专家配 1 个共享专家。

共享专家解决的是前面反复出现的那对矛盾。如果没有共享专家,路由器的每一分负载均衡压力都要靠"削弱专门化"来偿还——因为某个领域的 token 想全部涌向某个专家是不允许的,辅助损失会持续把分配拉平,而专门化恰恰需要不均衡。共享专家把"所有 token 都要经过的那部分通用变换"抽出来,路由专家之间就可以放心地分化:既然通用能力由共享专家兜底,路由专家就不需要每个都变成通才,它们可以分别去学代码、数学、自然语言这类专门表征。均衡损失施加在路由专家上的压力因此小了很多。

从梯度角度看,共享专家还有一个更隐蔽的作用:它提供了一个不会坍缩的梯度汇聚点。路由专家在负载严重不均时可能有一半专家长期收不到 token、参数几乎不更新,而共享专家每一步都在被所有 token 训练,等于给优化器提供了一个稳定的下降方向。当某个阶段的路由分布出现异常漂移时,共享专家的存在让模型至少还有一条不退化的路径。这也是它在很多 MoE 模型里被保留为固定配置而不是可选项的原因。

代价当然也有:共享专家是稠密的,它的激活计算无法被稀疏化掉,等于每 token 要多算一份完整 FFN。所以把共享专家的规模做得比普通专家小是常见选择——细粒度专家架构下,共享专家通常被设计成中等大小,而不是与路由专家等大。判断标准很简单:拿掉共享专家跑一小段对照,如果各项指标没有明显下降,那这份稠密切算是白付的;DeepSeekMoE 报告的消融结论就是共享专家在同等参数预算下带来一致的小幅提升,且这个提升在专家数更多时更明显。

另一个边界要讲清楚:共享专家不能替代均衡。它缓解但不消除均衡压力。如果路由专家的负载方差依然很大,你还是要靠偏置法或辅助损失去管。所以把它当成"降低均衡难度的结构手段",而不是"免掉均衡的开关"。

路由器本身的设计变体:门控不一定要是一个线性层

前面所有讨论都建立在一个前提上:路由器就是一层线性层加 softmax。这个前提够用,但了解一下变体能更好地判断什么时候该换,以及换了之后要付出什么代价。

噪声 top-k 是一种很直观的改法:不加高斯噪声,而是在每个专家的门控分数上乘一个从对数正态分布采样的乘性噪声,噪声越大探索越充分。它的好处是噪声的作用方式与概率尺度相乘,天然避免了噪声把某些专家的分数推到不合理区域。相比加性噪声,它的训练稳定性通常更好,但代价是每步都要采样,并且采样在验证和推理时要关闭,不同噪声强度之间也不能直接对比。

哈希路由 走的是另一个极端:完全不用学习,而是把 token 的一个哈希值映射到固定的专家。它的意义不在于效果,而在于确定性和成本——没有门控参数、没有均衡损失、没有路由漂移,非常适合作为对照基线,也常被用在特定结构里做专家的均匀分配。它的缺点同样明确:专家之间无法形成语义专门化,效果上限明显低于学习式路由。

低秩门控 针对的是"专家数很多时门控参数量太大"的问题。先把隐藏态投影到一个低维空间,在低维空间里算门控,再投影回专家维。DeepSeekMoE 里就用到了低秩投影的门控。它的收益是门控参数量从"专家数乘以隐藏维度"降到"两个小矩阵的代价",在专家数很大的配置下这个节省是可观的。代价是门控的表达能力被低秩瓶颈限制,如果专家数量继续增长、低秩不够用,路由质量会退化。

更深的门控网络(比如两层小 MLP)能表达更复杂的路由策略,理论上可以让专家分工更有结构。但它带来两个实际问题:门控的计算开销上升,以及更容易过拟合于训练数据的分布。实践中的判断是,如果线性门控的专家输出相似度已经偏高(说明专家在重复劳动),换成更深的门控网络往往确实有帮助;如果相似度正常、专家分工已经清晰,那加深门控基本是浪费参数。

共享专家的路由也可以变化。前面把它讲成一个恒定接收所有 token 的稠密分支,这是最常见的做法。也有设计让共享专家也参与门控、或者设置多个不同大小的共享专家分别承接不同粒度的输入。前者会削弱共享专家"提供无偏梯度汇聚点"的作用,后者则重新引入了均衡问题。共享专家的价值恰恰在于它不均衡,一旦让它参与均衡调度,它的作用就消失了。所以这里不要为了"结构统一"而做多余的改动。

选择路由器变体时的一个通用原则:先诊断,再换结构。专家分工混乱时,问题多半出在门控的表达能力或探索不足;负载不均时,换更深的门控通常帮不上忙,该动的是均衡机制。把两件事分开,是避免无效调参的关键。

专家并行与 all-to-all:通信才是真正的墙

到这一节,MoE 的工程重心已经明确从算法转移到系统。稠密模型的张量并行模式是固定的:每一层按行/列切分,前向和反向走 all-reduce 或 reduce-scatter,通信量和形状都可以静态推导。MoE 的专家并行不一样——每个 token 去哪个专家是数据相关的、运行期才确定的,所以必须靠 all-to-all 把 token 送到持有对应专家的设备上,算完再 all-to-all 送回来。一层 MoE 至少两次 all-to-all,前向两次、反向两次(实际实现里因为梯度也需要路由信息,次数还会更多)。

import torch
import torch.distributed as dist


class ExpertParallelDispatch:
    """把 [T, D] 的 token 按专家分片送到对应设备,算完再收回来。

    设备 r 持有 experts_per_rank 个专家。all-to-all 之后本地收到的是
    别的设备上、属于本地专家的那些 token,顺序与分桶索引一一对应。
    """

    def __init__(self, n_ranks, n_experts):
        assert n_experts % n_ranks == 0, "专家数需要被并行度整除"
        self.n_experts = n_experts
        self.experts_per_rank = n_experts // n_ranks

    def dispatch(self, x, topk_i, topk_w):
        T, D = x.shape
        k = topk_i.shape[1]

        # 每个 token 有 k 条路由记录,先展平成 [T*k] 这一条长序列
        flat_i = topk_i.reshape(-1)
        flat_w = topk_w.reshape(-1)

        # 目标设备 = 专家编号 // 每设备专家数
        dst_rank = flat_i // self.experts_per_rank

        # 按目标设备分桶,桶内保持稳定序;这一步的排布直接决定
        # 后面能不能用 grouped GEMM 一次算完整层的专家
        order = torch.argsort(dst_rank, stable=True)
        counts = torch.bincount(dst_rank, minlength=dist.get_world_size())
        send = x[order // k] * flat_w[order].unsqueeze(-1)

        # 关键前置步骤:接收端必须先知道要收多少字节,
        # 因此要把每张卡的负载计数交换一次,这是一次额外同步
        input_splits = counts.cpu().tolist()
        recv_splits = self._exchange_counts(counts)

        recv = torch.empty(sum(recv_splits), D,
                           dtype=x.dtype, device=x.device)
        dist.all_to_all_single(recv, send.contiguous(),
                               output_split_sizes=recv_splits,
                               input_split_sizes=input_splits)
        return recv, recv_splits, order

    @staticmethod
    def _exchange_counts(counts):
        buf = [torch.zeros_like(counts) for _ in range(dist.get_world_size())]
        dist.all_gather(buf, counts.to(dist.get_backend()))
        return buf[dist.get_rank()].cpu().tolist()

    def combine(self, out, order, T, k, D):
        """把专家输出按原 token 顺序加权求和,送回它原本所在的设备。"""
        recv = torch.empty(T * k, D, dtype=out.dtype, device=out.device)
        dist.all_to_all_single(recv, out.contiguous(),
                               output_split_sizes=[T * k],
                               input_split_sizes=None)
        x = torch.zeros(T, D, dtype=out.dtype, device=out.device)
        x.index_add_(0, order // k, recv)   # 权重在派发时已经乘过
        return x

这段代码里最值得注意的是两个"非计算"的部分。第一,负载计数必须先 all-gather 一次,因为接收端在 all-to-all 之前不知道自己要收多少字节;这是一次额外的同步,在小消息下开销占比可能相当可观,很多框架把它和第一次 all-to-all 融合,或者用预估 + 校验的方式规避。第二,排序位置决定内核性能:把同一专家的 token 排成连续的一段,才能用 grouped GEMM 一次性算完所有专家;不能分组就只能退化成 N 次独立的小 GEMM,GPU 利用率直接腰斩。这类数据布局的细节往往比数学部分更能决定实际吞吐。

关于通信拓扑,MoE 对互联的敏感度远高于稠密模型。专家并行的 all-to-all 是一个跨节点的、全连接的、不规则的交换:每张卡都要同时向其他所有卡发送不同数量的 token,且这些数量每步都在变。在 NVLink 域内它还算可以,跨到节点之间走网络就很容易成为瓶颈,因为网络带宽通常是 NVLink 的十几分之一差。EP 并行度不能简单等于总 GPU 数,工程上更常见的做法是把 EP 限制在 NVLink 域内(比如 8 卡一个域),域内做 EP,域间对激活层和注意力层做 DP 或 TP;或者用 DeepEP 这类专门做 MoE 通信优化的库来降低小消息和跨节点传输的开销——它把 token 传输和低精度 KV/激活的传输分成不同路径来混合带宽,并对每个 token 的 RDMA 写入做更紧凑的打包。

另一个容易被忽略的事实是:MoE 的 batch 越小,通信效率越差。all-to-all 的每个参与者发送的数据量与本批 token 数成正比,token 太少时消息碎片化严重,延迟占比飙升。这就是为什么 MoE 训练通常把全局 batch 取得很大(动辄百万 token 量级的序列数 × 序列长),以及推理时更适合大批量服务而不是低并发在线场景。

还有一个前向 / 反向不对称的问题值得记一笔:前向 all-to-all 的数据量是反向的好几倍,因为反向需要把梯度送回所有涉及的设备、还要把前向保存的路由索引和门控权重重新广播回去。DeepSeek-V3 公开描述的做法是让反向同时传回激活梯度、门控权重(用于重算门控)以及路由索引,从而把反向的两到三次通信压成一次。Tutel、MegaBlocks 这类库也都在做类似的通信融合——MegaBlocks 的思路是用 block-sparse GEMM 把容量维度和 drop 维度合并进稀疏结构里,从而让整层的容量因子不影响计算量。

专家拆得太细会变差:一个反直觉但很稳定的规律

"专家越多越强"是流传最广的 MoE 误解之一。实际情况是:专家粒度存在一个甜点区,超过之后收益快速衰减甚至转负。原因有四条,都很具体。

第一是每个专家见到的 token 数太少。当总 token 数固定、专家数翻倍时,每专家的 token 份额减半。FFN 的参数需要足够的数据来训练,样本太少时专家学不到东西,只是勉强拟合一小块局部模式,容量利用率极低。工程上你会看到专家之间的输出分布开始趋同——因为每个专家都被限制在极窄的输入分布里,多个窄分布拼起来的整体能力反而不如一个稍宽的分布。

第二是通信和调度开销随专家数增长。专家数翻倍意味着 all-to-all 的分桶更碎、消息更小、grouped GEMM 的分组更多而每组计算更少,GPU 的效率随之下降。这部分开销是硬性的,不会被更大的模型规模摊薄。

第三是路由器的学习难度上升。输出维度是专家数的线性增长,门控的参数量随专家数增长,softmax 空间变大,最优路由的搜索更难,早期收敛更慢。观察到的现象是:专家数很多的前期 loss 下降更慢,需要更多 token 才能追平粗粒度配置的最终水平。

第四是细粒度的收益依赖于路由器本身足够强。细粒度专家的核心假设是路由器能做出足够精细的判别,让每个 token 找到最合适的小专家。如果路由器的判别能力没跟上,那么细粒度只是把一个能力不足的分配问题切得更碎,没有带来任何新信息。DeepSeekMoE 的实验设计上有一个关键对照:保持总参数量不变、只改变专家粒度,来隔离"细粒度"本身的贡献——这一点在很多复现里被忽略,导致把参数量变化误算成粒度收益。

那甜点区大概在哪?实践中的经验是:粗粒度(专家少、每个大)对应低到中等规模,细粒度(专家多、每个小)对应大规模。粗粒度的典型配置是 Mixtral 的 8 个专家、DBRX 的大规模少专家路线;细粒度的典型配置是 DeepSeek-V3 公开的 256 个路由专家、每专家只接 8 个 routed token 这类。判断粒度是否合适,最直接的指标是每个专家的有效 token 利用率和专家输出的两两相似度:如果相似度普遍很高,说明专家在重复劳动;如果利用率很低,说明大量专家参数在闲置。这两个指标比看 loss 曲线更早给出信号。

还有一个折中路线是混合粒度:层与层之间用不同专家数,或者同一层里一部分粗粒度专家处理通用特征、一部分细粒度专家处理专门特征。这在原理上合理,但实现上会让 all-to-all 的分桶逻辑变复杂,工程上未必划算,除非你已经有成熟的 EP 框架并且数据分布确实呈现多层异质性。

专家间的尺度漂移:一个不报错但很致命的问题

MoE 有一个稠密模型不存在的隐性问题:不同专家的输出尺度会随训练各自漂移。稠密模型里所有 token 走同一套参数,激活值的统计分布是整体收敛的;MoE 里每个专家只处理一小部分 token,如果某个专家的输入分布偏窄(它专门处理某一类 token),它的输出分布也会偏窄;而不同窄分布拼在一起做残差相加时,尺度不一致的问题就被放大了。

尺度漂移的直接后果有三个。第一个是训练不稳定:某个专家在某一步输出异常大的幅值,残差相加之后把整层的激活推到很大的范围,后续层的归一化被带偏,可能出现 loss 尖峰。第二个是路由更容易坍缩:某个专家因为输出幅度大,在残差流里的话语权变强,倾向于吸引更多 token,进一步加剧不均衡。第三个是量化精度被拖累:低精度量化的缩放因子通常按全局统计或按张量统计计算,如果某个专家的幅值是其他专家的十倍,整个张量的量化步长就要迁就它,其他专家的精度被白白牺牲掉。

处理办法有几条,按代价从低到高排列。最轻的是保持每个专家 FFN 内部的归一化结构,确保所有专家的参数化方式一致,这样它们的输出尺度天然在同一个量级,不会因为结构差异而漂移。这也是为什么很多 MoE 实现里专家 FFN 内部保留残差连接和归一化。稍重的是对专家输出做缩放监控:把每个专家输出张量的均值、标准差、最大值记录到训练日志里,直接观察哪些专家在漂移、漂移到什么程度。多数团队不做这件事,于是这类问题往往是在出现 loss 尖峰之后才开始追查,白白浪费很多时间。更重的是引入专家级的数值保护,比如对极端幅值做截断或者检测,这类手段通常只在低精度训练里作为溢出保护使用,不是常规配置。

还有一个容易被忽略的关联:专家间的尺度差异会直接放大路由的数值敏感性。前面说过,top-k 的选择取决于 logits 的相对大小,而当不同专家接收到的 token 数差异很大时,收到的 token 数量本身就会影响下一层门控输入的统计量,从而形成"负载不均导致尺度不同、尺度不同导致路由决策偏移、路由偏移加剧负载不均"的正反馈。所以监控专家尺度漂移,本质上也是在监控均衡的健康度。

一句实用的总结:MoE 的可观测性要求远高于稠密模型。稠密模型里 loss 曲线和梯度范数基本够用;MoE 里至少要有丢弃比例、按专家的负载分布、按专家的输出尺度这三组指标,加上偏置项幅值和门控熵,才算具备基本的调试能力。这几组指标的实现成本很低,但缺了它们,MoE 出问题时基本只能靠猜。

显存与算力的账:把 MoE 的真实成本摊开

MoE 最容易被误算的是显存。正确的算法是分训练和推理两套账,两套账的结论常常相反。

训练阶段:权重按 bf16 存(每参数 2 字节),优化器状态按 AdamW 估算(fp32 主权重加一阶二阶动量,每参数约 12 字节,合计约 14 字节),加上梯度本身的 2 字节,粗略每参数 16 字节量级,还要叠加通信 buffer、激活值、以及 MoE 特有的 dispatch/combine 中间结果。以 671B 总参数的量级算,仅优化器状态就是 TB 级。这意味着 MoE 训练必须把优化器状态也做专家并行分片,并且往往需要 CPU offload 或 ZeRO 式的分页优化;同时激活值显存会因为容量因子而被放大(每个专家要按容量预留 slot,哪怕实际有 drop 也有空槽)。这条账的结论是:MoE 训练省的是计算,不是显存,某些配置下显存开销反而高于同等激活量的稠密模型。

推理阶段:只有 bf16 权重加 KV cache,每参数 2 字节,671B 约合 1.3TB 量级的权重。相对于 KV cache 的开销,MoE 独有的成本在于:所有专家权重都必须常驻,不能像激活层那样按需换入换出(除非你接受巨大的换入延迟);以及 all-to-all 通信在每次前向都要发生。这直接决定了 MoE 推理必须批处理。没有大 batch,MoE 的两个优势(容量大、显存装得下)都会被通信延迟吃掉,而稠密模型在小 batch 下反而更快更简单。

算力账则更微妙。激活参数量 A 不等于每 token 实际 FLOPs:路由器的计算、top-k 选择、容量 padding、token dropping 造成的空转、all-to-all 的搬运开销,都是额外成本。在 k=2、专家数很多、容量因子 1.25 的配置下,这些额外项加起来能让实际 FLOPs 显著高于按 A 估算的值——粗略地讲,路由与调度开销在激活参数占比不高的小模型里尤其明显。工程上正确的做法是实测而非估算:固定硬件和并行配置,量 tokens/s 和端到端延迟,然后用激活参数量反推"有效吞吐参数",这个数才是 MoE 之间可比的指标。

还有一个容易被忽略的成本维度是量化对 MoE 的伤害是不均匀的。见下文。

推理侧的路由稳定性:训练与推理不一致的真实代价

这一节是 MoE 工程里最容易踩坑、也最少被系统讨论的部分。核心问题一句话概括:路由决策是数据相关的,而推理时的数值条件跟训练时不同,于是同一个输入在推理时会被路由到不同的专家,模型行为发生漂移。

批大小如何改变路由分布

top-k 路由本身是对每个 token 独立计算的,理论上不受 batch 影响。但实际实现中它受 batch 影响:路由器的矩阵乘会走不同的 GEMM kernel(batch 小时可能触发不同的 tile 策略或低精度路径),数值误差的量级会变;drop 逻辑在 batch 足够大时几乎不触发,在 batch 小的时候 drop 比例显著上升,而被丢的 token 的 FFN 输出为零,等价于模型结构被临时改变。这种"batch 大小改变模型"的效应在稠密模型里几乎不存在,在 MoE 里却相当明显。

由此产生一个实践性的检查项:同一段文本,在 batch=1 和 batch=64 下生成同样的贪心解码结果吗?如果不一致,说明你的实现存在数值敏感或者 drop 导致的结构变化。这个检查值得写进回归测试,因为它能提前暴露一批线上才会出现的诡异问题(同一个问题在不同并发量下给出不同答案)。

量化对路由的伤害比对 FFN 更致命

权重量化对 MoE 的影响是不均匀的,这一点很多人在把稠密模型的量化经验直接套用到 MoE 上时会吃亏。路由器只有一层很薄的线性层,它的输出 logits 直接决定 top-k 的选择,而 top-k 是一个阶跃操作:两个专家的 logit 差 0.01 和差 0.11,在量化误差面前可能翻转选择。当 logits 差值很小的时候(这恰恰是路由最关键、决定性最强的时刻),量化引入的相对误差占比最大。

所以实践中的共识是:路由器权重和偏置保持高精度,不要量化。它的参数量相对于全部专家微不足道(E × D 对比 E × 3D²),保持 fp16 或 bf16 的成本可以忽略,而收益是消除一整类难查的输出不稳定问题。相关的经验还有:在训练末期就做一次"路由温度标定"——检查 logits 的分布,如果大量 token 的 top-1 与 top-2 差距极小,说明路由处在模糊区域,对量化误差格外敏感,这类情况需要么加大路由温度要么在推理时对选中的 k 个专家都按概率混合而不是硬取 top-k。

其他推理侧的现实约束

还有几个必须考虑的点。激活值量化通常比权重量化更安全,因为它不直接扰动路由决策,但要注意 capacity padding 的空槽里全是零值,量化尺度如果按全局统计来算会被这些零拉偏,建议按有效 token 统计。专家合并或剪枝在推理阶段是可行的——这是 MoE 推理优化的一条实用路线:通过统计各专家的输入输出相似度或重要性,把冗余专家合并或直接裁掉,显著减少常驻显存和 all-to-all 的参与方。前提是你能接受少量精度损失,并且这个裁剪是离线做完固化进权重的。

多卡切分策略要跟着 EP 走:推理服务里 EP 的并行度应当匹配硬件互联的物理边界,并且切分粒度必须能整除专家数。如果一个域内的 GPU 数不能整除专家数,就会出现某个域持有部分专家、某些 token 需要跨域往返的情况,延迟直接翻倍。不同推理框架对 MoE 的支持成熟度差异很大:vLLM 和 SGLang 都有针对 MoE 的融合算子(把路由、gather/scatter、grouped GEMM 融成一两个 kernel),支持情况与专家数、top-k、是否共享专家、是否有 MLA 这类正交结构都有关,部署前应该先用目标框架实测吞吐,别用"模型支持 MoE"这种粗粒度结论来判断。

自己实现一个 MoE 层时的检查清单

看完前面这些内容,如果决定自己动手实现,这里给一份按顺序执行的自查清单。每一条都对应前面章节提到的一个坑,且都可以用小规模实验独立验证,不需要跑完整训练。

  • 门控之后是否重归一化:把归一化去掉跑几百步,看专家的梯度幅值是否出现明显抖动。这是最容易写错、也最容易验证的一条。
  • 路由器是否带偏置:如果带,检查这个偏置在训练初期是否让某些专家的分配概率系统性偏高。原则上不带。
  • 容量是否按期望负载计算:确认公式是总分配数除以专家数再乘容量因子,而不是直接拍一个常数容量。
  • 丢弃逻辑是否可复现:同样的输入跑两次,被丢的 token 集合应该完全一致;用随机数丢弃会引入不可复现的噪声。
  • 丢弃比例是否被记录:训练日志里必须有这一项,且要按专家分别记录,才能区分"整体不均衡"和"个别专家过载"。
  • 输入展平是否彻底:容量和派发都基于展平后的 token 维,前向反向要保持同一套展平与还原逻辑,否则梯度错位。
  • 被丢 token 的输出处理:明确约定是置零还是残差跳过,并确认它对下游梯度的处理符合预期。
  • 通信前置同步是否存在:负载计数的交换必须发生在 all-to-all 之前,缺了这一步会在小规模下偶发挂死。
  • 分组排序是否稳定:同一专家的 token 必须连续且顺序稳定,否则 grouped GEMM 结果不可复现。
  • 跨批一致性:固定一段文本,在 batch 大小取 1 和取 64 的情况下各跑一次推理,贪心解码结果应当一致。
  • 路由器保持高精度:推理侧确认门控层的权重没有被量化管线处理。
  • 专家并行度能整除专家数:不整除时的跨域往返会让延迟直接翻倍,这类问题在配置错误时不会报错。

这份清单里前六条属于算法正确性,后六条属于系统正确性,两者的排查手段完全不同:前者用几百步的小规模训练加日志就能验证,后者必须在真实的并行配置下跑才有意义。最容易被跳过的其实是最后两条,因为它们不报错、只在生产环境的特定并发量下暴露。把跨批一致性写进 CI,通常是投入产出比最高的一个动作。

长上下文与多模态下的 MoE:token 类型带来的新问题

前面讨论的路由问题默认所有 token 是同质的,但在长上下文和多模态场景里,这个假设不成立,而这会引出几类新的、值得单独处理的工程问题。

第一是 token 类型的负载失衡。多模态输入经过投影之后,视觉 token、文本 token、可能有音频 token,它们在隐藏态空间里的分布差异很大,路由器对它们的偏好也可能完全不同——视觉 token 可能被高度集中地路由到少数几个专家上,因为视觉特征的分布本来就比文本更集中。结果是:在混合 batch 里,文本侧的专家负载可能远低于容量,而视觉侧的专家频繁触碰容量上限并产生丢弃。处理方式是按 token 类型分别统计丢弃比例和负载方差,而不是只看全局平均;容量如果需要分别设定,通常意味着要维护两套容量逻辑,复杂度上升,需要权衡是否值得。

第二是上下文长度与容量因子的相互作用。容量是按平均负载算的,而负载的方差既取决于专家数,也取决于每个序列的 token 数。当序列长度从几千涨到几十万时,路由分布在大 batch 上会更稳定(方差下降),但一旦出现分布偏移的段落,某个专家可能瞬间吃掉大量 token,而这部分 token 超过容量后被丢弃——长上下文中被丢弃的往往不是随机的边缘样本,而是某些连续片段。这意味着丢弃对模型行为的影响比短上下文严重得多:丢掉的是一整段语义,而不是零散的 token。缓解思路包括为长序列单独放大容量、为不同来源的 token 设置不同的容量配额,或者干脆在长序列段上放宽 drop 并接受算力损失。

第三是序列级均衡与序列级偏置的取舍。前面提到偏置项的更新可以按序列统计来做,好处是避免长序列主导全局统计。但当一个 batch 里的序列长度差异很大(长上下文混合短文本训练)时,逐序列统计又会让短序列的噪声被放大成偏置抖动。折中做法是按 token 数加权统计,或者把更新窗口拉长以平滑噪声。DeepSeek-V3 公开描述的是序列级的辅助均衡系数与序列级偏置组合,用来保证路由不会偏向任何特定序列、也不偏向任何特定 token;这是一个值得借鉴的思路方向——把均衡从单一层次拆到多个层次分别处理。

第四是位置与结构的正交性。当模型的注意力层本身也引入了稀疏或低秩结构(比如低秩 KV 压缩、多头潜在注意力这类压缩注意力的做法)时,两者的稀疏模式会叠加:注意力在序列维做压缩,MoE 在特征维做稀疏。工程上的影响是显存峰值和通信模式都要重新测一遍,因为这两类结构的最佳并行配置往往不同,不能直接沿用纯注意力模型的配置结论。

这些问题的共同点是:它们都源于"路由是数据相关的,而真实数据的分布远比训练时的假设更宽"。所以在多模态和长上下文场景做 MoE,监控指标的粒度必须比纯文本场景更细,容量和均衡的设计也要从"全局一套"转向"分类型、分长度分档"。

一次 MoE 训练的并行与精度配置清单

讨论了那么多算法问题,最后还是要落到一张配置表上。下面这份清单按"先定并行、再定精度、最后定通信优化"的顺序组织,每一条都给出选择它的理由和它不适用的边界。

并行策略怎么切

注意力层和专家层用不同的并行方式,这是 MoE 训练配置的基本形态。注意力层用张量并行加流水并行,专家层用专家并行。原因是两者的通信模式完全不同:注意力层的通信是固定形状的规约,适合张量并行;专家层的通信是数据相关的不规则分发,只适合专家并行。把它们混在一起(比如专家也做张量并行)会得到极差的通信效率。

并行度的分配有一个常见的错误:专家并行度开得过大。专家并行的 all-to-all 是全连接的通信,跨出高速互联域之后,带宽急剧下降,而参与通信的设备越多,分桶越碎、平均消息越小,效率进一步恶化。实践中的经验是让专家并行度对齐到单个高速互联域的大小,域与域之间用数据并行或对激活层做张量并行来扩展。把这个域的大小当成一个硬约束来设计,比事后调优有效得多。

专家数必须能被并行度整除。不能整除时有两种处理:让某些设备多持有一个专家(负载天然不均,通常不划算),或者在专家层额外引入一层不规约的调度。前者简单但会让最忙的卡决定吞吐,后者灵活但实现复杂。多数情况下,把专家数设计成并行度的整数倍是最省心的做法,这也解释了为什么很多模型的专家数是 8、16、64、128、256 这种整数形态。

精度怎么定

主权重与前反向用 bf16,优化器状态用 fp32,这是当前大规模训练的主流选择。bf16 的动态范围足够容纳 MoE 里数值分布很宽的激活(尤其是不同专家输出尺度差异大的情况),而 fp32 主权重保证长时间训练的数值稳定性。MoE 对精度的敏感度比稠密模型高,原因在于路由 logits 的尺度会随训练漂移,而 logits 又直接决定阶跃式的专家选择。

更激进的低精度训练需要额外的路由保护。用 fp8 之类更低精度做前向和矩阵乘、把训练吞吐再往上推是可行的方向,但必须配合对路由路径的保护:门控的 GEMM 保留更高精度,或者在 fp8 的缩放因子计算上把路由那一层排除在外。DeepSeek-V3 公开描述的就是整体 fp8 训练加细粒度的精度分配与若干溢出保护手段。这条路的适用边界很清楚:只有在大规模、专家数很多、算力受限的预训练场景里,复杂度的增加才划算;中等规模训练用它多半是过度工程。

通信优化按什么顺序做

如果通信是瓶颈,优化有明确的优先级。第一优先是把通信次数减少:让反向一次传回激活梯度、门控权重和路由索引,而不是分多次传;这一项收益最大且不改变数值结果。第二优先是把通信与计算重叠:在派发某个专家的同时计算另一个专家,用通信和计算的双缓冲把两者的耗时叠加,而不是相加。第三优先才是压缩通信量本身:把激活压到更低精度传输、对不同来源的流量分路径传输、改进小消息的打包方式。

这个顺序背后的判断是:减少次数是纯粹的算法收益,重叠是系统收益,压缩是工程收益。前两者的上限更高,而且不引入额外的精度风险,所以应该优先做。实践中很多人一开始就去优化打包格式,收益往往不如预期。

MoE 的评测陷阱:离线指标为什么容易骗人

MoE 的评测比稠密模型多出两层不确定性,来自结构和运行时环境。这两层不确定如果不控制住,评测结论可能完全是噪声。

结构这一层的核心问题是:模型的输出依赖 batch 大小和部署配置。前面已经讨论过容量裁剪和低精度路径会让同一段输入在不同并发量下走到不同的专家,产生不同的输出。这意味着评测时如果只用单一固定的 batch 配置,你测到的其实是"某一个切片上的模型",而不是模型本身。正确的做法是在评测时固定 batch 与调度参数并记录下来,同时至少跑一次跨 batch 的一致性检查;如果一致性不成立,那这一轮评测的所有对比都要打折。

量化这一层要单独警惕。MoE 的量化误差不均匀地落在不同模块上:专家 FFN 的权重数量占绝对多数,量化误差主要由它们贡献,而路由器的参数量微不足道但对决策的影响是阶跃的。因此"整体比特相同"的量化方案在 MoE 上可能表现得更差,因为它们通常按参数量分配精度,把更多精度给了本来就该保持高精度的路由器。总字节数相同不代表有效信息相同,这一点是从稠密模型直接迁移量化经验时最容易踩的坑。

还有一个结构层面的评测陷阱:专家不均衡会影响评测集的分布覆盖。如果某些评测样本被大量路由到同一个专家上,它们的错误会被这个专家的能力短板放大,评测结果就过度反映了这个专家的偏好,而不是模型的平均能力。把评测集按路由结果的熵分层来看,是一个能揭示这种偏差的实用做法——看高熵样本和低熵样本上的指标差距,比只看总体均值更能反映模型的真实鲁棒性。

运行时环境这一层则是常规的工程纪律:固定推理框架版本、并行配置和内核实现再比较;对比不同 MoE 模型时确认使用相同的数据处理与评测脚本;报告结果时把容量因子、丢弃策略、量化方案这些会改变模型行为的配置一起写出来。最后这一条尤其重要——一份没有说明容量因子和量化方案的 MoE 评测报告,其数字是没有可比性的。

路由与数据配比:专家会学出什么,取决于你喂了什么

前面所有关于均衡和容量的讨论都把数据当成外生变量,但数据配比其实是 MoE 最被低估的一个设计变量。原因很直接:专家的分工是从数据里学出来的,数据分布的不均衡会直接翻译成负载的不均衡。

最典型的场景是数据配比里的语言或领域比例悬殊。如果某类数据占了训练语料的大部分,路由器会很快学会把这部分 token 大量送到某些专家上——这不是路由器的 bug,而是它在正确地优化:如果某个专家被反复训练来拟合这类数据,它在那类 token 上的表现就会更好,于是更多的这类 token 被路由过去,进一步强化它。结果就是这些专家过载、数据占比小的领域长期得不到足够训练、模型在小众领域上能力明显偏弱,而所有这些都会在负载直方图上表现为那几个专家的柱子特别高。

这带来一个反直觉但很重要的判断:MoE 的负载均衡目标在某种程度上和数据多样性目标是冲突的。均衡损失希望所有专家负载相同,它不区分 token 来自哪种领域;而你真正想要的是"每个领域都有一个能处理它的专家,并且这个专家被充分训练"。这两个目标只有在数据配比本身均衡时才一致。所以 MoE 训练里的均衡超参和数据配比必须一起调,单独调任何一个都容易得到次优结果。

反过来,这也是共享专家价值的一个新的注脚。共享专家接收所有 token,因此它天然地被按数据真实比例训练,能提供一份不被人为均衡扭曲的通用能力。而路由专家在均衡约束下被压得比较平,它们的专门化程度反而受限于均衡压力。两者结合,让模型在"通用能力的保真度"和"少数领域的专门化"之间取得了比较合理的折中。

还有一个实践建议:看负载直方图的时候,一定要按数据来源分桶看。全局看可能已经很均匀(因为大领域被压下去了),但按领域拆开看会发现某些小领域的 token 高度集中在少数专家上,而且这些专家的总负载不高——也就是说它们有容量闲置却没被用上,只是路由决策没有把它们选上。这种情况下增加容量因子或者加大均衡力度都不解决问题,该做的是检查路由器的判别能力和该领域在训练数据里的样本量。

顺带一提,这和推理侧也有呼应:如果你的线上流量分布和训练数据配比差异很大(比如训练时中文为主、线上多语言混合),那么 MoE 的专家分布在实际服务时会被推向一个它没被充分训练过的状态,负载分布会明显偏移。这个问题在稠密模型上不存在,在 MoE 上必须专门处理,常见做法是在数据配比上刻意保留一部分分布外但结构化的数据,让路由器对这些类型有一个"不会崩"的兜底区域。

工程权衡清单:什么时候不该上 MoE

MoE 不是默认更好的架构,它的收益有明确的前提条件。把这些条件列出来,反过来就是判断自己的项目该不该上 MoE 的一组问题。

规模够大吗?MoE 的核心收益是"容量与计算解耦",这个收益需要足够大的激活计算量才能显现。如果你的目标激活参数只有 1B 到 3B 量级,加专家带来的容量增加很可能被路由开销和通信开销吃掉。粗粒度 MoE 的最小有效规模大致在 7B 激活参数以上(这也是 Mixtral 8x7B 这个配置被反复复制的原因),细粒度路线对规模的要求更高。

你有跨卡互联吗?如果你的部署目标只有单卡或者少量节点且互联很弱,MoE 的 all-to-all 会直接判它死刑。MoE 的应用前提是:至少在节点内有多卡高速互联(NVLink 域),或者你能接受算力利用率下降来换容量。

你的服务形态是吞吐型还是延迟型?MoE 适合大批量、高吞吐、长上下文的场景,比如离线处理、数据合成、批量 API。不适合低延迟小批量的在线单请求场景——那正是稠密模型的舒适区。

你的运维能力撑得住吗?跑 MoE 意味着要同时监控 drop 比例、专家负载方差、专家输出相似度、all-to-all 通信占比、路由偏置的幅度这几组指标,并且要有能力在它们异常时定位到是容量、均衡还是通信的问题。相比之下,稠密模型的调优路径短得多。团队规模有限时,把稠密模型做扎实(更好的数据、更长的上下文、更稳的推理栈)通常回报更高。

你的任务分布够多样吗?如果数据高度同质(比如全是同一种格式的分类任务),路由专家没有可分化的空间,MoE 的收益无从体现,还会平白增加开销。MoE 的收益与数据的模式多样性强相关。

反过来,值得上 MoE 的信号是:稠密模型在同样算力下已经明显撞到能力天花板、你的显存足够装下远超激活量的参数、你的负载是大批量处理、而且你的训练集群有高速互联并且你能承受一次较高复杂度的分布式调试。

调参顺序:这三个旋钮根本不能分开调

MoE 的调参之所以痛苦,是因为辅助损失系数、容量因子、专家数、top-k 这四个量是强耦合的,改一个会让另外三个的最优值跟着动。实践中最容易犯的错误是一次性把所有旋钮都动一遍,然后根据最终 loss 判断"哪个参数不对"——这个判断几乎没有信息量,因为最终 loss 是四个变量共同作用的结果。

相对稳妥的顺序是:先固定结构,再调均衡,最后压容量。具体来说,专家数和 top-k 属于结构决策,一旦开训就不改,因为改了等于重置训练。均衡方式(辅助损失法还是偏置法)在开训前就要选定,因为它决定了训练曲线的整体形态,事后换掉需要重跑。辅助损失系数 alpha 用一个粗略的量级去扫,比如 0.01 到 0.1 之间取几个点,每个点跑几千步看专家负载方差的下降速度和主任务 loss 的上升速度,找一个能压住方差但主任务没有明显变慢的点,就固定下来不要再动。容量因子留到最后,等分布稳定之后再逐步往下压,压的过程中监控丢弃比例。

判断"均衡是否过度"有一个很实用的间接指标:专家输出的两两相似度。如果任意两个专家在某组输入上的输出余弦相似度普遍偏高,说明专家之间在重复劳动,那么继续加大均衡力度不会有收益,问题出在路由器的判别能力或者专家结构上,而不是负载不够均。这个指标比看负载直方图更容易发现真正的退化。

反过来,如果负载已经相当均匀但路由分布明显偏斜(少数几个专家拿走了大部分 token),说明偏置项的修正力度不够,或者修正的统计窗口太粗,把更新频率提上去通常就有改善。

常见故障与定位思路

现象最可能的原因处理方向
训练早期 loss 下降异常慢,之后才追平路由探索不足,专家还没被充分访问加大训练期噪声,检查是否有冷门专家长期为零负载
负载已经很均但指标不升反降均衡过度,专家同质化降低辅助损失系数,或改用无梯度的偏置项做均衡
训练中途 loss 突然跳变某个专家负载短时冲高、token 丢弃比例上升检查容量因子余量与偏置项的修正步长
吞吐随训练步数缓慢下降路由分布越来越尖锐,all-to-all 消息越来越不均衡给 logits 加温度约束,观察门控熵是否持续下降
推理输出在不同并发量下不一致容量裁剪与低精度路径共同造成的数值敏感推理期不裁剪、路由器保持高精度,做跨批一致性回归测试
显存占用远高于预期优化器状态未分片、容量预留的空槽过多优化器分片,压缩容量因子,合并冗余专家

这张表里最值得单独强调的是第四行。吞吐随训练步数缓慢下降是一个很隐蔽的问题,它不报错、不崩,只是慢慢变慢,所以很容易被误认为"集群变忙了"。真实原因往往是路由分布随训练逐渐尖锐:早期专家之间分配比较随机,all-to-all 的消息量在各卡之间比较均匀;随着训练推进,路由越来越确定性,热门专家所在的那张卡收发的数据量远超其他卡,其余卡在等待。反向的 all-to-all 更明显,因为梯度回传的数据量比前向大。结果就是集群里最忙的那张卡决定整体吞吐,其余算力空转。缓解手段包括给路由 logits 加一个温度上界、周期性做负载重平衡、或者在物理上把最热门的专家分散到不同节点。

专家冗余与后训练:一条把 MoE 变稠密的务实路线

前面反复出现的一个现象是专家冗余:专家数很多,但实际起作用的只有一小部分,剩下的在重复劳动或者长期闲置。它带来的成本很直接——显存里躺着大量用不到的权重,all-to-all 依然要为它们付出分桶和通信开销,路由器的输出维度也因此变大而更难学。

识别冗余有几种互补的手段。最直接的是输入分布统计:把每个专家收到的隐藏态做聚类,看有多少个专家的输入簇高度重叠。其次是输出等价性测试:对一组探针输入,逐对把专家 A 的输出和专家 B 的输出做对比,如果差异极小,说明这两个专家可以被合并。再就是裁剪后验证:直接砍掉一批低价值专家,跑一个短程评测看指标掉多少,这是最诚实但也最贵的办法,因为每试一次都要重跑一次验证。

基于这些判断,有一条被称为稀疏模型稠密化的路线值得注意。它的基本操作是:在一个已经训练好的稠密模型里,把每个 FFN 拆成若干小 FFN(这就是最原始形态的 MoE),然后用原模型去生成大量样本作为蒸馏目标,让这些小专家去拟合原 FFN 的输出。这个过程不需要改动主干、不需要重新预训练数据,成本远低于从零训练一个 MoE,对于"想把已有稠密模型快速转成稀疏形态"的场景非常实用。DeepSeekMoE 的小规模专家加共享专家结构、后来的稀疏上采样类工作也属于相近的思路家族:用已训练好的稠密模型作为初始化,通过增量训练把稠密 FFN 的能力拆分到多个小专家里。

这条路线对工程团队的现实意义在于:它把"要不要上 MoE"从一个架构赌注降级成了一个可回退的工程选项。你可以先在现有稠密模型上做稠密化改造拿到第一版收益,如果效果不理想,成本也只是增量训练的时间,而不是一次完整的预训练。反过来,如果收益成立,你的稠密模型也已经在了。

但也要说清楚它的边界。稠密化得到的是一个"权重被拆开的稠密模型",它的路由器是事后拟合出来的、倾向于复现原 FFN 的分工,而不是学出来的专门化路由。这类结构通常专家分工整齐、负载均衡容易,但每个专家的表达多样性有限,上限也低于从零训练的、允许专家真正专门化的 MoE。它更适合作为过渡形态和基线,而不是终点形态。

另一条相关但方向相反的路线是推理期的专家合并与结构化裁枝:不改训练,只在部署时把冗余专家合并成等效的更大 FFN,从而减少常驻显存、参与 all-to-all 的设备数、甚至直接退化成更接近稠密的推理形态。对延迟敏感但显存紧张的部署场景,这条路比重新训练便宜得多。

这两件事放在一起,勾出了 MoE 工程上的一条现实主线:MoE 的部署形态不应该是固定的,它是一条从稠密到稀疏、再按需退回稠密的连续谱。训练时用稀疏形态换取能力上限,部署时按实际负载和延迟预算选择稀疏度,中间任意一个环节都可以调整。把 MoE 当成一个需要长期维护的、形态可变的系统来对待,而不是一个固定的架构选型,往往是长期收益最大的做法。

小结:MoE 落到工程上要记住的几条

把这些内容压缩成可以直接用于决策的几条。稀疏激活的本质收益是容量与计算解耦,这让总参数量和每 token 计算量成为两个可以独立调节的旋钮,但它把系统问题的重心从算力搬到了通信和调度。路由器的实现细节比数学公式更影响成败:top-k 后必须重归一化、训练期加噪抑制 softmax 过早饱和、路由器不带 bias、容量按期望负载而非实际负载来定。容量因子是一笔保险费,开太大等于用算力掩盖路由问题、掩盖到超限就突然崩,开太小则 drop 比例上升、直接拖吞吐。

负载均衡损失是 MoE 里最著名的梯度冲突点:它在门控参数上施加的方向和主任务的专门化诉求相反,需要手工调系数且可复现性差。无梯度的偏置项均衡、z-loss、熵正则、节点受限路由是四条更"外科手术式"的替代路线,其中偏置法因为把"选谁"和"选中的权重"解耦,是目前自回归模型里最实用的一条。共享专家不是可选项:它把通用能力从路由专家的专门化压力里抽出来,提供一个永不坍缩的梯度汇聚点,代价是一份无法稀疏掉的稠密计算。混合粒度需要总参数量严格对齐才能看出收益,粒度过细会因为数据不足、通信碎片化、路由器变难而反噬。

推理侧最需要警惕的是路由的数值敏感性:路由器保持高精度、batch 大小改变路由分布、量化误差在 top-1 与 top-2 差距很小时被放大。把"同一段文本在不同 batch 下生成一致"写进回归测试,是成本最低、收益最高的一个防御措施。最后,判断一个项目该不该上 MoE,看的是五条:模型规模、互联条件、服务形态、运维能力、数据多样性。任何一条不满足,稠密模型都可能是更好的工程选择——这不是保守,这是把 MoE 的复杂度成本算进决策。

从公开配置里反推设计意图,也是一个值得掌握的读法。看到"大量路由专家配少量激活"的组合,可以判断它走的是细粒度路线,此时容量因子的余量一定很小,负载方差会天然偏高;看到"路由专家数不多、每个专家很大"的组合,则是粗粒度路线,对容量余量的要求更高;看到有共享专家,通常说明均衡压力较大、模型规模较大。把这些配置读一遍,你对这套 MoE 的均衡策略、并行切分和显存账就能有相当接近的判断,而不需要看到任何训练日志。

2026 年这个方向的共识已经相当清晰,可以概括成四句话:路由专家越来越细粒度,共享专家成为标准配置;均衡机制正从有梯度的辅助损失转向不污染主任务梯度的统计式修正;专家并行的通信优化从减少通信次数转向更精细的带宽匹配;推理侧的量化策略需要把路由器当作精度禁区来对待。这四句话背后是同一个判断——MoE 的主要矛盾已经从"能不能训练起来"转移到了"能不能高效、稳定、可预测地用起来"。对于正在做这个方向的团队,这句话基本决定了资源应该投在哪里。

最后回到最初那个判断:MoE 已经不是一种"更省的模型",而是另一种"更难的模型"。它用通信和调度的复杂度换来了容量与计算的解耦,这个交换在规模足够大、负载足够重、互联足够快的时候非常划算,在其他情况下则会让一个本可以简洁的方案变得脆弱。清楚地知道自己在交换什么,是做 MoE 这件事的前提。