news 2026/7/11 22:11:07

AI对话系统的后端会话管理:从短期记忆到长期记忆的架构演进

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
AI对话系统的后端会话管理:从短期记忆到长期记忆的架构演进

AI对话系统的后端会话管理:从短期记忆到长期记忆的架构演进

大多数 AI 对话产品的"记忆"其实只是把最近 20 轮消息塞进 Prompt。当你问"上周我们聊的那个方案怎么样了",它只能茫然地回答"抱歉,我没有之前的对话记录"。真正的会话管理远不止于此。

一、会话记忆的分层模型

人类对话中的记忆不是一个扁平的列表——有些信息只在当前话题中有效(短期),有些可以被几天后的对话引用(长期),还有一些构成了对话对象的基本认知(永久)。AI 对话系统的会话管理同样需要这种分层思维:

graph TB subgraph 短期记忆层 - 对话窗口 A1[当前轮次消息] A2[最近N轮对话] A3[当前会话元信息] A1 --> A2 --> A3 end subgraph 中期记忆层 - 会话摘要 B1[滑动窗口摘要] B2[话题分段摘要] B3[关键决策记录] end subgraph 长期记忆层 - 向量存储 C1[用户偏好向量] C2[历史对话片段] C3[知识库关联] end subgraph 永久记忆层 - 结构化存储 D1[用户画像] D2[长期偏好] D3[关系图谱] end A3 -->|超窗口触发摘要| B1 B1 -->|定期归档| C2 C2 -->|特征提取| D2 style 短期记忆层 fill:#e3f2fd style 中期记忆层 fill:#fff8e1 style 长期记忆层 fill:#f3e5f5 style 永久记忆层 fill:#e8f5e9

四种记忆的存储介质和检索方式各有侧重:

记忆层级存储介质检索方式时效性容量上限
短期(窗口)内存/Redis顺序读取秒级数十 KB
中期(摘要)Redis/MySQL会话ID索引毫秒级每会话数 KB
长期(向量)Milvus/Qdrant语义检索十毫秒级每用户数 MB
永久(画像)MySQL/MongoDB用户ID索引毫秒级每用户数 KB

二、对话上下文窗口管理

2.1 窗口大小与 Token 预算

上下文窗口管理首先要解决的问题是:在模型允许的 Token 上限内,如何分配预算。

总 Token 预算 = System Prompt + 检索到的长期记忆 + 中期摘要 + 最近N轮对话 + 预留输出空间

实际操作中,长对话的窗口管理需要"滑动 + 压缩"策略:

/** * 对话上下文窗口管理器 * 负责 Token 预算分配、消息裁剪、上下文组装 */ @Service @Slf4j public class ContextWindowManager { private final TokenCounter tokenCounter; private final ConversationRepository conversationRepository; // 上下文窗口配置 private static final int MAX_CONTEXT_TOKENS = 8000; // 上下文总预算 private static final int RESERVED_OUTPUT_TOKENS = 2000; // 输出预留 private static final int SYSTEM_PROMPT_TOKENS = 500; // 系统提示估算 private static final int MEMORY_TOKENS_BUDGET = 1000; // 长期记忆预算 private static final int SUMMARY_TOKENS_BUDGET = 500; // 中期摘要预算 // 实际可用于最近对话的 Token 数 private static final int RECENT_MESSAGE_BUDGET = MAX_CONTEXT_TOKENS - RESERVED_OUTPUT_TOKENS - SYSTEM_PROMPT_TOKENS - MEMORY_TOKENS_BUDGET - SUMMARY_TOKENS_BUDGET; public ContextWindowManager(TokenCounter tokenCounter, ConversationRepository conversationRepository) { this.tokenCounter = tokenCounter; this.conversationRepository = conversationRepository; } /** * 构建当前请求的完整上下文 */ public ContextAssemblyResult assembleContext(String conversationId, String currentMessage, UserProfile userProfile) { List<Message> allMessages = conversationRepository .findMessagesByConversationId(conversationId); // 1. 从最新消息往前截取,控制在 RECENT_MESSAGE_BUDGET 范围内 List<Message> windowMessages = buildSlidingWindow(allMessages, RECENT_MESSAGE_BUDGET); // 2. 如果有被裁剪掉的消息,生成中期摘要 String summary = null; int trimmedCount = allMessages.size() - windowMessages.size(); if (trimmedCount > 0) { summary = conversationRepository.getConversationSummary(conversationId); log.debug("对话裁剪: conversationId={}, trimmed={}, kept={}", conversationId, trimmedCount, windowMessages.size()); } // 3. 检索长期记忆 List<MemoryFragment> longTermMemories = retrieveRelevantMemories(conversationId, currentMessage, MEMORY_TOKENS_BUDGET); return ContextAssemblyResult.builder() .recentMessages(windowMessages) .summary(summary) .longTermMemories(longTermMemories) .totalTokens(tokenCounter.count(windowMessages) + (summary != null ? tokenCounter.count(summary) : 0) + longTermMemories.stream().mapToInt(m -> m.getTokenCount()).sum()) .build(); } /** * 滑动窗口裁剪:从最新消息往前截取,直到达到 token 上限 * 保证消息的配对完整性(不裁剪 user 消息而保留其对应的 assistant 响应的一半) */ private List<Message> buildSlidingWindow(List<Message> allMessages, int tokenBudget) { if (allMessages.isEmpty()) { return Collections.emptyList(); } List<Message> window = new ArrayList<>(); int currentTokens = 0; // 从最新消息往前遍历 ListIterator<Message> iterator = allMessages.listIterator(allMessages.size()); while (iterator.hasPrevious() && currentTokens < tokenBudget) { Message msg = iterator.previous(); int msgTokens = tokenCounter.count(msg.getContent()); // 保证消息对完整性:如果加上这条消息会超预算且当前窗口已有配对消息 if (currentTokens + msgTokens > tokenBudget && !window.isEmpty()) { // 如果当前超预算的消息是 assistant 消息, // 需要把对应的 user 消息也移除(保证配对) if ("assistant".equals(msg.getRole()) && !window.isEmpty() && "user".equals(window.get(0).getRole())) { Message removed = window.remove(0); currentTokens -= tokenCounter.count(removed.getContent()); } break; } window.add(0, msg); // 插入到开头(因为我们是从后往前遍历) currentTokens += msgTokens; } return window; } }

2.2 消息配对完整性

一个容易被忽略的细节是消息配对:如果你裁剪掉了 user 消息但保留了其对应的 assistant 响应,模型会看到一个"无中生有"的回复,容易产生幻觉。裁剪逻辑必须保证:要么同时保留一对 user/assistant 消息,要么同时移除。

三、历史消息的摘要压缩与检索

当对话长度超出窗口限制时,不可能把所有历史消息都塞进 Prompt。摘要压缩有两种策略:

3.1 增量式摘要(滑动窗口摘要)

每轮对话后,将新消息与上一轮的摘要合并,生成新的摘要:

/** * 增量摘要生成器 * 每 N 轮对话触发一次摘要更新,避免频繁调用 LLM */ @Service public class IncrementalSummaryGenerator { private final SummaryPromptBuilder promptBuilder; private final ChatLanguageModel summaryModel; // 使用轻量模型(如 GPT-3.5) private final ConversationSummaryRepository summaryRepository; // 每 10 轮对话触发一次摘要更新 private static final int SUMMARY_TRIGGER_ROUNDS = 10; public IncrementalSummaryGenerator(SummaryPromptBuilder promptBuilder, ChatLanguageModel summaryModel, ConversationSummaryRepository summaryRepository) { this.promptBuilder = promptBuilder; this.summaryModel = summaryModel; this.summaryRepository = summaryRepository; } /** * 新消息到达后评估是否需要更新摘要 */ public void onNewMessage(String conversationId, Message newMessage) { int roundCount = conversationRepository.countRounds(conversationId); if (roundCount % SUMMARY_TRIGGER_ROUNDS == 0) { generateIncrementalSummary(conversationId); } } private void generateIncrementalSummary(String conversationId) { // 获取上次摘要和新产生的消息 String previousSummary = summaryRepository.getLatestSummary(conversationId); List<Message> newMessages = conversationRepository .findMessagesSinceLastSummary(conversationId); if (newMessages.isEmpty()) { return; } // 构建摘要 Prompt String summaryPrompt = promptBuilder.buildIncrementalSummaryPrompt( previousSummary, newMessages); try { String newSummary = summaryModel.generate(summaryPrompt); // 存储新摘要(保留摘要历史版本,便于追溯) summaryRepository.saveSummary(conversationId, SummaryEntry.builder() .content(newSummary) .roundNumber(conversationRepository.countRounds(conversationId)) .coveredMessageIds(newMessages.stream() .map(Message::getId) .collect(Collectors.toList())) .createdAt(Instant.now()) .build()); log.info("增量摘要生成完成: conversationId={}, rounds={}, summaryLength={}", conversationId, conversationRepository.countRounds(conversationId), newSummary.length()); } catch (Exception e) { log.error("增量摘要生成失败: conversationId={}", conversationId, e); // 摘要生成失败不应阻断对话流程,降级为仅使用窗口消息 } } }

3.2 结构化关键信息提取

摘要不仅是文本压缩,更需要提取结构化关键信息:用户提到的偏好、做出的决策、设定的目标等。这些结构化信息构成了从"中期记忆"到"长期记忆"的桥梁。

四、长期记忆的向量存储与语义召回

长期记忆的核心能力是:根据当前对话内容,检索出相关的历史信息。这依赖向量数据库实现的语义搜索。

/** * 长期记忆管理器 * 负责记忆的存储、语义检索、优先级计算与遗忘策略 */ @Service @Slf4j public class LongTermMemoryManager { private final EmbeddingService embeddingService; private final VectorDatabase vectorDatabase; // Milvus/Qdrant private final MemoryPriorityCalculator priorityCalculator; private final MemoryForgettingStrategy forgettingStrategy; private static final int DEFAULT_TOP_K = 5; private static final double MIN_SIMILARITY_THRESHOLD = 0.72; public LongTermMemoryManager(EmbeddingService embeddingService, VectorDatabase vectorDatabase, MemoryPriorityCalculator priorityCalculator, MemoryForgettingStrategy forgettingStrategy) { this.embeddingService = embeddingService; this.vectorDatabase = vectorDatabase; this.priorityCalculator = priorityCalculator; this.forgettingStrategy = forgettingStrategy; } /** * 存储对话记忆片段 */ public void storeMemory(String userId, String conversationId, MemoryFragment fragment) { try { // 生成文本片段的向量表示 float[] embedding = embeddingService.embed(fragment.getContent()); // 计算初始优先级 float priority = priorityCalculator.calculateInitialPriority(fragment); // 存储到向量数据库 vectorDatabase.upsert(MemoryVector.builder() .id(fragment.getId()) .userId(userId) .conversationId(conversationId) .content(fragment.getContent()) .embedding(embedding) .memoryType(fragment.getType()) // PREFERENCE/DECISION/FACT/GOAL .priority(priority) .createdAt(fragment.getCreatedAt()) .lastAccessedAt(fragment.getCreatedAt()) .accessCount(0) .build()); log.debug("长期记忆存储成功: userId={}, memoryId={}, type={}", userId, fragment.getId(), fragment.getType()); } catch (Exception e) { log.error("长期记忆存储失败: userId={}, memoryId={}", userId, fragment.getId(), e); } } /** * 基于当前消息语义检索相关记忆 * * @param userId 用户ID * @param query 当前查询文本(用于语义匹配) * @param tokenBudget Token 预算 * @return 按相关性+优先级排序的记忆列表 */ public List<MemoryFragment> retrieveMemories(String userId, String query, int tokenBudget) { try { // 生成查询向量 float[] queryEmbedding = embeddingService.embed(query); // 语义检索 List<MemoryVector> candidates = vectorDatabase.search( userId, queryEmbedding, DEFAULT_TOP_K * 2); // 过滤低相关性结果 List<MemoryVector> relevant = candidates.stream() .filter(v -> v.getSimilarity() >= MIN_SIMILARITY_THRESHOLD) .collect(Collectors.toList()); if (relevant.isEmpty()) { return Collections.emptyList(); } // 综合排序:语义相似度 × 0.6 + 记忆优先级 × 0.4 relevant.sort((a, b) -> { double scoreA = a.getSimilarity() * 0.6 + a.getPriority() * 0.4; double scoreB = b.getSimilarity() * 0.6 + b.getPriority() * 0.4; return Double.compare(scoreB, scoreA); }); // 根据 Token 预算截取 List<MemoryFragment> results = new ArrayList<>(); int currentTokens = 0; for (MemoryVector vec : relevant) { int tokens = estimateTokens(vec.getContent()); if (currentTokens + tokens > tokenBudget) { break; } results.add(MemoryFragment.fromVector(vec)); currentTokens += tokens; // 更新访问时间和计数 vectorDatabase.updateAccessMetrics(vec.getId(), Instant.now()); } log.debug("长期记忆检索: userId={}, found={}, selected={}, tokens={}", userId, relevant.size(), results.size(), currentTokens); return results; } catch (Exception e) { log.error("长期记忆检索失败: userId={}, query={}", userId, query, e); return Collections.emptyList(); } } /** * 记忆优先级更新与遗忘策略执行 * 定期执行(如每天凌晨) */ @Scheduled(cron = "0 0 3 * * ?") public void executeMemoryMaintenance() { log.info("开始执行记忆维护任务..."); // 1. 衰减所有记忆的优先级 int decayedCount = vectorDatabase.decayAllPriorities(0.95); // 每天衰减5% log.info("记忆优先级衰减: {} 条", decayedCount); // 2. 归档低优先级记忆(优先级低于 0.1 且 30 天未访问) int archivedCount = forgettingStrategy.archiveLowPriorityMemories( 0.1f, Duration.ofDays(30)); log.info("低优先级记忆归档: {} 条", archivedCount); // 3. 强化高价值记忆(最近7天内被访问3次以上的) int reinforcedCount = vectorDatabase.reinforceFrequentMemories(3, Duration.ofDays(7)); log.info("高价值记忆强化: {} 条", reinforcedCount); } private int estimateTokens(String text) { // 粗略估算:中文约 1.5 字符/Token,英文约 4 字符/Token return (int) Math.ceil(text.length() / 2.5); } }

遗忘策略设计

长期记忆不能无限膨胀。遗忘策略基于三个维度:

  1. 时间衰减:记忆优先级每天按比例衰减(如 0.95),长期未被访问的记忆自然降低权重
  2. 冲突更新:当用户表达了与旧记忆矛盾的新偏好时,降低旧记忆的优先级而非直接删除(因为新表述可能是临时性的)
  3. 情绪标记:用户表达强烈情绪(如"太差了""非常喜欢")的记忆片段获得额外的优先级加成

五、总结

AI 对话系统的会话管理从"把所有消息塞进 Prompt"到"分层记忆 + 语义检索"的演进,本质是从无状态到有状态、从单轮上下文到跨会话记忆的跨越。

架构落地的核心要点可以归结为三条:

第一,分层是性价比最高的优化。短期窗口、中期摘要、长期向量、永久画像——每层使用不同的存储介质和检索策略,用最合适的成本解决对应的问题。

第二,摘要不是一次性的。增量式摘要需要与对话轮次绑定,每次只处理新增的消息,避免反复对全量历史做摘要(既浪费 Token 也容易丢失细节)。

第三,遗忘机制和记忆机制同等重要。无止境的内存增长会稀释检索精度、增加存储成本。一套好的遗忘策略(时间衰减 + 访问频率加权 + 情绪标记)比一味增加存储容量更有价值。

当前这个领域仍处于快速演进中——从 OpenAI 的 Memory 功能到 Anthropic 的长上下文窗口,各家在"记忆"这件事上的路线不同。但分层记忆的架构思路是通用的,不依赖于具体模型的能力。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/7/11 22:10:47

AD74412R与STM32L152RE的高精度工业信号采集方案

1. 为什么选择AD74412R与STM32L152RE组合在工业控制和嵌入式系统设计中&#xff0c;信号采集与处理的精度和实时性往往决定整个系统的性能上限。AD74412R作为ADI公司推出的四通道软件可配置I/O解决方案&#xff0c;其独特之处在于单芯片集成多种功能模式——包括10V模拟输出、1…

作者头像 李华
网站建设 2026/7/11 22:10:27

Skills:AI能力的模块化革命

#解锁AI新范式&#xff1a;Skills——模块化、可插拔的智能体能力扩展革命 在AI应用开发领域&#xff0c;如何让大语言模型&#xff08;LLM&#xff09;突破其固有的知识边界&#xff0c;精准、高效地执行特定领域的复杂任务&#xff0c;一直是开发者面临的挑战。Skills&#…

作者头像 李华
网站建设 2026/7/11 22:09:36

嵌入式系统:地图功能可以使用GDAL+PROJ

一开始我想用ArcGIS。本身不支持&#xff0c;我想用安卓版本。后来搜索&#xff0c;推荐tangram-es&#xff0c;结果根本搜索不到可用的范例代码。后来AI推荐使用GDALPROJ&#xff1a;编译很顺利。范例代码也遇到不少问题&#xff0c;都顺利解决了。

作者头像 李华
网站建设 2026/7/11 22:09:31

Claude Code人机协作实战指南:50条防崩溃工作流契约

1. 项目概述&#xff1a;这不是一份“技巧清单”&#xff0c;而是一份Claude Code实战生存手册你已经装上了Claude Code&#xff0c;也成功让它改过几行代码、跑过几个测试。但很快你就发现&#xff0c;它有时像一位思路清晰的资深同事&#xff0c;有时又像一个热情过度却总在关…

作者头像 李华
网站建设 2026/7/11 22:09:20

PCIe 5.0 SSD如何解决AI PC的存储IO瓶颈

1. 这块8TB PCIe 5.0硬盘&#xff0c;真不是给普通笔记本准备的“为AI PC而生”——雷克沙NM1090 PRO这个宣传语一出来&#xff0c;我就多看了两眼。不是因为雷克沙突然押中了风口&#xff0c;而是这句话背后藏着一个被很多人忽略的事实&#xff1a;当前市面上绝大多数标称“AI…

作者头像 李华