你刷 Reels 时看到一个小气泡,标注着"你的朋友也看了这条视频"——这个交互看起来毫不起眼,但背后牵动的是数十亿用户的关系图谱、实时观看信号和推荐排序的交汇点。Meta 最近在工程博客上聊了 Friend Bubbles 这个功能的构建过程,核心信息很明确:越是"看起来简单"的产品功能,越可能藏着最深的工程难题。
为什么"朋友也看了"远比想象中复杂
表面逻辑只有一行:查出朋友看过的 Reels,排个序,展示出来。但拆开后至少有三层难题:
- 关系图谱的规模:每个用户可能有数百甚至上千个"朋友",每个朋友又在持续产生观看行为。在十亿级用户体量下,单次查询需要遍历的边数量就已经爆炸。
- 信号的时效性:朋友刚看完一条 Reel 和三天前看完,对用户的发现价值完全不同。你需要近实时的信号注入,而不是离线批处理后的过期数据。
- 推荐与社交的融合:纯推荐系统追求个性化相关性,纯社交信号追求"熟人背书"。两者排序逻辑不同,合并时谁优先、谁降权,直接影响产品体验。
Meta 的工程师 Subasree 和 Joseph 在访谈中强调,Friend Bubbles 不是在现有推荐管道上"加一层滤镜",而是需要一套独立的信号获取、聚合和排序管线,同时还要与主推荐流协同。
关键工程决策的几个方向
虽然播客细节有限,但从规模化社交发现的通用挑战出发,可以推断几个必须面对的决策点:
1. 信号获取:推模式 vs 拉模式
朋友观看行为是持续产生的。你有两种选择:
- 推模式(Push):朋友产生观看事件时,立即写入你的"社交信号池"。优点是查询时零计算,缺点是写扩散——每个事件要推给所有朋友的存储,写放大严重。
- 拉模式(Pull):查询时实时从朋友的观看记录中读取。优点是写路径简单,缺点是读路径重,延迟高。
在 Meta 的体量下,纯推或纯拉都撑不住。混合策略更常见:热数据用推模式保证实时性,冷数据用拉模式控制存储成本。
2. 图谱剪枝:不是所有朋友都算"朋友"
社交图谱中大量关系是弱连接——加了好友但从不互动。如果把这些人的观看信号全拉进来,噪声远大于价值。工程上需要:
- 按互动频率、亲密度等维度对朋友做分层,只取强连接的信号。
- 对同一朋友的信号做去重和衰减,避免一个人狂刷时淹没你的发现流。
3. 排序融合:社交信号如何进入主推荐流
Friend Bubbles 不是独立页面,而是嵌入在 Reels 的主消费流中。这意味着社交信号要和推荐模型的得分做融合。常见做法是:
- 推荐模型产出候选集和基础排序分。
- 社交信号作为 boost factor 或 rerank 特征注入。
- 控制社交内容的占比上限,防止"朋友都在看"变成信息茧房。
实践:用 Python 构建一个简化版社交发现管线
下面用一个可运行的 Python 示例,演示社交发现的核心逻辑——从朋友图谱中提取近实时观看信号,与基础推荐分融合排序。这是教学性质的简化实现,Meta 的真实系统远比这复杂(分布式存储、流式计算、多阶段排序等),但核心思路可以对照理解。
"""
简化版 Friend Bubbles 社交发现管线
演示:朋友观看信号获取 + 与推荐分融合排序
"""
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from typing import Dict, List, Set
import random
import math
# ---------- 数据模型 ----------
@dataclass
class User:
uid: str
friends: Set[str] = field(default_factory=set) # 朋友集合
watch_history: List[tuple] = field(default_factory=list) # (reel_id, timestamp)
@dataclass
class Reel:
reel_id: str
base_score: float # 推荐模型给出的基础排序分
creator: str
# ---------- 模拟数据 ----------
def generate_mock_data(num_users=50, num_reels=100):
"""生成模拟用户图谱和 Reels 数据"""
users = {}
reels = {}
for i in range(num_reels):
reels[f"reel_{i}"] = Reel(
reel_id=f"reel_{i}",
base_score=random.uniform(0.1, 0.95),
creator=f"creator_{random.randint(0, 20)}"
)
for i in range(num_users):
uid = f"user_{i}"
# 每个用户随机 5-15 个朋友
friend_count = random.randint(5, 15)
friends = set(random.sample(
[f"user_{j}" for j in range(num_users) if j != i],
min(friend_count, num_users - 1)
))
# 每个用户最近看了 3-10 条 Reels
now = datetime.now()
watch_count = random.randint(3, 10)
watched = random.sample(list(reels.keys()), watch_count)
watch_history = []
for reel_id in watched:
# 时间从 5 分钟前到 48 小时前随机分布
hours_ago = random.uniform(0.08, 48)
watch_history.append((reel_id, now - timedelta(hours=hours_ago)))
users[uid] = User(uid=uid, friends=friends, watch_history=watch_history)
return users, reels
# ---------- 核心逻辑 ----------
def fetch_social_signals(
target_user: User,
all_users: Dict[str, User],
time_window_hours: float = 24.0,
max_signals_per_friend: int = 3,
) -> Dict[str, float]:
"""
从朋友的观看历史中提取社交信号。
返回: {reel_id: social_score},social_score 越高代表社交背书越强。
关键设计点:
- 时间衰减:越近的观看权重越高
- 朋友亲密度:这里简化为均匀权重,实际应按互动频率分层
- 单朋友信号上限:防止一个狂刷用户淹没信号池
"""
now = datetime.now()
cutoff = now - timedelta(hours=time_window_hours)
social_scores: Dict[str, float] = {}
for friend_uid in target_user.friends:
friend = all_users.get(friend_uid)
if not friend:
continue
# 只取该朋友在时间窗口内的观看,且限制条数
recent_watches = [
(reel_id, ts) for reel_id, ts in friend.watch_history
if ts >= cutoff
]
# 按时间倒序,取最近的几条
recent_watches.sort(key=lambda x: x[1], reverse=True)
recent_watches = recent_watches[:max_signals_per_friend]
for reel_id, ts in recent_watches:
# 时间衰减:1小时内的观看权重≈1.0,24小时前的≈0.25
hours_ago = (now - ts).total_seconds() / 3600
decay = math.exp(-0.05 * hours_ago)
# 朋友权重(简化版:均匀;实际应按亲密度分层)
friend_weight = 1.0
contribution = decay * friend_weight
social_scores[reel_id] = social_scores.get(reel_id, 0.0) + contribution
return social_scores
def merge_and_rank(
base_candidates: List[Reel],
social_scores: Dict[str, float],
social_boost_weight: float = 0.3,
max_social_ratio: float = 0.4,
) -> List[dict]:
"""
将推荐基础分与社交信号融合排序。
参数:
- social_boost_weight: 社交信号在总分中的权重
- max_social_ratio: 最终列表中社交内容占比上限,防止信息茧房
"""
results = []
for reel in base_candidates:
social = social_scores.get(reel.reel_id, 0.0)
# 归一化社交分到 0-1 范围(简化:用 max 做除数)
max_social = max(social_scores.values()) if social_scores else 1.0
normalized_social = social / max_social if max_social > 0 else 0.0
final_score = (
(1 - social_boost_weight) * reel.base_score
+ social_boost_weight * normalized_social
)
results.append({
"reel_id": reel.reel_id,
"base_score": reel.base_score,
"social_score": normalized_social,
"final_score": final_score,
"has_social_signal": social > 0,
})
# 按总分排序
results.sort(key=lambda x: x["final_score"], reverse=True)
# 控制社交内容占比上限
capped = []
social_count = 0
total_count = 0
for item in results:
total_count += 1
if item["has_social_signal"]:
if social_count / total_count >= max_social_ratio and total_count > 5:
# 超过比例上限,跳过这条社交内容
continue
social_count += 1
capped.append(item)
return capped
# ---------- 运行演示 ----------
def main():
users, reels = generate_mock_data(num_users=50, num_reels=100)
target_uid = "user_0"
target = users[target_uid]
print(f"=== 为 {target_uid} 构建 Friend Bubbles ===")
print(f"朋友数量: {len(target.friends)}")
# Step 1: 获取社交信号
social_scores = fetch_social_signals(target, users, time_window_hours=24)
print(f"\n24小时内朋友观看覆盖的 Reels 数: {len(social_scores)}")
if social_scores:
top_social = sorted(social_scores.items(), key=lambda x: x[1], reverse=True)[:5]
print("社交信号 Top 5:")
for reel_id, score in top_social:
print(f" {reel_id}: social_score={score:.3f}")
# Step 2: 融合排序
candidates = list(reels.values())
ranked = merge_and_rank(
candidates,
social_scores,
social_boost_weight=0.3,
max_social_ratio=0.4,
)
print(f"\n=== 最终推荐列表 Top 10 ===")
print(f"{'Reel':<10} {'基础分':>8} {'社交分':>8} {'总分':>8} {'有社交信号':>10}")
for item in ranked[:10]:
print(
f"{item['reel_id']:<10} "
f"{item['base_score']:>8.3f} "
f"{item['social_score']:>8.3f} "
f"{item['final_score']:>8.3f} "
f"{'✓' if item['has_social_signal'] else '—':>10}"
)
# 统计社交内容占比
social_in_top = sum(1 for item in ranked[:20] if item["has_social_signal"])
print(f"\nTop 20 中有社交信号的占比: {social_in_top}/20 = {social_in_top/20:.0%}")
if __name__ == "__main__":
main()
运行方式:
python friend_bubbles_demo.py
输出会展示某个用户的朋友观看信号分布、社交分与推荐基础分的融合结果,以及最终排序中社交内容的占比控制。
这个示例里几个值得调整的参数:
time_window_hours:缩小到 2-6 小时,信号更实时但覆盖更少;放大到 72 小时,覆盖更多但噪声增加。social_boost_weight:0.3 表示社交信号占总分 30%,调高则"朋友都在看"更容易胜出,调低则回归纯推荐。max_social_ratio:0.4 是社交内容占比上限,防止推荐流被社交信号完全主导。friend_weight:目前简化为均匀值,实际应按互动频率、共同群组等维度做亲密度分层。
从十亿级回看这些设计
上面的 Python 脚本跑在单机内存里,几十个用户、几百条 Reels,秒级出结果。但 Meta 面对的规模是:
- 数十亿用户,每人数百朋友,图谱边数量在万亿级。
- 每秒数百万观看事件,需要近实时注入社交信号池。
- 推荐模型本身已经是多阶段大规模管线,社交信号要嵌入其中而不拖慢整体延迟。
这意味着每一个"看起来简单"的设计点——时间衰减、朋友剪枝、占比控制——在单机上是一行代码,在十亿级上就是一套分布式存储+流式计算+多级缓存+在线推理的复合系统。Subasree 和 Joseph 在播客中反复强调的正是这一点:产品侧的简洁,必须由工程侧的深度来支撑。
落地前的检查清单
如果你在自己的系统中要引入类似的社交发现功能,不管规模是万级还是亿级,这几件事值得提前想清楚:
| 决策点 | 要回答的问题 |
|---|---|
| 信号获取模式 | 写扩散你能承受多少?读路径延迟上限是多少?是否需要推拉混合? |
| 图谱剪枝 | 哪些关系算"强连接"?是否需要互动频率阈值?冷启动用户怎么处理? |
| 时间窗口 | 信号保鲜期多长?过期信号是硬删除还是衰减归零? |
| 排序融合 | 社交权重占多少?是否需要 A/B 实验确定最优比例? |
| 占比控制 | 社交内容上限多少?超出时降权还是直接截断? |
| 存储成本 | 每个用户的社交信号池多大?冷热分层策略是什么? |
Friend Bubbles 最大的工程启示不是某个具体技术,而是这个认知:当产品说"就是加个小气泡"时,工程师需要追问的是——这个气泡背后的信号从哪来、多久更新、给谁看、排第几、占多少。 回答完这些问题,"简单功能"的工程深度才会真正浮现。