千人千面 + 无限瀑布流:推荐 Feed 的完整实现架构
- 约 22 分钟读完 - 全文 8434 字千人千面 + 无限瀑布流:推荐 Feed 的完整实现架构
目标读者:三类人——
- 后端工程师:要实现一个推荐 Feed,想知道每个组件的职责边界(看"召回/粗排/精排/重排"四章和"离线 vs 在线分工总表");
- 架构师:在做 Feed 系统选型,需要 push/pull、offset/cursor 这些关键决策的对比依据(看各节的对比表);
- 前端/客户端工程师:关心无限滚动的分页协议和预取时机(看"游标分页"和"预取"两节)。
阅读前提:不需要推荐算法背景,算法部分只讲工程接口,不讲模型内部。
上一篇讲 Netflix 的 Flink 扩缩容时提过一个三层结构:离线训练、近线特征、在线排序。有读者问:**首页千人千面,是每个用户打开时都跑一遍计算吗?**答案是否定的,而完整的答案就是这篇文章的主题——把微博、X 那种"每个人看到的都不一样、而且永远拉不到头"的推荐瀑布流,从客户端一路拆到数据闭环。
先把两个需求拆开,因为它们的难点完全不同:
- 千人千面:难在"算"——几亿用户每人一套候选,怎么做到几十毫秒出结果;
- 无限瀑布流:难在"协议"——内容在不断新增、排序在不断变化,怎么保证用户连续下拉时不重复、不漏看、永远有货。
总体架构:重活在用户到来之前干完
整个系统的核心思想只有一句话:请求路径上只做轻活,所有重计算都提前发生在事件流和离线任务里。
flowchart TD
subgraph 提前计算["提前计算(用户到来之前)"]
A["离线层<br/>批量训练模型<br/>计算全量候选池"] --> D["在线存储<br/>画像 特征 候选集"]
B["近线层<br/>行为事件经 Kafka<br/>Flink 实时更新画像特征"] --> D
end
subgraph 请求路径["请求路径(用户下拉这一刻)"]
C["在线服务<br/>召回 排序 组装<br/>几十毫秒返回"] --> E["客户端<br/>渲染一屏"]
end
D --> C
E -.->|"曝光 点击 停留事件"| B
- 离线层(小时/天级):Spark 类批处理训练推荐模型,算出全量候选——比如"每个用户最可能感兴趣的一万条内容";
- 近线层(秒级):用户的每一次曝光、点击、停留、点赞都作为事件进 Kafka,流处理作业(如 Flink)实时更新用户画像和特征。你上一次刷的行为,下一刷就生效——“越刷越懂你"的体感来自这一层;
- 在线层(几十毫秒):你下拉刷新这一刻,服务只做"取候选 → 排序 → 组装一页”,所有重活的结果早已躺在在线存储里。
这三层 Netflix 在 2013 年就公开过(offline/nearline/online 的经典划分),至今仍是推荐系统的主流形态。上一篇 Flink 自动扩缩容里 Netflix 的"个性化自定义作业",干的就是近线层的活。
为什么是这个分工:Kafka、Spark、Flink 各自的角色
架构图里三个组件的角色容易被误解,值得单独说清。
Kafka 不是"记录数据"的,是事件总线。 它解决四个问题:
- 解耦:打点的客户端不需要知道谁在消费、有几个消费者;
- 削峰:曝光事件的洪峰来临时,消费端可以按自己的节奏追,消息堆在 Kafka 里不会丢;
- 可回放:特征算错了、逻辑改了,把昨天的事件流重新放一遍就能重算——消息队列变成时间机器;
- 多消费者:同一份事件流同时喂给画像更新(Flink)、数仓落盘、实时监控,各取所需互不干扰。
没有它的替代方案是客户端打点直连 Flink——Flink 一挂数据就丢。Kafka 是缓冲带加保险箱。
Spark 和 Flink 看着像,血统不同:
| 维度 | Spark | Flink |
|---|---|---|
| 计算血统 | 批处理:数据是有界数据集 | 流处理:逐条事件,批是特例(有界流) |
| 流处理形态 | Structured Streaming 是微批(攒一批算一次) | 原生逐条处理 |
| 典型延迟 | 秒级到分钟级 | 毫秒到秒级 |
| 生态主场 | 数仓(Hive/Iceberg)、MLlib、Python 数据科学工具链 | 有状态流计算:keyed state、事件时间水位线、exactly-once |
分工逻辑由此而来:
- 训练用 Spark,因为它是吞吐型任务:几 TB 历史数据扫几遍、小时级跑完即可,不在乎单条延迟;而且机器学习工具链都在它这边。
- 画像更新用 Flink,因为近线层的 KPI 是"行为发生后多久生效":要求秒级,且要维护滚动状态(最近 N 次行为的加权向量)——逐条处理加状态管理正是 Flink 的主场。用 Spark 微批也能跑,代价是生效延迟从秒变分钟,“越刷越懂你"的体感就钝了。
要诚实补一句:边界在模糊化。Spark 能流、Flink 能批,用 Spark Streaming 做近线的公司也不少。这个分工一半是技术判断,一半是生态和组织惯性——团队的历史栈是什么,往往和"哪个理论最优"同等重要。
用户偏好怎么算出来:交互矩阵与 embedding
讲四层漏斗之前,先看"偏好"这个东西在系统里的物理形态——它决定了每层能用什么数据。
一切从一张用户-物品交互矩阵开始:行是用户,列是内容(视频、微博、商品),格子里的值是行为强度。显式反馈(评分、点赞)稀疏但明确;隐式反馈(点击、停留时长、完播、划走速度)量大但噪声多——工业界主要靠后者。
矩阵太大太稀疏(几亿用户 × 几亿内容,99.99% 是空格),没法直接用,于是有了两条经典技术路线,都是十多年前就成熟的:
- 矩阵分解(Matrix Factorization):把大矩阵分解成两个小矩阵——每个用户一个向量、每个物品一个向量,维度几十到几百。用户对物品的偏好 ≈ 两个向量的点积。2006-2009 年 Netflix Prize 竞赛把它打成主流,用 ALS(交替最小二乘)在集群上批量求解。
- embedding(嵌入向量):矩阵分解的现代化身。item2vec(2016)把物品当"词”、用户行为序列当"句子"套 word2vec;双塔模型(DSSM 系)用两个神经网络分别把用户和物品映射到同一个向量空间。殊途同归:最终产物都是"每个用户一个向量、每个物品一个向量,点积或余弦相似度就是偏好分"。
这套东西直接决定了离线/在线的分工:
- 离线算:物品向量(内容发布后批量算一次,更新频率低)、用户长期兴趣向量(按天/小时批量更新)、物品相似度矩阵;
- 近线算:用户短期兴趣向量——最常见的实现是把用户最近 N 次行为对应的物品向量做加权平均(行为类型加权:完播 > 点赞 > 点击 > 秒划过;时间衰减:越久的行为权重越低)。Flink 作业消费行为事件、滚动更新这个向量,秒级生效;
- 在线算:只剩查表和点积——拿用户向量去向量库里找最近邻,或用画像特征给候选打分。
也就是说,“用户偏好"在请求到来前就已经被压缩成几百维的向量躺在存储里了。下面四层漏斗里所有"懂用户"的操作,本质都是在读这份预先算好的压缩包。
四层排序漏斗总览
在线服务收到一次下拉请求,要在几十毫秒内从海量内容里选出这一屏的 20 条。对全量内容逐一打分是不可能的,工程上的标准解法是漏斗:每一层用更贵的模型处理更少的候选。
flowchart TD
A["召回<br/>万级候选<br/>多路召回: 协同过滤 向量 热门 关注"] --> B["粗排<br/>千级<br/>轻量模型快速打分"]
B --> C["精排<br/>百级<br/>深度排序模型融合全部特征"]
C --> D["重排 混排<br/>二十条一页<br/>多样性打散 插入广告 业务规则"]
先说清一个术语问题:这套漏斗的主力技术是深度学习排序模型,不是大语言模型(LLM)。协同过滤 2000 年代就有,矩阵分解是 2009 年 Netflix Prize 的明星,GBDT+LR 是 Facebook 2014 年的论文,Wide & Deep 是 Google 2016 年的——整个栈在十年前就完整存在。LLM 近两年开始进入推荐链路(内容理解、生成标签、甚至生成式召回),但在线排序的主力依然是经典深度模型。别把"推荐系统"和"大模型"混为一谈。
召回:离线算相似度,在线做检索
任务:从几亿内容里捞出万级候选。关键词是覆盖率——宁多勿漏,漏了的后面几层救不回来。
召回是"多路并行"的,每一路是独立的候选来源,技术路径和离线/在线分工各不相同:
第一路:协同过滤(itemCF 为主)。逻辑是"和你喜欢过的东西相似的东西”。物品两两之间的相似度由共现矩阵算出(喜欢 A 的人也多大比例喜欢 B,余弦相似度),这个矩阵全离线算,按天或按小时更新,结果存 KV:item_id → 相似物品 TopK 列表。在线时只做一件事:拿用户最近行为过的物品列表去 KV 里批量查,合并候选。在线路径上没有任何矩阵运算,全是查表。
第二路:向量召回(双塔 + ANN 检索)。物品向量离线批量算好,灌进向量索引库(Faiss、HNSW 这类近似最近邻索引,百万级向量检索只要几毫秒);用户向量由近线层滚动更新。在线时拿用户向量在索引里查 TopK——这一步是召回里唯一称得上"在线计算"的环节,但它也只是一次索引检索,不是模型推理(双塔的用户塔也可以在线跑一次,把实时上下文特征算进去,看延迟预算)。
第三路:运营类候选。热门榜、新内容池、同城/地理围栏、关注关系产出——这些本质是离线或近线算好的榜单,在线直接读。它们的作用不是精准,而是兜底和探索:新用户没有画像(冷启动)时,热门榜就是全部;老用户的候选里也固定掺一部分,保证 Feed 有新鲜感。
多路结果合并、去重、按路配额截断,凑齐万级候选交给下一层。
粗排:夹在中间的性价比层
任务:万级 → 千级。它存在的唯一理由是精排太贵——精排模型打一万条候选,延迟和算力都吃不消,但召回结果直接用又太糙。
粗排的模型选型讲究"够用就好":早年用 LR(逻辑回归)或 GBDT,特征只取最强的一批(物品热度、用户类目偏好分、历史 CTR);近年常用简化版双塔——向量点积打分,几乎就是向量召回的打分器复用。特征从特征存储里批量读,模型在线跑,但都是毫秒级的小模型。
工程上粗排是可选层:候选量小的业务(百万级内容池)召回直送精排也扛得住;亿级内容池才必须用它护住精排的延迟。
精排:深度学习,但不是大模型
任务:对千级候选逐条打分,预测"这个用户看到这条内容,会不会点、会看多久"。这是"千人千面"真正被算出来的地方,也是算法团队的主战场。
模型演进的主线清晰可见,每一步都发表在十年内:
| 年代 | 模型 | 解决的问题 |
|---|---|---|
| 2010 前后 | LR | 基线:特征加权和,可解释、可工程化 |
| 2012 | FM / FFM | 自动学习特征两两交叉(“男性 × 游戏类"这种组合特征不用人工配) |
| 2014 | GBDT + LR(Facebook) | GBDT 自动做特征组合和离散化,输出喂给 LR |
| 2016 | Wide & Deep(Google) | Wide 侧记忆规则(“已安装的应用”),Deep 侧泛化(embedding 学没见过组合) |
| 2017-2018 | DeepFM / DIN(阿里) | DeepFM 把 FM 嵌进网络;DIN 引入注意力——给用户历史行为按"和当前候选的相关性"加权,你刷运动鞋时,你上周看鞋的权重就压过看手机的 |
注意这张表里没有一个是大语言模型。它们参数量百万到亿级,输入是结构化特征向量,一次推理是几十毫秒的批量矩阵乘法。
精排的在线链路是漏斗里最长的:
flowchart LR
A["候选列表 千级"] --> B["特征拼接<br/>用户画像 物品特征<br/>实时上下文: 时间 设备 网络"]
B --> C["特征存储 Feature Store<br/>离线特征读 KV<br/>近线特征读 Flink 产出"]
C --> D["深度排序模型<br/>批量推理 输出 CTR/时长预测"]
D --> E["按预估分排序<br/>截断到百级"]
关键组件是特征存储(Feature Store):离线特征(用户长期画像、物品属性)和近线特征(最近一小时的点击序列、实时曝光计数)在请求时按 key 批量读出、和实时上下文(现在是几点、什么设备)拼成特征向量。特征的一致性是这个环节最大的坑:离线训练用的特征和在线服务用的特征必须同一份定义,否则"训练时很准、上线后翻车”——特征平台存在的意义就是管这份一致性。
重排/混排:从"预测喜欢"到"设计一页"
精排输出的是分数,但分数最高的 20 条直接端给用户往往体验很差:可能十条都是同一个作者、同一类题材。重排层的职责变了——不再预测"你多喜欢",而是设计"这一页长什么样"。它几乎全是规则和策略,在线执行:
- 多样性打散:相邻 N 条不允许同作者/同类目;更讲究的用 MMR(每次选"分高且与已选差异大"的)或 DPP(行列式点过程,数学上保证选出的集合整体多样);
- 混排:把广告、关注更新、运营位按规则插进自然结果。广告有自己的排序逻辑——eCPM = 出价 × 预估点击率,既要用户可能点、又要广告主出价高;
- 负反馈过滤:你点过"不感兴趣"的作者和类目,在这里被硬性屏蔽;
- 业务护栏:去重、过滤已举报/审核中内容、新内容冷启扶持配额。
重排是业务规则密度最高的一层,通常做成规则引擎而不是模型——产品运营每周调的都是这里。
离线 vs 在线:一张分工总表
把全链路摊开,每个环节算什么、什么时候算、存哪里,一张表收拢:
| 环节 | 算什么 | 离线/近线/在线 | 存储 |
|---|---|---|---|
| 模型训练 | 排序模型参数、双塔参数 | 离线(小时/天) | 模型仓库 |
| 物品向量 | 内容的 embedding | 离线(发布时 + 定期刷新) | 向量索引库(Faiss/HNSW) |
| 物品相似度 | itemCF 共现矩阵 | 离线(天/小时) | KV |
| 热门榜/新内容池 | 全站榜单 | 近线(分钟级滚动窗口) | KV |
| 用户长期画像 | 长期兴趣向量、类目偏好 | 离线(天) | KV / 特征存储 |
| 用户短期兴趣 | 最近行为的加权向量 | 近线(秒级,Flink) | 特征存储 |
| 召回 | 查相似物品、ANN 检索、读榜单 | 在线(但全是查表/检索) | — |
| 粗排/精排 | 特征拼接 + 模型打分 | 在线(特征预先备好,只做推理) | — |
| 重排/混排 | 规则执行 | 在线 | — |
| 已曝光记录 | 用户看过什么 | 在线读写,近线聚合 | KV / 布隆过滤器 |
规律一目了然:越靠近"知识沉淀"(模型参数、向量、相似度)的越离线,越靠近"当下上下文"(你刚点了什么、现在几点)的越在线。在线路径上真正的计算只有两样——向量检索和模型推理,其余全是查表。
注意一个工程事实:漏斗每一层都是"算法团队调模型、工程团队保接口"。接口契约是稳定的(输入候选列表和特征,输出打分排序后的列表),模型怎么迭代都不影响上下游——这是推荐系统能持续进化的架构前提。
无限瀑布流(一):分页协议为什么必须是游标
无限下拉遇到的第一个坑是分页。直觉做法是 offset 分页(“给我第 41-60 条”),在推荐流场景下它有三个致命伤:
| 问题 | offset 分页 | cursor 游标分页 |
|---|---|---|
| 新内容插入头部 | 第 2 页和第 1 页之间内容错位,用户看到重复 | 游标锚定"上次最后一条",新内容插进来也不影响 |
| 排序实时变化 | 每次请求重新排序,offset 语义直接失效 | 游标携带上次的排序上下文 |
| 深翻页性能 | 数据库 OFFSET 越大越慢 | 从锚点继续,代价恒定 |
所以推荐流的分页协议长这样:每次请求带一个游标(cursor),响应返回一页内容 + 下一页的游标。
请求: GET /feed?cursor=eyJscCI6...&count=20
响应: { "items": [...20条...], "next_cursor": "eyJs..." }
游标是个不透明字符串(服务端编码,客户端不许解析),里面通常装着:上一页最后一条的标识和排序分、本次推荐的批次号、过期时间。“不透明"是关键设计:服务端可以随时改游标格式和分页逻辑,客户端零感知。
无限瀑布流(二):一次下拉请求的完整链路
把上面两节合起来,用户下拉一次的完整链路:
sequenceDiagram
participant U as 客户端
participant F as Feed 服务
participant S as 在线存储
participant R as 排序服务
U->>F: 下拉 带上次游标
F->>S: 查用户画像和已曝光记录
F->>S: 取候选集 多路召回合并
F->>R: 粗排 精排 重排
R-->>F: 排序后的候选
F->>F: 过滤已曝光 取一页<br/>写入已曝光记录
F-->>U: 返回 20 条 + 下一页游标
U->>U: 渲染 上报曝光事件
链路里有两个不那么显眼但缺一不可的组件:
已曝光去重(seen filter)。用户刷过的内容不能再出现,否则无限下拉就成了无限重播。实现上是在线存储里维护每用户的已曝光集合——请求时过滤、响应后写入。数据量大时用布隆过滤器压缩内存(允许极低误判率,误判的代价不过是少推一条内容),或者按天滚动窗口只记最近 N 天的曝光。
预取(prefetch)。如果等用户滑到底再发请求,每一页都有加载等待,无限流就成了"有停顿流”。客户端的标准做法:滚动到当前列表 70%-80% 位置时就预取下一页;网络差时加大预取提前量。用户感知到的"永远流畅",一半是服务端架构,一半是这一个提前量。
“永远拉不到头"怎么兑现
这是无限瀑布流真正的核心问题:个性化候选再多也是有限的,用户刷 200 屏之后呢?答案是分层供给,逐级降级:
- 个性化候选:精排得分最高的一批,前几十屏的主力;
- 探索流量(exploration):故意混入你没表现出过兴趣的类目——推荐系统必须留探索比例,否则画像会收敛到死胡同(信息茧房在工程上的成因就是探索为零)。这层对用户是"刷到了意外之喜”,对系统是"采集新兴趣信号";
- 热门与新内容兜底:当个性化候选耗尽,降级到全站热门、最新发布——这些内容池每天都在被离线任务刷新,事实上是无限的;
- 时间衰减:同一条内容的推荐价值随时间衰减,昨天刷过的爆款明天权重下降,保证"明天再来还有新东西"。
所以"永远拉不到头"不是因为算出了无限多的个性化结果,而是因为个性化耗尽之前,系统会不露痕迹地滑向兜底池。用户刷到第 100 屏时看到的"千人千面",成分里热门内容的占比早已悄悄超过个性化——体验连续,供给无限。
数据闭环:这一刷决定下一刷
最后把环闭上。客户端上报的每一条曝光、点击、停留时长、划走速度,都经 Kafka 进入近线层:
- 停留 5 秒以上 → 画像里对应类目的权重上调;
- 秒划走 → 权重下调;
- 点了的广告、取关的话题 → 写入负反馈特征。
下一刷的召回和排序用的就是更新后的画像。推荐系统的"智能"不在某一次排序多准,而在于这个闭环转得多快——从行为发生到画像生效的延迟,直接决定用户"它懂我"的体感。这也是为什么 Netflix 那样的公司要养三万个流处理作业、还专门给它们做自动扩缩容。
和关注流的关系
顺带澄清一个常见混淆:微博、X 都有两种流——
- 关注流(关注的人发的内容,按时间排序):经典问题是写扩散(发微博时推给所有粉丝)还是读扩散(刷的时候现拉),粉丝量大的大 V 是写扩散的噩梦;
- 推荐流(本文讨论的):没有"关注"这个约束,内容来自全站,靠排序漏斗筛选,游标分页。
现代产品里两者早已混合:推荐流里插关注内容、关注流里插推荐内容,都在重排混排那一层完成。
落地清单
如果要自己实现一套,按这个顺序检查:
骨架
- 三层分离:离线训练、近线特征、在线排序,请求路径上只留轻活;
- 排序漏斗四层:召回保覆盖、粗排保性价比、精排保准确、重排保体验,层间接口契约稳定。
无限流协议
- 游标分页,游标不透明、带过期时间;
- 已曝光去重:在线集合或布隆过滤器,按天滚动窗口;
- 客户端 70%-80% 位置预取下一页。
“拉不完"的保障
- 分层供给:个性化 → 探索 → 热门/新内容兜底,降级要无感;
- 探索流量比例显式配置,别让它收敛到零;
- 内容时间衰减,保证"明天还有新东西”。
闭环
- 曝光/点击/停留全量上报,经消息队列进近线层;
- 监控"行为 → 画像生效"的端到端延迟,这个数就是用户体感的下限。
一句话收拢:千人千面不是"为每个用户算一遍",而是把计算摊到用户到来之前的每一秒里;无限瀑布流不是"有无限内容",而是个性化耗尽之前,系统早已悄悄换档。两个魔术的机关,都在用户看不见的那一侧。
想直观感受这套漏斗里的数据长什么样,可以看姊妹篇《从余弦定理到 TikTok:一个用户的推荐流是怎么被算出来的》——造一个用户,带着他一晚上的行为数据走完召回、粗排、精排、重排。
参考:Netflix 技术博客的个性化系统架构(offline/nearline/online 三层划分);推荐系统排序漏斗与游标分页为业界通用架构实践。