当推荐系统每天处理数十亿次用户互动时,用户刚刚看过什么、点击过什么、停留了多久,以及这些行为发生的先后顺序,往往比一组静态标签更能说明当前意图。Meta 关于广告排序的最新实践,将讨论重点从“如何加入序列特征”推进到了“如何让序列建模在大规模系统中持续扩展”:模型结构、数据处理和计算预算需要一起设计。
静态特征无法完整表达用户意图
传统广告排序系统通常依赖稀疏特征,例如用户兴趣标签、广告类别、设备信息、地域和历史统计值。这些特征仍然有价值,但它们会丢失行为发生的顺序和时间关系。
例如,下面两种行为历史可能包含完全不同的意图:
用户 A:浏览跑鞋 -> 搜索马拉松训练 -> 点击运动手表
用户 B:点击运动手表 -> 浏览跑鞋 -> 观看旅行视频
如果只把行为聚合成“对跑步、运动手表和旅行感兴趣”,排序模型很难区分用户当前更可能购买什么。序列特征则可以保留以下信息:
- 行为的先后顺序:搜索通常比普通浏览更接近明确意图。
- 行为间隔:连续几分钟内发生的行为,可能属于同一个任务。
- 行为类型:曝光、点击、停留、收藏和转化的信号强度不同。
- 跨产品上下文:用户在内容、广告和其他产品中的互动可以共同描绘兴趣变化。
这并不意味着静态特征会被完全替代。更实际的方向是让序列表示与已有的稀疏特征、上下文特征和广告特征共同参与排序。
多阶段架构解决规模问题
用户行为序列天然会不断增长,但广告系统不可能对每个候选广告都运行一个昂贵的大模型。一个可行的工程分工是把排序拆成多个阶段:
- 候选生成:从海量广告中快速找出与用户和上下文相关的一小批候选。
- 轻量预排序:使用较低延迟的模型过滤候选,控制后续计算量。
- 序列增强排序:对保留下来的候选使用更丰富的用户行为序列和广告上下文。
- 最终决策:综合预测结果、业务约束和展示策略,确定最终排序。
多阶段并不只是“把模型串起来”。每个阶段都有不同的目标函数和资源约束。候选生成更重视召回覆盖率,预排序更重视吞吐量,最终排序则可以承担更高的单请求计算成本。
这种分工也改变了序列模型的使用方式:完整历史序列不一定需要进入每一个阶段。早期阶段可以使用压缩后的用户表示或短窗口特征,后期阶段再使用更细粒度的行为顺序和时间信息。
从单点优化转向规模定律
当序列模型逐步扩大时,开发者需要关注的不只是“模型是否更大”,还要观察增加数据、参数和计算量是否带来稳定收益。这里可以把规模定律理解为一种实验方法:在可控条件下改变模型规模、训练数据规模或计算预算,记录离线指标、在线指标、延迟和成本的变化。
一个简单的实验表可以包含:
| 实验变量 | 示例取值 | 需要观察的指标 |
|---|---|---|
| 序列长度 | 32、128、512 | 排序质量、延迟、显存 |
| 表示维度 | 128、256、512 | 增益、模型大小、服务成本 |
| 训练数据量 | 25%、50%、100% | 数据效率和过拟合 |
| 计算预算 | 不同训练步数或 GPU 小时 | 指标提升是否递减 |
重要的是,离线指标的提升不能单独决定上线方案。广告排序还受到请求延迟、峰值吞吐、模型更新频率、特征新鲜度和基础设施成本的限制。一个更大的序列模型如果只带来很小的增益,却显著增加线上延迟,就不一定适合放在最终排序阶段。
一个可改造的多阶段序列排序原型
下面的 Python 示例是一个最小化的本地原型,用于演示“短序列预排序 + 更长序列精排”的思路。它不代表 Meta 的内部实现,模型和分数只是可运行的工程示例。运行前只需要安装 Python 3.10 或更高版本,不依赖第三方库。
from dataclasses import dataclass
from math import exp
from typing import Iterable
@dataclass(frozen=True)
class Ad:
ad_id: str
topic: str
quality: float
@dataclass(frozen=True)
class Event:
topic: str
kind: str
age_hours: float
EVENT_WEIGHT = {
"impression": 0.05,
"view": 0.20,
"click": 0.60,
"save": 0.90,
"conversion": 1.20,
}
def sequence_score(ad: Ad, events: Iterable[Event], limit: int) -> float:
"""Use recent events as a tiny sequence representation."""
score = 0.0
recent_events = list(events)[:limit]
for event in recent_events:
if event.topic != ad.topic:
continue
recency = exp(-event.age_hours / 24.0)
score += EVENT_WEIGHT.get(event.kind, 0.0) * recency
return score
def rank_ads(events: list[Event], ads: list[Ad], shortlist_size: int = 3) -> list[tuple[str, float]]:
# Stage 1: cheap pre-ranking with a short behavior window.
pre_ranked = sorted(
ads,
key=lambda ad: sequence_score(ad, events, limit=8) + 0.1 * ad.quality,
reverse=True,
)[:shortlist_size]
# Stage 2: richer ranking with a longer behavior window.
final_ranked = sorted(
pre_ranked,
key=lambda ad: sequence_score(ad, events, limit=32) + 0.2 * ad.quality,
reverse=True,
)
return [(ad.ad_id, round(score, 4)) for ad, score in [
(ad, sequence_score(ad, events, 32) + 0.2 * ad.quality)
for ad in final_ranked
]]
if __name__ == "__main__":
user_events = [
Event("running", "click", 2),
Event("running", "view", 5),
Event("travel", "click", 12),
Event("running", "save", 30),
]
candidates = [
Ad("ad-running-shoes", "running", 0.8),
Ad("ad-travel-bag", "travel", 0.9),
Ad("ad-camera", "photography", 1.0),
Ad("ad-running-watch", "running", 0.7),
]
print(rank_ads(user_events, candidates))
在真实系统中,可以把这个原型替换为以下组件:
- 用事件 ID、时间间隔和行为类型 embedding 表示每个行为。
- 用 Transformer、注意力模块或其他序列编码器生成用户表示。
- 在不同排序阶段使用不同的序列长度和模型宽度。
- 对线上延迟设置硬性预算,并记录每个阶段的候选数量和耗时。
- 通过离线回放和在线实验验证序列表示是否带来真实增益。
落地时要守住几个边界
数据新鲜度比盲目加长序列更重要。 很久以前的行为可能对当前意图帮助有限,还会增加存储、传输和计算成本。可以通过时间衰减、窗口截断或分层摘要保留更有价值的信息。
序列越长,训练和服务成本越高。 长序列会带来更高的显存、计算和通信开销。多阶段架构的价值就在于:只把昂贵计算留给最有希望的候选。
离线收益必须与线上约束一起评估。 需要同时记录预测质量、延迟、吞吐、资源消耗和模型更新成本。否则,实验可能得到一个离线更强、线上无法稳定运行的模型。
隐私和数据治理不能后置。 用户行为序列包含高密度的兴趣与意图信息。实际部署时应明确数据保留周期、访问权限、用途限制和删除机制,并对跨产品信号进行严格治理。
给工程团队的采用清单
- 先确认序列信号是否解决了静态特征无法表达的顺序或新鲜度问题。
- 为候选生成、预排序和精排分别设定延迟与吞吐预算。
- 用小规模实验测量序列长度、数据量和模型容量的边际收益。
- 同时关注离线指标、在线指标、成本和系统稳定性。
- 将用户序列视为可治理的数据资产,而不是无限增长的缓存。
从用户行为序列走向规模定律,核心变化不是简单地把模型做大,而是把数据、模型、阶段划分和系统资源放进同一个设计框架。只有当每一阶段都清楚自己要优化什么,序列学习才能从研究原型变成可持续运行的广告排序能力。