· liyu · rag-agent · 15 min read

AI 大模型 Ragent 项目:会话记忆系统设计与多轮对话状态管理实践

大语言模型 API 天然是无状态的,如何让每次独立的请求表现为连贯自然的连续对话?深入剖析 Ragent 问答流水线阶段一(loadMemory)背后的三层记忆架构、异步并发拉取与容错降级、滑动窗口规整(normalizeHistory)、loadAndAppend 时序避坑以及结构化 Prompt 上下文注入顺序。

大语言模型 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 摘要提取与水位线维护     │
└─────────────────────────────────────┘ └─────────────────────────────────────┘

为什么这样分层?

  1. 门面层屏蔽存储差异: 当前持久化基于数据库读写;未来若需要引入 Redis 提升 QPS,只需实现 RedisConversationMemoryStore 并注入门面层,编排层和业务层无需改动任何一行代码;
  2. 持久化与摘要压缩独立演进: 存储方案的重构不会破坏摘要算法,摘要算法从单次 LLM 总结升级为增量压缩时也无需改动存储层;
  3. 编排层只关心结果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 开头

大模型对话标准格式要求消息必须以 USERASSISTANT 严格交替出现。

如果滑动窗口刚好从一条 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 构建了一套轻量、鲁棒且具备高扩展性的会话状态底座,为后续的查询改写、意图识别与精确检索奠定了稳固的基础。

技术专栏 · 总分体系

AI 大模型 Ragent 实战

已收录 3 篇深度解析(当前第 2 篇)
📍 阅读脉络:总览篇阶段 1:会话记忆系统AI 大模型 Ragent 项目:会话记忆系统设计与多轮对话状态管理实践
Share:
Back to Blog

Related Posts

View All Posts »
AI 大模型 Ragent 项目:长会话 Token 爆炸?会话摘要压缩策略与水位线机制深度解析
阶段 1 进阶:摘要压缩算法

AI 大模型 Ragent 项目:长会话 Token 爆炸?会话摘要压缩策略与水位线机制深度解析

当多轮对话聊到 30 甚至 50 轮,滑动窗口滑走了开头的关键约束、全量保留又会挤爆 Token 上下文,RAG 系统该如何破局?深入剖析 Ragent 记忆系统中的增量摘要压缩算法、lastMessageId 水位线推进机制、攒批压缩优化(summaryBatchSize)、Prompt 绝对禁止记录答案的设计哲学以及 Redisson 分布式锁防重实践。

阿里开源 TTL 原理解析:跨线程池上下文透传与 RAG 效率/成本追踪实战

阿里开源 TTL 原理解析:跨线程池上下文透传与 RAG 效率/成本追踪实战

深入剖析阿里巴巴开源 TransmittableThreadLocal (TTL) 的底层机制(Capture、Replay、Restore 与 Holder 弱引用字典),彻底破解线程池复用下的上下文丢失与污染困境。结合生产级 RAG 架构,实战解析如何利用全局 traceId 实现多路并发检索归因、分阶段耗时(TTFT/TBT)拆解与单次请求 Token 级成本精细化核算。