公司动态
Spring AI对话记忆实战:ChatMemory、Redis与数据库怎么选?|Java转AI第5课
目录1. ChatMemory、Advisor和Repository分别负责什么1.1 ChatMemory决定模型能够看到哪些消息1.2 ChatMemoryRepository决定消息存在哪里1.3 MessageChatMemoryAdvisor自动完成上下文读写2. 使用MessageWindowChatMemory实现多轮对话2.1 配置ChatMemory2.2 注册MessageChatMemoryAdvisor2.3 调用时传入conversationId3. conversationId应该怎么设计3.1 不要把userId直接当作conversationId3.2 conversationId不能只依赖前端传入3.3 会话ID和请求traceId不能混用4. 使用Redis保存近期对话4.1 添加Redis依赖4.2 不建议直接序列化Message接口4.3 实现RedisChatMemoryRepository4.4 接入MessageWindowChatMemory5. 为什么还要单独保存完整聊天历史5.1 会话表5.2 消息表5.3 每轮如何写入数据库6. 流式输出时应该什么时候保存消息7. 生产环境中最容易踩的几个坑7.1 清空ChatMemory不等于删除完整历史7.2 Redis TTL过期不等于会话被删除7.3 maxMessages不是越大越好7.4 Tool Calling消息不能简单压缩成文本7.5 并发请求可能破坏同一会话顺序8. 三种方案怎么选本文以Spring Boot 3.x Spring AI 1.1.x为基础。不同版本的依赖名称和部分API可能存在差异实际使用时以项目版本为准。上一篇我们使用Spring AI完成了SSE流式输出。但一个真正可用的AI聊天系统除了要像ChatGPT一样逐字返回还必须能够理解多轮对话。例如用户先问帮我分析一下这个项目的技术架构。模型回答后用户继续追问第二个问题应该怎么解决如果系统不知道“第二个问题”指的是什么这次对话就失去了连续性。Spring AI提供了ChatMemory解决这个问题但真正落地时很快又会遇到新的选择ChatMemory到底保存了什么MessageChatMemoryAdvisor有什么作用内存、Redis和数据库应该怎么选Redis已经保存消息为什么还要单独写数据库多实例部署后如何保证会话不丢失本文不再重复企业AI记忆的整体架构而是聚焦Spring AI的具体实现。本文的核心结论是ChatMemory负责管理进入模型的近期上下文Redis负责跨实例共享和快速读取数据库负责保存完整会话历史。三者不是替代关系而是不同职责。1. ChatMemory、Advisor和Repository分别负责什么使用Spring AI实现对话记忆首先要理解三个核心组件MessageChatMemoryAdvisor ↓ ChatMemory ↓ ChatMemoryRepository它们看起来都和“记忆”有关但职责并不相同。1.1 ChatMemory决定模型能够看到哪些消息ChatMemory负责管理当前会话需要保留的上下文。Spring AI常用的实现是MessageWindowChatMemory它采用滑动窗口机制消息没有超过窗口时继续保存消息超过窗口后淘汰较早的消息下一次调用模型时只携带窗口内的消息。例如ChatMemory chatMemory MessageWindowChatMemory.builder() .maxMessages(12) .build();这里的12表示最多保留12条消息而不是12轮对话。一次普通对话通常包含1条UserMessage 1条AssistantMessage所以12条消息大致对应最近6轮对话。Spring AI当前文档说明MessageWindowChatMemory默认窗口是20条消息超过上限后会淘汰旧消息并对SystemMessage进行特殊保留。需要注意消息条数只能解决最基础的窗口控制。一条包含大量代码的消息可能比十轮简短问答占用更多Token。因此真实项目后续还需要结合Token数量控制上下文这部分更适合单独实现摘要压缩策略。1.2 ChatMemoryRepository决定消息存在哪里ChatMemoryRepository负责底层存储。它的核心接口可以简化为public interface ChatMemoryRepository { ListString findConversationIds(); ListMessage findByConversationId( String conversationId); void saveAll( String conversationId, ListMessage messages); void deleteByConversationId( String conversationId); }它可以有不同实现内存JDBCRedisMongoDBNeo4j。但必须特别注意saveAll()的语义它会用新的消息集合替换当前会话中已有的消息而不是向数据库中无限追加历史记录。这意味着ChatMemoryRepository保存的是当前记忆窗口不天然等于完整聊天历史。1.3 MessageChatMemoryAdvisor自动完成上下文读写如果没有Advisor每次请求都需要手动查询历史消息将历史消息放进Prompt调用大模型保存用户问题保存模型回答。Spring AI提供的MessageChatMemoryAdvisor可以自动完成调用前后的记忆处理。调用前它会从ChatMemory读取历史消息并以Message集合的形式加入Prompt调用完成后再将本轮消息更新到记忆中。三者的职责可以总结为组件主要职责MessageChatMemoryAdvisor调用前读取历史调用后更新记忆ChatMemory控制保留哪些上下文ChatMemoryRepository决定消息存储在哪里2. 使用MessageWindowChatMemory实现多轮对话先从最简单的内存方案开始。2.1 配置ChatMemoryConfiguration public class ChatMemoryConfig { Bean public ChatMemory chatMemory() { return MessageWindowChatMemory.builder() .maxMessages(12) .build(); } }如果没有指定其他ChatMemoryRepository可以使用内存存储保存当前消息窗口。这种方案适合本地学习单元测试Demo验证单实例临时工具。2.2 注册MessageChatMemoryAdvisorConfiguration public class ChatClientConfig { Bean public ChatClient chatClient( ChatClient.Builder builder, ChatMemory chatMemory) { MessageChatMemoryAdvisor memoryAdvisor MessageChatMemoryAdvisor.builder(chatMemory) .build(); return builder .defaultAdvisors(memoryAdvisor) .build(); } }配置成默认Advisor后通过这个ChatClient发起的请求都会自动经过对话记忆处理。2.3 调用时传入conversationIdService RequiredArgsConstructor public class AiChatService { private final ChatClient chatClient; public String chat( String conversationId, String question) { return chatClient.prompt() .system( 你是一名企业AI应用助手。 请结合历史对话理解当前问题。 不要补充历史中不存在的信息。 ) .user(question) .advisors(advisor - advisor.param( ChatMemory.CONVERSATION_ID, conversationId )) .call() .content(); } }第一次调用chatService.chat( conversation-001, 我的项目使用Spring AI开发 );第二次调用chatService.chat( conversation-001, 我刚才说使用的是什么框架 );两次请求使用相同的conversationId第二次调用时就能够读取第一轮的历史消息。如果第二次换成另一个IDchatService.chat( conversation-002, 我刚才说使用的是什么框架 );它就属于另一段独立会话不应该读取conversation-001的内容。3. conversationId应该怎么设计很多对话记忆问题并不是ChatMemory本身导致的而是conversationId设计不合理。3.1 不要把userId直接当作conversationId一个用户可能同时拥有多个会话用户10001 ├─ Spring AI项目讨论 ├─ RAG架构分析 └─ 简历优化如果直接使用userId作为会话ID这些完全不同的主题就会进入同一个上下文。最终可能出现不同任务互相干扰历史消息越来越长模型混淆当前讨论对象用户无法独立删除某个会话。更合理的关系是一个userId ↓ 对应多个conversationId例如userId10001 conversationId chat_20260721_001 chat_20260721_002 chat_20260722_0013.2 conversationId不能只依赖前端传入后端收到会话ID后必须校验当前conversationId 是否属于当前登录用户否则用户修改请求参数就可能读取其他人的对话上下文。业务代码中至少要执行conversationService.checkOwner( conversationId, currentUserId );校验通过后才能继续访问ChatMemory。3.3 会话ID和请求traceId不能混用两者解决的问题不同标识作用conversationId标识一段持续的对话traceId标识一次具体请求链路userId标识对话属于哪个用户一次会话可以包含几十个traceId但它们共享同一个conversationId。4. 使用Redis保存近期对话内存方案最大的问题是应用重启后消息丢失多实例之间无法共享用户请求切换实例后可能失忆。例如系统部署了两个实例第一轮请求 → 实例A → JVM内存A 第二轮请求 → 实例B → JVM内存B实例B无法读取实例A中的历史消息。因此多实例部署时可以把ChatMemoryRepository放到Redis中。4.1 添加Redis依赖dependency groupIdorg.springframework.boot/groupId artifactId spring-boot-starter-data-redis /artifactId /dependency4.2 不建议直接序列化Message接口Spring AI的消息存在多种实现UserMessageAssistantMessageSystemMessage工具调用相关消息直接序列化Message接口容易遇到多态反序列化框架升级后字段变化消息Metadata丢失Tool消息无法正确恢复。为了突出核心流程本文先定义一个简单DTO只处理普通聊天中的三种消息public record MemoryMessageDTO( String type, String content) { }生产项目如果需要保存Tool Calling、多模态内容或消息Metadata需要继续扩展DTO不能只保留content。4.3 实现RedisChatMemoryRepositoryComponent RequiredArgsConstructor public class RedisChatMemoryRepository implements ChatMemoryRepository { private static final String KEY_PREFIX ai:chat:memory:; private static final String INDEX_KEY ai:chat:memory:index; private static final Duration MEMORY_TTL Duration.ofDays(7); private final StringRedisTemplate redisTemplate; private final ObjectMapper objectMapper; Override public ListString findConversationIds() { SetString conversationIds redisTemplate.opsForSet() .members(INDEX_KEY); if (conversationIds null || conversationIds.isEmpty()) { return List.of(); } return conversationIds.stream() .filter(this::memoryExists) .toList(); } Override public ListMessage findByConversationId( String conversationId) { String json redisTemplate.opsForValue() .get(buildKey(conversationId)); if (json null || json.isBlank()) { return List.of(); } try { ListMemoryMessageDTO messages objectMapper.readValue( json, new TypeReference() { } ); return messages.stream() .map(this::toMessage) .toList(); } catch (JsonProcessingException exception) { throw new IllegalStateException( 读取对话记忆失败conversationId conversationId, exception ); } } Override public void saveAll( String conversationId, ListMessage messages) { ListMemoryMessageDTO dtoList messages.stream() .map(this::toDTO) .toList(); try { String json objectMapper.writeValueAsString(dtoList); redisTemplate.opsForValue().set( buildKey(conversationId), json, MEMORY_TTL ); redisTemplate.opsForSet() .add(INDEX_KEY, conversationId); } catch (JsonProcessingException exception) { throw new IllegalStateException( 保存对话记忆失败conversationId conversationId, exception ); } } Override public void deleteByConversationId( String conversationId) { redisTemplate.delete( buildKey(conversationId) ); redisTemplate.opsForSet() .remove(INDEX_KEY, conversationId); } private boolean memoryExists( String conversationId) { Boolean exists redisTemplate.hasKey( buildKey(conversationId) ); if (!Boolean.TRUE.equals(exists)) { redisTemplate.opsForSet() .remove(INDEX_KEY, conversationId); return false; } return true; } private String buildKey( String conversationId) { return KEY_PREFIX conversationId; } private MemoryMessageDTO toDTO( Message message) { return new MemoryMessageDTO( message.getMessageType().name(), message.getText() ); } private Message toMessage( MemoryMessageDTO dto) { MessageType messageType MessageType.valueOf(dto.type()); return switch (messageType) { case USER - new UserMessage(dto.content()); case ASSISTANT - new AssistantMessage(dto.content()); case SYSTEM - new SystemMessage(dto.content()); default - throw new IllegalArgumentException( 暂不支持的消息类型 messageType ); }; } }这段实现主要解决了四个问题按conversationId隔离会话将消息窗口保存为JSON设置TTL清理长期不活跃的缓存使用Set维护会话索引避免通过KEYS扫描Redis。还需要特别注意saveAll(conversationId, messages)不是追加一条消息而是覆盖当前会话保存的消息集合这与ChatMemoryRepository的接口约定一致。4.4 接入MessageWindowChatMemoryConfiguration public class ChatMemoryConfig { Bean public ChatMemory chatMemory( RedisChatMemoryRepository repository) { return MessageWindowChatMemory.builder() .chatMemoryRepository(repository) .maxMessages(12) .build(); } }上层业务代码不需要修改。完整调用链变成ChatClient ↓ MessageChatMemoryAdvisor ↓ MessageWindowChatMemory ↓ RedisChatMemoryRepository ↓ Redis5. 为什么还要单独保存完整聊天历史接入Redis以后ChatMemory已经可以跨实例共享但它仍然不能直接代替业务消息表。原因就在于MessageWindowChatMemory本身会淘汰窗口之外的消息。假设窗口最多保存12条消息随着对话增加Redis中的内容会不断变化第1次消息112 第2次消息314 第3次消息516这是正确的上下文窗口行为但消息14不能因此从企业系统中永久消失。Spring AI文档也明确区分了Chat Memory和Chat HistoryChat Memory服务于模型上下文Chat History服务于完整记录、展示、审计和恢复。因此建议单独维护业务会话表和消息表。5.1 会话表CREATE TABLE ai_conversation ( id BIGINT PRIMARY KEY, conversation_id VARCHAR(64) NOT NULL UNIQUE, user_id BIGINT NOT NULL, title VARCHAR(200), status VARCHAR(32) NOT NULL, create_time DATETIME NOT NULL, update_time DATETIME NOT NULL );5.2 消息表CREATE TABLE ai_conversation_message ( id BIGINT PRIMARY KEY, conversation_id VARCHAR(64) NOT NULL, role VARCHAR(32) NOT NULL, content LONGTEXT NOT NULL, model_name VARCHAR(100), token_count INT, trace_id VARCHAR(64), status VARCHAR(32), create_time DATETIME NOT NULL, INDEX idx_conversation_time ( conversation_id, create_time ) );数据库主要承担历史消息分页展示会话恢复问题排查内容审计Token与成本统计后续摘要生成用户主动删除和数据治理。5.3 每轮如何写入数据库普通同步调用可以采用Service RequiredArgsConstructor public class AiChatService { private final ChatClient chatClient; private final ConversationMessageService messageService; public String chat( Long userId, String conversationId, String question) { messageService.saveUserMessage( userId, conversationId, question ); String answer chatClient.prompt() .user(question) .advisors(advisor - advisor.param( ChatMemory.CONVERSATION_ID, conversationId )) .call() .content(); messageService.saveAssistantMessage( userId, conversationId, answer ); return answer; } }这里存在两套写入ChatMemory 保存模型需要看到的近期消息 业务消息表 保存用户需要查看的完整历史两者虽然内容存在交集但用途不同。6. 流式输出时应该什么时候保存消息流式输出不能在每返回一个Token时就向数据库写一条消息。假设模型输出企 企业 企业A 企业AI如果每个片段都落库不仅会产生大量写操作还会留下许多不完整记录。更合理的方式是用户问题进入系统后立即保存流式过程中在内存中拼接完整答案流式正常结束后保存模型完整回复发生异常时记录失败状态和已输出内容。以Flux为例可以采用类似思路public FluxString streamChat( String conversationId, String question) { StringBuilder answerBuffer new StringBuilder(); messageService.saveUserMessage( conversationId, question ); return chatClient.prompt() .user(question) .advisors(advisor - advisor.param( ChatMemory.CONVERSATION_ID, conversationId )) .stream() .content() .doOnNext(answerBuffer::append) .doOnComplete(() - messageService.saveAssistantMessage( conversationId, answerBuffer.toString() ) ) .doOnError(exception - messageService.saveFailedMessage( conversationId, answerBuffer.toString(), exception.getMessage() ) ); }这里需要继续考虑一个细节如果客户端主动断开连接模型调用是否还会继续以及最终答案是否需要保存这取决于业务设计聊天工具可以在断开后取消生成案件分析、报告生成等长任务通常应该继续执行并允许用户稍后查看结果。因此流式连接状态和业务任务状态不应始终绑定在一起。7. 生产环境中最容易踩的几个坑7.1 清空ChatMemory不等于删除完整历史chatMemory.clear(conversationId);它只应该清理模型上下文。是否同时删除数据库历史需要由业务接口单独决定。例如可以区分清空上下文 重新开始模型对话但保留历史记录 删除会话 删除或软删除业务历史7.2 Redis TTL过期不等于会话被删除Redis过期只代表近期上下文缓存失效。用户再次进入旧会话时可以选择从数据库加载最近几轮重建ChatMemory根据完整历史生成摘要后恢复直接作为新上下文开始。不能把Redis Key不存在直接理解为数据库中的会话也应该删除。7.3 maxMessages不是越大越好窗口设置过小模型容易缺失上下文设置过大又会增加Token成本和历史干扰。可以先根据业务设置普通问答1020条消息 代码、文档等长文本 消息窗口 Token阈值真正的企业项目不能只看消息数量还要结合System Prompt长度当前问题长度RAG召回内容Tool返回内容模型输出预算。7.4 Tool Calling消息不能简单压缩成文本本文中的Redis DTO只适合普通的SystemUserAssistant。复杂Agent场景还可能包含工具调用请求工具参数工具执行结果多步骤中间消息。Spring AI当前默认顺序下Memory Advisor在工具调用循环之外主要保存最终用户消息和最终助手回复不会把中间工具消息全部写入普通ChatMemory官方文档说明这也与Spring AI 1.x的默认行为一致。但企业项目仍然应该单独保存Tool审计记录toolName toolArguments toolResult latency success traceId userId permissionResultChatMemory解决上下文Tool日志解决执行追踪两者不能混为一谈。7.5 并发请求可能破坏同一会话顺序如果同一个conversationId同时收到两个请求请求A读取旧窗口 请求B读取旧窗口 请求A写回新窗口 请求B再次覆盖就可能出现消息丢失或顺序错乱。常见处理方式包括前端限制同一会话重复提交按conversationId加分布式锁使用版本号和CAS控制将同一会话消息串行化处理。对于普通聊天最简单有效的办法通常是一个会话上一次生成未结束前不允许再次提交新问题。8. 三种方案怎么选根据项目阶段可以直接这样选择。使用场景推荐方案本地学习、DemoMessageWindowChatMemory 内存单实例、小型内部工具MessageWindowChatMemory JDBC多实例企业应用MessageWindowChatMemory Redis 业务消息表超长对话Redis近期窗口 数据库完整历史 摘要压缩跨会话长期记忆在上述方案上增加长期记忆提取与向量召回常见问题1. JdbcChatMemoryRepository能代替业务消息表吗不建议。它可以让ChatMemory在重启后恢复但Repository仍然遵循当前记忆窗口的保存语义不等同于不可变的完整业务历史。2. Redis保存多久合适可以从17天开始根据用户活跃周期调整。TTL只用于清理近期缓存不能决定完整聊天历史的生命周期。3. conversationId由前端还是后端生成可以由前端发起创建请求但最终应该由后端生成、保存并校验归属关系。4. System Message会不会被窗口淘汰MessageWindowChatMemory会对SystemMessage进行特殊处理普通窗口淘汰主要针对其他消息。具体行为应结合项目实际使用的Spring AI版本验证。5. 什么时候需要摘要压缩当近期窗口已经无法同时满足上下文完整性和Token预算时再引入摘要压缩。摘要压缩属于更高一层的上下文管理策略不应该和最基础的ChatMemory代码混在一起完成。总结Spring AI中的对话记忆可以用三句话理解MessageChatMemoryAdvisor 负责自动读取和更新消息 MessageWindowChatMemory 负责控制模型能看到的近期窗口 ChatMemoryRepository 负责把当前窗口存到内存、Redis或数据库但在企业项目中还需要再补充一层业务消息表 负责保存完整、可查询、可审计的会话历史所以ChatMemory、Redis和数据库并不是三选一ChatMemory解决哪些消息进入模型Redis解决近期上下文的快速读取和多实例共享数据库解决完整历史、审计与恢复。ChatMemory管理的是模型需要看到的上下文而不是企业系统需要永久保存的全部聊天记录。理解这一点才能避免把一个简单的多轮对话Demo误认为已经完成了企业级记忆系统。下一篇预告下一课将继续解决大模型应用中的另一个高频问题Spring AI结构化输出怎么做Prompt约束、Bean映射与JSON Schema如何选择会重点介绍为什么直接解析模型返回的JSON容易失败如何将模型输出映射成Java对象结构化输出如何增加校验和降级流式输出与结构化结果应该怎么配合。 一起讨论你的项目目前使用的是内存、Redis还是数据库保存对话 在多实例部署或流式输出场景中是否遇到过上下文丢失、消息覆盖或历史记录不完整的问题 欢迎在评论区分享你的方案和实际问题。 推荐专栏AI 技术专栏从 0 到 1 学习 AI 应用开发持续分享 Spring AI、RAG、Agent、企业级 AI 项目实战。 https://blog.csdn.net/qupengkun/category_13184360.htmlAI 转型日记记录一名 10 年 Java 开发者从传统后端转向 AI 应用开发的全过程。 https://blog.csdn.net/qupengkun/category_13183497.htmlAI 开源项目实战持续分享Java AI业务项目、AI Coding效率工具和工程治理实践。 https://blog.csdn.net/qupengkun/category_13189373.html 关于作者QCoding专注 AI 应用开发与 Java 技术实践。持续分享 Spring AI、RAG、Agent、企业级 AI 项目实战、架构设计与职业成长。