会话记忆架构设计
架构记忆管理摘要压缩Token优化
实现可插拔式会话记忆组件,支持无记忆、滑动窗口、增量摘要压缩三种策略灵活切换,结合数据库持久化,在 Token 成本与上下文质量之间实现平衡,适配长短多轮问答场景
会话记忆架构设计
实现可插拔式会话记忆组件,支持无记忆、滑动窗口、增量摘要压缩三种策略灵活切换,结合数据库持久化,在 Token 成本与上下文质量之间实现平衡,适配长短多轮问答场景。
一、架构全景
会话记忆是五步编排器的第一步,在对话启动时最先执行:
用户请求
→ ChatPreparationOrchestrator.prepare()
→ Step 1: 记忆装载 ← 本章内容
→ Step 2: 问题改写
→ Step 3: 文档路由
→ Step 4: 决策路由
→ Step 5: 组装执行计划
对话结束后,异步触发摘要刷新:
对话完成/失败/停止
→ safeRefreshConversationSummary(conversationId)
→ 判断是否需要压缩
→ 增量合并摘要 → 持久化到 MySQL
二、三种记忆策略
没有显式的策略枚举或策略接口,通过 enabled 标志 + 内部状态自动切换三种模式:
| 策略 | 触发条件 | 行为 |
|---|---|---|
| 无记忆 | historySummary.enabled = false | 仅加载最近轮次原文,不生成摘要,longTermSummary 为空串 |
| 滑动窗口 | enabled = true 且轮次数 ≤ keepRecentTurns | 加载最近 N 轮原文作为上下文,不触发压缩 |
| 增量摘要压缩 | enabled = true 且轮次数 > keepRecentTurns | 窗口外的旧轮次批量送入 LLM 压缩为结构化摘要,与窗口内原文拼接 |
三种模式通过一个配置开关 app.chat.rag.history-summary.enabled 控制,无需改代码即可切换。
三、核心数据模型
3.1 记忆上下文输出
ConversationMemoryContext —— 记忆装载的最终产物,供后续编排步骤使用:
| 字段 | 类型 | 说明 |
|---|---|---|
assembledHistory | String | 最终组合历史(长期摘要 + 最近窗口原文) |
longTermSummary | String | 压缩后的长期摘要文本 |
recentTranscript | String | 格式化最近轮次(用于规划/改写) |
answerRecentTranscript | String | 仅含用户问题的最近轮次(用于回答上下文) |
summaryPayload | ConversationSummaryPayload | 完整结构化摘要 JSON |
coveredExchangeId | Long | 摘要覆盖到的最新交换 ID |
coveredExchangeCount | Integer | 摘要覆盖的总轮次数 |
compressionCount | Integer | 已执行压缩的次数 |
compressionApplied | boolean | 摘要是否处于活跃状态 |
3.2 结构化摘要载体
ConversationSummaryPayload —— LLM 压缩输出的 JSON 结构:
{
"summary": "一段 180~260 字的中文摘要",
"conversation_goal": "用户长期目标(一句话)",
"stable_facts": ["已确认的业务事实、术语、系统名"],
"user_preferences": ["用户偏好"],
"resolved_points": ["已解决的结论"],
"pending_questions": ["待跟进的问题"],
"retrieval_hints": ["对后续检索有帮助的关键词"]
}
| 约束 | 值 |
|---|---|
| 摘要长度 | ≤ summaryMaxChars(默认 1400) |
| 每个数组上限 | 6 条 |
| 每条上限 | 80 字符 |
| 目标上限 | 120 字符 |
3.3 数据库实体
SuperAgentChatMemorySummary —— 表 super_agent_chat_memory_summary:
| 字段 | 类型 | 说明 |
|---|---|---|
id | Long (PK) | UID 生成器主键 |
dialogue_code | String | 会话 ID |
covered_exchange_id | Long | 摘要覆盖到的最新交换 ID |
covered_exchange_count | Integer | 摘要覆盖的总轮次数 |
compression_count | Integer | 执行压缩的次数 |
summary_version | Integer | 单调递增版本号 |
summary_text | String | 人类可读摘要文本 |
summary_json | String | 完整 ConversationSummaryPayload JSON |
last_source_edit_time | Date | 源交换的最近编辑时间 |
每个会话只保留一行记录,每次压缩时覆盖更新。
四、记忆装载流程
PersistentConversationMemoryService.loadMemoryContext() 详细过程:
1. 从 ConversationArchiveStore 获取会话存档
→ 读取 dialogue 记录 + exchange 列表
2. 获取现有摘要快照
→ 查询 super_agent_chat_memory_summary(每个会话最多一行)
→ 存在 → 反序列化 summaryPayload
→ 不存在 → 空摘要
3. 判断是否需要压缩
if enabled && stableExchanges.size() > keepRecentTurns:
→ refreshSummaryIfNecessary()
→ 批量压缩溢出轮次
→ 持久化新摘要
→ compressionApplied = true
4. 渲染最近轮次原文
→ renderRecentTranscript():用户+助手全文
→ renderAnswerRecentTranscript():仅用户问题
5. 组装最终历史
→ assembledHistory = longTermSummary + recentTranscript
→ 返回 ConversationMemoryContext
五、增量摘要压缩
5.1 触发条件
三个条件同时满足才触发压缩:
historySummary.enabled = true- 已完成且稳定的轮次数 >
keepRecentTurns(默认 4) - 稳定轮次定义:状态为
COMPLETED且question非空
5.2 批量处理
过期轮次(窗口外的旧轮次)以 compressionBatchTurns(默认 6)为一组,依次送入 LLM:
现有摘要 JSON + 新一批对话原文
→ buildSummaryMergePrompt()
→ observedChatModelService.callText("summary", SYSTEM_PROMPT, userPrompt)
→ 解析 LLM 返回的 ConversationSummaryPayload JSON
→ 失败回退到 fallbackMerge()
5.3 LLM 摘要提示词
系统提示:你是企业会话长期记忆压缩助手。
你的任务是把已有长期摘要与新增对话批次合并成新的长期记忆。
你必须只保留跨轮仍然有价值的信息,例如:
1. 用户真正的目标、范围和限制
2. 已经确认的业务事实、术语、系统名、模块名
3. 已经解决的结论和仍待继续追问的问题
4. 对后续知识检索仍有帮助的关键词
不要保留寒暄、重复确认、纯过程性客套话。
最终只返回合法 JSON,不要输出 Markdown,不要附加解释。
5.4 规则兜底(fallbackMerge)
当 LLM 调用失败时,执行基于规则的提取:
| 字段 | 提取策略 |
|---|---|
summary | 拼接已有摘要 + 批次高亮("用户关注"、"已有结论") |
conversation_goal | 从最后一个交换中提取 |
pending_questions | 从所有交换中累积 |
retrieval_hints | 正则提取字母数字 + 中文词汇,排除噪音词("请问"、"帮我"等 11 个停用词) |
六、原文渲染策略
6.1 renderRecentTranscript(规划/改写用)
格式:【最近对话原文】
| 规则 | 值 |
|---|---|
| 跳过状态 | RUNNING 中的交换 |
| 用户消息前缀 | 用户: |
| 助手消息前缀 | 助手:(仅 COMPLETED 状态) |
| 问题长度限制 | 160 字符 |
| 回答长度限制 | 320 字符 |
| 总长度上限 | recentTranscriptMaxChars(默认 2200) |
| 裁剪策略 | 从尾部截断(保留最近上下文) |
6.2 renderAnswerRecentTranscript(回答上下文用)
格式:【最近相关对话】
仅包含用户问题,排除助手回答。同样受 answerHistoryMaxChars(默认 1000)预算控制。
七、Token 预算管理
系统使用字符预算(保守估计 1 Token ≈ 4 个中文字符):
| 预算参数 | 默认值 | 用途 |
|---|---|---|
recentTranscriptMaxChars | 2200 | 最近窗口原文上限 |
summaryMaxChars | 1400 | 长期摘要文本上限 |
planningHistoryMaxChars | 1600 | 规划历史的组装预算 |
answerHistoryMaxChars | 1000 | 回答上下文预算 |
规划历史分配策略
总预算 1600 字符按比例分配:
| 部分 | 比例 | 默认值 |
|---|---|---|
| 最近原文 | 65%(至少 50%,最多 100%) | ~1040 字符 |
| 结构化摘要 | 剩余部分 | ~558 字符 |
recentPart = min(max(maxChars/2, round(maxChars * 0.65)), maxChars);
structuredPart = max(0, maxChars - recentPart.length() - 2);
Token 成本估算
在 ObservedChatModelService 中:
estimatedTokens = max(1, ceil(content.length() / 4.0));
模型费率:
| 模型 | 输入 | 输出 |
|---|---|---|
| qwen-plus | $0.004 / 1K Token | $0.012 / 1K Token |
| deepseek | $0.002 / 1K Token | $0.008 / 1K Token |
八、历史上下文的三路分发
编排器在 Step1 拿到 ConversationMemoryContext 后,将其拆分为三个独立上下文:
| 上下文 | 来源 | 用途 |
|---|---|---|
historySummary | 结构化摘要 + 最近原文 | 送入 Step2 问题改写 + Step3 文档路由 |
historyPlanningContext | summaryPayload 的结构化字段(目标/事实/待跟进/检索提示) | 辅助规划和路由决策 |
answerHistoryContext | answerRecentTranscript | 送入 LLM 生成答案时的上下文 |
ConversationExecutionPlan(最终产物)携带全部三类历史:
| 字段 | 类型 | 内容 |
|---|---|---|
historySummary | String | 组合规划历史 |
longTermSummary | String | 原始压缩摘要 |
historyPlanningContext | HistoryPlanningContext | 结构化规划历史 |
recentHistoryTranscript | String | 纯最近原文 |
answerRecentTranscript | String | 仅用户问题的原文 |
answerHistoryContext | String | 回答上下文 |
九、配置参数汇总
全部位于 ChatRagProperties.HistorySummaryProperties:
| 参数 | 默认值 | 说明 |
|---|---|---|
enabled | true | 摘要压缩开关(关 = 无记忆模式) |
keep-recent-turns | 4 | 滑动窗口保留轮数 |
compression-batch-turns | 6 | 每批送入 LLM 压缩的轮数 |
recent-transcript-max-chars | 2200 | 最近原文最大字符数 |
summary-max-chars | 1400 | 摘要文本最大字符数 |
相关顶层配置:
| 参数 | 默认值 | 说明 |
|---|---|---|
rewrite-history-turns | 4 | 送入改写器的历史轮数 |
planning-history-max-chars | 1600 | 规划历史组装预算 |
answer-history-max-chars | 1000 | 回答历史上下文预算 |
十、并发安全
| 机制 | 说明 |
|---|---|
ConcurrentHashMap.newKeySet() | refreshingConversationIds 集合,防止同一会话并发压缩 |
| 乐观锁查询 | saveSummarySnapshot 写入前重查数据库,检查是否有更高版本的 coveredExchangeId |
| 专用线程池 | chatMemorySummaryExecutorService:2 核心线程 + 32 容量队列 + CallerRunsPolicy |
十一、生命周期管理
对话启动
→ loadMemoryContext() // 装载记忆
→ 执行对话
对话结束
→ finishSuccessfully() // 正常结束
→ finishWithFailure() // 异常结束
→ stopTask() // 用户停止
└─ safeRefreshConversationSummary() // 三种路径均触发异步摘要刷新
会话重置
→ deleteConversationSummary() // 删除摘要记录
手动操作
→ rebuildConversationSummary() // 删除后从头重新压缩(API: POST /session/summary/rebuild)
十二、完整文件清单
核心服务
| 文件 | 职责 |
|---|---|
chatagent/service/ConversationMemoryService.java | 记忆服务接口(6 个方法) |
chatagent/service/PersistentConversationMemoryService.java | 核心实现(841 行):装载、压缩、渲染、持久化 |
chatagent/service/ConversationArchiveStore.java | 对话存档接口(获取原始轮次) |
chatagent/service/MybatisConversationArchiveStore.java | MyBatis 存档实现(617 行) |
chatagent/service/ObservedChatModelService.java | LLM 调用封装 + Token 用量追踪 |
数据模型
| 文件 | 职责 |
|---|---|
chatagent/model/memory/ConversationMemoryContext.java | 记忆上下文输出模型 |
chatagent/model/memory/ConversationSummaryPayload.java | 结构化摘要 JSON 模型 |
chatagent/model/ConversationMemorySummaryView.java | 摘要视图 DTO |
chatagent/model/ConversationExchangeView.java | 交换轮次数据模型 |
chatagent/model/ConversationSessionView.java | 会话视图(含 memorySummary) |
chatagent/data/SuperAgentChatMemorySummary.java | 摘要数据库实体 |
chatagent/mapper/SuperAgentChatMemorySummaryMapper.java | MyBatis-Plus Mapper |
编排集成
| 文件 | 职责 |
|---|---|
chatagent/rag/service/ChatPreparationOrchestrator.java | 五步编排器(Step1 调用记忆装载 + 三路历史分发) |
chatagent/rag/service/AnswerHistoryContextAssembler.java | 回答历史上下文组装 |
chatagent/rag/model/HistoryPlanningContext.java | 结构化规划历史模型 |
chatagent/rag/model/AnswerHistoryContext.java | 回答历史上下文模型 |
chatagent/rag/model/ConversationExecutionPlan.java | 完整执行计划(携带全部历史字段) |
配置
| 文件 | 职责 |
|---|---|
chatagent/rag/config/ChatRagProperties.java | 记忆相关全部配置属性 |
chatagent/rag/config/ChatRagExecutorConfiguration.java | 摘要专用线程池配置 |
chatagent/model/trace/ConversationTraceStageCode.java | 追踪阶段:MEMORY |
主流程
| 文件 | 职责 |
|---|---|
chatagent/service/BusinessChatService.java | 主业务服务(启动时调用记忆装载,结束时触发摘要刷新) |
chatagent/enums/ChatTurnStatus.java | 交换状态枚举(COMPLETED/RUNNING/FAILED/STOPPED) |
十三、设计亮点总结
- 开关式策略切换:一个
enabled配置即可在无记忆/滑动窗口/增量摘要三种模式间切换,无需改动代码或策略接口 - 增量非全量:每次只压缩窗口外的新增轮次,与已有摘要合并,避免重复处理历史轮次,性能 O(新轮次) 而非 O(总轮次)
- 结构化摘要载体:不是纯文本摘要,而是包含目标/事实/偏好/待跟进/检索提示的结构化 JSON,下游可精确提取所需信息
- 三路历史分发:同一份记忆根据用途拆分为规划历史、改写上下文、回答上下文三条通路,各自有独立预算,避免上下文污染
- LLM + 规则双链路:摘要压缩首选 LLM,失败时回退到基于规则的
fallbackMerge,保证压缩不会完全失败 - 尾部裁剪:原文超出预算时从尾部截断而非头部,确保保留最近对话(最相关),牺牲早期对话
- 异步无感:摘要刷新在对话结束后异步执行,不阻塞用户请求,专用线程池 2 核心 + CallerRunsPolicy 防止 OOM
- 乐观锁防并发:写入前重查数据库检查版本号,避免并发压缩导致摘要错乱
