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 兜底,就能在超时后返回“热门榜单/缓存结果”,保证页面有内容。兜底不是降级体验,是保证可用性的最后一道门。

关联