给 AI Agent 做记忆:Fawn 的三层实践
上一篇写 Fawn 的 Agent Harness Engineering,讲的是工具路由、权限、确认和短期任务状态。那篇更偏“怎么让 Agent 稳稳地办事”。这篇换一个问题:Agent 到底应该怎么记东西。
Fawn 是我给自己家宝宝做的中文育儿 Agent。这个场景有点特殊,因为它不是单纯聊天。它要知道宝宝是谁、家里有哪些人、谁有什么权限,也要理解“刚刚喝了奶”和“上周补录了一条喂养记录”不是一回事。
一开始我也很容易把这些都叫作 memory。最近对话是 memory,宝宝档案是 memory,用户偏好是 memory,RAG 知识库好像也算 memory。叫起来很顺,但做起来会乱。
后来我给 Fawn 拆成了三层:
- 短期记忆
- 长期记忆
- 全量存档
这不是一个多复杂的理论。它更像我在做这个 Agent 时慢慢整理出来的一套实践:先别急着问“怎么加记忆”,先问这条信息到底属于哪一种记忆。
memory 这个词太大了
做 Agent 的时候,“记忆”很容易变成一个万能词。什么都往里放,最后什么都说不清。
育儿场景里尤其明显。
有些信息强依赖时间:
刚才喂了 90ml
昨晚睡了多久
今天早上体温是多少
这些内容如果不带时间锚点,Agent 很容易答错。“刚刚喝了 90ml”和“昨天补录了 90ml”在数据库里都可能是刚写入,但对用户当前问题的意义完全不同。
还有一些信息比较稳定。比如宝宝的基本情况、家庭成员关系、父母希望 Agent 怎么回答、哪些提醒不要太啰嗦。这些不应该靠最近几轮聊天临时凑,也不适合每次从一堆业务表里散查。
再往后,是完整历史。全部聊天、所有喂养睡眠记录、RAG 知识库、历史摘要。它们当然有用,但不能默认塞进 prompt。上下文不是越多越好,塞多了反而会把真正有用的东西冲淡。
所以我把 Fawn 的记忆拆成三个问题:
短期记忆:刚刚发生了什么?
长期记忆:这个家庭长期是什么样?
全量存档:历史上完整发生过什么,需要时怎么查?
这个拆法比“我要给 Agent 加 memory”更可操作。
短期记忆:别让 Agent 只看当前这句话
短期记忆的第一部分还是最近对话。
Fawn 每次处理聊天,会加载当前会话最近的消息,并带上时间、相对时间、说话人、家庭角色和权限。这样模型看到的不是一句裸文本,而是一个有上下文的对话片段。
这点在育儿场景里很有用。用户经常不会把话说完整:
那刚才那次算多吗?
不是,是 90ml
晚 8 点到凌晨 3 点
这些话单独看都不完整,但放回最近对话里,人一眼就知道在接着哪件事说。Agent 也应该尽量接近这种理解方式。
这次重构里,我又加了一块短期上下文:最近确定性数据。
比如用户刚刚记录了喂养、睡眠、生长或健康数据,Fawn 会把最近 24 小时内更新过的记录放进当前上下文。最多取 12 条,不追求多,够用就行。
这里我特别保留了两种时间:
- 业务时间:事情实际发生的时间,比如喂奶时间、睡眠开始时间、测量日期。
- 写入/更新时间:用户什么时候把它写进系统。
这个细节看起来小,但很容易影响回答。用户刚补录了一条昨晚睡眠,和宝宝刚刚睡醒,不应该被 Agent 混成同一类“最近事件”。
实现上,这块叫 RecentDeterministicContext。它会从生长、喂养、睡眠、健康记录里取最近更新的数据,拼成单独的上下文块:
<recent-deterministic-context>
...
</recent-deterministic-context>
我没有把它揉进最近聊天摘要里。聊天记录是对话事实,确定性数据是产品数据库里的事实。它们都短期相关,但来源和可信度不一样,最好一开始就分开。
长期记忆:按家庭放进 Markdown
长期记忆是这次重构里改动最大的一块。
Fawn 的基本单位不是单个用户,而是一个家庭。所以长期记忆也按家庭隔离:
memory/
families/
<family_id>/
Soul.md
Memory.md
Baby.md
users/
<user_id>.md
我最后选 Markdown,不是因为它高级,而是因为它朴素。它能被人读懂,能手动改,调试时也不需要先写一堆查询。对这种长期语义记忆来说,这些优点很实在。
现在 Fawn 主要有四类长期记忆:
| 文件 | 上限 | 放什么 |
|---|---|---|
Soul.md | 3000 字符 | Agent 在这个家庭里的定位、回答原则和长期行为要求 |
Memory.md | 3000 字符 | 家庭结构、共同偏好、历史摘要和家庭级上下文 |
Baby.md | 2000 字符 | 宝宝相关的长期记忆,以及同步后的结构化宝宝档案 |
users/<user_id>.md | 1000 字符/人 | 每个家庭成员自己的画像和偏好 |
这些文件不用在创建家庭时一次性生成。Agent 读取长期记忆,或者前端打开家庭记忆列表时,再懒初始化就可以。
这里有很强的 Fawn 业务痕迹。Baby.md 之所以单独存在,是因为育儿 Agent 的核心对象就是宝宝。换成销售 Agent,这个位置可能是 Account.md;换成学习 Agent,可能是 Student.md;换成项目 Agent,可能是 Project.md。
三层结构可以直接借鉴,但每层放哪些领域对象,要按自己的业务重新设计。
Baby.md 不能变成数据库
Baby.md 是我最不想做糊的一块。
Fawn 原来就有结构化宝宝档案,比如出生日期、出生体重、出生身长、头围、是否早产、孕周。这些字段会被 dashboard、tracker 和其他功能使用,所以数据库必须继续是权威来源。
如果为了“记忆系统统一”,把这些字段都迁进 Markdown,让用户和模型自由编辑,后面一定会出问题。页面展示用一个版本,统计计算用另一个版本,Agent 上下文里又是第三个版本。听起来就很难收拾。
所以我做的是双层同步:
- 数据库仍然保存宝宝结构化档案,并且是权威来源。
-
Baby.md作为 Agent 的长期上下文读取入口。 - 宝宝档案更新时,系统把结构化字段同步到
Baby.md的固定区块。
固定区块用 marker 包起来:
<!-- FAWN:BABY_PROFILE:START -->
## 结构化宝宝档案
- 姓名: ...
- 性别: ...
- 出生日期: ...
- 出生体重: ...
- 出生身长: ...
- 出生头围: ...
- 是否早产: ...
- 孕周: ...
- 档案同步时间: ...
<!-- FAWN:BABY_PROFILE:END -->
## 宝宝记忆
...
这样 Agent 读 Baby.md 时能拿到最新基础档案;用户也可以在 ## 宝宝记忆 下面写自由文本,比如“最近对刷牙很抗拒”“晚上更容易被爸爸哄睡”。这些自由文本会进入长期语义记忆,但不会反向覆盖数据库。
这条边界我觉得很重要:
Markdown 是 Agent 可读、用户可改的长期记忆界面。
数据库才是确定性字段的事实源。
不要让“可编辑”变成“事实来源可以随便漂移”。
MemoryCurator:让模型参与,但别放任它写
长期记忆如果完全靠用户手动维护,会很累,也不像一个会成长的 Agent。
但让模型随便写,也不行。它可能把一次性闲聊写成长久偏好,把低置信推断写成事实,还可能不断追加重复内容。记忆一旦变脏,后面每次 prompt 都会带着这点脏东西。
Fawn 现在用 MemoryCurator 处理这件事。每轮对话结束后,它异步判断是否需要更新长期记忆。
它能做的动作被限制住:
no_changeappendupdatecompressdelete_obsolete
能写的目标也被限制住:
Soul.mdMemory.mdBaby.mdusers/<user_id>.md
模型可以判断“这句话值不值得沉淀”,但系统要限制它“能写到哪里、用什么动作写”。这跟上一篇里说的 harness 思路是一致的:模型负责理解,产品系统负责边界。
我给 MemoryCurator 定了几条规则:
- 用户明确说“记住”“以后请”“记录一下”,只要内容安全,就不能静默忽略。
- 如果是在纠正已有事实,应该
update,不要追加一条矛盾记忆。 - 确定性 tracker 数据不写进长期语义记忆。
- RAG 外部知识不写进家庭记忆。
- 低置信推断和一次性闲聊应该
no_change。 - 医疗、用药、隐私这类敏感内容,不轻易自动写入。
还有一个兜底:如果模型返回 no_change,但用户这句话明显是在要求记忆,系统会自己选一个默认目标。
大致规则是:
- 说的是以后怎么回答,写
Soul.md - 说的是宝宝,写
Baby.md - 说的是家庭关系或家庭偏好,写
Memory.md - 其他偏个人的内容,写当前用户的
users/<user_id>.md
这不是很精致,但很实用。用户明确要求“你记住”,Agent 不应该因为 curator 一次判断保守就装作没听见。
记忆要让用户看得见
长期记忆如果只藏在后端,调试会很痛,用户也很难纠正 Agent。
所以 Fawn 的家庭 tab 里不再保留旧的“个性化记忆 / 家庭记忆 / 宝宝档案”几个分散入口,而是统一展示长期记忆文件:
SoulMemoryBaby对 XXX 的记忆
父母账号可以查看和编辑,其他用户只能查看。前端不会暴露 Memory.md、users/<id>.md 这种文件路径,而是显示人能看懂的名字。
点击进去之后,默认是 Markdown 渲染结果;有权限的人可以切到编辑模式。保存时后端仍然会做字数裁剪。Baby.md 保存时会保留结构化宝宝档案区块,避免用户手动编辑时把同步内容弄坏。
这块看起来像产品 UI,其实是记忆系统的一部分。
因为长期记忆不是只给模型看的。用户能看见、能修改,Agent 的行为才有解释和纠偏入口。
Prompt 里怎么放这些记忆
每次普通聊天时,Fawn 会先加载长期记忆:
## Agent Soul
Soul.md
## 家庭 Memory
Memory.md
## 宝宝档案
Baby.md
## 当前用户画像
users/<current_user_id>.md
这些进入 system prompt,作为稳定背景。
当前用户消息前,再拼短期上下文:
Human:
最近对话上下文
最近确定性数据
当前用户消息
更完整一点,大概是:
System:
安全原则
知识来源规则
数据记录规则
当前对话者
长期记忆
Human:
最近对话上下文
最近确定性数据
当前用户消息
这个结构没有什么花活,主要是让信息各归各位。
长期稳定的东西在 system 里。短期动态的东西跟着当前消息走。确定性数据库记录明确标出来。RAG 和历史消息不默认进来,需要时再查。
这样做之后,Agent 不需要在一大段混在一起的上下文里猜“这句话到底是什么性质的信息”。
全量存档:别默认塞进 prompt
第三层是全量存档。
它包括:
- RAG 知识库
- 聊天历史消息
- 喂养、睡眠、生长、健康等确定性数据库记录
- 历史摘要和可检索记录
这些都重要,但不该常驻 prompt。
用户问“上周三我说宝宝睡眠的问题是什么”,应该查历史消息。用户问专业育儿知识,应该查 RAG。用户问最近喂养趋势,应该查数据库记录。用户只是接着刚才的话题说,那就用短期上下文。
长期记忆不是数据库的压缩版,也不是 RAG 的替代品。它更像 Agent 对这个家庭的稳定语义背景。全量存档负责保留完整事实,需要时由工具或检索系统取出来。
这两个东西分开之后,系统会清爽很多。
可以借鉴什么,别照搬什么
如果你也在做 AI Agent,我觉得可以直接借鉴的是三层拆法:
短期记忆:当前对话附近需要立即理解的信息
长期记忆:长期稳定、适合进入 prompt 的语义背景
全量存档:完整历史和外部知识,需要时检索
但每一层具体放什么,不应该照搬 Fawn。
Fawn 是家庭育儿场景,所以我有 Baby.md、家庭成员画像、喂养睡眠数据、父母权限。企业销售 Agent 可能会放客户画像、账号偏好、合同约束、CRM、邮件和会议记录。学习 Agent 可能会放学生画像、薄弱知识点、作业历史和课程资料。
文件名不重要。真正要想清楚的是:
- 哪些信息只是短期相关?
- 哪些信息应该长期进入 Agent 背景?
- 哪些历史必须完整保留,但只在需要时查询?
- 哪些事实必须由数据库负责,不能被模型总结覆盖?
- 哪些记忆应该让用户看见并编辑?
这些问题答清楚,再决定用 Markdown、数据库、向量检索,还是别的东西。
最后
这次重构后,Fawn 的记忆系统看起来只是多了一些文件和上下文块:
- 最近对话
- 最近确定性数据
Soul.mdMemory.mdBaby.mdusers/<user_id>.md- RAG 和历史数据检索
但真正变化的是边界。
短期记忆解决刚刚发生了什么。
长期记忆解决这个家庭长期是什么样。
全量存档解决历史上完整发生过什么。
Fawn 的文件结构是家庭育儿场景下的取舍,不一定适合所有 Agent。但这个三层拆法,我觉得可以复用。
不要一上来就问“要不要给 Agent 加 memory”。这个问题太大,最后很容易把所有东西搅在一起。
先问:这条信息到底应该住在哪一层?
Enjoy Reading This Article?
Here are some more articles you might like to read next: