M7 · 长期记忆 Store —— 开发文档(面试向)#
讲为什么这么做、做了哪些取舍,不讲代码。读完能用大白话讲出来。
一句话概括#
M7 给 Agent 装了一个跨会话的记忆:你这次说「不要塑料」,下次开个全新对话搜别的东西,它还记得。做法是把这种「结论性偏好」从对话里抽出来,存进一个独立的小仓库(Store),下次会话一起来就把相关的几条注进 system prompt 的开头——于是模型一上来就「知道你是谁、你忌讳什么」。
打个比方:以前 Agent 像便利店里换班的店员,上一班你交代的事下一班完全不知道;M7 给它配了一本「老顾客本子」,你一进门它先翻一眼本子再招呼你。
1. 为什么不能靠「把聊天记录都留着」来记住用户#
最直觉的想法是:把历史对话全存下来,下次接着用不就记得了?这条路走不通,两个硬伤:
- 跨会话本来就不共享。每个新会话是一张白纸,上一次的消息默认不带过来。
- 就算硬带过来也会爆。对话历史是按 token 收费、按轮数膨胀的,几十轮下来几万 token,又贵又把模型的注意力稀释掉。
所以正确的解法是把两种东西分开:
- 对话过程(这轮搜了洗漱包、那轮比了价)——留在当前会话的上下文里,会膨胀,归 M6 压缩去治理。
- 结论性偏好(不要塑料、喜欢小众、预算 100–300)——抽出来单独存,跨会话持久。
面试怎么讲:长上下文和长期记忆是两个完全不同的数据结构。一个是有序消息流、按 token 涨钱、单会话有效;一个是结构化条目、按条数存、跨会话共享。把「值得长期记住的」从「一次性的对话过程」里拆出来,是这一章的核心认知。
2. 它和上一章压缩是「一对互补」#
这点特别值得讲,因为它把 M6 和 M7 串成了一个完整的故事:
有了长期记忆保底,上下文才敢放心压缩。
举例:会话里用户说了「不要塑料」。压缩环节迟早会把这条老消息丢掉以省 token——但没关系,因为这条偏好已经落进 Store 了,下次会话起来照样注回来。如果没有 Store 兜底,压缩就成了「失忆」,丢一条少一条。
面试怎么讲:M6 负责「安全地忘掉」,M7 负责「在忘掉之前把该记的存好」。两者搭一起,才能既不爆 token、又不丢用户偏好。
3. 记忆什么时候写、什么时候读#
对齐 AgentLoop 的 Think→Act→Observe→Reflect 节奏,记忆就挂在一头一尾:
- 写在 Reflect(收尾时):主链路跑完,收尾工具
shopping_summary会顺手识别出本轮的新偏好(「用户明确说了不要 X / 喜欢 Y」),把它们落库。注意——写入是有门槛的,不是每句话都存,只有「明确表达的偏好」才沉淀,避免把噪声也记下来。 - 读在新会话开头(注入时):下次
run_agent一进来,先从 Store 把这个用户的偏好捞出来,格式化成一小段文本,填进 system prompt 里专门留好的<user_long_term_preferences>槽位。
效果就是:用户没有重复说「不要塑料」,但 Agent 表现得像记得——因为偏好已经在它的「开机指令」里了。
面试怎么讲:写和读是错开的——这一轮收尾时写,下一轮开场时读。这正是「长期」的含义:不是当场生效,是攒下来给未来用。
4. read_relevant:偏好多了,只挑相关的注入#
一个老用户可能攒了几十条偏好。要是每次都全量注入,光偏好就吃掉两千多 token,还把一堆无关的(「上次在 eBay 买过充电宝」)硬塞给模型干扰判断。
所以 Store 除了「全量读」,还有一个按相关性读的口子:拿用户这次的搜索意图当锚点,和每条偏好算一下语义接近度,只挑最相关的几条注进去。比如这次搜「洗漱包」,就把「不要塑料」「预算区间」「喜欢小众」捞上来,而「上次买过充电宝」这种不相关的就不打扰。
这里复用了 M3 召回层那套现成的编码器(把文字转成向量再比相似度),没有另起炉灶——长期记忆的「语义匹配」和商品召回的「语义匹配」本质是同一件事。
面试怎么讲:偏好注入也要做「检索」,不能无脑全塞。这是一个小号的 RAG——按当前意图,从用户的偏好库里召回最相关的几条。省 token,也让注入的信息更聚焦。
5. 关键取舍:默认后端为什么是「本地文件」而不是 Redis#
路线图原本写的是「Redis 默认」。我主动改成了本地 JSON 文件默认、Redis 可选,理由是项目从 M3、M5 一路贯穿的一条原则:默认形态必须能脱离外部服务(docker)独立跑。
- 如果默认就吊着 Redis,那本地开发、跑测试、CI 都得先把 Redis 拉起来才能动——一个外部依赖卡住整条链路。
- 改成本地文件默认后:每个用户一个 JSON 文件,零依赖、离线可跑、结果可复现,测试直接落到临时目录、不污染真实数据。
- 想要真持久化、跨进程共享时,配一个环境变量就切到 Redis——这跟 M5「配了 OPENSEARCH_HOST 就走真引擎,不配就走本地回退」是同一套解耦套路。
诚实讲代价:本地文件后端是单机的,多实例部署时不能共享,并发写也只能靠进程内的锁串起来——它是「开发/演示档」,不是「生产档」。生产要跨实例共享,就切 Redis。我把这点明明白白标在配置和文档里,不假装本地文件能扛生产。
面试怎么讲:我没盲从路线图的「Redis 默认」,而是对齐了项目「离线优先、外部后端可选」的一贯原则。判断依据是:默认形态的第一诉求是「谁拉下来都能立刻跑」,而不是「一步到位上生产」。
6. 几个容易被问到的设计细节#
- 重复表达怎么办? 同一条偏好换个说法再讲一遍,不应该堆成两条。所以每条偏好按「极性 + 归类 + 内容」算一个稳定的去重键,撞键就走「覆盖 + 提一点置信度」——讲得越多越可信,而不是越攒越乱。
- 「不要塑料」和「喜欢帆布」怎么区分? 每条偏好带一个极性标记(喜欢 / 排斥)。排斥就是「黑名单」语义,下游精挑时按它做硬过滤。这个极性字段和收尾工具
shopping_summary吐出来的格式是对齐打通的,记忆层不用再猜。 - 用户 ID 直接当文件名安全吗? 不安全——用户可控的字符串若含
../之类,可能写到目录外去(路径穿越)。所以落盘前先把危险字符洗成安全文件名,再用统一的safe_join兜一道底,保证文件只会落在记忆目录里面。 - 为什么记忆层不反向依赖工具层? 收尾工具产出的「新偏好」要落库,但我没让记忆层直接 import 工具类,而是用「鸭子类型」——只要对象带 content/category/polarity 这几个字段就能存。这样分层是单向的(工具可以用记忆,记忆不依赖工具),不会绕成循环依赖。
- source_session 为什么不手动传? 项目规矩是「上下文(会话 ID)走 ContextVar,不手动透传」。所以写偏好时来源会话默认从上下文里自动取,只有测试/离线脚本这种没有请求上下文的场景才显式传——既守了规矩,又留了后门。
7. 边界与没做的事(诚实标注)#
- 写入触发目前靠收尾工具结构化产出,没有再单独做一套「从原始用户消息里用规则/NLU 抠偏好」的旁路。refdocs 提了规则匹配(命中「不要」「喜欢」就存),但那条路噪声大、容易误存;交给
shopping_summary在收尾时judiciously 识别更干净。真正把读写两端挂进主链路(入口注入、收尾写回)是 M9 合龙时的活,M7 先把 Store 和注入/写回的函数做对、测透。 - 语义召回的质量受编码器限制。离线默认用的是确定性本地编码(字符级哈希),它能保证「同输入恒同输出、可复现」,但语义分辨力弱——示例里英文偏好能按字面相关性排对,中文细粒度排序就一般。配上真 embedding(BGE-M3 等)后这一路才满血。这是「无 GPU、只用现成模型」约束下的已知取舍,不是 bug。
- 置信度是个简化模型:每次复述固定 +0.2、封顶 1.0。够用来表达「讲得越多越可信」,但没做时间衰减、没做矛盾偏好的对冲——这些留给真有需求时再加。
8. 一句话面试总结#
M7 让 Agent「记得你」:把结论性偏好从会话里抽出来、跨会话持久、按相关性注回 system prompt。它和上一章压缩是互补的一对——有记忆保底,上下文才敢放心压缩。落地上我坚持了项目「离线优先」的原则,把默认后端从路线图的 Redis 改成了本地文件、Redis 降级为可选,让谁拉下来都能立刻跑,同时把生产档的去路(切 Redis)和它的代价讲清楚。