长期记忆:让 Agent 跨会话认识用户
作者:程序员马丁
Ragent AI —— 从 0 到 1 纯手工打造企业级 Agentic RAG,拒绝 Demo 玩具!AI 时代,助你拿个offer。
前面几篇咱们一路走来,先给 TinyAgent 装上了短期记忆(ChatMemory 接口 + InMemoryChatMemory),又实现了三种记忆管理策略(滑动窗口、摘要压缩、混合策略),最后用 PostgreSQL 做了持久化(JdbcChatMemory + SessionManager),让会话记忆能跨重启保留、在不同用户之间互相隔离。
到目前为止,Agent 的记忆能力已经不错了——同一个会话里能记住之前聊过什么,服务重启也不怕丢,用户和会话之间互不干扰。但还有一个问题没解决:每个会话的记忆是孤立的。
假设一个用户今天上午在比特严选的客服窗口开了一个会话,聊了 6 轮,从查物流到退款到推荐新款扫地机,Agent 表现得像个老朋友——记得订单号、知道退款编号、推荐了 S20 Pro。用户满意地关了窗口。
第二天下午,这个用户又打开客服窗口,开了一个新会话,问了一句:
昨天那个退款到账了吗?
Agent 的回复:
请问您说的是哪个退款呢?能提供一下订单号吗?
用户昨天刚聊了 6 轮,Agent 今天就完全不认识他了。
这不是 Bug——JdbcChatMemory 忠实地把昨天的对话存在了数据库里,只要用户点开昨天那个会话,所有消息都能完整还原。问题在于,用户今天开的是一个新会话,新会话的消息列表是空的,Agent 自然什么都不知道。
会话记忆解决的是单次会话内的连贯性。要让 Agent 跨会话记住用户,需要长期记忆。
本项目中具体代码已上传 GitHub TinyAgent,大家 Clone 项目后,将代码分支切换到 1.8.x,默认主分支是最新代码。运行前复制
.env.example为.env,把自己的 API Key 填进去,默认阿里云百炼平台;.env已加入.gitignore,切分支时不会丢。
会话记忆 vs 长期记忆:边界在哪
先把两者的边界理清楚。
| 维度 | 会话记忆 | 长期记忆 |
|---|---|---|
| 生命周期 | 一次会话(开始到结束) | 跨会话持久化(天、周、月) |
| 存储介质 | 内存 / PostgreSQL(JdbcChatMemory) | Key-Value / 向量库 |
| 写入时机 | 每轮对话实时追加 | 会话结束时提炼关键信息 |
| 读取时机 | 每轮对话构建 messages 时注入 | 新会话开始时按相关性检索 |
| 内容粒度 | 原始消息(用户说了什么、Agent 回了什么) | 提炼后的结构化信息(用户偏好、交互记录) |
| Token 占用 | 随对话轮次线性增长 | 通常固定在几百 Token 以内 |
用一句话概括两者的关系:会话记忆管一次会话内的连贯性,长期记忆管跨会话的个性化。
打个比方:会话记忆就像你跟朋友的一次电话通话——挂了电话,录音还在(JdbcChatMemory 帮你存着),下次想回顾可以翻出来。但如果你接到一通新电话,你不会自动想起上次聊了什么。长期记忆就像你对这个朋友的了解——他喜欢什么、最近在忙什么、上次帮他办了什么事——这些信息在你接起电话的 瞬间就自动浮现在脑海里,不需要翻任何录音。
三层记忆的完整架构
加上之前几篇实现的能力,TinyAgent 的记忆体系现在有三层,各管各的:

有一个容易混淆的地方:会话摘要和交互记录不是一回事。
| 维度 | 会话摘要 | 交互记录 |
|---|---|---|
| 归属 | 会话级(属于某个 session) | 用户级(属于某个 user) |
| 写入时机 | 会话进行中,消息超过阈值时自动压缩 | 会话结束时,调大模型提炼一次 |
| 内容 | 对话的压缩版(为了省 Token) | 关键事件的富文本叙述(为了给未来会话用) |
| 谁管 | HybridChatMemory(第 11 篇) | UserProfileExtractor(本篇) |
| 生命周期 | 随会话存在 | 跨会话持久化,保留数周到数月 |
会话摘要解决的是“当前这通电话太长了,把前面说过的内容压缩一下”;交互记录解决的是“下次来电话时,快速回忆起上次聊了什么”。两者在不同层次解决不同问题,互不替代。
在实际代码中,两者通过 PersistentHybridChatMemory 和 LongTermMemoryRetriever 分别管理,最终在 ReActAgent 的 messages 数组里各就各位:
messages 数组的完整结构:
[0] system: 系统提示词(你是比特严选的智能客服...)
[1] system: 长期记忆上下文(用户画像 + 相关交互记录) ← 长期记忆层
[2] system: [对话摘要](HybridChatMemory 压缩的) ← 会话记忆层
[3] user: 最近的用户输入(问题) ← 会话记忆层
[4] assistant: 最近的 Agent 回复 ← 会话记忆层
...
[N] user: 当前轮次的用户输入
这一篇聚焦在最上面那一层——长期记忆。会话记忆的持久化和压缩已经在前面的篇章里实现了,这里直接复用。
长期记忆要记什么
不是所有对话内容都值得存到长期记忆里。存太多,检索时噪音大、Token 浪费;存太少,等于没记。关键是只存对未来会话有用的信息。
以比特严选的客服场景为例,长期记忆分两类:
1. 用户画像
用户在多次交互中暴露出来的偏好和特征:
用户 A 的画像:
- 偏好品类:智能家居(扫地机、智能音箱)
- 价格敏感度:中等,预算通常 2000-3000 元
- 购买习惯:喜欢等促销活动下单
- 家庭场景:有老人,关注操作简便性
这类信息不是一次对话就能收集完的。用户第一次来问扫地机,你知道他对智能家居感兴趣;第二次来问智能音箱,你确认了他在组建智能家居生态;第三次来说给爸妈用的,你知道了他的家庭场景。画像是多次会话逐步积累的。
2. 交互记录
过去几次会话的完整叙述,每条记录里嵌入了具体数据:
用户 A 的交互记录:
- [2026-06-29] 用户因订单 88231 的比特 S10 Pro 扫地机(1999 元)出现无法回充的质量问题申请退款,退款编号 RF20260629001,预计 1-3 个工作日到账
- [2026-06-30] 用户对比了 S20 Pro(2999 元)和 S20 Max(3999 元),倾向 S20 Pro 但认为价格偏高
- [2026-07-01] 用户咨询了 S20 Pro 的促销活动,被告知 7 月中旬有满减活动
跟之前的摘要相比,区别在于:每条记录里都嵌入了具体数据——订单号 88231、退款编号 RF20260629001、金额 1999 元、商品型号 S10 Pro。这些数据不是单独存放的,而是作为叙述的一部分。当向量检索命中这条记录时,Agent 同时拿到了事件的上下文和所有相关数据。
有了这些,用户第四次来的时候说“那个扫地机的促销开始了吗”,Agent 通过向量检索命中第三条记录,直接知道他问的是 S20 Pro、7 月中旬的满减活动,不需要额外查任何 Key-Value。
3. 什么不该存
| 该存 | 不该存 |
|---|---|
| 用 户偏好(品类、预算、风格) | 对话原文(太长、太冗余) |
| 交互记录(每次会话的完整叙述,包含具体数据) | 中间推理过程(Thought 链) |
| 用户反馈(投诉过什么、表扬过什么) | 工具调用的原始 JSON 返回 |
| 寒暄和闲聊内容 |
原则很简单:只存未来会话中可能用到的信息,丢掉过程性和临时性的东西。
记忆条目的数据模型
长期记忆不像会话记忆那样直接存原始消息,它需要一个更结构化的数据模型。每一条长期记忆可以抽象成一个 MemoryEntry:
public record MemoryEntry(
String key,
String content,
String userId,
String type,
long timestamp,
double[] embedding
) {
public static MemoryEntry of(String key, String content, String userId, String type) {
return new MemoryEntry(key, content, userId, type, System.currentTimeMillis(), null);
}
}
几个字段的含义:
key:记忆的唯一标识,用于精确查找。比如profile:user_10086或record:user_10086:1719648000000。content:记忆的文本内容。比如用户偏好智能家居,预算 2000-3000 元。userId:关联的用户 ID,一个用户的长期记忆跟别人的隔离。type:记忆类型——USER_PROFILE(用户画像)、INTERACTION_RECORD(交互记录)。timestamp:写入时间,用于按时间排序和过期清理。embedding:内容的向量表示,向量检索时用。用户画像不需要这个字段,传null即可。