1. 路由日志里的一处违和:PPL在降,专家负载却在“画选区”
上个月我在OLMo-core 3的训练中途做了一轮例行的路由健康检查,结果看到了一个让我印象非常深刻的画面:验证集困惑度一路平稳下降,下游任务分数也在涨,一切指标都在说“模型状态很好”。但我随手把路由决策按时间顺序拉成图之后,发现专家负载分布完全不像一个健康MoE该有的样子——前两万token几乎全部涌向Expert 0和Expert 3,后两万token又几乎全部涌向Expert 1和Expert 5,中间几乎不重叠,像有人在专家之间硬划了一条边界,把token按时间段“圈”进了不同选区。
这个现象后来被我同事半开玩笑地叫作“token gerrymandering”。Gerrymandering本来是政治学里的“选区划分舞弊”——通过刻意扭曲选区边界来操纵选举结果。放在MoE路由里,就是路由网络对token分配呈现出的系统性的、有时间依赖的倾斜:某个时间片里某几个专家吃到过量token,另一个时间片里又换成另外几个专家,整体统计上看起来似乎“挺均衡”,但拆开看每一段都不均衡。
OLMo-core 3的这次提醒让我意识到一个事:我们验收MoE路由的方式太粗了。只盯整体指标、只看最终loss、只看平均负载均衡,是发现不了这种“画选区”行为的。MoE路由必须按时间窗验收,否则你会把一只跛脚模型当成健步如飞的运动员放上线。
1.1 用“选区划分”理解token gerrymandering
为了把概念讲透,我拿一个日常例子类比。
你把100个token交给10个专家处理,最理想的情况当然是每个专家吃10个。但MoE路由不是均匀分配器,它是一个学出来的决策网络,会根据token的特征给专家打分,选top-k。从功能上讲,这是好事——专家可以专业化。问题是,专业化和“系统性倾斜”之间的界限非常微妙。
想象一座城市要划10个选区,每个选区人口应该差不多。如果负责划分的人出于某种偏好,把穷人集中划到1个选区、富人集中划到9个选区,总体人均收入听着可能一样,但每个选区内部极度同质。MoE里的token也有类似的“人口结构”——它们按时间序列进入模型,相邻token往往具备极强的语义相关性。
路由网络如果学到了“把时间上靠前的这一类token都给Expert A”,那它本质上就是在做“选区划分”。划分本身不是灾难,灾难在于:这种划分不受控制、不被察觉,并且会在某些窗口形成过热专家或长期闲置专家,最终体现为训练吞吐抖动、显存碎片化、推理时单卡热点,以及极难排查的偶发性劣化。
1.2 先补齐MoE路由的最小背景
MoE(Mixture of Experts,混合专家)不是新东西,Shazeer在2017年的经典论文里就把稀疏门控的思路讲明白了。它的核心组件是两个:
一是专家网络。一般就是前馈网络FFN,把输入token的隐藏层特征再做一次非线性变换。
二是路由网络(Router,也叫Gate)。它接收每个token的隐藏层特征,输出一个维度等于专家数量的分数向量,再用softmax转成概率分布,最后取概率最高的前k个专家作为这个token的激活集合。
上述“取top-k”这一步,就是token被分配、被“划分”的瞬间。在OLMo-core 3这类模型里,路由网络的输出还会接一个辅助负载均衡损失,目的是防止某些专家长期过载。但这个aux loss通常只在整批数据上做平均统计,管得住“总量”,管不住“时间窗”。
所以,token gerrymandering问题的核心机制就浮出水面了:路由网络在连续的token序列上形成了稳定但非均匀的分配模式,而现有监控手段对这种模式几乎无感。
2. 未被察觉的边界:MoE路由器为什么会在时间上形成稳定“派系”
在继续讲验收方法之前,我必须先把机制说清:为什么路由网络会形成时间上的稳定派系?这不完全是玄学,背后有几个非常现实的训练动力学因素。
2.1 批次内token的高度相关性把路由“带偏”
MoE模型训练时,输入序列是按文档句子切分的,同一批(batch)里的token大概率来自同一批文档、同一批主题。如果你用的是一个流式数据管线,相邻step的数据往往还有时间上的主题聚集效应——某个时段爬到的网页集中讲同一个话题,这很常见。
路由网络是个吃特征分布的模型,它天然会发现“最近这批token的统计特征很像,那我把它们分给同一批专家,loss最低”。这是它的最优解,但对系统来说这就是一种时间上的倾斜。
我实测过OLMo-core 3训练日志里的一个片段:连续三个batch的token几乎全部命中Expert 1/Expert 6/Expert 9这组专家,直到数据源切到另一批主题,才整体迁移到Expert 0/Expert 4/Expert 7。如果只看整轮的平均负载,这三个batch的倾斜会被后面几百个batch抹平——但训练过程中,Expert 1/6/9对应的那块GPU在那一小时里就是比别的卡烫、别的卡闲。
2.2 路由器的自强化会让偏差固化
更麻烦的是,这种倾斜往往不是随机游走,而是会自我强化。
原因很简单:专家网络也在训练。某个专家如果连续一段时间吃到大量同一分布的token,它对这类token的拟合会越来越好,路由给它的分数就会越来越高。其他专家因为吃不到这类token,对这类特征的反向传播梯度稀疏,处理该类输入的能力原地踏步。此消彼长,路由网络很快就会把某类token稳定地、近乎垄断地交给少数几个“熟悉”的专家——哪怕其他专家理论上也能学好,它们没有机会。
这就是路由层面的马太效应,也是token gerrymandering从“统计波动”变成“稳定结构”的关键转折点。等到这种固化形成,你再想靠aux loss掰回来,代价就是重新扰动已经收敛的专家权重,训练损失会出现明显回弹。
2.3 为什么这会影响训练和推理两个阶段
训练期的问题很好理解:路由倾斜导致算力不均衡,单卡热点限制整体吞吐,还可能让部分专家欠拟合。放在流水线并行场景下,专家被分散到不同设备,一次路由决策可能触发设备间的数据传输,热点专家所在的设备会变成通信瓶颈,整轮训练速度被它拖住。
推理期的影响更隐蔽。你把训练好的模型部署上线后,线上token分布是有时间段特征的:工作日跟周末不一样,白天跟深夜不一样,上新产品前后的查询分布不一样。如果路由在训练时已经固化成“时间段派系”,它在线上就会复现同样的倾斜——白天某几个专家被大量激活,夜里换一批。结果就是:你说不清哪台机器会先扛不住,因为你没法用整体负载预测任何一台机器的负载。
2.4 值得注意:跟“专家专业化”的本质区别
这里有读者一定会问:难道MoE不应该让专家专业化吗?让特定专家擅长处理特定类型token,这不是设计目标吗?
对,但专业化和时间派系之间有清晰边界。专业化是由token内容特征驱动的,比如“数学题归Expert A,法律文本归Expert B”,不管这道数学题出现在一天中的哪个时段,路由结果都应该稳定。而时间派系是由token到达顺序驱动的——同样一道数学题,上午到达被分给Expert A,下午到达被分给Expert D。前者是健康的专业化分工,后者是路由学到了不稳定的时间依赖,这就会让模型在不同时间段的输出质量发生无规律漂移。
判断方法也很简单:把token的时间戳打乱重排,再看路由决策是否发生显著变化。如果变化大,说明路由依赖的不是内容而是时间上下文,这就是token gerrymandering的典型信号。
3. 全局验收的两处陷阱:平均值平衡、单轮评估不稳定
我一直对“验MoE只看平均指标”这个习惯很不放心。因为它真的会骗人,而且骗得很隐蔽。下面我把两个最常见的陷阱摊开讲。
3.1 陷阱一:整体负载均衡,窗口内却“旱涝不均”
负载均衡损失(aux loss)的作用目标通常是“整个batch上的负载分布尽量均匀”。这个目标没问题,但它管不住时间维度的分布。
我举个例子你就明白了。假设模型有8个专家,aux loss很给力,在100万token上,每个专家都被分配到12.5%的token,完美均衡。然后你把100万token按先后顺序切成100个窗口,每个窗口1万token,再看窗口内的分配比例——结果可能是:窗口1的token 90%给了Expert 0,90%给了Expert 1(top-2),其他专家饿着;窗口2又换成Expert 2和Expert 3吃满。
整体统计完全健康,窗口统计每一帧都很极端。如果你只关心最终loss,这种“旱涝不均”可能不影响模型精度,但影响训练稳定性和推理资源规划。尤其当模型规模变大、专家数量变多,这种窗口内的极端倾斜会放大显存碎片化,严重的会直接触发OOM。
3.2 陷阱二:路由稳定性与时间语义互相纠缠
第二个陷阱更容易被忽略:很多人用“路由稳定性”来验收模型,但用的是错误的稳定标准——他们计算了整个验证集上路由决策的一致性,或者对比两个完全不重叠的验证集上路由分布的KL散度。
这样做的问题在于:验证集通常在预处理时被随机打乱。一旦打乱,token原本的时间顺序没了,路由决策的时间依赖性就无从谈起,你测出来的“稳定性”实际上是“模型在不同随机样本上的稳定性”,不是“模型在时间流逝中的稳定性”。
OLMo-core 3启发我的一点就是:要验时间窗,就要保持token的原始顺序。打乱顺序后做验收,等于把运动员放到没有对手的赛场上,数字好看但毫无意义。真正的做法是保留一个连续切片的token流,按时间顺序送进模型,然后观察路由决策随时间的演化,而不是把人造的平稳分布当成真实分布。
3.3 第三种常见的错觉:单次窗口抽查
有些团队意识到时间维度的存在后,会做“抽查”——随机抽一个万token窗口看看路由分布,发现没问题就觉得OK。这可能踩上更大的坑。
时间窗验收的核心在于看“窗口之间的迁移和波动”,而不是看单个窗口。单个窗口只能告诉你某个瞬间的路由状态,你要判断的是这个状态是否持续、是否在恶化、是否会周期性复发。所以正确姿势是一整套滑动窗口体系,从连续的时间序列里提特征,而不是随机快照。
4. 时间窗验收指标设计:从“验最终结果”到“验路由过程”
想搞明白MoE路由到底稳不稳,我建议把验收范式从“只看最终结果”切换到“结果+路由过程一起看”。下面是我在OLMo-core 3项目里落地的一套时间窗验收指标,按重要程度排好,供参考。
4.1 窗口长度按什么逻辑定
窗口长度是整个验收方案的第一个参数,选错会导致完全不同的结论。
如果窗口太短(比如512个token),噪音会很大,任何一点数据波动都会让指标剧烈跳动,看不出趋势;如果太长(比如50万token),又会把短期的系统性倾斜抹平,重新回到全局统计的老路。我建议按以下公式估算:
窗口长度 ≈ 专家数量 × 平均每个token激活的专家数 × 50~200
举个例子,模型有16个专家,top-2路由,每个token激活2个专家,那么窗口长度就是16×2×100≈3200个token。这个量级既能保证每个专家在窗口内平均能分到200个token,统计上有意义,又能保留时间窗的敏感性。如果你只想看大尺度漂移,可以×1000,但我建议不要只看一种尺度,至少跑两套:一套“细窗”用于捕捉短时波动,一套“粗窗”用于看长期趋势。
4.2 六项可量化指标
我整理了一个表格,这些指标覆盖了路由偏差的大多数临床表现:
| 指标 | 公式/定义 | 观察目标 |
|---|---|---|
| 窗口负载基尼系数 | 按窗口统计每个专家被选中的token数,计算基尼系数 | 窗口内是否旱涝不均 |
| 路由翻转率 | 相邻窗口间top-1专家集合差异的比例 | 路由决策是否在时间上剧烈跳变 |
| 专家激活熵 | 单窗口内专家命中分布的熵值 | 判断窗口内是否过度集中 |
| 时间自相关 | 每隔N个token采样一次路由决策,算自相关系数 | 判断路由是否有短周期振荡 |
| 热点专家持续度 | 某个专家在多少个连续窗口内保持前3活跃 | 排查固定派系的固化 |
| 区间迁移幅度 | 用Wasserstein距离衡量相邻窗口的专家分布差异 | 判定分布是否发生突变 |
这六项不是孤立的,我一般会组合起来看。例如激活熵长期偏低+热点专家持续度高,说明路由已经固化成“老几位”;翻转率高+区间迁移幅度大,说明路由可能在两个状态之间反复横跳,模型行为不稳定。
4.3 判定标准的“三色线”
光有指标不够,你还得给每个指标定义“什么时候算过”。我习惯用三色线来分级,收窄判断口径:
- 绿色:窗口负载基尼系数<0.25,激活熵大于理论最大熵的70%以上,翻转率低于10%,可以认为路由健康。
- 黄色:基尼系数在0.25~0.45之间,或者连续5个窗口热点专家持续度偏高,进入观察名单,继续看趋势,不干预。
- 红色:基尼系数>0.45,或者区间迁移幅度超过0.8(Wasserstein距离除以专家平均负载),立刻触发人工排查;如果连续10个窗口都是红色,必须考虑调整路由的训练目标或数据采样策略。
这套门槛不是拍脑袋定的,我基于一批开源MoE模型的路由日志统计归纳过,绝大多数健康模型在稳定训练阶段的基尼系数能稳定在0.3以下。如果你的模型长期高于0.4,基本可以认定存在token gerrymandering。
5. 落地脚本:一小时内跑完路由时间窗体检
下面给出一套最小可落地的方案。假设你已经有一个能输出路由决策的MoE模型,OLMo-core 3或者其他架构都通用,需要做的只有三件事:采集日志、按窗口聚合、计算指标。
5.1 路由日志的最小采集方案
首先,你得让模型把每个token的路由决策打出来。通常在模型forward阶段可以拿到路由网络的logits,在你的训练或推理框架里加一行回调即可。最少要记录的字段有这些:
| 字段 | 说明 |
|---|---|
| seq_id | token所属序列ID |
| token_pos | token在序列内的位置 |
| global_step | 走到第几步,用于跨数据源对齐 |
| expert_top1 | top-1命中的专家ID |
| expert_top2 | top-2命中的专家ID |
| router_prob | top-1对应的路由概率 |
| entropy | 路由分布的熵值 |
如果框架不支持直接输出,就手动在路由网络后接一个hook,把张量导出来。数据量不用全存,可以抽样存——每100个token存1条记录也能看出趋势,但我建议前1万token全量存,用来校准抽样方案。
5.2 窗口聚合与异常识别代码
有了日志后,窗口聚合的逻辑非常简单。下面这个Python代码块可以直接复用:
import json import numpy as np from collections import Counter, defaultdict def load_route_log(path): records = [] with open(path, "r", encoding="utf-8") as f: for line in f: records.append(json.loads(line)) return records def window_aggregate(records, window_size=3200): windows = [] n = len(records) for start in range(0, n, window_size): chunk = records[start:start + window_size] if len(chunk) < window_size // 2: continue expert_counter = Counter() top1_collection = [] entropy_collection = [] for rec in chunk: expert_counter[rec["expert_top1"]] += 1 expert_counter[rec["expert_top2"]] += 1 top1_collection.append(rec["expert_top1"]) entropy_collection.append(rec["entropy"]) total = sum(expert_counter.values()) dist = np.array([expert_counter[i] / total for i in sorted(expert_counter)]) windows.append({ "start_idx": start, "expert_dist": dist, "gini": gini_coefficient(dist), "top1_seq": top1_collection, "avg_entropy": np.mean(entropy_collection), "hot_experts": {k: v for k, v in expert_counter.most_common(3)} }) return windows def gini_coefficient(arr): arr = np.sort(arr) n = len(arr) index = np.arange(1, n + 1) return (2 * np.sum(index * arr) - (n + 1) * np.sum(arr)) / (n * np.sum(arr))这份代码做的事就是按窗口切分、统计每个专家作为top-1/top-2的命中次数、算基尼系数、记录每个窗口的热点专家,然后你把输出结构化地存下来画图。跑一次万token级别的日志,脚本执行时间一般不超过三分钟。
5.3 一个典型的异常输出示例
我给一份脱敏的真实输出,方便你对“异常长什么样”有直观认知:
window 0 gini=0.31 hot_experts={0: 921, 3: 880, 7: 102} window 1 gini=0.36 hot_experts={0: 1088, 3: 1044, 7: 90} window 2 gini=0.38 hot_experts={0: 1001, 3: 990, 7: 77} window 3 gini=0.29 hot_experts={5: 720, 8: 812, 0: 109} window 4 gini=0.33 hot_experts={5: 855, 8: 940, 0: 44}前三帧稳定集中在Expert 0/3,第四帧突然整体切到Expert 5/8,中间几乎没有任何过渡。这就是我前面说的“时间派系”的典型特征——热点专家跨窗口切换,切换前后的分布几乎不重叠。这种模型即便整体PPL正常,我也建议多观察一段时间,因为它反映的是路由在时间维度上对特定数据分布过度敏感。
6. 验收不过时该怎么处理:策略优先级与我的实战判断
时间窗验收的意义不只是“发现问题”,更在于“发现问题之后能精准定位”。根据我在OLMo-core 3训练过程中的经验,不同异常对应的处理手段差异很大,盲目调参反而会让情况更糟。
6.1 慢热型倾斜与跳变型倾斜要区别对待
慢热型倾斜的特征是:热点专家在几十个窗口里渐变切换,基尼系数慢慢爬升。这通常意味着数据流本身的主题分布在缓慢漂移,路由网络在跟随着调整。这种情况一般不需要紧急干预,你只需要保证训练数据的采样在时间上足够均匀——比如把同主题文档打散放到不同批次里,倾斜自然缓解。
跳变型倾斜的特征是:相邻窗口的专家分布突然从一组跳到另一组,比如上面的示例。这种情况要优先怀疑数据管线出了问题:是不是数据源突然切换了?是不是某个预处理步骤产生了异常时间段?先把数据管线的日志找出来对齐,再决定要不要调路由。很多时候数据侧的问题修好,路由自己就会恢复正常。
6.2 路由侧的三类调整手段
如果确认是路由网络自身的问题,我建议按下面顺序从轻到重调整:
第一,检查容量系数(capacity factor)。MoE训练里给每个专家分配Buffer时会有容量上限,如果容量系数设得过紧,路由网络会被迫把溢出token踢到残差连接里,这会让路由决策受到严重的buffer状态干扰。试着把capacity factor从1.0放宽到1.1~1.2,很多时间窗抖动会自然消失。
第二,给aux loss加入时间维度的正则。默认的负载均衡loss是整批算均值,可以改成“先按mini-batch切分,对每个子窗口算负载偏差,再加和”。这段实现很小,但能直接抑制token gerrymandering——因为它在训练目标里就约束了“每个小时间段都要均衡”,而不是只在总体均值上做约束。
第三,给路由网络加一个偏置项或噪声项。用少量噪声扰动路由分数,能打破自强化循环,让原本被垄断的token有机会落到其他专家头上。这个手段最适合时间派系已经固化、训练又不想大幅回退的场景。
6.3 我的实操心得与踩坑复盘
最后分享三条教训,每条都是踩过坑换来的。
第一条,千万不要在评估时打乱验证集顺序再测路由。我早期为了图方便,把验证集shuffle之后去测路由稳定性,得出的结论是“模型路由分布很均匀、很稳定”,上线两周后被线上真实流量打脸。时间是路由的一个隐式维度,打乱顺序等于抹掉了这个维度。
第二条,窗口长度别只测一种尺度。我吃过亏:单用细窗跑了3000个token的窗口,测出来的结果是“翻转率超高、系统不稳定”,吓出一身冷汗;后来换成30000个token的粗窗再看,其实模型是在两个健康状态之间缓慢过渡,并不算异常。正确做法是细窗+粗窗双轨并行,细窗看瞬时波动,粗窗看趋势,両者对上了才能下结论。
第三条,路由日志别省,哪怕不全存也一定要有采样的存档。我经历过一次训练中途数据管线事故,事故后哪项指标都正常,就是找不到根因。最后倒查路由日志,发现在事故时间戳上恰好有一个持续数万token的专家分布跳变——撞上跳变型倾斜,数据问题才被锁定。你要是没存日志,这种case基本无解。
我在实际项目里的习惯是:每次跑完时间窗验收,把基尼系数、翻转率、热点专家持续度三张图画在同一张时间轴上,另外叠加上训练数据的主题分布切换标记。这样一旦路由跳变,我立刻就能区分是数据侧换了主题还是路由自己崩了,排查时间可以从半天压缩到半小时。如果你也在被MoE路由的隐性故障折磨,我建议从今天起就把时间窗验收纳入日常流程,这可能是你做过的最划算的一项改动。