Spring AI对话记忆实战:ChatMemory、Redis与数据库怎么选?|Java转AI第5课

Spring AI对话记忆实战:ChatMemory、Redis与数据库怎么选?|Java转AI第5课

目录

1. ChatMemory、Advisor和Repository分别负责什么

1.1 ChatMemory:决定模型能够看到哪些消息

1.2 ChatMemoryRepository:决定消息存在哪里

1.3 MessageChatMemoryAdvisor:自动完成上下文读写

2. 使用MessageWindowChatMemory实现多轮对话

2.1 配置ChatMemory

2.2 注册MessageChatMemoryAdvisor

2.3 调用时传入conversationId

3. conversationId应该怎么设计

3.1 不要把userId直接当作conversationId

3.2 conversationId不能只依赖前端传入

3.3 会话ID和请求traceId不能混用

4. 使用Redis保存近期对话

4.1 添加Redis依赖

4.2 不建议直接序列化Message接口

4.3 实现RedisChatMemoryRepository

4.4 接入MessageWindowChatMemory

5. 为什么还要单独保存完整聊天历史

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 { List<String> findConversationIds(); List<Message> findByConversationId( String conversationId); void saveAll( String conversationId, List<Message> messages); void deleteByConversationId( String conversationId); }

它可以有不同实现:

  • 内存;
  • JDBC;
  • Redis;
  • MongoDB;
  • Neo4j。

但必须特别注意saveAll()的语义:

它会用新的消息集合替换当前会话中已有的消息,而不是向数据库中无限追加历史记录。

这意味着:

ChatMemoryRepository保存的是当前记忆窗口,不天然等于完整聊天历史。

1.3 MessageChatMemoryAdvisor:自动完成上下文读写

如果没有Advisor,每次请求都需要手动:

  1. 查询历史消息;
  2. 将历史消息放进Prompt;
  3. 调用大模型;
  4. 保存用户问题;
  5. 保存模型回答。

Spring AI提供的MessageChatMemoryAdvisor,可以自动完成调用前后的记忆处理。

调用前,它会从ChatMemory读取历史消息,并以Message集合的形式加入Prompt;调用完成后,再将本轮消息更新到记忆中。

三者的职责可以总结为:

组件主要职责
MessageChatMemoryAdvisor调用前读取历史,调用后更新记忆
ChatMemory控制保留哪些上下文
ChatMemoryRepository决定消息存储在哪里


2. 使用MessageWindowChatMemory实现多轮对话

先从最简单的内存方案开始。

2.1 配置ChatMemory

@Configuration public class ChatMemoryConfig { @Bean public ChatMemory chatMemory() { return MessageWindowChatMemory.builder() .maxMessages(12) .build(); } }

如果没有指定其他ChatMemoryRepository,可以使用内存存储保存当前消息窗口。

这种方案适合:

  • 本地学习;
  • 单元测试;
  • Demo验证;
  • 单实例临时工具。

2.2 注册MessageChatMemoryAdvisor

@Configuration 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 调用时传入conversationId

@Service @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,第二次调用时就能够读取第一轮的历史消息。

如果第二次换成另一个ID:

chatService.chat( "conversation-002", "我刚才说使用的是什么框架?" );

它就属于另一段独立会话,不应该读取conversation-001的内容。


3. conversationId应该怎么设计

很多对话记忆问题,并不是ChatMemory本身导致的,而是conversationId设计不合理。

3.1 不要把userId直接当作conversationId

一个用户可能同时拥有多个会话:

用户10001 ├─ Spring AI项目讨论 ├─ RAG架构分析 └─ 简历优化

如果直接使用userId作为会话ID,这些完全不同的主题就会进入同一个上下文。

最终可能出现:

  • 不同任务互相干扰;
  • 历史消息越来越长;
  • 模型混淆当前讨论对象;
  • 用户无法独立删除某个会话。

更合理的关系是:

一个userId ↓ 对应多个conversationId

例如:

userId:10001 conversationId: chat_20260721_001 chat_20260721_002 chat_20260722_001

3.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> <groupId>org.springframework.boot</groupId> <artifactId> spring-boot-starter-data-redis </artifactId> </dependency>

4.2 不建议直接序列化Message接口

Spring AI的消息存在多种实现:

  • UserMessage
  • AssistantMessage
  • SystemMessage
  • 工具调用相关消息

直接序列化Message接口,容易遇到:

  • 多态反序列化;
  • 框架升级后字段变化;
  • 消息Metadata丢失;
  • Tool消息无法正确恢复。

为了突出核心流程,本文先定义一个简单DTO,只处理普通聊天中的三种消息:

public record MemoryMessageDTO( String type, String content) { }

生产项目如果需要保存Tool Calling、多模态内容或消息Metadata,需要继续扩展DTO,不能只保留content

4.3 实现RedisChatMemoryRepository

@Component @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 List<String> findConversationIds() { Set<String> conversationIds = redisTemplate.opsForSet() .members(INDEX_KEY); if (conversationIds == null || conversationIds.isEmpty()) { return List.of(); } return conversationIds.stream() .filter(this::memoryExists) .toList(); } @Override public List<Message> findByConversationId( String conversationId) { String json = redisTemplate.opsForValue() .get(buildKey(conversationId)); if (json == null || json.isBlank()) { return List.of(); } try { List<MemoryMessageDTO> 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, List<Message> messages) { List<MemoryMessageDTO> 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 ); }; } }

这段实现主要解决了四个问题:

  1. conversationId隔离会话;
  2. 将消息窗口保存为JSON;
  3. 设置TTL,清理长期不活跃的缓存;
  4. 使用Set维护会话索引,避免通过KEYS扫描Redis。

还需要特别注意:

saveAll(conversationId, messages)

不是追加一条消息,而是覆盖当前会话保存的消息集合,这与ChatMemoryRepository的接口约定一致。

4.4 接入MessageWindowChatMemory

@Configuration public class ChatMemoryConfig { @Bean public ChatMemory chatMemory( RedisChatMemoryRepository repository) { return MessageWindowChatMemory.builder() .chatMemoryRepository(repository) .maxMessages(12) .build(); } }

上层业务代码不需要修改。

完整调用链变成:

ChatClient ↓ MessageChatMemoryAdvisor ↓ MessageWindowChatMemory ↓ RedisChatMemoryRepository ↓ Redis

5. 为什么还要单独保存完整聊天历史

接入Redis以后,ChatMemory已经可以跨实例共享,但它仍然不能直接代替业务消息表。

原因就在于:

MessageWindowChatMemory

本身会淘汰窗口之外的消息。

假设窗口最多保存12条消息,随着对话增加,Redis中的内容会不断变化:

第1次:消息1~12 第2次:消息3~14 第3次:消息5~16

这是正确的上下文窗口行为,但消息1~4不能因此从企业系统中永久消失。

Spring AI文档也明确区分了Chat Memory和Chat History:

  • Chat 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

如果每个片段都落库,不仅会产生大量写操作,还会留下许多不完整记录。

更合理的方式是:

  1. 用户问题进入系统后立即保存;
  2. 流式过程中在内存中拼接完整答案;
  3. 流式正常结束后保存模型完整回复;
  4. 发生异常时记录失败状态和已输出内容。

Flux为例,可以采用类似思路:

public Flux<String> 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成本和历史干扰。

可以先根据业务设置:

普通问答:10~20条消息 代码、文档等长文本: 消息窗口 + Token阈值

真正的企业项目不能只看消息数量,还要结合:

  • System Prompt长度;
  • 当前问题长度;
  • RAG召回内容;
  • Tool返回内容;
  • 模型输出预算。

7.4 Tool Calling消息不能简单压缩成文本

本文中的Redis DTO只适合普通的:

  • System;
  • User;
  • Assistant。

复杂Agent场景还可能包含:

  • 工具调用请求;
  • 工具参数;
  • 工具执行结果;
  • 多步骤中间消息。

Spring AI当前默认顺序下,Memory Advisor在工具调用循环之外,主要保存最终用户消息和最终助手回复,不会把中间工具消息全部写入普通ChatMemory;官方文档说明,这也与Spring AI 1.x的默认行为一致。

但企业项目仍然应该单独保存Tool审计记录:

toolName toolArguments toolResult latency success traceId userId permissionResult

ChatMemory解决上下文,Tool日志解决执行追踪,两者不能混为一谈。

7.5 并发请求可能破坏同一会话顺序

如果同一个conversationId同时收到两个请求:

请求A读取旧窗口 请求B读取旧窗口 请求A写回新窗口 请求B再次覆盖

就可能出现消息丢失或顺序错乱。

常见处理方式包括:

  • 前端限制同一会话重复提交;
  • 按conversationId加分布式锁;
  • 使用版本号和CAS控制;
  • 将同一会话消息串行化处理。

对于普通聊天,最简单有效的办法通常是:

一个会话上一次生成未结束前,不允许再次提交新问题。


8. 三种方案怎么选

根据项目阶段,可以直接这样选择。

使用场景推荐方案
本地学习、DemoMessageWindowChatMemory + 内存
单实例、小型内部工具MessageWindowChatMemory + JDBC
多实例企业应用MessageWindowChatMemory + Redis + 业务消息表
超长对话Redis近期窗口 + 数据库完整历史 + 摘要压缩
跨会话长期记忆在上述方案上增加长期记忆提取与向量召回

常见问题

1. JdbcChatMemoryRepository能代替业务消息表吗?

不建议。

它可以让ChatMemory在重启后恢复,但Repository仍然遵循当前记忆窗口的保存语义,不等同于不可变的完整业务历史。

2. Redis保存多久合适?

可以从1~7天开始,根据用户活跃周期调整。

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.html

🚀AI 转型日记

记录一名 10 年 Java 开发者从传统后端转向 AI 应用开发的全过程。

👉 https://blog.csdn.net/qupengkun/category_13183497.html

🧩AI 开源项目实战

持续分享Java AI业务项目、AI Coding效率工具和工程治理实践。

👉 https://blog.csdn.net/qupengkun/category_13189373.html


👨‍💻 关于作者

QCoding

专注 AI 应用开发与 Java 技术实践。

持续分享 Spring AI、RAG、Agent、企业级 AI 项目实战、架构设计与职业成长。