Spring AI 1.x 系列【28】基于内存和 MySQL 的多轮对话实现案例
文章目录1. 前言2. 内存记忆2.1 构建 ChatMemory2.2 构建记忆增强器2.3 构建对话客户端2.4 会话 ID2.4.1 重要性2.4.2 多会话列表实现2.5 多轮会话测试3. 数据库记忆3.1 引入依赖3.2 属性配置3.3 数据库结构初始化3.4 方言支持3.5 创建 JdbcChatMemoryRepository3.6 构建对话客户端3.7 多轮会话测试1. 前言大模型的 “记忆” 本质是把历史信息塞进本次请求的上下文而非模型真的记住了内容。实现多轮对话的核心是维护一个messages数组。每一轮对话都需要将用户的最新提问和模型的回复追加到此数组中并将其作为下一次请求的输入。示例首先第一轮对话添加用户消息UserMessageuserMessage1UserMessage.builder().text(我叫张三今年30岁养了一只叫“可乐”的柯基犬家住上海。).build();Stringcontent1zhiPuAiChatClient.prompt().messages(userMessage1).call().content();第二轮对话时消息数组添加第一次对话用户消息、大模型回复内容、用户的最新提问AssistantMessageassistantMessageAssistantMessage.builder().content(content1).build();UserMessageuserMessage2UserMessage.builder().text(你知道我叫什么名字吗).build();Stringcontent2zhiPuAiChatClient.prompt().messages(userMessage1,assistantMessage,userMessage2).call().content();System.out.println(content1);System.out.println(content2);输出示例2. 内存记忆基于ConcurrentHashMap存储消息仅在应用内存中生效应用重启后数据丢失适用于无依赖、轻量、高性能适合开发测试、临时演示场景。2.1 构建 ChatMemorySpring AI只提供了一个MessageWindowChatMemory滑动窗口机制实现核心特点维持一个固定大小的消息窗口当消息数量超过上限时自动淘汰旧消息。特殊处理SystemMessage系统消息新增系统消息时自动删除所有旧的系统消息保证只有最新的系统指令生效。消息淘汰时优先保留系统消息只淘汰普通对话消息。默认窗口大小20条消息可自定义。通过builder()方法构造MessageWindowChatMemorychatMemoryMessageWindowChatMemory.builder().maxMessages(10).build();2.2 构建记忆增强器记忆功能会由MessageChatMemoryAdvisor自动管理两种聊天记忆Advisor核心对比特性MessageChatMemoryAdvisorPromptChatMemoryAdvisor记忆方式直接拼接消息列表嵌入系统提示词文本系统消息强制置顶 保留原位置内部嵌入记忆灵活性低固定消息格式高自定义提示词模板适用场景通用场景简单直接提示词工程精准控制上下文实现复杂度低中基于提示词工程这里使用MessageChatMemoryAdvisor传递MessageWindowChatMemoryMessageChatMemoryAdvisormemoryAdvisorMessageChatMemoryAdvisor.builder(chatMemory).conversationId(default_conversationId)// 默认对话 ID 默认为 default.build();2.3 构建对话客户端将MessageChatMemoryAdvisor传递给ChatClientChatClientchatClientChatClient.builder(zhiPuAiChatModel).defaultSystem(你是一个智能助手).defaultAdvisors(memoryAdvisor).build();2.4 会话 ID2.4.1 重要性会话ID是会话的唯一主键用于将同一会话的所有消息用户 / 助手 / 系统归为一组并与其他会话彻底隔离。作用说明无会话 ID 的后果会话隔离区分不同用户 / 不同会话的对话历史避免消息混乱所有用户共享同一份记忆回答相互干扰上下文关联保证同一会话内多轮对话的连贯性AI 能基于历史生成连贯回答每次请求都是 “单轮对话”AI 失忆记忆寻址让 ChatMemory 快速定位并加载当前会话的历史消息无法加载历史上下文丢失多用户支持支撑多用户并发场景为每个用户维护独立对话状态多用户会话互相串线数据泄露持久化标识持久化记忆如 Redis/MySQL中作为会话消息的外键关联重启后会话记忆丢失无法续聊会话ID是ChatMemory接口的核心操作参数所有记忆实现均围绕它展开publicinterfaceChatMemory{// 向指定会话添加消息voidadd(StringconversationId,ListMessagemessages);// 获取指定会话的历史消息ListMessageget(StringconversationId);// 清空指定会话的记忆voidclear(StringconversationId);}Spring AI统一使用ChatMemory.CONVERSATION_ID作为上下文传递的键名StringDEFAULT_CONVERSATION_IDdefault;StringCONVERSATION_IDchat_memory_conversation_id;BaseChatMemoryAdvisor中定义了获取会话ID的核心逻辑defaultStringgetConversationId(MapString,Objectcontext,StringdefaultConversationId){// 1. 参数合法性校验Assert.notNull(context,context cannot be null);Assert.noNullElements(context.keySet().toArray(),context cannot contain null keys);Assert.hasText(defaultConversationId,defaultConversationId cannot be null or empty);// 2. 优先从上下文获取对话ID无则使用默认值returncontext.containsKey(ChatMemory.CONVERSATION_ID)?context.get(ChatMemory.CONVERSATION_ID).toString():defaultConversationId;}2.4.2 多会话列表实现例如豆包、元宝等AI助手中左侧有多个对话窗口使用同一个用户UID 不同的窗口会话ID用户新建对话 → 后端生成一个新的会话ID每个会话ID对应一条独立的对话记录切换对话窗口 → 切换会话ID加载对应历史记忆完全隔离互不干扰定义一个简单的ID生成器/** * 分步式会话 ID 生成器 * 流程先生成随机ID → 传入用户ID随机ID → 生成最终会话ID */publicfinalclassConversationIdGenerator{// 会话ID前缀privatestaticfinalStringCONVERSATION_PREFIXconv;// 短ID长度privatestaticfinalintSHORT_ID_LENGTH8;// 禁止实例化privateConversationIdGenerator(){}/** * 第一步生成纯随机短ID无任何前缀 * return 8位随机字符串 */publicstaticStringgenerateRandomShortId(){returnUUID.randomUUID().toString().replace(-,).substring(0,SHORT_ID_LENGTH);}/** * 第二步根据 用户ID 第一步生成的随机ID生成最终会话ID * param userId 业务用户ID必填 * param randomId 第一步生成的随机ID必填 * return 标准会话IDconv:userId:randomId */publicstaticStringgenerateConversationId(StringuserId,StringrandomId){if(userIdnull||userId.isBlank()){thrownewIllegalArgumentException(用户ID不能为空);}if(randomIdnull||randomId.isBlank()){thrownewIllegalArgumentException(随机ID不能为空);}returnString.format(%s:%s:%s,CONVERSATION_PREFIX,userId,randomId);}}2.5 多轮会话测试模拟多轮会话// 1. 业务用户ID你的系统用户IDStringuserIduser_10086;// 2. 第一步先生成独立随机ID模拟新建一个会话窗口同一个会话窗口中都需要将这个ID 传过来StringrandomIdConversationIdGenerator.generateRandomShortId();System.out.println(生成的随机IDrandomId);// 示例a7f29d3c// 3. 第二步传入 用户ID 随机ID生成最终会话IDStringconversationIdConversationIdGenerator.generateConversationId(userId,randomId);System.out.println(最终会话IDconversationId);// 示例conv:user_10086:a7f29d3c// 4. 第一轮对话Stringresponse1chatClient.prompt().user(我叫张三今年30岁养了一只叫“可乐”的柯基犬家住上海。).advisors(advisorSpec-advisorSpec.param(ChatMemory.CONVERSATION_ID,conversationId)).call().content();System.out.println( 第一轮对话 );System.out.println(response1);// 5. 第二轮对话Stringresponse2chatClient.prompt().user(你知道我叫什么名字吗).advisors(advisorSpec-advisorSpec.param(ChatMemory.CONVERSATION_ID,conversationId)).call().content();System.out.println(\n 第二轮对话 );System.out.println(response2);控制台输出生成的随机ID58990584最终会话IDconv:user_10086:58990584第一轮对话你好张三很高兴认识你。30岁住在上海还养了一只可爱的柯基犬可乐听起来是很棒的生活柯基犬以其短腿、大耳朵和友好性格而闻名可乐真是个可爱的名字。 你平时和可乐一起在上海有哪些活动呢比如喜欢去哪些公园散步或者有什么特别的养狗心得可以分享第二轮对话是的我记得你叫张三。你之前告诉我你今年30岁养了一只叫可乐的柯基犬并且住在上海。在ChatMemory可以看到内存中存储的数据3. 数据库记忆JdbcChatMemoryRepository是一个内置实现使用JDBC将消息存储在关系数据库中。它开箱即用支持多个数据库适合需要持续存储聊天内存的应用。3.1 引入依赖需要引入JDBC存储记忆对应的依赖!-- Spring AI Chat Memory Repository JDBC --dependencygroupIdorg.springframework.ai/groupIdartifactIdspring-ai-starter-model-chat-memory-repository-jdbc/artifactId/dependencyMySQL驱动!-- MySQL Driver --dependencygroupIdcom.mysql/groupIdartifactIdmysql-connector-j/artifactIdscoperuntime/scope/dependency3.2 属性配置配置数据库连接spring:application:name:ai-chat-demo# MySQL 数据库配置用于对话记忆持久化datasource:url:jdbc:mysql://192.168.1.235:3306/admin?useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltruedriver-class-name:com.mysql.cj.jdbc.Driverusername:rootpassword:root数据库记忆存储相关配置属性描述默认值spring.ai.chat.memory.repository.jdbc.initialize-schema控制数据库结构初始化时机可选值embedded、always、neverembeddedspring.ai.chat.memory.repository.jdbc.schema数据库初始化脚本路径支持classpath路径与平台占位符classpath:org/springframework/ai/chat/memory/repository/jdbc/schema-platform.sqlspring.ai.chat.memory.repository.jdbc.platform当使用platform占位符时的数据库平台自动检测3.3 数据库结构初始化自动配置会在启动时自动创建SPRING_AI_CHAT_MEMORY表使用对应数据库的SQL脚本。默认仅对嵌入式数据库H2、HSQL、Derby等执行结构初始化。可通过以下配置控制初始化行为# 仅对嵌入式数据库执行默认spring.ai.chat.memory.repository.jdbc.initialize-schemaembedded# 始终执行初始化spring.ai.chat.memory.repository.jdbc.initialize-schemaalways# 从不执行配合Flyway/Liquibase使用spring.ai.chat.memory.repository.jdbc.initialize-schemanever自定义初始化脚本路径spring.ai.chat.memory.repository.jdbc.schemaclasspath:/custom/path/schema-mysql.sql数据表执行脚本--admin.SPRING_AI_CHAT_MEMORYdefinitionCREATETABLESPRING_AI_CHAT_MEMORY(conversation_idvarchar(36)COLLATEutf8mb4_unicode_ciNOTNULL,content textCOLLATEutf8mb4_unicode_ciNOTNULL,typeenum(USER,ASSISTANT,SYSTEM,TOOL)COLLATEutf8mb4_unicode_ciNOTNULL,timestamp timestampNOTNULL,KEYSPRING_AI_CHAT_MEMORY_CONVERSATION_ID_TIMESTAMP_IDX(conversation_id,timestamp))ENGINEInnoDBDEFAULTCHARSETutf8mb4COLLATEutf8mb4_unicode_ci;3.4 方言支持Spring AI会从你的jdbc:mysql://jdbc:postgresql:// URL中自动识别数据库无需配置。支持通过方言抽象处理多个关系数据库以下数据库开箱即用支持PostgreSQLMySQL/MariaDBSQL ServerHSQLDBOracle使用JdbcChatMemoryRepositoryDialect.from(DataSource)时框架会自动从JDBC URL中检测出正确的数据库方言。示例importorg.springframework.ai.chat.memory.JdbcChatMemory;importorg.springframework.ai.chat.memory.JdbcChatMemoryRepositoryDialect;importorg.springframework.jdbc.core.JdbcTemplate;importjavax.sql.DataSource;// 1. 注入 Spring 数据源 (DataSource)privatefinalDataSourcedataSource;// 2. 自动检测数据库方言核心代码JdbcChatMemoryRepositoryDialectdialectJdbcChatMemoryRepositoryDialect.from(dataSource);// 3. 构建 JdbcTemplateJdbcTemplatejdbcTemplatenewJdbcTemplate(dataSource);// 4. 构建 数据库版聊天记忆持久化会话ID、对话历史JdbcChatMemoryjdbcChatMemorynewJdbcChatMemory(jdbcTemplate,dialect);如果你的数据库不在自动检测支持列表如达梦、人大金仓、OceanBase手动实现接口即可。实现方言接口importorg.springframework.ai.chat.memory.JdbcChatMemoryRepositoryDialect;/** * 自定义数据库方言示例适配达梦数据库 */publicclassDmChatMemoryDialectimplementsJdbcChatMemoryRepositoryDialect{// 返回建表SQL存储对话历史的表OverridepublicStringgetCreateChatMemoryTableSql(){return CREATE TABLE IF NOT EXISTS AI_CHAT_MEMORY ( id BIGINT PRIMARY KEY AUTO_INCREMENT, conversation_id VARCHAR(255) NOT NULL, message_type VARCHAR(50) NOT NULL, content TEXT NOT NULL, create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ;}// 插入消息SQLOverridepublicStringgetInsertMessageSql(){returnINSERT INTO AI_CHAT_MEMORY (conversation_id, message_type, content) VALUES (?, ?, ?);}// 查询历史消息SQLOverridepublicStringgetSelectMessagesByConversationIdSql(){returnSELECT message_type, content FROM AI_CHAT_MEMORY WHERE conversation_id ? ORDER BY create_time ASC;}// 清空会话SQLOverridepublicStringgetDeleteMessagesByConversationIdSql(){returnDELETE FROM AI_CHAT_MEMORY WHERE conversation_id ?;}}使用自定义方言// 手动指定自定义方言JdbcChatMemoryRepositoryDialectdialectnewDmChatMemoryDialect();// 构建持久化聊天记忆JdbcChatMemoryjdbcChatMemorynewJdbcChatMemory(jdbcTemplate,dialect);3.5 创建 JdbcChatMemoryRepositorySpring AI提供自动配置JdbcChatMemoryRepository可以直接使用AutowiredJdbcChatMemoryRepositorychatMemoryRepository;ChatMemorychatMemoryMessageWindowChatMemory.builder().chatMemoryRepository(chatMemoryRepository).maxMessages(10).build();也可以手动创建ChatMemoryRepositorychatMemoryRepositoryJdbcChatMemoryRepository.builder().jdbcTemplate(jdbcTemplate).dialect(newPostgresChatMemoryRepositoryDialect()).build();ChatMemorychatMemoryMessageWindowChatMemory.builder().chatMemoryRepository(chatMemoryRepository).maxMessages(10).build();当前示例/** * 手动配置 JdbcChatMemoryRepository */BeanpublicJdbcChatMemoryRepositoryjdbcChatMemoryRepository(JdbcTemplatejdbcTemplate){returnJdbcChatMemoryRepository.builder().jdbcTemplate(jdbcTemplate).build();}/** * 基于数据库持久化的对话记忆 */BeanpublicChatMemorychatMemory(JdbcChatMemoryRepositoryrepository){returnMessageWindowChatMemory.builder().chatMemoryRepository(repository).maxMessages(20)// 最多保留20条历史消息.build();}3.6 构建对话客户端将PromptChatMemoryAdvisor传递给ChatClientBean(zhiPuAiChatClient)publicChatClientzhiPuAiChatClient(ZhiPuAiChatModelzhiPuAiChatModel,ChatMemorychatMemory){PromptChatMemoryAdvisormemoryAdvisorPromptChatMemoryAdvisor.builder(chatMemory).conversationId(default_conversationId)// 默认对话 ID 默认为 default.build();ChatClientchatClientChatClient.builder(zhiPuAiChatModel).defaultSystem(你是一个智能助手).defaultAdvisors(memoryAdvisor).build();returnchatClient;}3.7 多轮会话测试模拟多轮会话// 1. 业务用户ID你的系统用户IDStringuserIduser_10086;// 2. 第一步先生成独立随机ID模拟新建一个会话窗口同一个会话窗口中都需要将这个ID 传过来StringrandomIdConversationIdGenerator.generateRandomShortId();System.out.println(生成的随机IDrandomId);// 示例a7f29d3c// 3. 第二步传入 用户ID 随机ID生成最终会话IDStringconversationIdConversationIdGenerator.generateConversationId(userId,randomId);System.out.println(最终会话IDconversationId);// 示例conv:user_10086:a7f29d3c// 4. 第一轮对话Stringresponse1chatClient.prompt().user(我叫张三今年30岁养了一只叫“可乐”的柯基犬家住上海。).advisors(advisorSpec-advisorSpec.param(ChatMemory.CONVERSATION_ID,conversationId)).call().content();System.out.println( 第一轮对话 );System.out.println(response1);// 5. 第二轮对话Stringresponse2chatClient.prompt().user(你知道我叫什么名字吗).advisors(advisorSpec-advisorSpec.param(ChatMemory.CONVERSATION_ID,conversationId)).call().content();System.out.println(\n 第二轮对话 );System.out.println(response2);查看数据库根据会话ID保存了每条记录没有传会话ID的则使用默认的