· liyu · rag-agent · 15 min read
AI 大模型 Ragent 项目:会话记忆系统设计与多轮对话状态管理实践
大语言模型 API 天然是无状态的,如何让每次独立的请求表现为连贯自然的连续对话?深入剖析 Ragent 问答流水线阶段一(loadMemory)背后的三层记忆架构、异步并发拉取与容错降级、滑动窗口规整(normalizeHistory)、loadAndAppend 时序避坑以及结构化 Prompt 上下文注入顺序。
一、开篇:大模型是无状态的,多轮对话如何做到“不失忆”?
在上一篇《AI 大模型 Ragent 项目:知识问答在后端经历的八个阶段与三大短路点》中,我们解构了问答流水线 StreamChatPipeline 的全景架构。在核心编排方法 execute() 中,第一行代码非常轻巧:
// 阶段 1:加载会话上下文记忆
loadMemory(ctx);看似只有一行调用,但其背后却承载着整套**记忆系统(Memory System)**的完整工程实现——持久化存储、并发读取、滑动窗口、降级容错以及摘要注入。
为什么大模型需要记忆系统?
设想在企业内部知识库助手场景下,员工连续提出了三个关于 OA 审批的问题:
第 1 轮:OA 系统怎么提交加班申请?
第 2 轮:提交之后审批流程是怎样的?
第 3 轮:如果它超过三天没审批怎么办?在第 3 轮中,“它”指代的是第 1 轮的“加班申请”,而“三天没审批”承接了第 2 轮的“审批流程”。
大语言模型(LLM)的底层 API 本质上是完全无状态的 HTTP 请求。如果后端不把前序对话历史打包注入,大模型看到的就只是一句孤立的 “如果它超过三天没审批怎么办” ——模型既不知道“它”指什么,也不知道在讨论哪个审批业务。
记忆系统的终极目标,就是让每次独立的 API 调用,在用户端体验为一段连贯、自然的连续对话。
二、记忆系统的三层分层架构
在 Ragent 项目中,记忆系统严格遵循单一职责与关注点分离原则,拆解为三层结构:
Ragent 记忆系统分层架构
┌─────────────────────────────────────────────────────────────────────────────┐
│ 编排层:StreamChatPipeline │
│ • 职责:只负责调用 loadMemory(ctx),对上下文数据的来源与底层存储细节完全解耦 │
└──────────────────────────────────────┬──────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ 门面层:ConversationMemoryService │
│ • 职责:统一暴露 load() / append() / loadAndAppend(),对外屏蔽存储介质差异 │
└──────────────────────────────────────┬──────────────────────────────────────┘
│
┌───────────────────┴───────────────────┐
▼ ▼
┌─────────────────────────────────────┐ ┌─────────────────────────────────────┐
│ 存储能力:MemoryStore │ │ 压缩能力:SummaryService │
│ • 负责持久化读写 (MySQL/PostgreSQL) │ │ • 负责长对话摘要生成与压缩 │
│ • 预留 Redis 缓存透明切换扩展点 │ │ • 异步 LLM 摘要提取与水位线维护 │
└─────────────────────────────────────┘ └─────────────────────────────────────┘为什么这样分层?
- 门面层屏蔽存储差异: 当前持久化基于数据库读写;未来若需要引入 Redis 提升 QPS,只需实现
RedisConversationMemoryStore并注入门面层,编排层和业务层无需改动任何一行代码; - 持久化与摘要压缩独立演进: 存储方案的重构不会破坏摘要算法,摘要算法从单次 LLM 总结升级为增量压缩时也无需改动存储层;
- 编排层只关心结果:
StreamChatPipeline只需要拿到规整后的List<ChatMessage>,无感知该列表究竟来自缓存、数据库还是包含历史摘要。
三、消息的持久化:append 的工程考量
当用户发送一条提问,或者大模型生成了一句回答,都需要被持久化记录。
┌───────────────────┬───────────────────┬──────────────────────────────────────────┐
│ 核心字段 │ 字段类型 │ 工程设计考量 │
├───────────────────┼───────────────────┼──────────────────────────────────────────┤
│ id │ String (雪花 ID) │ 分布式唯一主键,按时间严格递增,兼做水位线 │
│ conversation_id │ String │ 会话全局逻辑隔离标识 │
│ user_id │ String │ 用户标识,用于多租户与数据权限隔离 │
│ role │ String │ 角色标识:user 或 assistant │
│ content │ String │ 消息文本正文 │
│ thinking_content │ String (可空) │ 深度思考链内容(Deep Thinking 模式) │
│ thinking_duration │ Integer (可空) │ 模型深度思考耗时(秒) │
│ create_time │ DateTime │ 消息写入时间 │
└───────────────────┴───────────────────┴──────────────────────────────────────────┘1. 雪花 ID 兼做“压缩水位线”
采用雪花算法(Snowflake)生成的 ID 天然具备时间单调递增属性。在后续的摘要压缩机制中,记录“当前摘要已经覆盖到了哪条消息”,只需直接对比 message_id 的大小,无需引入额外的时间戳或版本字段。
2. 异步触发摘要压缩(仅限 ASSISTANT 消息)
// 追加消息核心逻辑(伪代码)
public String append(String conversationId, String userId, ChatMessage message) {
// 1. 写入持久化存储
String messageId = memoryStore.append(conversationId, userId, message);
// 2. 若为 ASSISTANT 消息,异步检查并触发摘要压缩
if (message.getRole() == Role.ASSISTANT) {
summaryService.compressIfNeededAsync(conversationId, userId, message);
}
return messageId;
}- 为什么只在 ASSISTANT 消息到达时触发? 一轮完整的有效交互是由
USER提问与ASSISTANT回答配对组成的。如果在USER提问时就触发,模型的回复尚未生成,压缩出的摘要就会缺失后半段; - 异步解耦,不阻塞主流程: 摘要压缩涉及额外的大模型 API 调用。将其交由异步线程池处理,绝不会拖慢用户当前请求的端到端生成速度。
四、历史加载核心机制:load 与滑动窗口
从存储中读取历史对话,是整个记忆模块最关键的性能与正确性交汇点。
并发拉取与容错降级
load(conversationId, userId)
│
┌────────────────────────┴────────────────────────┐
▼ (异步线程池) ▼ (异步线程池)
loadSummaryWithFallback() loadHistoryWithFallback()
[拉取早期历史浓缩摘要] [拉取最近 N 轮原文滑动窗口]
│ │
└────────────────────────┬────────────────────────┘
│ CompletableFuture.allOf().join()
▼
attachSummary(summary, history)
│
▼
[装配前情提要 SYSTEM + 最近 N 轮原文]1. 摘要与历史的并行拉取(CompletableFuture)
如果串行先查摘要表再查历史消息表,总耗时为两个 I/O 的叠加(RT₁ + RT₂)。 通过异步线程池并发拉取:
// 并行加载摘要与最近历史
CompletableFuture<ChatMessage> summaryFuture = CompletableFuture.supplyAsync(
() -> loadSummaryWithFallback(conversationId, userId), memoryExecutor
);
CompletableFuture<List<ChatMessage>> historyFuture = CompletableFuture.supplyAsync(
() -> loadHistoryWithFallback(conversationId, userId), memoryExecutor
);
// 等待两路全部就绪,总耗时 = max(RT_summary, RT_history)
CompletableFuture.allOf(summaryFuture, historyFuture).join();2. 双路独立的容错降级(WithFallback)
生产环境绝不能因为边缘功能的异常而拖垮核心问答链路:
- 摘要拉取失败:降级返回
null,历史消息正常加载,系统依然能基于最近几轮原文完成问答; - 历史拉取失败:降级返回空列表,当前请求依然可以作为单轮无上下文问答正常处理。
3. 滑动窗口查询与 normalizeHistory 规整
① 倒序 LIMIT 提升 SQL 效率
系统默认保留最近 8 轮(16 条)对话:
SELECT * FROM t_message
WHERE conversation_id = ? AND user_id = ? AND deleted = 0
ORDER BY create_time DESC
LIMIT 16;采用倒序 + LIMIT 查询,数据库索引直达最新记录,随后在 Java 内存中反转恢复时间升序。
② normalizeHistory:确保切片以 USER 开头
大模型对话标准格式要求消息必须以 USER 和 ASSISTANT 严格交替出现。
如果滑动窗口刚好从一条 ASSISTANT 回复切开,历史列表头部就会变成孤立的 ASSISTANT。模型在读取时会发生困惑:“为什么第一句话是我自己的回答?用户到底问了什么?”
private List<ChatMessage> normalizeHistory(List<ChatMessage> messages) {
int start = 0;
// 剔除头部孤立的 ASSISTANT 消息,确保切片由 USER 开头
while (start < messages.size() && messages.get(start).getRole() == Role.ASSISTANT) {
start++;
}
return messages.subList(start, messages.size());
}五、时序设计的核心精髓:loadAndAppend 为何先加载后追加?
在 StreamChatPipeline 的阶段一中,调用的是 memoryService.loadAndAppend():
// 核心时序设计
public List<ChatMessage> loadAndAppend(String conversationId, String userId, ChatMessage currentMessage) {
// 1. 先加载历史记录(返回的是追加之前的快照)
List<ChatMessage> history = load(conversationId, userId);
// 2. 再将当前用户消息追加写入持久化存储
append(conversationId, userId, currentMessage);
// 3. 返回不包含当前消息的历史快照
return history;
} 先加载后追加 vs 先追加后加载
【错误做法:先 append 再 load】
1. 数据库写入当前消息 Q
2. load 查出历史列表:[历史消息..., 当前消息 Q]
3. 阶段 8 组装 Prompt:[历史消息..., 当前消息 Q, 当前消息 Q (又拼了一次)] ❌ 重复!
【正确做法:先 load 再 append】
1. load 查出历史列表:[历史消息...] (只到上一轮)
2. 数据库异步/同步写入当前消息 Q
3. 阶段 8 组装 Prompt:[历史消息..., 当前消息 Q] ✔️ 清晰且不重复!关注点分离:记忆服务只负责管理“上一轮之前的历史快照”,当前轮次的问题由 StreamChatPipeline 在最终组装 Prompt 时统一注入,彻底消除上下文重复拼接的隐患。
六、记忆注入 Prompt 的完整结构顺序
加载出的记忆,在阶段八(streamRagResponse)中会与其他多模态上下文按照严格的优先级组装成消息数组:
发给大模型的消息数组结构规范
┌────────────┬───────────┬────────────────────────────┬─────────────────────────────┐
│ 排序位置 │ 角色 Role │ 上下文来源 │ 设计考量与注意力权重 │
├────────────┼───────────┼────────────────────────────┼─────────────────────────────┤
│ 位置 1 │ SYSTEM │ RAG 角色规则(系统 Prompt)│ 最高优先级全局指令约束 │
│ 位置 2 │ SYSTEM │ 对话摘要(如有) │ 长期记忆前情提要 │
│ 位置 3 │ USER │ 知识库检索片段 (KB/MCP) │ 外部客观事实参考依据 │
│ 位置 4~M │ U / A │ 最近 N 轮滑动窗口原文 │ 保持自然交替的短期对话流转 │
│ 位置 M+1 │ USER │ 当前用户最新提问 │ 离生成端最近,分配最高注意力│
└────────────┴───────────┴────────────────────────────┴─────────────────────────────┘把对话摘要作为 SYSTEM 前情提要插在知识检索之前、把当前提问放在最末尾,既符合大模型注意力机制对首尾段落的高权重分配规律,又保证了事实依据与对话流转的逻辑严密性。
七、配置项与参数调优建议
@ConfigurationProperties(prefix = "rag.memory")
public class MemoryProperties {
/** 滑动窗口保留最近对话轮数(1 轮 = 1 条 user + 1 条 assistant),默认 8 */
private Integer historyKeepTurns = 8;
/** 是否启用长对话摘要压缩,默认 false */
private Boolean summaryEnabled = false;
/** 触发摘要压缩的轮数阈值,默认 9 */
private Integer summaryStartTurns = 9;
/** 摘要最大字数限制,默认 200 字 */
private Integer summaryMaxChars = 200;
}关键交叉校验约束:summaryStartTurns > historyKeepTurns
在应用启动时,系统会执行配置交叉校验:当开启摘要压缩时,summaryStartTurns 必须严格大于 historyKeepTurns。
- 原因:当对话轮数仍在 8 轮以内时,滑动窗口能够无损覆盖全部历史,此时生成摘要纯属浪费 Token 算力;
- 只有当对话进行到第 9 轮、最初的第 1 轮即将被滑动窗口“滑出视线”时,启动摘要提炼才有真正的工程意义。
八、总结
在 RAG 问答流水线中,看似简单的 loadMemory(ctx) 是保障大模型具备“拟人化连贯交互”的起点。
通过三层架构隔离、并行拉取与容错降级、normalizeHistory 规整以及先加载后追加的时序设计,Ragent 构建了一套轻量、鲁棒且具备高扩展性的会话状态底座,为后续的查询改写、意图识别与精确检索奠定了稳固的基础。