如何设计推荐系统(如今日头条)


📌 一、题目解读与需求澄清

这道题的本质是:如何在海量内容和海量用户之间,实时、精准地完成个性化匹配。核心挑战不在于单点技术,而在于多个子系统的协同:特征工程、模型召回、排序、实时更新、冷启动。

功能性需求(Functional Requirements)

# 需求 说明
1 用户打开 App 时,获取个性化 Feed 首屏加载 ≤ 10 条,支持下拉刷新和上拉分页
2 系统根据用户行为实时更新兴趣画像 点击、停留时长、点赞、分享、不感兴趣
3 新内容能在发布后短时间内进入推荐池 内容冷启动,新文章 ≤ 5 分钟可被推荐
4 支持多种内容类型 图文、视频、短视频、直播(可先聚焦图文)
5 支持屏蔽、举报、「不感兴趣」反馈 负反馈需快速生效,不应在本次 session 内再出现

非功能性需求(Non-Functional Requirements)

# 需求 量化目标
1 低延迟 推荐接口 P99 ≤ 200ms
2 高吞吐 支撑亿级 DAU,峰值 QPS 10w+
3 高可用 SLA 99.99%,推荐降级不应导致白屏
4 最终一致性 用户行为反馈可延迟数分钟生效,不要求强一致
5 可解释性与安全 内容需过审,屏蔽违规内容和敏感话题

容易被忽略的隐藏需求

  • 新用户冷启动:没有历史行为的用户如何初始化画像(年龄、地域、注册兴趣标签)
  • 内容时效性衰减:3天前的新闻不应与今天的突发新闻竞争相同的曝光位
  • 结果多样性:如果全是科技内容,用户会审美疲劳(Diversity vs. Relevance 的 trade-off)
  • 推荐去重:用户刷过的内容不能在短时间内重复出现
  • 马太效应:热门内容越推越热,长尾创作者没有曝光机会

📐 二、规模估算

关键假设

参数 假设值 理由
DAU 1亿 对标头条量级
人均每日打开次数 10次 每次刷新获取 10 条
人均每日行为事件 50条 点击、曝光、停留等
每日新增内容 500万篇 含 UGC、PGC、转载
内容平均大小(元数据) 1KB 标题、作者、标签、封面URL

QPS 计算

Feed 请求 QPS(读):
  1亿用户 × 10次/天 / 86400秒 ≈ 11,574 QPS
  峰值(早晚高峰 × 5倍) ≈ 60,000 QPS

行为上报 QPS(写):
  1亿用户 × 50条/天 / 86400秒 ≈ 57,870 QPS
  峰值 ≈ 300,000 QPS

存储计算

内容元数据:
  500万篇/天 × 1KB = 5GB/天
  保留1年 = 5GB × 365 ≈ 1.8TB(索引可用 Elasticsearch)

用户行为日志:
  1亿用户 × 50条 × 200字节 = 1TB/天
  保留90天 ≈ 90TB(冷存储 → HDFS / S3)

用户特征向量(Embedding):
  1亿用户 × 256维 × 4字节 = 100GB(可放 Redis)

内容向量:
  每天 500万内容 × 256维 × 4字节 = 5GB/天
  有效期7天 ≈ 35GB(可放 Faiss / Milvus)

带宽计算

Feed 响应包(10条内容 × 1KB)= 10KB/请求
带宽:60,000 QPS × 10KB = 600 MB/s ≈ 4.8 Gbps(需 CDN 分摊)

关键结论

这是一个写多读也多的混合型系统,但读的延迟要求极严苛(P99 200ms)。推荐不能在用户请求时实时计算完整结果,必须大量依赖预计算和缓存。行为写入量巨大,需要异步消息队列削峰。


🏗️ 三、高层架构设计

核心组件分为五层:数据采集层 → 特征层 → 模型层(召回+排序) → 服务层 → 内容管控层。### 各组件职责说明

API Gateway:统一入口,做限流(令牌桶)、用户鉴权、A/B 实验分流,将请求路由到推荐服务。

推荐服务(Recommendation Service):核心编排者,串联召回→粗排→精排→重排四个阶段,超时则走降级策略返回热门兜底列表。

召回层:从亿级内容池中筛出候选集(~1000条)。多路并行执行,用 Scatter-Gather 模式聚合结果。

粗排(Pre-rank):用轻量模型(如线性模型或小型 DNN)将 1000 条缩减到 200 条,核心目标是快速剪枝。

精排(Ranking):用重型深度模型(如 MMOE、DeepFM)对 200 条打分,同时预测多个目标(CTR、完读率、分享率)。

重排(Re-rank):在精排结果基础上做最后一道业务处理:多样性打散、去重、广告位插入、违规内容过滤。

核心数据流

用户下拉刷新
→ API GW(鉴权 + A/B 分流)
→ 推荐服务(从 Redis 获取用户实时特征)
→ 并行触发 3 路召回(User-CF / 向量召回 / 热门召回)
→ 候选集合并去重(~1000条)
→ 粗排(LR/小DNN,~5ms)→ 200条
→ 精排(MMOE深度模型,~30ms)→ 200条打分
→ 重排(多样性/安全过滤)→ 取 Top-30
→ 返回客户端
→ 客户端行为(曝光/点击/停留)→ Kafka → Flink实时更新用户特征 → Redis

核心数据库表结构

-- 内容表(MySQL + Elasticsearch 双写)
CREATE TABLE articles (
    article_id    BIGINT PRIMARY KEY,
    author_id     BIGINT NOT NULL,
    title         VARCHAR(200),
    category      VARCHAR(50),        -- 类别标签
    tags          JSON,               -- ["AI","科技","创业"]
    quality_score FLOAT DEFAULT 0.0, -- 内容质量分(离线计算)
    publish_time  TIMESTAMP,
    status        TINYINT DEFAULT 1, -- 1=正常 2=审核中 3=下架
    INDEX idx_publish_time (publish_time),
    INDEX idx_category (category)
);

-- 用户画像表(Redis Hash 存储实时部分,HBase 存储历史)
CREATE TABLE user_profiles (
    user_id       BIGINT PRIMARY KEY,
    category_pref JSON,      -- {"科技":0.8,"体育":0.3}
    keyword_pref  JSON,      -- 兴趣关键词权重
    embedding     BLOB,      -- 256维用户向量(定期更新)
    updated_at    TIMESTAMP,
    INDEX idx_updated_at (updated_at)
);

-- 用户行为表(写 Kafka → 落地 HBase,按 user_id+time 分区)
CREATE TABLE user_actions (
    user_id       BIGINT,
    article_id    BIGINT,
    action_type   TINYINT, -- 1=曝光 2=点击 3=完读 4=分享 5=不感兴趣
    dwell_time_ms INT,     -- 停留时长(毫秒)
    event_time    TIMESTAMP,
    PRIMARY KEY (user_id, event_time, article_id)
);

-- 内容向量表(存 Milvus,主键映射存 Redis)
-- article_id → embedding[256]

🔬 四、深入细节

子问题1:向量召回(Two-Tower Model)

问题描述:协同过滤依赖历史共现数据,对新内容和新用户效果差。需要一种能泛化的实时召回方式。

解决方案分析:Two-Tower(双塔模型)是目前工业界最主流的方案。User Tower 和 Item Tower 分别独立编码用户和内容特征,训练完成后可以离线预计算内容向量,在线只需实时计算用户向量,然后做 ANN(Approximate Nearest Neighbor)检索。

推荐方案:Two-Tower + Faiss(HNSW 索引)

# Two-Tower 模型核心结构(PyTorch 伪代码)
class TwoTowerModel(nn.Module):
    def __init__(self, user_feat_dim, item_feat_dim, embed_dim=256):
        super().__init__()
        # 用户塔:将用户特征压缩为 256 维向量
        self.user_tower = nn.Sequential(
            nn.Linear(user_feat_dim, 512),
            nn.ReLU(),
            nn.LayerNorm(512),
            nn.Linear(512, embed_dim),
        )
        # 内容塔:将内容特征压缩为 256 维向量
        self.item_tower = nn.Sequential(
            nn.Linear(item_feat_dim, 512),
            nn.ReLU(),
            nn.LayerNorm(512),
            nn.Linear(512, embed_dim),
        )

    def forward(self, user_features, item_features):
        user_emb = F.normalize(self.user_tower(user_features), dim=-1)
        item_emb = F.normalize(self.item_tower(item_features), dim=-1)
        # 点积相似度作为 logit
        score = torch.sum(user_emb * item_emb, dim=-1)
        return score

# 在线推理:用户向量实时计算,内容向量预存 Faiss
def recall_by_vector(user_id: str, top_k: int = 1000):
    # 1. 从 Redis 获取用户实时特征
    user_features = redis.get(f"user_feat:{user_id}")
    
    # 2. User Tower 推理得到用户向量(< 1ms)
    user_emb = user_tower.forward(user_features)  # shape: [256]
    
    # 3. Faiss HNSW 近邻检索(< 10ms,候选池 5000万内容)
    distances, article_ids = faiss_index.search(
        user_emb.reshape(1, -1), k=top_k
    )
    return article_ids.flatten().tolist()

关键细节:内容向量每天批量更新(新内容增量更新),用户向量在每次 Feed 请求时实时计算,或以 5 分钟为周期写入 Redis 做缓存。


子问题2:实时特征更新与用户画像

问题描述:用户刚点了一篇科技文章,系统应该立刻意识到这个信号,而不是等到次日离线训练才反映。这要求特征系统具备秒级响应能力。

解决方案分析:将用户行为分为「实时特征」和「历史特征」两层。实时特征用 Flink 流式消费 Kafka,做窗口统计后写入 Redis;历史特征用 Spark 批处理每日更新,存入 HBase。推荐时融合两层特征。

# Flink 实时特征计算(Java 伪代码,此处用 Python 描述逻辑)

# 数据流:Kafka 消费用户行为事件
# 每条事件格式:{user_id, article_id, action_type, category, tags, timestamp}

def process_user_action_stream(event_stream):
    """
    滑动窗口聚合:最近 1 小时内,用户各 category 的点击次数
    """
    return (
        event_stream
        .filter(lambda e: e.action_type in ["click", "share"])  # 正向行为
        .key_by("user_id")
        # 1小时滑动窗口,每 5 分钟触发一次计算
        .sliding_window(size=60*60, slide=5*60)
        .aggregate(CategoryCounter())
        .map(lambda agg: update_redis(
            key=f"user_realtime:{agg.user_id}",
            field="category_click_1h",
            value=agg.category_counts,  # {"科技": 5, "体育": 1}
            ttl=3600  # 1小时过期
        ))
    )

def get_merged_features(user_id: str) -> dict:
    """
    推荐时融合实时特征 + 历史特征
    实时特征权重更高,覆盖历史特征
    """
    # 实时特征:最近1小时行为(来自 Redis)
    realtime = redis.hgetall(f"user_realtime:{user_id}")
    
    # 历史特征:长期偏好画像(来自 HBase)
    history = hbase.get(f"user_profile:{user_id}")
    
    # 融合策略:实时信号衰减加权
    merged = {}
    for category in ALL_CATEGORIES:
        rt_score = float(realtime.get(f"category_click_1h:{category}", 0))
        hist_score = float(history.get(f"category_pref:{category}", 0))
        # 实时信号权重 0.7,历史权重 0.3
        merged[category] = 0.7 * min(rt_score / 10, 1.0) + 0.3 * hist_score
    
    return merged

子问题3:精排模型设计(多目标优化)

问题描述:推荐不能只优化点击率(CTR),否则会出现「标题党」大量被推荐的问题。实际上需要同时优化 CTR、完读率(Read Rate)、分享率(Share Rate)等多个目标。

解决方案分析:MMOE(Multi-gate Mixture-of-Experts)是目前工业界多目标排序的主流架构。它使用多个共享 Expert 网络,每个目标通过独立的 Gate 网络动态选择 Expert 组合,有效解决了多目标之间的跷跷板问题。

class MMOE(nn.Module):
    """
    Multi-gate Mixture-of-Experts
    同时优化 CTR、完读率、分享率 三个目标
    """
    def __init__(self, input_dim, num_experts=8, expert_dim=256, num_tasks=3):
        super().__init__()
        # 8个共享 Expert(每个是一个小MLP)
        self.experts = nn.ModuleList([
            nn.Sequential(nn.Linear(input_dim, expert_dim), nn.ReLU())
            for _ in range(num_experts)
        ])
        # 每个任务独立的 Gate(决定用哪几个 Expert 的组合)
        self.gates = nn.ModuleList([
            nn.Linear(input_dim, num_experts)
            for _ in range(num_tasks)
        ])
        # 每个任务的预测头
        self.task_heads = nn.ModuleList([
            nn.Sequential(
                nn.Linear(expert_dim, 64),
                nn.ReLU(),
                nn.Linear(64, 1),
                nn.Sigmoid()  # 输出概率
            )
            for _ in range(num_tasks)
        ])

    def forward(self, x):
        # 计算所有 Expert 输出
        expert_outputs = torch.stack(
            [expert(x) for expert in self.experts], dim=1
        )  # [B, num_experts, expert_dim]

        task_outputs = []
        for i, (gate, head) in enumerate(zip(self.gates, self.task_heads)):
            # Gate 计算每个 Expert 的权重
            gate_weights = F.softmax(gate(x), dim=-1)  # [B, num_experts]
            # 加权聚合 Expert 输出
            mixed = torch.sum(
                gate_weights.unsqueeze(-1) * expert_outputs, dim=1
            )  # [B, expert_dim]
            task_outputs.append(head(mixed))

        # 返回:[ctr_prob, read_rate_prob, share_rate_prob]
        return task_outputs

# 综合分计算(加权融合多目标)
def compute_final_score(ctr, read_rate, share_rate):
    """
    这个权重配置直接影响推荐生态,需要精心调整
    完读率权重高于CTR,抑制标题党
    """
    return (
        0.3 * ctr +
        0.5 * read_rate +
        0.2 * share_rate
    )

子问题4:内容冷启动(新文章如何快速进入推荐池)

问题描述:一篇新文章刚发布时没有任何用户交互数据,向量召回(依赖内容 Embedding)和协同过滤(依赖共现)都无法正常工作。

解决方案分析

  • 阶段1(发布后0-30分钟):基于内容本身的文本特征做召回。用 BERT 对文章标题和摘要做 Embedding,与用户偏好标签匹配,挂到「新内容」召回通道。
  • 阶段2(30分钟~数小时):用「探索流量」(小比例,约5%)主动曝光给兴趣匹配的用户,收集初始点击信号。
  • 阶段3(数小时后):内容有了足够的点击/完读数据,通过置信度判断是否进入主推荐通道。
def cold_start_score(article) -> float:
    """
    内容冷启动置信度评估
    综合作者历史、内容质量信号,决定给多少探索流量
    """
    author_score = get_author_quality_score(article.author_id)  # 作者历史CTR
    content_score = content_quality_model.predict(article)       # 文章质量分
    category_heat = get_category_trending_score(article.category) # 品类热度
    
    # 初始曝光量(探索流量配额)
    initial_traffic = int(
        1000 * author_score * 0.4 +  # 知名作者给更多初始流量
        500  * content_score * 0.4 +
        300  * category_heat * 0.2
    )
    return initial_traffic

# 冷启动毕业判断:当内容有足够信号后,进入正式排序
def should_graduate_from_cold_start(article_id) -> bool:
    stats = get_article_stats(article_id)
    return (
        stats.impressions >= 500 and   # 至少 500 次曝光
        stats.ctr >= 0.02 and          # CTR > 2%
        stats.avg_dwell_time_ms >= 30000  # 平均停留 > 30秒
    )

子问题5:推荐结果多样性(Diversity)

问题描述:精排后 Top-30 可能全是同一个 Category,或者同一作者的多篇文章。用户体验会非常糟糕。

解决方案:MMR(Maximal Marginal Relevance)算法,在相关性和多样性之间做 trade-off。

def mmr_rerank(candidates: list, lambda_param: float = 0.5, top_k: int = 30):
    """
    MMR 重排:每次选择「与已选集合差异最大 & 精排分最高」的候选
    lambda_param: 0=纯多样性, 1=纯相关性
    """
    selected = []
    remaining = candidates.copy()

    while len(selected) < top_k and remaining:
        best_item = None
        best_score = float('-inf')

        for item in remaining:
            # 相关性分(精排输出)
            relevance = item.ranking_score
            
            # 与已选列表的最大相似度(同类别/同作者 → 相似度高)
            if selected:
                max_similarity = max(
                    compute_similarity(item, s) for s in selected
                )
            else:
                max_similarity = 0

            # MMR 分 = λ × 相关性 - (1-λ) × 与已选的最大相似度
            mmr_score = lambda_param * relevance - (1 - lambda_param) * max_similarity

            if mmr_score > best_score:
                best_score = mmr_score
                best_item = item

        selected.append(best_item)
        remaining.remove(best_item)

    return selected

def compute_similarity(item_a, item_b) -> float:
    """简单规则:同Category相似度0.8,同作者相似度0.9,其他0.1"""
    if item_a.author_id == item_b.author_id:
        return 0.9
    if item_a.category == item_b.category:
        return 0.8
    return 0.1

⚖️ 五、技术选型与 Trade-off 讨论

决策点 选项A 选项B 本题推荐 & 理由
召回策略 纯协同过滤 向量召回 + 规则召回 + CF 多路并行 ✅ 多路并行。单一召回有明显盲区(新用户/新内容),多路互补覆盖率更高
排序模型 单目标 LR/GBDT 多目标深度模型(MMOE) ✅ MMOE。单目标优化 CTR 会劣化生态;深度模型能捕捉特征交叉
特征更新 纯离线日增量 实时流(Flink)+ 离线批处理双轨 ✅ 双轨。纯离线无法感知用户当次 Session 内的行为转变
向量检索 暴力全量搜索 ANN(Faiss HNSW / Milvus) ✅ ANN。5000万内容全量点积不可接受(秒级延迟),HNSW 可在10ms内完成
一致性 强一致性(同步更新所有副本) 最终一致性(异步) ✅ 最终一致性。推荐系统允许几分钟延迟,强一致大幅增加写延迟,得不偿失
内容存储 纯 MySQL MySQL(主数据)+ ES(全文检索)+ Redis(热点缓存) ✅ 分层存储。不同查询模式用不同存储,MySQL 做源头真实,ES 做搜索/过滤
推送模式 推模式(写扩散) 拉模式(读扩散) ✅ 拉模式为主。头条是信息流,用户关系不如微博强,读时拉取更灵活。大 V 内容可混合推

❓ 六、常见面试追问点

Q1:召回阶段为什么不直接用精排模型?

精排模型(MMOE等)推理一次需要5-30ms,候选集如果有5000万内容,全量精排根本不可能。召回的本质是在可接受延迟内将5000万缩小到1000,牺牲精度换效率;精排在小候选集上追求精度。这是一个典型的漏斗思想。


Q2:用户 Embedding 和内容 Embedding 怎么保持在同一个向量空间?

Two-Tower 训练时使用同一套对比损失(通常是 In-batch Softmax 或 Sampled Softmax),用用户的正向行为(点击、完读)作为正样本对,负样本从 batch 内随机采样。两个 Tower 在相同的优化目标下训练,因此 Embedding 天然在同一空间中,点积可以度量匹配程度。


Q3:如何处理热门内容的马太效应?

核心手段有三:① 在曝光分配时给新内容和长尾内容预留 5-10% 的「探索配额」;② 精排时对已曝光次数过多的内容施加惩罚(曝光量惩罚项);③ 指标监控层面,追踪「基尼系数」衡量内容分发不均程度,阈值触发时调整策略。


Q4:用户 A/B 实验如何正确划分,避免网络效应干扰?

推荐系统 A/B 实验的标准做法是按 user_id 取模(而非按请求),保证同一用户在实验期内始终在同一桶。复杂场景下(如社交关系密集的产品),需要用 Graph Partition 做「图划分实验」,但头条这种信息流产品用户间关系弱,user-level 分桶基本够用。


Q5:如何评估推荐系统的效果?离线指标和在线指标的关系?

  • 离线指标:AUC(分类)、NDCG(排序质量)、Recall@K(召回率)。问题是离线好≠线上好,因为存在「曝光偏差」(只有被推荐的内容才有反馈)。
  • 在线指标:CTR(点击率)、Session Duration(用户停留时长)、Retention(次日留存)。这是真实业务目标,但反馈周期长。
  • 实践中用离线指标做快速筛选,显著改进才上 A/B 实验,以在线指标做最终决策。

Q6:Faiss 索引如何应对内容的实时增删(新内容发布 / 内容下架)?

Faiss 本身不支持高效的增量更新(删除需重建索引)。工业界通常的做法是:内容 Embedding 每小时做一次全量重建,同时维护一个「黑名单」布隆过滤器(Bloom Filter)。召回结果返回后,立刻过滤黑名单(已下架内容),实现准实时的内容管控。更新频率要求极高时可以考虑 Milvus,它原生支持实时 CRUD。


Q7:推荐系统的降级策略怎么设计?

降级是生产环境必备能力,分三层:① 局部降级:精排超时(>100ms)时跳过精排,直接用粗排结果;② 召回降级:向量召回服务不可用时,只用热门召回兜底;③ 全量降级:推荐服务整体不可用时,直接从 Redis 返回「全局热门列表」(每5分钟预计算一次)。每个降级层都需要埋点监控,方便排查问题。


Q8:如何防止「信息茧房」(Filter Bubble)?

技术层面:① 在 Re-rank 阶段强制引入一定比例的跨领域内容;② 定期对用户兴趣向量做「正则化」,防止某个类别权重无限增大;③ 监控用户兴趣分布的熵值,熵值过低时主动增加多样性。更根本的是产品设计层面,提供用户可感知的兴趣管理入口。


Q9:内容安全过滤放在哪个环节最合适?

分两道:① 发布时过滤(Pre-filter):内容入库前过审,违规内容不进推荐池,这是第一道防线;② 召回后过滤(Post-filter):推荐链路中在 Re-rank 阶段再次过滤,防止漏网之鱼以及针对特定用户的定向违规(如对未成年人过滤特定内容)。两道过滤缺一不可,但 Post-filter 要保持轻量,避免引入延迟。


Q10:如何设计推荐系统的监控体系?

监控分三层:

  • 业务层:CTR、DAU、Session Duration、内容分发的基尼系数,每分钟粒度大盘看板;
  • 服务层:推荐接口 P50/P99 延迟、召回率、各召回通道的候选数量、降级触发次数;
  • 模型层:特征分布漂移(Feature Drift)、预测分布漂移(Score Drift)。

一旦线上 CTR 相对前一周同期下跌超过 5%,立刻触发告警并回滚最近的模型更新。


🗺️ 七、学习路线图

延伸阅读方向

1. 工业界论文(必读)

  • Deep Interest Network (DIN)(阿里,2018):用户历史行为序列建模的 Attention 机制
  • MMOE: Modeling Task Relationships in Multi-task Learning(Google,2018):多目标排序的基础架构
  • Sampling-Bias-Corrected Neural Modeling(Google,2019):Two-Tower 训练时负样本偏差修正
  • 字节跳动技术博客:ByteDance AI Lab 有多篇关于头条推荐系统演进的公开技术文章

2. 开源项目

  • Faiss(Facebook):向量检索核心库,深入阅读 HNSW 和 IVF 两种索引
  • Milvus:云原生向量数据库,对比 Faiss 理解其工程化差异
  • RecBole:推荐系统算法库,涵盖从 BPR 到 BERT4Rec 的完整实现,适合对比学习

3. 系统设计扩展

  • 重点理解 Lambda Architecture(批处理 + 流处理)在特征工程中的应用
  • 阅读 Flink 官方文档中 Streaming Window 和 State Backend 的设计

知识复用关系

题目 复用的核心知识
设计 YouTube 视频推荐 Two-Tower、多目标排序、冷启动,几乎完全复用;区别在于视频有更长的 Dwell Time 信号
设计 Twitter/微博 信息流 推拉结合模型(大 V 推模式)、时效性排序,是本题的社交版变体
设计搜索引擎排序 精排模型(BERT Reranker)、点击率模型与本题深度相关;区别是搜索有明确 Query
设计广告系统 召回→粗排→精排漏斗几乎完全相同;区别在于广告有出价约束,需要 eCPM = bid × pCTR
设计 Spotify 歌单推荐 协同过滤、Embedding 检索复用;区别在于音乐有更强的序列性(下一首歌预测)
Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐