沸点推荐流系统方案
沸点推荐不应被设计成“实时算一批推荐结果再缓存”。它应该是一套信息流系统:后台持续生产候选池,用户请求只做会话管理、去重、补池和详情补全,从而支持首屏刷新、持续下拉、重新刷新和用户行为反馈。
本文给出 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 | 关注、互动、评论、收藏等作者亲密度 |
| HotScore | HotIndex 标准化 |
| 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,可选 |
flowType | Recommend / Hot |
clubId | 圈子过滤 |
topicId | 话题过滤 |
limit | 1-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仍然偏重,后续应继续收敛职责。