☰
多目标实时推荐系统升级实践:从MMoE架构到近线训练全链路解析
2026/10/19 2:05:54 网站建设 项目流程

1. 业务侧的压力,来自三个反常的信号

先说背景。过去两年我们团队在维护一个内容社区的推荐服务,模型经历了三个阶段:最早是离线训练的LR和GBDT,后来换成了Wide&Deep风格的深度排序模型,特征和模型参数一天一更新。这套体系放在一年前还算够用,但最近半年,我们接连接到几类反馈,让我意识到“静态模型”这条路真的走到头了。

第一个信号是热点追不上。社区里偶尔会冒出一波小范围的热点话题,从关键词突增到用户集中浏览,通常只有几个小时的窗口期。我们的模型每天凌晨定时调度,早上八点才把前一天的样本训练完推上线。等新模型生效,热点基本已经凉了。更尴尬的是,模型完全感知不到“用户刚才刷了三条同类型内容”这种短周期行为,推荐流里的内容永远是“昨天喜欢的东西”,而不是“此刻想看的这条”。第二个信号来自业务方。我们原本的排序目标只有一个:点击率。于是出现了很典型的“点击欺骗”现象——标题足够夸张、封面图够吸睛的内容拿走了大量曝光,但用户点进去不到三秒就退出来,除了击率数字好看,对留存、时长、互动没有任何贡献。业务方开始要求同时优化点击率、有效阅读时长、互动率、甚至后续的付费转化。单目标模型无论怎么调参,都没办法同时满足这么多相互拉扯的目标。第三个信号更隐蔽,是我们查数据时才发现的。用户在同一天内先后看了A类目的内容,又切到B类目,离线特征用的是天级聚合统计,把两类行为模糊成了一锅粥。我们再怎么加特征、加深网络,离线评测指标都是平的。

这三个信号的本质其实是同一件事:推荐系统面对的已经是一个实时变化、多目标权衡、行为复杂交织的环境,而我们的模型还是“昨天训练、今天上线、只回答一个问题”的静态单目标体。于是我们立项做了一件事——把整个推荐排序链路升级为实时多目标深度学习系统。这篇文章就完整记录这次升级的过程,包括多目标模型怎么设计、实时链路怎么搭、在线训练怎么避坑、上线后踩了哪些雷。如果你也在经历从“天级更新+CTR单目标”向“小时级甚至分钟级更新+多目标优化”的迁移,这篇内容应该能帮你省掉不少弯路。

2. 多目标建模的架构演进与最终选型:从ESMM到MMoE变体

2.1 多目标不是一个头加一条loss那么简单

在最开始,我们以为多目标优化就是把输出层的sigmoid从1个换成3个,loss各算各的,加起来反传。结果第一次实验就给我们上了一课。三个目标的loss直接相加后,整个网络的loss曲线像心电图一样抖动,点击率模型这一路梯度比较猛,把共享层的参数牢牢拽向“只顾点击”的方向,阅读时长和互动率两个塔学了个寂寞。评测指标出来,点击率小涨,时长指标反而跌了1.4%。

这里面的问题在于,多个目标之间既共享数据分布,又存在复杂的竞争关系。一个用户点击了内容但立刻跳出,对点击率目标是正向样本,对时长目标就是负向样本。如果简单共享底层参数,梯度更新方向会互相冲突。业界在解决这个问题上有一个清晰的演进脉络:先是阿里提出的ESMM,专门解决点击率到转化率这种“漏斗式”场景下的样本选择偏差;后来是Google提出的MMoE,通过门控网络让每个目标从共享专家网络中按需取用信息;再后来是腾讯的PLE,在MMoE的共享专家之外,给每个目标增加独占专家,减少跷跷板效应。

2.2 为什么我们选了MMoE变体而不是直接上PLE

做选型的时候,我们在MMoE和PLE之间犹豫了很久。从理论效果上说,PLE在任务相关性低、冲突明显的场景里通常优于MMoE,因为它给了每个目标独立的表达空间。但PLE的问题是参数量和训练复杂度都明显上涨,专家数量翻倍,门控网络也要跟着膨胀,对在线推理的时延也有压力。我们当时是多目标系统搭建的第一版,团队对实时链路的稳定性还没完全跑通,我倾向于先把复杂度控制住。

最终我们选的是MMoE的一个变体:共享专家层保留8个专家网络,每个目标塔通过独立的门控网络对8个专家做加权求和,得到各自的融合表征。为什么是8个专家?我们做过一组实验,4个专家时多目标之间的干扰仍比较明显,12个专家相比8个的提升大约只有0.3个百分点的离线AUC,但参数量和推理耗时增加了约28%,性价比不划算。另外,我们给门控网络加了一个dropout,比例设在0.1,强制每个专家不能只服务某一条特定路径,降低多目标之间的切分壁垒。

我用一段伪代码把核心结构写出来,方便你对照自己的框架改造:

class MMoELayer(nn.Module): def __init__(self, num_experts=8, num_tasks=3, expert_dim=64): super().__init__() self.num_experts = num_experts self.num_tasks = num_tasks # 共享专家网络,输入维度由上层特征拼接维度决定 self.experts = nn.ModuleList([ nn.Sequential( nn.Linear(input_dim, expert_dim), nn.ReLU(), nn.Linear(expert_dim, expert_dim) ) for _ in range(num_experts) ]) # 每个任务一个门控网络,对专家输出做加权 self.gates = nn.ModuleList([ nn.Sequential( nn.Linear(input_dim, num_experts), nn.Softmax(dim=-1) ) for _ in range(num_tasks) ]) # 每个任务一个塔,输出各自目标 self.towers = nn.ModuleList([ nn.Sequential( nn.Linear(expert_dim, 32), nn.ReLU(), nn.Linear(32, 1) ) for _ in range(num_tasks) ]) def forward(self, x): expert_outs = [expert(x) for expert in self.experts] # 列表长度 num_experts outputs = [] for i in range(self.num_tasks): gate_weight = self.gates[i](x) # [batch, num_experts] # 加权融合专家输出 fused = sum( gate_weight[:, j].unsqueeze(1) * expert_outs[j] for j in range(self.num_experts) ) outputs.append(self.towers[i](fused)) return outputs # 三个目标各自的logit

2.3 损失函数设计:权重怎么定才不打架

多目标训练的另一个大坑是loss权重。固定权重最大的问题是,不同目标的学习难度和梯度量级天然不一样。我们一开始用固定权重1:1:1,训练到第三个epoch就发现阅读时长目标的loss贡献占比跌到总loss的不到15%,因为它的梯度数值天然偏小,被点击率目标的梯度压住了。

后来我们参考了两个方案。第一个是不确定性加权,把每个目标的loss理解为一个高斯分布噪声的负对数似然,让模型自动学习每个目标的置信度权重;第二个是梯度归一化,动态地把每个目标塔回传到共享层的梯度拉到一个相似的数量级,同时限制最大梯度缩放倍数。我们最终采用的是一套结合方案:基础权重按业务优先级先设为1.2:0.9:0.9,然后在每个batch反传前对共享层的梯度做一次归一化,将三路梯度的L2范数缩放至相同水平,最后再乘回各自权重。这样做之后,三个目标在共享层的“话语权”相对均衡,训练也稳定了很多。

还需要特别注意负样本的处理。阅读时长这个目标,我们把“曝光未点击”和“点击后时长极短”统统视为负样本,但负样本和正样本的比例在真实流量里可以差到50倍。如果不做样本加权,模型很容易把所有东西都预测成低时长。我们采用的方案是:负样本采样比例控制在5:1以内,并给正样本额外加上时长分桶后的倍数权重——时长超过60秒的样本权重是5秒以下样本的4倍,让模型真正去区分“优质长阅读”和“误点击跳出”。

2.4 样本观测偏差:回归到真实的业务目标

模型结构搭好之后,我们回过头去重新审视样本标注的问题。推荐系统里最容易犯的错误是拿“曝光后点击”当唯一事实,这会把严重的样本选择偏差引入模型。用户之所以看到某条内容,是被上一版模型排到前面的,那些没被曝光过的好内容在样本里根本不存在,模型永远学不到它们。我们的做法是为每个候选item在曝光日志基础上补充了一路“随机探索流量”的样本,每天约有5%的请求完全按随机策略分配item,保证冷门但优质的内容也有机会出现在样本空间里。探索流量在短期内牺牲了一点线上点击率,但换来了更干净的正样本分布,对阅读时长和互动率这两个目标的提升贡献显著。

3. 实时链路设计:从行为发生到特征进入模型,必须控制在60秒内

3.1 链路总览:每一环的职责划分

选型和模型结构都定了之后,真正困难的部分才开始。多目标模型再先进,如果喂给它的特征还是天级批处理统计,实时性依然无从谈起。我们搭的实时特征链路,核心目标是一句话:用户产生行为后60秒内,这个行为的影响能够出现在下一次推荐请求的特征里。

链路大致分五段:客户端和服务端行为打点,进入消息队列缓存;流式计算引擎做窗口聚合和实时统计;计算结果写入在线特征存储;推荐服务在请求时合并实时特征与静态特征;特征数据最终随样本回传,进入训练管道。我们用Kafka承接打点数据,流量峰值大概每秒几万条事件,分区数按用户id做哈希,保证同一个用户的行为在流式计算里能按序处理。流式计算选的是Flink,主要看中它的窗口机制和状态管理能力,比我们自己用消息队列加Redis手动聚合省心得多。

我在设计链路时最重要的一条原则是:实时链路的每一层都要可观测、可回放。Kafka里的事件必须有事件时间和进入链路时间两个字段,Flink计算的每个窗口结果要能输出到旁路日志,Redis里的特征要能随时和离线数据做对比。没有这些可观测能力,后面线上出了问题,排查起来会非常痛苦。

3.2 特征分两层:用户事实特征与统计特征要分开设计

实时特征不能简单地把离线的按天聚合改成按分钟聚合。我们实际把特征分成两层。第一层是事实特征,直接描述用户刚做了什么,比如“最近5分钟点击的item_id序列”“最近30分钟浏览的品类分布”“是否新用户”等,这类特征不需要太多历史累积,要求的是新鲜度。第二层是统计特征,比如“用户24小时总点击次数”“7天平均阅读时长”“长期互动率”等,这些特征变化慢,完全没必要实时重算,我们在在线特征库里冷启动时一次性灌入,流式计算只负责对它的短期分量做增量更新。

分层的意义在于控制计算成本和特征延迟。如果我们对所有特征都按分钟窗口重算,Flink的CPU和Redis的写入量都会翻好几倍,而收益微乎甚微。最终的实时特征组合是:长期统计特征按天更新,中期统计按小时级小时聚合,只有“短期行为序列”和“短期统计量”真正走秒级到分钟级的实时链路。

3.3 离线在线一致性:一个容易让模型悄悄失效的深坑

这个坑我们会专门放在最后的踩坑章节里详述,但设计阶段就必须要有一个意识:实时特征的生产逻辑必须与离线训练样本的特征逻辑完全一致。我们当时差点犯的错误是,离线训练时对“用户最近30分钟点击序列”的统计,用的是行为日志全部落库后精确计算的;而在线推理时,这个特征来自实时流式窗口。两边如果对“行为发生时间归属窗口”的定义有偏差,哪怕只差几秒,模型训练时学到的特征分布和在线推理时的特征分布就是两个分布,表现会明显下滑。

我们最终的解法是统一特征计算血缘:把“最近30分钟点击序列”“最近1小时品类分布”等实时特征的计算逻辑抽成一个公共特征算子库,离线的批量管道和在线Flink作业各自调用同一份算子代码,并用同一份参数配置。这个实践我们内部叫“一套代码,两条管道”,是这次升级里性价比最高的决策之一。

3.4 特征存取与性能预算:不能拖垮推荐主链路

实时链路还有一个容易被低估的问题是延迟预算。推荐服务本身对延迟有严格的要求,我们当时内部定的SLO是端到端p99小于180毫秒。实时特征链路如果不能在预算内返回结果,宁可走降级方案,也不能让主请求超时。

我们把所有实时特征值统一放在Redis里,key设计为user:{user_id}:rt_features,field分别是各个实时特征名,value直接存序列化后的字符串或紧凑的数组编码。聚合查询时,用一条HMGET把所有需要的实时特征一次取回,避免多次网络往返。进一步优化时,我们在Redis前面加了一层本地缓存,把当前请求用户最近5秒内取过的特征值缓存下来,命中率大约在40%左右,省掉了不少内部网络开销。

我建议任何要做实时推荐的团队,在动手之前先画一张延迟预算表,类似下面这样:

链路环节预延迟实际优化手段
行为打点上报小于5秒客户端批量上报,合并小请求
消息队列缓冲小于3秒分区合理,避免热点用户集中在同分区
Flink窗口计算小于30秒滑动窗口粒度5秒,状态后端用RocksDB
特征写入Redis小于10秒批量Pipeline写入,减少RTT
推荐请求读取特征小于15毫秒HMGET批量取,本地缓存兜底
模型推理小于20毫秒GPU/CPU选择与批量配置稍后详述

这样一张表贴在工位上,每个环节一超时立刻就能定位到问题,比事后对着链路瞎猜高效得多。

4. 模型更新链路:近线训练与在线学习,而不是无脑实时训练

4.1 不是所有模型都值得“在线训练”

系统搭建初期,有同事提出干脆直接上完整的在线学习:每来一批用户行为就更新一次模型参数,让模型永远“活在当下”。我对此一直持保留态度。在线学习看起来美好,但工程上很难做稳。训练样本的分布随时在波动,凌晨两点和下午三点的用户行为模式完全不同;实时样本噪音很大,容易把模型带到沟里;一旦训练管道出现问题,模型会在用户完全无感知的情况下悄悄劣化,等到发现时已经造成了大面积指标下跌。

我们最终采用的是近线增量训练方案:每10分钟从消息队列里抽取一批新产生的训练样本,把这批样本与最近24小时的历史样本按比例混合,对线上模型做一次小步长的增量训练,训练完成后将新模型参数同步到推理服务。这个方案兼具“更新及时”和“训练稳定”两个优点:10分钟级别对绝大多数推荐场景已经足够灵敏,样本混合则缓解了单一窗口内噪音过大的问题。

4.2 增量训练的参数设置与模型保护策略

增量训练并不是简单地用新数据继续跑全量训练。我们做了三件事来保证增量更新的安全。第一,学习率大幅降低。全量训练时初始学习率用1e-3,增量训练阶段我们控制到1e-5到3e-5之间,并且使用Adam优化器配合梯度裁剪,梯度范数超过5就截断,防止个别异常batch把共享层参数顶飞。第二,混合样本比例固定。每个增量训练batch由当前10分钟新样本(约40%)和回放的历史样本(约60%)组成。历史样本是从最近24小时样本里随机抽取的,这一设计是为了防止模型对最近一小段时间的过度拟合,我会在后面踩坑部分用实际案例说明。第三,新模型不直接全量上线,而是先推送到灰度服务。我们内部设了一个至少观察1小时的灰度期,对比新模型的点击率、阅读时长、互动率三个核心指标与基线模型的差异,任何一项下降超过0.5%就自动阻断,保留上一版模型继续服务。

4.3 双模型结构:线上预测模型与训练模型的解耦

这里我要特别强调一个架构设计:线上推理服务和增量训练服务之间一定要做模型版本的解耦。我们采用了“训练模型不停机产出,线上模型按版加载”的方式。增量训练服务每10分钟产出一个新版本模型,推送到一个模型仓库;线上推理服务启动时加载一个版本,然后在后台定期检查模型仓库的版本变化,发现新的稳定版本再热加载切换。整个过程用户可以无感知,服务不会中断。

灰度切换时,我们在同一个推荐请求里让新旧模型各自计算分数,但只有新模型的分数真正用于排序。这样AB实验的流量分割就能精确到请求级别,并且能通过对比同一批请求下新旧模型的分数差异,尽早发现模型是否出现极端预测。

5. 推理服务性能调优与多目标融合:线上既不超时,排序又要符合业务利益

5.1 推理时延的三大瓶颈与对应解法

多目标模型上线前,我们做了压测,发现对比原单目标模型,p99延迟涨了将近30毫秒。这在推荐场景里不是个小数目。排查下来瓶颈主要在三处:MMoE模块的多次专家网络前向计算、多目标各塔的全连接计算、特征embedding的查询。

针对第一处,我们把专家网络从在线计算的循环展开改成按batch并行计算,8个专家一次前向全部出结果,再利用门控权重做加权求和,减少Python层循环的额外开销。针对第二处,我们把各目标塔的隐藏层从统一的32维降到16维,离线指标几乎没有损失,延迟降了4毫秒。第三处是最大的优化点:原本用户和item的embedding全部存在远端存储里,每次请求都要网络读取,我们改成了本地进程内预加载热embedding,只对未命中的冷embedding走远端查询,并把这部分查询结果异步写入本地缓存。整体推理p99优化后排到了14毫秒以内。

另一个维度是批处理。推荐服务通常需要一次性给同一用户生成几十个候选item的排序分数,这意味着一个用户请求内部天然就是一个batch。我们把这个batch从Python层面直接切成固定大小的块传给模型,配合CPU对int8的推理优化,把模型部分的耗时控制在很低水平。

5.2 多目标融合:不是简单相乘,而是计算期望价值

模型输出三个分数:点击率预估、阅读时长分桶后的期望值、互动率预估值。这三个分数不能简单乘在一起,否则会对长内容推荐产生明显偏差——长内容天然点击率偏低但时长高,简单相乘会把这类优质内容全部压到后面。

我们采用的融合公式是:

final_score = math.pow(pctr, alpha) * math.pow(pduration, beta) * math.pow(plike, gamma)

这里的alpha、beta、gamma是三个业务可调参数,初期根据业务目标设为0.5、0.8、0.3。为什么点击率权重最低?因为点击率已经通过多目标结构和其他两个目标产生了信息交互,如果融合阶段再给它最高权重,等于又把“点击欺骗”问题请了回来。实际调参中我们发现,alpha从1.0降到0.5,整体时长指标提升了约9%,点击率只下降了不到2%,净收益明显。这个融合公式的好处是参数少、可解释性强,业务方也很容易理解。

5.3 上线验证:AB实验到底应该看什么

模型上线前我们设计了两层验证。离线层看AUC之外,一定要看多目标的联合指标:常见的做法是算GAUC和用户维度上的各目标提升,而不是单纯看全局AUC。全局AUC容易被头部热门内容主导,用户维度上的指标才能反映个性化效果的真正变化。在线层我们随机分流,实验组和对照组各占10%流量,连续观测一周。我当时特别叮嘱数据同事,不要只看一个指标,要把“曝光点击率、点击后有效阅读时长、互动率、次留”四个指标绑在一起看,取综合增益。最终全增量上线后的结果,阅读时长提升约7%,互动率提升约5%,点击率基本持平,没有出现跷跷板式的大幅波动。

6. 踩坑实录:三个让我熬到凌晨的问题

6.1 多目标loss互相拉扯:点击率不升反跌,门控网络成了摆设

第一次把MMoE结构跑起来是在离线评测环境,我们满怀期待地等结果,看到的却是点击率AUC比之前的单目标模型还低了0.8个百分点,阅读时长和互动率也没有明显提升。明明多目标应该共赢,怎么会全输?

我把目光集中在门控网络的权重分布上。打印出来一看,三个目标的门控权重分布几乎一模一样,而且都集中在同一个专家上,其他7个专家的权重趋近于零。也就是说,虽然结构上是多门控,模型实际学出来的单路径表达能力,还不如一个简单的共享底层。根因在于任务之间的相关性被高估了,点击信号和阅读时长信号虽然语义相关,但梯度方向在很多样本上是在打架的,MMoE里门控网络太容易收敛到“一条路走到黑”的解。

这一轮的修复我做了三件事:门控网络Softmax前加温度系数降低方差,让每个目标更倾向组合多个专家;给门控加dropout,结构里设0.1;再把专家数量从8提到10。改完重训之后,三路门控的权重分布终于出现了明显分化,点击率AUC恢复到了单目标模型的水平以上,阅读时长AUC开始上涨。后来回头看,如果一开始就在门控上做这样的约束,至少能省我一整周的调试时间。

6.2 实时特征延迟导致线下线上不一致:同一套特征,两个分布

第二个坑出现在实时链路稳定运行两周之后。某天晚上线上阅读时长指标突然出现了一个下探又回升的尖峰,但当天既没有发版也没有调整流量。把日志梳理了一遍,发现时间是Flink集群做了一次比较大的状态恢复,部分实时特征的产出延后了几分钟。为什么几分钟的延后就能影响指标?因为我们的训练样本里,特征记录的是“日志回填时刻”的实时特征值;而在线推理时,特征是请求那一个瞬间从Redis里取到的实时特征值。如果特征产出延迟,在线推理时取到的特征可能是“旧状态”,而训练样本里对应的却是延迟后的“新状态”,两边特征分布的语义就错位了。

解决这个问题的思路是,把训练样本的特征取用时间对齐到“预测发生的时刻”而不是“行为落库的时刻”。我们给每条训练样本都记录了“模型预测时的请求时间”,离线生成样本时,实时特征来自预测时间之前、按真实延迟窗推算的特征快照,而不是等日志完整之后再统一补齐。这套“时间旅行回放”机制做完后,在线指标的尖峰明显减少。我强烈建议每个做实时推荐的人,在你的训练样本schema里一定要有“request_time”和“feature_lag”这两个字段,缺一个,出问题时你都无从查起。

6.3 模型遗忘:兴趣漂移之后,模型疯狂推荐旧内容

第三个坑发生在系统稳定运行一个多月后,我们发现部分高活跃用户的推荐多样性在肉眼可见地下降,点进去的内容越来越集中在他们两周前喜欢过的几个品类。我们第一时间怀疑是探索流量不足,检查后发现随机探索比例一直稳定在5%,理论上不至于造成这么明显的多样性坍缩。于是我把增量训练的样本分布拉出来看,发现近线训练使用的混合样本池里,历史回放样本权重虽然维持在60%,但“历史样本”的取样范围是最近24小时——在这个时间尺度上,模型已经被一天之内的高频用户行为完全主导了。

问题本质是缺少更长时间尺度的样本回放,模型逐渐遗忘了用户两周前的兴趣偏好。这就像一个人只记得昨天发生的事,却忘了过去一个月形成的习惯。我们最终的解法是把回放样本池按时间衰减分为三层:最近6小时占30%,最近7天占50%,近30天抽样占20%。这样既保证了快速反应,又保住了长期记忆。加了这一层之后,推荐多样性的指标显著回升,高活跃用户的次留也稳住了。

6.4 一次典型线上事故的完整排查复盘

为了让你对排查链路有更直观的印象,我把6.2里提到的那次指标异常完整复盘一遍。现象是阅读时长指标在22点06分开始下探,22点30分左右跌到谷底,22点54分左右开始回升,全程没有发版。我们第一反应是查模型灰度,确认无异常后查实时特征链路。看Flink的监控面板,发现22点05分到22点18分之间,实时特征产出的p95延迟从正常的2.3秒一路涨到11秒,这段时间正好对应指标下探的起点。我们紧接着对比了线上Redis中特征值的更新时间,确认“最近5分钟点击序列”特征在这个窗口里出现大量陈旧值。再查训练管道,发现这段窗口产生的训练样本已经被收入近线训练的样本池,样本中的实时特征值受到了延迟的影响。整个链路闭环后,根因非常清晰:实时特征的延迟异常通过“样本与线上特征语义错位”传导到了模型更新,又因近线训练每天多次更新,影响被迅速放大,直到Flink状态恢复、新一批正常样本逐步稀释旧样本,指标才逐渐回归。

复盘下来我们做了三件事:第一,给实时特征延迟加了监控报警,p95超过5秒就触发值班;第二,把特征延迟超过10秒的样本直接丢弃,不进训练样本池,宁可用稍少但干净的样本;第三,在Flink作业里加了故障自动恢复和状态Checkpoint的定期验证流程。这三件套上线后,类似问题再没出现过。

如果让我给这套系统总结一句经验,那就是:多目标实时推荐系统不是模型的堆砌,而是一条从行为发生到训练反馈的全链路工程。模型结构只占20%的工作量,剩下的都是特征的一致性、训练更新的稳定性、故障恢复的兜底策略。把这80%做好,你那20%的模型才能安心地往前跑。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询