跳到主要内容

沸点推荐流系统方案

沸点推荐不应被设计成“实时算一批推荐结果再缓存”。它应该是一套信息流系统:后台持续生产候选池,用户请求只做会话管理、去重、补池和详情补全,从而支持首屏刷新、持续下拉、重新刷新和用户行为反馈。

本文给出 CmsKit 沸点推荐流的设计与实现方案。这里的“类似 X”指信息流产品常见工程形态:多路候选池、会话流、曝光去重、混排、补池和降级,而不是复刻某个外部平台的闭源算法。

X 推荐系统可借鉴点

X 官方公开材料把 For You 时间线描述成一个多阶段推荐管线:

Candidate Sources
-> Ranking
-> Heuristics / Filters / Product Features
-> Mixing and Serving

可借鉴的是系统分层,而不是某个具体模型。

X 设计公开说明CmsKit 对应设计
Candidate Sources从多种来源召回候选,先把海量内容缩小到千级候选hot/fresh/user/topic/club/author/cold-start/explore 多候选池
In-Network / Out-of-Network关注网络和非关注网络内容并存关注作者池 + 话题/圈子/热门探索池
RealGraph预测用户与作者未来互动概率AuthorAffinity,基于关注、点赞、评论、收藏、浏览计算
Social Graph关注的人最近互动了什么,兴趣相似的人还喜欢什么二度关系召回,后续基于关注作者和相似用户互动实现
Embedding Spaces / SimClusters用社区或向量空间表达用户和内容兴趣先用话题、圈子、标签替代;后续可接入 embedding
Heavy Ranker对候选统一打分,不强依赖候选来源ShortMsgRanker 统一输出分数和推荐理由
Visibility Filters安全、偏好、屏蔽、内容质量过滤审核、隐私、举报、拉黑、负反馈过滤
Author Diversity / Content Balance避免单作者刷屏,保持来源平衡同作者/同圈子/同话题连续数量限制
Feedback Fatigue用户负反馈后降权或过滤negative:{userId} 与疲劳控制
Home Mixer / Product Mixer统一编排候选源、打分、过滤、混排和服务ShortMsgRecommendationService + FeedMixer

X 公开材料还强调用户行为信号是候选召回、特征和训练标签的重要输入,包括关注、取消关注、点赞、取消点赞、转发、回复、分享、收藏、点击、观看、不感兴趣、举报等。CmsKit 当前已有浏览、点赞、收藏、评论、关注等基础信号,应先把这些信号沉淀成画像和曝光状态,再逐步扩展负反馈。

CmsKit 推荐流分层

结合 X 的公开流程,CmsKit 沸点推荐流采用五层:

1. Signal Layer
用户行为、内容质量、作者关系、话题圈子、审核安全状态

2. Candidate Source Layer
多路候选池:关注作者、个性化、话题、圈子、热门、新鲜、冷启动、探索

3. Ranking Layer
统一打分:兴趣相关性、作者亲密度、热度、新鲜度、质量、负反馈惩罚

4. Filtering & Mixer Layer
已曝光过滤、安全过滤、多样性约束、来源比例控制、补池与降级

5. Serving Layer
session、cursor、seen、API 响应、曝光上报

每层先用规则实现,后续可以替换为模型、向量召回或相似用户召回。

目标

  • 首屏刷新能快速返回,不在请求链路做重型召回和排序。
  • 下拉加载使用同一个推荐会话,不重复返回本 session 已消费内容。
  • 重新刷新首屏会创建新 session,但优先避开近期已曝光内容。
  • 推荐流可以一直刷,session 候选不足时自动从基础池补充。
  • 推荐结果支持个性化、热门、新鲜、关注作者、话题和圈子混排。
  • 推荐策略、缓存存储、曝光记录、会话管理可独立演进。

当前落地状态

当前第一阶段已经落地在 ShortMsgRecommendationService 中:

能力状态说明
请求链路只读推荐基础池已实现base pool miss 时不再同步 BuildBasePoolAsync
首屏新建 session已实现不带 cursor/requestId 时生成新 requestId
Cursor 下拉续读 session已实现cursor 记录 requestId + offset
近期曝光 seen 缓存已实现cms:rec:shortmsg:seen:{userId}
返回后写入 seen已实现服务端返回即视为曝光,后续可替换为前端曝光上报
重新刷新去重已实现新 session 读取 seen 并过滤
session 低水位补池已实现剩余候选少于 40 时尝试补池
多路池混排组件待拆分目前仍在推荐服务内部完成
fresh/cold/topic/club/user 多类物理池待增强当前主要复用已有 base pool 和模板池
负反馈 negative待实现预留设计,尚未落地
二度关系/相似用户召回待实现对应 Social Graph,需要用户关系和互动图
向量/社区召回待实现对应 embedding / SimClusters,先用话题圈子替代

第一阶段的目标是先把推荐流体验跑起来:同一 session 下拉不重复,重新刷新尽量不重复,请求链路不实时重算推荐池。

总体架构

后台 Job
-> 构建基础候选池
-> 写入 Redis

用户请求 GetFeedAsync
-> 创建或加载 FeedSession
-> 读取基础候选池
-> 过滤 seen / negative / 本 session 已返回
-> 混排与补池
-> 写 session pool
-> 截取本页
-> 记录曝光
-> 查询详情并补全 DTO

推荐服务不拥有所有细节。最终应拆成以下组件:

ShortMsgRecommendationService
编排推荐流请求,保持 API 契约稳定。

IShortMsgRecommendationPoolStore
读写 hot/fresh/cold-start/user/topic/club 等候选池。

IShortMsgRecommendationPoolBuilder
供 Job 调用,负责从数据库和画像构建候选池。

IShortMsgFeedSessionStore
负责 session pool 创建、读取、续写、过期和低水位补池。

IShortMsgExposureStore
负责 seen/negative 的记录和查询。

IShortMsgFeedMixer
负责多路候选混排、比例控制、多样性约束和降级。

缓存模型

基础候选池

基础候选池由 Job 预计算,用户请求只读取。

cms:rec:shortmsg:base-pool:{hash} 当前兼容键,按查询条件和用户画像版本 hash

cms:rec:shortmsg:base:hot 全站热门池
cms:rec:shortmsg:base:fresh 新鲜高质量池
cms:rec:shortmsg:base:cold-start 冷启动池
cms:rec:shortmsg:base:topic:{topicId} 话题池
cms:rec:shortmsg:base:club:{clubId} 圈子池
cms:rec:shortmsg:base:author:{authorId} 作者池
cms:rec:shortmsg:base:user:{userId} 用户个性化池

第一阶段保留 base-pool:{hash},后续逐步引入语义化物理池。

推荐会话

cms:rec:shortmsg:pool:{requestId}

session pool 保存一次推荐会话的候选序列。继续下拉时,cursor 只需要保存:

RequestId
Offset

不要把候选列表塞进 cursor,候选列表存 Redis。

用户状态

cms:rec:user-profile:{userId} 用户画像
cms:rec:shortmsg:seen:{userId} 近期曝光
cms:rec:shortmsg:negative:{userId} 负反馈,待实现

seen 保存最近曝光的沸点 ID,TTL 建议 7 天,列表长度建议限制在 2000 左右。

推荐项结构

缓存池不要保存完整 DTO,只保存轻量项。

Id
Score
RecallSource
Reason
ClubId
TopicIds
AuthorId
GeneratedAt

完整内容、用户信息、点赞状态、投票、评论数等动态字段仍通过 ShortMsgReadService.EnrichShortMsgDtosAsync 补全。

候选源设计

关注作者池

对应 X 的 In-Network Source。

输入:用户关注作者、作者近期公开沸点、作者亲密度
排序:AuthorAffinity + Freshness + HotIndex
缓存:cms:rec:shortmsg:base:followed:{userId}

AuthorAffinity 初期用规则计算:

关注 +8
点赞作者内容 +3
评论作者内容 +5
收藏作者内容 +4
浏览作者内容 +1
最近 30 天互动按时间衰减

个性化兴趣池

基于用户画像中的话题、圈子、作者权重。

输入:UserRecommendProfile.TopicWeights / ClubWeights / AuthorWeights
召回:命中高权重话题、圈子、作者的近期沸点
排序:InterestMatch + HotIndex + Freshness
缓存:cms:rec:shortmsg:base:user:{userId}

话题/圈子池

解决用户进入某个圈子或话题时的局部推荐。

cms:rec:shortmsg:base:topic:{topicId}
cms:rec:shortmsg:base:club:{clubId}

热门池

热门池保证推荐流不会因为个性化池不足而断流。

输入:公开、审核通过、近 7-30 天沸点
排序:EditorIndex + HotIndex + Freshness + Quality
缓存:cms:rec:shortmsg:base:hot

新鲜池

新鲜池给新内容机会,避免老热门长期占据流量。

输入:近 24-72 小时公开沸点
排序:Freshness + EarlyEngagement + Quality
缓存:cms:rec:shortmsg:base:fresh

探索池

探索池对应 X 的 Out-of-Network 探索思想。CmsKit 初期不做复杂图遍历,先从优质热门、编辑精选、相邻话题中补充。

输入:用户未关注作者、相邻话题、热门圈子、编辑精选
排序:Quality + Freshness + DiversityBonus
缓存:cms:rec:shortmsg:base:explore

排序设计

统一 Ranker 不应只看候选来源。所有候选进入同一打分函数:

Score =
InterestScore * 0.35
+ AuthorAffinityScore * 0.15
+ HotScore * 0.20
+ FreshnessScore * 0.15
+ QualityScore * 0.10
+ SourceBonus * 0.05
- NegativePenalty
- FatiguePenalty
分数来源
InterestScore话题、圈子、标签命中用户画像
AuthorAffinityScore关注、互动、评论、收藏等作者亲密度
HotScoreHotIndex 标准化
FreshnessScore发布时间指数衰减
QualityScore审核、举报率、互动质量、编辑指数
SourceBonus关注作者、编辑精选、新鲜内容等来源加权
NegativePenalty不感兴趣、举报、屏蔽、拉黑
FatiguePenalty作者/话题/圈子近期曝光过多

第一阶段可用规则打分。后续如果引入 ML/embedding,仍保留同一 Ranker 接口。

请求流程

首屏

不传 cursor 时,服务端创建新 session。

GetFeedAsync(query)
-> limit = clamp(query.Limit, 1, 50)
-> requestId = query.RequestId ?? Guid.NewGuid()
-> offset = 0
-> load seen ids
-> read base pools
-> filter seen / query filters
-> save session pool
-> take first page
-> mark exposed
-> enrich DTO
-> return RequestId + NextCursor

首屏刷新不复用 5 分钟时间桶。否则用户重新刷新会拿到同一个 session pool,体验上像“刷新没变化”。

下拉

cursor 时,继续使用同一个 session。

GetFeedAsync(cursor)
-> decode RequestId + Offset
-> load session pool
-> if remaining < 40: refill session
-> take next page by offset
-> mark exposed
-> enrich DTO
-> return next cursor

同一个 session 内,offset 单调向后移动,因此不会重复返回已经消费过的候选。

重新刷新

重新刷新首屏时前端不要传旧 cursor。

GetFeedAsync(no cursor)
-> create new requestId
-> read base pools
-> filter seen
-> create new session pool
-> return first page

新 session 会重新混排,但由于读取 seen:{userId},近期已经曝光过的沸点会被过滤。

补池机制

session pool 不能只是一批固定候选,否则用户刷完就断流。每次读取 session 时检查低水位。

if sessionPool.Count - offset < 40:
refill = read base pools
refill = filter existing session ids
refill = filter seen ids
append refill to session pool
save session pool

第一阶段补池复用已有 base pool。后续补池应从多类物理池读取并混排:

user pool
followed authors pool
topic pool
club pool
fresh pool
hot pool
cold-start pool
explore pool
old high-quality pool

主推荐流不能因为用户个性化池为空而直接返回空。候选不足时应执行 fallback cascade:

1. user personalized
2. followed authors
3. topic / club related
4. fresh high-quality
5. hot
6. cold-start / editor-picked
7. explore / random high-quality
8. old high-quality backfill

越靠后的候选相关性越弱,但可以保证流不断。最后的 old high-quality 只在内容池不足时启用,并且必须保留 session 强去重。

去重策略

推荐流使用多层去重。

层级数据来源处理
Session 去重当前 session pool / offset强制不重复
已曝光去重seen:{userId}默认过滤
互动去重浏览、点赞、收藏日志IncludeViewed=false 时过滤
负反馈去重negative:{userId}待实现,强过滤或强降权

候选不足时可以降级,但不能破坏 session 去重:

第一优先级:永远过滤当前 session 已返回
第二优先级:过滤 24 小时 seen
第三优先级:过滤 7 天 seen
候选不足:放宽 7 天 seen,但保留 session 去重

当前第一阶段使用 7 天 seen 强过滤。后续可把 seen 拆成 24 小时与 7 天两个窗口。

混排策略

推荐流不应只按一个分数排序。建议使用多路候选比例混排。

来源初始比例作用
个性化兴趣45%满足用户主要兴趣
热门高质量20%防止个性化过窄
新鲜内容15%给新沸点曝光机会
关注作者10%强关系内容
话题/圈子探索10%扩展兴趣边界

当用户没有足够个性化信号时,比例自动调整:

来源冷启动比例
热门高质量35%
新鲜内容25%
编辑/冷启动20%
话题/圈子探索20%

混排后再做多样性约束:

同作者不连续出现
同圈子连续最多 2 条
同话题连续最多 2 条
低质量内容降权
新内容加 freshness bonus
举报、私密、未审核内容强过滤

当前第一阶段仍沿用已有排序逻辑。下一阶段应新增 IShortMsgFeedMixer,把混排逻辑从 ShortMsgRecommendationService 中拆出去。

Job 设计

Job 负责重计算,避免用户请求链路查大表、做多路召回和排序。

高频 Job

建议 5 分钟执行一次。

BuildShortMsgHotPool
BuildShortMsgFreshPool
BuildShortMsgColdStartPool
BuildActiveTopicPools
BuildActiveClubPools

中频 Job

建议 10-30 分钟执行一次。

BuildActiveUserShortMsgPools
BuildActiveAuthorPools
WarmActiveUserProfiles

行为触发

用户行为只更新轻量状态,不同步重算推荐池。

view/expose -> write seen
like/bookmark/comment -> update profile signal
follow -> update author preference
not interested/report -> write negative

用户画像变化在下一轮 Job 中体现在用户池里。

API 契约

现有接口保持不变。

GET /api/cms/recommend/shortmsg

推荐调用方式:

场景参数
首屏进入不传 cursor,通常也不传 requestId
下拉加载传上次响应的 nextCursor
重新刷新不传旧 cursor
调试固定 session显式传 requestId

响应继续保留:

Items
RequestId
HasMore
NextCursor

推荐流接口

GET /api/cms/recommend/shortmsg

查询参数:

参数说明
cursor下拉加载时传上一页 nextCursor
requestId调试或固定 session,可选
flowTypeRecommend / Hot
clubId圈子过滤
topicId话题过滤
limit1-50
includeViewed是否包含已浏览/互动内容

响应示例:

{
"requestId": "session id",
"nextCursor": "base64 cursor",
"hasMore": true,
"items": [
{
"id": "shortmsg id",
"score": 123.4,
"recallSource": "followed+topic+hot",
"reason": "你关注的作者发布了新沸点"
}
]
}

曝光上报接口

后续建议新增。当前实现是“服务端返回即曝光”。

POST /api/cms/recommend/shortmsg/exposures
{
"requestId": "session id",
"itemIds": ["..."],
"exposedAt": "2026-06-28T10:00:00Z",
"viewport": "feed"
}

作用:

  • 写入 seen:{userId}
  • 生成训练/画像信号。
  • 统计曝光但未点击、曝光但跳过等负样本。

负反馈接口

POST /api/cms/recommend/shortmsg/feedback
{
"requestId": "session id",
"itemId": "shortmsg id",
"feedbackType": "NotInterested",
"reason": "TooMuchSameTopic"
}

feedbackType 建议:

NotInterested
HideAuthor
HideTopic
HideClub
Report
Undo

负反馈写入 negative:{userId},并影响后续过滤、降权和画像。

曝光记录策略

曝光去重有两种实现方式。

服务端返回即曝光

当前第一阶段采用这种方式。

优点:

  • 不需要前端新增接口。
  • 可以立即解决重新刷新重复问题。

缺点:

  • 返回给前端不等于用户真的看到了。
  • 如果一次返回较多,底部内容可能被提前标记为 seen。

前端视口曝光上报

最终推荐方式。

POST /api/cms/recommend/shortmsg/exposures
{
requestId,
itemIds,
exposedAt
}

优点:

  • 更准确。
  • 可采集停留时长、点击、跳过等训练信号。

缺点:

  • 前端需要接入曝光检测。
  • 后端需要新增批量曝光接口。

降级策略

当个性化池不存在:

user pool -> followed authors -> topic/club -> fresh -> hot -> cold-start -> explore

当指定话题/圈子池不存在:

topic/club pool -> generic user pool -> hot pool

当 Redis 缓存缺失:

返回空结果或热门模板池
不要在请求链路实时重算完整推荐池

当候选不足:

1. 保留 session 强去重
2. 保留 24 小时 seen 去重
3. 先补 followed/topic/club/fresh/hot/explore
4. 仍不足,再放宽 7 天 seen
5. 最后允许老高质量内容回流,但降权

不要因为用户个性化池为空就返回空。主推荐流应该优先保证可用性,再逐步优化相关性。

实现计划

阶段 1:推荐流可用

已实现:

  • 首屏创建新 session。
  • Cursor 下拉续读 session。
  • 读取预热 base pool,不在请求链路实时构建。
  • 写入并读取 seen:{userId}
  • 重新刷新过滤 seen。
  • session 低水位补池。

阶段 2:拆分组件

建议拆出:

Application/Discovery/Recommendations/ShortMsgs/ShortMsgRecommendationPoolStore.cs
Application/Discovery/Recommendations/ShortMsgs/ShortMsgFeedSessionStore.cs
Application/Discovery/Recommendations/ShortMsgs/ShortMsgExposureStore.cs
Application/Discovery/Recommendations/ShortMsgs/ShortMsgFeedMixer.cs
Application/Discovery/Recommendations/ShortMsgs/ShortMsgRecommendationPoolBuilder.cs

拆分后 ShortMsgRecommendationService 只负责编排。

阶段 3:多物理池

新增语义化候选池:

hot
fresh
cold-start
topic:{topicId}
club:{clubId}
author:{authorId}
user:{userId}
followed:{userId}
explore

并让 Job 定期构建这些池。

阶段 4:排序与混排升级

新增:

ShortMsgRanker
ShortMsgFeedMixer
AuthorAffinityScore
InterestScore
FatiguePenalty
SourceBalance

把现有单一路径排序升级为“统一打分 + 多路混排 + 多样性约束”。

阶段 5:负反馈和曝光上报

新增:

negative:{userId}
exposure report API
dwell time / skip / click signals

负反馈进入过滤和排序,曝光上报替代“返回即曝光”。

阶段 6:相似用户与向量召回

在内容和行为量足够后,再考虑:

相似用户召回
二度关系召回
内容 embedding
用户 embedding
话题/圈子社区 embedding

不要在早期直接上重模型。先把候选池、去重、反馈、混排的工程闭环做好。

验收标准

  • 首屏不传 cursor 时,连续两次请求返回不同 RequestId
  • 用户第二次刷新首屏时,不应返回第一次已经曝光的沸点,除非候选不足并触发降级。
  • 下拉使用 nextCursor 时,不重复返回本 session 已消费内容。
  • base pool 未预热时,请求链路不实时构建推荐池。
  • Job 预热后,新内容不会在下一轮预热前进入推荐池。
  • Redis 中 session pool 和 seen 记录均有 TTL。
  • 用户个性化池为空时,推荐流应从 followed/fresh/hot/explore 降级补充,而不是直接为空。
  • 负反馈内容不应在下一次刷新中再次出现,除非用户撤销反馈。

风险与取舍

  • 服务端返回即曝光会牺牲曝光准确性,但可以快速解决刷新重复。
  • 只读缓存池会依赖 Job 可用性,Job 不运行时推荐流可能为空或退化。
  • 过强的 seen 过滤会让小内容池更快耗尽,需要补池和降级策略配合。
  • 组件拆分前,ShortMsgRecommendationService 仍然偏重,后续应继续收敛职责。