前言
写这篇文章主要是想记录自己从零开始探索 Agent 记忆系统的过程经历,同时把记忆系统如何设计以及设计过程中的踩坑经验整理为文章分享给大家。
内容会分多期展开。这一篇先从基础讲起,介绍记忆的种类、各自的作用,记忆系统的存储设计
1. 为什么 Agent 需要记忆
大熊开发的博客系统出现了登录超时。Agent 检查了数据库连接池,提出了调整方案,但效果还没验证。
第二天,大熊打开新会话,说「继续昨天的排查」,如果 Agent 缺少记忆能力,大熊需要从头到尾再交代一遍事故发生的背景,反之 Agent 能根据昨天的记忆快速进入状态解决问题。
避免重复尝试无效方案
假设后续排查中,大熊又试了两个方案。
| 尝试过的方案 | 当时观察到的结果 |
|---|---|
| 调大数据库连接池上限 | 调整后仍然出现登录超时 |
| 重启服务 | 暂时恢复,但问题之后再次出现 |
一周后,问题再次出现。如果没有记忆,之前的尝试就没有留下记录。那么再次排查同样的问题将会消耗的很多无意义的 Token 成本与时间成本。
记忆让后续排查可以参考已有结果。不过,一次调整无效,还不足以断定连接池完全没有问题,保存时需要保留当时的条件。
总结:为什么 Agent 需要记忆?
因为 Agent 可以通过记忆能力记住项目背景、用户偏好、任务进度,以及之前尝试过的方案和结果,并在后续对话中取回相关信息,来减少重复沟通和无效尝试,让任务能够接着推进。
2. 记忆的种类
在我们设计记忆系统时,一般将记忆分为三类工作记忆、语义记忆和情节记忆。三者分别保存当前任务状态、已知信息和过去的经历。
工作记忆
工作记忆保存当前任务的状态,帮助 Agent 接上进度。包括当前目标、 已完成步骤、待办事项
阻塞问题、待验证判断、临时要求 等。
语义记忆
语义记忆用于保存可以复用的事实、偏好和经验、知识,帮助 Agent 了解用户与项目背景
| 类型 | 含义 |
|---|---|
FACT | 用户身份、背景、环境(技术栈、项目名) |
PREFERENCE | 工作习惯、偏好设置 |
RULE | 用户明确要求的约束(命名规范、目录结构、命令规范) |
SKILL | 掌握的工具/语言/框架,或完成任务的有效方法 |
ERROR | 踩过的坑、常见错误模式 |
EXPERIENCE | 可复用的成功操作流程、解决方案 |
情节记忆
情节记忆保存某次任务的经过与结果,帮助 Agent 回顾之前做过什么,以及当时为什么这样做。包括
时间与目标、执行动作、观察到的现象、决策依据、尝试结果、 未完成事项。
3. 记忆的作用
工作记忆:记录任务状态,支持中断后的继续执行
让 Agent 帮忙开发功能时,一次会话里未必能把所有事情做完。
比如,你让 Agent 给管理后台增加 Excel 导出。接口和按钮已经写好,联调时发现导出的数据没有按页面上的筛选条件过滤。当天先停在这里,第二天再说一句「继续昨天的导出功能」。
假设工作记忆中保存了这些内容:
当前任务:给订单列表增加 Excel 导出
已完成:编写导出接口,添加前端按钮
验证结果:能够下载文件,但导出数据未按筛选条件过滤
待确认:前端是否传递筛选参数,后端是否应用筛选条件
下一步:检查导出请求中的参数,再沿调用链定位问题
Agent 读取后,就能从筛选条件失效的位置继续检查。
这里的关键是区分计划、执行动作和验证结果。「准备编写接口」和「接口已经写好」代表不同的进度;文件能够下载,也不代表导出功能已经符合要求。如果只记住用户最后一句「增加 Excel 导出」,Agent 仍然不知道该从哪里接着做。
语义记忆:保存事实与偏好,为回答和行动提供背景
让 Agent 帮忙开发前端页面时,我们通常希望它沿用项目已有的组件和交互方式。
比如,之前的开发中已经明确了这些约定:
项目背景:前端使用 React 和 TypeScript
组件约定:表单、弹窗和消息提示复用项目已有组件
交互规则:提交期间禁用按钮,提交失败保留已填写内容
界面要求:页面需要同时适配电脑和手机
下次让 Agent 增加一个「邀请成员」页面,这些信息就能继续发挥作用。
它可以先查找现有表单组件,按已有方式处理提交和错误提示,并在布局时考虑手机屏幕。用户不必每次都补一句「用我们自己的组件」「失败后别把输入清空」。
语义记忆保存的是这些能够跨任务使用的项目背景、开发约定和交互规则。它不需要还原之前每个页面的开发过程,也能为新任务提供依据。
这些信息还需要有适用范围。例如,管理后台和产品官网可能采用不同的组件与布局约定,不能因为都属于同一个项目,就混着使用。项目更换组件库后,也要更新对应记录,避免 Agent 继续照着旧方案开发。
情节记忆:保留事件经过,为类似问题提供经验参考
开发时,让 Agent 批量修改代码也很常见,比如把一个旧接口名替换成新名字。
假设有这样一次修改:Agent 直接做了全文替换,把测试中用于模拟旧客户端的请求样例也改了。后来检查变更时,才发现这些样例需要保留旧接口名。
如果没有情节记忆,那么 Agent 只会记住一句:
不要使用全文替换。
这条记忆对于 Agent 来说就极有可能造成混淆与错乱。下次如果只是修改普通变量名,全文替换仍然可能合适。
情节记忆可以保存当时的具体经过:
任务:将业务代码中的旧接口名迁移为新接口名
执行动作:对搜索到的内容做全文替换
发现的问题:兼容性测试中的旧客户端请求样例也被修改
后续处理:恢复这些样例,检查其余修改是否符合迁移范围
当次经验:同一个名字在业务代码和兼容性测试中承担不同作用
下次再遇到类似迁移,Agent 就有依据先检查匹配位置的用途,再确定修改范围。
情节记忆的价值,在于保留当时的条件、采取的动作和观察到的结果。Agent 可以参考这次经历,同时判断当前任务是否具备相同条件。
4. 记忆系统的存储设计
明确了三类记忆的职责,接下来就要考虑怎么把它们保存下来。
存储设计需要支持后续使用:当前状态能够更新,事实能够修改,历史经过能够追溯,相关内容能够检索。
在这个项目中,三类记忆分别保存在 SQLite 的三张表里:
| 数据表 | 保存的内容 | 主要维护方式 |
|---|---|---|
scratchpad | 当前任务状态 | 随任务推进更新 |
memories | 事实、偏好和可复用知识 | 按独立记录新增、修正或失效 |
episodes | 某次任务的经过与结果 | 按任务阶段整理,必要时补充结果 |
这里先采用 SQLite,方便在本地 Agent 中保存和管理数据,不需要单独部署数据库服务。对于需要多台服务共享数据、存在大量并发写入的系统,则需要重新评估存储方案。SQLite 官方文档也说明了这些适用边界。
下面的 Python 片段沿用项目现有模型,省略枚举定义、序列化方法和部分字段,用来说明存储结构。
工作记忆保存一份可更新的当前状态
工作记忆可以用 ScratchpadEntry 表示:
@dataclass
class ScratchpadEntry:
user_id: str
workspace_id: str
updated_at: str
content: str = ""
current_focus: str = ""
active_projects: list[str] = field(default_factory=list)
open_questions: list[str] = field(default_factory=list)
next_steps: list[str] = field(default_factory=list)
current_focus 保存当前任务,open_questions 保存还没解决的问题,next_steps 保存下一步计划。content 则补充已经完成的步骤、观察结果等信息。
例如,调整连接池后仍然超时,就应该把这个结果写入状态,同时更新下一步计划。已经解决的阻塞问题也需要移除,否则下次读取时,Agent 还会把它当作待办。
项目中的 scratchpad 表以 (user_id, workspace_id) 作为联合主键,因此同一用户在同一工作区内维护一份工作状态。
这个结构适合维护工作区的整体焦点。如果后续允许多个任务同时运行,就需要进一步按 task_id 区分状态,避免两个任务互相覆盖进度。
语义记忆保存为独立记录
事实和偏好适合拆成独立记录,方便单独查询、修改和删除。
@dataclass
class Memory:
id: str
content: str
created_at: str
type: MemoryType = MemoryType.FACT
source: str = "manual"
user_id: str = "default"
workspace_id: str = "default"
subject: str = ""
predicate: str = ""
source_episode_id: str | None = None
superseded_by: str | None = None
expires_at: str | None = None
例如,「博客后台使用 PostgreSQL」可以保存为一条记录:
id:memory_001
content:博客后台使用 PostgreSQL
subject:博客后台
predicate:使用的数据库
workspace_id:blog_backend
content 供 Agent 阅读,subject 和 predicate 帮助系统定位同一主体的同一属性。
另外几个字段负责保存这条记忆的状态:
| 字段 | 用途 |
|---|---|
source | 区分手工写入、自动抽取等来源方式 |
source_episode_id | 关联提炼这条记忆的历史情节 |
superseded_by | 指向替代当前内容的新记录 |
expires_at | 标记有明确有效期的内容 |
subject | 记忆描述的主体 |
predicate | 主体的属性或者与其他主体的关系 |
例如,旧记录被新记录替代后,可以保留它用于追溯,同时让正常检索排除已经失效的内容。
这里还要注意,source="extraction" 只说明记录来自于自动抽取,无法定位具体原话。需要追溯到原始材料时,还应关联会话、消息或工具记录。
同时在检索记忆时程序可以通过按主体或关系查找记忆,例如查找「当前项目」的相关事实
情节记忆同时保存概括和关键过程
情节记忆可以用 Episode 表示:
@dataclass
class Episode:
id: str
session_id: str
summary: str
started_at: str
ended_at: str
goal: str = ""
outcome: EpisodeOutcome = EpisodeOutcome.COMPLETED
action_nodes: list[dict[str, Any]] = field(default_factory=list)
linked_memory_ids: list[str] = field(default_factory=list)
summary 保存概括,方便快速判断这段经历是否相关;action_nodes 保存关键动作及结果,供需要时进一步查看。
登录超时的排查,可以整理成下面这样的过程:
目标:定位登录接口超时
动作一:读取连接池配置
结果:获取当前配置,尚未确认根因
动作二:提出调整方案
结果:等待验证
阶段结果:部分完成
这里要保留动作之间的先后关系,也要保留「提出」「执行」「验证」的区别。
模型中的 outcome 默认是 COMPLETED,写入时必须根据实际结果设置。排查还在等待验证,就应该写成部分完成或进行中,不能因为会话结束了,就把任务记成完成。
情节也不必严格对应整场会话。一场会话可能处理多个任务,一个任务也可能跨越多场会话。可以在阶段结束、话题切换或上下文压缩时整理情节,并记录对应的来源。
如果后续从情节中提炼出了可复用经验,就通过 linked_memory_ids 关联语义记忆;语义记忆也可以通过 source_episode_id 指回来源情节。
这样,Agent 取回一条经验后,还能继续查看它是在什么条件下得到的。
正文存储和检索索引分别负责什么
记忆保存下来之后,还需要能找到。
这里把 SQLite 中的正文记录作为主要数据,另外建立检索索引:
flowchart LR
W[工作记忆] --> DB[SQLite 正文存储]
S[语义记忆] --> DB
E[情节记忆] --> DB
DB --> F[全文索引]
DB --> V[可选的向量索引]
Q[当前任务] --> R[按归属和相关性检索]
F --> R
V --> R
R --> C[回读正文并检查有效性]
DB --> C
C --> P[整理为当前上下文]
全文检索适合查找明确出现的词,比如数据库名称、文件名和报错内容。实战中可以使用 SQLite FTS5 建立全文索引;索引效果还受到分词方式影响,中文内容需要单独验证。FTS5 官方文档介绍了其索引和分词机制。
向量检索则用于补充表达不同、含义相近的内容。例如,用户问「上次登录卡住是怎么处理的」,历史记录写的是「排查登录接口超时」,两句话可能没有完全相同的关键词。
索引返回候选记录后,还需要回读正文,检查它是否已经过期、被替代,以及是否属于当前用户和工作区。
按照这个设计,索引需要跟随正文维护:修改内容后更新索引,删除内容后清理索引。索引暂时不可用时,可以通过正文数据重新构建。
归属和更新规则要一起设计
存储时还需要明确:这条记忆属于谁,适用于哪个项目,什么时候不再有效。
用户的交流偏好可能适用于多个项目,数据库配置通常只属于某个工作区。它们需要有明确的作用域,检索时也要按作用域过滤,避免把一个项目的事实带入另一个项目。
情节记忆同样需要确定归属。实战中现有模型通过 session_id 关联会话;如果要直接跨会话检索情节,就需要保证查询能够沿会话确定用户和工作区,或者在情节记录中补充对应字段。
三类记忆的更新规则也有所不同:
- 工作记忆跟随当前任务更新,任务结束后清理当前状态。
- 语义记忆在信息变化时修正,过期或被替代的记录不再作为当前事实使用。
- 情节记忆保留当时的经过;后来有了新结果,可以补充关联记录,避免把历史判断改写成当时已经知道的结论。
从零实现时,可以先完成这三张表的写入、读取、更新和删除,再验证同一个任务能否在新会话中接着推进。基础行为明确之后,再加入全文检索和向量检索,逐步解决“保存了很多记忆,却找不到有用内容”的问题。
5.答疑工作记忆
最后可能很多人有疑问,有上下文为什么还需要工作记忆: 其实对于短对话、步骤少的任务,单靠上下文通常就够了。上下文本身就可以承载工作记忆。
这两个概念关注的层面不同:
- 上下文关注模型这次能看到哪些信息,包括指令、聊天记录、工具结果和检索内容。
- 工作记忆关注当前任务需要维持哪些状态,例如目标、已完成步骤、验证结果和待解决问题。
工作记忆的价值,主要在任务变长之后。
随着代码、日志和讨论不断增加,任务进度会分散在大量记录中。系统可以把其中仍然有效的状态整理出来:
当前目标:完成订单导出
已完成:接口与按钮修改
验证结果:下载成功,筛选条件未生效
下一步:检查筛选参数的传递和使用
这样,后续执行可以直接参考当前状态,减少反复从历史记录中梳理进度的需要。上下文需要裁剪或压缩时,也有一份明确的任务状态可供保留。但这份状态必须及时更新,否则同样可能误导 Agent。
总结:对于需要持续执行的任务,可以显式维护工作记忆,记录当前目标、执行进度和验证结果。简单任务可以直接依靠上下文;复杂任务则可以通过任务状态管理,让进度更容易维护和恢复。