面试知识库

BP8 · 跨会话偏好记忆与个性化召回#

定位:面试官追问记忆系统设计时的拷问预备。


核心口述(30 秒)#

“三层记忆分治:长期 Store(跨会话偏好)+ 会话级 P_t(本轮约束)+ 行为亲和(收藏聚合)。写入只有 curator 一个口,读取只有 assemble 一个装配点。硬软的分界是「谁授权的」,不是 LLM 的置信度——用户 亲手勾的黑名单才有硬淘汰权,agent 从对话里推断的一律只减分,从行为里推断的更弱、只微调排序。”

口述里三个词已经作废,别再讲confidence(LLM 猜的置信度,无信息量,Mmem 删)、 rejected_options(淘汰流水账,喂给模型有害,Mmem 删)、like 走向量融合(双通道融合零调用, 已删,个性化改走「偏好词并入检索词 + payload filter」)。


拷问链#

Q: 为什么不在 shopping_summary 里直接写偏好?#

早期这样做。问题:summary 的 new_preferences 格式不稳定 + 一次性提取容易漏。改成 curator 会话结束后统一扫全轮对话,更全面更一致。

Q: embedding 对否定编码弱怎么发现的?#

“不要塑料”和”要塑料”向量距离很近——语义否定在向量空间里不像人类理解那样”相反”。实测发现 dislike 偏好走向量通道等于没用。

Q: 矛盾消解怎么做?#

curator 拿到已有偏好的 dedup_key 列表,冲突时引用要顶替的旧 key(keys_to_supersede),先删旧再写新。 之前说”不要塑料”、现在说”塑料也行” → 旧条目被顶掉。(注意:不是”重置 confidence”,那个字段已经不存在。)

Q: 偏好注入会不会撑爆 prompt?#

不会,靠域隔离PrefDomain)压:本轮买鞋,就只注入 footwear + global 域的偏好,个位数条目。 域闸是 fail-closed 的——判不出品类时只有 global 生效。早期还叠了一层语义 top-k,域隔离上线后纯属 冗余,已删(连带删掉了记忆层对 embedding 的依赖)。


行为亲和(Mmem.2)——唯一一路不经过 LLM 的偏好证据#

Q: 没有用户行为数据,个性化不是空转?(这道题的答案已经变了#

以前只能承认”缺隐式信号”。现在有一路:收藏favorites)。用户点收藏是付出成本的真实动作,比 “我喜欢小众设计”这种自陈可靠得多——推荐系统几十年的共识就是隐式行为 > 自陈偏好。做法是纯统计:从收藏 商品的标题里数属性词(材质/功能),同一属性被 ≥2 件收藏命中才算信号,聚合成 item_picker 的弱加分。 零 LLM、零延迟、无幻觉。

关键是档位:它是我们推断的,不是用户明说的,所以压到最低——只加分不淘汰、不进检索词、冲突时让位 于显式表达。这条尺子是全系统统一的:用户明说的 > 用户做过的 > 模型推断的

Q: 效果?#

固定候选池 A/B(同一批 25 件真实商品、同一个 picker,唯一变量是收藏):B 组收藏 2 件亚麻单品后,池里 7 件亚麻款从 #3/#8/#9/#16/#18/#20/落榜 全部上浮进前七,清单件数不减(证明没淘汰任何东西)。

别只说漂亮数字,主动讲边界TITLE_ATTRS 词表对真实商品库的覆盖率是 29.8%(3000 件采样),而且 按品类严重分化——服装类池子 30 件里有 4~7 件带材质词(有效),包袋类几乎是 0(旅行收纳包那类 query 上基本空转)。权重 0.2 是调出来的,没做消融标定


压力题(两个真实踩的坑,都是「学出来的偏好是反的」)#

这两个 bug 的共性:不崩、不报错、不抛异常,只是安静地把推荐做反。记忆系统的 bug 基本都是这个形态—— 这也是为什么它的测试必须照着真实数据形态写,而不是照着实现里最顺手的那条路写。

Q: 你这个”从收藏里数词”,怎么保证数出来的是对的?#

踩过坑,而且是反向的坑。

场景:一个素食用户收藏了两件包 —— "Vegan Leather-Free Tote Bag""Leather-Free Canvas Backpack", 他明摆着是在躲开皮革。然后他来搜「通勤包,预算 60 美元」。

我第一版抽词用了裸子串 if token in title。而 "leather" 这个字符串确确实实出现在 "vegan leather-free" 里面 —— 两件收藏各投一票,leather 拿到 2 票跨过证据阈,系统学到的结论是 「这人偏好皮革」,接下来给所有真皮款 +0.2 上浮。他越是精心挑无皮革的东西,系统越把真皮顶到他眼前。

为什么会写成这样(这才是面试官想听的):仓库里早就有 term_hits(),带词边界 + 否定修饰判定, 它上面的注释白纸黑字记着同一次真实误杀——“裸子串匹配把 vegan leather / leather-free 全杀了,而这些正是 不喜欢皮革的人想要的东西”。正确的抽象就在同一个文件里,我写新函数时没复用它。 这类 bug 最常见的成因 不是想不到,而是已有的正确抽象没被复用。修法就是把 in 换成 term_hits

Q: 用户明确说了”不要皮革”,但他收藏夹里有皮包,听谁的?#

设计上早就定了:说过的话压过做过的事(不许一边减分一边加分,否则净效果取决于两个权重谁大,不可解释)。 但这条纪律的实现在最常见的真实形态下静默失效了

场景:用户说「帮我找个双肩包,不要皮革的」。curator 把它沉淀成长期偏好,keywords=["皮革"]—— 中文,这是 curator 从中文对话抽词的真实形态。而他收藏夹里有两件 "Genuine Leather Bag/Wallet"

第一版压制写成 if t not in blocked(精确字符串相等)。两道坎,一道都没跨过去

  1. 跨语言blocked 里是中文 "皮革",亲和词是从英文标题数出来的 "genuine leather" —— 八竿子打不着, 压制根本没发生。而 item_pickermem.penalty 去匹标题前是先跑 normalize_terms 归一成英文的皮革leather)——我在压制这一路漏了同一道工序
  2. 长词变体:就算归一了,blocked 里是 "leather",亲和 token 是更长的 "genuine leather" —— not in 是精确相等,照样穿过去

实测(修复前):penalty = ['皮革'](减分正常)、affinity = ['genuine leather'](加分照常放行)。 一边给皮革减分、一边给皮革加分。用户明明白白说了不要,系统反而把真皮往上顶。

修法:压制判定必须和匹配判定用同一套口径——先 normalize_terms 归一,再用 term_hits 判”这条排斥词 命中了这个亲和词吗”,而不是字符串相等。

Q: 这么明显的 bug,测试没拦住?#

没有——而且更糟:我的测试给这个 bug 发了通行证,还一直是绿的。

test_explicit_dislike_beats_favorites 这条测试就是专门守这条纪律的。但我写的时候图省事,keywords 填了 英文 ["genuine leather"] —— 恰好与亲和 token 完全同形,精确相等成立,压制”生效”,测试通过。

它只守住了「英文 / 单词 / 完全同形」这一条最窄的路 —— 而那恰恰是真实链路里唯一不会发生的情况 (curator 从中文对话抽词,存的必然是中文)。把 keywords 改成真实形态 ["皮革"],它立刻变红。

教训:测试要照着数据的真实形态写(中文原子词、否定修饰的标题、长词变体),别照着实现里最好写的那条 路径写——后者只是在给实现拍照,不是在验证它。

Q: 你怎么验证行为亲和真的生效了?#

第一次验错了,值得讲。 直觉做法是跑端到端 A/B:两个用户、同一条 query,一个有收藏一个没有,比排序。 烧了 4 次真实 LLM 任务,什么也没验到,两个原因叠加:

  1. planner 每轮产的检索词有随机性 → 两个用户的候选池根本不同 → 排序差异无法归因到亲和;
  2. 更阴的:我用「夏天穿的男士短袖」当 query,结果 planner 自己从”夏天短袖”脑补出”棉” 塞进 prefer_keywords → cotton 走了显式偏好那一档(权重 1.0)→ 亲和按去重规则正确地让位 → 观察窗口被自己的正确逻辑吃掉了

改用固定候选池做因果对照:同一批 25 件真实 Qdrant 商品、同一个真实 item_picker,唯一变量是收藏。 一次就拿到干净证据。而且换成 亚麻(planner 绝不会从”夏天短袖”脑补出亚麻)才有观察窗口。

教训:想验证一个确定性组件,别把它塞在一条非确定性链路(LLM)的下游去观察 —— 先把变量隔离 出来。端到端只能证明”链路不崩”,证明不了”这个组件按预期工作”。