推荐排序一旦上线,最怕用户说“不准”,开发者却只能凭感觉猜。中式美食首页推荐会同时参考收藏、浏览、购物清单食材和分类点击,如果不留快照,后面根本说不清某一道菜为什么排在前面。我的处理方式很直接:每次排序都能导出一份
score + explain快照,先让问题能回放,再谈怎么调权重。
图 1 - 中式美食推荐排序快照:重点不是展示一个分数,而是把输入、候选和解释串起来,方便排查。
| 栏目 | 内容 |
|---|---|
| 应用名称 | 中式美食 |
| 工程环境 | HarmonyOS、ArkTS、ArkUI、Stage 模型 |
| 本文主题 | 推荐排序快照、score/explain、问题回放、权重调试 |
| 核心文件 | HomeRecommendViewModel.ets、RecommendRanker.ets、DishRepository.ets、FavoriteRepository.ets |
| 验收入口 | 构造固定用户信号,输出前五名快照,检查排序原因 |
| 读完能带走什么 | 推荐问题怎么从“感觉不准”变成可复现、可讨论、可修正 |
本章导读
这次只讲推荐排序的排错方式,不重复讲完整首页推荐流。
- 为什么推荐排序要保存输入信号。
- 为什么
score不能单独看,必须带explain。 - 快照里要记录哪些字段,才能回放问题。
- 权重调整以后,怎么对比前后结果。
- 验收时怎么判断快照真的能帮开发排错。
当前验证环境
本文讨论的是中式美食本地推荐规则,不依赖服务端推荐系统。排序输入来自本地仓储,输出给首页推荐流使用。
| 项目 | 说明 |
|---|---|
| 技术栈 | HarmonyOS、ArkTS、ArkUI |
| 主要入口 | 首页推荐流 |
| 信号来源 | 收藏、最近浏览、购物清单食材、分类点击 |
| 调试方式 | 固定样本 + 快照输出 |
| 验收重点 | 同一份输入能不能稳定复现同一份排序 |
一、推荐问题不能靠感觉排查
如果首页推荐里番茄炒蛋排第一,开发者至少要回答三个问题。
| 问题 | 没有快照时 | 有快照时 |
|---|---|---|
| 为什么排第一 | 只能猜 | 看命中了哪些信号 |
| 是哪路权重太高 | 很难判断 | 看每路分数 |
| 调权后有没有变好 | 靠肉眼比较 | 对比前后快照 |
| 用户反馈能不能复现 | 很难 | 用同一份输入重跑 |
所以我不把推荐排序当成一个只返回数组的函数。它必须能把排序过程讲出来。
二、快照先记录输入信号
输入信号是推荐排序的起点。没有输入,就没法解释输出。
exportinterfaceRecommendSignalSnapshot{favoriteRecipeIds:string[]recentlyViewedIds:string[]shoppingIngredients:string[]clickedCategories:string[]generatedAt:number}这里我会保留generatedAt,不是为了做复杂统计,而是为了排查“这个推荐结果是什么时候算出来的”。如果用户刚刚收藏了菜,但快照时间在收藏之前,那排序没变化就不是算法问题。
三、候选菜也要入快照
只保存用户信号不够。候选池如果漏菜,排序再正确也没用。
exportinterfaceCandidateSnapshot{recipeId:stringtitle:stringcategory:stringingredients:string[]}exportinterfaceRecommendDebugSnapshot{signal:RecommendSignalSnapshot candidates:CandidateSnapshot[]result:RankedSnapshotItem[]}候选池快照能帮我排除一类问题:不是排序没把某道菜排上来,而是这道菜根本没有进入候选集合。
| 异常现象 | 优先检查 |
|---|---|
| 明明有番茄却没推荐番茄菜 | 候选池有没有番茄相关菜 |
| 收藏菜没有出现 | 收藏 id 是否能在候选池找到 |
| 同类菜太多 | 候选池是不是本身就偏一类 |
| 推荐结果为空 | 仓储查询是否返回空集合 |
四、score要拆开,不要只留总分
总分只能告诉我谁高谁低,不能告诉我为什么高。
exportinterfaceScorePart{source:'favorite'|'viewed'|'ingredient'|'category'score:numberreason:string}exportinterfaceRankedSnapshotItem{recipeId:stringtitle:stringtotalScore:numberparts:ScorePart[]}这样排查时就能看到每一道菜的分数组成。
| 菜谱 | favorite | viewed | ingredient | category | total |
|---|---|---|---|---|---|
| 番茄炒蛋 | 36 | 0 | 28 | 10 | 74 |
| 鸡蛋炒饭 | 0 | 12 | 14 | 0 | 26 |
| 莲藕排骨汤 | 0 | 10 | 0 | 0 | 10 |
这张表比一个总分有用得多。比如番茄炒蛋排第一,原因很清楚:收藏、食材、分类同时命中;鸡蛋炒饭靠前,是因为最近浏览和鸡蛋命中。
五、生成快照时不要改排序逻辑
快照只是记录,不应该影响排序结果。排序函数先算结果,再把结果转换成快照。
exportfunctionbuildRecommendSnapshot(signal:RecommendSignalSnapshot,candidates:RecipeCandidate[],ranked:ScoredRecipe[]):RecommendDebugSnapshot{return{signal,candidates:candidates.map((item)=>({recipeId:item.recipeId,title:item.title,category:item.category,ingredients:item.ingredients})),result:ranked.map((item)=>({recipeId:item.recipe.recipeId,title:item.recipe.title,totalScore:item.score,parts:item.parts}))}}这里不要为了调试去改原排序逻辑。调试代码一旦改变业务结果,后面看到的就不是同一个问题了。
六、调权重要对比前后快照
调权重不能靠“感觉更顺眼”。我会把同一份输入跑两遍:一遍旧权重,一遍新权重。
exportinterfaceSnapshotDiffItem{recipeId:stringbeforeRank:numberafterRank:numberchangedReason:string}exportfunctiondiffSnapshot(before:RankedSnapshotItem[],after:RankedSnapshotItem[]):SnapshotDiffItem[]{returnafter.map((item,index)=>{constoldIndex=before.findIndex((old)=>old.recipeId===item.recipeId)return{recipeId:item.recipeId,beforeRank:oldIndex<0?-1:oldIndex+1,afterRank:index+1,changedReason:item.parts.map((part)=>part.reason).join('、')}})}我重点看两类变化。
| 变化 | 判断 |
|---|---|
| 强命中菜上升 | 通常合理 |
| 无命中菜上升 | 要警惕 |
| 同类菜连续霸榜 | 需要加多样性约束 |
| 收藏菜全部压前 | 收藏权重可能太高 |
工程验收记录
推荐快照的验收不看页面好不好看,只看能不能排查问题。
| 验收项 | 操作方式 | 通过标准 |
|---|---|---|
| 输入可复现 | 固定收藏、浏览、食材样本 | 多次运行输出一致 |
| 候选可检查 | 打印候选池 | 能确认目标菜是否进入候选 |
| 分数可解释 | 查看parts | 每个总分都有来源 |
| 调权可对比 | 调整食材权重后重跑 | 排名变化能解释 |
| 异常可定位 | 构造空候选池 | 能看出是仓储问题,不误判为排序问题 |
这套验收的目的很朴素:推荐出了问题,先知道问题在哪里,而不是先猜怎么改。
常见问题复盘
1. 为什么不只保存总分?
总分无法排错。看到 74 分,只知道它高;看到收藏 36、食材 28、分类 10,才知道它为什么高。
2. 为什么候选池也要保存?
因为很多推荐问题不是排序问题,而是候选池问题。菜没进候选,再好的排序也排不出来。
3. 快照会不会影响性能?
线上不需要每次都持久保存完整快照。开发阶段可以输出,必要时只保留前几名和关键字段。
4. 为什么先做本地快照,不先接服务端日志?
当前中式美食的推荐规则还在本地验证阶段,本地快照更快、更直接。等规则稳定后,再考虑把关键字段上报或接服务端分析。
本章小结
- 推荐排序不能只看结果数组,必须能回放输入、候选、分数和解释。
score要拆成多个来源,排查时才知道哪路信号影响了排序。- 调权重要对比前后快照,不要靠肉眼觉得顺眼。