上一篇聊部署形态,结尾留了个钩子:会话跨天之后,记忆怎么设计。这篇把它补上。
先摆一个做 Agent 绕不开的场景:项目持续三周,用户周一说"数据库用 PostgreSQL,别用 ORM",周三的会话里 Agent 又推荐了 Prisma。它不是故意忘的——上下文窗口只保留当前对话,前几天的会话早就被截断了。用户视角这叫"失忆",工程视角这是没有记忆层。
先把两个概念切开:
记忆系统设计的全部问题,就是往这块磁盘写什么、怎么组织、什么时候读、什么时候扔。这篇文章讲我落地的分层方案:从最简的 Markdown 文件,到检索式记忆库,再到最难的部分——写入策略。代码是 TypeScript。
不是所有"记住"都是同一种东西,混着设计必然做砸:
三种"记住"对应三种完全不同的存储和淘汰策略。工作记忆适合"一个文件整体覆盖更新",长期记忆适合"逐条积累 + 检索"。最常见的失败是把工作记忆塞进长期记忆库——三个月后检索回来一堆过期进度,比没有记忆更糟。
动手上向量库之前,先看看需求的真实规模。单项目、单用户的 Agent,有价值的跨会话记忆通常不超过十几条:技术栈约束、几个关键决策、当前进度、几条教训。这个量级,一个结构化的 Markdown 文件就是最优解:
# 项目记忆
## 技术约束
- 数据库:PostgreSQL 16,禁用 ORM,统一用 Kysely
- 部署:Docker Compose,单机,无 K8s
## 关键决策
- [10-12] 认证选了 session 而不是 JWT:理由是同域部署,不需要跨服务鉴权
- [10-14] 放弃微服务拆分:三人团队,拆了没法维护
## 当前进度
- 支付回调联调中,沙箱环境返回签名偶尔校验失败(待排查)
## 教训
- 直接改 DB 数据不写迁移脚本,下次部署会丢——已经踩过一次使用方式同样简单:会话开始时整个注入 system prompt,会话结束(或每完成一个里程碑)时让 Agent 输出更新后的版本整体覆盖:
async function loadMemory(): Promise<string> {
try {
return await fs.readFile(MEMORY_PATH, "utf8");
} catch {
return "(暂无项目记忆)";
}
}
async function saveMemory(content: string): Promise<void> {
// 整体覆盖,不 append——过期状态靠这次写入自然消失
await fs.writeFile(MEMORY_PATH, content, "utf8");
}整体覆盖而不是追加,是关键设计:过期信息的退出机制靠"重写"实现,不需要任何删除逻辑。Agent 每次更新时自然会把"支付回调联调中"替换成新状态,旧的进度不会堆积。
这个方案的边界也很清楚:记忆条目多到全量注入会挤占上下文(经验值大约超过 4-8KB),或者多项目共享同一个记忆库需要隔离,就到了换方案的临界点。
当记忆条目超出"全量注入"的规模——比如 Agent 服务多个项目、或者要积累用户在长期使用中的碎片事实——就该按需检索了。套路和 RAG 一样:写入时嵌入,查询时 top-k:
interface MemoryEntry {
id: string;
content: string;
embedding: number[];
createdAt: number;
kind: "fact" | "decision" | "lesson" | "preference";
}
async function recall(query: string, topK = 5): Promise<MemoryEntry[]> {
const [qv] = await embed([query]);
const all = await store.all();
return all
.map(e => ({ e, score: cosine(qv, e.embedding) }))
.sort((a, b) => b.score - a.score)
.slice(0, topK)
.map(x => x.e);
}但检索式记忆有三个 RAG 场景没有的坑:
读取顶多是排序问题,写入是"什么值得记住"的判断问题,这才是记忆系统的成败点。
按价值分三档:
两个落地规则:
其一,冲突用覆盖不用追加。 新事实和旧事实矛盾时,旧的那条必须删除或标记作废,而不是两条并存。实现上给写入加一步"相似检索 + 冲突消解":新条目写入前先查 top-3,语义冲突(同一主体、相反断言)就替换。用户搬家了,记忆库里只能有一个地址。
其二,遗忘要有机制,不能靠自觉。 最简单的两种:createdAt 超过 TTL 且未被检索命中的条目定期清理;每过一段时间(或条目超阈值时)跑一次"蒸馏"——让模型把几十条碎片合并成几条结构化事实,比如二十条关于部署的零散记录合并成"部署环境"一节。蒸馏和第二节的整体覆盖是同一件事在检索式存储上的翻版:给记忆一个收敛机制,否则它只会单调膨胀。
把上面的方案叠成分层结构,按记忆的性质路由:
层 | 存储 | 内容 | 读法 | 写法 |
|---|---|---|---|---|
L1 | 会话上下文 | 当前对话 | 自动 | 自�� |
L2 | 项目记忆文件 | 约束/决策/进度/教训 | 每次会话全量注入 | 里程碑时整体覆盖 |
L3 | 向量记忆库 | 碎片事实/偏好/长尾细节 | 相关时 top-k 检索 | 逐条写入+冲突消解 |
路由规则一句话:结构化的、每次都该看的知识进 L2;零散的、按需才相关的进 L3。团队共享的项目约束写成文档和 L2 注入,用户个人的使用偏好进 L3 检索——混在一起,两边都做不好。
一个容易忽略的点:给用户看记忆、让用户改记忆。记忆错了(过期的偏好、错误的教训)比没有记忆更伤,因为它会固执地影响每次会话。一个 /memory 命令让用户查看和编辑当前记忆,是信任问题,也是调试问题——记忆系统出问题时,你总得有个地方看它到底记了什么。
清单版总结:
记忆系统做好之后,你会发现下一个瓶颈是上下文本身:长会话里早期内容被截断,Agent 行为就开始"变形"——下一篇文章可能聊聊会话内的上下文管理:压缩时机、摘要策略,以及什么内容永远不该被压缩掉。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。