SpringAI 集成 MySQL 实现会话记忆

前因:由于Spring AI 的会话记忆(ChatMemory)默认只实现了内存会话记忆(InMemoryChatMemory),一旦服务挂了或者服务器重启了,之前了用户对话内容就全都会丢失。

所以我们需要将会话记忆持久化到磁盘中,这就有多种方案,例如:持久化到文件中、持久化到数据库中.....

接下来我将实现将AI会话记忆持久化到数据库中。

  1. 准备工作:
xml
复制代码
<!-- Mysql连接池 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.28</version> <scope>runtime</scope> </dependency> <dependency> <groupId>com.baomidou</groupId> <!--看清楚是boot3,调试报错一晚上才发现之前写的是boot,导致一直报错初始化MapperBean失败--> <artifactId>mybatis-plus-spring-boot3-starter</artifactId> <version>3.5.12</version> </dependency> <!--解决实体类生成的swagger注解报错--> <dependency> <groupId>io.springfox</groupId> <artifactId>springfox-boot-starter</artifactId> <version>3.0.0</version> <!-- 请检查是否有更新版本 --> </dependency>
yml
复制代码
# mybatis 配置 mybatis-plus: mapper-locations: classpath*:mapper/*Mapper.xml #xml扫描,多个目录用逗号或者分号分隔(告诉 Mapper 所对应的 XML 文件位置) typeAliasesPackage: com.bossen.aiagent.domain # 配置slq打印日志 configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: # logic-delete-field: isDel #全局逻辑删除字段值 3.3.0开始支持,详情看下面。 logic-delete-value: 1 # 逻辑已删除值(默认为 1) logic-not-delete-value: 0 # 逻辑未删除值(默认为 0) spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://127.0.0.1:3306/bossen_ai_agent?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimeZone=GMT+8 username: root password: 123456
  • 启动类上加上包扫描:@MapperScan("com.bossen.aiagent.mapper")
sql
复制代码
java
复制代码
@Data @EqualsAndHashCode(callSuper = false) @Accessors(chain = true) @TableName("t_ai_db_chat_memory") @ApiModel(value="AiDbChatMemory对象", description="") public class AiDbChatMemory implements Serializable { private static final long serialVersionUID=1L; @ApiModelProperty(value = "主键") @TableId(value = "id",type = IdType.ASSIGN_ID) private String id; @ApiModelProperty(value = "会话ID") @TableField("conversation_id") private String conversationId; @ApiModelProperty(value = "会话类型(user、assistant、system、tool)") @TableField("message_type") private String messageType; @ApiModelProperty(value = "消息内容") @TableField("content") private String content; @ApiModelProperty(value = "元数据") @TableField(value = "meta_data",typeHandler = MybatisJsonMapTypeHandler.class) private Map<String, Object> metaData; @ApiModelProperty(value = "media") @TableField(value = "media",typeHandler = MybatisJsonListTypeHandler.class) private List<Media> media; @ApiModelProperty(value = "工具调用") @TableField(value = "tool_calls",typeHandler = MybatisJsonListTypeHandler.class) private List<AssistantMessage.ToolCall> toolCalls; @ApiModelProperty(value = "工具返回信息") @TableField(value = "tool_responses",typeHandler = MybatisJsonListTypeHandler.class) private List<ToolResponseMessage.ToolResponse> toolResponses; @ApiModelProperty(value = "创建时间") @TableField(value = "create_time",fill = FieldFill.INSERT) private LocalDateTime createTime; }
java
复制代码
public interface AiDbChatMemoryMapper extends BaseMapper<AiDbChatMemory> { } public interface AiDbChatMemoryService extends IService<AiDbChatMemory> { } @Service public class AiDbChatMemoryServiceImpl extends ServiceImpl<AiDbChatMemoryMapper, AiDbChatMemory> implements AiDbChatMemoryService { }

实现过程:

java
复制代码
public class DatabaseChatMemory implements ChatMemory { private final AiDbChatMemoryService aiDbChatMemoryService; public DatabaseChatMemory() { aiDbChatMemoryService = SpringUtil.getBean(AiDbChatMemoryService.class); } @Override public void add(String conversationId, List<Message> messages) { aiDbChatMemoryService.saveBatch(messages.stream().map(message -> Message2AiDbChatMemory(conversationId, message)).collect(Collectors.toList())); } @Override public List<Message> get(String conversationId, int lastN) { List<AiDbChatMemory> dbChatMemoryList = aiDbChatMemoryService.list(Wrappers.<AiDbChatMemory>lambdaQuery() .eq(AiDbChatMemory::getConversationId, conversationId) .last(lastN != 0,"limit " + lastN) //根据ID降序取最新的lastN条数据 .orderByDesc(AiDbChatMemory::getId)); return dbChatMemoryList.stream() .sorted(Comparator.comparing(AiDbChatMemory::getId)) //根据ID升序保持会话顺序 .map(this::AiDbChatMemory2Message) .toList(); } @Override public void clear(String conversationId) { aiDbChatMemoryService.remove(Wrappers.<AiDbChatMemory>lambdaUpdate().eq(AiDbChatMemory::getConversationId, conversationId)); } /** * 将Message转换为AiDbChatMemory(序列化) * @param message * @return */ private AiDbChatMemory Message2AiDbChatMemory(String conversationId,Message message){ MessageType messageType = message.getMessageType(); AiDbChatMemory aiDbChatMemory = new AiDbChatMemory(); aiDbChatMemory.setMessageType(messageType.name()); aiDbChatMemory.setConversationId(conversationId); switch (message) { case UserMessage userMessage -> { aiDbChatMemory.setContent(userMessage.getText()); aiDbChatMemory.setMetaData(userMessage.getMetadata()); aiDbChatMemory.setMedia(userMessage.getMedia()); } case SystemMessage systemMessage -> { aiDbChatMemory.setContent(systemMessage.getText()); aiDbChatMemory.setMetaData(systemMessage.getMetadata()); } case AssistantMessage assistantMessage -> { aiDbChatMemory.setContent(assistantMessage.getText()); aiDbChatMemory.setMetaData(assistantMessage.getMetadata()); aiDbChatMemory.setMedia(assistantMessage.getMedia()); aiDbChatMemory.setToolCalls(assistantMessage.getToolCalls()); } case ToolResponseMessage toolResponseMessage -> { aiDbChatMemory.setContent(toolResponseMessage.getText()); aiDbChatMemory.setMetaData(toolResponseMessage.getMetadata()); aiDbChatMemory.setToolResponses(toolResponseMessage.getResponses()); } default -> throw new IllegalArgumentException("Unknown message type: " + messageType); } return aiDbChatMemory; } /** * 将AiDbChatMemory转换为Message(反序列化) * @param aiDbChatMemory * @return */ private Message AiDbChatMemory2Message(AiDbChatMemory aiDbChatMemory){ String messageType = aiDbChatMemory.getMessageType(); String content = aiDbChatMemory.getContent(); Map<String, Object> metaData = aiDbChatMemory.getMetaData(); List<Media> media = aiDbChatMemory.getMedia(); List<AssistantMessage.ToolCall> toolCalls = aiDbChatMemory.getToolCalls(); metaData = metaData == null ? Map.of() : metaData; media = media == null ? Collections.emptyList() : media; toolCalls = toolCalls == null ? Collections.emptyList() : toolCalls; return switch (messageType) { case "USER" -> new UserMessage(content, media, metaData); case "SYSTEM" -> new SystemMessage(content); case "ASSISTANT" -> new AssistantMessage(content, metaData, toolCalls, media); case "TOOL" -> new ToolResponseMessage(Collections.emptyList(), metaData); default -> throw new IllegalArgumentException("Unknown message type: " + messageType); }; } }

遇到的问题:由于我在数据库字段用了几个json字段,然后我实体类是用的Map<String, Object>和List<T>格式的字段,mybatisplus在类型转换的时候不知道要将这两个类型的字段映射到mysql对应的什么类型字段上去,所以我们需要自己==实现Map<String, Object>、List<T>与json字段的相互转换==。因为mybatisplus在3.4.x以后可以直接使用它提供的抽象类来简化开发。直接上代码

java
复制代码
public class MybatisJsonListTypeHandler<T> extends AbstractJsonTypeHandler<List<T>> { private static final ObjectMapper objectMapper = new ObjectMapper(); private final Class<T> type; public MybatisJsonListTypeHandler(Class<T> type) { super(type); this.type = type; } @Override public List<T> parse(String json) { if (json == null) { return null; } try { return objectMapper.readValue(json, new TypeReference<List<T>>() {}); } catch (Exception e) { throw new RuntimeException("Error parsing JSON string to List", e); } } @Override public String toJson(List<T> obj) { if (obj == null) { return null; } try { return objectMapper.writeValueAsString(obj); } catch (Exception e) { throw new RuntimeException("Error converting List to JSON string", e); } } }
java
复制代码
public class MybatisJsonMapTypeHandler extends AbstractJsonTypeHandler<Map<String, Object>> { private static final ObjectMapper mapper = new ObjectMapper(); public MybatisJsonMapTypeHandler(Class<?> type) { super(type); } @Override public Map<String, Object> parse(String json) { try { return json == null ? new HashMap<>() : mapper.readValue(json, Map.class); } catch (Exception e) { throw new RuntimeException(e); } } @Override public String toJson(Map<String, Object> obj) { try { return mapper.writeValueAsString(obj); } catch (Exception e) { throw new RuntimeException(e); } } }

然后在ChatClient初始化的时候指定DatabaseChatMemory。就大功告成了~

java
复制代码
public LoveApp(ChatModel dashscopeChatModel) { //聊天记忆 // ChatMemory chatMemory = new InMemoryChatMemory(); // String baseDir = System.getProperty("user.dir") + "/tmp/chat-memory"; // ChatMemory chatMemory = new FileBaseChatMemory(baseDir); ChatMemory chatMemory = new DatabaseChatMemory(); chatClient = ChatClient.builder(dashscopeChatModel) .defaultSystem(DEFAULT_SYSTEM) .defaultAdvisors(new MessageChatMemoryAdvisor(chatMemory) //自定义日志Advisor 按需开启 ,new MyLoggerAdvisor() //自定义重读Advisor 按需开启 // ,new ReReadingAdvisor() ) .build(); }

接下来我们去测试用例测试一下:

java
复制代码
@SpringBootTest class LoveAppTest { @Resource private LoveApp loveApp; @Test void test() { String chatId = UUID.randomUUID().toString(); String message = "你好,我是程序员bossen,你是谁"; String answer = loveApp.doChat(message,chatId); Assertions.assertNotNull(answer); } }

执行结果:

text
复制代码
JDBC Connection [HikariProxyConnection@692860601 wrapping com.mysql.cj.jdbc.ConnectionImpl@5e781b4f] will be managed by Spring ==> Preparing: INSERT INTO t_ai_db_chat_memory ( id, conversation_id, message_type, content, meta_data, media, create_time ) VALUES ( ?, ?, ?, ?, ?, ?, ? ) ==> Parameters: 1922863225824542722(String), fe1aae62-876a-458a-b2ef-06f8e1cd4856(String), USER(String), 你好,我是程序员bossen,你是谁(String), {"messageType":"USER"}(String), [](String), null 2025-05-15T11:54:52.896+08:00 INFO 27688 --- [bossen-ai-agent] [ main] c.b.aiagent.advisor.MyLoggerAdvisor : AI REQUEST: 你好,我是程序员bossen,你是谁 2025-05-15T11:54:58.575+08:00 INFO 27688 --- [bossen-ai-agent] [ main] c.b.aiagent.advisor.MyLoggerAdvisor : AI RESPONSE: 你好,bossen!我是专注于恋爱心理领域的咨询专家。很高兴认识你!如果你有任何恋爱相关的困惑或难题,都可以随时向我倾诉,我会尽力为你提供专业且实用的建议。 想先了解一下你的现状吗?比如你是单身、正在恋爱,还是已婚呢?这样我可以更有针对性地提问和帮助你。 如果是单身,可以聊聊你在拓展社交圈或追求心仪对象时遇到的困扰; 如果是恋爱中,可以分享你们因为沟通或习惯差异产生的矛盾; 如果是已婚,也可以谈谈你在家庭责任或亲属关系处理上的问题。 期待听到你的故事!😊 MybatisJsonMapTypeHandler(interface java.util.Map) MybatisJsonListTypeHandler(interface java.util.List) MybatisJsonListTypeHandler(interface java.util.List) JDBC Connection [HikariProxyConnection@750052529 wrapping com.mysql.cj.jdbc.ConnectionImpl@5e781b4f] will be managed by Spring ==> Preparing: INSERT INTO t_ai_db_chat_memory ( id, conversation_id, message_type, content, meta_data, media, tool_calls, create_time ) VALUES ( ?, ?, ?, ?, ?, ?, ?, ? ) ==> Parameters: 1922863250164088834(String), fe1aae62-876a-458a-b2ef-06f8e1cd4856(String), ASSISTANT(String), 你好,bossen!我是专注于恋爱心理领域的咨询专家。很高兴认识你!如果你有任何恋爱相关的困惑或难题,都可以随时向我倾诉,我会尽力为你提供专业且实用的建议。 想先了解一下你的现状吗?比如你是单身、正在恋爱,还是已婚呢?这样我可以更有针对性地提问和帮助你。 如果是单身,可以聊聊你在拓展社交圈或追求心仪对象时遇到的困扰; 如果是恋爱中,可以分享你们因为沟通或习惯差异产生的矛盾; 如果是已婚,也可以谈谈你在家庭责任或亲属关系处理上的问题。 期待听到你的故事!😊(String), {"finishReason":"STOP","id":"2fee43bc-6f9b-9d8e-900b-4066711ac933","role":"ASSISTANT","messageType":"ASSISTANT","reasoningContent":""}(String), [](String), [](String), null 2025-05-15T11:54:58.682+08:00 WARN 27688 --- [bossen-ai-agent] [ Thread-1] c.a.n.common.executor.ThreadPoolManager : [ThreadPoolManager] Start destroying ThreadPool 2025-05-15T11:54:58.682+08:00 WARN 27688 --- [bossen-ai-agent] [ Thread-15] c.a.n.common.http.HttpClientBeanHolder : [HttpClientBeanHolder] Start destroying common HttpClient 2025-05-15T11:54:58.683+08:00 WARN 27688 --- [bossen-ai-agent] [ Thread-1] c.a.n.common.executor.ThreadPoolManager : [ThreadPoolManager] Destruction of the end 2025-05-15T11:54:58.683+08:00 WARN 27688 --- [bossen-ai-agent] [ Thread-15] c.a.n.common.http.HttpClientBeanHolder : [HttpClientBeanHolder] Destruction of the end 2025-05-15T11:54:58.686+08:00 INFO 27688 --- [bossen-ai-agent] [ionShutdownHook] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Shutdown initiated... 2025-05-15T11:54:58.697+08:00 INFO 27688 --- [bossen-ai-agent] [ionShutdownHook] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Shutdown completed.

我们看到执行了insert语句,并成功将数据插入到数据库表中

image.png
0个评论
点击登录,快来和大家讨论吧~
表情
图片
暂无评论
Kevin
下载 APP