统一精排不是简单合模:从 MTFM 看外卖多业务推荐基座

2026-09-22 22 预计阅读时间: 1 分钟
来源: tech.meituan.com AI 摘要 Original link

Disclaimer: This article is an AI-assisted summary. Read it together with the original source when precision matters. The summary may omit context, version differences, or edge cases and is not official documentation.

预计阅读时间:10 分钟

美团在 MTGR 基础上提出统一推荐基座大模型 MTFM,并首次实现外卖多个主要业务共用统一精排模型。这项工作的关键不只是“减少模型数量”,而是让不同业务场景在同一套表示与参数体系中共享知识,同时保留各自的排序目标和决策边界。

公开摘要没有披露 MTFM 的具体网络结构、训练数据或线上指标,因此下面不会猜测其内部实现,而是围绕“多业务统一精排”这一确定方向,拆解工程上需要解决的问题,并给出一个可以运行和改造的简化示例。

统一精排到底统一了什么

典型推荐链路通常包括召回、粗排、精排和重排。精排模型接收用户、商品、上下文及交叉特征,为候选集合计算更精确的分数。过去,不同入口或业务可能分别维护模型:

  • 每个模型拥有独立样本、特征和训练任务;
  • 相似的用户偏好被反复学习,低流量场景难以利用高流量场景的数据;
  • 特征升级、模型发布和回滚需要在多条链路上重复执行;
  • 各模型分数的含义不同,跨场景治理和校准更困难。

统一精排的核心不是把所有样本拼接后训练一个二分类器。更合理的抽象通常包含三层:

  1. 共享表示层:学习用户、商家、商品和上下文中的共性规律。
  2. 场景感知层:将业务标识、入口、时段等条件加入模型,避免抹平场景差异。
  3. 任务或场景输出层:为点击、下单等目标,或不同业务保留独立预测头。

这意味着“统一”与“差异化”必须同时存在。共享不足,模型只是被放进同一个服务;共享过度,则可能让高流量业务支配梯度,损害小业务效果。

真正困难的是数据与目标,而不只是网络结构

多业务模型最容易遇到的是负迁移。两个场景表面上都预测点击,用户意图和曝光机制却可能完全不同。外卖首页中的浏览行为,与明确品类入口中的行为,不能仅凭同名标签就视作同一任务。

落地时应重点检查以下几类问题:

  • 标签口径:点击、有效点击、下单和支付是否拥有一致的归因窗口?
  • 特征语义:同一个字段在不同业务中是否真的代表同一概念?
  • 样本规模:高流量业务是否通过梯度数量压制其他业务?
  • 缺失模式:某些特征只在特定场景存在时,缺失值是否泄露场景身份?
  • 分数校准:统一模型输出能否直接用于多个入口,还是需要分场景校准?

因此,模型上线前不能只观察整体 AUC。至少要按业务、流量层级、新老用户和高低活跃度分别评估,并关注排序指标、概率校准以及核心业务指标。整体指标上涨,可能只是最大业务的样本占比掩盖了局部退化。

一个可运行的多场景精排原型

下面是一个教学性质的 PyTorch 示例。它不是 MTFM 的实现,也不代表美团采用了这种结构;它只是演示一种可实践的设计:共享底座接收连续特征和场景 ID,不同场景使用独立输出头。

运行前安装 PyTorch:

python -m pip install torch

将以下内容保存为 multi_scenario_ranker.py,然后执行 python multi_scenario_ranker.py

import torch
from torch import nn
from torch.utils.data import DataLoader, TensorDataset

# 固定随机种子,便于复现实验
torch.manual_seed(42)

NUM_SCENARIOS = 3
FEATURE_DIM = 8
SAMPLES = 6000

# 构造三个场景的模拟样本。真实项目应替换为日志生成的数据集。
x = torch.randn(SAMPLES, FEATURE_DIM)
scenario = torch.randint(0, NUM_SCENARIOS, (SAMPLES,))

# 不同场景共享一部分规律,同时拥有各自偏好。
shared_signal = 1.2 * x[:, 0] - 0.8 * x[:, 1]
scenario_bias = torch.tensor([-0.7, 0.2, 0.9])[scenario]
scenario_signal = torch.stack([
    0.9 * x[:, 2],
    -1.1 * x[:, 3],
    0.7 * x[:, 4] + 0.4 * x[:, 5],
])[scenario, torch.arange(SAMPLES)]
probability = torch.sigmoid(shared_signal + scenario_signal + scenario_bias)
y = torch.bernoulli(probability)

loader = DataLoader(
    TensorDataset(x, scenario, y),
    batch_size=256,
    shuffle=True,
)

class MultiScenarioRanker(nn.Module):
    def __init__(self):
        super().__init__()
        self.scenario_embedding = nn.Embedding(NUM_SCENARIOS, 4)
        self.backbone = nn.Sequential(
            nn.Linear(FEATURE_DIM + 4, 32),
            nn.ReLU(),
            nn.Linear(32, 16),
            nn.ReLU(),
        )
        self.heads = nn.ModuleList([
            nn.Linear(16, 1) for _ in range(NUM_SCENARIOS)
        ])

    def forward(self, features, scenario_ids):
        context = self.scenario_embedding(scenario_ids)
        hidden = self.backbone(torch.cat([features, context], dim=1))

        # 每个样本只经过它所属场景的输出头。
        all_logits = torch.cat([head(hidden) for head in self.heads], dim=1)
        return all_logits.gather(1, scenario_ids[:, None]).squeeze(1)

model = MultiScenarioRanker()
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
loss_fn = nn.BCEWithLogitsLoss()

for epoch in range(8):
    total_loss = 0.0
    for features, scenario_ids, labels in loader:
        logits = model(features, scenario_ids)
        loss = loss_fn(logits, labels)

        optimizer.zero_grad()
        loss.backward()
        optimizer.step()
        total_loss += loss.item() * labels.size(0)

    print(f'epoch={epoch + 1} loss={total_loss / SAMPLES:.4f}')

# 对三个场景中的候选分别打分。
model.eval()
with torch.no_grad():
    candidates = torch.randn(3, FEATURE_DIM)
    candidate_scenarios = torch.tensor([0, 1, 2])
    scores = torch.sigmoid(model(candidates, candidate_scenarios))
    for sid, score in zip(candidate_scenarios.tolist(), scores.tolist()):
        print(f'scenario={sid} score={score:.4f}')

将这个原型迁移到真实系统时,需要替换的远不止模拟数据:

  • 用稳定的 scenario_id 映射替代示例中的整数;
  • 加入用户、商家、商品和交叉特征的嵌入表示;
  • 根据各业务样本量设置采样比例或损失权重;
  • 为点击、下单等不同目标增加多任务输出头;
  • 保存特征字典、模型权重和校准参数,确保训练与在线推理一致。

如果高流量场景仍占据绝大多数批次,可以使用加权损失。设每个样本的场景权重为 w_s,二分类损失可改为:

loss_fn = nn.BCEWithLogitsLoss(reduction='none')
scenario_weights = torch.tensor([1.0, 1.8, 2.5])

raw_loss = loss_fn(logits, labels)
loss = (raw_loss * scenario_weights[scenario_ids]).mean()

权重不应凭经验长期固定。更稳妥的做法是结合样本规模、离线指标和线上实验逐步调整,并监控某个业务获得收益时,其他业务是否发生系统性退化。

从单模型实验走向统一基座

统一模型改变的不只是训练任务,还会影响特征平台、模型服务和发布机制。可以按以下顺序降低迁移风险:

  1. 统一数据定义:先对齐曝光、标签、特征时间和归因窗口,再讨论合并训练。
  2. 建立分场景基线:保留原模型结果,逐场景比较统一模型的增益与损失。
  3. 从共享底座开始:先共享稳定的底层表示,对差异明显的业务保留独立头或适配层。
  4. 限制负迁移:采用平衡采样、损失加权或梯度治理,并按场景监控指标。
  5. 逐步灰度发布:先覆盖低风险流量,确保模型、特征和降级链路都可回滚。
  6. 评估系统收益:除了效果指标,还应统计训练成本、推理延迟、显存占用和迭代周期。

MTFM 所代表的方向说明,推荐系统的复用单位正从单个特征、单个模块,进一步走向跨业务的统一精排基座。但统一模型不是天然更优:它用更强的知识共享能力,换来了更复杂的数据治理、目标协调和故障影响面。真正值得追求的不是“一个模型覆盖所有场景”,而是让共享发生在有效的位置,让差异保留在必要的位置。


相关推荐