跳到主要内容

面试题:MoE 为什么能用更少的激活参数换来更大的容量?专家路由怎么设计、负载不均衡怎么治、为什么 MoE 推理比同规模稠密模型更难?

· 阅读需 16 分钟

这是「每日一题」专栏的第十九篇。每天一道面试题,后端 + AI 混合路线,从原理到代码一次讲清。上一篇是 短链接系统进阶,间隔了一个月没更新,今天按轮换回到 AI/LLM 方向,聊近两年面试出现频率陡增的一题:混合专家(MoE)——为什么 671B 的模型只需要 37B 的激活参数就能跑起来,这条路到底省了什么、又难在哪。

一、题目与考点拆解​

先看面试官的原话通常是这么问的:

Mixtral 8x7B 有 47B 参数,但每个 token 只激活 13B;DeepSeek-V3 总参数 671B,激活 37B。既然每次只算一小部分,为什么不能说它「就是一个 13B / 37B 的模型」?MoE 的稀疏到底换来了什么?代价是什么?

这一题真正的考点有四个,逐个拆:

  1. 容量与计算量的解耦:模型「记得住多少」(参数容量)和「算得多累」(FLOPs)被拆成两个独立旋钮;
  2. 路由机制:token 怎么选专家,top-k、门控、capacity、dropping 各自的作用;
  3. 负载均衡:路由塌缩(所有 token 挤向少数专家)为什么必然发生,怎么治;
  4. 工程代价:训练时的 all-to-all 通信、推理时的显存墙与长尾延迟——为什么 MoE 不是「免费的午餐」。

前三题答得好只能算及格,第四题才是区分 P6 和 P7 的地方。

二、先讲清楚稀疏换来了什么​

2.1 稠密模型的死结​

标准 decoder-only Transformer 里,FFN(MLP 层)是参数大头。以 d_model = d、中间维度 4d(门控版 3 个矩阵按 LLaMA 的 SwiGLU 口径约 3 * d * d_ff,取 d_ff = 8/3 d 时约等于 8d²)计算,每层 FFN 参数量约:

  • Attention(QKV + O 四个 d × d):约 4d²
  • FFN(SwiGLU 三个矩阵):约 8d²

也就是说 FFN 吃掉了每层约 2/3 的参数。想让模型更「聪明」(更多参数、更强记忆容量),就只能把 FFN 加宽加深——而 FFN 的参数是每个 token 都要参与的,参数量和计算量线性绑定。想扩容就得等比多花钱,这条路在 2022 年之后就走到了成本墙。

2.2 MoE 的破局:把一个大 FFN 切成 N 个小 FFN​

MoE 的做法极其直白:把一层的 FFN 换成 N 个并行的「专家」FFN,再加一个轻量的路由网络(router / gate)。每个 token 只被送往其中 k 个专家(典型 k = 1~2,DeepSeek-V3 是 top-8)。

于是两个量被彻底分开了:

概念公式含义
总参数量P_total ≈ P_attn + N × P_expert决定显存占用和「模型容量」
激活参数量P_active ≈ P_attn + k × P_expert决定单 token 计算量(FLOPs)

关键点:MoE 省的是 FLOPs,不是显存。 这句话一定要说出来,很多候选人答到「省参数」就停了,直接暴露没部署过。

2.3 落到真实数字​

模型总参数激活参数稀疏度路由策略
Switch Transformer1.6T~?top-1单专家,容量因子 1.25
Mixtral 8x7B47B~13B8 选 2top-2
DeepSeek-V2236B21B160 选 6 + 2 共享top-6 + shared
DeepSeek-V3671B37B256 选 8 + 1 共享top-8 + shared,无辅助损失

看 Mixtral 这行:47B 的参数量、13B 的推理算力,效果却接近更大规模的稠密模型——这就是稀疏的红利。而 DeepSeek-V3 用 37B 的激活量打到接近稠密 70B+ 的效果,同时保持训练成本可控。

面试时补一句「容量和算力解耦」的定量直觉更加分:在固定 FLOPs 预算下,把稠密模型换成 MoE,通常能在等算力下拿到明显更低的 loss——这是 GLaM / Switch Transformer 时代的核心结论。

三、路由机制怎么设计​

3.1 最朴素的 top-k 门控​

Router 是一个只有 d × N 参数的小线性层(相对整个模型可以忽略不计):

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

class TopKGate(nn.Module):
"""最简门控:token 级 top-k 路由 + 权重归一化"""
def __init__(self, d_model, num_experts, top_k=2):
super().__init__()
self.num_experts = num_experts
self.top_k = top_k
self.gate = nn.Linear(d_model, num_experts, bias=False)

def forward(self, x): # x: [T, d]
logits = self.gate(x) # [T, N]
scores = F.softmax(logits, dim=-1) # 概率
topv, topi = scores.topk(self.top_k, dim=-1) # [T, k]
# 只保留被选中的专家权重并按和归一化
topv = topv / topv.sum(dim=-1, keepdim=True)
return topi, topv

然后每个 token 的输出是:

y = Σ_{i ∈ top-k} gate_i · Expert_i(x)

三个必须讲清的设计点:

  1. 为什么用 softmax 之后再取 top-k(而不是取 top-k 再 softmax):工程上两种都有人用,但主流实现(GShard 系)是先 softmax 再 top-k 再重归一化,这样门控权重天然非负且和为 1,数值更稳。
  2. top-1 还是 top-2:top-1 最省算力但训练不稳(Switch Transformer 靠精心调参和大容量因子压住了);top-2 让两个专家各贡献一半,梯度更平滑,是现在的主流。
  3. token 级 vs 序列级路由:token 级(逐 token 独立选)最灵活;但有些实现用句子级/序列级路由以提高一致性、降低路由抖动。

3.2 共享专家与细粒度专家(DeepSeek 的两个改进)​

这两点值得单独提,是近两年 MoE 设计里最常被追问的:

  • 共享专家(Shared Expert):留 1~2 个专家永远参与,所有 token 都过它。目的是把「通用知识」沉淀到共享专家里,避免每个专家都重复学一遍公共模式,同时缓解路由的随机性。
  • 细粒度专家(Fine-grained Expert Segmentation):不再用少量「大专家」,而是把专家切得更细(如 256 个),但激活更多个(top-8)。好处是同等的激活参数下,组合空间爆炸(C(256,8) 远超 C(8,2)),模型可以更精细地组合不同能力。

一句话总结:共享专家负责通用底座,细粒度路由负责专用能力。

3.3 Capacity Factor 与 Token Dropping(训练侧)​

训练时如果把专家当成「带缓冲区的队列」,一个专家能处理的 token 数上限是:

capacity = ceil( (tokens_per_batch / num_experts) × capacity_factor )
  • capacity_factor 通常取 1.25(Switch 论文口径);
  • 超出容量的 token 会被 drop(跳过该专家的计算,直接走残差);
  • drop 掉的 token 相当于这一层少算了,会造成信息损失和训练不稳。

这就引出一个根本矛盾:capacity 设小了丢 token,设大了显存/算力浪费且负载更不均衡。所以要治负载,而不是靠调 capacity 硬扛。

四、负载不均衡:MoE 最核心的坑​

4.1 为什么必然不均衡(路由塌缩)​

这不是调参问题,而是自我强化的系统效应:

  1. 初始时某几个专家恰好表现稍好 →
  2. 更多 token 路由到它们 → 它们被更多梯度更新,变得更强 →
  3. 于是更多 token 流向它们……最终少数专家吃掉绝大部分 token,其余专家几乎不训练。

专家的利用率可以画成一条长尾曲线,塌缩严重时几个专家承担 90% 的 token,其余专家基本是「僵尸参数」——总参数白白占了显存,容量优势全丢。

4.2 传统解法:辅助损失(Aux Loss)​

在训练 loss 里加一项,惩罚「专家负载不均」和「路由概率不均」:

def load_balancing_loss(gate_probs, expert_idx, num_experts):
"""
gate_probs: [T, N] softmax 后的路由概率
expert_idx: [T] 每个 token 的主专家
返回: 负载均衡辅助损失(越小越均衡)
"""
T = gate_probs.shape[0]
# f_i: 分给专家 i 的 token 比例
f = torch.bincount(expert_idx, minlength=num_experts).float() / T
# P_i: 专家 i 的平均路由概率
P = gate_probs.mean(dim=0)
# 两者的点积:既惩罚 token 数不均,也惩罚概率偏高
return num_experts * torch.sum(f * P)

要点:

  • 辅助损失系数要小心调(典型 0.01 量级),太大会让路由偏离语言建模目标,损害效果;
  • DeepSeek-V3 里还加了一个极小的序列级均衡损失(约 1e-4),只用来兜底防极端不均衡,其余交给下面的新方法。

4.3 新解法:Aux-loss-free 偏置调节(加分项)​

DeepSeek-V3 的做法很漂亮,面试官大概率会追问「除了辅助损失还有别的办法吗」:

  • 给每个专家维护一个可学习的偏置项 b_i,只加在「选谁」的比较分数上,不参与门控权重的计算;
  • 训练中按符号规则更新:某专家过载就 b_i -= γ,欠载就 b_i += γ(γ 是固定步长,如 1e-3);
  • 偏置只影响 top-k 的选择顺序,不影响最终加权系数,所以不干扰语言建模目标的梯度。

对比一下:

方案均衡效果好损害主目标调参成本
辅助损失中有(系数大时明显)系数敏感
容量因子 + dropping中丢 token依赖 capacity
偏置调节(aux-loss-free)好几乎无一个 γ

一句话回答:辅助损失是「用梯度逼路由均衡」,偏置调节是「在路由选择上做加法、把均衡和主任务解耦」。

4.4 专家并行(EP)与 all-to-all​

负载均衡不只是效果问题,还是性能问题:专家分布在不同 GPU 上,一个 token 被路由到别的卡的专家,就必须跨卡传输。

  • 训练时常见组合是 TP / EP / DP / PP 混合并行,其中 EP 通常把专家切到不同 rank;
  • 每层两次 all-to-all:一次把 token 发给目标专家所在的卡(dispatch),一次把结果收回来(combine);
  • 这是 MoE 训练的主要通信瓶颈,也是为什么 MoE 对**互联带宽(NVLink / IB)**极其敏感;
  • 如果负载不均衡,就是「少数卡通信排队、多数卡空转」,理论 FLOPs 没变,但端到端被最慢的卡拖死。

五、推理阶段:为什么 MoE 比同规模稠密模型更难​

这一节是很多候选人的盲区。训练省算力不代表推理省事,MoE 在推理侧有三堵墙。

5.1 显存墙(memory-bound)​

  • 计算量按激活参数走,但权重要全部常驻显存——671B 参数即使按 FP8 存也要约 671GB;
  • 结论就是:MoE 推理首先是显存问题,其次才是算力问题。DeepSeek-V3 官方 FP8 部署要 8 卡级别的机器;
  • 反过来看,MoE 在低并发、单请求场景下非常吃亏:显存要装 671B,但每步只算 37B,GPU 算力严重闲置,性价比远不如一个稠密的 37B 模型。

5.2 长尾延迟(tail latency)​

decode 阶段每步都要路由:

  • 同一个 batch 里不同 token 命中不同专家,单卡上专家负载天然不均;
  • 一两张卡成为热点 → 整个 batch 的迭代时间由它决定 → tail latency 拉高;
  • 这就是为什么 MoE 在大 batch / 高吞吐场景才划算:batch 越大,专家负载越平均,稀疏红利才被吃满;
  • 工程手段:expert 复制(热点专家多副本)、动态路由调度、PD 分离(prefill 与 decode 分池)。

5.3 量化与 router 精度​

MoE 对量化非常敏感,原因是误差会被路由放大:

  • expert 权重可以放心量化(FP8 / INT8 / 4-bit),因为它们是「计算主体」,量化误差平摊到大量累加上;
  • router 和门控权重必须保持高精度(FP32/BF16)。一旦 router 输出的排序因量化翻转,token 会被送到完全不同的专家,输出直接崩掉,而且是不可预测的崩;
  • 实践口径:量化专家,别量化路由。

5.4 KV Cache 视角的补充​

MoE 不改变 attention 的结构,所以 KV Cache 的显存占用与同层数的稠密模型一样。也就是说:MoE 靠稀疏省了权重计算,但长上下文场景下 KV Cache 依然按 token 数线性增长——这两笔账要分开算,别混在一起说。

六、一个能跑的 MoE 层(含容量与丢弃)​

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

class Expert(nn.Module):
def __init__(self, d_model, d_ff):
super().__init__()
self.w1 = nn.Linear(d_model, d_ff, bias=False)
self.w2 = nn.Linear(d_ff, d_model, bias=False)

def forward(self, x):
return self.w2(F.silu(self.w1(x)))

class MoELayer(nn.Module):
"""教学版 MoE:top-k 路由 + capacity factor + token dropping + 负载辅助损失"""
def __init__(self, d_model, d_ff, num_experts=8, top_k=2, capacity_factor=1.25):
super().__init__()
self.num_experts = num_experts
self.top_k = top_k
self.capacity_factor = capacity_factor
self.gate = nn.Linear(d_model, num_experts, bias=False)
self.experts = nn.ModuleList([Expert(d_model, d_ff) for _ in range(num_experts)])

def forward(self, x): # [T, d]
T, d = x.shape
logits = self.gate(x)
probs = F.softmax(logits, dim=-1)
topv, topi = probs.topk(self.top_k, dim=-1) # [T, k]
topv = topv / (topv.sum(-1, keepdim=True) + 1e-9)

capacity = int((T / self.num_experts) * self.capacity_factor)
capacity = max(capacity, self.top_k) # 至少留 k 个位置
# 每个专家维护一个计数器,超容量则丢弃(用 -1 标记)
counts = torch.zeros(self.num_experts, dtype=torch.long)
slot = torch.full_like(topi, -1)

for i in range(T):
for j in range(self.top_k):
e = int(topi[i, j])
if counts[e] < capacity:
slot[i, j] = counts[e]
counts[e] += 1

out = torch.zeros_like(x)
for e in range(self.num_experts):
mask = (topi == e) # [T, k]
tok_idx, k_idx = mask.nonzero(as_tuple=True)
if tok_idx.numel() == 0:
continue
keep = slot[tok_idx, k_idx] >= 0 # 保留未被丢弃的
if keep.sum() == 0:
continue
y = self.experts[e](x[tok_idx[keep]]) # 只算被保留的 token
w = topv[tok_idx[keep], k_idx[keep]].unsqueeze(-1)
out.index_add_(0, tok_idx[keep], y * w)

# 辅助损失:让分给各专家的 token 比例尽量均匀
dominant = topi[:, 0]
f = torch.bincount(dominant, minlength=self.num_experts).float() / T
P = probs.mean(dim=0)
aux = self.num_experts * torch.sum(f * P)
return out, aux

这个版本是所有主流框架(Megablocks、Tutel、DeepSpeed-MoE、vLLM 的 fused MoE)的简化骨架。真实实现把上面的 Python 循环换成一个大的 index_select + grouped GEMM,一次矩阵乘算完所有专家,否则性能会差一个数量级。

七、面试官的连环追问(附简答)​

Q1:MoE 是「模型更大」还是「模型更小」? 总参数按 N × P_expert 看是更大(容量更大),单 token 计算量按 k × P_expert 看是更小。它把两个维度解耦了,所以「更大还是更小」本身是个错误问题。

Q2:为什么 MoE 能比稠密模型省训练成本? 等 FLOPs 下 MoE 的有效容量更高、loss 更低;反向看,达到同等效果时 MoE 的总训练算力更省(注意总参数量并不省显存)。

Q3:top-1 和 top-2 怎么选? top-1 最省算力,训练不稳、对均衡机制依赖重;top-2 梯度更平滑、效果更稳,多花一份专家算力。工程默认 top-2,超大模型用更细粒度 + 更大的 k。

Q4:token dropping 为什么不好,能不能不丢? 丢弃会让部分 token 这一层的 FFN 完全失效,等于随机少了容量,还会让梯度信号变稀。所以出现了 dropless MoE(不设容量上限,用别的机制保均衡),代价是显存和通信峰值不可控。

Q5:为什么不干脆把专家做成跨卡共享 / 全放一张卡? 放一张卡受限于显存(671B 根本装不下);跨卡就必然 all-to-all 通信。这是 MoE 的本质权衡——用通信换计算。

Q6:MoE 的显存和稠密 671B 一样吗? 权重显存基本一样(都是 P_total),KV Cache 也基本一样(attention 没变)。省的是激活值和部分中间计算,不是权重。

Q7:MoE + LoRA 微调有什么坑? 只微调部分专家会导致路由漂移(其他专家在推理时被选中但没微调过);稳妥做法是同时微调 router 或给所有专家加 LoRA,或冻结路由、只动指定专家并保持负载均衡。

八、一页速记​

维度稠密模型MoE
参数与算力线性绑定解耦(P_total vs P_active)
省的是什么——省 FLOPs,不省权重显存
核心机制——Router + top-k + capacity
必修课——负载均衡(aux loss / 偏置调节)
训练瓶颈通信(TP/DP)all-to-all(EP)
推理瓶颈算力/带宽显存墙 + 长尾延迟
量化全层可量化专家可量化,router 必须高精度
最佳场景低并发单请求大 batch 高吞吐

一句话收尾:MoE 是用「通信复杂度 + 工程复杂度 + 显存占用」去换「等算力下的模型容量」——它不是免费的,只是这笔账在超大规模训练里划得来。

九、下一篇预告​

按轮换,下一篇回到 后端/分布式 方向。预告一题:分库分表之后的在线扩容——不停机把 4 库扩到 8 库,双写、影子表、数据校验与回滚怎么做? 数据迁移期间的读写一致性、增量补偿、灰度切流,是这套题的主线。


相关阅读