给 AI Agent 做记忆:Fawn 的三层实践

上一篇写 Fawn 的 Agent Harness Engineering,讲的是工具路由、权限、确认和短期任务状态。那篇更偏“怎么让 Agent 稳稳地办事”。这篇换一个问题:Agent 到底应该怎么记东西。

Fawn 是我给自己家宝宝做的中文育儿 Agent。这个场景有点特殊,因为它不是单纯聊天。它要知道宝宝是谁、家里有哪些人、谁有什么权限,也要理解“刚刚喝了奶”和“上周补录了一条喂养记录”不是一回事。

一开始我也很容易把这些都叫作 memory。最近对话是 memory,宝宝档案是 memory,用户偏好是 memory,RAG 知识库好像也算 memory。叫起来很顺,但做起来会乱。

后来我给 Fawn 拆成了三层:

  1. 短期记忆
  2. 长期记忆
  3. 全量存档

这不是一个多复杂的理论。它更像我在做这个 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_change
  • append
  • update
  • compress
  • delete_obsolete

能写的目标也被限制住:

  • Soul.md
  • Memory.md
  • Baby.md
  • users/<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 里不再保留旧的“个性化记忆 / 家庭记忆 / 宝宝档案”几个分散入口,而是统一展示长期记忆文件:

  • Soul
  • Memory
  • Baby
  • 对 XXX 的记忆

父母账号可以查看和编辑,其他用户只能查看。前端不会暴露 Memory.mdusers/<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.md
  • Memory.md
  • Baby.md
  • users/<user_id>.md
  • RAG 和历史数据检索

但真正变化的是边界。

短期记忆解决刚刚发生了什么。
长期记忆解决这个家庭长期是什么样。
全量存档解决历史上完整发生过什么。

Fawn 的文件结构是家庭育儿场景下的取舍,不一定适合所有 Agent。但这个三层拆法,我觉得可以复用。

不要一上来就问“要不要给 Agent 加 memory”。这个问题太大,最后很容易把所有东西搅在一起。

先问:这条信息到底应该住在哪一层?




Enjoy Reading This Article?

Here are some more articles you might like to read next: