上周调一个 OLMo-core 3 系列的 32 专家 MoE 模型时,我盯着损失曲线看了一小时,一切正常。可一到线上跑长文本任务,延迟忽高忽低,同一个测试集每次表现都不一样。最后翻路由日志才发现,问题根本不在 loss 上,在 token 被专家“分片”的方式上——这就是最近大家讨论比较多的 token gerrymandering 现象。简单说,MoE 的路由模块像画选区一样,把连续的 token 块悄悄划给了一批固定专家,全局平均负载看着均衡,实际某一小段时间里的路由分配早就失衡了。
这篇文章想把这个问题彻底说透。我会先用 OLMo-core 3 做观察样本,解释 token gerrymandering 到底是什么;再讲清楚为什么只看平均负载会误判;然后给出一套可落地的“按时间窗验收”方法,包含日志采集、窗口切分、阈值设定和回归卡控。
1. 从 OLMo-core 3 的日志里最先察觉的问题:路由分布比 loss 更容易骗人
1.1 我为什么会在意 token gerrymandering
很多人训练 MoE 模型时,重点盯两个指标:loss 和全局辅助负载均衡损失。这两个指标正常,大家就觉得路由没问题。但我自己的经验是,loss 是“结果指标”,路由行为是“过程指标”,结果正常不代表过程健康。
以 OLMo-core 3 为例,它是 AI2 开源的 MoE 模型家族,结构并不复杂:token 输入后,每一层都有一个 router(门控网络),router 对全部 expert 计算打分,再取 top-k 个 expert 做加权求和。这个机制听起来干净,但实际问题往往出在 top-k 的选择偏好上:如果连续一段时间内,某种类型 token 总被同一个 expert 子集接走,其他 expert 长期空转,那全局统计里因为 token 量大,平均值会被摊平,根本看不出问题。
所谓 token gerrymandering,我理解就是:路由模块对不同 token 并不是按理想概率均匀分配,而是形成了一种“稳定的、带偏好的分片结构”。某些 expert 成了“热门选区”,大量 token 涌入;某些 expert 成了“冷门选区”,偶尔才有 token 路过。它不是随机的抖动,而是有记忆性的分布畸变。
你可能会问:有辅助负载均衡损失,router 为什么还会这样?因为平衡损失通常约束的是“当前 batch 的整体分布”,而 token 之间的依赖、位置、内容主题,会让路由在短时间窗口内表现出极强的聚集性。就像一条马路上,一天的平均车流很均匀,但早晚高峰全都堵在同一段路。MoE 里 token 的“高峰”就是被内容上下文驱动的,一篇文章、一段代码、一组指令,可能会集中命中某几个 expert。
所以我在 OLMo-core 3 上做任何改造前,第一件事就是打开 token 级路由日志,而不是只看 loss。
1.2 OLMo-core 3 作为观察样本的合理性
我选择 OLMo-core 3 作为这次实验样本,主要原因有三个:模型完全开放,权重和日志接口都能拿到;它的 MoE 结构具有代表性,32 个 expert、每 token 激活 top-8,是常见的中等规模配置;还因为它迭代版本多,方便对比不同训练阶段的路由行为。
以我这次用的配置为例,关键超参数如下:
| 参数项 | 典型数值 | 作用 |
|---|---|---|
| num_experts | 32 | 每层专家总数,越多越稀疏 |
| top_k | 8 | 每个 token 实际激活的专家数 |
| capacity_factor | 1.25 | 允许 expert 临时超过理论容量的比例 |
| aux_loss_coef | 0.01 | 路由均衡辅助损失的权重 |
| hidden_dim | 2048 | 专家内部特征维度 |
这个配置下,理想情况下每个 expert 应该收到大约 8/32 = 25% 的 token 流量。但实际上,我翻日志时发现,某些 expert 在连续 64 个 token 窗口内收到超过 60% 的转发量,而另一些 expert 在同一窗口几乎颗粒无收。
这正是“时间窗”的意义:全局统计里看 25% 是正常值,时间窗里看可能已经偏到了 2~3 倍以上。
1.3 先把容易混淆的“路由”概念理清
这里想多说一句,很多人看到 MoE 路由会联想到网络里的 PBR 策略路由、Vue 路由,甚至 OSPF 特殊区域配置,其实完全是两码事。MoE 路由解决的是“这个 token 应该交给哪个专家计算”,本质是 token 与 expert 的匹配问题,跟数据包的转发路径没有关系。
但我认为这个类比有个共同点值得借鉴:网络工程师做策略路由时,一定不会只看“平均带宽利用率”,他们更关注某个时间窗口里哪条链路拥塞、哪条链路闲置。MoE 路由也应该这样验收——看时间窗,而不是看总量。
2. 为什么平均负载正常,线上还是出了幺蛾子
2.1 平均统计会掩盖三类典型异常
我在复现 OLMo-core 3 的过程中,把路由日志按三种时间窗重放,发现平均指标下有三类问题几乎是隐形的:
| 异常类型 | 全局平均的表现 | 时间窗内的表现 | 典型触发场景 |
|---|---|---|---|
| 聚集效应 | 各 expert 使用率接近 | 连续 token 集中打到少数 expert | 长文本中连续相似的代码片段 |
| 周期脉冲 | 均值正常,方差被摊平 | 每 N 个 token 出现一次路由尖峰 | 结构化文档、列表、JSON 模板 |
| 上下文记忆偏移 | 整体看不出明显失衡 | 对话轮次越长,路由偏离越稳定 | 多轮对话、system prompt 固定 |
第一种聚集效应最好理解:一段 512 token 的代码,可能 80% 的 token 在语义上高度相似,router 给它们打出的分数非常接近,结果它们总是流向同一个专家子集。如果这段代码出现在一批 sample 的开头,那就等于这批 sample 在同一个时间窗里对少数专家发起集中访问。
第二种周期脉冲比较隐蔽。比如处理 JSON 数据时,{、"key"、:、,这些 token 的出现是有节奏的,这种节奏会让路由选择也在固定间隔内周期性摆动。表面看,每个 expert 在一个大 batch 里的访问总数差不多,但按每 256 token 切一个窗口看,就会出现“这个窗口偏 expert 3-7-19,下个窗口偏 expert 11-12-20”的锯齿形分布。
第三种是最像 gerrymandering 的:一旦上下文里存在长期稳定的高频 token 模式,路由器会形成一种惯性,每次都把特定位置的 token 分给同样的专家。时间越久,这种路径依赖越强,最后变成“选区固化”。这种问题如果不在时间窗维度上去看,根本发现不了。
2.2 时间分辨率对推理性能的影响
你可能觉得路由偏一点就偏一点,只要最终结果还在正常范围就行。但实际部署时,路由时间窗失衡会带来三个直接后果。
第一是显存访问热点。MoE 推理时,expert 参数按需加载到显存,如果一段时间内大家只访问固定的几个专家,这几个专家的参数会在不同 request 之间被反复读取,形成带宽热点。而其它专家虽然也在显存里,利用率却极低,等于白占空间。
第二是调度器的并发碰撞。我用 vLLM 做批量推理时发现,同一个时间窗内如果多个并发 request 都选择了同一批专家,GPU 上就会出现明显的计算排队,延迟从 200ms 飙到 400ms 以上,但下一个窗口又恢复平静。这种高延迟抖动,光看 P99 是摸不准的,得看延迟在时间轴上的分布。
第三是缓存失效。expert 的激活模式如果长期偏向固定子集,那么 LRU 式的缓存策略会反复召回那批专家,而冷门专家一旦被访问,反而会因为缓存 miss 引发更长的时延。
这些影响单拿一个出来都不致命,但它们叠在一起,就是线上那些“说不清为什么突然慢了一下”的现象。
2.3 训练阶段的时间窗问题:step 级路由漂移
再往外伸一步。我发现 token gerrymandering 不只是推理阶段的问题,训练中同样存在,而且更危险。
训练 MoE 时,每过几个 step 模型参数就会更新,router 的行为也会跟着变。如果在某个训练阶段,数据 pipeline 巧合地把同类样本排在了一起,router 就会在连续多个 step 里强化同一批专家的权重,形成“自我实现的预言”——因为某个 expert 被频繁选中,它的梯度更新更快,梯度更新快又让它在下一次打分时更占优,最后这个 expert 变成赢家通吃。
我在 OLMo-core 3 的中间 checkpoint 上做过验证:把训练日志按 step 分组,每 50 个 step 统计一次路由分布,发现某些 expert 的 token 占比从 20% 一路涨到 35%,然后又随着数据分布的切换跌回去。这种漂移如果不用时间窗观察,只看最终 checkpoint 的全局统计,你是不知道训练过程经历了这么剧烈的路由震荡的。
这也是为什么我坚持说,MoE 路由要按时间窗验收,而不是只做一次性体检。
3. 按时间窗验收:一套可以照抄的 token 路由检查方法
3.1 第一步:先解决日志问题,拿不到数据一切都是空谈
验收的前提是能记录每个 token 去了哪个 expert。很多开源模型的推理框架会聚合路由信息,但聚合后的统计对时间窗分析没什么用,必须拿到细粒度日志。
我在 OLMo-core 3 上做的是在 MoE 层的 forward 里加一个 hook,把 top-k 的 expert id 和对应权重,连同 token id、batch id、position id一起落盘:
# 伪代码:在 MoE 层 forward 中追加路由日志 def moe_forward_with_log(self, x, batch_ids, token_ids, positions): gate_logits = self.gate(x) top_k_vals, top_k_idx = torch.topk(gate_logits, k=self.top_k, dim=-1) # 逐 token 写入本地日志文件 for b, t, p, eids, evals in zip( batch_ids, token_ids, positions, top_k_idx.tolist(), top_k_vals.tolist() ): log_entry = { "batch": b, "token": t, "pos": p, "topk_ids": eids, "topk_weights": evals } router_logger.append(log_entry) return self.experts(x, top_k_idx, top_k_vals)注意一点:在线推理时不要同步写盘,I/O 会阻塞计算,最好异步用队列收集,批量压缩后定时刷盘。如果是在训练阶段,可以直接利用 deepspeed 或 megatron 的 log 接口,但一样要按 step 和 micro-batch 打上时间戳,否则后续窗口切分没有依据。
3.2 第二步:时间窗怎么切,切多细
拿到细粒度日志后,下一步就是定义“时间窗”。我常用的有三种切法:
- 固定 token 窗口:每 N 个连续 token 作为一个窗口。N 可以取 128、256、512、1024。适合观察内容驱动的周期性。
- 滑动时间窗口:按推理时钟或训练时钟切,比如每秒、每 5 秒一个窗口。适合观察在线服务的高峰碰撞。
- 任务级窗口:按一个完整的 sample、对话、文档切窗口。适合观察上下文长度对路由的影响。
我自己的经验是三层同时切,互相参照。比如固定 token 窗口最容易暴露内容聚集;滑动时间窗口最容易暴露并发冲突;任务级窗口最容易暴露 prompt 格式导致的系统性偏差。
下面是我常用的切窗伪代码:
def slice_windows(router_logs, mode, window_size, stride=None): if mode == "token": windows = group_by_continuous_token(router_logs, window_size) elif mode == "time": windows = group_by_time_bucket(router_logs, window_size) elif mode == "task": windows = group_by_task_id(router_logs) if stride: # 滑动窗口 windows = make_overlap(windows, stride) return windows3.3 第三步:窗内指标怎么算,阈值怎么定
窗口切好后,我一般算四个指标:
- 高活跃专家占比:窗口内接收 token 数超过理论均值 2 倍的专家占比。
- 低活跃专家占比:窗口内接收 token 数为 0 或者不到理论均值 1/10 的专家占比。
- 路由稳定性:同一个 token 在多次前向推理中,top-1 expert 是否频繁变化。这个指标对推理缓存很重要。
- 窗口偏移度:当前窗口的 expert 分布与全局分布的 KL 散度或 JS 散度。
以 OLMo-core 3 的 32 专家配置为例,理论均值是每个窗口每个 expert 收约 1/32 的 token。实际操作中我给出的经验阈值是:
| 指标 | 健康范围 | 预警线 | 红色警戒线 |
|---|---|---|---|
| 高活跃专家占比 | < 20% | 20%~30% | > 30% |
| 低活跃专家占比 | < 30% | 30%~40% | > 40% |
| 窗间路由稳定性 | > 0.8 | 0.6~0.8 | < 0.6 |
| 窗口 KL 散度 | < 0.1 | 0.1~0.3 | > 0.3 |
注意这些阈值不是死的。模型层数越深、专家数越多,阈值可能需要放宽。比如深层 MoE 的 expert 本身就可能出现功能分化,某些层固定负责句法、某些层固定负责语义,这种结构性偏置是正常的,不能按同一套标准卡。
3.4 第四步:自动卡控与回归
如果只是手动看看日志,那还谈不上“验收”。我建议把时间窗指标做成回归测试的一部分,跑完一批评测,自动输出一张路由健康报告。
做法很简单:评测集设计成包含不同长度、不同格式、不同主题的样本,跑完以后把路由日志汇总成窗口指标;然后对比基线版本,如果窗口偏移度比基线涨了超过 30%,就需要人工介入。这一步看起来繁琐,但非常值得,因为 MoE 模型的路由行为对数据分布变化极其敏感,你不把它放进回归流程,它就一定会偷偷退化。
4. 我在复现中的四个路由事故,都是怎么被时间窗揪出来的
4.1 事故一:system prompt 成了“选区划分”的幕后推手
我用 OLMo-core 3 做多轮对话实验时,固定了一段 300 token 的 system prompt。从全局路由统计看,一切正常,不同 expert 的访问量都很平均。但切换到任务级窗口后立刻发现问题:每个对话第一轮的前 100 个 token,几乎全部命中同一个 expert 子集。
原因不复杂,system prompt 是每个 request 都带的,内容又完全相同,router 很容易对它的开头形成固定偏好。也就是说,只要你的服务给每个请求都加了长 system prompt,那就等于人为制造了一个“计划性路由尖峰”。这个尖峰在每一轮对话的固定位置都会出现,单看平均数据根本看不到。
后来我把 system prompt 换了几种措辞,路由分布确实跟着变了。这说明 router 对 prompt 的措辞级细节非常敏感,不只对语义敏感。
这件事提醒我:任何在推理时新增的固定前缀,都要重新跑一版时间窗验收,而不是沿用未加前缀时的路由健康结论。
4.2 事故二:长文本生成的后半段发生路由偏移
另一个印象深刻的案例是长文本生成。我先生成 512 token 的短文本,再生成 4096 token 的长文本,对比不同窗口的路由分布。短文本一切正常,长文本则在后半段出现了明显的“专家收敛”。
具体来说,长文本生成进行到 3000 token 以后,top-1 expert 的分布越来越集中,到最后 512 token 的窗口里,几乎 60% 的 token 都指向了同一个专家。这个事情非常危险,因为长文本生成占用显存大,如果路由又集中到少数专家,显存和算力的利用率都会更加倾斜,生成的性能会进一步下降,造成恶性循环。
我猜测原因是:长文本生成的后期,上下文中的自注意力模式开始趋于稳定,语义表达也更连贯,不同 token 的表示在向量空间里变得高度相似,router 打出来的分数自然趋于一致。
这说明什么?说明 MoE 路由的长期行为,是会随着上下文长度发生慢慢漂移的,而且这种漂移在短文本评测里完全测不出来。如果你要上线长文本能力,就必须把“长文本长度分段验收”作为单独的一步来跑。
4.3 事故三:批量推理里的并发突刺
还有一次是上线前压测,我开了 16 路并发请求,发现 GPU 利用率在某个时间窗突然吃满,下个时间窗又回落。刚开始以为是动态 batching 的波动,后来按时间窗重放路由日志才发现,16 路请求在同一时刻处理的 token 段,全都选到了同一组 expert。
原因是这批请求前面都有一段相似的用户输入前缀,尽管业务上看起来是不同用户,但输入的开头内容高度雷同。16 个请求并发时,等于短时间内同一个 expert 子集被 16 个请求同时访问,计算排队自然严重。
这个案例也说明,时间窗验收不能只看单个请求的路由日志,还要按“全局并发视角”去看,也就是把所有并发请求的路由事件叠加到同一时间轴上统计。
4.4 事故四:few-shot 示例的选择带来了路由偏见
最后一个案例跟评测集设计有关。我在做 few-shot 评测时,发现同一份测试集,换一组示例后,路由分布会出现明显变化。进一步定位,发现示例和测试样本在主题上越接近,路由分布就越偏到示例主题对应的专家子集上。
这其实是一个很值得警惕的现象:few-shot 示例本质上是人为注入的数据分布偏移,模型不仅会模仿示例的输出,还会模仿示例的“计算路径”。如果你的评测集 few-shot 示例选得不够多样,路由验收的结果就会被示例本身带偏,得出错误的健康结论。
我的调整办法是:做 MoE 路由验收时,few-shot 示例要么固定为极少量,要么用充分多样化的集合,保证示例覆盖多种主题,否则你验的不是模型的路由能力,而是示例的引导能力。
5. 训练和推理侧还有哪些隐藏因素会直接影响时间窗验收
5.1 aux loss 解决不了时间窗问题
很多人会问:那我加大辅助负载均衡损失的权重,不就能让路由更均匀吗?我试过,aux loss 确实能让全局分布更均衡,但它约束的是当前 batch 的统计分布,对时间窗内的局部聚集几乎没有约束力。
你可以想象一个老师,只能在期末看全班平均分,却看不到平时谁在划水。aux loss 就是这个老师,它对短时间窗口内的路由尖峰无能为力。
更麻烦的是,aux loss 权重调太大会压制 router 的真实语义选择能力,模型整体性能下降。我的经验是 aux loss 保持在一个能“兜底”的区间就够了,想要治理 token gerrymandering,必须从数据和路由结构上做文章,不能指望辅助损失一劳永逸。
5.2 capacity_factor 调大只会掩盖问题,不会消除问题
capacity_factor 的本意是允许 expert 临时接收超过理论容量的 token,避免 token 溢出丢失。很多人觉得,把 capacity_factor 调大,路由偏一点也没关系,反正 expert 装得下。
但实际上,调大 capacity_factor 意味着每个 expert 都要预留更大的显存空间,而且 token 集中在少数专家时,这些专家的计算时间会明显拉长,形成性能瓶颈。你只是在用更大的显存和更长的排队,掩盖路由不均衡的真相。
在我自己的实验中,capacity_factor 从 1.25 调到 2.0 之后,全局路由不平等反而更严重了,因为 router 发现即使偏好个别专家也不会 overflow,就没有动力去分散流量。这里面的因果关系值得注意。
5.3 推理框架的调度策略会影响你看到的“路由事实”
还有一个很容易被忽略的点:推理框架本身可能已经对路由做了干预。有些推理框架为了保证吞吐,会强制对特定 batch 内的 token 做负载均衡,或者对某些 expert 做参数预加载。
这会导致你从框架日志里看到的路由分布,和模型原生的路由分布不完全一致。所以做时间窗验收时,最好在框架层和模型原生层各打一份日志,交叉比对,否则你修了半天,可能修的是框架的调度偏好,而不是路由模型本身的问题。
我在 vLLM 上就发现过两者的差异:框架为了减少 expert 参数换入换出,会把同一批 token 尽量路由到同一组 expert,这个行为会放大 token gerrymandering 的表象。
5.4 数据 pipeline 的排序策略不容忽视
最后再补一个训练侧的坑:数据 pipeline 的排序策略会直接制造时间窗路由偏移。如果你的数据 pipeline 按文章长度排序,或者按主题聚类排序,那么在一个 step 里连续喂给模型的样本,可能全都来自同一类主题。
我做过实验:同一份数据集,随机打乱训练时,路由时间窗分布比较平缓;按领域聚类训练时,时间窗里的路由集中度明显上升。这个发现给了我一个很实用的调优思路——如果你的训练 loss 迟迟不再下降,可以考虑先检查一下数据管道是不是在“制造”持续的单向路由压力,而不是急着调学习率。
6. 一套可复制的验收模板和常见问题排查表
6.1 验收流程的最小闭环
最后把我现在用的验收流程整理出来,可以直接抄进自己的项目里。
- 定义验收场景:明确是训练阶段验收、推理阶段验收,还是上线后回归验收。不同场景的时间窗粒度不一样。
- 采集细粒度路由日志:至少包含 batch id、token id、position、layer id、top-k expert id、top-k weight。
- 切分时间窗:同时用固定 token 窗口、滑动时间窗口、任务级窗口三种方式切分。
- 计算窗内指标:高活跃专家占比、低活跃专家占比、路由稳定性、窗口偏移度。
- 对照阈值判定:区分健康、预警、红色警戒三个等级。
- 自动出回归对比:与基线模型或上一版本对比窗口偏移度,超过阈值自动标记。
- 人工复现与归因:对红色警戒的窗口,把对应的 token 段和上下文抽出来,看看是什么内容触发了路由偏移。
6.2 常用问题定位表
| 现象 | 可能原因 | 排查动作 |
|---|---|---|
| 固定位置出现路由尖峰 | system prompt 固定前缀 | 更换措辞或简化前缀 |
| 长文本后半段路由集中 | 上下文自注意力模式趋同 | 按长度分段验收,检查衰减趋势 |
| 并发请求同时慢 | 相似前缀触发同一批专家 | 增加前缀多样性或调整调度策略 |
| few-shot 评测结果波动 | 示例内容引导路由偏移 | 多样化 few-shot 示例 |
| 训练不同 step 波动大 | 数据 pipeline 聚类型排序 | 随机打乱数据或调整 shuffle 策略 |
| 框架日志与模型日志不一致 | 框架做了额外负载均衡 | 双层日志交叉比对 |
6.3 关于阈值调整的个人经验
阈值这个东西,一定要结合具体任务的容忍度来调。如果是离线训练,路由偏移大一点问题不大,只要最终收敛正常,中间窗波动可以容忍。但如果是线上推理,特别是对延迟敏感的场景,指标就要卡得死一些。
我个人的标准是,每次大版本更新,都会重新校准一次阈值,因为模型规模、数据分布、专家数量都变了,旧阈值不一定还适用。校准的方法也很简单:拿一批“已知健康”的历史日志做底,算出各指标的 P90 作为预警线,P99 作为警戒线,然后再人工微调。
这里多提醒一句:不要迷信任何固定的“健康区间”。MoE 的配置空间太大,不同层、不同专家数、不同 top-k 下的健康区间差异很大,只有自己跑出来的基线才最可靠。
这篇文章写到这里,核心的要点基本都覆盖了。对我个人来说,最大的收获不是学会了几个指标,而是改变了一个习惯——再也不看平均负载下结论了。只要是 MoE 架构相关的东西,我第一反应就是问一句话:这个东西在任意一个时间窗里,行为还能不能保持健康?如果你也打算深入调 MoE,我的建议是你也把这个问题当成自己的默认检查项。