news 2026/10/27 10:20:56

GoWind ShortPlay 拆解:不用模型的推荐闭环——埋点回流、三路召回与已看去重

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
GoWind ShortPlay 拆解:不用模型的推荐闭环——埋点回流、三路召回与已看去重

GoWind ShortPlay 拆解:不用模型的推荐闭环——埋点回流、三路召回与已看去重

本文回答一个问题:没有算法团队、没有特征平台、没有模型服务,一个短视频 Feed 的"推荐"要怎么做才不是自欺欺人?

GoWind ShortPlay 的 Feed 有一套完整的推荐链路:Flutter 播放器实时上报观看行为 → core 防刷入库 → 三路召回 + 热度加权排序 → 已看去重 → 召回理由随内容回传。全程一个模型都没有,但闭环的每一环都是真的——埋的是真数据、算的是真信号、去重是真去重。

写这篇文章前先立个牌子:它不是 AI 推荐系统。没有用户画像、没有协同过滤、没有 CTR 预估,热门榜对每个登录用户长一个样。这是刻意的,而且我认为在开源 MVP 语境下,这是唯一诚实的选择——下面拆完每一环,你会看到"简单"本身是怎么被设计出来的。


一、先泼冷水:为什么不用模型

在数据没攒够之前上模型,不是先进,是负资产:

  • 没有正负样本。推荐模型的燃料是"曝光-行为"对,开源版上线第一天数据量是零。规则排序零数据可用,模型排序零数据报废。
  • 不可解释。运营问"为什么这个视频在首页",view×1 + like×3 + collect×5 + complete×4能当场算给他看,模型的 feature importance 不能。
  • 不可调试。规则错了改权重立刻生效;模型错了要查特征、重训、灰度——这些基建本身就是三万人月。
  • 闭环比聪明重要。短视频产品的冷启动阶段,"用户滑走的不再推、完播的被加权"这两条规则贡献的体验提升,远大于一个没有数据喂的模型。

所以 GoWind ShortPlay 的选择是:先花最小代价把反馈环转起来,把升级路径留在契约里。全链路长这样:

Flutter 播放器 core 服务 ┌──────────────────┐ HTTPS ┌─────────────────────────┐ │ onPlayStart │ │ ReportEvents │ │ onHeartbeat(10s) ├──批量上报──▶│ 单批≤100 防刷 │ │ onComplete │ │ 逐条落 sp_uba_events │ │ onSwipeAway(<95%)│ └───────────┬─────────────┘ └──────────────────┘ │ ▼ ┌──────────────────┐ ┌─────────────────────────┐ │ GetFeed │◀──gRPC────│ 三路召回: │ │ 上下滑沉浸流 │ │ 关注 → 热门 → 最新兜底 │ │ (reason 回传) │ │ 热度分 = SQL 加权排序 │ └──────────────────┘ │ 已看去重 = Redis SET │ └─────────────────────────┘

四类事件、一张表、一条 SQL、一个 SET。下面逐环拆。


二、信号从哪来:播放器就是埋点器

推荐系统的第一课是:信号质量取决于离行为现场有多近。GoWind ShortPlay 把埋点直接做进了播放器组件——video_player_item.dart封装 media_kit,向上抛四个回调,宿主页面负责上报:

信号触发时机语义
onPlayStart进入当前页开始播放一次真实曝光(进入过播放态,不只是划过卡片)
onHeartbeat10 秒定时器,仅播放中触发持续观看证明,带当前进度
onComplete播放器 completed 流触发,percent=100看完了
onSwipeAway离开当前页且进度 <95%没看完就走了

这里最值得抄的是95% 这条线的互补设计:

if(!oldWidget.isActive&&widget.isActive){/* 进入 */}if(oldWidget.isActive&&!widget.isActive){unawaited(_player.pause());finalprogress=_snapshot();if(progress.percent<95){widget.onSwipeAway?.call(progress);// 只有"没看完就走"才算划走}}

用户在第 96% 处滑走、播放器恰好没来得及触发 completed,这条行为不算划走——它接近完播,让位给COMPLETE事件。两个信号不重叠计费,热门分不会因为"结尾长"的短视频被重复加权。埋点的去重做在客户端定义层,比服务端事后清洗便宜得多。

心跳的节流也做在客户端:Timer.periodic(Duration(seconds: 10))且仅在_player.state.playing时触发——暂停不计心跳,快进/拖动由_snapshot()取实时位置,服务端拿到的就是干净的"每 10 秒一次、只在真看"的脉冲。

上报是 fire-and-forget,这是采集段的第二原则——埋点永远不配阻塞播放:

voidreportAll(List<UbaEvent>events){unawaited(_dio.post('/uba/events',...).then((_){},onError:(Objecte){debug('uba report failed (ignored):$e');}));}

unawaited+ 失败静默。序列化层还处理了一个 protojson 的实际坑:durationMs是 int64,必须序列化成字符串('durationMs': '$durationMs'),watchPercent是 uint32 传数字并 clamp 到 0-100——前后端契约的精度细节在客户端就锁死了。


三、信号去哪了:一张埋点表,两个消费方

服务端接收接口POST /app/v1/uba/events在游客白名单里——游客也能上报,否则冷启动期的行为数据永远归因不到人。配套的UbaIdentityInjector中间件做了一件精细的事:白名单短路了鉴权,但请求带有效 token 时尽力解析出 uid 写回请求头,解析失败静默放行按游客处理。身份是尽力而为的增强项,不是上报的前置条件。

入库三道闸(core 的uba_service.go):

  1. 单批上限 100(ubaMaxBatchSize)——防刷的第一道墙:正常客户端单次上报撑死几条,一次 POST 几千条的必是脚本;
  2. 逐条落库、单条失败不中断整批——CreateBatch是尽力而为语义,脏数据丢弃不影响好数据;
  3. 表设计直接为消费方服务:
index.Fields("event_type","occurred_at").StorageKey("idx_sp_uba_event_type_occurred_at")index.Fields("video_id","event_type").StorageKey("idx_sp_uba_video_event")

(video_id, event_type)这个复合索引就是给热门聚合用的——后面那条热门 SQL 的WHERE event_type='VIDEO_COMPLETE' GROUP BY video_id走的就是它。埋点表的索引是照着查询画出来的,不是拍脑袋加的。

而这张表最"一鱼两吃"的地方:观看历史就是埋点数据本身。ListWatchHistoryPage对该用户的VIDEO_PLAY事件做GROUP BY video_id + MAX(COALESCE(occurred_at, created_at)),聚合出"最近观看时间"排序——没有独立的 history 表,看过的视频天然按最后观看时间倒序。“行为日志即业务数据”,少一张表就少一条同步链路。


四、分怎么算:一条 SQL 的推荐排序

Feed 的召回-排序-合并在feed_service.go的GetFeed,先看全景再拆热度:

recallN:=int(pageSize)*4// 超采样 4 倍// 1. 关注召回:最近关注 100 人的已发布视频,发布时间倒序authorIDs,_:=s.interRepo.FollowingAuthorIDs(ctx,uid,100)followVideos:=s.videoRepo.ListPublishedByAuthors(ctx,authorIDs,recallN)// 2. 热门加权hotVideos:=s.videoRepo.ListHot(ctx,recallN)// 3. 最新兜底(带 tag 走标签聚合)latestVideos:=s.videoRepo.ListLatest(ctx,recallN)

三路各自超采样 4 倍(每路都取 4×pageSize 条),合并是一个先到先得的闭包:

add:=func(vs[]*ent.Video,reason feedV1.FeedReason){for_,v:=rangevs{if_,dup:=mergedIDs[v.ID];dup{continue// 已被更高优先级召回想,跳过}mergedIDs[v.ID]=struct{}{}merged=append(merged,v)reasonsOf[v.ID]=reason// 先到先得:关注 > 热门 > 最新}}

合并时用 map 去重、理由先到先得(关注 > 热门 > 最新),每条 item 的reason字段随内容一起下发——前端可以拿它做角标(“你关注的 TA”)、做分路占比的灰度观测。召回理由进契约而不是藏在服务端,这是后面升级路径的地基。

热度分公式

ListHot是全项目少数原生 SQL 聚合点,值得整段读:

// UBA 完播聚合(每视频至多一行,不产生行增殖):完播是内容质量的// 强信号,计数列(view/like/collect)只反映互动量不反映看完率。agg:=sql.Select("video_id","COUNT(*) AS complete_cnt").From(sql.Table("sp_uba_events")).Where(sql.EQ("event_type","VIDEO_COMPLETE")).GroupBy("video_id").As("uba_agg")s.LeftJoin(agg).On(s.C(video.FieldID),"video_id")s.OrderBy(sql.Desc("view_count * 1 + like_count * 3 + collect_count * 5 + COALESCE(complete_cnt, 0) * 4"),sql.Desc(video.FieldPublishedAt),)

权重不是拍脑袋的,每个数字对应一种行为成本:

信号权重行为成本说明
view_count×1零成本详情接口 +1,容忍丢失不走事务——最廉价的信号只配最低权重
like_count×3一次点击主动互动,但要双击/点按钮
collect_count×5一次点击 + 未来回报预期收藏是"我还要再看",意向最强的轻量信号
complete_cnt×4完整时长投入质量信号:互动可以刷,看完刷不了

两个 SQL 细节:

  • 子查询聚合 + LEFT JOIN,GROUP BY video_id保证每视频至多一行,注释专门点名"不产生行增殖"——埋点表是几十万行,JOIN 出来的还是视频行数,排序在视频表规模上完成;
  • COALESCE(complete_cnt, 0)兜住没有完播记录的视频,同分回退published_at DESC——没人互动的新内容按新鲜度兜底,冷启动视频永远有机会进池。

关于这条 SQL 的量级账也值得算清楚:聚合子查询WHERE event_type='VIDEO_COMPLETE' GROUP BY video_id走(video_id, event_type)复合索引,代价与完播事件总量线性而非视频总量线性——埋点表涨到百万行时,这个子查询会是热门召回的主要成本。到那一步的正确动作不是给 MySQL 加机器,而是把聚合结果周期物化(视频表加一列complete_count冗余,或挪进 OLAP)——但 MVP 阶段刻意不做:冗余列的增量维护会把埋点写入从"尽力而为的单条 insert"变成"带计数事务的写",采集端的失败语义会跟着变。读时聚合是惰性的正确,物化是规模到了之后的优化,两者切换点清晰。

这就是"够用的推荐"的形态:一条索引友好的 SQL,把质量信号(完播)从行为信号(互动)里拆出来加权。它回答不了"这个人爱看什么",但能保证"垃圾内容沉底、好内容上浮"——而后者决定了 Feed 的及格线。


五、怎么不重复:已看集合与诚实的"尽力而为"

沉浸式 Feed 最大的体验底线是不重复推。实现是一个 Redis SET(feed_service.go):

const(feedSeenKeyPrefix="gowind-shortplay:feed:seen:"feedSeenTTL=7*24*time.Hour feedSeenCap=2000)
  • 写:本页下发即记已看——SAdd+Expire(7 天)走 Pipeline,每次下发滑动续期:
pipe:=s.redis.Pipeline()pipe.SAdd(ctx,key,members...)pipe.Expire(ctx,key,feedSeenTTL)// 每次写入滑动续期 7 天if_,err:=pipe.Exec(ctx);err!=nil{return}ifn,err:=s.redis.SCard(ctx,key).Result();err==nil&&n>feedSeenCap{s.redis.SPopN(ctx,key,n-feedSeenCap)// 超上限,随机弹出多余成员}

注意语义:已看的定义是"下发过"而非"播放过",Feed 场景下推了没看≈不想看,同样不该再推。

  • 读:SMembers全量拉回内存 set,三路召回各自过一遍seenFiltered。敢全量拉是因为容量有界——上限 2000,约 20KB/用户,微秒级过滤。
  • 防膨胀:SCard超 2000 时SPopN(key, n-2000)随机弹出多余成员。注释大方承认是"尽力而为"——不是 LRU,最早看过的和最近看过的有同等概率被弹掉。

为什么不用 ZSet 做真 LRU、不用 bitmap?算一笔账:单用户 2000 个 ID 的 SET 约 20KB,SMembers一次内存过滤微秒级;换成 ZSet 每次写多一次 score 更新,读侧改成 ZRANGEBYSCORE 分页——复杂度上去了,收益是"第 2001 个之前看过的视频理论上不重复",而页码截页的分页方式本来就无法保证严格不重复。这是两个刻意声明的取舍:

  1. 随机淘汰:超容量的老记忆可能被弹掉,换 SET 的简单;
  2. 页码截页(非游标):往回翻可能因已看过滤出现空页,换实现直接。

代码不装。注释里写的就是 trade-off 本身,等真到商业化量级,替换的是这一层,GetFeed的接口签名不动。


六、个性化在哪、不在哪:升级路径是设计出来的

把边界画清楚,这一节回答"那它到底个性化了个啥":

有的个性化:

  • 关注流——唯一的"人"维度信号,最近关注 100 人的新作优先(先到先得占住理由位);
  • 已看去重——千人千面最朴素的形式:你看过的,别人不用管,你自己不再看到。

没有的个性化:

  • 热门榜全局一份,没有"相似用户也看了";
  • 划走信号采集了但排序还没消费——SWIPE_AWAY在sp_uba_events里躺着,热门分只用了COMPLETE。信号先行入库、消费后行跟进,这是有意的节奏。

冷启动的真实路径也值得走一遍:新用户第一屏,关注召回为空(FollowingAuthorIDs返回空集直接短路),热门召回对它是全局榜——新用户和老用户看到的头部内容一样,唯一的差异会随关注关系与已看集合慢慢积累;游客(uid=0)更彻底,已看集合整个跳过,刷两页同内容会重复——游客本来就不承载个性化预期。这两个"体验弱点"都是刻意不修的:修复它们的每一项(画像、协同过滤)成本都远超 MVP 阶段的收益,而埋点已经把需要的数据存下来了。

那下一步怎么走?代码里已经留好了三段路径:

  1. 消费已有信号:把SWIPE_AWAY变成负权重(- swipe_cnt × k)、给心跳聚合出"平均观看进度",都是现表现索引上的 SQL 改动;
  2. 加画像维度:tag 偏好(用户对哪类标签完播率高)可以纯 SQL 聚合出来,插进召回层做一路"兴趣召回"——GetFeed里加一路add(),契约不变;
  3. 换真推荐服务:ListHot的代码注释明示了这条升级路径是预留过的——「替换本召回时接口形态不变(ROADMAP 预留)」。因为FeedReason枚举进了 proto 契约、召回收敛在 videoRepo 的三个方法里,未来无论后面是向量召回还是模型排序,GetFeed的调用方(Flutter App / BFF)一行不用改。

这就是开篇说的"把升级路径留在契约里"的完整含义:推荐系统的可替换性不取决于今天用没用模型,取决于召回/排序有没有被隔离在契约边界内。


七、快速验证

# 起服务后(见仓库 README),游客直接刷 Feedcurl'http://127.0.0.1:7700/app/v1/feed?pageNum=1&pageSize=5'# 连刷两页:第二页不含第一页的 videoId(游客无已看集合,登录后生效)# 登录用户登录态刷两页,观察 reason 字段的分路占比# FEED_REASON_FOLLOW / FEED_REASON_HOT / FEED_REASON_LATEST# 手工制造完播信号,看热门分变化curl-XPOST http://127.0.0.1:7700/app/v1/uba/events\-H'Content-Type: application/json'\-H"Authorization: Bearer <token>"\-d'{"events":[{"eventType":"EVENT_TYPE_VIDEO_COMPLETE","videoId":1,"durationMs":"60000","watchPercent":100}]}'

Flutter 端跑起来更直观:flutter run --dart-define=API_BASE=http://127.0.0.1:7700,上下滑几个视频,看管理后台数据看板的趋势变化——那就是你刚喂进去的埋点。


八、结语

这篇拆的是 GoWind ShortPlay 里最"不像功能"的功能:它没有独立的服务、没有模型、甚至没有一张新表,但它把推荐系统的骨架——信号采集、防刷入库、召回排序、去重、理由回传——全部立住了。回头看这个闭环的设计原则,其实就三条:

  1. 信号定义做在离行为最近的地方(播放器回调层,95% 线互补去重);
  2. 埋点永远尽力而为(fire-and-forget、逐条落库、失败静默——数据管道不能反过来拖垮主业务);
  3. 可解释先行、可替换留门(SQL 权重当场能算给运营看;FeedReason进契约,未来换推荐服务调用方不动)。

这套"SQL + Redis 够用推荐"的适用边界由几个常量共同划出:热门聚合走(video_id, event_type)索引、已看集合封顶 2000、单批上报上限 100——每个常量都对应一个明确的量级假设。假设被打破时(埋点表千万行、单用户消费深度远超 2000),先被打破的是聚合查询延迟与去重精度,届时该动的只是消费层:完播聚合周期物化、已看集合换 ZSet 分页淘汰——埋点表结构不用动,采集端一条代码都不用改。

仓库:

  • GitHub:github.com/tx7do/go-wind-shortplay
  • Gitee:gitee.com/tx7do/go-wind-shortplay
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/27 10:18:32

45.7MB 整合包,OpenClaw 双平台部署实操全记录

&#x1f4cc; 说明 本文以 OpenClaw 为例展开讲解&#xff0c;全程采用图形化、可视化的交互方式。整合包已内置全部运行依赖&#xff0c;即使是普通用户&#xff0c;也能轻松复现完整的部署流程。 ✨核心亮点&#xff1a; 全程可视化图形交互界面&#xff0c;自动补齐全部运行…

作者头像 李华
网站建设 2026/10/27 10:06:01

AI重塑B2B出海贸易:从阿里国际站采购节看全球供应链新变局

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华