设计一个推荐系统
提出问题
推荐系统是互联网产品的核心引擎,从短视频 Feed 流、电商商品推荐到内容资讯分发,本质上都是"从海量候选集中为用户找到最可能感兴趣的内容"。面试中出现频率极高,因为推荐系统涵盖了系统设计的几乎所有核心要素:数据处理(实时/离线)、算法分层(漏斗)、性能与延迟的权衡、AB 实验平台。生产场景中,推荐系统直接关系到用户留存和商业收入,设计的好坏有明确的数据反馈。
分析问题
四阶段漏斗:召回 → 粗排 → 精排 → 重排
推荐系统的核心架构是一个逐层过滤的漏斗,每层候选集缩小一个数量级。以下是各层的数据流和关键参数:
┌─────────────────────────────────────┐
│ 物品池(千万级) │
└────────────┬────────────────────────┘
│
┌─────────▼─────────┐
│ 召回(Recall) │ 候选从千万 → 千级
│ Faiss ANN 检索 │ P99: 10-20ms
│ 协同过滤 / 热门兜底 │
└─────────┬─────────┘
│
┌─────────▼─────────┐
│ 粗排(Pre-Rank) │ 候选从千级 → 百级
│ 双塔向量内积 │ P99: 1-3ms
│ 轻量 LR 模型 │
└─────────┬─────────┘
│
┌─────────▼─────────┐
│ 精排(Ranking) │ 候选从百级 → 几十级
│ DeepFM / DIN │ P99: 20-50ms
│ MMOE 多目标 │
└─────────┬─────────┘
│
┌─────────▼─────────┐
│ 重排(Re-Rank) │ 最终输出 Top-N
│ MMR 多样性打散 │ P99: <5ms
│ 业务规则/广告插入 │
└─────────┬─────────┘
│
┌─────────▼─────────┐
│ 用户最终看到的 Feed │
└───────────────────┘召回层(Recall)
目标是从千万级物品池中筛选出千级候选。关键在于多路召回,单一路径永远不够:
| 召回策略 | 原理 | 候选量级 | 延迟 | 适用场景 |
|---|---|---|---|---|
| 向量召回(双塔) | 用户/物品 Embedding 内积,Faiss 检索 Top-K | 500 | 10-20ms | 泛化推荐,基础召回 |
| ItemCF(协同过滤) | 物品共现矩阵,取"看了A的人也看了B" | 300 | 5-10ms | 长尾兜底,关联推荐 |
| 热门召回 | 按热度/时效预排序,全局 Top-N | 200 | <1ms | 冷启动、爆发热点 |
| 图召回(GraphSAGE) | 用户-物品交互图随机游走 | 200 | 20-50ms | 社交关系推荐 |
| 语义召回 | 标签/关键词倒排匹配 | 300 | 3-5ms | 搜索场景 |
向量召回的生产落地细节:双塔模型一般用 Item-ID 和用户行为序列作为输入,Embedding 维度 64-128(更高维度 recall 提升边际递减,但检索延迟线性增长)。Faiss 索引选择上,HNSW 适合高精度(延迟 10ms、召回率 95%+),IVF-PQ 适合高吞吐(延迟 1-2ms、召回率 85%)。实际生产中,我用 128 维 Embedding + HNSW 32-efSearch 128 配置,在 1000 万物品集上 P99 召回延迟 15ms。
容易踩的坑:向量召回只在训练时覆盖的用户-物品交互上有表现,对新物品/新用户基本失效,所以必须配合多路召回聚合。各路召回取 Top-K 后做合并去重,再进入粗排。
粗排层(Pre-ranking)
千级候选直接进精排计算量太大(单次精排 20-50ms,千级就是 20-50 秒),所以必须用粗排快速筛到百级。
粗排模型选型对比:
| 模型 | 参数量 | 单次推理耗时 | 精度(相对精排) | 适用条件 |
|---|---|---|---|---|
| 双塔内积 | 百万级 | 0.5ms | 60-70% | 最轻量,Embedding 复用 |
| 双塔+Attention | 千万级 | 2ms | 75-80% | 引入序列特征 |
| LR(逻辑回归) | 万级 | 0.1ms | 50-60% | 极简降级方案 |
真实场景里,我见过粗排分数和精排分数排序一致性只有 60-70% 的情况,也就是说有 30-40% 的候选在粗排阶段被误杀——但总体接受,因为粗排的目标是宁可漏掉一些好内容,也要保证精排的计算资源不被撑爆。
精排层(Ranking)
精排用最复杂的模型做精确 CTR/CVR 预估。主流模型演进:
Wide & Deep (2016) → DeepFM (2017) → DIN (2018) → DCN V2 (2020) → MMOE (2018) → SIM (2020)DeepFM 架构要点:
- Wide 侧:手工交叉特征(如"用户性别=男 & 物品类目=游戏"),用 LR 直接学习
- Deep 侧:Dense Embedding → 多层 MLP(一般为 3 层,256→128→64),学习高阶隐式交叉
- 输出:Sigmoid 做 CTR 预估
精排特征输入构成(以 DeepFM 为例):
# 特征拼接伪代码
class DeepFMRanker:
def predict(self, user_features, item_features, context_features):
# 稀疏特征:用户 ID、物品 ID、类目 ID → Embedding 层
sparse_inputs = concat(
self.user_embedding(user_features['user_id']), # 64-dim
self.item_embedding(item_features['item_id']), # 64-dim
self.cate_embedding(item_features['category_id']) # 16-dim
)
# 稠密特征:价格、CTR_7d、时长均值等 → 直接拼接
dense_inputs = concat(
normalize(item_features['price']), # 1-dim
normalize(item_features['ctr_7d']), # 1-dim
normalize(item_features['duration_avg']) # 1-dim
)
# Wide 侧:手工交叉特征
wide_features = self.cross_features(user_features, item_features)
# DeepFM 前向
deep_out = self.deep_mlp(sparse_inputs, dense_inputs) # 256→128→64
wide_out = self.wide_lr(wide_features)
return sigmoid(deep_out + wide_out)面试会问的坑:特征交叉爆炸。50 个特征做两两交叉就是 1225 个组合,全部塞进 Wide 侧不现实。实践上只挑高频共现对,或者用 FM 自动学习交叉。另外,精排模型线上推理必须控制特征获取时间——每个特征从 Redis 取花 1ms,30 个特征就 30ms,全链路延迟会超 50ms 的 P99 目标。
重排层(Re-ranking)
精排给出的是"最可能点击"的排序,但实际业务需要更多约束:
- MMR(最大边际相关性)打散:每选一个物品,不仅考虑相关性,还要考虑与已选物品的相似度。公式:Score = λ * 相关性 - (1-λ) * max(已选物品相似度)。λ 通常取 0.5。
- 广告插入:从第 4 位开始,每 7-10 个自然结果插入一个广告位,广告本身也经过精排 CTR 预估,但会乘以出价系数。
- 运营提权:特定节日、活动商品,加权 +20% 排序分。
- 硬性过滤:已购买、已举报、未成年人不适内容直接删除。
特征工程与特征平台
推荐系统的效果上限(注意不是下限,下限靠模型架构)取决于特征质量。特征体系分为三类:
- 用户特征:长期画像(性别、年龄、城市、兴趣标签)、短期行为(最近点击/购买/搜索序列、时长分布)
- 物品特征:内容属性(类目、标签、价格、发布时间)、统计特征(近 7 天曝光/点击/转化率、好评率)
- 上下文特征:时间(工作日/周末、凌晨/午间)、设备(手机型号、网络环境)、位置(GPS 经纬度)
特征平台承担特征的生产、存储、在线获取:
# 真实线上特征服务代码(简化版)
class FeatureService:
def __init__(self):
self.redis = RedisCluster('redis://rec-feature:6379')
self.kv_store = S3AwareKVStore('s3://rec-feature-store/')
def get_features(self, user_id: str, item_ids: list[str]) -> dict:
start = time.time()
# 实时特征(Flink 每 30 秒窗口写入 Redis,过期 5 分钟)
realtime_key = f"rec:user:{user_id}:realtime"
realtime = self.redis.mget(realtime_key, fields=['click_seq', 'last_category', 'duration_30s'])
# 离线特征(Hive T+1 产出,写入 KV 存储,更新时全量替换)
profile = self.kv_store.get(f"rec:user:{user_id}:profile",
keys=['age', 'gender', 'city', 'tag_weights'])
# 物品特征(批量从 Redis 拉取,用 pipeline 避免一次取一个)
pipe = self.redis.pipeline()
item_keys = [f"rec:item:{item_id}:features" for item_id in item_ids]
for k in item_keys:
pipe.hgetall(k)
item_features = dict(zip(item_ids, pipe.execute()))
# 耗时打点,超过 20ms 打印警告
elapsed = time.time() - start
if elapsed > 0.02:
logger.warning(f"feature_service latency {elapsed*1000:.1f}ms user={user_id}")
return merge_features(user=realtime, profile=profile, items=item_features)特征一致性坑:离线训练和在线推理用的特征值必须一致,否则会有"训练时特征 A 是 0.8,在线变成 0.2"的偏差,导致 CTR 预估偏低。解决方法:在线特征和离线特征用同一套 SQL/Pipeline 产出,且特征日志落盘回头用于离线训练。
在线/离线/近线架构与冷启动
推荐系统有三条数据流并行执行:
时间粒度: 天级 ←——————————————→ 毫秒级
┌──────────┐ ┌──────────┐ ┌──────────┐
│ 离线流 │ │ 近线流 │ │ 在线流 │
├──────────┤ ├──────────┤ ├──────────┤
│ Hive/Spark│ │ Flink │ │ 实时推理 │
│ 全量训练 │ │ 增量更新 │ │ 特征拼接 │
│ 模型产出 │ │ Embedding │ │ 规则过滤 │
│ 用户画像 │ │ 实时特征 │ │ 打分排序 │
└──────────┘ └──────────┘ └──────────┘生产事故经验:曾有一次离线模型训练完推送后,特征 schema 不兼容(新模型需要 128 维 Embedding,在线 KV 里存的还是 64 维),导致线上召回的向量维度不匹配,直接零召回持续 20 分钟。之后加了一道 schema 校验:模型上线前,用线上样本集跑一遍推理,对比旧模型输出,差异超过 5% 就自动拦截。
冷启动是推荐系统最棘手的工程问题之一:
| 冷启动类型 | 问题 | 解决方案 | 上线效果参考 |
|---|---|---|---|
| 用户冷启动 | 新用户无行为 | 注册画像映射 + 热门兜底 + Bandit 探索 | 新用户 7 日留存提升 15% |
| 物品冷启动 | 新内容无曝光 | 内容属性向量召回 + 小流量探索(1% 流量) | 新内容曝光量提升 3 倍 |
| 系统冷启动 | 新系统无数据 | 内容 Embedding 用预训练模型(如 Sentence-BERT)初始化 | 冷启阶段 CTR 达到成熟期 60% |
EE(Explore-Exploit)的具体实现:给每个用户分配一个探索概率 ε,初始为 0.2,随曝光量增加线性衰减到 0.05。探索时随机选非热门物品展示,但限制探索产生的曝光不超过总曝光 5%,避免影响收入。
AB 实验与评估
推荐系统必须用数据说话,AB 实验是核心基础设施。每个推荐策略变更都需要在流量桶上做分桶实验。
实验分层架构:
流量入口(100%)
├── Layer 1: 召回策略实验(10% = 实验组,90% = 对照组)
│ ├── 实验组:新向量召回
│ └── 对照组:旧向量召回
├── Layer 2: 精排模型实验(10% / 90%)
│ ├── 实验组:DeepFM → DCN V2
│ └── 对照组:DeepFM
└── Layer 3: 重排规则实验(10% / 90%)
├── 实验组:新 MMR 打散 λ=0.6
└── 对照组:旧 MMR 打散 λ=0.5核心指标跟踪:
- 核心指标:CTR(点击率,一般提升 0.1% 就算显著)、CVR(转化率,提升 0.05% 就有商业价值)、用户停留时长、人均曝光数
- 业务指标:GMV(电商)、广告收入、次日留存
- 质量指标:多样性(Simpson 指数,低于 0.1 说明内容太单一)、覆盖率(长尾内容曝光比例,低于 20% 说明马太效应严重)、新颖性
统计显著性要求:p-value < 0.05,且实验至少运行 7 天,避免周末和工作日行为差异导致的偏差。我之前踩过的坑:一个模型上线实验跑了 3 天 CTR 提升 2%,紧急全量上线,结果第 5 天 CTR 回落到 -0.5%,最后发现是前 3 天赶上促销活动,实验组和对照组的样本分布不一致导致的虚假提升。
总结
推荐系统设计面试的关键产出是一张清晰的分层漏斗图,搭配每条数据流的处理方式。可以用以下话术组织回答:
"我的推荐系统设计分为四层:召回用双塔向量 + 协同过滤,粗排用轻量级双塔筛到百级,精排用 DeepFM 做 CTR 预估,重排加多样性和业务规则。离线走 Hive 训练,近线用 Flink 更新特征,在线用 Redis 提供特征服务。冷启动用热门兜底 + Bandit 探索。AB 实验平台承接所有策略变更验证。"
说给面试官听的工程细节话术:
- 特征实时性保障:Flink 30 秒窗口写入 Redis,特征过期时间 5 分钟,避免数据膨胀
- 向量检索:128 维 HNSW 索引,P99 延迟 15ms,召回率 95%
- 全链路 P99 延迟预算:召回 15ms + 粗排 3ms + 精排 50ms + 重排 5ms + 网络 10ms = 83ms,需要压到 50ms 以内的话,精排就得降级模型或减少特征数
- 特征一致性:离线在线同源产出,线上推全前做 schema 校验和输出一致性校验
参考
参考:《深度学习推荐系统》王喆;Google Wide & Deep / YouTube DNN 论文;Faiss 向量检索库文档;美团推荐系统技术博客。