Uber Eats 如何用实时序列特征与列表级生成式推荐重塑首页 Feed

2026-05-22 45 预计阅读时间:1 分钟
来源:infoq.com AI 摘要 原文链接

免责声明:本文为 AI 摘要整理,建议结合原文阅读。摘要可能省略上下文、版本差异或边界条件,不作为官方说明。

预计阅读时间:13 分钟

Uber Eats 的首页推荐长期依赖手工特征和逐条打分——用户点了一个汉堡,系统 24 小时后才知道这件事,再逐个给候选餐厅算分数排序。这套流程在冷启动和实时意图捕捉上都有明显短板。最近 Uber 工程团队把整个 Home Feed 推荐系统翻了一遍:特征从手工拼接到 Transformer 序列建模,新鲜度从天级压缩到秒级,排序从逐条打分切换到列表级生成式推荐(Generative Recommender, GenRec)。效果是首页 Feed 更"跟手",上下文感知更强。

从手工特征到 Transformer 序列建模

旧方案的特征工程是典型的大数据团队做法:几十个统计特征(过去 7 天订单数、偏好品类占比、平均客单价……)拼成一个大向量,喂给 XGBoost 或浅层神经网络。问题在于——这些特征是快照,不是轨迹。用户连续浏览了三家日料店又跳出,手工特征只能事后统计"日料偏好上升",无法捕捉"正在找日料但还没下单"的实时意图。

新方案用 Transformer 对用户近期行为序列做建模:每次点击、浏览、加购、下单都作为一个事件 token,按时间顺序输入序列模型。Transformer 的自注意力天然能捕捉"刚看了三家日料 → 第四条该推什么"这种上下文依赖,比手工统计特征灵活得多。

下面是一个最小化的用户行为序列建模示例,用 PyTorch 演示核心思路:

import torch
import torch.nn as nn

# 假设行为词表:click, view, add_cart, order + 品类/餐厅 ID
VOCAB_SIZE = 5000
EMB_DIM = 64
MAX_SEQ_LEN = 50  # 最近 50 个行为事件

class BehaviorSequenceModel(nn.Module):
    """最小化 Transformer 序列模型,用于用户实时行为编码"""
    def __init__(self):
        super().__init__()
        self.event_emb = nn.Embedding(VOCAB_SIZE, EMB_DIM)
        self.pos_emb = nn.Embedding(MAX_SEQ_LEN, EMB_DIM)
        encoder_layer = nn.TransformerEncoderLayer(
            d_model=EMB_DIM, nhead=4, dim_feedforward=128, dropout=0.1, batch_first=True
        )
        self.transformer = nn.TransformerEncoder(encoder_layer, num_layers=2)
        self.head = nn.Linear(EMB_DIM, 128)  # 输出用户实时意图向量

    def forward(self, event_seq):
        # event_seq: (batch, seq_len) — 最近行为的 token ID 序列
        seq_len = event_seq.size(1)
        positions = torch.arange(seq_len, device=event_seq.device)
        x = self.event_emb(event_seq) + self.pos_emb(positions)
        x = self.transformer(x)
        # 取最后一个位置作为用户实时意图表示
        user_intent = self.head(x[:, -1])
        return user_intent

# 模拟一条用户行为序列:view_sushi → click_sushi → view_ramen → click_ramen → view_burger
mock_seq = torch.tensor([[120, 305, 410, 312, 880]])  # shape (1, 5)

model = BehaviorSequenceModel()
intent_vec = model(mock_seq)  # (1, 128) — 可直接用于下游排序
print(f"用户实时意图向量维度: {intent_vec.shape}")

实际部署中,Uber 会把品类、餐厅、价格区间、时段等多维度信息编码进 token,序列长度和 Transformer 层数也远超这个 demo。但核心逻辑一致:用序列代替统计,用注意力代替手工组合。

特征新鲜度:24 小时 → 秒级

旧系统的特征管道是典型的离线批处理:每天跑一次 Spark 作业,把用户特征写入特征存储,模型读取时特征可能已经滞后 24 小时。对于外卖场景,用户午饭和晚饭的偏好可能完全不同,昨天的统计特征对"现在想吃什么"几乎没有直接指导价值。

新方案把特征管道拆成两层:

  • 近实时流特征:用户每次交互事件通过 Kafka/Flink 管道,秒级写入在线特征存储(如 Redis 或 Feature Store),Transformer 序列模型直接读取最近 N 个事件。
  • 周期性聚合特征:长期偏好统计(7 天品类偏好等)仍由批处理更新,但作为补充而非主信号。

这种分层设计的关键是:实时信号做主驱动,离线统计做兜底和纠偏。下面是一个简化的实时特征写入与读取流程:

import json
import time
import redis

r = redis.Redis(host="feature-store", port=6379, decode_responses=True)

def write_realtime_event(user_id, event_type, item_id, timestamp):
    """将用户行为事件追加到实时序列特征"""
    key = f"user_seq:{user_id}"
    event = json.dumps({"type": event_type, "item": item_id, "ts": timestamp})
    # 追加到 Redis List,保留最近 100 条
    r.lpush(key, event)
    r.ltrim(key, 0, 99)          # 截断旧事件
    r.expire(key, 3600)           # 1 小时过期,冷用户自动清理

def read_recent_events(user_id, limit=50):
    """读取用户最近行为序列,供模型消费"""
    key = f"user_seq:{user_id}"
    events = r.lrange(key, 0, limit - 1)
    return [json.loads(e) for e in events]

# 模拟实时写入
write_realtime_event("u_12345", "view", "restaurant_88", time.time())
write_realtime_event("u_12345", "click", "restaurant_88", time.time())
write_realtime_event("u_12345", "view", "restaurant_201", time.time())

# 模型推理前读取
seq = read_recent_events("u_12345", limit=20)
print(f"实时序列长度: {len(seq)}, 最新事件: {seq[0]}")

生产环境中 Uber 用的是更重的流处理栈(Kafka + Flink + 自研 Feature Store),但上面的 Redis List 模式足以说明核心思路:事件追加、截断保新、秒级可读。

从逐条打分到列表级生成式推荐

旧排序逻辑是 pointwise:对每个候选餐厅独立算一个分数(pCTR × pConversion × 价格因子……),然后按分数排序。Pointwise 的致命问题是——它不考虑候选之间的相互影响。用户面前摆了五家日料,第六条再推日料大概率被忽略;但如果只看单条分数,六家日料可能全部高分上榜。

GenRec 的做法是 listwise:把一整组候选作为输入,模型同时考虑组内上下文来决定排序。具体实现上,Uber 的 GenRec 用生成式思路——模型逐位"生成"排序列表,每一步选择下一个最合适的候选时,已经考虑了前面已选出的项目,从而天然避免重复和冗余。

一个简化的 listwise 排序示意:

import random

candidates = [
    {"id": "r_1", "cuisine": "japanese", "score_pointwise": 0.92},
    {"id": "r_2", "cuisine": "japanese", "score_pointwise": 0.88},
    {"id": "r_3", "cuisine": "japanese", "score_pointwise": 0.85},
    {"id": "r_4", "cuisine": "burger",   "score_pointwise": 0.70},
    {"id": "r_5", "cuisine": "pizza",    "score_pointwise": 0.65},
    {"id": "r_6", "cuisine": "salad",    "score_pointwise": 0.60},
]

def pointwise_rank(cands):
    """逐条打分排序:纯按分数排,三家日料挤在一起"""
    return sorted(cands, key=lambda x: x["score_pointwise"], reverse=True)

def listwise_greedy_rank(cands, diversity_penalty=0.15):
    """简化版 listwise 排序:每选一个,同品类候选被惩罚"""
    result = []
    remaining = cands[:]
    cuisine_count = {}

    while remaining:
        # 对每个候选重新计算上下文调整分数
        for c in remaining:
            cuisine = c["cuisine"]
            penalty = diversity_penalty * cuisine_count.get(cuisine, 0)
            c["adjusted"] = c["score_pointwise"] - penalty

        best = max(remaining, key=lambda x: x["adjusted"])
        result.append(best)
        cuisine_count[best["cuisine"]] = cuisine_count.get(best["cuisine"], 0) + 1
        remaining.remove(best)

    return result

print("Pointwise 排序结果:")
for r in pointwise_rank(candidates):
    print(f"  {r['id']} ({r['cuisine']}) score={r['score_pointwise']}")

print("\nListwise 排序结果(品类多样性惩罚):")
for r in listwise_greedy_rank(candidates):
    print(f"  {r['id']} ({r['cuisine']}) adjusted={r['adjusted']:.2f}")

运行结果大致如下:

Pointwise 排序结果:
  r_1 (japanese) score=0.92
  r_2 (japanese) score=0.88
  r_3 (japanese) score=0.85
  r_4 (burger)   score=0.70
  r_5 (pizza)    score=0.65
  r_6 (salad)    score=0.60

Listwise 排序结果(品类多样性惩罚):
  r_1 (japanese) adjusted=0.92
  r_4 (burger)   adjusted=0.70
  r_2 (japanese) adjusted=0.73
  r_5 (pizza)    adjusted=0.65
  r_3 (japanese) adjusted=0.55
  r_6 (salad)    adjusted=0.60

Pointwise 把三家日料堆在最前面;Listwise 在选了第一家日料后给同品类加惩罚,汉堡和披萨提前露出,用户体验更自然。Uber 实际的 GenRec 比这个贪心算法复杂得多——用 Transformer 解码器逐位生成,每步都基于已选序列做注意力计算,但核心目标一致:排序时看见整张列表。

落地时需要想清楚的几件事

实时特征的延迟与一致性:秒级特征管道意味着 Kafka 到 Feature Store 的端到端延迟必须稳定在低秒级。如果流处理管道出现积压,模型读到的序列可能缺几条最近事件,排序质量会瞬间下滑。生产部署需要监控特征新鲜度 P99,设好降级阈值——新鲜度超过 30 秒就回退到近离线特征。

序列长度与推理成本:Transformer 序列模型推理成本随序列长度线性增长(自注意力是 O(n²),但 Uber 实际用的层数和维度经过压缩)。首页 Feed 推荐的延迟预算通常在 50–100ms,序列长度、模型层数、候选集大小三者必须一起调。建议从短序列(20–30 个事件)和浅层模型(2 层)起步,验证收益后再逐步加长。

Listwise 排序的候选集规模:GenRec 对整组候选做上下文排序,计算量比 pointwise 大。Uber 的做法是先用轻量检索模型筛出几百个候选,再让 GenRec 对精排集(通常 50–100 个)做 listwise 排序。不要把 GenRec 直接扔在万级候选池上——先粗排再精排是必须的。

离线与在线特征的融合策略:实时序列特征捕捉即时意图,但新用户或冷启动时段序列很短,模型表达力不足。这时长期统计特征(7 天偏好、地域统计)必须能补位。架构上建议两条特征流并行输入模型,实时序列走 Transformer 编码器,离线统计走单独的嵌入层,最终拼接后输入排序头。


Uber 这次改造的核心逻辑可以压缩成一句话:让推荐系统看见用户"刚才在做什么",并且看见"列表里已经推了什么"。从手工特征到序列建模、从天级新鲜度到秒级、从逐条打分到列表级生成——每一步都是在补旧方案看不见的信息。如果你的推荐系统还在用昨天的特征给今天的用户排序,值得认真评估一下实时序列 + listwise 排序这条路。


相关推荐