跳至正文
来两杯美式
返回

我手搓了一个 AI Agent 记忆框架,才发现“长期记忆”不是存聊天记录

By 来两杯美式
发布于更新于

AI Agent 的长期记忆系统,不应该只是把聊天记录全文存起来,也不应该把所有用户信息塞进一个越来越大的 profile JSON。真正长期可用的记忆,需要能被检索、能被更新、能去重、能解释来源,也要能在多个会话或多个 Agent 并发写入时保持一致。

上一篇已经把 Agent 记忆分成了短期记忆、任务记忆、项目记忆和长期用户记忆。那篇解决的是“记忆到底有哪些类型”。这一篇继续往下走,只聚焦其中最难的一类:长期用户记忆。

更稳的设计是:把值得长期记住的信息拆成一条条原子事实,用向量检索和全文检索共同召回,再让 LLM 作为“记忆裁判”判断是新增、更新、删除还是忽略。最后由确定性代码生成写入计划,在短事务里完成提交。

这篇文章对应的轻量实现放在 agent-memory-lab。它不是完整 Agent 框架,而是一个专门验证长期记忆系统设计的 TypeScript 实验项目,覆盖原子记忆、去重、裁判式写入、混合检索和 revision 提交语义。

一句话概括:

Agent 记忆系统的目标不是“记住更多”,而是记住真正长期有用的事实,并在需要时准确想起来。

一、为什么不能只保存聊天记录

大多数对话式 AI 在单轮任务里表现不错,但一旦进入长期协作,就会暴露几个问题:

所以长期记忆要解决的不是“把所有内容存下来”,而是:

从对话中提炼长期有效的事实

在需要时准确召回

当事实变化时更新、合并或删除

这和普通 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 项目,不喜欢太客套的回答……"
}

这种方式短期简单,但长期很难维护。

它的问题包括:

更适合长期演进的方式,是把记忆拆成一条条原子事实:

用户偏好使用 pnpm 作为包管理器。
用户正在维护一个 Next.js 项目。
用户希望技术解释直接、少客套。
当前项目的开发服务器端口是 4000。

每条记忆是一行数据库记录。一个简化后的核心字段可以这样设计:

字段说明
id记忆条目 ID
user_id用户隔离边界
content原子事实正文
category记忆类别,如 profile / preference / project / decision / note
subject结构化主体,如某个项目、系统、技术栈
importance重要性,0 到 1
embedding语义向量,用于相似度检索
search_text用于全文检索的文本
fts_vectorPostgreSQL 全文检索向量
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。”

类别的意义不是替代检索,而是给检索提供结构化过滤条件。

例如:

也就是说,类别更像检索和运营的辅助维度,而不是知识本身。

六、写入:让 LLM 做记忆裁判

记忆写入最容易犯的错是:只要用户说了一句话,就直接追加到数据库。

这样很快会得到一堆重复、矛盾、临时、无价值的信息。

更稳的做法,是引入一个“记忆裁判”流程:

  1. 输入一段新信息;
  2. 先召回与它相关的旧记忆;
  3. 把新输入和旧记忆一起交给 LLM;
  4. 让 LLM 判断应该新增、更新、删除还是忽略;
  5. 输出结构化 JSON;
  6. 服务层把 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不需要改变记忆

这里有几个关键约束:

裁判的核心价值,是让系统能处理“新增 / 修正 / 忽略”。

例如用户说:

这个项目不用 npm 了,之后都用 pnpm。

如果旧记忆里有“该项目使用 npm”,裁判应该输出 UPDATE,而不是再追加一条“该项目使用 pnpm”。

七、写入计划:先计划,后提交

LLM 裁判不应该直接写数据库,而应该产出一个 WritePlan

type WritePlan = {
  adds: AddOp[];
  updates: UpdateOp[];
  deletes: string[];
};

服务层再对裁判结果做规范化:

这样做的好处是: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 时,不创建新行,而是刷新原行:

这能防止用户多次表达同一事实时生成大量重复记忆。

需要注意的是,content hash 只能解决“字面重复”,不能解决“语义重复”。

例如:

用户喜欢简洁直接的回答。
用户不喜欢太客套的回答。

这两句话 hash 不同,但语义接近。它们需要靠“召回旧记忆 + LLM 裁判”来合并。

所以去重一般是两层:

content hash 处理字面重复
LLM 裁判处理语义重复和事实修正

九、检索:不要只做向量召回

长期记忆和 RAG 一样,只做向量检索不够。

原因很直接:

所以更推荐混合检索:

  1. 向量召回:query → embedding → pgvector 相似度检索;
  2. 全文召回:query → 中文分词 → PostgreSQL tsquery
  3. RRF 融合:把两路候选按 Reciprocal Rank Fusion 合并;
  4. 重要性重排:最终分数加入 importance

一个简化打分方式可以是:

score = retrievalScore * 0.8 + importance * 0.2;

其中 retrievalScore 来自融合后的检索分数,importance 表示这条记忆本身的长期价值。

这种设计让系统同时具备三类能力:

十、中文全文检索要先分词

中文没有天然空格,不能直接把整句塞进 PostgreSQL 全文检索。

比较实用的做法是:

  1. 用 jieba 等中文分词器切词;
  2. 对词元做清洗和转义;
  3. 构造安全的 tsquery
  4. 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 已变化:抛冲突,重新召回并重试

核心机制有几个:

伪代码:

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;
  });
}

这套机制兼顾了几件事:

这点在多 Agent、多端同时在线的场景里很重要。

十二、访问统计:记忆也需要使用痕迹

每次检索命中后,可以异步刷新:

这些数据很有用:

注意,刷新访问统计不应该阻塞主检索流程。

即使刷新失败,也不应该影响用户拿到答案。它是运营和调优信号,不是主链路强依赖。

十三、输入边界和安全约束

记忆系统必须限制输入规模,否则很容易被异常输入拖垮。

一个实用的边界可以是:

建议限制
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。

用户不需要反复说明项目背景。

这就是长期记忆真正带来的价值:它不是把历史聊天搬进上下文,而是在合适的时候补上关键事实。

十五、这套设计的取舍

这套方案的优点很明确:

但它也有代价:

所以不建议在所有 Agent 项目第一天就做完整长期记忆。

如果只是一个短期原型,先保存最近对话和少量用户偏好就够了。只有当产品进入长期协作、多会话、多项目、多 Agent 的阶段,才值得把记忆系统做成一套可演进的基础设施。

十六、后续还能怎么演进

这套架构只是一个起点,后续可以继续做:

  1. 记忆衰减:长期不用且低重要性的记忆逐渐降权;
  2. 冲突检测:自动标记互相矛盾的记忆,而不是立即覆盖;
  3. 用户可视化管理:让用户浏览、编辑、删除自己的记忆;
  4. 项目级记忆:区分用户个人记忆和团队 / 项目共享记忆;
  5. 多级召回:先召回 subject,再召回具体 fact;
  6. 更强的审计解释:记录某条记忆为何被新增或更新;
  7. 记忆压缩:把大量低层事实周期性归纳为高层摘要,但保留原始原子条目;
  8. 离线评测集:用固定查询评估召回准确率和排序质量。

长期记忆不是一个单点功能,而是一套会持续演进的 Agent runtime 能力。

总结

一个真正可用的 Agent 记忆系统,不应该只是“把聊天记录存起来”。

更合理的设计是:

最终目标不是让 Agent 记住更多,而是让它记住真正有用的东西,并在正确的时候想起来。

配套代码

本文配套项目叫 agent-memory-lab,定位是一个轻量 TypeScript 实验项目,用来把文章里的核心设计跑起来。

它刻意不接真实 LLM、不依赖 PostgreSQL,也不做完整权限系统,而是聚焦长期记忆最核心的几件事:

本地运行方式:

pnpm install
pnpm test
pnpm example

上一篇

AI 工程化相关阅读


分享这篇文章:
通过邮件分享这篇文章✓ 链接已复制
查看系列全部文章
  1. 01.做 AI 助手,最怕一上来就建知识库
  2. 02.RAG 入门:为什么大模型需要查资料再回答
  3. 03.RAG 核心原理:检索、向量与嵌入——计算机如何理解语义
  4. 04.RAG 实战:核心组件、调优与问题排查
  5. 05.RAG 为什么回答流程问题会漏步骤?原因和改进方案(附完整源码)
  6. 06.企业级 RAG 怎么落地?不只是向量检索和大模型(附完整源码)
  7. 07.RAG 检索不准?先别换模型,把问题问对再说
  8. 08.RAG 答非所问?从索引到生成,打通最后一公里
  9. 09.别再把聊天记录当记忆了:AI Agent 的四种记忆到底怎么分?
  10. 10.我手搓了一个 AI Agent 记忆框架,才发现“长期记忆”不是存聊天记录
  11. 11.精选数据与垂直 AI:AI 时代的护城河在哪

上一篇
RAG 为什么回答流程问题会漏步骤?原因和改进方案(附完整源码)
下一篇
别再把聊天记录当记忆了:AI Agent 的四种记忆到底怎么分?