63.1 推荐系统架构
一句话
推荐系统的架构是一条漏斗 + 一条延迟预算:从千万级候选逐层缩到几十个,每一层都在“用更贵的模型换更准的排序”,而每一层能花多少时间,由端到端 P99 预算倒推。
漏斗的四段
| 阶段 | 输入 → 输出 | 模型复杂度 | 延迟预算(量级) | 目标 |
|---|---|---|---|---|
| 召回 | 千万 → 千 | 简单(向量/倒排/规则) | 10–30 ms | 别漏掉可能相关的 |
| 粗排 | 千 → 百 | 轻量模型(几十特征) | 5–15 ms | 粗筛掉明显不相关的 |
| 精排 | 百 → 十 | 重模型(几百特征,深度) | 20–50 ms | 排准 |
| 重排 | 十 → 十 | 规则 + 多样性/业务约束 | 1–5 ms | 排得“能看” |
🔑 漏斗的形状决定了成本:召回必须便宜(候选多),精排必须准(候选少)。反了就是“用重模型筛千万级”(算不起)或“用规则排几十个”(排不准)。
代码:漏斗与延迟预算
package main
import "fmt"
type stage struct {
name string
in, out int
p99Ms float64
}
func main() {
stages := []stage{
{"召回(多路并行)", 10_000_000, 2_000, 25},
{"粗排", 2_000, 200, 10},
{"精排", 200, 20, 35},
{"重排", 20, 10, 3},
}
budget := 120.0 // 端到端预算(含网络与特征)
fmt.Printf("%-20s %-14s %-10s %-10s %s\n", "阶段", "候选", "缩小倍数", "p99(ms)", "累计")
cum := 0.0
need := 0.0
for _, s := range stages {
cum += s.p99Ms
fmt.Printf("%-20s %-14s %-10s %-10.0f %.0f\n", s.name,
fmt.Sprintf("%d → %d", s.in, s.out),
fmt.Sprintf("%.0fx", float64(s.in)/float64(s.out)), s.p99Ms, cum)
}
need = cum
fmt.Printf("\n模型链路 %.0fms,端到端预算 %.0fms → 剩余 %.0fms 给特征/网络/兜底\n",
need, budget, budget-need)
// 串行 vs 并行:多路召回必须并行
fmt.Println("\n=== 多路召回:串行 vs 并行 ===")
channels := []float64{25, 20, 15, 10, 8}
serial, max := 0.0, 0.0
for _, c := range channels {
serial += c
if c > max {
max = c
}
}
fmt.Printf("5 路召回串行 = %.0fms;并行 = %.0fms(取最慢的一路)\n", serial, max)
// 每层候选数与算力:精排 200 个候选 × 每次 0.15ms
fmt.Printf("\n精排 200 候选 × 0.15ms/条 = %.0fms(串行);批量推理可压到 1/5\n", 200*0.15)
}
本机实测(macOS,Apple M1,Go 1.27):
阶段 候选 缩小倍数 p99(ms) 累计
召回(多路并行) 10000000 → 2000 5000x 25 25
粗排 2000 → 200 10x 10 35
精排 200 → 20 10x 35 70
重排 20 → 10 2x 3 73
模型链路 73ms,端到端预算 120ms → 剩余 47ms 给特征/网络/兜底
=== 多路召回:串行 vs 并行 ===
5 路召回串行 = 78ms;并行 = 25ms(取最慢的一路)
精排 200 候选 × 0.15ms/条 = 30ms(串行);批量推理可压到 1/5
读数:
| 结论 | 说明 |
|---|---|
| 模型链路 73ms,预算 120ms | 剩下 47ms 给特征读取、网络往返、兜底逻辑 |
| 5 路召回串行 78ms,并行 25ms | 召回必须并行,否则光召回就吃掉大半预算 |
| 精排 200×0.15ms=30ms | 必须批量推理 / GPU,否则单请求算不完 |
🔑 延迟预算要从用户侧倒推:用户能忍 200ms → 网关 10ms → 特征 20ms → 模型 120ms → 兜底 20ms。每一层超支都要在别处省回来,而不是“先把模型做准,延迟以后再优化”。
三条工程主线
| 主线 | 内容 | 关键 |
|---|---|---|
| 数据 | 日志 → 样本 → 特征(离线/近线/在线) | 样本与线上一致(63.5) |
| 模型 | 召回/粗排/精排/重排 | 离线指标 → 在线 A/B → 全量 |
| 服务 | 特征服务、模型服务、兜底 | 超时降级、缓存、限流(63.3) |
反模式
| 反模式 | 后果 |
|---|---|
| 没有兜底(推荐服务挂了就白屏) | 一次故障损失全部曝光 |
| 召回只做一路 | 覆盖不足,长尾内容无曝光 |
| 精排同步调 20 个下游特征 | P99 抖动,拖垮整条链路 |
| 延迟预算不设,模型越做越重 | 用户感知卡顿,转化下降 |
| 离线训练与线上服务特征不一致 | 线下 AUC 涨、线上 CTR 不涨(63.5) |
| 不做 A/B 就全量 | 无法归因,回滚靠猜 |
自检
Q1 为什么召回必须“多路”,而不能只用一个向量召回?
▸ 答案:因为单路召回有系统性盲区:向量召回擅长语义相似,但抓不到“刚上新的爆款”(无行为数据)、“跨类目的关联”(买手机推壳)、以及“业务强插”(运营位)。多路召回(协同过滤、向量、热门、规则、关注)各自覆盖不同分布,融合后覆盖率显著提升。代价是要做去重与融合(63.2)。
Q2 为什么精排不能直接对召回的两千个候选做,而要加一层粗排?
▸ 答案:算力。精排是重模型(几百特征、深度网络),单条 0.1–0.5ms,2000 条就是 200–1000ms,直接超出预算;粗排用几十个特征的轻模型,2000 条只要几毫秒,先筛到 200 条,精排再算。这是“用两级模型换算力”的典型做法,代价是粗排可能漏掉一些精排会喜欢的候选。
Q3 延迟预算里为什么一定要留“兜底时间”?
▸ 答案:因为推荐链路的任何一环都可能超时(特征服务慢、模型排队、下游限流)。没有兜底预算时,超时只能“整体失败”或“无限等待”;留出 20–30ms 兜底,就能在超时后返回“热门榜单/缓存结果”,保证页面有内容。兜底不是降级体验,是保证可用性的最后一道门。