← 文章 / Writing

Engineering11 min编者 nabunana ↗

做“今天吃什么”,我只想让页面给一家店

ELMA 目前的项目思路:少问几个问题,一次给一家店,再从反馈和行为里慢慢学。
#Java#Vue#uni-app#推荐系统#产品设计

做 ELMA 的起因很普通。饭点打开地图和点评,附近能吃的店一大堆,筛了半天还是不知道去哪。

所以首页没有餐厅列表。定位后点“帮我选”,页面直接给一家。用户可以选正餐、小吃快餐、饮品甜品或者随便,也能填不想吃的东西,但这些都是可选纠偏,不会变成一份问卷。

一次只给一家后,麻烦都到了服务端:哪些店不能进来,评分该信多少,换一家会不会重复,用户点了“就它了”算不算喜欢,以及最近刚吃过的店要不要继续出现。

先过滤,再排

附近餐厅从高德获取,按距离、预算、品类、营业状态和“不想吃”做硬过滤。品类暂时只分四个大类,没有继续细到湘菜、烧烤或拉面。现有地点数据还撑不起稳定的细分类,界面做得太精确只会制造错觉。

高风险候选会在个性化和探索之前剔除,不能靠用户偏好把它重新捞回来。其余候选再算 LowRegretScore。V0.4 把餐厅质量、风险安全、口味匹配、预算、距离和近期多样性拆成六项:

WeightedScore =
  25% RestaurantQuality + 25% RiskSafety + 20% TasteMatch
+ 12% BudgetFit + 10% DistanceFit + 8% RecentDiversity

之后只减一项最强的近期惩罚,再加本次探索奖励,结果限制在 0 到 100。权重是目前的产品规则,不是训练出来的参数。真实使用数据还少,先用能解释、能写测试的办法。

服务端会在高分候选里做品类多样化,再从 Top-10 按分数加权、不放回地抽出最多 6 家。分高的出现概率大一些,同一家又不会长期占住第一个位置。客户端仍然只拿当前这一家来展示。

换一家只读这次的候选池

每次点击“换一家”都重新跑推荐,写起来简单,实际用起来很乱。外部地点数据可能有变化,刚看过的店可能再次出现,剩余次数也不好解释。

创建推荐时,服务端会冻结最多 6 家候选。第一次展示一家,之后最多换 5 次,每次取下一家没出现过的店。候选用完后再点,会回到最初推荐,不临时生成第七家。

候选的风险、个性化分项、理由和跨平台证据摘要一起保存。换一家不再调用地图,也不受刚提交的反馈影响。页面显示的 alternativesRemaining 由服务端返回,前端不自己推算。

评分旁边还要放一个置信度

评分高不等于数据就可靠。4.9 分可能来自稳定口碑,也可能样本很少,或者两个平台对同一家店的记录差得很远。

V0.2 先用了规则风险模型,检查评论模板相似度、短期集中出现、近期趋势和数据是否充足。没有评论 Evidence 时不会按零风险处理,而是降低置信度,并给排序加一部分不确定性补偿。Evidence 文件读取失败或没有匹配,只降级这一份证据,推荐还能继续。

V0.3 加入百度 Place 做交叉核对。高德仍是餐厅主实体,百度提供第二份地点信息。名称、坐标、地址和电话归一化后计算匹配分,同名分店还要满足距离等条件;一个百度地点也不能同时匹配多家高德餐厅。

找不到百度匹配时保持中性,不把“没找到”说成平台冲突。匹配成功后,综合评分差异才进入风险计算。缺失字段保留为空,不补成零。

现在用的仍是确定性规则,阈值集中在配置里,也有对应测试。真实样本还不够,先不上机器学习。

用户口味、行为和近期饮食历史不会进入 RiskScore。有人不喜欢某家店,只会改变这个人的后续排序,不会把餐厅的客观风险改高。风险与口味分开以后,后面做风险校准也不用先从数据里拆掉用户偏好。

反馈留给下一次推荐

客户端保存一个匿名 UUID,不要求注册。用户可以反馈“喜欢”“一般”或“不喜欢”,服务端据此更新 TasteProfile。

现在的画像记录细品类、价格档、距离档和五种口味。点完三态反馈后,可以顺手选择辣、甜、油、咸、清淡,最多 3 个,也可以跳过。口味只更新用户实际勾选的项,不根据店名猜。

同一口味至少被两个不同匿名用户标注后,才会成为这家店的公共特征;当前用户自己标过的内容可以立即用于自己的下一次推荐。没有可靠标签时按中性处理,不补一套看起来很懂菜名的推断规则。

画像权重限制在 [-3, 3],并按 90 天半衰期减弱。可信度也不会一开始就是 1:

confidence = min(1, (显式反馈数 + floor(已处理隐式行为数 / 3)) / 5)

最终偏好会按这个可信度向中性 50 分收缩。新用户是 50 分和 0 可信度,一两次反馈只会轻微改变结果。

画像已经从旧版 JSON 迁到单独的数据库表,用乐观锁和有限重试处理并发更新。V0.4 回滚一段时间再恢复时,还会补处理期间产生的新反馈,避免版本切换把画像留在旧位置。

当前候选池不会跟着反馈重排。用户已经拿到一组有限选项,这次就按原顺序走完;新画像从下一次“帮我选”开始使用。

点击和离开也算信号,但权重很轻

三态反馈发生得不算频繁,完全靠它学习,画像会长时间停在初始值。V0.4 又记录了几种行为:展示、换一家由服务端写入;客户端提交“就它了”、成功打开地图和未操作离开。

客户端行为带持久化 eventId。网络重试继续使用同一个 ID,第一次写入返回 201,重复命中返回 200,不会重复计数。行为请求失败也不能挡住导航或返回首页。

“就它了”和打开地图各加 0.1,同一会话、同一家店合计最多 0.2;换一家减 0.1,跳过减 0.05。信号先按特征放进 30 天累加器,同一特征凑到 3 个事件后才写入长期画像,单批调整也有限制。

这些行为只能说明意向。只有喜欢、一般和不喜欢三种反馈会被记成真正吃过,并进入近期饮食历史。

最近吃过,只做降权

7 天内对同一家店点过不喜欢,惩罚是 40 分;同店其他反馈是 30 分。连续两个自然日吃过同品类减 12 分,最近一个自然日吃过减 5 分。几条同时命中时只取最强的一条,日期按 Asia/Shanghai 计算。

这里没有写“吃过一次,七天内禁止出现”。附近候选少时,硬删除很容易把结果清空。降权后它通常会往后排,实在没有更合适的店,也还能回来。

留一点低风险探索

如果画像只会强化已有偏好,用户喜欢过一次粉面,后面可能一直看到粉面。现在首次展示有 20% 概率尝试低风险的新类型,其余情况走个性化排序。

探索候选必须是 LOW 风险,风险置信度不低于 0.6,近 7 天没吃过同品类,也不能命中明确负偏好或 30 天内同品类不喜欢。没有合格候选就退回正常排序。探索只决定第一次展示,后面的换一家仍然使用冻结候选池。

这个比例暂时不会自己变化。现阶段样本不够,固定规则更容易看出哪里出了问题。

想多看一点时再深挖

默认推荐只查高德、百度和本地评论 Evidence。B站、小红书与大众点评的公开 Web 搜索线索没有放进主链路,因为它们慢,也可能失败。多数饭点请求用不到这一步。

结果页有“深挖一下这家”。用户主动点击后,服务端才通过 Brave Search 查询三个站点,只读取公开索引里的标题、链接、摘要和时间,不打开结果页或评论区。

这些摘要按规则统计正面、负面、运营提醒和营销倾向,生成独立的 deep-risk-v0.1。它对基础风险最多调整 10 分,也不能单独把原本非高风险的餐厅推成高风险。某个来源失败就标为不可用,基础判断照常返回。

深挖结果不改推荐排序、候选池、换一家顺序或 TasteProfile。它只是给已经感兴趣的餐厅补一些公开线索。

项目接口以 OpenAPI 为准。创建推荐、换一家、反馈和深挖都先确定契约,再改后端与小程序类型。风险分数会带等级、置信度和算法版本,经纬度统一使用 GCJ-02。前端不做排名,也不直接调用高德 Web Service。

V0.4 已在内测环境跑过一次完整链路:新用户从中性画像开始,行为重试不会重复写入,反馈后旧会话仍按原快照换店,下一次推荐才切到个性化模式。外部证据仍有覆盖盲区,规则阈值也需要更多真实使用来调。现在缺的是校准数据,不是再往公式里塞一层看起来聪明的模型。

今聴いている / NOW LISTENINGYorushika

读完之后,留一点安静给音乐。这是一则状态记录,不是播放器。

站内搜索

AgentVibe的东西 真的看懂了吗Notes · 工程 反思 AI 能力 · 攒了一堆能跑的项目,能力却没长进。问题不在代码谁写的,在于我有没有真正想明白。谈谈多Agent并发与OpenclawNotes · AI 工作流 OpenClaw 工程取舍 · 把一个写文案的活拆成五六个 Agent 开会,听着爽,实际是 Token 火葬场。真正把事做完的,是单 Agent 加工具,加上几个克制的角色。一次 GitHub Actions 自动发布复盘Engineering · Astro GitHub Actions Nginx SSH CI/CD 部署 · 从旧 Hexo GitHub Pages、Astro 新站到服务器:怎样处理分支切换、SSH 信任、未入库的 94 首音乐,以及可回滚的原子发布.更新静态博客时,我不直接覆盖 /var/www/blogEngineering · Astro Nginx SFTP 部署 静态网站 · 这个 Astro 博客的发布过程:先在旁边准备好完整站点,核对资源,再一次替换并保留回滚。Java 没有断,WebSocket 为什么一直重连Engineering · Java WebSocket Nginx Vue 部署 · 一次公网部署故障复盘:REST 正常、进程没重启,WebSocket 却持续 403,子路由刷新也跟着 404。做“今天吃什么”,我只想让页面给一家店Engineering · Java Vue uni-app 推荐系统 产品设计 · ELMA 目前的项目思路:少问几个问题,一次给一家店,再从反馈和行为里慢慢学。做一个能按真实尺寸打印的卡牌 PDF 工具Projects · Python Pillow PDF Tkinter Desktop · 卡牌图片放进 A4 不难,麻烦的是毫米、DPI、图片比例和桌面程序里的几个小坑。三体模拟器跑起来以后,四条链路开始互相拖后腿Engineering · Java Vue WebSocket Canvas 性能优化 · 一次实时模拟项目里的拆分:积分照常跑,网络只发该发的,画布和归档各自守住上限。聊天摘要 Agent:消息拉全以后,才轮得到模型Agent · Python Agent SQLite Privacy 容错设计 · 记录聊天摘要从能运行到可以放心使用,中间补过的分页、游标、证据和隐私边界。拿一个 8KB 数组写个 mallocSystems · C Memory Data Structure 嵌入式 · MiniMalloc 里真正需要想明白的几件事:块头放哪、空闲块怎么找,以及 free 后怎样把内存拼回去。我为什么还在用 RSSNotes · RSS Reading Web · 不猜兴趣,也不替我调整顺序。想看的来源,自己订阅就够了。这个夏天看过的动画ACG · Anime ACG Life · 没做排名,只记下几段过了一阵子还会想起来的内容。ProjectsPage · 正在构建的项目与实验MusicPage · Yorushika、n-buna、Vocaloid 与听歌记录AboutPage · 关于这个数字空间和它的主人