AI Agent 的长期记忆系统,不应该只是把聊天记录全文存起来,也不应该把所有用户信息塞进一个越来越大的 profile JSON。真正长期可用的记忆,需要能被检索、能被更新、能去重、能解释来源,也要能在多个会话或多个 Agent 并发写入时保持一致。
上一篇已经把 Agent 记忆分成了短期记忆、任务记忆、项目记忆和长期用户记忆。那篇解决的是“记忆到底有哪些类型”。这一篇继续往下走,只聚焦其中最难的一类:长期用户记忆。
更稳的设计是:把值得长期记住的信息拆成一条条原子事实,用向量检索和全文检索共同召回,再让 LLM 作为“记忆裁判”判断是新增、更新、删除还是忽略。最后由确定性代码生成写入计划,在短事务里完成提交。
这篇文章对应的轻量实现放在 agent-memory-lab。它不是完整 Agent 框架,而是一个专门验证长期记忆系统设计的 TypeScript 实验项目,覆盖原子记忆、去重、裁判式写入、混合检索和 revision 提交语义。
一句话概括:
Agent 记忆系统的目标不是“记住更多”,而是记住真正长期有用的事实,并在需要时准确想起来。
一、为什么不能只保存聊天记录
大多数对话式 AI 在单轮任务里表现不错,但一旦进入长期协作,就会暴露几个问题:
- 用户会反复告诉它相同偏好,比如技术栈、输出风格、项目背景和禁忌事项;
- Agent 无法稳定继承历史决策,比如上次为什么选择 A 而不是 B;
- 上下文窗口有限,不能把所有历史对话都塞进去;
- 直接保存聊天记录全文,检索噪音很大,也很难更新;
- 用户事实、项目事实、偏好和决策会变化,不能只追加不修正。
所以长期记忆要解决的不是“把所有内容存下来”,而是:
从对话中提炼长期有效的事实
↓
在需要时准确召回
↓
当事实变化时更新、合并或删除
这和普通 RAG 很像,但又不完全一样。
RAG 面向的是外部知识,重点是“查资料”。长期记忆面向的是用户、项目、偏好和历史决策,重点是“维护一组会演进的事实”。
二、设计目标:长期记忆必须可维护
一套可用的 Agent 记忆系统,至少要满足这些目标。
| 目标 | 含义 |
|---|---|
| 原子化 | 每条记忆只表达一个稳定事实,避免大段 blob 难以维护 |
| 可检索 | 支持语义检索,也支持关键词精确命中 |
| 可更新 | 用户偏好和项目事实会变化,不能只会追加 |
| 可去重 | 相同事实不能无限重复写入 |
| 用户隔离 | 不同用户的记忆必须天然隔离 |
| 并发安全 | 多个会话同时写记忆时不能互相覆盖 |
| 低事务成本 | LLM 和 embedding 调用很慢,不能放进数据库长事务 |
| 可解释 | 每条记忆要有类别、主体、来源、重要性和访问统计 |
这里最容易被低估的是“可更新”。
很多记忆系统早期只做 append-only,看起来实现很快:用户说一句“记住我喜欢 pnpm”,就新增一条记录。但时间一长,里面会出现大量重复、过期和矛盾信息。
例如:
用户喜欢使用 npm。
用户喜欢使用 pnpm。
这个项目使用 3000 端口。
这个项目使用 4000 端口。
用户希望回答详细一点。
用户不喜欢太客套,希望回答简洁。
如果系统只会追加,不会判断“新事实是否修正了旧事实”,记忆就会从资产变成负担。
三、总体架构:写入和检索分开看
长期记忆系统可以拆成两条主链路:写入链路和检索链路。
写入链路负责从新输入中提炼长期事实:
用户输入 / 对话片段
↓
Ingest 接口
↓
计算输入 hash,判断是否重复处理
↓
生成 embedding
↓
召回相关旧记忆
↓
LLM 裁判:新增 / 更新 / 删除 / 忽略
↓
生成 WritePlan
↓
短事务提交
↓
PostgreSQL + pgvector
检索链路负责在对话中找到相关记忆:
用户问题
↓
查询 embedding
↓
向量召回
用户问题
↓
全文检索
向量候选 + 全文候选
↓
RRF 融合排序
↓
重要性重排
↓
返回 Top-K 记忆
系统内部可以分成四层:
| 层级 | 职责 |
|---|---|
| 接入层 | 暴露 search、ingest、list、delete 等能力 |
| 服务层 | 输入校验、裁判编排、重试、写入计划构建 |
| 数据访问层 | 向量检索、全文检索、写入、去重、统计刷新 |
| 存储层 | PostgreSQL、pgvector、tsvector、revision 元信息 |
这个分层的重点是:LLM 负责判断,确定性代码负责执行。
不要让 LLM 直接操作数据库,也不要把“裁判”和“提交”混成一件事。
四、数据模型:从大 profile 到原子记忆条目
最朴素的做法,是给每个用户维护一份 profile:
{
"profile": "用户是后端工程师,喜欢 TypeScript,正在做 Agent 项目,不喜欢太客套的回答……"
}
这种方式短期简单,但长期很难维护。
它的问题包括:
- 追加容易,局部更新困难;
- 难以判断哪句话被哪些查询命中;
- 无法给不同事实设置不同重要性;
- 一个事实过期时,很难安全删除;
- 多个 Agent 同时更新时,很容易互相覆盖。
更适合长期演进的方式,是把记忆拆成一条条原子事实:
用户偏好使用 pnpm 作为包管理器。
用户正在维护一个 Next.js 项目。
用户希望技术解释直接、少客套。
当前项目的开发服务器端口是 4000。
每条记忆是一行数据库记录。一个简化后的核心字段可以这样设计:
| 字段 | 说明 |
|---|---|
id | 记忆条目 ID |
user_id | 用户隔离边界 |
content | 原子事实正文 |
category | 记忆类别,如 profile / preference / project / decision / note |
subject | 结构化主体,如某个项目、系统、技术栈 |
importance | 重要性,0 到 1 |
embedding | 语义向量,用于相似度检索 |
search_text | 用于全文检索的文本 |
fts_vector | PostgreSQL 全文检索向量 |
content_hash | 归一化内容 hash,用于字面去重 |
source | 来源,如 conversation / api / migration |
metadata | 扩展元信息 |
last_accessed_at | 最近访问时间 |
access_count | 被召回次数 |
created_at / updated_at | 创建和更新时间 |
对应的表结构可以简化成这样:
CREATE TABLE user_memory_item (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
content TEXT NOT NULL,
category VARCHAR(50) NOT NULL DEFAULT 'note',
subject VARCHAR(128),
importance DOUBLE PRECISION NOT NULL DEFAULT 0.5,
embedding vector(1536),
search_text TEXT NOT NULL DEFAULT '',
fts_vector tsvector,
content_hash VARCHAR(64) NOT NULL,
source VARCHAR(50),
metadata JSONB,
last_accessed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
access_count INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (user_id, content_hash)
);
CREATE INDEX idx_user_memory_category ON user_memory_item(user_id, category);
CREATE INDEX idx_user_memory_subject ON user_memory_item(user_id, subject);
CREATE INDEX idx_user_memory_updated_at ON user_memory_item(user_id, updated_at);
这里的 UNIQUE (user_id, content_hash) 很关键。它是第一道去重闸门,可以挡住大量重复写入。
五、记忆类别不要太多,但要能过滤
记忆类别不宜一开始设计得太细。类别越多,LLM 裁判越容易摇摆,后续过滤和统计也越复杂。
一个实用集合可以是:
| 类别 | 含义 | 示例 |
|---|---|---|
profile | 用户稳定画像 | “用户是后端工程师。” |
preference | 用户偏好 | “用户喜欢简洁直接的回答。” |
skill | 用户技能或熟悉领域 | “用户熟悉 TypeScript 和 Next.js。” |
project | 项目背景 | “当前项目使用 PostgreSQL。” |
decision | 历史决策 | “该项目选择 pgvector 做向量检索。” |
relationship | 人或组织关系 | “用户与某团队协作维护该系统。” |
note | 其他长期备注 | “开发服务默认端口是 4000。” |
类别的意义不是替代检索,而是给检索提供结构化过滤条件。
例如:
- 只查
project类记忆; - 只查某个
subject下的记忆; - 在回答项目问题时,提高
project和decision的权重; - 在生成个性化回复时,提高
preference的权重。
也就是说,类别更像检索和运营的辅助维度,而不是知识本身。
六、写入:让 LLM 做记忆裁判
记忆写入最容易犯的错是:只要用户说了一句话,就直接追加到数据库。
这样很快会得到一堆重复、矛盾、临时、无价值的信息。
更稳的做法,是引入一个“记忆裁判”流程:
- 输入一段新信息;
- 先召回与它相关的旧记忆;
- 把新输入和旧记忆一起交给 LLM;
- 让 LLM 判断应该新增、更新、删除还是忽略;
- 输出结构化 JSON;
- 服务层把 JSON 规范化成写入计划。
裁判输出可以设计成这样:
[
{
"action": "ADD",
"id": null,
"fact": "用户偏好使用 pnpm 作为包管理器。",
"category": "preference",
"subject": "pnpm",
"importance": 0.7
},
{
"action": "UPDATE",
"id": "mem_123",
"fact": "当前项目的开发服务器端口是 4000。",
"category": "project",
"subject": "dev server",
"importance": 0.6
}
]
支持的 action 不需要太多:
| Action | 含义 |
|---|---|
ADD | 新增一条原子事实 |
UPDATE | 修正或替换已有记忆 |
DELETE | 旧记忆已明确失效 |
NONE | 不需要改变记忆 |
这里有几个关键约束:
- LLM 只能引用召回出来的已有记忆 ID,不能凭空编 ID;
- 输出必须是 JSON,不能夹杂自然语言解释;
- 每条 fact 必须是原子事实;
- 对短且明确的单一事实,如果裁判失败,可以降级为直接 ADD;
- 对删除这类动作要谨慎,最好显式触发,而不是让 LLM 静默删除。
裁判的核心价值,是让系统能处理“新增 / 修正 / 忽略”。
例如用户说:
这个项目不用 npm 了,之后都用 pnpm。
如果旧记忆里有“该项目使用 npm”,裁判应该输出 UPDATE,而不是再追加一条“该项目使用 pnpm”。
七、写入计划:先计划,后提交
LLM 裁判不应该直接写数据库,而应该产出一个 WritePlan:
type WritePlan = {
adds: AddOp[];
updates: UpdateOp[];
deletes: string[];
};
服务层再对裁判结果做规范化:
- 丢弃非法 ID;
- 丢弃冲突 action;
- 合并重复 ADD;
- 限制单次 action 数量;
- 补齐 category、subject、importance;
- 为新增或更新内容生成 embedding;
- 计算
content_hash; - 构造全文检索文本。
这样做的好处是:LLM 只参与判断,不掌握数据库写入细节。
真正的写库逻辑由确定性代码完成,错误更容易约束,也更容易测试。
八、去重:content hash 是第一道闸
每条记忆都应该计算一个内容 hash。
function normalizeContent(content: string): string {
return content.trim().toLowerCase().replace(/\s+/g, " ");
}
function computeContentHash(content: string): string {
return sha256(normalizeContent(content));
}
数据库层对 (user_id, content_hash) 建唯一约束。
当新增命中已有 hash 时,不创建新行,而是刷新原行:
importance = max(oldImportance, newImportance);access_count += 1;last_accessed_at = now();- 合并 metadata;
- 更新来源信息。
这能防止用户多次表达同一事实时生成大量重复记忆。
需要注意的是,content hash 只能解决“字面重复”,不能解决“语义重复”。
例如:
用户喜欢简洁直接的回答。
用户不喜欢太客套的回答。
这两句话 hash 不同,但语义接近。它们需要靠“召回旧记忆 + LLM 裁判”来合并。
所以去重一般是两层:
content hash 处理字面重复
LLM 裁判处理语义重复和事实修正
九、检索:不要只做向量召回
长期记忆和 RAG 一样,只做向量检索不够。
原因很直接:
- 一些专有名词、配置项、端口号、错误码更适合关键词匹配;
- 中文语义 embedding 对精确词的保真不一定稳定;
- 用户可能直接问一个 subject,比如“Keycloak 登出配置”;
- 项目名、工具名、版本号、文件名这类信息,全文检索通常更可靠。
所以更推荐混合检索:
- 向量召回:query → embedding → pgvector 相似度检索;
- 全文召回:query → 中文分词 → PostgreSQL
tsquery; - RRF 融合:把两路候选按 Reciprocal Rank Fusion 合并;
- 重要性重排:最终分数加入
importance。
一个简化打分方式可以是:
score = retrievalScore * 0.8 + importance * 0.2;
其中 retrievalScore 来自融合后的检索分数,importance 表示这条记忆本身的长期价值。
这种设计让系统同时具备三类能力:
- 语义泛化能力:用户换一种说法也能命中;
- 关键词精确能力:技术名词、数字、系统名不容易丢;
- 长期价值偏好:重要事实更容易排前面。
十、中文全文检索要先分词
中文没有天然空格,不能直接把整句塞进 PostgreSQL 全文检索。
比较实用的做法是:
- 用 jieba 等中文分词器切词;
- 对词元做清洗和转义;
- 构造安全的
tsquery; - 把
subject + content一起进入全文检索文本。
例如用户问:
Keycloak 的登出配置
系统应该既能命中 subject 里的 Keycloak,也能命中 content 里的 登出配置。
如果只依赖向量检索,这类问题可能会被相似但不精确的记忆挤掉。
十一、并发控制:不要把 LLM 调用放进事务
记忆写入有一个常见陷阱:
为了保证一致性,把召回、LLM 裁判、embedding、数据库写入全部放进一个事务。
这看起来安全,实际很危险。
因为 LLM 和 embedding 调用都很慢。把它们放进数据库事务里,会导致事务时间过长,占用连接,锁住资源,甚至触发 ORM 的事务超时。
更稳的方式是:事务外做重活,事务内只做短提交。
流程可以这样设计:
Service 读取当前 revision
↓
事务外执行 embedding / 召回 / LLM 裁判
↓
生成 WritePlan
↓
开启短事务
↓
获取 user 级 advisory lock
↓
复查当前 revision
↓
如果 revision 一致:应用 WritePlan,revision + 1,提交
↓
如果 revision 已变化:抛冲突,重新召回并重试
核心机制有几个:
- 每个用户有一个
revision; - 事务外先读取
baseRevision; - 写入前进入短事务,拿用户级 advisory lock;
- 事务内复查当前 revision;
- 如果 revision 已变化,说明期间有人写过,丢弃当前计划并重试;
- 如果一致,应用写入计划并 bump revision。
伪代码:
async function commitOnce(userId, baseRevision, plan) {
return db.transaction(async tx => {
await acquireUserLock(tx, userId);
const currentRevision = await getRevisionForUpdate(tx, userId);
if (currentRevision !== baseRevision) {
throw new RetryConflictError();
}
const result = await applyPlan(tx, userId, plan);
await bumpRevision(tx, userId);
return result;
});
}
这套机制兼顾了几件事:
- 同一用户下写入串行化;
- 不同用户之间互不阻塞;
- LLM / embedding 不占用长事务;
- 冲突后可以重新召回、重新裁判,避免基于过期上下文写入。
这点在多 Agent、多端同时在线的场景里很重要。
十二、访问统计:记忆也需要使用痕迹
每次检索命中后,可以异步刷新:
last_accessed_at;access_count。
这些数据很有用:
- 可以做长期衰减和清理;
- 可以分析哪些记忆真正有价值;
- 可以辅助排序:常被使用的事实可能更重要;
- 可以帮助调试召回质量。
注意,刷新访问统计不应该阻塞主检索流程。
即使刷新失败,也不应该影响用户拿到答案。它是运营和调优信号,不是主链路强依赖。
十三、输入边界和安全约束
记忆系统必须限制输入规模,否则很容易被异常输入拖垮。
一个实用的边界可以是:
| 项 | 建议限制 |
|---|---|
| ingest 输入 | 约 2000 字以内 |
| 单条 fact | 约 1000 字以内 |
| search query | 约 500 字以内 |
| subject | 约 64 到 128 字以内 |
| topK | 不超过 50 |
| 裁判 action 数 | 不超过 20 |
这些限制不是单纯为了省 token,而是为了让记忆保持原子化、可维护、可预测。
另外,长期记忆里可能包含用户偏好、项目背景、组织关系、历史决策,甚至一些敏感信息。工程上至少要考虑:
- 用户隔离;
- 权限过滤;
- 敏感字段脱敏;
- 删除和导出能力;
- 写入来源审计;
- 高风险记忆变更确认。
记忆系统越强,隐私和审计越不能省。
十四、一个完整例子
用户说:
这个项目以后都用 pnpm,开发服务跑在 4000 端口。顺便记一下,我不喜欢太客套的回答。
系统可能生成三条记忆:
[
{
"content": "该项目使用 pnpm 作为包管理器。",
"category": "project",
"subject": "package manager",
"importance": 0.7
},
{
"content": "该项目的开发服务端口是 4000。",
"category": "project",
"subject": "dev server",
"importance": 0.6
},
{
"content": "用户不喜欢太客套的回答。",
"category": "preference",
"subject": "response style",
"importance": 0.8
}
]
之后用户问:
启动开发服务要注意什么?
检索可能召回:
该项目使用 pnpm 作为包管理器。
该项目的开发服务端口是 4000。
Agent 就可以回答:
用 pnpm 启动,注意开发服务端口是 4000,不是默认的 3000。
用户不需要反复说明项目背景。
这就是长期记忆真正带来的价值:它不是把历史聊天搬进上下文,而是在合适的时候补上关键事实。
十五、这套设计的取舍
这套方案的优点很明确:
- 原子记忆比大 blob 更容易更新和删除;
- 混合检索比单纯向量检索更可靠;
- content hash 能挡住大量重复写入;
- LLM 裁判让系统能处理“新增 / 修正 / 忽略”;
- 短事务 + revision 适合多 Agent 并发写入;
- category / subject / importance 让记忆具备可解释结构。
但它也有代价:
- 实现复杂度明显高于“追加一行聊天记录”;
- 依赖 embedding 服务和 LLM 裁判,写入链路更长;
- 需要持续调 prompt 和召回策略;
- DELETE 类动作要非常谨慎,最好显式触发;
- 向量维度、分词策略、数据库扩展都会带来部署约束。
所以不建议在所有 Agent 项目第一天就做完整长期记忆。
如果只是一个短期原型,先保存最近对话和少量用户偏好就够了。只有当产品进入长期协作、多会话、多项目、多 Agent 的阶段,才值得把记忆系统做成一套可演进的基础设施。
十六、后续还能怎么演进
这套架构只是一个起点,后续可以继续做:
- 记忆衰减:长期不用且低重要性的记忆逐渐降权;
- 冲突检测:自动标记互相矛盾的记忆,而不是立即覆盖;
- 用户可视化管理:让用户浏览、编辑、删除自己的记忆;
- 项目级记忆:区分用户个人记忆和团队 / 项目共享记忆;
- 多级召回:先召回 subject,再召回具体 fact;
- 更强的审计解释:记录某条记忆为何被新增或更新;
- 记忆压缩:把大量低层事实周期性归纳为高层摘要,但保留原始原子条目;
- 离线评测集:用固定查询评估召回准确率和排序质量。
长期记忆不是一个单点功能,而是一套会持续演进的 Agent runtime 能力。
总结
一个真正可用的 Agent 记忆系统,不应该只是“把聊天记录存起来”。
更合理的设计是:
- 用原子条目表达长期事实;
- 用向量检索和全文检索共同召回;
- 用 LLM 裁判决定新增、更新或忽略;
- 用 content hash 做字面去重;
- 用 importance 和访问统计辅助排序;
- 用短事务、advisory lock 和 revision 处理并发写入。
最终目标不是让 Agent 记住更多,而是让它记住真正有用的东西,并在正确的时候想起来。
配套代码
本文配套项目叫 agent-memory-lab,定位是一个轻量 TypeScript 实验项目,用来把文章里的核心设计跑起来。
它刻意不接真实 LLM、不依赖 PostgreSQL,也不做完整权限系统,而是聚焦长期记忆最核心的几件事:
- 原子记忆条目;
- content hash 去重;
- 裁判式 ADD / UPDATE / NONE 写入;
- 向量得分 + 关键词得分 + RRF 融合;
- importance 排序;
- userId 隔离;
- revision 提交语义。
本地运行方式:
pnpm install
pnpm test
pnpm example