MyPrototypeWhat

ContextChef (5):Memory——读取零成本,写入结构化

English version: ContextChef (5): Memory — Zero-Cost Reads, Structured Writes

Agent 对用户说”我记得你之前提过更喜欢 TypeScript strict mode”。用户觉得这个产品真的在记事。下次会话,Agent 又问了一遍。

这不是记忆问题,是持久化问题——信息有了,没有活过 session 边界。

记忆不是 RAG

解决记忆问题的第一反应通常是向量数据库:把对话存进去,每次检索相关片段注入上下文。Letta(MemGPT 的继任者)在他们的 Context Engineering 指南 里专门写了一篇 RAG is not Agent Memory 来反驳这个思路。

区别在于访问模式:RAG 的访问是概率性的——当前 query 必须和记忆内容在语义上足够接近,才能召回。但用户的编程语言偏好、项目约定、AI 的角色设定,不应该依赖”这轮对话恰好和它语义相近”才能出现。这些信息应该每次都在,无条件注入。

Letta 把这类信息称为 memory blocks:上下文窗口中的预留区域,有固定的大小上限,由系统自动注入,而不是按需检索。Anthropic 在 Claude Code 里用的是 structured note-taking,让 Agent 维护一个持久化笔记文件,每次上下文重置后先读取笔记再继续工作——他们举的例子是 Claude 玩《精灵宝可梦》:数千步游戏之后,Agent 通过笔记记住了探索过的地图、升级进度、有效的战斗策略,没有这些笔记就完全无法维持长程策略。

设计角度:读取零成本,写入结构化

Memory 的设计核心是一个简单的原则:读取应该是零成本的,写入应该是结构化的

读取零成本是指:memory 的内容在每次 compile() 时自动注入到上下文,开发者不需要在每个 Agent 循环里手动 fetch 记忆、手动拼接到消息里。这件事库来做。好处是:你不会因为忘记写 inject memory 就让模型失忆;记忆的注入位置也是固定的(system prompt 之后、历史消息之前),不会因为开发者各自的拼装方式不同而导致注入位置不稳定。

写入结构化是指:记忆不是通过在消息里堆自由文本实现的,而是通过有明确语义的键值对。这带来两个好处:一是记忆内容可以被程序精确操作(覆盖、删除、查询),不依赖从大段文本里解析;二是注入时生成的 XML 结构化表达(<memory><entry key="lang"><value>TypeScript</value></entry></memory>),对 LLM 的结构理解更友好,比自由文本更稳定。

存储后端可插拔是这个设计带来的额外优势。InMemoryStore 用于测试和快速原型,VFSMemoryStore 用于生产持久化,你也可以实现自定义的 MemoryStore 接口对接 Redis 或数据库。切换存储后端不需要改任何业务逻辑,因为读写记忆的接口是统一的,存储实现被隔离在外。

TTL 与 Selector:两轴控制注入

随着 Agent 运行时间增长,memory 有两个潜在问题:一是条目积累越来越多,注入成本随之增长;二是任务特定的短期记忆(“当前步骤是 3/10”)如果不清理,会一直占据上下文,干扰后续任务。TTL 和 Selector 是针对这两个问题的两轴控制。

TTL:控制记忆的存活时间。 每次写入可以指定生命周期,支持两种表达方式:{ turns: N } 是基于轮次的 TTL——适合”接下来 N 轮之内有效”的任务状态;{ ms: N } 是基于时间的 TTL——适合有时效性的信息(如 API 返回的数据快照)。没有 TTL 的条目永不过期,适合用户偏好、项目约定等长期知识。compile() 时会自动扫描并清除过期条目。

Selector:控制哪些活跃条目参与注入。 即使条目没有过期,也不一定每次都需要注入。selector 函数在 compile() 前被调用,接收全部活跃条目,返回实际要注入的子集——按 importance 字段降序取前 N 个、按 updatedAt 取最近更新的、或结合任务阶段做自定义过滤。importance 是一个可选数值字段,写入时标注,供 selector 排序使用。

两轴的职责清晰:TTL 管”这条记忆还在不在”,Selector 管”活着的记忆里注入哪些”。两者独立配置,也可以组合使用。

缩小模型的决策面,而不是消除它

在设计记忆系统时有一个容易踩的陷阱:让模型决定某条信息应该放进”核心记忆”还是”归档记忆”。

MemGPT 把两个 tier 暴露为命名不同的工具——core_memory_replacearchival_memory_insert,通过工具描述引导模型做分类,同时给 core memory 设了硬字符上限,满了就写不进去,逼模型思考”这条信息真的需要一直在吗”。这是一种有效的缓解,但本质上还是在用 prompt 和工具描述引导 LLM 分类,模型选错层级的可能性依然存在,而且这类错误极难复现和调试。

公平地说,ContextChef 也没有从根本上解决这个问题——模型仍然在决定”要不要写”、“写什么内容”。ContextChef 做的是缩小模型需要决策的范围:tier 由调用路径固定,模型只需要决定写不写,不需要判断层级。这把一个难以观察的分类决策(“core 还是 archive?“)变成了一个可预测的行为——写进来的,永远是 memory。代价是灵活性:如果你需要模型自主决定分层,这个设计不适合你。

具体来说:模型通过 create_memory / modify_memory 工具写入的,永远是 memory 层级;开发者通过 chef.getMemory().set() 写入的,默认也是 memory。接口设计本身锁死了层级,不留给模型选择的余地。

写入协议:create + modify 分离

记忆写入通过两个工具完成:create_memory(创建新条目)和 modify_memory(更新或删除已有条目,通过 action 枚举区分)。

为什么不合并成一个工具?因为拆开之后,每个工具的参数约束更紧。create_memory 的 key 是自由字符串——模型需要命名新条目;modify_memory 的 key 是已有 key 的枚举——模型只能从现有条目里选,不会凭空编造一个不存在的 key 去更新。更关键的是,modify_memory 只在存在已有条目时才会出现在工具列表中;如果记忆是空的,模型只看得见 create_memory,不会产生”更新一个不存在的条目”这种无效调用。

compile() 会自动把这两个工具的定义注入到 payload 的 tools 列表中,开发者不需要手动注册。读取方式同样是零成本——compile() 自动注入,模型直接看见,不需要任何工具调用。

Memory 模块有三个独立钩子:onMemoryUpdate(否决 createMemory / updateMemory / deleteMemory 写入)、onMemoryChanged(全量变更通知)、onMemoryExpired(TTL 过期通知)——三者职责和触发时机各不相同,详见 ContextChef (8):编译管道里的五个扩展点


下一篇:Snapshot & Restore——Manus 说要保留错误记录,但有时候你就是需要撤回。