面试知识库

Mmem —— 记忆系统重构:让 LLM 只做识别,用户做授权,机制做执行#

背景:一次代码审查引出的重构#

起因是一个问题:「如果我说鞋子不喜欢皮革,会不会以后所有皮革制品都不给我推?」

答案是——而且当时已经在发生了。顺着这条线查下去,发现的不是一个 bug,而是一类系统性的病。

病灶:写入侧维度远多于读取侧#

一条长期偏好当时有 6 个维度:polarity × strength × category × domain × source × 时间衰减。问题不在维度多,而在于每多一个维度,就要在写入端和读取端各接一次线——接漏一处,它就变成一个「写了不读」的死字段,而且是静默死掉的。

审查找出的几乎所有问题都是这个形状:

  • domain 是写了不读的字段。 curator 精心填、还专门写了一个 _scope_subjective_prefs 兜底函数防止漏填,但三个读取端(prompt 注入、硬过滤、向量画像)一个都没消费。于是那个兜底函数达不到它自己 docstring 声称的目的——收窄 domain 完全阻止不了跨品类注入,它唯一的效果是改变了去重键。
  • recency_weight 半衰期算得很讲究(90 天半衰、用户手填的不衰减、重复提及重置),但唯一的消费者把全部 like 偏好一股脑加权平均成一个向量——权重再准,也在「所有口味的质心」里被稀释没了。
  • strength 的四格路由在 P_t 和长期库各接一遍 = 8 条通路,每条都要单独维护。

而库里当时只有 3 条偏好。半衰期、语义 top-k 裁剪、域隔离——全是为「几十上百条偏好」准备的解法,而规模还没到,也就没有任何真实反馈告诉你哪个维度是有效的。复杂度换来的不是正确性,而是 bug 的藏身之处。

三条原则#

一、不可逆的杀伤力只能由用户授予,不能由 LLM 判定#

strength: hard|soft 是 curator 的 LLM 出来的,而 hard 意味着永久、跨品类、静默的硬淘汰。让一个每轮都在猜的模型去决定「这件商品用户永远不该看到」,风险和收益完全不匹配——猜错了,用户搜不到东西,还归因不了(前端不会告诉他「有条偏好把它们全杀了」,他只会觉得「这破 Agent 老是搜不出东西」)。

改成 blocking,且只认 source="user":用户在偏好页面亲手勾了「绝不推荐」的条目,才有权把商品从结果里删掉。curator 学到的一律只减分。这道闸设在唯一落库口上(source="agent" 强制 blocking=False),而不是指望每条调用路径自觉——「LLM 不得决定永久排除」必须是机制,不是约定。

这个决策还顺带解释清楚了一个此前含混的问题:硬 / 软的分界应该是什么? 不是 LLM 的置信度,而是信息的来源。于是 P_t 和长期库形成一个刻意的不对称

  • P_t(会话级)装的是用户本轮刚亲口说的(「这次不要塑料」)→ 明确性极高 → 一律硬执行
  • 长期库装的是 curator 推断出来的一贯取向 → 只能软执行(减分),除非用户显式授权。

二、个性化必须可观测,所以用文本、不用向量#

原来的 user 塔向量画像(把所有 like 加权平均成一个向量、按 β 融进请求向量)被整条删掉。它的问题不是效果差,是不可观测:召回结果怪了,你无法归因、无法调试、也没法向用户解释「为什么这次搜出来的东西不对劲」。

换成把本轮域内的 like 偏好原子词拼进 query 文本。同样是个性化,但它出现在工具上报里、前端的思考过程里、日志里——看得见、调得动

可观测的弱个性化,胜过不可观测的强个性化。后者出问题时,你连从哪儿查都不知道。

(只取 keywords 不取 content:整句「喜欢小众设计的帆布包」拼进 query 会把语义带偏。)

三、一个字段要么被读取端消费,要么删掉#

没有第三种状态。

关键设计决策#

为什么域用封闭枚举,而不是语义匹配#

domain 和商品品类都是自由文本时(shoes / footwear / 跑鞋 / running shoes),精确匹配对不上——这正是当初放弃在读取端消费 domain 的原因。

一个直觉的解法是用现成的 embedding 算语义相似度。但这个方案必须否掉TowerClient.encode_* 走 HTTP 打远程 API,而 item_picker 是热路径——为一个字段匹配在每次精挑时插一次网络往返,代价和收益完全不成比例。

正确的做法是把匹配成本挪到冷路径:域收敛成封闭枚举(22 项),写入侧由 curator / 偏好页面在后处理里判定(不在用户感知的延迟里),读取侧由 planner 顺手产出(它本来就在跑 LLM,多要一个字段零额外调用)。于是读取端退化成一次字符串相等比较——零网络、微秒级。

两个逃生舱的语义天差地别#

  • other = 「判不出是哪个域」。保守档:这条偏好本轮几乎不生效。LLM 漏填时落这里。
  • global = 「跨品类底线」(过敏 / 伦理 / 安全)。激进档:全局生效、跨品类杀商品。

改造前 domain 留空即全局——漏填的默认值是最激进的那个,这是「买鞋时说不喜欢皮革」污染所有品类的直接原因。这次把默认值翻转了:选项越多,LLM 漏判越多,所以默认值的失效方向就越重要。

失效方向必须是「这条偏好范围偏窄」(用户再说一遍即可),而不是「它在所有品类里静默杀商品」。

踩过的坑#

一、裸 ContextVar 读不回来。 照着 set_session_pt 的样子写了个 ContextVar 存品类域,planner 判出 footwear,item_picker 读回来是空。原因项目里早有注释写明:工具各自在独立 context 里执行,一个工具 set 的 ContextVar,另一个工具读不到——set_retrieval_mode / set_dest_country 正是因此才改用「按 session_dir 聚合的模块级 dict」。这个坑那个文件已经踩过两次,我是第三个。

二、prompt 才是主指令通道,而且措辞的失效方向很关键。 第一版只改了 Pydantic schema 的 Field description,模型全部返回空——它照着 system prompt 里逐字列出的字段清单干活。补进 prompt 后还有两轮反复:写「判不出就留空」→ 模型全留空(措辞在鼓励它偷懒);改成「归不进就填 other」→「给猫买饮水机」判成 other 而不是 pet(只给兜底不给引导)。最后加上「先把清单从头扫一遍找最贴近的,确实对不上才填 other」才稳定。

三、vegan leather 正是不喜欢皮革的人想要的东西。 域隔离修好了,裸子串匹配仍然会在买鞋的时候误杀 vegan leather / faux-leather / leather-free / 人造皮革。加了词边界 + 否定修饰过滤。连字符复合词要特别处理(按分隔符切分会在首尾留空串,取到 "" 而不是 "faux")。

四、测试从「一测一目录」变成「共享一个库」。 三类数据搬进 SQLite 后,原来靠 tmp_path 天然隔离的测试开始互相串——而且先跑谁就变谁的锅,这种红最难查。加了 autouse 清表。

净效果#

改造前改造后
长期库维度64
执行通路6 条3 条(淘汰 / 加分 / 减分)
memory → recall 依赖有(语义排序要 embedding)
硬淘汰权LLM 判定仅用户授权
个性化通路向量画像(不可观测)检索词(可观测)
记忆生效情况静默memory_applied 事件

面试可讲点#

「你怎么发现 domain 是死字段的?」 —— 不是靠读代码发现的,是靠一个具体问题倒逼出来的:「鞋子不喜欢皮革,会不会连沙发也不推了?」好的审查问题往往是场景化的,而不是「这段代码有什么问题」。

「为什么不做大重构,只砍这几样?」 —— 分清主干和装饰的判据是:这个维度失效了,用户会不会察觉? 会察觉的(域隔离、词边界、polarity)必须做对;不会察觉的(半衰期、软档、兜底桶字段)现在就是纯成本。

「这套系统真正的病是什么?」 —— 不是复杂,是复杂且不可观测。所以 memory_applied 事件是这次改动里我最看重的一项:它把「静默失效」变成「看得见」,以后任何一个维度接漏了,你和用户都能立刻发现。

可能的追问#

  • 「blocking 只由用户勾,那用户不去勾,硬淘汰不就永远不生效了?」 —— 是的,而且这是刻意的。当前阶段所有 agent 学到的 dislike 都只减分,语义上是安全的(宁可漏挡不误杀)。硬淘汰是个不可逆、不可见的操作,它应该稀缺。
  • 「域判错了怎么办?」 —— 判成 other 时这条偏好本轮不生效(保守,用户重说一次即可);判成邻近域时可能漏挡一件。两者都比「误判 global 后在所有品类里静默杀商品」轻得多。而且 memory_applied 事件会把判定结果暴露出来。
  • 「删掉向量画像,个性化召回不就变弱了?」 —— 变弱了,但换来了可解释。而且原来那条通路在几十条偏好时基本是噪声(不裁剪的加权平均)。真要做强个性化,应该先把可观测性建好,再在有反馈的前提下加回来。

未做 / 诚实边界#

  • 前端未接 已补齐:偏好页面的「绝不推荐」勾选、域枚举下拉(中文标签 + global 单独标出)、「这条 N 个月没用过了」提示、memory_applied 的渲染都已落地。

    补前端时还揪出一个后端 bug,而且是这次重构自己的病灶又长了一次memory_applied 上报的是「去重后新增的排除词」,可偏好本来就注入了 prompt,模型多半会把 keywords 照抄进 exclude_keywords——于是「新增」为空,事件恰好在最常见的路径上静默:记忆真的杀了商品,用户什么都看不到。根因是去重(拼匹配列表用)兼职当了上报口径。判断记忆是否生效,看的是它在不在本轮域内_in_scope 已经判过),不是模型这轮凑巧有没有替它重说一遍。

    教训:可观测性的口径不能挂在一个「为别的目的而存在」的中间量上——它会随那个目的的实现细节一起漂移,而且漂移时是静默的。

  • TowerClient.fuse / encode_user_weighted 保留但无生产调用者:它们是 recall 层的通用能力(三塔双通道架构的一部分,有教学价值),上层选择不用而已。

  • 域枚举的粒度未经真实数据校准:22 项是从 duty.py 的品类税率表推出来的,那是一份已在生产里跑的品类切分,但没有针对偏好域做过对照实验。