面试知识库

M7.3 · 对齐 refdocs 补缺口:forget / history / 极性矛盾消解 —— 开发文档(面试向)#

承 M7.2(把提取放进 reasoning),这篇对照 refdocs/06 逐条补上「有设计、没落地」的三个缺口,把「记忆只会长、不会改」这个结构病治了。讲为什么这么做、为什么某些地方刻意不用 LLM、哪些是睁眼 YAGNI,不讲代码。

一句话概括#

M7 把偏好「读 / 写」接好了,但对照 refdocs/06 逐条过,有三处 refdocs 画了、实现里没有的洞:①delete 接口在、入口没接(用户没法主动撤回偏好);② Store 该有的 history 段(上次买 / 搜了什么)完全没存;③ 同一对象的 like / dislike 共存不消解(以前喜欢塑料、现在不要塑料 → 两条并立自相拆台)。M7.3 把这三个补齐,核心是让长期记忆从「只增不减的沉淀池」变成能删、能记行为、能纠矛盾的可维护记忆。

1. 先讲怎么发现的:对照 refdocs 的「缺口盘点」#

面试里「你怎么知道缺什么」比「你补了什么」更能体现工程判断。方法是拿 refdocs §3(数据结构)/ §6(接口)当 checklist 逐条比对实现:

refdocs/06 元素实现现状结论
§6.1 read/write/delete/read_relevant四个口都实现了,但 delete 全项目零调用方洞①:接口在、入口不在
§3.2 Store 的 history 段(last_purchase/last_search)只存偏好,不存行为历史洞②:整类条目缺失
§3.1 PreferenceEntry 字段实现是超集(多了 polarity/keywords)无缺,反而更强
§6.2 read_relevant 向量召回已实现(编码器算余弦)无缺

外加一个 refdocs 没要求、但真实存在的弱点:偏好只增不减、同物 like/dislike 共存(洞③)。区分「refdocs 缺口」与「超纲弱点」本身就是一种诚实——面试时能讲清「哪些是对着文档补的、哪些是我自己看出来的」。

2. 缺口①:forget —— 让记忆能删#

问题:delete 早在接口里,却没有任何工具 / API / prompt 触发它。用户说「别再记着我不要塑料了」,系统无动于衷。记忆只能攒、不能撤。

解法:加一个 forget_preference 工具,是 remember_preference镜像反操作:

  • 确定性匹配,不猜:用户给的描述 / 关键词,与已存偏好的 content 或 keyword 互为子串即命中删除(和后面矛盾消解、以及 M7.1 的硬过滤同一套字面匹配)。
  • 宁可漏删不误删:匹配不上就不动。删错他人一条偏好的代价,远大于漏删一条——所以匹配judged 从严。
  • 可选 polarity 限定只在 like / dislike 一侧删。

为什么这是对的设计:撤回是个指向明确的动作(用户点名要删的东西),不需要语义推断,确定性子串匹配足够且可预期。它和 remember 一起,让长期记忆第一次具备「可维护性」——这也是治洞③的前置件(记忆能改,才谈得上纠矛盾)。

3. 缺口②:history —— 让 Agent 记得你上次做了什么#

问题:refdocs §3.2 的 Store 明确画了 history 段(上次买了硅胶瓶套装上次搜了收纳袋、最终选帆布款),实现里完全没有。

一个必须澄清的易混点(面试高频陷阱):项目里已经有一个 history.py,但它存的是对话消息轨迹(turns.json,给续聊 / 前端回看),不是 refdocs 说的那种提炼后的行为快照。前者是「原始对话流」,后者是「上次搜 / 买了什么」的结构化事实。名字撞了,定位完全不同——能一眼分清这两者,是这块的加分点。

解法:新增 HistoryEntry,与偏好分开存,三个刻意的设计点:

  1. 为什么与偏好分文件 / 分 key 存:两者生命周期和合并语义不同。偏好是「一贯取向」→ 去重 + 置信累加;历史是「上次做了什么」的事实快照 → last-write-wins(每种 kind 只留最近一条,last_search 覆盖旧的),既不去重也不累加。硬塞进 PreferenceEntry 会让「上次买了 X」被当成一条 like 偏好注入向量、污染召回——所以必须分开。
  2. 为什么机制写、不靠模型调工具:历史是「本轮客观发生了什么」,不需要模型判断,收尾时由 run_agent 确定性写一条(搜了什么、精选了几件、首选哪件)。比给模型加个「记历史」工具更可靠——不依赖模型自觉。
  3. 诚实边界:本项目无真实下单,所以只记 search 类历史,purchase 类留待将来接入下单流程再写。不假装有购买数据。

读取注入:入口读出 → 填进新增的 <user_recent_history> prompt 块(与偏好块并列,同样只在主 loop 注入、子 Agent 不注入)。作用是给本轮推荐当上下文(延续风格 / 避免重复推荐),但不覆盖用户本轮明确意图。

4. 缺口③:极性矛盾消解 —— 让记忆能纠错(本篇重点)#

问题(行为级 bug,不是洁癖):去重 key 是 极性:归类:内容哈希。「喜欢塑料」和「不要塑料」——极性不同、内容也不同 → 落两个 key、共存。后果是两处自相拆台:

  • 注入 prompt 同时出现「喜欢塑料」+「不要塑料」→ 模型精神分裂;
  • build_user_profile 用 like 把请求向量拉向塑料,dislike_exclude_terms 又把塑料硬过滤掉

解法:写入时 recency-wins。在唯一落库口 persist_new_preferences 里,写每条新偏好前,扫极性相反、且指向同一对象的旧条目 → 删掉(新表态覆盖旧的),日志留痕。

  • 判「同一对象」= keyword / content 子串匹配(塑料 ↔ 塑料),复用 forget 那套。
  • 挂单一落库口 → remember 工具 / 规则兜底 / summary 三个捕获点自动全覆盖
  • 只删反极性,与同极性的正常 merge 正交。
  • 无 keyword 时 honest no-op:判不了同一对象就不动,绝不误删。

4.1 这块最值得答的一题:为什么矛盾消解不调 LLM#

这是面试官容易追的点,也是设计的精华。答案分三层:

① 分工:LLM 管「理解」,落库口只管「记账」。 偏好从产生到落库,LLM 只在上游出现一次做 NLU(把用户话拆成 content/polarity/keywords)。到 persist 手里已经是结构化数据,没有「需要理解」的东西了。再塞 LLM = 重新推导上游已定的结论,纯浪费。

② 矛盾消解本身是集合运算,不是判断题。 它实际在算的是「极性相反 keyword 有交集({塑料} ∩ {塑料} ≠ ∅)」——布尔集合 + 子串匹配。算这个用不着大模型;用了反而把 1 微秒的确定运算换成几百毫秒、烧 token、还可能抽风的网络调用。

③ persist 在热路径且绝不能失败。 它每轮收尾 / 每次 remember / 每条兜底都走,还被降级保护(Redis 挂了不崩)。插 LLM = 凭空多一个失败面(超时 / 限流 / 拒答),让「写偏好」有可能拖垮收尾。这也是项目一贯原则:正确性保证靠机制,不靠 LLM / prompt

4.2 诚实边界:什么情况才轮得到 LLM#

确定性匹配有个真盲区:不共享字面词的语义矛盾——「喜欢环保材质」vs「不要塑料」、「喜欢皮革」vs「不要动物制品」,人能看出张力,但没有共享 keyword → 机制抓不到。

这是睁眼 YAGNI,不是疏漏:(a) 这类低频、低危害(最坏两条软偏好共存、注入略吵,不像字面同词那样造成向量 / 硬过滤行为级拆台);(b) 做对要每次写都让 LLM 扫全量偏好,成本高;(c) 高频高危害的字面翻转已被廉价确定性路全覆盖。真要补,思路也不是塞进 persist,而是另开一个低频异步的「记忆体检」LLM 任务扫语义矛盾,与热路径解耦——那是后话。

5. 为什么衰减 / 淘汰没做#

同属「记忆治理」,但有意识判定为 YAGNI:

  • 衰减:一条明说的「不要塑料」并不会随时间失效(dislike 尤其耐久);真正会过时的只有偶发软 like,为这一小撮做全局时间衰减性价比低。且矛盾消解做完后,剩下的旧偏好已被 read_relevant 按 query 相关性 + top_k 封顶,收益更小。
  • 淘汰 / 上限:read_relevant 已封顶注入成本,存储那点字节不值得为它加淘汰逻辑,等真有用户攒到几百条再说。

能讲清「识别了三个治理点、只做会造成 bug 的那个、另两个是有意识 YAGNI」,比全做了更能体现判断力。

6. 验证#

  • forget:按 keyword / content 子串删、polarity 过滤、无匹配不误删、匿名 no-op、工具端到端。
  • history:跨实例持久、同 kind last-write-wins、与偏好互不污染、Redis 降级、注入块渲染。
  • 矛盾消解:dislike 覆盖 like、content 子串命中、不同对象不误删、同极性走 merge 不误删、无 keyword honest no-op。
  • 三块共新增十余条针对性测试。自动门:改动文件 ruff / mypy 干净、全量 pytest 342 全过

7. 交付与边界#

交付:forget_preference 工具(进 FULL_TOOL_SET)+ injector.forget_preferences;HistoryEntry 模型 + 两后端(Local / Redis)read_history/write_history + 收尾机制写 search 历史 + <user_recent_history> 注入;persist_new_preferences 写前 _resolve_contradictions(纯确定性、零 LLM)。

边界 / 下一步:

  • history 只记 search:无真实下单,purchase 类待接入下单流程。
  • 矛盾消解只覆盖字面同词:语义矛盾留给未来的异步「记忆体检」任务,不进热路径。
  • 衰减 / 淘汰 YAGNI:留到真实规模出现再评估;做的话优先「读时非破坏式降权」而非删数据。
  • forget / 消解的匹配是子串,不是 NLU:指向明确的撤回够用;模糊指代(「把那个贵的偏好删了」)不支持,也不该靠猜。

附:三处捕获、一个落库口 —— 记忆写侧的完整形态#

连同 M7.2,长期记忆的写侧现在是一张自洽的图:三个捕获点(remember 工具 / 入口规则兜底 / summary 终结)+ forget 撤回入口,全部汇进 persist_new_preferences 单一落库口;落库口内先做极性矛盾消解、再去重合并、再记进本轮累加器。单一落库口的价值在这里兑现:矛盾消解只写一处,三个捕获点 + 任何未来新增写入路径自动继承,不会漏。