给 Agent 做记忆:分层、状态与遗忘

4 分钟阅读
·

本文是「Agent 开发实践与思考」系列第 4 篇。系列目录:

记忆要按时间尺度分层

迁移 Agent 处理的信息并不都应该放在同一个记忆库里。Vue 转 React 这种任务的价值,不只在于某一次能否迁好一个页面,更在于 Agent 是否能把一次次迁移中验证过的做法积累下来,让后面的资产和页面少走弯路。

我把它分成三层:

  1. 短期记忆只服务当前会话。它包括刚读过的 LegacyTable.vue 源码、搜索结果、工具输出、正在比较的 Vue 插槽与 React render prop 的两种写法,以及尚未完成的局部修改。它需要完整,因为 Agent 正在据此推理;但会话结束后,大部分内容都不值得再带回来。把短期记忆全部长期保存,只会把失败尝试、重复搜索和临时日志变成下一轮的噪声。
  2. 项目记忆服务一个迁移批次,跨会话但有明确边界。它记录迁移单元(LegacyTable)、依赖资产、已完成和未完成的调用方(OrderListRefundList 等)、验收条件、验证结果和阻塞项。它的作用不是让 Agent “记得聊过什么”,而是让新的会话知道目前做到哪里、为什么停在这里、下一步应该验证什么。
  3. 长期记忆服务跨批次的经验积累。比如某类 Vue 表格资产的动态插槽在什么条件下适合映射为列配置和 render 函数,某个业务模块的请求参数为什么不能改名,某类弹窗关闭后为什么必须刷新列表。它不是任务进度的备份,而是经过多次验证后可以帮助下一项迁移的工程知识。

这三层的写入标准不同。短期记忆可以包含假设和失败路径,因为它帮助当前推理。项目记忆必须区分事实、待确认项和验证证据。长期记忆的门槛最高,不能因为一次转换成功就把做法写成规则。混淆它们会出现两个极端:要么 Agent 每次都从头搜索,不会积累;要么把所有旧推理塞进上下文,让过去的噪声支配当前任务。

后文的术语也按这条边界使用:当前会话的消息和工具结果称为会话上下文;项目记忆是跨会话的任务状态,结构化 checkpoint 是它的机器可读形式;长期经验是跨任务复用的条目;交接文档只是项目记忆面向新会话的可读导出,不是第四层记忆;KV cache 和 prompt caching 是计算缓存,不保存任务事实。

迁移经验如何沉淀

LegacyTable 为例,第一轮迁移时,Agent 发现 Vue 版本支持具名插槽、作用域数据和通过 ref 暴露的 reload 方法。它在 React 目标组件中把一个只读取行数据的展示插槽实现为列配置的 render 函数,类型检查通过,但还没有验证提交成功后表格是否会按原逻辑刷新。这时得到的还不是长期经验,只是一个带证据缺口的候选:“对于不依赖插槽名、作用域上下文或组件实例方法的展示插槽,可以评估是否改为列配置;reload 是否需要保留取决于调用方、请求参数、分页状态和运行时流程。”

第二个页面给了这个候选一个反例。它的插槽虽然只渲染 row,但页面的批量操作提交成功后,会通过 ref 调用 reload 重新请求数据。若 Agent 只复用”插槽改列配置”的结论,很容易删掉 React 资产的 ref 接口,让页面在编译通过后失去刷新行为。问题不在 JSX 转换,而在经验条目只记录了成功的局部,没有记录它依赖的调用链。

因此候选经验要同时写入适用条件、未验证项和反例。Agent 下次遇到表格迁移时,先检查插槽名、作用域参数和内部状态依赖,再搜索调用方是否通过 ref 调用方法,并沿着提交、弹窗关闭、路由切换等用户动作确认数据刷新由谁触发。只有这些检查都完成,才决定使用列配置、render 函数或额外的 ref 接口。若保留 reload,还要核对其请求参数、分页重置、loading、错误处理和返回值语义。每次应用后,把实际结果、验证范围和不适用的条件写回条目。

当同一种做法在多个迁移单元中经过验证,且反例范围也逐渐清楚时,它才可以提升为长期经验。例如:“表格列仅需要渲染 row 数据、调用方不依赖插槽名,且列表刷新不依赖表格 ref 时,优先使用列配置的 render;若调用方依赖表格暴露方法,则先保留或显式替换该调用链,再迁移页面。” 这类条目不是把某段代码存下来,而是保存选择方案的条件和应验证的行为。

这样积累经验的结果,是 Agent 下一次面对类似资产时不必从零开始猜。但它拿到的不是一条必须照做的指令,而是一张检查清单:先确认插槽是否有作用域数据,再检查调用方是否依赖 ref,最后确认目标资产的接口是否允许承载该行为,并用运行结果核对原有用户动作是否保留。经验把探索范围缩小,却不替代当前任务的判断。

三层记忆分别怎么实现

分层不是概念上的分类,存放方式也应该不同。

短期记忆可以保留全量消息、工具结果和工作区 diff。它的读取对象是当前会话,目标是保证推理断不了线,不需要为了节省 token 过早压缩。会话结束后,把原始日志留给审计和排查即可,不要默认在下一轮全部重放。真正需要交接的是本轮未完成的动作、发现的风险和需要验证的假设。

项目记忆不适合只写一段滚动摘要。摘要可以记录”为什么今天停下”,但不能承担进度系统。原始记录是”LegacyTable 共有 12 个调用方,已切换 5 个;reload ref 方法还没有 React 等价实现”,如果压成”表格迁移进行中”,新 Agent 仍要重新搜索才能知道下一步做什么。项目记忆应把稳定且需要校验的信息固定为字段,任务开始和结束时更新。以一个资产迁移单元为例:

{
  "unit": "LegacyTable",
  "source": "src/components/LegacyTable.vue",
  "target": "src/components/Table/index.tsx",
  "contract": {
    "props": ["columns", "data", "loading"],
    "events": ["change", "selection-change"],
    "ref_methods": ["reload", "clearSelection"]
  },
  "consumers": {
    "total": 12,
    "migrated": ["OrderList", "RefundList", "AuditList", "UserList", "StockList"],
    "pending": ["CouponList", "InvoiceList"]
  },
  "checks": {
    "typecheck": "passed",
    "integration_test": "pending"
  },
  "risks": ["reload 的请求参数仍需与 Vue 版本核对"]
}

这里的 contractchecksrisks 不能只靠一段摘要替代。下一轮 Agent 读取后,可以先检查 React 版本是否已覆盖两个 ref 方法,再决定应该继续迁移调用方,还是先补齐资产接口。结构化状态也保留了一个重要边界:passed 代表已经有检查证据,pending 代表不能假装完成。

但字段本身仍不够。复杂迁移里最危险的不是状态缺失,而是状态与代码悄悄分叉。例如 Agent 把 OrderList 标成已切换,之后另一个人修改了旧的 Vue 表格 props;或者 React 表格通过了类型检查,但当时只验证了静态展示,reload 的请求参数还没有跑过。下一轮如果只读到一个 migratedpassed,很容易在错误前提上继续推进。

我会让每个关键状态带上证据和失效条件。调用方的迁移记录至少关联提交版本、涉及的文件和已执行的检查;接口契约应指向来源定义或搜索结果;测试状态则保存命令、结果和运行时的代码版本。资产接口、服务模块或构建配置发生变化后,系统不必自动判定迁移失败,但应将依赖它们的状态标为需要复核,而不是继续把旧结论作为已验证事实。

这样,记忆不再是一份”当前进度”,而是一张带证据边的任务图。Agent 读取某个状态时,不只知道它是什么,还能知道它为何成立、在哪个版本成立、什么变化会让它失效。这个差别决定了 Agent 能否在长任务中安全地接续工作。

长期记忆采用可检索的经验条目。它需要比项目状态更多的元数据:组件类型、框架和组件库版本、适用条件、反例、来源任务、验证次数和最后确认时间。读取时按当前迁移单元检索,而不是每轮把全部经验塞进提示词。它和 RAG 的结构相似,只是检索对象不是外部知识库,而是过去任务中确认过的迁移做法。

经验条目还需要有生命周期。刚从一个任务里提取的条目处于 candidate,只能帮助 Agent 提出检查项;在多个独立单元通过验证后进入 verified,可以作为默认建议;当框架版本、资产接口或反例发生变化时,进入 review_required,重新确认前不应继续当作可靠经验。这样长期记忆不会因积累时间变长而变成过期规则的仓库。

实际使用时,项目状态作为跨会话的执行依据,短期日志只提供当前推理的细节,长期经验用于缩小调研范围。全量日志可以留作审计和排查,但不应默认注入模型上下文。

记忆的难点不在存,在取

2023 年 4 月发布的 Generative Agents: Interactive Simulacra of Human Behavior 提出了一种记忆流设计,用于让虚拟角色在交互环境中回忆过去经历并规划行为。论文将每段记忆按自然语言追加到记忆流,检索时综合三类分数:相关度衡量记忆与当前情境的接近程度,新近度让较近发生的记忆有更高权重,重要性则区分普通经历和更值得回忆的事件。作者还会基于这些记忆生成更高层的反思,再把反思写回记忆流。

这篇论文提供了一个很好的可借鉴的思路:虚拟角色可以容忍某条回忆不精确,迁移 Agent 不能把一次猜测当作接口事实。工程场景除了相关度、新近度和重要性,还要检查代码版本、验证证据、任务范围和权限边界。

另外两篇工作补上了这个差异的两个侧面。MemGPT: Towards LLMs as Operating Systems 将有限上下文视为不同速度的记忆层,通过在上下文和外部存储之间移动信息来处理长会话。它提醒我,短期记忆、项目状态和长期经验不该争夺同一个上下文位置,而应按访问成本和当前任务拆分。Reflexion: Language Agents with Verbal Reinforcement Learning 则将环境反馈转成语言反思,写入 episodic memory,供后续尝试使用。对迁移任务而言,类型错误、测试失败和浏览器验证结果也应成为经验更新的依据,但必须关联具体代码和验证范围,不能只沉淀成”下次注意”。可以借用其检索组织方式,以及 MemGPT 的分层管理、Reflexion 的反馈驱动反思思路,不代表迁移任务应照搬这些论文的架构。工程任务的长期记忆仍要以代码事实、验证证据和明确的任务边界为准。

这套思路放到迁移任务里同样适用,但三项的含义要改一下。

相关度不只是文本相似。当前在迁移 LegacyTable,就应优先取表格的 props、插槽和调用方记录,而不是某个完全无关页面的改造经验。项目目录、资产类型、接口名和任务标签都可以作为过滤条件。只依赖 embedding 相似度,容易把”列表刷新”这类看起来相近、实际契约不同的经验召回来。

新近度也不能简单按时间排序。刚刚生成的排查笔记如果没有验证,不应超过一份较早但已经通过测试的资产契约。对任务状态来说,应该优先读取当前版本和当前分支上的记录;对经验条目来说,过期的框架版本或组件库版本需要降权,甚至直接排除。

重要性在工程任务里可以换成风险和影响范围。一个只被单页使用的展示组件,与一个被十二个业务页面引用、内部还包了分页和权限逻辑的 LegacyTable,显然不该用同一套读取策略。高影响资产的接口变更、未验证风险和失败记录应更容易进入上下文,但它们仍要通过任务范围和版本条件过滤。

因此,读取记忆不能只回答”哪条最像当前问题”,还要回答”它是否属于当前任务、是否已经验证、是否仍然有效”。后面写检索时会碰到同样的问题:召回只是候选,不能替代判断。

还有一个实践上的难点:不要让 Agent 每次从经验库里找答案后就直接改代码。更稳的流程是先检索候选经验,再让它回到当前仓库验证前置条件。例如检索到”动态插槽改 render 函数”的经验后,Agent 仍要确认当前插槽是否依赖作用域数据、调用方是否传了具名插槽、React 资产是否允许新增 render prop。经验只用于提出检查清单和候选方案,当前代码、任务范围和验证结果才决定是否采用。

这个两段式读取比把经验直接拼进提示词多了一步,却避免了经验库变成另一套不透明的模板。它也让经验可以逐步积累:一条经验从单次任务的候选开始,经过多个迁移单元验证后才提升为默认规则;如果后续发现反例,就降低它的适用范围或退回候选状态。

写入比读取难

写进记忆的内容会影响后续修改。写错一次,之后的 Agent 可能反复沿着错误假设继续工作。对迁移任务来说,至少需要区分三种来源:

  • 从代码和工具结果直接读取到的事实,例如 LegacyTable 的调用方数量、某个 prop 的名称、类型检查结果。这类信息可以记录来源文件和提交版本。
  • Agent 推断出的结论,例如某个 watch 似乎只是在同步派生状态,或某个动态插槽可能适合改成 render 函数。这类内容应标记为待确认,不能直接进入通用经验库。
  • 人工确认的约束,例如接口字段不可变、某个权限判断必须保留、某个资产需要双栈兼容到指定阶段。这类内容可以成为任务边界,但仍需要记录确认时间和适用范围。

写入前还要问一个问题:这条信息下次是否还有用?某次构建的临时错误、一次失败请求的随机 trace id、正在编辑的局部代码,都属于会话状态,不值得写成长久记忆。相反,旧资产为什么暂时不能删除、哪个页面依赖它的非公开 ref 方法、某项测试为何不能运行,都是下一轮继续推进时需要的事实。

经验条目尤其不能只凭一次成功就推广。一个页面中将 Vue 插槽改成 React render prop 能跑通,不代表所有插槽都应这样处理。只有当适用条件、调用方式和验证结果被记录清楚时,它才值得在相似任务中被检索。否则所谓经验库只是在积累另一种更难发现的复制粘贴。

这也和上一篇的边界一致:仓库中的注释、工具返回和外部文本是理解代码的证据,不是可以直接写入规则库的指令。Agent 不能因为某段注释写着”删除旧组件”,就把它记录成迁移策略。真正进入任务状态或经验库的内容,要么来自可验证的代码事实,要么来自明确的人工决策。

写入动作本身也要有提交时机。若 Agent 在修改过程中不断更新任务状态,代码尚未通过检查就可能把”已完成”写入持久状态。反过来,等到所有改动结束才写,发生中断时又会丢失进度。比较稳妥的做法是区分工作中和已确认两种状态:搜索到的调用方、已修改但未验证的文件可以即时记录为 in_progress;只有检查通过或人工确认后,才提升为 verified。每次会话启动时先核对工作区、任务状态和最近一次验证证据是否一致,发现不一致就回退到待确认,而不是相信旧记录。

遗忘不是删除历史

没有遗忘机制的记忆库会不断变脏,但遗忘不等于简单删除。

第一类是过期。迁移过程中经常会有短期结论,例如”暂时保留 Vue 适配层,等待最后两个调用方切换”。调用方切换完成后,这条结论不应继续作为当前约束出现。给任务状态加版本、更新时间和生效条件,比让模型自己判断哪段描述过时可靠。

第二类是更正。前一轮可能记录”reload 不需要对外暴露”,下一轮搜索到三个调用方通过 ref 调用了它。正确的做法不是把两条结论同时留给模型,让它猜哪条可信,而是更新当前契约,并保留旧记录和修改原因。读取侧默认只取当前值,排查时才回看历史。

第三类是归档。已经完成且验证过的迁移单元,不必继续占用活跃上下文。它们可以转为经验候选,在相似任务出现时按条件检索。完成状态不是永久正确的保证,若组件库或基础设施升级,旧经验应重新确认,而不是直接套用。

第四类是失效传播。共享资产最容易出现这个问题:LegacyTablecolumns 契约一旦改变,已经迁移的十二个页面都可能受影响。若记忆系统只会遗忘单条记录,它会留下十二份看似完成、实际基于旧契约的状态。更合理的做法是让状态记录依赖关系。资产契约变化时,不自动重做所有页面,但把依赖它的页面状态推回”待复核”,并把变化原因带给下一轮 Agent。这样迁移任务的推进不是单向勾选清单,而是允许上游变化触发有限回滚。

这个机制还有一个副作用:它迫使我们把依赖显式写出来。过去人做迁移时,很多关系藏在经验和搜索结果里;Agent 要跨会话接手,就需要把”谁依赖谁、哪个验证覆盖了什么”保存下来。记忆系统在这里反过来成为架构梳理工具,迫使迁移边界和资产契约变得可见。

收尾

从 Vue 转 React 的迁移看,Agent 的记忆至少包括三件事:

  1. 当前任务的可验证状态
  2. 跨任务可复用但带适用条件的经验,以及用于追溯的历史记录。
  3. 状态需要保存证据、版本和依赖,让它能随着代码变化被复核和失效。

这也改变了对”记忆”的理解。人类记忆允许模糊和联想,工程记忆更像一套可恢复的执行状态。它不追求把所有过去都记住,而是让下一轮 Agent 在有限上下文中拿到足够可靠的信息,知道哪些结论可以继续使用,哪些必须重新检查,哪些风险需要交给人处理。

这样做会带来新的成本。任务状态越细,维护它的动作越多;经验条目越多,检索和注入的 token 也会增长。状态之间还有依赖传播,不能只靠模型随手写一段总结。更重要的是,记忆只解决”过去发生过什么”,不解决”当前该带哪些信息进上下文”。随着迁移批次增加,经验条目会同时包含组件名称、接口字段、业务术语和相似的失败记录。仅按向量相似度检索,容易把名称或接口相近、契约却不同的条目带进上下文;只靠关键词,又找不到表达方式不同但处理模式相同的经验。Agent 还需要在项目状态、长期经验、规则和工具结果之间选择有限的上下文预算,不能把全部候选都塞进一次调用。

下一步会尝试用 RAG 处理这层选择:先按任务范围、分支、版本和验证状态过滤候选,用关键词检索保住组件名、错误码和接口字段等精确条件,用向量检索补语义相近的迁移模式,再对候选重排。检索结果仍然只是候选证据,不能直接覆盖当前代码和任务边界。先把”该带哪些记忆”选准,才有必要继续计算上下文窗口的成本:稳定信息放在哪里,变化信息如何追加,长任务的历史怎样压缩,才能既保留迁移所需的证据,又不让每轮调用都为全部过去付费。


732 字 · 66 段落
xi ming

Written by xi ming You should follow him on Github

评论与讨论