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 排序这条路。