大模型没有记忆多轮对话怎么做到不失忆
开篇引言
上一篇画了 StreamChatPipeline 的全景地图,八个阶段从加载记忆到流式生成,一行 execute() 方法 20 行代码就是整个问答系统的骨架。你可能注意到了,阶段 1 只有一行调用:
loadMemory(ctx); // 阶段 1
看起来很轻,但这一行背后藏着记忆系统的完整设计——存储、加载、并行拉取、降级容错、摘要注入,全都压缩在这一个方法调用里。
来看一个具体场景。假设你在一家企业做内部知识库助手,有个员工连续问了三个关于 OA 系统的问题:
第 1 轮:OA 系统怎么提交加班申请? 第 2 轮:提交之后审批流程是怎样的? 第 3 轮:如果它超过三天没审批怎么办?
第 3 轮的“它”指的是加班申请,“三天没审批”承接了第 2 轮的审批流程。大模型的 API 每次请求都是独立的,它不记得之前聊了什么。如果你不把前两轮对话塞进去,模型看到的就只是一句“如果它超过三天没审批怎么办”——它不知道“它”是什么,也不知道审批是哪个审批。
这就是记忆要解决的核心问题:让每次独立的 API 调用看起来像一段连贯的对话。
但记忆不是无脑把所有历史都塞进去就完事了。塞多了 Token 预算爆了,加载慢了用户体验差,存储方案选错了扩展性堵死。本篇聚焦记忆的存储与加载——记忆从哪来、怎么存、怎么加载、怎么装进消息数组。至于记忆太长了怎么办——摘要压缩策略,那是下一篇的事。
记忆系统的整体架构
1. 三层设计
Ragent 的记忆系统不是一个大类把什么都干了,而是拆成了三层,每层各管各的事:

用一句话概括每层的职责:
| 层级 | 核心角色 | 职责 |
|---|---|---|
| 编排层 | StreamChatPipeline | 只管调 loadMemory,不关心记忆怎么来的 |
| 门面层 | ConversationMemoryService | 统一暴露 load / append / loadAndAppend 三个方法,屏蔽底层差异 |
| 基础能力层 | MemoryStore + SummaryService | 一个管持久化读写,一个管摘要压缩,各自独立 |
2. 为什么这样分层
你可能会觉得,就一个记忆功能,至于拆这么细吗?实际上这三层各自解决一个问题:
门面层屏蔽存储差异。 当前版本的持久化方案是 PostgreSQL 直读(MyBatis-Plus),接口上预留了 refreshCache 方法为未来的 Redis 缓存做准备,但当前实现是空操作。假设下个版本要加 Redis 做缓存,只需要新写一个 RedisConversationMemoryStore 实现 ConversationMemoryStore 接口,门面层和编排层一行代码不用动。
持久化和压缩独立演进。 换存储方案不影响压缩逻辑,换压缩策略也不影响存储。比如你想把摘要压缩从单次 LLM 调用改成增量式压缩,只需要替换 ConversationMemorySummaryService 的实现,持久化那边完全无感。
编排层只关心结果。 Pipeline 调 loadMemory 拿到一个 List<ChatMessage> 就够了,它不需要知道这个列表是从 PostgreSQL 查出来的还是从 Redis 拿的,也不需要知道里面有没有摘要。
消息的持久化:append 做了什么
用户发了一条消息,或者模型回了一句话,都要存下来。append 方法就是干这个事的。
1. 存储到 PostgreSQL
每条消息存在 t_message 表里,核心字段长这样:
| 字段 | 类型 | 说明 |
|---|---|---|
id | String(雪花 ID) | 主键 |
conversation_id | String | 会话 ID |
user_id | String | 用户 ID |
role | String | user 或 assistant |
content | String | 消息内容 |
thinking_content | String | 深度思考链(可空) |
thinking_duration | Integer | 思考耗时秒数(可空) |
create_time | Date | 自动填充 |
deleted | Integer | 逻辑删除(0=有效,1=删除) |
几个细节值得注意: