给 AI Agent 写了个记忆系统,凌晨两点它会自己整理记忆

4 阅读6分钟

最近在搞 AI Agent 相关的项目,发现一个很烦的问题——每次新开对话,Agent 什么都不记得。你跟它聊了三周的项目,切个会话它就问"你这个项目用的什么技术栈"。

框架们把 function calling、RAG 都卷完了,但跨对话记忆这块几乎没人做。

找了一圈发现 Claude Code 自己内部有一个记忆子系统,设计得挺巧妙的——纯 Markdown 文件存储,不用数据库,靠 LLM 侧查询做召回。于是我用 Java 照着这个思路实现了一遍,顺便加了点原版没有的东西:记忆生命周期管理、凌晨自动蒸馏、冲突检测。

仓库在这:github.com/li130/agent…

为什么不用数据库

很多人第一反应是"加个 MySQL 不就行了"。我一开始也这么想的。

后来看了 Claude Code 的方案才明白——Agent 通常跑在开发者本机上,为了记几个偏好装数据库太重了。而且 Markdown 是 LLM 训练数据里占比最高的格式,它"读" Markdown 的准确率天然高于 JSON dump 或 SQL 导出。再一个,记忆文件本身就是 git 仓库的一部分,手滑删错了还能 checkout 回来。

所以就定了:纯文件。一个文件夹,一堆 .md,一个 MEMORY.md 做索引。

data/projects/my-project/
├── memory/
│   ├── MEMORY.md
│   ├── 不要mock数据库.md
│   ├── 偏好响应式编程.md
│   ├── DAILY_BRIEF.md
│   └── .archive/              ← 沉睡的记忆放这里
└── logs/2026/05/2026-05-05.md  ← 白天的对话日志

记忆不是"存进去就完了"

这是我花了最多心思的地方。大多数所谓"Agent 记忆"就是一个 KV 存储加全文搜索——存进去,搜出来。但我觉得好的记忆系统不应该这样。

你自己的大脑就不是什么都记得。昨天中午吃了什么你可能忘了,但拿到驾照那天你不会忘。重要的反复强化,不重要的自然遗忘,很久没想起来的不是消失了,只是"归档"了。

所以每条记忆在这个系统里是有生命周期的:

新建 (weight 0.70)
  │
  ├─ 多次被确认/使用 → reinforce() → weight 慢慢涨到 0.80+
  │
  ├─ 长期没被提起 → decayAll() → weight 往下掉
  │
  ├─ weight > 0.5   → 正常参与召回
  ├─ 0.2 ~ 0.5      → 降优先级,但还是能被搜到
  └─ < 0.2           → 移到 .archive/,不再参与日常
                          │
                     awaken() 可以手动复活,weight 回到 0.5

另外给记忆分了四个重要性级别,衰减速度不一样:

级别初始权重衰减用在什么地方
CRITICAL0.95极慢"测试不准 mock 数据库"(线上事故的教训)
HIGH0.80较慢长期技术偏好
NORMAL0.70正常默认
TEMPORAL0.50较快"周五要冻结合并"这种过了就过期的

相关性评分也不是拍脑袋的:relevanceScore = weight × 时效因子 × (1 + 强化次数 × 0.05)。被强化过的记忆天然排在前面。

凌晨两点自动蒸馏

这个想法是从 Claude Code 的 /dream 技能来的——人睡觉的时候大脑在整理白天的记忆,代码为什么不能?

配了一个 cron 在凌晨两点跑:

① 蒸馏昨天的对话日志 → 提取核心信息,合并重复观点
② 全局衰减 → 每条记忆按自己的速率掉 weight(今天被召回过的跳过)
③ 归档 → weight < 0.2 的移到 .archive/
④ 冲突检测 → 两条内容高度重叠的记忆建议合并
⑤ 死链清理 → 有记忆引用了已删除的文件,标记出来
⑥ 输出 DAILY_BRIEF.md

第二天打开 DAILY_BRIEF.md 能看到:

# 2026-05-05 简报

昨夜:新建 3 条 · 更新 1 条 · 归档 2 条
健康:活跃 45 条 · 归档 8 条 · 平均权重 0.72
濒危:3 条权重已低于 0.25

待处理:
- "mock策略" 与 "集成测试规范" 内容高度重叠,建议合并
- "Q2 OKR" 引用的文件已删除

这个功能实现不复杂,就是定时任务加一堆逻辑。但我觉得它让整个系统从"被动存取"变成了"会自己维护自己"——这种感觉很不一样。

召回怎么做

不是关键词匹配。

输入"Java 怎么写好测试",内部流程是:

  1. 扫描 MEMORY.md 拿到所有记忆清单
  2. 去噪——本轮对话已经展示过的跳过,用户正在用的工具文档跳过
  3. 把剩下的扔给 LLM,让它按 JSON Schema 输出最相关的 5 条

关键在于 LLM 能做语义联想。"mock" 和 "测试规范" 在字面上没有交集,但 LLM 知道它们高度相关。这是关键词搜索做不到的。

另外它还会输出"为什么选这条"的理由,调 prompt 的时候比一个冰冷的 score 有用得多。

代码里几个值得说的细节

原子写入——不是 FileWriter.write() 就完事了:

Path tmp = target.resolveSibling(target.getFileName() + ".tmp");
Files.writeString(tmp, content);
// 写入后立即读回校验
if (!Files.readString(tmp).equals(content)) {
    throw new IOException("内容校验失败");
}
Files.move(tmp, target, ATOMIC_MOVE);

写一半断电?.tmp 还在,正式文件没动。

索引截断——MEMORY.md 硬限制 200 行且不超过 25KB。因为每次对话都要把索引塞进 LLM 上下文,不截断的话 token 很快就爆了。

双向关联——删除记忆 A 的时候,扫描所有其他记忆的 related 字段,清理对 A 的引用。不然死链越攒越多。

路径沙箱——所有文件操作前 toRealPath() 然后校验前缀,../ 穿越直接抛异常。

说说来处

这个项目的核心设计不是我想出来的,是参考 Claude Code(Anthropic)的内置 Memory 子系统。我分析了它的存储结构、索引格式、四类型分类和去噪策略,整理在了项目的 memory/Memory-design.md 里。

在这个基础上我自己加的东西:

  • 完整的记忆生命周期(权重衰减、休眠归档、人工复活)
  • KAIROS 夜间蒸馏(原版的 /dream 是手动触发的,这里做成了全自动 cron)
  • 重要性分级(原版没有 CRITICAL/HIGH/NORMAL/TEMPORAL 的区分)
  • 冲突检测和死链清理
  • 合并时自动生成旧版本快照
  • 标签分组统计

项目现状

Java 17 + Spring Boot 3.5 + AgentScope SDK。47 个测试全过。支持 OpenAI / DeepSeek / Qwen / Ollama 四种 LLM 后端。

还没搞定的部分:并发锁(目前多线程写同一个目录会有竞争)、LLM 挂了的时候没有关键词回退(设计文档写了但还没实现)、没有 REST 接口只能当库用。V2 打算接到 AgentScope 的 ReActAgent 上,实现对话中自动记忆。

仓库:github.com/li130/agent… 协议。如果你也在给 Agent 做基建或者对这个思路感兴趣,欢迎来逛逛。


设计参考:Claude Code Memory Subsystem (Anthropic),详细分析见 memory/Memory-design.md