面试知识库

M7.5 · 记忆 schema 重构:domain/slug 与结构化 P_t —— 开发文档(面试向)#

讲为什么这么改、取舍在哪,不讲代码。承接 M7.4:那篇把偏好判定收敛成唯一的 curator、补上了 P_t,但顺手留了两个没解决的问题——长期偏好的去重键本身设计有裂缝、P_t 只是长期库结构的”轻量复刻”没有自己的形状。M7.5 把这两处都补上。

一句话概括#

M7.4 修完 canonical_key 那个字段名 bug 之后,我在做后续 code review 式复盘时发现:那个 bug 只是表象,根子是去重键的设计本身就有问题——key 是 LLM 手拼的字符串,却把已经作为独立字段存在的 polarity/category 又编码了一遍,是同一份信息存两遍、还可能自相矛盾。同时,长期库的 dislike 硬过滤不分商品域,全局生效,会把”电子产品不要塑料”错误地应用到搜衣服上。M7.5 把长期库的身份设计重做了一遍:LLM 只提供真正的原子新信息(slug),去重键交给代码从结构化字段(polarity/category/domain/slug)确定性拼出来。顺带把 P_t 从”长期库结构的轻量复刻”升级成真正的结构化会话状态——加了 current_intent/slots/rejected_options/open_questions/decisions_made,让”这次买什么、已经拒绝过谁、还欠用户什么回答”都有专门的地方装,而不是全靠散在 messages 里的自然语言。

1. 为什么 key 手拼是个坏设计,不只是”有个 bug”#

M7.4 修的 canonical_key/key 字段名不一致,本质是同一份信息被要求存两次导致的必然结果:key = "dislike:material:plastic" 里的 dislike 就是 polarity 字段的值,material 就是 category 字段的值——这两段在同一条记录里各自有专门字段,却还要 LLM 在一个字符串里再拼一遍。字段名对不上只是这个设计缺陷第一次暴露出来的症状;就算字段名对齐了,LLM 完全可能拼出 key="dislike:material:plastic"polarity 字段却填 "like"(一次幻觉),没有任何校验能拦住这种自相矛盾。

改法PreferenceEntry 不再要求 LLM 提供完整 key,只提供 polarity/category(封闭枚举)/domain(商品域,可留空)/slug(原子标识,如 "plastic")四个结构化字段,dedup_key 是一个只读的派生属性,由代码从这四个字段拼出来——LLM 拼错的可能性被移出了这个环节。category 也从自由文本收窄成封闭枚举(material/style/brand/budget/color/size/other),理由一样:自由文本作为去重键的一部分,同一维度换个措辞(“material” vs “材质” vs “fabric”)就会让 dedup 静默失效,这和 key 手拼是同一类问题。

面试怎么讲:一个信息只该有一个权威来源。去重身份如果既能从独立字段推出来、又要求调用方额外拼一遍,这两份数据迟早会不一致——修复不是”让字段名对齐”,是”别让同一份信息存两遍”。

2. domain 字段解决的是什么、不解决什么#

给长期偏好加了一个 domain(商品域,如 electronics/clothing)字段,动机是修复一个具体案例:用户对电子产品说”不要塑料”和对衣服说”材质排除塑料”,如果不带商品域区分,两条会撞上同一个 dedup_key、后写的覆盖先写的,语境直接丢失;就算不撞 key,读取时 item_picker 的硬过滤也会不分商品域全局生效,导致”电子产品的排除词”跑去过滤衣服候选。

但这个字段只解决写入去重(不同域的同 slug 不会互相覆盖),不解决检索时的精确域匹配——domain 是自由文本,没有干净的商品分类体系可对齐(商品候选自己的 category 字段就是六个平台各自的原始文本,并不统一),拿它做”当前搜索品类 == 存的 domain 才生效”这种精确匹配,只会引入新的字符串漂移问题。所以 curator 的判断口径是”拿不准就留空”(留空=全局生效)——尤其是过敏/安全类偏好,即使字面像是”针对某类商品”,也不该被域字符串意外挡住,错的代价不对称:漏过滤一条不相关域的排除词,比该全局生效的安全约束被域限制漏过滤,代价轻得多。真正的读取精度问题,交给已经存在的”按本轮 query 语义 top-k”注入策略去缓解,不在这里另建一套域匹配逻辑。

面试怎么讲:新加一个字段前先想清楚它解决问题的哪一半。domain 解决的是”存储身份不冲突”,不是”检索时刻精确路由”——把两件事的责任边界分清楚,比试图用一个字段同时解决两件事更诚实、也更不容易踩偏。

3. P_t 从”长期库的轻量复刻”到”结构化购物会话状态”#

M7.4 的 P_t(SessionPrefState)在结构上基本是照搬长期库 PreferenceEntry 的形状(polarity/strength/keywords),只是加了 TTL。这次重新看这个设计,发现它漏了几类真实存在、但当时没建模的会话态信息:

  • 当前意图SessionPrefState.category 只是一个粗粒度品类字符串,没有”买一双跑步鞋”这种完整意图描述——现在加了 current_intent
  • 单值客观事实:像鞋码这种”要么符合要么不符合”的属性,硬塞进 polarity/strength 的梯度式偏好列表里不合适(它不是”喜欢 42 码”,是”就是 42 码”)——单独开一个 slots: dict[str, str] 覆盖式槽位。
  • 已拒绝的具体商品:之前完全没有”这件已经展示过又被拒绝”的记忆,只能靠把”超预算被淘汰”泛化成一条 dislike 约束,但那是错的(不是讨厌这个品牌,只是这一件超了预算)——加了 rejected_options,且不由 curator 的 LLM 判断,直接由 item_picker 淘汰候选时机械记录(谁被淘汰、因为什么),curator 原样折进 P_t,零额外 token 成本、100% 准确。
  • 待澄清的问题:多轮追问场景下,“助理问了什么、用户还没答”之前完全没有结构化记忆——加了 open_questions,且设计了配套的 resolved_questions 用于精确匹配移除(不是只增不减,否则会话内会无限堆积)。
  • 操作性事实兜底桶:像”确认用默认收货地址”这类不属于前面任何一类的杂项——decisions_made,prompt 里明确要求”能归进前面几类就别塞这里”,避免它变成什么都装的垃圾桶字段。

面试怎么讲:P_t 一开始是照抄了一个现成结构(省事),但真正跑起来才发现购物会话有它自己天然的形状——“我在干嘛、有什么硬性参数、拒绝过什么、还欠什么回答”,这些和”用户长期喜好”根本不是一回事。发现结构对不上真实需求就该动它,而不是硬凑。

4. 一个明确的克制:短期层没有跟着加 domain#

长期库加了 domain 字段解决跨品类覆盖问题,但短期层 SessionConstraint 的去重键这次只做了同款”key 别手拼”的整改(改成代码从 category/slug 派生),没有加 domain 维度。理由是 P_t 生命周期只有 TTL 窗口(默认 24 小时)、一次选购会话通常聚焦一个商品域,跨品类冲突的爆炸半径远小于跨会话累积的长期库——按 YAGNI,等真观察到同一会话内换品类导致约束错误覆盖的实际 case 再补,不是现在就顺手做掉。

面试怎么讲:同一类问题在两个不同生命周期的容器里,代价可能完全不对称。加一个维度前先算一下”不加的最坏情况有多坏”,而不是看到长期库加了就默认短期层也该加——一致性不是目的,解决实际问题才是。

5. 明确没做、如实标注的事#

  • curator 复用同轮 planner 的结构化输出planner 工具已经在做类似的意图/约束结构化解析,理论上 curator 可以复用它而不是从 final_text 重新做一遍 NLU,能控制 curator prompt 的复杂度。评估后没做——planner 工具不走 content_and_artifact,从 messages 里可靠取回它的结构化输出成本较高(会引入脆弱的 JSON 解析,或者改 planner 的工具契约、影响面更大),这次先不做,留作后续优化点。
  • scripts/cleanup_memory_pollution.py 未同步更新:这是 M7.4 修 canonical_key bug 时写的一次性清理脚本,引用的 .key 字段这次已经不存在了。它只支持 STORE_BACKEND=redis(本地默认后端场景不受影响),且是一次性工具,没有删除或更新,只在此记录知会。

6. 验证#

  • 单元测试tests/test_memory.py/test_curator.py/test_session_state.py/test_main_agent.py/test_auth.py/test_server.py/test_tools.py 同步改到新 schema(去重键从直接断言字符串改成断言 dedup_key 派生值、鸭子类型测试对象补 slug/domain 属性),新增分层注入的回归测试(dislike-hard 超阈值仍全量注入)。
  • 自动门ruff check/ruff format 干净,pytest 388 项全过(较 M7.4 时的 381 项新增 7 项,覆盖新字段/新分层策略)。
  • 数据data/memory/ 直接清空重建,没有写迁移脚本——已 gitignore 的开发态数据,不是生产数据,清空重来的成本远低于写一次性迁移逻辑。

7. 交付与边界#

交付

  • app/memory/store.pyPreferenceEntry 废弃 key,改 polarity/category(枚举)/domain/slug + dedup_key 派生属性;PreferenceStore.rank_relevant 从内部方法改为公开方法供跨模块复用。
  • app/memory/injector.pybuild_preference_block 加分层注入(dislike-hard 永远全量 + 其余超阈值语义 top-k);删掉 remember_preference/shopping_summary 两条已废弃写入路径遗留的双字段兼容死代码。
  • app/memory/curator.py:长期库侧对齐新字段、supersede 引用改成 dedup_key;短期层新增 current_intent/slots_patch/new_open_questions/resolved_questions/decisions_made 输出字段。
  • app/memory/session_state.pySessionConstraint 去重键代码派生;SessionPrefState 加五个新字段 + RejectedOption/DecisionRecord 子模型。
  • app/api/context.py + app/tools/item_picker.py:新增 rejected_options 机械化累加器,item_picker 淘汰候选时记录、run_agent 收尾时折进 P_t。
  • prompt/prompts.ymlmemory_curator_prompt 全面改写,补 domain/category 判断规则、slots vs constraints 边界规则、decisions_made 范围限定。

边界 / 下一步

  • curator 复用 planner 结构化输出——见上,评估后暂缓。
  • 短期层 SessionConstraint 加 domain 维度——按 YAGNI 暂缓,等实际观察到跨品类冲突 case 再做。
  • scripts/cleanup_memory_pollution.py 未随 schema 变化更新——已知过期,不影响本地默认后端。