MoE 的原理是什么?
MoE(Mixture of Experts,混合专家)是一种稀疏激活架构。
它的基本做法是:
把 Transformer 中原本的单一 FFN 替换为多个并行的专家网络(Experts),再引入一个路由器(Router)决定每个 token 应该由哪些专家处理。
每个 token 通常只激活少量专家(Top-1 或 Top-2),其余专家不参与计算。
公式上:
MoE(x)=∑i∈S(x)gi(x)⋅Experti(x) MoE(x) = \sum_{i \in S(x)} g_i(x) \cdot Expert_i(x) MoE(x)=i∈S(x)∑gi(x)⋅Experti(x)
其中:
- S(x)S(x)S(x) 是路由器为 token xxx 选出的专家集合,大小 k≪Ek \ll Ek≪E;
- gi(x)g_i(x)gi(x) 是路由权重,通常对选中的 kkk 个专家做 Softmax 归一化;
- 未选中的专家权重为 0,完全不参与计算。
核心特点:
- 参数多、计算少 :整个模型有 EEE 个专家,参数量巨大;但每次前向只激活 kkk 个,实际 FLOPs 与稠密模型相当甚至更低。
- 稀疏激活:通过路由实现条件计算,不同 token 走不同专家。
- 适用场景:参数规模大、数据和算力充足时优势明显;小规模或资源受限时可能不如稠密模型。
直观比喻:MoE 像一个拥有大量书籍的图书馆,读者(token)不需要翻遍所有书架,而是由管理员(路由器)指引,只去相关书架(专家)找书。
TC 与 EC 路由机制是什么?各有什么缺陷?
TC(Token Choice,词元选择专家)
核心思路:每个 token 主动选择自己最合适的 Top-K 个专家。
路由得分矩阵形状为 B,EB, EB,E(BBB 个 token,EEE 个专家)。TC 对每一行(token)在列维度(专家)上做 Top-K。
优点:
- 每个 token 都会被分配到至少一个专家,语义完整性高,信息丢失风险小。
缺点:
- 负载不均衡:少数"热门"专家会处理绝大部分 token,得到充分训练;大量"冷门"专家长期闲置,能力停滞,甚至出现"死亡专家"。
- 模型容易"偏科":高频领域表现好,低频领域能力不足。
EC(Expert Choice,专家选择词元)
核心思路:每个专家主动从所有 token 中挑选自己最想处理的 Top-K 个 token。
把路由得分矩阵转置为 E,BE, BE,B,EC 对每一行(专家)在列维度(token)上做 Top-K。
优点:
- 天然负载均衡 :每个专家最多处理 kkk 个 token,处理量有上限,避免热门专家过载。
缺点:
- token 可能被丢弃:如果某个 token 没有被任何专家选中,它就不会被任何专家处理,导致语义信息缺失,模型可能"断章取义"。
案例:TC 的负载不均衡 vs EC 的 token 丢失
设:
text
4 个 token:t0, t1, t2, t3
3 个专家:E0, E1, E2
K = 1(每个 token 选 1 个专家,或每个专家选 1 个 token)
路由得分矩阵 4,34, 34,3:
| token | E0 | E1 | E2 |
|---|---|---|---|
| t0 | 0.6 | 0.3 | 0.1 |
| t1 | 0.7 | 0.2 | 0.1 |
| t2 | 0.2 | 0.5 | 0.3 |
| t3 | 0.1 | 0.3 | 0.6 |
TC 模式:每个 token 选 Top-1 专家
- t0 → E0 (0.6 最大)
- t1 → E0 (0.7 最大)
- t2 → E1 (0.5 最大)
- t3 → E2 (0.6 最大)
专家负载:
| 专家 | 处理 token 数 | 处理的 token |
|---|---|---|
| E0 | 2 | t0, t1 |
| E1 | 1 | t2 |
| E2 | 1 | t3 |
问题: E0 处理了 2 个 token,E1、E2 各 1 个,负载不均衡。如果 token 更多、得分更集中,热门专家可能处理绝大多数 token,冷门专家闲置。
EC 模式:每个专家选 Top-1 token
把得分矩阵转置,得到每个专家对所有 token 的分数:
| 专家 | t0 | t1 | t2 | t3 |
|---|---|---|---|---|
| E0 | 0.6 | 0.7 | 0.2 | 0.1 |
| E1 | 0.3 | 0.2 | 0.5 | 0.3 |
| E2 | 0.1 | 0.1 | 0.3 | 0.6 |
每个专家选 Top-1:
- E0 → t1 (0.7 最大)
- E1 → t2 (0.5 最大)
- E2 → t3 (0.6 最大)
专家负载:
| 专家 | 处理 token 数 | 处理的 token |
|---|---|---|
| E0 | 1 | t1 |
| E1 | 1 | t2 |
| E2 | 1 | t3 |
问题:负载完全均衡,但 t0 没有被任何专家选中,它的信息被完全丢弃。这就是 EC 的 token dropout 风险。
对比总结
| 模式 | 负载分布 | token 覆盖 | 主要风险 |
|---|---|---|---|
| TC | 不均衡 (E0=2, E1=1, E2=1) | 所有 token 都被处理 | 热门专家过载,冷门专家闲置 |
| EC | 均衡 (各 1 个) | t0 被丢弃 | 部分 token 语义丢失 |
LSH-MoE 是什么?有什么缺陷?
LSH-MoE 的核心
LSN(Locality-Sensitive Hashing,局部敏感哈希)路由不使用可学习的路由器 ,而是用固定哈希函数把 token 映射到专家。
典型做法(随机超平面符号哈希):
- 生成 nhashesn_{\text{hashes}}nhashes 个随机向量 r1,...,rnhashesr_1, \dots, r_{n_{\text{hashes}}}r1,...,rnhashes;
- 对 token 嵌入 xxx,计算投影 x⋅rix \cdot r_ix⋅ri;
- 取符号:正数为 1,负数为 0;
- 把符号位编码成整数 hash;
- 对专家数取模,得到专家 ID:
expert_id=hash(x) mod E \text{expert\_id} = \text{hash}(x) \bmod E expert_id=hash(x)modE
案例
设:
text
d_model = 2
n_hashes = 2
num_experts = 3
随机向量:r0 = [1, 0], r1 = [0, 1]
4 个 token 嵌入:t0=[1,1], t1=[-1,1], t2=[1,-1], t3=[-1,-1]
步骤 1:投影
| token | x⋅r0x \cdot r_0x⋅r0 | x⋅r1x \cdot r_1x⋅r1 |
|---|---|---|
| t0=1,1 | 1 | 1 |
| t1=-1,1 | -1 | 1 |
| t2=1,-1 | 1 | -1 |
| t3=-1,-1 | -1 | -1 |
步骤 2:取符号(正数为 1,负数为 0)
| token | sign0 | sign1 |
|---|---|---|
| t0 | 1 | 1 |
| t1 | 0 | 1 |
| t2 | 1 | 0 |
| t3 | 0 | 0 |
步骤 3:编码 hash
权重为 1,21, 21,2(因为 1 << arange(2) = 1, 2):
hash=sign0×1+sign1×2 \text{hash} = \text{sign0} \times 1 + \text{sign1} \times 2 hash=sign0×1+sign1×2
| token | hash |
|---|---|
| t0 | 1×1+1×2=31 \times 1 + 1 \times 2 = 31×1+1×2=3 |
| t1 | 0×1+1×2=20 \times 1 + 1 \times 2 = 20×1+1×2=2 |
| t2 | 1×1+0×2=11 \times 1 + 0 \times 2 = 11×1+0×2=1 |
| t3 | 0×1+0×2=00 \times 1 + 0 \times 2 = 00×1+0×2=0 |
步骤 4:取模得到专家 ID
expert_id=hash mod 3 \text{expert\_id} = \text{hash} \bmod 3 expert_id=hashmod3
| token | hash | expert_id |
|---|---|---|
| t0 | 3 | 0 |
| t1 | 2 | 2 |
| t2 | 1 | 1 |
| t3 | 0 | 0 |
负载统计:
| 专家 | 处理 token |
|---|---|
| E0 | t0, t3 |
| E1 | t2 |
| E2 | t1 |
E0 处理 2 个,E1、E2 各 1 个。LSH 是概率性均衡,不保证绝对均匀,但比 TC 的极端倾斜好很多。
注意:LSH 的路由结果会随 embedding 训练而变化(因为投影基于 embedding),但哈希函数本身不通过梯度优化。
负载均衡在 MoE 中的作用是什么?
负载均衡的核心目标是:防止少数专家过载、多数专家闲置,避免"死亡专家",让所有专家都能得到充分训练。
常见方法:
| 策略 | 核心思路 | 说明 |
|---|---|---|
| 辅助负载均衡损失 | 在训练 loss 中加正则项,鼓励专家接收的 token 量、路由概率趋于均匀 | Switch Transformer 等采用,最早最经典 |
| 容量控制 + overflow | 给每个专家设容量上限,超出的 token 走 fallback 或 dropout | 防止单个专家过载,也避免忽略冷门专家 |
| 动态无辅助损失均衡 | 根据过去负载统计给每个专家加 bias,动态调整路由分数 | Loss-Free Balancing for MoE 提出,不破坏原优化目标 |
| 改善路由器、相似度保持路由 | 让相似语义的 token 分配到相似专家,均匀分布 | 提升收敛速度和负载均衡 |
| 混合共享 + 路由专家池 | 部分专家设为共享专家,所有 token 都激活;其余为路由专家 | DeepSeekMoE 采用,保证语义覆盖 + 专家特长训练 |
为什么负载均衡重要?
- 如果某些专家长期不被激活,它们的参数得不到梯度更新,能力停滞,甚至"死亡";
- 热门专家过载,训练效率下降,且模型过度依赖少数专家,泛化能力受限;
- 负载均衡让所有专家都参与训练,提升模型整体容量利用率。
MoE 代码
TC_MoE 代码
python
class TC_MoE(nn.Module):
def __init__(self, dim, num_experts, k):
super().__init__()
# 设置专家数量
self.num_experts = num_experts
# 设置每个token选用的专家数量
self.k = k
# 路由器:将输入映射到专家特征空间
self.router = nn.Linear(dim, num_experts)
# 创建专家模块列表(每个专家是独立的)
self.experts = nn.ModuleList([Expert(dim) for _ in range(num_experts)])
def forward(self, x, tokens=None, verbose=False):
# 获取批量大小和特征维度
B, D = x.shape
# 计算每个专家对每个token的分数,使用softmax得到概率分布
gate_scores = F.softmax(self.router(x), dim=-1) # gate_scores: [B, E]
# token选取分数最高的k个专家及其分数
# topk_scores: [B, k](对应被选中的专家概率值)
# topk_idx: [B, k](对应被选中的专家索引)
topk_scores, topk_idx = gate_scores.topk(self.k, dim=-1)
# 初始化输出张量与输入同形状
out = torch.zeros_like(x)
# 每个token对应的每一个top-k位置单独处理(同一个token可能被不同专家处理)
for i in range(self.k):
# B表示处理的token总数
# expert_ids表示每个token在第i个Top-K选择的专家编号,形状:[B]
expert_ids = topk_idx[:, i]
# expert_weight表示第每个token在第i个Top-K选择上专家所占的权重,形状:[B]
expert_weight = topk_scores[:, i]
# 用于累加当前第i个选择位置上所有专家的输出
expert_output = torch.zeros_like(x)
# 遍历所有专家,让对应专家处理被分配给它的token
# e_id表示对应Top-K专家处理的token索引值
for e_id, expert in enumerate(self.experts):
# 创建掩码当token在第i个选择位置的专家索引等于当前专家e_id时为1,否则为0
# mask形状为[B, 1],用于在计算专家网络前把不属于该专家的token置0
mask = (expert_ids == e_id).float().unsqueeze(1)
# mask.sum()表示属于该专家的token数量;若为0表示该专家在本轮没有任务
if mask.sum() == 0:
continue
# 只把属于该专家的token送入该专家的前馈网络
# 注意:这里采用x * mask的方式,能保持张量形状一致并保留反向传播路径
expert_output += expert(x * mask)
# 将第i个选择位置上专家的输出按对应权重加权并累加到最终out
# expert_weight.unsqueeze(1)变为[B, 1]以便广播乘到[B, D]
out += expert_output * expert_weight.unsqueeze(1)
# out:每个token在Top-K专家上的加权聚合的向量表示
return out
功能说明:
router(x)把每个 token 映射为 EEE 维得分,Softmax 归一化;topk为每个 token 选出 kkk 个专家;- 外层循环遍历 kkk 个选择位置;
- 内层循环遍历所有专家,用
mask筛选出"当前 token 的第 iii 个选择正好是这个专家"的 token; - 只把这些 token 送入对应专家,输出乘以路由权重累加。
对应数值例子(TC): 如果 t0、t1 都选 E0,那么 E0 会处理 2 个 token,E1、E2 各处理 1 个,负载不均。
EC_MoE : 专家选择模式实现步骤
python
class EC_MoE(nn.Module):
def __init__(self, dim, num_experts, k):
super().__init__()
# 专家的总数量
self.num_experts = num_experts
# 每个专家最多选多少个token
self.k = k
# 用于给每个token输出E个专家得分的路由器
self.router = nn.Linear(dim, num_experts)
self.experts = nn.ModuleList([Expert(dim) for _ in range(num_experts)])
def forward(self, x, tokens=None, verbose=False):
# 获取输入token的数量B_total和维度D
# B_total表示所有token的总数(批次 × token数)
B_total, D = x.shape
# 路由器计算每个token、每个专家的匹配得分输出维度:[B_total, num_experts]
# softmax确保所有专家得分加起来为1
gate_scores = F.softmax(self.router(x), dim=-1)
# EC模式: "专家挑token"
# 转置后变成[num_experts, B_total]
# scores_T[e][t] = 第e个专家对第t个token的评分
scores_T = gate_scores.transpose(0, 1)
# 每个专家从所有token中挑选top-k个最相关的token
# topk_idx: 每个专家选中Top-K token的索引
# topk_scores: 对应的路由得分
# 维度:[num_experts, k]
topk_scores, topk_idx = scores_T.topk(min(self.k, B_total), dim=-1)
# dispatch_weights大小:[B_total, num_experts]
# 初始化dispatch_weights
dispatch_weights = x.new_zeros((B_total, self.num_experts))
# 对每个专家e,把top-k token的得分写入对应位置
for e in range(self.num_experts):
# topk_idx[e]是一个Top-K token索引列表
# topk_scores[e]是Top-K token的得分
# 填写dispatch_weights:每个专家对各个token评分归一化处理
for t_idx, s in zip(topk_idx[e].tolist(), topk_scores[e].tolist()):
dispatch_weights[t_idx, e] = s
# 初始化输出out,与输入x大小相同
out = torch.zeros_like(x)
# 对每个专家进行前向计算
for e_id, expert in enumerate(self.experts):
# mask: 这个专家是否选择了该token
# mask[t] == 1 → token t被这个专家选中
# 维度:[B_total, 1]
mask = (dispatch_weights[:, e_id] > 0).float().unsqueeze(1)
# 如果专家没有选择任何token,则跳过计算
if mask.sum() == 0:
continue
# 确保每个专家只处理其选中的Top-K token
# mask会把不属于该专家的token设置为0,不同专家可能会处理相同的token
expert_out = expert(x * mask)
# 将专家输出按其权重加回到最终输出中
# dispatch_weights[:, e_id]是每一组Top-K token对这个专家的权重
out += expert_out * dispatch_weights[:, e_id].unsqueeze(1)
return out
功能说明:
router(x)得到 B,EB, EB,E 得分;- 转置为 E,BE, BE,B,每个专家对每个 token 的分数;
topk为每个专家选出它最想要的 kkk 个 token;dispatch_weights记录每个 token 被哪些专家选中、权重多少;- 对每个专家,
mask筛选出它选中的 token,送入专家,输出乘以权重累加。
对应数值例子(EC): E0 选 t1,E1 选 t2,E2 选 t3,t0 没有被任何专家选中,信息丢失。
基于LSH路由机制的简易MoE实现:
python
import torch
import torch.nn as nn
# 简单字符级 tokenizer
class CharTokenizer:
def __init__(self):
self.vocab = {}
self.inv_vocab = {}
def build_vocab(self, texts):
chars = set("".join(texts))
self.vocab = {c:i for i,c in enumerate(sorted(chars))}
self.inv_vocab = {i:c for c,i in self.vocab.items()}
def encode(self, text):
return [self.vocab[c] for c in text]
def decode(self, ids):
return "".join([self.inv_vocab[i] for i in ids])
# Expert FFN
class Expert(nn.Module):
def __init__(self, dim):
super().__init__()
self.ffn = nn.Sequential(
nn.Linear(dim, dim*4),
nn.ReLU(),
nn.Linear(dim*4, dim)
)
def forward(self, x):
return self.ffn(x)
# LSH Router
class LSHRouter(nn.Module):
def __init__(self, d_model, num_experts, n_hashes=8):
super().__init__()
self.num_experts = num_experts
self.n_hashes = n_hashes
self.register_buffer(
"random_vectors",
torch.randn(n_hashes, d_model)
)
def forward(self, x):
projections = x @ self.random_vectors.T
signs = (projections > 0).long()
hashes = signs @ (1 << torch.arange(self.n_hashes, device=x.device))
expert_ids = hashes % self.num_experts
return hashes, expert_ids
# LSH-MoE
class LSH_MoE_Text(nn.Module):
def __init__(self, dim, num_experts, n_hashes=8, vocab_size=None):
super().__init__()
self.embedding = nn.Embedding(vocab_size, dim) # embedding 层
self.dim = dim
self.num_experts = num_experts
self.router = LSHRouter(dim, num_experts, n_hashes)
self.experts = nn.ModuleList([Expert(dim) for _ in range(num_experts)])
def forward(self, token_lists, verbose=True):
"""
token_lists: list of LongTensor,每条tensor是一条文本token ID
"""
lengths = [t.size(0) for t in token_lists]
total_tokens = sum(lengths)
x_flat = torch.cat(token_lists, dim=0) # [total_tokens]
x_flat = self.embedding(x_flat) # [total_tokens, D]
hashes, expert_ids = self.router(x_flat)
out_flat = torch.zeros_like(x_flat)
expert_load = torch.zeros(self.num_experts, dtype=torch.long, device=x_flat.device)
for e_id, expert in enumerate(self.experts):
mask = (expert_ids == e_id).float().unsqueeze(1)
n_tokens = int(mask.sum().item())
expert_load[e_id] = n_tokens
if n_tokens > 0:
out_flat += expert(x_flat * mask) * mask
# 拆回原句子
outputs = []
start = 0
for l in lengths:
outputs.append(out_flat[start:start+l])
start += l
if verbose:
print("\n========== LSH-MoE Token 哈希映射 ==========")
start = 0
for idx, l in enumerate(lengths):
for j in range(l):
token_idx = start + j
print(f"Sentence {idx}, Char {j}: Hash={hashes[token_idx].item()} -> Expert {expert_ids[token_idx].item()}")
start += l
print("\n========== LSH-MoE 专家负载统计 ==========")
for e in range(self.num_experts):
print(f"Expert {e}: {expert_load[e].item()} tokens")
print("------------------------------------------------\n")
return outputs
# 测试
if __name__ == "__main__":
sentences = ["你好世界", "今天天气很好"]
tokenizer = CharTokenizer()
tokenizer.build_vocab(sentences)
token_lists = [torch.tensor(tokenizer.encode(s), dtype=torch.long) for s in sentences]
dim, num_experts = 16, 5 # 每个 token 的 embedding 维度,专家数量
moe_text = LSH_MoE_Text(dim=dim, num_experts=num_experts, vocab_size=len(tokenizer.vocab))
outputs = moe_text(token_lists)
for i, out in enumerate(outputs):
print(f"Sentence {i} 输出shape: {out.shape}")
总结
| 问题 | 回答 |
|---|---|
| MoE 原理 | 多个专家 + 路由器,每个 token 只激活 Top-K 专家,参数多但计算稀疏 |
| TC | 每个 token 选 Top-K 专家;优点:token 全覆盖;缺点:负载不均衡,热门专家过载、冷门专家闲置 |
| EC | 每个专家选 Top-K token;优点:负载均衡;缺点:部分 token 可能被丢弃,语义丢失 |
| LSH-MoE | 固定哈希函数把 token 映射到专家;优点:无需训练、负载概率均衡、高效;缺点:语义表达弱、无法动态调整 |
| 负载均衡 | 防止专家过载/闲置,避免死亡专家,让所有专家充分训练;方法包括辅助损失、容量控制、动态偏置、共享专家等 |
| 数值例子核心 | TC:E0 处理 2 个 token,E1/E2 各 1 个;EC:各 1 个但 t0 被丢弃;LSH:E0 处理 2 个,E1/E2 各 1 个,概率均衡 |
| 代码对应 | TC_MoE 按 token 选专家;EC_MoE 按专家选 token;LSH_MoE_Text 用随机投影哈希路由 |