Agent 的记忆:从会话历史到长期经验
人们常说 Agent 应该有记忆,但“记忆”并不是一个单独的功能。模型能看到当前对话,不代表新会话还能记得用户偏好;系统保存了完整聊天记录,也不代表它知道下一次应该找哪一段;把旧消息压缩成摘要,更不等于从许多任务中提炼出了可以复用的经验。
在工程上,至少要把下面四类数据分开:
| 数据 | 解决的问题 | 常见生命周期 |
|---|---|---|
| 会话历史 | 当前对话说过什么 | 一个 Session,可持久化后恢复 |
| 上下文压缩 | 历史过长时怎样继续对话 | 当前 Session |
| 项目指令 | 这个仓库应怎样工作 | 跨 Session,通常随项目保存 |
| 长期记忆 | 过去哪些事实和经验值得再次使用 | 跨 Session,按用户或应用等范围检索 |
它们之间可以互相提供原料,却不能相互替代。会话历史可以成为长期记忆的来源,压缩摘要可以帮助恢复当前任务,AGENTS.md 可以记录稳定的项目约定,但每一种数据的写入时机、作用域和召回方式都不同。
Microsoft Agent Framework、OpenCode 和 Codex 恰好给出了三种不同答案。MAF 把记忆开放成可插拔的 Provider 管线,OpenCode 主要依靠持久会话和项目指令,Codex 则额外实现了一条从历史会话中抽取并合并经验的后台管线。理解它们的差异,关键不是看源码里有没有名为 memory 的类型,而是沿着“写入什么、存到哪里、何时召回、怎样注入”这条链路阅读。
先分清历史、压缩与记忆
一次模型调用只能使用有限的上下文。Agent 循环会不断追加用户消息、模型回复、工具调用和工具结果,达到上限后便需要丢弃或压缩旧内容。压缩解决的是“当前任务怎样继续”,它通常保留目标、进度和近期上下文,仍然属于当前 Session。
长期记忆解决的是另一个问题。例如用户在上周的会话中说明自己对花生过敏,本周新建会话后,系统需要先找到这条事实,再把它放进本次模型请求。这里至少多出三个步骤:
- 从过去的交互中保存或提炼事实。
- 使用用户、项目或应用标识限制检索范围。
- 根据当前问题召回相关内容,而不是重放全部历史。
项目指令也会跨会话生效,但它通常由人或 Agent 显式维护,加载时按目录确定作用域。它更像稳定的规则,而不是从历史中动态检索的事实。AGENTS.md 适合写“提交前运行 dotnet test”,不适合堆放每次会话的聊天摘要。
判断一套机制是不是长期记忆,可以连续问四个问题:
- 新 Session 是否会自动看到旧数据?
- 系统是读取全部内容,还是根据当前问题选择内容?
- 不同用户、项目和 Agent 的数据怎样隔离?
- 错误信息或恶意外部内容写入后,怎样更正和清理?
只要第一个问题的答案是否定的,它通常只是工作记忆或会话状态。只回答第一个问题而没有后面三项,则只能算持久化,还不能算完整的长期记忆设计。
MAF:用 Provider 组合历史与语义记忆
MAF 是供应用集成的框架,不是进入仓库后自动工作的编码 Agent。因此它不会在运行时向上查找 AGENTS.md,也不会替应用决定哪个用户可以读取哪些数据。宿主程序负责提供指令、会话标识和存储后端,框架负责定义调用边界。
MAF 把会话历史和长期记忆拆成两条管线:
ChatHistoryProvider在模型调用前恢复按时间排列的消息,在调用后保存新消息。AIContextProvider在模型调用前补充指令、消息或工具,在调用后保存可复用的上下文。
ChatClientAgent 先调用 ChatHistoryProvider.InvokingAsync(),再按注册顺序调用每个 AIContextProvider.InvokingAsync()。模型完成后,它先通知历史 Provider,再通知各个上下文 Provider。对应源码位于 src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs、src/Microsoft.Agents.AI.Abstractions/ChatHistoryProvider.cs 和 src/Microsoft.Agents.AI.Abstractions/AIContextProvider.cs。
这两个抽象名字相近,职责却不同。ChatHistoryProvider 要保持对话顺序,让同一个 Session 可以继续。AIContextProvider 不要求返回完整历史,它可以只给出和当前问题有关的几条消息,也可以临时增加一个检索工具。框架还会标记消息来源,默认只把外部请求交给上下文 Provider 搜索和存储,避免 Provider 反复学习自己注入的内容。
ChatHistoryMemoryProvider 的读写路径
ChatHistoryMemoryProvider 继承 MessageAIContextProvider,实现位于 src/Microsoft.Agents.AI/Memory/ChatHistoryMemoryProvider.cs。模型调用成功后,StoreAIContextAsync() 会把请求与响应逐条写入向量存储。记录除了角色、正文和时间,还包含四个可索引字段:
new VectorStoreDataProperty(ApplicationIdField, typeof(string)) { IsIndexed = true },
new VectorStoreDataProperty(AgentIdField, typeof(string)) { IsIndexed = true },
new VectorStoreDataProperty(UserIdField, typeof(string)) { IsIndexed = true },
new VectorStoreDataProperty(SessionIdField, typeof(string)) { IsIndexed = true },
下一次调用前,Provider 把本轮请求文本作为查询,在向量库中执行语义检索。非空的作用域字段会用 AndAlso 组合成过滤条件,默认最多返回三条结果。结果被拼在 ## Memories 提示之后,作为一条 User 消息加入模型上下文。
作用域不是附属配置,而是长期记忆的安全边界。ChatHistoryMemoryProvider.State 分别保存 StorageScope 和 SearchScope。两者默认相同,也可以显式分开:写入时记录 UserId 和 SessionId,查询时只限制 UserId,就能保留每条记录的会话来源,同时跨该用户的多个 Session 召回。反过来,如果搜索作用域四个字段全部为空,查询便不会生成过滤条件,可能读到集合中的所有记录。
检索时机也有两种。默认的 BeforeAIInvoke 会在每轮模型调用前自动搜索,使用简单,但每轮都会增加检索和 Token 成本。OnDemandFunctionCalling 不直接注入记忆,而是暴露一个默认名为 Search 的函数工具,由模型判断何时查询。
if (this._searchTime ==
ChatHistoryMemoryProviderOptions.SearchBehavior.OnDemandFunctionCalling)
{
AITool[] tools =
[
AIFunctionFactory.Create(
InlineSearchAsync,
name: this._toolName,
description: this._toolDescription)
];
return new AIContext { Tools = tools };
}
这里保存的是可检索的聊天片段,不是经过归纳的稳定事实。相似问题可能召回重复内容、过时结论或当时模型的错误回答。需要“用户当前偏好”这类强一致数据时,应用仍应维护结构化状态,不能只依赖向量相似度。
MAF 还提供 Mem0Provider 和 FoundryMemoryProvider 等实现。它们继续沿用调用前检索、调用后更新的边界,但把搜索和记忆更新交给外部服务。Foundry 的更新是异步操作,若后续步骤立即依赖新记忆,需要等待 WhenUpdatesCompletedAsync()。这些 Provider 说明 MAF 的重点不是规定唯一的记忆格式,而是让应用能够替换存储、提炼和检索策略。
OpenCode:持久会话与项目指令各管一层
OpenCode 当前源码中没有一条类似 MAF 向量检索或 Codex 经验抽取的长期记忆管线。它会持久化 Session,也会加载 AGENTS.md,但不会自动从旧 Session 中搜索与新问题相似的对话。恢复旧 Session 和新建 Session 是两种不同语义。
当前源码正在从 legacy Session 实现迁移到 V2 Session Core,因此阅读时必须区分两条路径。不能看到 legacy 目录仍有 CLAUDE.md 或远程指令支持,就断言 V2 已经具有相同能力。
V2 Session Core 保存了什么
V2 的核心表定义在 packages/core/src/session/sql.ts。session_message 保存模型可见的消息,session_input 保存已经接收但尚未提升为模型输入的持久队列,session_context_epoch 保存系统上下文基线与快照,todo 则保存当前 Session 的任务项。
输入经过 SessionInput.admit() 后先形成持久事件,再由 SessionProjector 投影到消息表。只要继续使用同一个 Session ID,Runner 就能重新加载这些消息。新建另一个 Session 时,系统不会自动搜索旧 Session,因此 SQLite 解决的是恢复,不是跨会话召回。
V2 的压缩实现位于 packages/core/src/session/compaction.ts。上下文超过预算后,模型会生成包含任务状态和近期上下文的 checkpoint,随后追加一条 compaction 消息。旧消息仍保留在 SQLite 中,读取模型历史时只选择最新压缩点之后的有效表示。它减少的是当前 Session 的上下文体积,不会把经验写成可供其他 Session 检索的知识。
todo、计划文件和 Git snapshot 同样不是长期语义记忆。它们分别记录任务状态、工作计划和文件树恢复点,可以帮助当前工作继续或回滚,却不会因为新问题与旧任务相似而自动进入上下文。
AGENTS.md 是跨会话指令,不是自动学习
V2 的项目指令实现在 packages/core/src/instruction-context.ts。InstructionContext.observe() 会读取全局配置目录下的 AGENTS.md,并从当前目录向项目根目录收集沿途的 AGENTS.md。读取结果注册为 System Context,基线发生变化时会生成一条持久的 system 消息,声明新内容取代此前加载的环境指令。
const paths = Array.dedupe([
yield* fs.resolve(join(global.config, "AGENTS.md")),
...discovered,
])
这条 V2 路径目前只发现 AGENTS.md。CLAUDE.md、已废弃的 CONTEXT.md、config.instructions 和远程指令仍属于 legacy 路径或待补齐能力。legacy 实现位于 packages/opencode/src/session/instruction.ts,除了启动时加载指令,还会在 Read 工具访问文件时向上寻找尚未挂载的项目指令。
OpenCode 可以通过显式的 /init 命令让模型创建或更新项目的 AGENTS.md。模板位于 packages/opencode/src/command/template/initialize.txt,目标是给未来会话留下经过核验的仓库说明。这是用户触发的文档维护,不是每轮结束后自动提炼经验。模型没有执行 /init 或没有主动修改文件时,旧会话不会自行变成项目记忆。
这种设计的好处是透明。项目规则可以进入 Git,可以由人审查,也能被其他支持 AGENTS.md 的工具读取。限制也同样明显:它适合稳定的构建命令、目录结构和代码规范,不适合保存私密用户偏好,更不能代替对海量历史的语义检索。
Codex:从 Rollout 中抽取并合并经验
Codex 同时具有三层持久数据。AGENTS.md 保存项目指令,rollout JSONL 保存可恢复的会话记录,memories 管线负责从过去的 rollout 中提炼跨会话经验。这三条路径相互关联,但生命周期和注入方式不同。
项目指令的发现位于 codex-rs/core/src/agents_md.rs。Codex 从项目根到当前目录逐层搜索,每个目录只选择一个文件,优先使用 AGENTS.override.md,其次是 AGENTS.md,最后才尝试配置的备用文件名。所有项目文档共用 project_doc_max_bytes 字节预算,越靠后的内容可能被截断。
AgentsMdManager 按当前执行环境选择缓存加载结果,AgentsMdState 再通过 World State 把变化注入上下文。首次加载会发送完整内容,环境选择改变后会发送替换或移除通知。需要注意的是,缓存键不是文件修改时间,在同一环境中只编辑 AGENTS.md 并不保证 Manager 立即重读。
会话记录由 codex-rs/rollout/src/recorder.rs 写入 $CODEX_HOME/sessions 下的 JSONL 文件。消息、工具调用、工具结果、Turn Context、World State 和压缩标记等都可以持久化,用于恢复线程,也为长期记忆抽取提供原始材料。rollout 本身仍然是会话证据,不会自动进入其他线程的模型上下文。
延迟执行的两阶段写入
Codex 的长期记忆功能由 Feature::MemoryTool 控制,配置键为 memories。当前源码中它是稳定功能,但默认关闭。临时会话和非根 Agent 也不会启动写入管线。
当 App Server 成功处理一个带用户输入的 turn/start 后,start_memories_startup_task() 会在后台启动 memories 任务。它不是总结刚刚开始的这一轮,而是扫描其他已经空闲的历史 rollout。默认筛选还会考虑来源、年龄、空闲时间、租约和处理状态,避免读取仍在变化的会话,也避免多个任务重复抽取。
if config.ephemeral
|| !config.features.enabled(Feature::MemoryTool)
|| source.is_non_root_agent()
{
return;
}
第一阶段逐个处理合格 rollout。读取器会过滤 Session 元数据、Turn Context、World State、压缩标记、Developer 消息以及 AGENTS.md 等上下文片段,并对输入执行敏感信息脱敏。抽取模型返回三个结构化字段:
{
"raw_memory": "可复用的事实和经验",
"rollout_summary": "本次会话摘要",
"rollout_slug": "简短标识"
}
结果先进入 $CODEX_HOME/memories_1.sqlite 的 stage1_outputs,不会直接改写最终记忆。这样可以为失败重试、租约、去重和使用次数保留结构化状态。
第二阶段取得全局锁,从候选 Stage 1 结果中选择一批输入,更新 $CODEX_HOME/memories/ 下的原始记忆和 rollout 摘要,再启动一个受限的 Memory Consolidation 线程进行归并。该线程关闭 memories 自身、MCP、协作、插件等能力,并把可写范围限制在记忆目录,最终维护以下核心文件:
| 文件 | 用途 |
|---|---|
raw_memories.md | 汇总第一阶段抽取出的原始记忆 |
rollout_summaries/*.md | 保存各个历史会话的摘要 |
MEMORY.md | 经过整理、可继续查阅的长期记忆 |
memory_summary.md | 新线程自动获得的精简入口 |
写入分成抽取与合并两阶段,是因为“这次会话有什么值得记住”和“新经验怎样并入已有知识”是两个问题。前者可以按 rollout 并行处理,后者必须看全局,删除重复或过时信息。后台执行则避免记忆维护阻塞用户正在进行的 Turn。
读取采用渐进式展开
启用 memories 且 use_memories 为真时,codex-rs/ext/memories/src/extension.rs 会注册读路径。新线程不会把整个记忆目录塞进 Prompt,而是读取 memory_summary.md,最多注入 2500 Token 的 Developer Policy。提示词再告诉模型何时查阅 MEMORY.md、相关 rollout 摘要和技能目录。
这是一种渐进式展开:先用很小的摘要判断是否存在相关经验,需要时再读取详细文件。配置 dedicated_tools = true 后,模型还可以使用 memories.list、memories.read、memories.search 和 memories.add_ad_hoc_note 等专用工具。不开启专用工具时,模型仍可通过普通文件工具访问记忆目录。
Codex 的 compaction 与 memories 没有共用同一条数据流。compaction 修改当前线程的有效历史,memories 则跨线程异步抽取。第一阶段还会明确过滤压缩标记,不会把一份为了续接当前任务而生成的摘要直接当成长期经验。
这套机制比保存全部聊天记录更接近“学习”,同时也带来更高成本。抽取和合并都要调用模型,记忆不会在当前会话结束后立刻可用,而且错误结论一旦被合并,可能影响未来许多线程。默认关闭说明它不是无成本的基础能力,而是一项需要用户主动接受的持久化策略。
设计长期记忆时真正要决定什么
三套实现的差异首先来自产品边界。MAF 面向多用户应用,因此把作用域和存储交给宿主显式配置;OpenCode 面向项目开发,把稳定规则保存在可审查的文件中;Codex 面向持续使用的编码助手,除了项目指令,还尝试从历史工作中自动提炼经验。
| 维度 | MAF | OpenCode | Codex |
|---|---|---|---|
| 会话恢复 | ChatHistoryProvider,后端由应用选择 | SQLite Session | rollout JSONL |
| 上下文压缩 | CompactionProvider 或 Reducer | Session compaction | 当前线程 compaction |
| 项目指令 | 宿主显式传入,运行时不自动读取 AGENTS.md | V2 加载全局与项目 AGENTS.md | 加载 AGENTS.override.md 或 AGENTS.md |
| 跨会话召回 | 向量检索或外部 Memory Provider | 没有自动跨 Session 检索 | memory_summary.md 加按需读取 |
| 长期记忆写入 | 调用成功后由 Provider 保存 | /init 等显式维护项目文档 | 历史 rollout 两阶段后台抽取 |
| 主要作用域 | 应用、Agent、用户、Session 可组合 | 项目目录与 Session | 当前 Codex Home 下的用户级记忆 |
如果应用需要按用户检索过去的偏好,MAF 式 Provider 更容易建立清晰的数据隔离,但必须正确设置搜索作用域,并为删除、更正和审计提供接口。如果目标只是让编码 Agent 掌握仓库命令与约定,AGENTS.md 更简单,也更容易进入团队评审。只有确实需要从大量历史任务中归纳经验时,Codex 式抽取与合并才值得承担额外的模型调用和治理成本。
无论选择哪种路线,以下约束都不能省略:
- 写入范围:只保存完成且可信的交互,失败调用、未经核验的模型猜测和外部不可信内容不应直接沉淀。
- 读取范围:用户、组织、项目和 Agent 的边界必须进入存储过滤条件,不能只依靠 Prompt 约束模型。
- 召回预算:自动检索简单但每轮有成本,按需工具更节省上下文,却依赖模型正确判断何时回忆。
- 更新策略:偏好和项目状态会变化,系统要能处理新旧记忆冲突,而不是只追加不更正。
- 可见与可删:用户应该知道系统保存了什么,并能定位来源、修改错误内容和彻底删除敏感信息。
记忆系统最危险的失败不是“忘了”,而是“记错了还长期相信”。会话历史强调完整,压缩强调续接,项目指令强调稳定,长期记忆强调选择。把四者分开,再明确写入、作用域、召回和清理,Agent 才不只是把旧文本保存下来,而是在可控边界内重新使用过去。
本文对应的主要源码入口如下:
- MAF:
src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs、src/Microsoft.Agents.AI.Abstractions/AIContextProvider.cs、src/Microsoft.Agents.AI.Abstractions/ChatHistoryProvider.cs、src/Microsoft.Agents.AI/Memory/ChatHistoryMemoryProvider.cs。 - OpenCode:
packages/core/src/instruction-context.ts、packages/core/src/session/sql.ts、packages/core/src/session/compaction.ts、packages/opencode/src/session/instruction.ts。 - Codex:
codex-rs/core/src/agents_md.rs、codex-rs/rollout/src/recorder.rs、codex-rs/memories/write/src/phase1.rs、codex-rs/memories/write/src/phase2.rs、codex-rs/ext/memories/src/extension.rs。