X (Twitter) 推荐系统架构设计深度解析 电子说
本文基于
xai-org/x-algorithm开源仓库的源码分析,系统性地解读了 X 平台 "For You" 信息流的推荐算法架构、核心机制与设计哲学。
X 的推荐系统由三个核心模块构成:
| 组件 | 技术栈 | 职责 |
|---|---|---|
| Home Mixer | Rust | Pipeline 编排层,负责组装整个推荐流水线 |
| Phoenix | JAX/Python | ML 模型层,提供召回 (Retrieval) 和精排 (Ranking) 模型 |
| Thunder | Rust | 实时数据层,提供 In-Network(关注的人)推文流 |
┌─────────────────────────────────────────────────────────────────┐
│ 全量推文库 │
│ (~数亿条推文) │
└───────────────────────────┬─────────────────────────────────────┘
│
▼ Thunder (关注) + Phoenix Retrieval (推荐)
┌─────────────────────────────────────────────────────────────────┐
│ 候选集 │
│ (~1000 条) │
└───────────────────────────┬─────────────────────────────────────┘
│
▼ Pre-Scoring Filters (初筛)
┌─────────────────────────────────────────────────────────────────┐
│ 过滤集 │
│ (~500 条) │
└───────────────────────────┬─────────────────────────────────────┘
│
▼ Phoenix Scorer (精排 Transformer)
┌─────────────────────────────────────────────────────────────────┐
│ 精排集 │
│ (~32 条) │
└───────────────────────────┬─────────────────────────────────────┘
│
▼ Weighted Scorer + Selection
┌─────────────────────────────────────────────────────────────────┐
│ 最终 Feed │
│ (Top K 展示) │
└─────────────────────────────────────────────────────────────────┘
核心矛盾 :精排模型虽然精准,但计算复杂度 O(N),无法遍历数亿推文。
解决方案 :双塔模型通过解耦 (Decoupling) 实现极速召回。
┌─────────────────┐ ┌─────────────────┐
│ User Tower │ │ Candidate Tower│
│ (Transformer) │ │ (MLP) │
├─────────────────┤ ├─────────────────┤
│ - 用户 ID │ │ - 推文 ID │
│ - 历史行为序列 │ │ - 作者 ID │
│ - 用户特征 │ │ - 推文特征 │
└────────┬────────┘ └────────┬────────┘
│ │
▼ ▼
V_user [256] V_item [256]
│ │
└──────────────┬───────────────────────┘
│
▼
Similarity = V_user · V_item
│
▼
Top-K 召回
| 特性 | 说明 |
|---|---|
| 物品向量可预计算 | 推文发布时即可离线计算 Embedding,存入向量数据库 |
| 用户向量实时计算 | 请求时只需跑一次 User Tower |
| ANN 极速检索 | 利用近似最近邻算法,毫秒级从亿级候选中检索 Top-K |
问题 :用户特征(年龄、性别)和物品特征(文本、图片)看似不在同一空间,为何能计算相似度?
答案 :通过监督学习强行对齐。
训练数据: (用户A, 推文B, 点赞=1) -- 正样本
(用户A, 推文C, 滑过=0) -- 负样本
Loss Function: 让 V_A · V_B ↑, 让 V_A · V_C ↓
结果: 神经网络自动学会把"喜欢篮球的用户"和"NBA推文"映射到同一区域
| 维度 | 召回 (双塔) | 精排 (Transformer) |
|---|---|---|
| 处理规模 | 亿级 → 千级 | 百级 → 十级 |
| 交互方式 | 独立编码,仅点积 | 全交互 Attention |
| 精度 | 粗糙 | 精准 |
| 计算复杂度 | O(1)(配合索引) | O(N times S) |
精排模型将所有信息拼接成一个长序列:
输入序列 = [用户特征 | 历史记录1 | ... | 历史记录S | 候选推文1 | ... | 候选推文C]
形状: [Batch, 1 + 128 + 32, 256] = [Batch, 161, 256]
问题 :如果候选推文之间可以互相 Attention,评分会受批次内其他推文影响,不稳定。
解决方案 :特殊的 Attention Mask。
Keys (被看的位置)
─────────────────────────────────────────────▶
│ 用户 │ 历史 │ 候选 │
┌─────┼──────┼──────────────────┼──────────────────┤
Q │ 用户│ ✓ │ ✓ ✓ ✓ ✓ ✓ ✓ ✓ │ ✗ ✗ ✗ ✗ ✗ ✗ │
u ├─────┼──────┼──────────────────┼──────────────────┤
e │ 历史│ ✓ │ ✓ ✓ ✓ ✓ ✓ ✓ ✓ │ ✗ ✗ ✗ ✗ ✗ ✗ │
r ├─────┼──────┼──────────────────┼──────────────────┤
i │ │ │ │ 只能看自己 │
e │ 候选│ ✓ │ ✓ ✓ ✓ ✓ ✓ ✓ ✓ │ ✓ ✗ ✗ ✗ ✗ ✗ │
s │ │ │ │ ✗ ✓ ✗ ✗ ✗ ✗ │
│ │ │ │ ✗ ✗ ✓ ✗ ✗ ✗ │
└─────┴──────┴──────────────────┴──────────────────┘
规则:
- 候选 → 用户/历史: ✓ (可以看)
- 候选 → 其他候选: ✗ (不能看)
- 候选 → 自己: ✓ (可以 Self-Attention)
模型不输出单一分数,而是预测 所有交互行为的概率 :
输出: [B, Num_Candidates, Num_Actions]
= [32, 32, 15]
Actions:
├── P(favorite) # 点赞
├── P(reply) # 评论
├── P(repost) # 转发
├── P(click) # 点击
├── P(video_view) # 视频观看
├── P(share) # 分享
├── P(follow_author) # 关注作者
├── P(not_interested) # 不感兴趣 (负面)
├── P(block_author) # 屏蔽 (负面)
├── P(mute_author) # 静音 (负面)
└── P(report) # 举报 (负面)
WeightedScorer 将多目标概率组合为最终分数:
text{Score} = sum_{i} w_i times P(text{action}_i)
优势 :业务层可通过调整权重快速改变推荐策略,无需重新训练模型。
X 的 Phoenix 模型使用了 三张独立的 Embedding Table :
┌─────────────────────────────────────────────────────────────────┐
│ Embedding Table 架构 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────┐ │
│ │ User Table │ 用户 ID → Embedding │
│ │ [N_user, D] │ 例如: [10,000,000, 256] │
│ └─────────────────────┘ │
│ │
│ ┌─────────────────────┐ │
│ │ Post Table │ 推文 ID → Embedding │
│ │ [N_post, D] │ 例如: [100,000,000, 256] │
│ │ │ (历史推文和候选推文共用此表) │
│ └─────────────────────┘ │
│ │
│ ┌─────────────────────┐ │
│ │ Author Table │ 作者 ID → Embedding │
│ │ [N_author, D] │ 例如: [50,000,000, 256] │
│ │ │ (历史作者和候选作者共用此表) │
│ └─────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
代码依据 (recsys_model.py):
class RecsysEmbeddings(NamedTuple):
user_embeddings: ... # 来自 User Table
history_post_embeddings: ... # 来自 Post Table
candidate_post_embeddings: ... # 来自 Post Table (同一张表)
history_author_embeddings: ... # 来自 Author Table
candidate_author_embeddings: ... # 来自 Author Table (同一张表)
注意 :用户和作者虽然本质上都是 UserID,但使用的是 不同的表 。User Table 代表"读者的兴趣画像",Author Table 代表"作者的内容风格/人设"。
@dataclass
class HashConfig:
num_user_hashes: int = 2 # 用户 ID 用 2 个 Hash 函数
num_item_hashes: int = 2 # 推文 ID 用 2 个 Hash 函数
num_author_hashes: int = 2 # 作者 ID 用 2 个 Hash 函数
以推文 ID 为例:
┌─────────────────────────────────────────────────────────────────┐
│ 推文 ID → Embedding 完整流程 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 原始推文 ID: 1757483920193847296 (Snowflake 格式) │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Hash 函数 (2 个不同的 seed) │ │
│ │ hash1 = xxhash64(tweet_id, seed=1) % TABLE_SIZE │ │
│ │ hash2 = xxhash64(tweet_id, seed=2) % TABLE_SIZE │ │
│ │ │ │
│ │ 假设 TABLE_SIZE = 10,000,000 │ │
│ │ hash1 = 888 │ │
│ │ hash2 = 1024 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ post_hashes = [888, 1024] # 形状: [2] │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Embedding Table 查表 │ │
│ │ │ │
│ │ Post_Table[888] → V1 = [0.12, -0.34, ..., 0.56] [256]│ │
│ │ Post_Table[1024] → V2 = [0.78, 0.23, ..., -0.11] [256]│ │
│ └─────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ post_embeddings = [V1, V2] # 形状: [2, 256] │
│ │
└─────────────────────────────────────────────────────────────────┘
查表得到多个向量后,需要通过 Reshape (展平) 合并为单个向量:
# 查表后的原始形状
history_post_embeddings.shape = [B, S, 2, 256]
# ^ ^ ^ ^
# | | | └── Embedding 维度 D
# | | └── num_item_hashes = 2
# | └── 历史序列长度 S (如 128)
# └── Batch 大小 B
# Reshape: 把最后两维 [2, 256] 展平成 [512]
history_post_embeddings_reshaped = embeddings.reshape((B, S, 2 * 256))
# 结果形状: [B, S, 512]
本质 :把两个 256 维向量首尾相接成一个 512 维向量。
V1 = [0.12, -0.34, ..., 0.89] # 256 维
V2 = [0.78, 0.23, ..., 0.45] # 256 维
V_combined = [V1 | V2] = [0.12, -0.34, ..., 0.89, 0.78, 0.23, ..., 0.45]
# 512 维
所有实体的 Reshape :
| 实体 | Reshape 前 | Reshape 后 |
|---|---|---|
| 用户 | [B, 2, 256] | [B, 512] |
| 历史推文 | [B, S, 2, 256] | [B, S, 512] |
| 历史作者 | [B, S, 2, 256] | [B, S, 512] |
| 候选推文 | [B, C, 2, 256] | [B, C, 512] |
| 候选作者 | [B, C, 2, 256] | [B, C, 512] |
Reshape 后,还需将多个维度的特征 拼接并投影 :
┌─────────────────────────────────────────────────────────────────┐
│ 单条推文特征向量 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌──────┐ │
│ │ 推文 ID │ │ 作者 ID │ │ 用户行为 │ │ 场景 │ │
│ │ (Hash x2) │ │ (Hash x2) │ │ (Multi-Hot) │ │(枚举)│ │
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ └──┬───┘ │
│ │ │ │ │ │
│ ▼ ▼ ▼ ▼ │
│ [512] [512] [256] [256] │
│ │ │ │ │ │
│ └────────┬────────┴────────┬────────┴──────┬───────┘ │
│ │ │
│ ▼ Concatenate │
│ [1536] │
│ │ │
│ ▼ Linear Projection │
│ [256] ────▶ 进入 Transformer │
│ │
└─────────────────────────────────────────────────────────────────┘
核心洞察 :使用 Hash Trick 后, 不存在真正的 OOV 。
def get_embedding(any_id: int, table_size: int) - > int:
# 无论 ID 是什么,总能映射到表中的某一行
return hash(any_id) % table_size
任何新 ID 都会被 Hash 函数映射到 [0, TABLE_SIZE) 范围内,不会出现"找不到"的情况。
虽然技术上没有 OOV,但新 ID 会 继承旧 ID 的 Embedding (Hash 冲突):
新推文 X (刚发布,从未训练过)
│
▼ Hash
槽位 888 (之前被老推文 A 训练过)
│
▼ 查表
Embedding = 老推文 A 的语义向量 (可能完全不相关)
层 1:Multi-Hash 组合唯一性
# 新推文 X
hash1(X) = 888 # 撞了老推文 A
hash2(X) = 5001 # 撞了老推文 B
# 组合 (888, 5001) 是全新的,提供了区分度
层 2:Author Embedding 兜底
# 新推文 X 的特征
post_embedding = 噪声 (推文 ID 是新的)
author_embedding = 准确 (作者是老用户)
# Transformer 会自动 attend 到更可靠的信号
层 3:在线学习快速修正
t=0: Embedding 是噪声
t=5min: 收集到 1000 次反馈
t=10min: 梯度下降更新 Table[888] 和 Table[5001]
t=15min: Embedding 已包含推文 X 的真实语义
关键洞察 :推荐是 概率排序任务 ,不需要精确语义匹配。
| 对比 | LLM 词向量 | 推荐系统 ID Embedding |
|---|---|---|
| 映射方式 | 一对一 | 多对一 (Hash 冲突) |
| 是否允许冲突 | ❌ 不允许 | ✅ 可容忍 |
| 任务类型 | 精确生成 | 概率排序 |
| 错误代价 | 句子完全错乱 | Top-3 变 Top-5,可接受 |
冲突为何不致命 :
重要发现 :X 开源的代码中 没有显式的内容特征 (文本 Embedding、图片特征)。
模型主要依赖:
冷启动兜底 :依赖 Author Embedding 。新推文虽然 ID 是噪声,但作者是老用户,可提供初始语义。
┌─────────────────────────────────────────────────────────────────────────────┐
│ X Phoenix Embedding 完整架构 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 原始输入 │
│ ┌──────────┐ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐│
│ │ UserID │ │ History TweetIDs │ │ History AuthorIDs│ │ Candidate IDs ││
│ └────┬─────┘ └────────┬─────────┘ └────────┬─────────┘ └────────┬─────────┘│
│ │ │ │ │ │
│ ▼ ▼ ▼ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────────┐│
│ │ Hash 函数 (xxhash64, 2个seed) ││
│ └─────────────────────────────────────────────────────────────────────────┘│
│ │ │ │ │ │
│ ▼ ▼ ▼ ▼ │
│ user_hashes post_hashes author_hashes post_hashes │
│ [B, 2] [B, S, 2] [B, S, 2] [B, C, 2] │
│ │ │ │ │ │
│ ▼ ▼ ▼ ▼ │
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
│ │ User Table │ │ Post Table │ │Author Table│ │ Post Table │ │
│ │ [N_u, 256] │ │ [N_p, 256] │ │ [N_a, 256] │ │ (同一张表) │ │
│ └─────┬──────┘ └─────┬──────┘ └─────┬──────┘ └─────┬──────┘ │
│ │ │ │ │ │
│ ▼ ▼ ▼ ▼ │
│ [B, 2, 256] [B, S, 2, 256] [B, S, 2, 256] [B, C, 2, 256] │
│ │ │ │ │ │
│ ▼ ▼ ▼ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────────┐│
│ │ Reshape → 展平最后两维 ││
│ └─────────────────────────────────────────────────────────────────────────┘│
│ │ │ │ │ │
│ ▼ ▼ ▼ ▼ │
│ [B, 512] [B, S, 512] [B, S, 512] [B, C, 512] │
│ │ │ │ │ │
│ ▼ ▼ ▼ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────────┐│
│ │ Concat (拼接多维度特征) + Linear Projection → [256] ││
│ └─────────────────────────────────────────────────────────────────────────┘│
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────────┐│
│ │ Transformer ││
│ │ [User | History_1 | ... | Candidate_1 | ...] ││
│ │ 形状: [B, 1 + S + C, 256] = [B, 161, 256] ││
│ └─────────────────────────────────────────────────────────────────────────┘│
│ │
└─────────────────────────────────────────────────────────────────────────────┘
X 的 README 明确声明:
We have eliminated every single hand-engineered feature and most heuristics from the system.
| 层 | 技术 | 原因 |
|---|---|---|
| Pipeline 编排 | Rust | 高并发、低延时、内存安全 |
| ML 模型 | JAX | 高性能自动微分、TPU 原生支持 |
| 实时数据流 | Rust + Kafka | 处理百万级 TPS |
| 策略 | 说明 |
|---|---|
| 表大小 | 百万到亿级,平衡冲突率与内存 |
| 热门物品专属行 | 高频访问的推文/作者可分配独立索引 |
| 在线学习 | 新推文几分钟内根据反馈修正 Embedding |
64-bit ID 结构:
┌───────┬───────────────────┬────────────┬──────────────┐
│ 1 bit │ 41 bits │ 10 bits │ 12 bits │
│ sign │ timestamp (ms) │ machine ID │ sequence │
└───────┴───────────────────┴────────────┴──────────────┘
特性: 全局唯一、时间有序、高性能、去中心化
以下模块被排除,未开源:
pub mod clients; // 后端服务通信
pub mod params; // 模型权重参数
pub mod util; // 工具函数 (含 Hash 实现)
| 问题 | 思考 |
|---|---|
| 内容特征缺失 | 生产系统是否有额外的 Content Embedding 通道? |
| 冷启动 | 新作者 + 新推文如何处理?是否有探索机制? |
| 实时性 | 在线学习的更新频率?Embedding 漂移如何控制? |
| 文件 | 路径 | 职责 |
|---|---|---|
| 精排模型 | phoenix/recsys_model.py | PhoenixModel 定义 |
| 召回模型 | phoenix/recsys_retrieval_model.py | 双塔模型定义 |
| Transformer | phoenix/grok.py | 注意力机制与 Mask |
| Pipeline | home-mixer/candidate_pipeline/phoenix_candidate_pipeline.rs | 流水线编排 |
| 打分器 | home-mixer/scorers/weighted_scorer.rs | 多目标加权 |
本文档基于 xai-org/x-algorithm 开源代码分析,结合推荐系统工程实践整理。
审核编辑 黄宇
全部0条评论
快来发表一下你的评论吧 !