Appearance
第 15 章 记忆:跨会话保存什么
本章要解决的问题
第 14 章的会话日志保存“发生过什么”,让同一个任务可以恢复。但每次新会话都读取全部旧日志,既浪费上下文,也会把过期信息重新带进来。
长期记忆只保存经过选择、适用范围明确、能够更新或删除的知识。本章回答:什么值得记、怎样存取、发生冲突时怎样改、过期后怎样彻底删除。
15.1 一条记忆从发现到删除要经过什么步骤?
会话日志保存发生过的事,长期记忆只保存经过选择、限定 user/workspace/session 范围、可能失效的知识。需要依次处理:
text
候选发现 → 信任/授权判断 → 去重与合并 → 写入唯一可信记录
→ 建索引 → 按 scope 检索 → 带来源注入 → 使用后验证
→ 更新/合并/过期/遗忘建议每条结构化记忆至少能回答下面这些问题——Markdown 也可以表达它们;关键不是格式,而是这些字段能说明什么:
| 字段 | 回答什么 |
|---|---|
id + scope(user / workspace / session) | 这条记忆适用于哪里,谁能读到 |
content | 内容本身 |
source(sessionId + eventSeq + 来源类型) | 谁说的:用户陈述 / 已验证事实 / 推断——三者信任级不同 |
createdAt / lastVerifiedAt / expiresAt | 何时产生、上次核验、何时过期 |
revision | 版本号,UPDATE/DELETE 的 CAS 并发检查依据(见下) |
每条记忆都要回答三件事:谁说的、适用于哪里、现在还有效吗。信任级从高到低排序:用户陈述 > 已验证事实 > 推断;低信任级不能自动覆盖高信任级记忆。
为什么一次成功尝试不能直接写成记忆?
“用户偏好暗色主题”和“上次修这个报错时先清缓存有效”不是同一类东西。前者是用户明确表达的偏好;后者只是一次任务中的经验候选,可能偶然成功、不能用于其他场景,也可能已经过时。
长期记忆解决“以后应知道什么”。把反复验证有效的做法整理成 Skill、脚本或工具,解决“以后应怎样做”。
经验必须先带着原始任务记录和验证证据成为候选,不能因为任务最终成功就自动写成高信任记忆。经验应该保存为知识、Skill 还是代码工具,放到第 17 章讨论;失败经验怎样复现、测试和发布,服从第 19 章的验证要求。
候选记忆与当前记录冲突时怎么办?
抽取模型输出一段文字还不算写入决定。系统应先按稳定的 memoryKey + scope 读取当前有效值,再把候选归为四种操作:
| 操作 | 什么时候用 | 怎样写入 |
|---|---|---|
ADD | 当前不存在等价事实,候选满足信任与授权要求 | 创建 revision 1,保留来源 |
UPDATE | 同一事实已有当前值,但用户纠正、事实变化或 scope 改变 | 以 expectedRevision 做 CAS,新 revision 指向被替代版本 |
DELETE | 用户要求忘记、授权撤回、事实失效或 retention 到期 | 写删除事件/tombstone(墓碑记录:不抹掉历史,只追加一条“已删除”标记,用于审计和阻止旧事件复活),触发删除传播;不能只从 UI 隐藏 |
NOOP | 重复候选、低可信冲突、与任务无关,或旧事件试图复活已删除事实 | 不改当前值,但可记录拒绝原因供评估 |
这里要分清两份数据:
text
历史事件:ADD(v1) → UPDATE(v2) → DELETE(v3) 用于审计、解释和恢复
当前有效值:<不存在> 用于检索和注入模型“曾经记录过”不等于“现在仍有效”。查询时默认只返回当前、未过期且有权读取的版本;审计界面才按需展示历史。
UPDATE/DELETE 必须携带预期 revision。否则两个会话同时纠正同一事实时,后到者会静默覆盖先到者。这组并发更新规则是可选设计,不是说 codex、grok-build、dsh 已经共同实现了这套四操作协议。
15.2 长期记忆可以怎样存?
先分清两个问题:一条记忆怎样表示,以及这些记忆保存在哪里。Markdown、数据库和向量索引回答第二个问题;下面四种形式回答第一个问题:
| 表示形式 | 适合什么 | 主要代价 |
|---|---|---|
| 原子事实 | packageManager = pnpm 这类单一、明确、易更新的信息 | 事实之间的关系容易丢失,复杂问题需要重新组合 |
| 带上下文的短段落 | 一项决策的背景、原因和适用范围 | 容易重复,局部字段变化时难以只改一处 |
| 结构化记录 | 类似 TypeScript interface 或数据库记录,字段、时间、来源和关系明确 | 需要维护 schema 版本,不确定信息也未必能自然塞进固定字段 |
| 受信任代码中的规则 | 对结构化记录做统计、冲突检查或强制约束 | 规则代码必须单独测试和授权;不能把模型生成的任意代码当作普通记忆直接执行 |
一个系统可以混合使用:项目事实用原子记录,架构决定保留背景段落,需要并发更新和权限控制的内容用结构化记录。聚合、日期计算和硬约束尽量由受信任代码读取结构化数据后执行,不要每次把几十条文本交给模型“心算”。
表示得更结构化不等于内容更可信。无论哪种形式,仍要保留 15.1 中的来源、适用范围、版本和有效期。
方案 A:人工维护的 MEMORY.md
- 优点:用户可读可改、可 Git、零索引基础设施。
- 代价:规模大后查找效果变差;合并冲突、删除过期内容和控制 prompt 大小需要人工维护。
- 适合:个人/单项目、几十到几百条高价值事实。
方案 B:文件保存唯一可信记录,索引只用于加速
- Markdown 保留可纠错性;FTS/向量只加速检索。
- 索引损坏可重建,直接编辑文件后触发增量重建。
- 适合:仍希望用户掌控原文,但记忆已不能每次全读。
方案 C:结构化事实库 + 自动抽取/合并
- 支持多用户、revision、权限、过期、审计和批量维护。
- 代价:需要评估记忆提取是否正确,处理冲突,确保删除传播到各份副本,并长期运维。
- 适合:服务端产品或大量会话。
三个方案适用于不同规模和使用方式。参考 Agent 没采用某个方案,只说明它们当前不需要,不代表其他场景也不需要。
后台整理怎样不拖慢当前请求?
自动记忆不应在每次对话的主请求里同步重写全部文件和索引。一个简单流程是:
- 当前请求只保存带来源的候选记忆,不立即提升为长期事实。
- 候选达到数量或时间阈值后,后台任务取得带有效期的整理锁。
- 去重,处理冲突和相对日期,删除过期候选,再生成新快照。
- 先完整写入临时版本,检查通过后原子替换当前快照。
- 根据新快照重建可丢弃的搜索索引;索引失败不破坏原始记录。
- 整理任务只允许写记忆目录,并记录输入版本,失败后可从同一版本安全重试。
请求路径与整理任务使用同一 revision 规则,避免后台任务用旧候选覆盖用户刚做的纠正。
15.3 四家现在分别怎样做?
15.1 给出了从候选到遗忘的完整流程,15.2 则拆开了记忆的表示和存储。用这两组问题再看参考实现,才能区分“有会话历史”、“能搜索旧记录”和“有完整长期记忆”,不会因为仓库里出现 memory 一词就推断整条链已经存在。
codex:自动分两阶段处理,并提供可选的手工入口。 它并非只有 add_ad_hoc_note:
- Phase 1 从符合条件的 root rollout 中筛出适合记忆的内容,并行调用模型抽取原始记忆候选与 rollout 摘要;DB lease(带期限的占用记录)防止并发重复,失败有退避,敏感信息先脱敏。抽取单独成阶段,是因为它便宜、可并行,失败重跑一遍就行。
- Phase 2 获取全局锁——整理要改写全局共享的
MEMORY.md,同一时刻只能有一个整理者,否则并发整理会互相覆盖。它从 DB 选取有限的高价值结果,更新中间结果文件,再运行 consolidation agent(专门做合并整理的受限子 Agent)维护MEMORY.md、memory_summary.md和 skills。这个 agent 无网络、无审批:整理的输入混着网页等不可信内容,放开这两项,整理过程就可能被注入或越权。
此外,可选工具提供 add_ad_hoc_note 等手工增补/查阅入口;手工 note 与自动抽取是两种入口。“手工写入需用户明确要求”只约束手工工具,不表示 codex 不会自动形成记忆。
主会话读取的是受控的 memory summary,不能任意改写记忆根目录。
grok-build:Markdown 保存唯一可信记录,SQLite 只做索引。 该功能目前是 experimental 且默认关闭:
- global/workspace 两种 Markdown scope(session 侧只有会话日志目录,不是第三种记忆 scope);
- SQLite 全文检索加可选的向量索引做混合检索;
/remember保存前先展示确认面板;没有内置 forget 命令——删除靠直接编辑文件或后台整理;- 会话结束可保存不调用 LLM 的最小摘要,
/flush生成丰富总结,/dream做带锁的整理; - 文件 watcher 在搜索前同步脏索引,索引丢失可以重建。
这不是“向量库替代 Markdown”,而是用户可读的原始记录 + 随时可重建的搜索索引。
dsh 和 pi:核心都没有完整的长期记忆模块。 共同点是“能重新打开历史会话”不等于有记忆:dsh 的 session log、可选 FTS 与 Goal/Todo 解决的是会话审计、检索和任务控制;pi 持久化会话树、也允许包和扩展增加存储,但记忆抽取、冲突处理、检索注入和遗忘同样缺席。两个容易误读的地方:dsh 不能把“会话层能做的事”当作长期记忆的源码佐证;pi 仓库中的内存 session backend 只是存储后端形态,不是用户记忆系统。
15.4 怎样防止记忆注入和过期缓存?
15.3 的实现深浅不同,但只要一段内容会在未来会话重新进入模型,就共享同两类失败:不可信内容被升格成指令,以及已过期内容因缓存或索引继续出现。长期记忆会把一次 prompt injection 放大成跨会话攻击。至少遵守:
- 网页、仓库文本和工具输出只能成为候选,不能未经核验就变成高信任记忆。
- 用户明确陈述与自动推断分级保存;推断应可追溯、可撤回。
- 检索结果作为带来源的数据注入,优先级低于 system/developer 与当前用户指令。
- “忘记”必须同时更新原始记录、索引、摘要和缓存,不能只从搜索结果隐藏。
- 项目切换时按 scope 隔离,避免 A 仓库的规则污染 B 仓库。
MEMORY.md 也不是“跨会话永远不变”的稳定前缀。它会更新、合并、删除,并可能来自不同 workspace。更准确的上下文布局是:
text
稳定 system/policy 前缀
→ 带 version/fingerprint 的 memory snapshot
→ Goal + 当前会话历史
→ 本 turn 动态输入memory snapshot 不变时可复用它之前的 KV cache;一旦内容或 scope 改变,从该边界起缓存就应失效。不要为了缓存命中继续注入已过期记忆。
15.5 服务端怎样隔离用户记忆并彻底删除?
服务端或团队产品还要补哪些边界?
15.4 处理的是一个用户或一个本地工作区内的信任和过期问题。当记忆进入多用户服务,还会增加另一条不能交给模型判断的边界:谁有权写、搜、读和删哪些记忆。
个人本地文件可以把权限交给操作系统;多用户服务不能只靠 prompt 里的“不要看别人的数据”。至少把下面四道边界做在存储与查询层:
| 边界 | 最小要求 | 常见坑 |
|---|---|---|
| 租户与适用范围 | 每条记录带服务端从认证态派生的 tenantId/userId/workspaceId;客户端不能自行指定越权 scope | 只按 memoryId 查询,猜到 ID 就能跨租户读取 |
| 读写权限 | 写入、搜索、读取原文、查看历史、删除分别鉴权;FTS/向量查询在召回阶段就带 ACL 过滤 | 先跨租户 top-k,再在应用层过滤,既可能泄漏也会挤掉合法结果 |
| 脱敏 | 密钥、token、身份证件等在进入原始记录、索引、embedding 和摘要之前识别并拒绝/遮蔽;原始敏感值另走受控 secret store | 只在 UI 打码,向量索引和备份里仍留明文 |
| retention(保留期限) | 按 scope/source 配 TTL 与保留策略;删除传播到原始记录、索引、snapshot、缓存和生成的摘要,备份按制度到期清除 | /forget 只删 Markdown,搜索索引仍能召回 |
权限校验还要覆盖查询过程:谁可以搜索什么,和谁可以读取某条命中的原文,是两个动作。检索工具不能因为“由 Agent 调用”就绕过用户本人的 ACL;返回给模型的每条记忆应带 scope 与来源,模型输出也不能泄露无权展示的历史。
以上是面向 SaaS/团队产品的设计范围。四个参考 Agent 主要运行在本地开发者环境,各自只实现了其中一部分;不能把这张表反写成“四家现状”。
15.6 记忆系统需要测试哪些场景?
15.1~15.5 已经定义了记忆的生命周期、存储选择、现实实现和安全边界。最后不能只测“能不能找回来”,还要证明它不会在错误范围、错误时间或删除以后再次出现。记忆系统的失败既包括漏召回,也包括不该出现时出现。准备一组固定的多会话脚本,至少覆盖:
| 场景 | 测试构造 | 必须断言 |
|---|---|---|
| 准确召回 | 会话 A 明确确认项目使用 pnpm;会话 B 询问安装命令 | 召回正确 scope 和来源,不改写成 npm/yarn |
| 跨会话组合 | A 提供框架,B 提供测试约定,C 请求实现 | 只组合彼此兼容、仍有效的事实,并能追到各自来源 |
| 同名对象 | 两个仓库都有 api 模块,但构建命令不同 | 结合 workspace/scope 区分,不把 A 的命令注入 B |
| 时序冲突 | A 说 Node 18,B 后来纠正为 Node 20 | 当前查询只注入 Node 20;审计仍能看到 v1→v2 |
| 无关内容不注入 | 保存主题颜色偏好后询问数据库迁移 | 不因“有记忆”就强行注入无关内容;既测注入内容中有多少相关(precision),也测需要的记忆中找回了多少(recall) |
| 删除传播 | 写入→建立索引/snapshot→执行 forget | 原始记录、检索、摘要和缓存均不可再返回;审计按策略只留 tombstone |
| 注入防护 | 候选内容含“忽略系统指令”或来自不可信网页 | 不能把它变成指令;检索后仍以低信任数据注入或直接拒绝 |
离线指标至少包括:前 k 个结果找回了多少所需记忆(recall@k)、注入无关内容的比例、使用过期或冲突记忆的错误率,以及删除是否传播到所有副本。
还要用完整任务验证“找到记忆后是否真的做对”。只优化召回率会诱导系统把所有历史都塞进上下文,看似“记得多”,实际会加入更多噪声和风险。
本章小结
- 记忆的价值在跨会话复用:写什么、何时写、怎样查、错了怎么改、怎么删,每步都要有明确规则
- 四家投入差异极大——codex 有自动整理链路,grok 提供实验性 Markdown 记忆,dsh 与 pi 核心没有完整长期记忆;做不做、做多深是真实选型题
- 记忆的特有风险:被注入的偏见会潜伏到未来会话才触发;过期记忆比没有记忆更危险
到这里,单个 Agent 已经能够恢复会话,也能选择性复用过去的知识。但独立子任务仍会挤在同一个上下文里,并且只能依次执行。下一章基于第 14 章的会话与 fork,讨论什么时候值得创建子 Agent,以及怎样限制它们的上下文、预算和副作用。