面试知识库

Mmem.2 · 行为亲和:把「用户做过什么」接进精挑#

承接 Mmem-记忆系统重构。一句话:长期库里每一条偏好都是 LLM 从对话里 的,而 favorites 表里躺着一批最硬的证据,却只用来渲染收藏抽屉。这一版把它接进 item_picker 的打分——零 LLM、零延迟、无幻觉。


一、背景:整个记忆系统都建立在「用户说得出、且模型转述得对」之上#

item_picker 的四个打分格子(正硬 / 正软 / 负硬 / 负软),无论词是来自 planner 的当轮解析,还是来自 curator 沉淀的长期偏好,源头都是同一句用户的话,中间还隔着一层 LLM 的转述。这套东西有两个共同盲区:

  1. 人对自己口味的自陈既不全也不准。 没人会在买 T 恤时主动说「我偏好纯棉」——那是个默认到说不出口 的偏好。用户说得出的只是当下最显著的那几条约束(预算、品类、颜色)。
  2. 凡是 LLM 抽出来的,就有幻觉。 curator 判错一条,长期库里就多一条错的偏好,还会自我强化。

而推荐系统几十年的共识恰恰是:隐式行为 > 自陈偏好。点收藏是要付出成本的真实动作,不像随口一句 「都行吧」那样含糊。工业界的记忆方案(Mem0 / Zep / Letta)几乎全都只做「从对话里抽事实」这一路, 因为它们是通用记忆层、拿不到领域行为信号;而我们是电商 Agent,favorites 就摆在库里。

这条通路是全系统唯一一路不经过 LLM 的偏好证据。


二、关键决策与取舍#

1. 档位压到最低:它是「推断」,不是用户明说的#

Mmem 立过一条核心原则——硬软的分界是「谁授权的」,不是 LLM 的置信度:用户亲手勾的黑名单才有硬淘汰 权,agent 推断的一律只减分。行为亲和是我们从收藏里推断的,用户从没说过「我要帆布」,所以它必须站在 最低那一档:

  • 只加分,绝不淘汰。 「你收藏过帆布包」推得出「帆布可能对味」,推不出「你排斥皮革」——没收藏过的东西 绝大多数只是没见过。
  • 不进检索词。 检索词决定「捞哪一池」,把一个用户从没提过的词塞进 query,代价是全局的(整池被带偏); 作为打分项只在池内微调排序,代价是局部的。弱证据只配后者。
  • 冲突时让位。 用户排斥过的词,即便在收藏里高频出现也一律不进亲和——不许一边减分一边加分。

2. 证据阈 ≥2:频次是客观的,LLM 猜的置信度不是#

Mmem 删掉了 confidence 字段,因为那是 curator 的 LLM 出来的分数,没有信息量。但频次不是猜的。 收藏一件不构成偏好(可能只是随手存个链接),同一属性被收藏两次以上才当信号。这是把「用客观证据代替模型 自评」的思路,第一次真正落到实处。

3. 归因必须写进理由,且措辞与「你要的 X」严格分开#

卡片上写的是「和你此前收藏的是同一路(麻)」,而不是「正是你要的『麻』」。差别不是文风——用户压根没说过 这个词,把推断说成他的要求就是编造事实(P0 诚实红线)。同理 pref_matched 这个结构化判据也不认亲和, 否则收尾文案会写出「按你的要求选了帆布款」。

写明出处还有第二个作用:记忆最危险的失败是静默生效——排序被悄悄改了,用户不知道为什么、也就无从纠正。 这句话本身就是纠错入口:他一看「你收藏过的都是麻」,就知道该去收藏夹里删掉那几件不作数的。

4. 不做域隔离,且诚实标注失效方向#

长期偏好有 PrefDomain 域闸(「买跑鞋时不要皮革」不该在买沙发时生效),亲和没有。因为收藏发生在 POST /api/favorites,那里没有会话上下文——没跑 planner,也就没有品类域;要补域得让前端回传 thread_id 再去翻 pt.json,而存量收藏根本补不回来。

失效方向是可接受的:最坏是买旅行包时因为你收藏过真皮鞋,皮革款上浮几名。正向外溢的代价是排序偏一点, 不是误杀——跨域硬淘汰才是真 bug,而那条路在这里根本不通(亲和只进加分项)。真需要域了,给 Favorite 加一列即可。


三、验收阶段发现的三件事(都比「功能跑通」更值钱)#

1. 端到端对照实验被 LLM 的非确定性击穿了#

第一反应是跑真实链路 A/B:两个用户、同一条 query,一个有收藏一个没有。烧了 4 次真实任务,什么也没验到

  • planner 每轮产的检索词有随机性 → 两个用户的候选池根本不同 → 排序差异无法归因;
  • 在服装类池子里,planner 还会自己从「夏天短袖」脑补出「棉」塞进 prefer_keywords → cotton 走了显式偏好 那档,亲和按去重规则正确地让位 → 观察窗口被吃掉。

改用固定候选池做因果对照(同一批 25 件真实商品、同一个真实 item_picker,唯一变量是收藏),一次就拿到 干净证据:池内 7 件亚麻款从 #3/#8/#9/#16/#18/#20/落榜 全部上浮到前七,清单件数不减(不淘汰的证据)。

教训:想验证一个确定性组件,就别把它塞在一条非确定性链路的下游去观察。

2. 权重是「拍」的,真实 A/B 当场打脸#

初版 _W_AFFINITY = 0.5,只保证「低于软偏好 1.0」就交差了。A/B 一跑:7 件亚麻款霸占前 7,一件 3.8 分的 压过了 4.6 分的非亚麻款。原因很直白——一个亲和命中(+0.5)几乎等于评分项拉满(_W_RATING × 1.0 = 0.6), 弱证据事实上成了主导排序因子,越过了「同等条件下才上浮」的设计意图。

降到 0.2 后仍大幅上浮、但不再通吃。文档里诚实标注了:这是调出来的,不是像 _W_MATCH_SEM 那样跑消融标定 出来的。顺带暴露一个既有性质:rating 项的动态范围窄(4.0→4.6 只值 0.07 分),任何 ≥0.1 的加分项都能压过 评分微差——这不是亲和引入的问题,但值得记一笔。

3. 词表覆盖率强依赖品类:这个功能在主力 demo 品类上基本空转#

采样 3000 件真实商品,TITLE_ATTRS 能抽出属性词的占 29.8%(主力是 cotton / lightweight / waterproof)。 但按品类严重分化:服装类池子 30 件里有 4–7 件带材质词(亲和有效),而包袋类几乎是 0——第一次拿「旅行收纳包」 验收时,30 件候选一件 canvas 都没有。

这不是 bug(池里没有的东西不该被加分),但意味着实际影响面比预想窄:它在服装类有效,在旅行三件套 / 收纳包 这些主力 demo 品类上基本空转。想扩大覆盖,得扩词表或让入库 ETL 抽结构化材质字段——那是另一个工程。


四、code-review 抓出的两个高危:都是「学出来的偏好是反的」#

这两条我都能复现,而且都不会报错、不会崩,只会静默地把推荐做反——正是记忆系统最典型的失败形态。

① 裸子串匹配让「不含 X」变成 X 的证据#

抽属性词时图省事写了 token in title。于是 "Vegan Leather-Free Tote Bag" 被数成一条皮革证据——而这件 商品恰恰是不喜欢皮革的人才会收藏的。结果就是:用户越收藏无皮革款,系统越给真皮款加分,学出来的偏好完全 是反的

讽刺的是,仓库里早有 term_hits(带词边界 + 否定修饰判定),注释里白纸黑字记着「裸子串匹配在电商标题上就是 这么脆」这次真实误杀。新代码又踩了同一个坑——已有的正确抽象没被复用,是这类 bug 最常见的成因。

② 「说过的话压过做过的事」在最常见的真实形态下静默失效#

压制逻辑写成了 if t not in blocked(未归一 + 精确字符串相等)。两处叠加导致它形同虚设:

  • curator 从中文对话抽的词原样落库(keywords=['皮革']),而亲和词是英文标题 token(leather)—— 不做 normalize_terms 两边永远对不上(而 item_pickermem.penalty 是归一后才拿去匹标题的,压制这一路 漏了同一道工序);
  • 就算归一了,blocked 里是 leather、亲和 token 是更长的 genuine leather——精确相等照样穿过去

于是「用户明说不要皮革 → 系统反而给真皮加分」。而测试给这个 bug 发了通行证:它的 keywords 图省事写了英文 ['genuine leather'],恰好与亲和 token 精确相等,只守住了「英文 / 单词 / 完全同形」这条最窄的路。改成真实形态 ['皮革'] 后立刻红。


五、面试可讲点#

  1. 工业界记忆方案的共同盲区,与领域信号的价值。 Mem0 / Zep / Letta 都只做「从对话抽事实」——因为它们是通用 记忆层,拿不到领域行为。做垂直 Agent 的优势就在这儿:favorites / 点击 / 拒绝这些隐式信号,比 LLM 从对话里 猜出来的偏好可靠得多,而且零成本、无幻觉。
  2. 证据强度决定档位。 用户明说的 > 用户做过的 > 模型推断的。这条尺子同时决定了:能不能淘汰商品、能不能进 检索词、冲突时谁让位、理由里怎么措辞。所有设计决策都能从它推出来,不是一堆孤立的 if。
  3. 怎么验证一个确定性组件。 别把它塞在非确定性链路(LLM)的下游去做 A/B——先把变量隔离出来。我烧了 4 次真实 任务才想明白这件事。
  4. 「归因」是记忆系统的必需品,不是 nice-to-have。 静默改排序 = 用户无法纠错 = 错误记忆自我强化。
  5. 两个高危 bug 的共性:都不崩、都不报错,只是把结果做反。记忆系统的 bug 就是这个形态——所以它的测试必须 照着真实数据形态写(中文原子词、否定修饰的标题),而不是照着实现里最顺手的那条路写。

六、没做的(诚实边界)#

  • 没做消融标定_W_AFFINITY=0.2 是调出来的,不是像 _W_MATCH_SEM 那样跑 NDCG 消融定下来的。
  • 没跑 rubric 回归:不知道它对整体评测分的净影响。
  • 行为亲和不进 AGUI 事件流report_memory_applied 只上报 exclude / penalty,亲和的归因目前只活在卡片理由里, 前端「记忆生效」面板看不到它。(code-review findings 里记了,未修。)
  • 只吃了 favorites 一种行为信号:点击、停留、rejected_options(会话级、24h 就没了)都还没接。跨会话反复 拒绝同一品牌 → 晋升为长期 penalty,是最自然的下一步。