X 的 For You 推荐算法深度解读:从召回到排序的完整链路
最近把 X(原 Twitter)开源的推荐算法仓库 x-algorithm 完整读了一遍。这次开源(2026 年 8 月 13 日版)含金量很高:不再是演示代码,而是 For You 信息流的生产实现——真实的 Phoenix 排序/召回模型代码、打分权重、过滤规则,全部都在。
这篇文章带你走一遍完整链路,并在每个关键环节回答一个”为什么”的问题——因为这套系统几乎每个设计决策都值得琢磨。
一、整体架构:两条路径、一个模型
先看全局。For You 信息流要解决的核心问题是:每次刷新,从全站几亿条帖子中挑出几十条,排好顺序,并决定哪些能给你看。
系统分两条路径:
- 请求路径(Request Path):在线、毫秒级。负责召回候选 → 过滤 → 打分 → 排序 → 返回。核心代码在
home-mixer/。 - 打标路径(Labeling Path):离线/近线、持续运行。负责给帖子和账号打各种安全标签(垃圾、成人、暴力、机器人行为等),供可见性过滤使用。
一个重要的架构原则贯穿始终:排序和可见性是两套独立系统。排序只决定”顺序”,能不能展示由 visibility-filtering/ 单独裁决——不同服务、不同输入、不同规则。
一次 For You 请求,在 home-mixer/ 里经历 7 个阶段:
Query Hydration(查询补全)
↓
Candidate Sources(候选召回,并行)
↓
Candidate Hydration(候选补全)
↓
Pre-Scoring Filters(打分前过滤)
↓
Scoring(Phoenix 打分)
↓
Selection(Top-K 选择)
↓
Post-Selection Filters(选后过滤)
↓
Blending(混入广告、Who to Follow 等)→ 返回时间线
下面逐个拆解,并回答开头提到的几个问题。
二、查询补全(Query Hydration)
一次请求进来时,参数里其实只有一个 user ID。查询补全阶段(home-mixer/query_hydrators/)会把这个请求”养肥”:
- 用户行为序列(user action sequence):你最近的点赞、回复、停留、点击等互动历史——这是 Phoenix 模型最重要的输入
- 关注列表、拉黑/静音列表、静音关键词
- 已经看过/已经推送过的帖子 ID
- 关注的话题、语言、国家等
Q1:为什么要查询补全?
因为推荐系统的一切计算都围绕”这个用户是谁、想要什么”展开,而请求本身只携带了一个 ID。模型需要的不是 ID,而是这个用户的行为上下文。
举几个具体的用途:
- Phoenix 模型的核心输入是序列。Transformer 要吃的是你最近几百上千次互动组成的序列(最长 1022 个位置),不补全这些,模型根本没法跑。
- 过滤依赖用户状态。要过滤”你已看过的帖子""你拉黑的作者""你静音的关键词”,就必须先把这些列表拿到手。
- 个性化参数依赖用户特征。比如新用户有特殊的 OON(未关注)折扣系数,这要求先知道账号年龄、关注数等。
换句话说,查询补全是把”一次匿名请求”变成”一个完整用户画像”的过程——它是整个 pipeline 的输入准备层,后面每个阶段都消费它产出的数据。
三、候选召回(Candidate Sources)
候选来源并行查询,分两路(home-mixer/sources/):
| 来源 | 类型 | 机制 |
|---|---|---|
thunder/ | In-Network(你关注的人) | 内存中保存关注账号最近发布的帖子,直接取回 |
phoenix/ retrieval | Out-of-Network(你没关注的人) | 双塔模型:把你编码成向量,在候选索引中做最近邻检索 |
simclusters/ | Out-of-Network | 按”谁对什么内容有互动”聚类账号和帖子,用簇相似度找候选 |
Q2:为什么要候选召回?
答案很直接:不可能对全站帖子逐一打分。
Phoenix 排序模型是 8 层、2560 维的 Transformer,给一条帖子打分不便宜。X 全站每天有数亿条新帖,对每个用户的每次请求都全量打分,算力上是天文数字。所以业界标准做法就是两阶段:
- 召回(Retrieval):用便宜的方法(内存索引、向量近邻、簇相似度),从百万级候选快速收窄到千级——宁多勿漏,重查全率。
- 排序(Ranking):用昂贵的模型对这几千条精排——重查准率。
召回阶段另一个值得注意的设计是 Phoenix 的双塔召回模型:
- 用户塔:把你的互动历史序列过 Transformer,得到一个归一化向量。注意:生产版没有 per-user 的 ID embedding——“你是谁”完全由”你互动过什么”定义。
- 候选塔:帖子用语义 ID(Semantic ID)表示——对帖子的多模态 embedding 做残差量化(6 级 × 256 码本),同主题帖子共享 SID 前缀。这样模型对从未见过的新帖也能泛化,这是相比纯 ID 哈希的关键升级。
- 每次存 checkpoint 时,训练器把整个候选语料过一遍候选塔,把索引烘焙进 checkpoint。线上加载即用,点积 top-K 检索,毫秒级完成千万级 → 千级的收窄。
两路候选(关注 + 未关注)最后用同一个模型统一排序,而不是分开排再合并——这保证了 “For You” 里关注内容和发现内容是公平竞争、按质量混排的。
四、候选补全(Candidate Hydration)
召回回来的只是帖子 ID 列表。候选补全阶段(home-mixer/candidate_hydrators/)给每条帖子补齐打分所需的全部特征:
- 帖子正文、媒体信息、语言
- 作者信息及其账号标签(是否机器人、信誉分、成人内容标记等)
- 引用帖内容、互动计数(点赞数、转发数)
- 订阅状态(是否是订阅专属帖)等
Q3:为什么是候选补全?
这个问题可以拆成两个”为什么不”:
为什么不在召回时就带上这些特征? 因为召回阶段追求的是快。Thunder 的内存索引和 Phoenix 的向量索引里只存最精简的信息,如果把正文、作者标签、互动计数全塞进索引,内存会爆炸、更新会跟不上(互动计数每秒都在变)。召回器保持”瘦”,是它能快的前提。
为什么不在打分时才现场取? 因为排序模型需要的是批量、齐整的输入。Transformer 一次对几十上百个候选做推理,要求所有特征一次性准备到位;如果打分时逐条 RPC 去取特征,延迟和抖动都无法接受。集中一次批量补全,是利用批量 IO 摊薄成本的最优解。
此外补全阶段还有一个隐性作用:特征一致性。所有下游过滤器(比如”作者被拉黑""订阅专属不可见”)和打分器消费的是同一份快照数据,避免了各阶段读到不一致状态的问题。
五、打分前过滤(Pre-Scoring Filters)
补全之后、打分之前,先过一遍 17 个硬过滤器(home-mixer/filters/):
| 过滤器 | 移除什么 |
|---|---|
DropDuplicatesFilter | 多个来源重复召回的同一帖子 |
AgeFilter | 超过 48 小时的旧帖 |
SelfTweetFilter | 你自己发的帖 |
OONRetweetReplyFilter | 未关注账号的转发/回复 |
PreviouslySeenPostsFilter | 已经给你展示过的帖子 |
PreviouslyServedPostsFilter | 本次会话已推送过的帖子 |
MutedKeywordFilter | 命中你静音关键词的帖子 |
AuthorSocialgraphFilter | 你拉黑或静音的作者 |
IneligibleSubscriptionFilter | 你无权访问的订阅专属帖 |
NewUserMinEngagementFilter | 新用户收到的低互动 OON 帖 |
| … | 等共 17 个 |
Q4:为什么要打分前过滤?
核心是成本:模型打分是整条链路里最贵的一步,任何能用规则廉价排除的候选,都不应该浪费 Transformer 的算力。
但更细想一层,这些过滤器分两类,动机不同:
- 效率型过滤:去重、48 小时旧帖、已看过。这些帖子打分了也是浪费——重复帖没必要留两份,已看过的帖哪怕分数再高也不该再推(信息流的基本体验)。
- 正确性/体验型过滤:拉黑的作者、静音关键词、无权访问的订阅帖。这些帖子根本不应该进入打分视野——如果让模型给”你拉黑的人的帖子”打了高分再删掉,不仅浪费,还可能在边界情况下漏出去。
还有一个时序上的考量:先过滤再打分,让 Top-K 选择更充实。打分前把候选池洗干净,最后取 Top-K 时候补质量更高,不会出现”排完序发现前 10 名有 6 条不能展示,只能拿第 11-20 名凑数”的窘境。
注意一个设计细节:已看过的帖子被处理了两次——ThunderSource 在召回时就排除了它们,其他来源不排除、靠过滤器兜底。这是性能和简洁性的折中:能提前排除的提前排除,剩下的统一靠过滤器保证正确性。
六、打分(Scoring):算法的”价值观”所在
这是整条链路的大脑,分三步:
6.1 PhoenixScorer:多行为预测
Phoenix 排序模型对每条候选帖,预测你采取 25 种行为各自的概率:
Engagement 点赞 · 回复 · 转发 · 引用 · 分享 · 私信分享 · 复制链接分享
Clicks 点帖 · 点主页 · 点链接 · 展开图片 · 打开视频 · 点引用帖
Attention 视频质量播放 · 停留 · 停留时长 · 点击后停留时长 · 活跃秒数
Author 关注作者
Negative 不感兴趣 · 静音作者 · 拉黑作者 · 举报 · 未停留(划走)
模型本身是带”候选隔离”的 Transformer:
User/History 之间:双向全注意力(互相随便看)
Candidate → User/History:可以看(理解用户上下文)
Candidate → Candidate:互相看不见(只能自注意力)
候选隔离保证了”一条帖子的分数只取决于你和帖子本身,与批次里还有什么帖子无关”——分数确定、可缓存,不会因为”和某条帖同批”而变化。
生产配置:8 层、embedding 维度 2560、GQA(20 query 头 / 4 KV 头)、每实体 2 个哈希函数做 embedding 查询(无词表、新帖即时可表示)、64 种离散行为头 + 8 个连续值(停留时长)回归头。
6.2 RankingScorer:加权求和
模型输出概率后,RankingScorer 用一个朴素到极致的公式把它们合成一个分数:
Final Score = Σ (weight_i × P(action_i))
真正的玄机在权重里。home-mixer/params/param.rs 中的生产默认权重,就是 X 对”什么内容值得推”的量化表态:
| 行为 | 权重 | 行为 | 权重 |
|---|---|---|---|
| 复制链接分享 | +20.0 | 关注作者 | +4.0 |
| 回复 | +5.0 | 分享 | +2.0 |
| 私信分享 | +5.0 | 转发 | +1.0 |
| 引用 | +5.0 | 点赞 | +0.5 |
| 点击 | +0.4 | 打开链接 | +0.2 |
| 停留时长 | +0.004/秒 | 点图/开视频/VQV | +0.05 |
| 举报 | −234.0 | 静音作者 | −58.8 |
| 不感兴趣 | −43.2 | 拉黑作者 | −31.2 |
| 未停留(划走) | −0.02 |
Q5:如何理解打分?
读这张表,我提炼出四个观察:
1. 深度分享行为权重极高。 “复制链接去站外分享”(+20)是点赞(+0.5)的 40 倍。算法最看重的不是公开的点赞,而是你愿意把这条内容私下传给某个人——这是最强的价值信号。回复、引用、私信分享(各 +5)同理:它们都要求用户付出真实表达。
2. 负反馈惩罚极端严厉。 举报概率 × −234,意味着一条帖只要有一点点被举报的预期,分数就会被压到谷底。代码注释也解释了:权重同时反映行为价值和该行为在全网的基础发生率——负反馈本来就稀有,所以要放大惩罚才能让它起作用。
3. 熟人社交被明显扶持。 如果作者是双向互关且帖子是原创(非回复非转发),回复权重额外 +15(BidirectionalFollowReplyWeightBoost)。也就是说,朋友之间可能引发对话的内容会被显著抬升。
4. “看完就走”也有微小惩罚。 未停留(−0.02)权重虽小,但它是唯一对所有帖子普适的负信号——划走是一种微弱的失望。
算完加权和后,还有三个调整:
- 作者多样性衰减:同一作者第 k 条帖分数乘以
(1-f)·d^k + f(默认 d=0.5、地板 f=0.25)——防止一个人刷屏。 - OON 折扣:未关注账号的帖子分数 × 0.75(话题请求下 × 0.5)——关注关系有优先权,但未关注内容只是打折而非禁入,这是 “For You” 发现机制的开关。
- 新作者冷启动提升:曝光量低于阈值的作者被往目标位置抬——给新人流量扶持。
之后 VMRanker 会调用 vm-ranker/ 服务做重排:用**行列式点过程(DPP)**在帖子 embedding 上操作,牺牲一点点总分,换取相邻帖子之间更低的内容相似度——保证信息流刷起来的多样性。
6.3 一个可选的替代:dwell-regret 模式
代码里还有一套可选的”停留-后悔”打分模式:以预测停留时长为基础分,用”该帖相对本批次均值的互动优势”做 sigmoid 调制、用负反馈做指数衰减。直觉是:让你看了很久且不后悔的内容得分高,而不是单纯堆砌互动概率。目前默认仍是 weighted 模式,但这暗示了 X 在探索的价值方向。
七、选择与选后过滤(Selection & Post-Selection Filters)
打分完成后,TopKScoreSelector 按分数取 Top-K。但排序定下来之后,还有最后一道关卡——可见性过滤:
VFCandidateHydrator逐条询问visibility-filtering/服务:这条帖对这个用户是 ALLOW(正常展示)/ INTERSTITIAL(加遮罩,用户可点开,如成人内容)/ DROP(不展示)VFFilter删掉 DROP 的帖子AncillaryVFFilter连带删除”父帖/引用帖/被转发帖本身被 DROP”的帖子DedupConversationFilter折叠同一会话的多余分支
可见性裁决的输入来自打标路径:grox/(LLM/分类器打垃圾、成人、暴力标签)、media-model-proxy/ + clip/(图像视频模型)、agatha/(按”被拉黑/举报 vs 被点赞”比例给账号打标)、bdsm/(行为时序识别虚假账号)、user-cred-v2/(关注图 PageRank 信誉分)等系统持续产出的标签。
Q6:如何理解选后过滤?
这是我觉得整套系统里设计最讲究的一环,三个要点:
1. 为什么放在排序之后,而不是打分前? 因为可见性查询是逐条、且依赖(帖子 × 用户)组合的 RPC 调用,成本不低。对召回的几千条候选全量查询太浪费——反正最后只展示几十条,排完序再查,查询量小一个数量级。同时安全标签是近线异步更新的,查询发生在最后一刻,能读到最新鲜的标签状态。
2. 它和打分前过滤的本质区别是什么? 打分前过滤用的是用户自己的显式状态(你拉黑了谁、静音了什么词)——这些规则简单、确定、无争议。选后过滤用的是平台安全系统的判定(这条帖是不是垃圾、这个账号是不是机器人)——这些判定依赖模型和标签,复杂、有误差、需要独立审计。把两者分开,等于把”用户偏好”和”平台治理”解耦:排序系统不需要懂安全,安全系统不需要懂排序。
3. “DROP 只对推荐生效”的精妙。 可见性规则里有一批只对”未关注账号的推荐帖”生效——比如高召回率的垃圾内容判定:同一条可疑帖,推给陌生人时拦截,但你主动关注了作者就能正常看到。这是一个非常优雅的权责划分:你主动建立的关注关系,是你自己的选择,平台尽量少干预;平台推给你的内容,平台负全责。
最后,排好序的帖子进入 Blending Pipeline,与广告、Who to Follow、提示卡片等非帖子内容交错混排,然后返回客户端。side effects 异步记录本次推送、刷新缓存、打日志——为下一次请求的”已看过过滤”积累数据,形成闭环。
八、总结:值得借鉴的设计决策
通读下来,这套系统有五个设计决策值得反复品味:
- 多行为预测,价值观外置。 模型只负责预测各种 P(action),“这些行为值多少钱”是一个独立的、可审计的加权步骤。价值观和模型分离,调权重不用重训模型,权重表本身就是一份公开的产品宣言。
- 候选隔离。 打分与批次组成无关,分数确定、可缓存——这是工程一致性和 ML 表达力之间的漂亮取舍。
- 哈希 embedding + 语义 ID。 无需维护词表,新帖零延迟可表示;语义 ID 让模型对内容(而非仅仅 ID)有泛化能力。
- 排序与可见性分离。 排名管顺序,安全管生死。不同服务、不同输入、不同规则,各自独立演化、独立审计。
- 可组合的 pipeline 框架。 source / hydrator / filter / scorer / selector / side-effect 六类组件,所有参数走 feature-switch 配置系统,实验可以只影响 10% 流量,生产默认值用 cron 同步回开源仓库——这本身就是一种透明度的工程实践。
顺便一提:为了防止有人逆向钻空子,Grox 的 LLM prompt 和部分 botmaker 规则没有开源。作为替代,X 提供了 Under the Hood 透明工具,每个用户都能看到自己账号和帖子被打过哪些影响可见性的标签——“代码 + 可审计的输出”,这个组合思路挺值得学习的。