Skip to content

约 14 分钟阅读

10. 混合专家 MoE:总参数与激活参数 ​

上一篇讲到,现代 decoder-only 模型的每一层都是“注意力 + FFN”,Llama-3.1-8B 有七成参数在 FFN 里。想让模型知道得更多,最直接的办法是把 FFN 做大,可每个 token 都要经过完整的 FFN,参数翻倍,每个 token 的计算也跟着翻倍。资料助手如果换成一个 70B 的稠密模型,回答“年假怎么申请?”的每个字都要多花近 9 倍的计算。

混合专家(Mixture of Experts,MoE)换了个思路:把一层的 FFN 拆成很多份,每个 token 只挑其中几份来算。这一篇回答几个问题:路由器怎样挑、为什么 DeepSeek-V3 总共 6710 亿参数,每个 token 只用 370 亿、专家用得不均匀会出什么事、怎样把负载拉平,以及这种设计在推理时把成本转移到了哪里。读完你应该能拿到一个 MoE 模型的 config.json,算出它的总参数和激活参数,并判断它属于哪一类 MoE 设计。

把一个大 FFN 换成一组小 FFN ​

先看一层里具体变了什么。稠密模型里,“年假”这个 token 的向量 [4096] 进入 FFN,经过 gate/up 两个 4096 → 14336 的投影、SwiGLU 激活和 14336 → 4096 的 down 投影(见 第 05 篇),出来仍是 [4096],加回残差流。

Mixtral-8x7B 把这一个 FFN 换成 8 个结构完全相同的 FFN,叫作专家(expert)。每个专家的大小和 Llama-3.1-8B 的 FFN 一样,都是 3 × 4096 × 14336 = 176,160,768 个参数。前面多一个路由器(router,也叫门控网络):一个 4096 → 8 的线性层,为 8 个专家各打一个分。分数最高的 2 个专家处理这个 token,结果按权重相加。

稠密模型每个 token 都经过同一个 FFN;MoE 层先由路由器打分,只让得分最高的 2 个专家计算,再加权求和,部分模型还有一个所有 token 都经过的共享专家

图里有三点要注意。第一,MoE 只改 FFN,注意力、归一化、残差连接都不变,输入输出形状仍是 [序列长度, 4096]。第二,“选哪几个专家”是每个 token、每一层分别决定的:同一段输入里“年假”和“申请”可能去不同的专家,同一个“年假”在第 3 层和第 20 层也可能去不同的专家。第三,没被选中的 6 个专家这一步不参与计算,但它们的权重仍在显存里,下一个 token 可能就要用。

对应 Transformers 里的 MixtralSparseMoeBlock,简化后是这样:

python
# x: [T, 4096],T 是这一批的 token 数
logits = x @ W_router.T                    # [T, 8]
probs = softmax(logits, dim=-1)
w, idx = probs.topk(2, dim=-1)             # 每个 token 选 2 个专家
w = w / w.sum(-1, keepdim=True)            # 两个权重重新归一化到和为 1
out = zeros_like(x)
for e in range(8):                         # 按专家分组计算
    rows, slot = (idx == e).nonzero(as_tuple=True)
    out[rows] += w[rows, slot, None] * expert[e](x[rows])   # expert[e] 是一个 SwiGLU FFN

真实实现不会逐个专家写循环,而是把分到同一专家的 token 收集起来做一次批量矩阵乘法(grouped GEMM),但逻辑相同。

路由器怎样选专家:top-k 与两种门控 ​

路由器的输出要回答两件事:选哪几个专家,以及选中的专家各占多少权重。用一组可以手算的小数字看。假设只有 4 个专家,“年假”的向量经过路由器得到 4 个原始分数(logits)[2.0, 1.0, 0.5, -1.0],每个 token 选 2 个(top-2)。

text
softmax 门控(Mixtral、Qwen3、Qwen3.5)
  pᵢ = exp(zᵢ) / Σⱼ exp(zⱼ)
  [2.0, 1.0, 0.5, -1.0] → [0.609, 0.224, 0.136, 0.030]
  选最大的 E1、E2,再在两者之间归一化:0.609/(0.609+0.224) = 0.731,另一个 0.269
  输出 = 0.731 × E1(x) + 0.269 × E2(x)

sigmoid 门控(DeepSeek-V3、Kimi Linear)
  sᵢ = 1 / (1 + exp(−zᵢ)),每个专家单独打分,彼此不竞争
  [2.0, 1.0, 0.5, -1.0] → [0.881, 0.731, 0.622, 0.269]
  选 E1、E2,归一化:0.546、0.454;DeepSeek-V3 再乘 routed_scaling_factor 2.5 → 1.366、1.134

z 是路由器的原始分数,p 和 s 是两种换算后的分数。两种门控选出的专家一样,差别在分数的含义。softmax 把 4 个分数放在一起竞争,和为 1,一个专家分数高,其他专家就被压低;分数差 1,概率就差 e 倍,容易出现“一家独大”。sigmoid 让每个专家独立给出 0 到 1 之间的“合适程度”,专家数从 8 增加到 256 时,单个专家的分数也不会因为分母变大而被摊薄。归一化之后,两种方式都保证选中专家的权重之和固定(softmax 为 1,DeepSeek-V3 为 2.5),输出的数值尺度不随选中哪几个专家而大幅波动。

top-k 里的 k 是 config.json 里的 num_experts_per_tok:Mixtral 是 2,DeepSeek-V3 和 Qwen3-235B-A22B 是 8,Qwen3.5-397B-A17B 是 10。k 越大,每个 token 的计算越多,组合也越灵活。

总参数和激活参数怎样算 ​

总参数是模型存了多少个数,决定要准备多少显存;激活参数是处理一个 token 时实际参与矩阵乘法的参数,决定每个 token 的计算量。稠密模型两者相等,MoE 模型两者相差几倍到二十几倍。

先算 Mixtral-8x7B(hidden_size 4096、32 层、32 个 Q 头、8 个 KV 头、8 个专家选 2、词表 32000):

text
每层注意力:q 4096×4096 + k 4096×1024 + v 4096×1024 + o 4096×4096 = 41,943,040
每个专家:3 × 4096 × 14336 = 176,160,768
每层路由器:8 × 4096 = 32,768;每层两个 RMSNorm:8,192
词嵌入、输出层各 32000 × 4096 = 131,072,000;最终 norm 4,096

总参数  = 32 × (41,943,040 + 8 × 176,160,768 + 32,768 + 8,192) + 2 × 131,072,000 + 4,096
        = 46,702,792,704
激活参数 = 32 × (41,943,040 + 2 × 176,160,768 + 32,768 + 8,192) + 2 × 131,072,000 + 4,096
        = 12,879,925,248

总参数 467 亿,和官方 checkpoint 的权重文件逐字节换算出来的数完全一致;激活 129 亿,约为总参数的 27.6%。名字里的“8x7B”容易让人以为是 56B,实际上 8 个专家共用同一套注意力和词嵌入,只有 FFN 是 8 份。

DeepSeek-V3 的配置更复杂:前 3 层(first_k_dense_replace 3)是普通稠密 FFN,其余 58 层是 MoE,每层 256 个路由专家选 8 个,外加 1 个共享专家;每个专家的中间维度只有 2048(moe_intermediate_size),注意力用 MLA(见 第 04 篇)。按同样方法逐项代入:

text
每层 MLA 注意力:187,107,328,61 层合计 11,413,547,008
每个专家:3 × 7168 × 2048 = 44,040,192
前 3 层稠密 FFN:3 × (3 × 7168 × 18432) = 1,189,085,184
58 层 × 256 个路由专家 = 653,908,770,816(占总参数 97.4%)
58 层 × 每 token 选 8 个 = 20,434,649,088;58 层共享专家 = 2,554,331,136
词嵌入、输出层各 129280 × 7168 = 926,679,040

总参数  ≈ 671,026,404,352(6710 亿,不含 MTP 模块)
激活参数 ≈ 37,552,282,624(375 亿,含词嵌入和输出层;不含则 357 亿)

这就是“6710 亿为什么每个 token 只用 370 亿”的答案:97% 的参数在路由专家里,每个 token 每层只碰 256 个里的 8 个。另外约 140 亿参数的 MTP 模块(多 token 预测,第 15 篇)在官方口径里单独计。

四个 MoE 模型的总参数和激活参数条形图,激活比例从 Mixtral 的 27.6% 降到 Qwen3.5 的 4.4%

四个模型的配置放在一起看,能看出一条清楚的演进线:

模型发布每层专家每 token 选共享专家单个专家参数总参数激活参数
Mixtral-8x7B2023-1282无1.76 亿46.7B12.9B
DeepSeek-V32024-12256814404 万671.0B37.6B
Qwen3-235B-A22B2025-041288无1887 万235.1B22.2B
Qwen3.5-397B-A17B2026-025121011258 万396.3B17.3B

Qwen3-235B 的数字同样和权重文件完全对上;Qwen3.5 那行只算文本部分,不含视觉编码器和 MTP 模块,所以比权重文件里的 4034 亿少一些。专家越来越多、越来越小,激活比例从四分之一降到二十分之一左右。为什么往这个方向走,要先看 MoE 最大的麻烦:负载不均。

负载不均:路由坍缩与丢 token ​

路由器是和模型一起训练的。训练初期某几个专家碰巧表现好一点,路由器就更多地把 token 发给它们;它们收到的 token 多、梯度多,学得更快,下次更容易被选中。Shazeer 等人 2017 年提出稀疏门控 MoE 时就指出,这种不平衡会自我强化,门控网络会收敛到总是给同几个专家大权重的状态。极端情况下只剩一两个专家在干活,其余专家几乎收不到 token,MoE 退化成一个小得多的稠密模型,这叫路由坍缩(routing collapse)。

负载不均在训练和推理时还会带来一个硬件问题。多卡训练时,每个专家能处理的 token 数通常有上限,叫专家容量(expert capacity):

text
容量 = 每批 token 数 / 专家数 × 容量因子

容量因子(capacity factor)是留给不均匀的余量,1.0 表示刚好平均分,1.25 表示多留 25%。超出容量的 token 怎么办?Switch Transformer 的做法是直接跳过这一层的 FFN,只沿残差连接传到下一层,这叫丢 token(token dropping)。

8 个 token 中有 5 个选了 E1,容量因子 1.0 时 E1 只能收 2 个,3 个 token 被丢弃;容量因子 1.25 时仍丢 2 个

图里 8 个 token 分给 4 个专家,每个 token 选 1 个。容量因子 1.0 时每个专家收 2 个,偏偏有 5 个 token 都想去 E1,t3、t5、t7 只好被丢弃;容量放宽到 1.25(2 × 1.25 = 2.5,这里向上取整为每个专家 3 个)仍丢 2 个;要一个都不丢,E1 得收 5 个,相当于容量因子 2.5,其他专家的槽位就白白空着。被丢的 token 在这一层等于什么都没做。训练时这会损失学习信号;推理时更麻烦,同一个问题的结果会受同一批里其他请求影响,难以复现。

所以 MoE 的负载问题有两层:模型层面要防止坍缩,让每个专家都学到东西;系统层面要让每张卡分到的 token 数大致相等。下面三种办法各管一部分。

怎样看出路由出了问题:训练时记录每层每个专家收到的 token 比例,健康的分布大致平坦,坍缩时会看到少数专家占去大半、一批专家长期接近 0;辅助损失(下一节)的数值长期明显高于理论最小值 1,也是信号。推理时如果专家并行的某几张卡利用率持续高于其他卡,多半是热门专家集中在那里。

辅助损失:在训练目标里加一项“别偏心” ​

最常用的办法是在语言模型的损失之外,加一个辅助损失(auxiliary loss),专门惩罚不均匀。Switch Transformer 的公式至今仍是主流实现(Transformers 里的 load_balancing_loss_func 就是它):

text
L_aux = α × N × Σᵢ fᵢ × Pᵢ

N   专家数
fᵢ  这一批 token 里被分给专家 i 的比例(数出来的,不可导)
Pᵢ  这一批 token 给专家 i 的平均路由概率(可导,梯度从这里回传)
α   系数:Switch Transformer 用 0.01,Mixtral 的 config 里是 0.02,Qwen3 是 0.001

还是 4 个专家,代入三种情况(只看 N × Σ fᵢPᵢ 这部分):

text
完全均匀:f = P = [0.25, 0.25, 0.25, 0.25]         → 4 × 4 × 0.25 × 0.25 = 1.0
偏向 E1:f = [5/8, 1/8, 1/8, 1/8],P = [0.55, 0.15, 0.15, 0.15] → 1.6
完全坍缩:f = P = [1, 0, 0, 0]                      → 4.0

越偏心,这一项越大,梯度会把路由器往“少给热门专家打高分”的方向推。

辅助损失的代价是它和语言模型目标在“抢”梯度。系数太小压不住坍缩,太大则路由器为了均匀而把 token 发给不合适的专家,模型质量下降。Wang 等人 2024 年的 Loss-Free Balancing 论文(DeepSeek-V3 偏置方法的来源)专门讨论了这一点:较大的辅助损失会引入干扰训练的梯度,降低模型效果。Qwen3 的折中是把 f 和 P 的统计范围从单个小批次放大到全局批次(global-batch load balancing),只要求整体均匀,允许某段代码文本集中用某几个专家,给专家分工留出空间。

DeepSeek-V3 的无辅助损失偏置 ​

DeepSeek-V3 换了个思路:不改损失函数,直接在“选专家”这一步加一个可调的偏置。每个专家 i 有一个偏置 bᵢ,选专家时用 sᵢ + bᵢ 排序,但算权重时仍用原始分数 sᵢ。每个训练步结束后,看各专家这一步收到多少 token:超载的专家 bᵢ 减 γ,不足的加 γ。偏置不参与梯度计算,语言模型的损失里没有任何“均衡项”。

用 8 个 token、4 个专家、每个 token 选 1 个、γ = 0.05 模拟几步(每步路由分数相同,只有偏置在变):

text
第 1 步  偏置 [ 0.00, 0.00, 0.00, 0.00]  负载 [7, 1, 0, 0]
第 2 步  偏置 [-0.05, 0.05, 0.05, 0.05]  负载 [3, 3, 1, 1]
第 3 步  偏置 [-0.10, 0.00, 0.10, 0.10]  负载 [3, 2, 2, 1]

E1 一开始几乎包揽所有 token,偏置一降,原本以微弱差距选它的 token 就转去了第二合适的专家;而 0.95、0.90 这种明显偏好 E1 的 token 仍然留在 E1。门控权重始终用原始分数,被“劝走”的 token 拿到的是它对新专家的真实打分。再往后几步,负载会在均衡点附近来回摆动,γ 越大调得越快、摆得也越厉害。DeepSeek-V3 的技术报告给出的设置是 γ = 0.001,训练最后 5000 亿 token 时设为 0;另外保留一个系数只有 0.0001 的序列级平衡损失,防止单条序列内部极端偏斜。

源码里能直接看到这套设计。config.json 的 topk_method 是 noaux_tc,DeepseekV3TopkRouter 里有一个叫 e_score_correction_bias 的缓冲区(不是可训练参数),选择用 scores + e_score_correction_bias,权重用 scores.gather(...) 取回原始 sigmoid 分数。还有一层分组:256 个专家分成 8 组(n_group 8),先按每组最高两个分数之和选出 4 组(topk_group 4),再在这 4 组里选 8 个专家。这对应报告里的节点受限路由:训练时 256 个专家分布在 8 个节点上,一个 token 最多发往 4 个节点,跨节点通信量有上限。靠这两层控制,DeepSeek-V3 在训练和推理中都不丢 token。

细粒度专家与共享专家 ​

回到前面那条演进线:专家为什么越切越细?DeepSeekMoE(2024 年 1 月)给出了两个理由,也提出了两种对应的结构。

细粒度专家(fine-grained experts):把每个专家切小,数量相应增加,激活的个数也成比例增加,总参数和激活参数都不变。好处是组合数暴涨。16 个专家选 2 个只有 120 种组合;切成 64 个、选 8 个,计算量相同,组合有 4,426,165,368 种。Mixtral 的 8 选 2 只有 28 种,DeepSeek-V3 的 256 选 8 约 4.1 × 10¹⁴ 种。组合越多,每个专家可以只负责更窄的一类模式,再由路由器拼出 token 需要的那一套,而不是让一个大专家同时记住很多不相干的东西。

共享专家(shared expert):留一个或几个专家给所有 token 必经,不参与路由。语言里有大量所有 token 都需要的通用处理(基本语法、常见搭配),如果每个路由专家都各学一份,就是重复存储。共享专家把这部分集中起来,路由专家专心学有区分度的部分。DeepSeek-V3 有 1 个共享专家,输出直接加到路由专家的结果上;Qwen3.5 也有 1 个,但先乘一个由当前 token 决定的 sigmoid 门控(源码里的 shared_expert_gate),让模型自己决定这个 token 要多少通用处理。

这两种结构不是必选项。Qwen3 的技术报告明确说 Qwen3-MoE 去掉了 Qwen2.5-MoE 用过的共享专家,改用前面提到的全局批次均衡损失;到 Qwen3.5 又加了回来。共享专家的利弊目前没有一边倒的结论,各家仍在根据自己的实验取舍。另一个常见选择是前几层不用 MoE:DeepSeek-V3 前 3 层、Kimi Linear 第 1 层是稠密 FFN,浅层的表示还比较“通用”,按 token 分流的收益相对小,这几层留给稠密 FFN 的参数也不多。

专家真的按领域分工吗 ​

“专家”这个名字容易让人想象成“法律专家”“数学专家”。资料助手问年假,是不是会被送到一个“人事制度专家”那里?

现有证据不支持这种想象。Mixtral 论文专门做过路由分析:在 arXiv 论文、PubMed 医学摘要、PhilPapers 哲学文本上,各层专家的分配比例非常相似,没有看到按主题分工的明显模式,只有合成的 DM Mathematics 数据集略有不同。能看出来的规律更接近句法和格式:Python 代码里的 self、缩进 token 总是被送到固定的专家;高层里相邻 token 被分到同一专家的比例明显高于随机。这类分析是对已训练模型的观察,不同模型、不同层的结论可能不一样,细粒度专家的模型里分工可能更明显,但目前没有充分证据说某个专家对应某个业务领域。MoE 的路由依据是内部表示,不能拿来当可解释的分类器用。

相邻 token 爱去同一专家还有一个工程后果:一段长文本的连续 token 会集中打到少数几个专家上,专家并行时这些专家所在的卡就成了热点,Mixtral 论文自己也指出了这一点。

推理时:显存按总参数,计算按激活参数 ​

把前面的参数账搬到推理上,MoE 的取舍就很清楚了:

  • 显存要装下全部专家。 Mixtral-8x7B 按 BF16 存是 93.4 GB(87.0 GiB),一张 80 GB 的卡放不下,虽然它每个 token 只用 129 亿参数。Qwen3-235B-A22B 按 BF16 是 470 GB;DeepSeek-V3 官方发布的是 FP8 权重,每个参数约 1 字节,也要 671 GB 左右,必须多卡。
  • 单个 token 的计算按激活参数算。 DeepSeek-V3 每个 token 前向约 2 × 375 亿 ≈ 75 GFLOPs,和一个 37B 左右的稠密模型相当,远低于 671B 稠密模型的 1.3 TFLOPs。
  • 批量一大,几乎所有专家都会被用到。 Decode 时一次处理一批请求,每个请求的当前 token 各选各的专家。假设路由均匀且相互独立,DeepSeek-V3 一层 256 选 8,批里有 8 个 token 时约 22% 的专家被用到,32 个时约 64%,64 个时约 87%。也就是说,小批量时 MoE 每步只需从显存读一小部分专家权重,速度接近同等激活参数的稠密模型;批量变大后要读的权重接近全部,Decode 的访存压力回到总参数的量级,只是计算仍按激活参数算。

这些特点决定了 MoE 的部署方式:权重太大要多卡,每个 token 又只去少数专家,于是产生了专家并行(expert parallelism):不同专家放在不同卡上,每层先把 token 按路由结果分发到专家所在的卡,算完再收回来,分发和收回都是所有卡之间互相交换数据的 all-to-all 通信。热门专家所在的卡会拖慢整层,引擎会复制热门专家或动态调整放置。具体做法在推理系列的 分布式推理 里展开,显存怎样分配见 显存账本。

对资料助手来说,选 MoE 还是稠密模型主要看部署条件。并发高、有多卡的服务端,MoE 用较少的每 token 计算换来更大的模型容量,划算;只有一张卡、一次只服务一个人的本地部署,显存先被总参数卡住,同样显存能放下的稠密模型往往更合适。

MoE 把 FFN 拆成多个专家,路由器为每个 token 在每层挑出 k 个,总参数决定存储,激活参数决定计算。路由器用 softmax 或 sigmoid 打分,再对选中的专家归一化。它最大的风险是负载不均:坍缩让专家闲置,容量上限会丢 token;辅助损失、全局批次统计和 DeepSeek-V3 的偏置调整分别从不同角度拉平负载。细粒度专家和共享专家让专家越切越细,激活比例越来越低。MoE 解决的是“参数多、计算少”,没有解决上下文变长时注意力越来越贵的问题。下一篇换到注意力这一侧:长上下文与高效注意力,看把整本员工手册放进上下文时成本花在哪里,以及滑动窗口和稀疏注意力怎样省下这部分。

别急,先让缓存热一下。