LangChain4j 会话记忆存数据库?手把手教你自定义 ChatMemoryStore 接口实现
LangChain4j 会话记忆持久化实战:从接口设计到 MySQL 实现
当开发者需要构建一个具备长期记忆能力的对话系统时,LangChain4j 提供的ChatMemoryStore接口就像一把瑞士军刀——小巧但功能完备。这个看似简单的接口背后,隐藏着对话系统持久化存储的核心逻辑。本文将带您深入理解接口设计哲学,并手把手实现一个基于 MySQL 的存储方案。
1. 理解 ChatMemoryStore 的设计哲学
ChatMemoryStore接口只有三个方法,这种极简设计体现了"约定优于配置"的理念。在 Spring Boot 项目中引入 LangChain4j 依赖后,您会发现这个接口就像对话系统的记忆中枢:
<dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j</artifactId> <version>0.28.0</version> </dependency>接口的三个核心方法构成一个完整的 CRUD 循环:
- getMessages:对话系统的"记忆读取"操作
- updateMessages:兼具插入和更新功能的"记忆写入"操作
- deleteMessages:记忆清除功能
这种设计有两大精妙之处:
- 将复杂的对话树结构序列化责任交给实现类
- 通过合并 insert 和 update 操作简化接口
提示:在实际项目中,updateMessages 的实现要特别注意线程安全问题,特别是在高并发场景下。
2. MySQL 存储方案设计与实现
2.1 数据库表结构设计
我们采用关系型数据库最经典的"会话-消息"双表结构。以下是经过生产环境验证的表设计:
CREATE TABLE `chat_session` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `session_id` VARCHAR(64) NOT NULL COMMENT 'LangChain4j 生成的会话ID', `user_id` VARCHAR(64) NOT NULL COMMENT '业务系统用户ID', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_session` (`session_id`), KEY `idx_user` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `chat_message` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `session_id` VARCHAR(64) NOT NULL, `message_id` VARCHAR(64) NOT NULL COMMENT '消息唯一标识', `message_type` ENUM('SYSTEM','AI','USER') NOT NULL, `content` JSON NOT NULL COMMENT '结构化存储消息内容', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_message` (`message_id`), KEY `idx_session` (`session_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这个设计有几个关键优化点:
- 使用 JSON 类型存储序列化后的消息内容
- 通过 created_at/updated_at 实现自动时间戳管理
- 为所有查询条件建立合适索引
2.2 MyBatis 实现数据访问层
在 Spring Boot 项目中整合 MyBatis 和 MySQL:
<dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>3.0.3</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency>定义 Mapper 接口时,特别注意批量操作的方法:
@Mapper public interface ChatMessageMapper { @Select("SELECT content FROM chat_message WHERE session_id = #{sessionId} ORDER BY created_at ASC") List<String> findMessagesBySession(String sessionId); @Insert({ "<script>", "INSERT INTO chat_message(session_id, message_id, message_type, content) VALUES ", "<foreach collection='messages' item='msg' separator=','>", "(#{sessionId}, #{msg.id}, #{msg.type}, #{msg.content})", "</foreach>", "</script>" }) int insertMessages(@Param("sessionId") String sessionId, @Param("messages") List<ChatMessageDTO> messages); @Delete("DELETE FROM chat_message WHERE session_id = #{sessionId}") int deleteMessages(String sessionId); }3. 实现 ChatMemoryStore 接口
完整的实现类需要考虑事务管理、异常处理和性能优化:
@Component @RequiredArgsConstructor public class MySQLChatMemoryStore implements ChatMemoryStore { private final ChatSessionMapper sessionMapper; private final ChatMessageMapper messageMapper; private final ObjectMapper objectMapper; @Override @Transactional(readOnly = true) public List<ChatMessage> getMessages(Object memoryId) { String sessionId = (String) memoryId; if (!sessionMapper.exists(sessionId)) { return Collections.emptyList(); } return messageMapper.findMessagesBySession(sessionId).stream() .map(json -> ChatMessageDeserializer.messageFromJson(json)) .collect(Collectors.toList()); } @Override @Transactional public void updateMessages(Object memoryId, List<ChatMessage> messages) { String sessionId = (String) memoryId; // 原子性的"存在即更新,不存在则插入" if (sessionMapper.exists(sessionId)) { sessionMapper.touch(sessionId); } else { sessionMapper.insert(new ChatSession(sessionId, "default-user")); } // 先删除旧消息再插入新消息 messageMapper.deleteMessages(sessionId); List<ChatMessageDTO> dtos = messages.stream() .map(msg -> new ChatMessageDTO( UUID.randomUUID().toString(), msg.type().name(), ChatMessageSerializer.messageToJson(msg))) .collect(Collectors.toList()); messageMapper.insertMessages(sessionId, dtos); } @Override @Transactional public void deleteMessages(Object memoryId) { String sessionId = (String) memoryId; messageMapper.deleteMessages(sessionId); sessionMapper.delete(sessionId); } }实现时的几个技术要点:
- 使用
@Transactional确保操作原子性 - 采用批量插入提高性能
- 通过 touch 操作更新会话时间戳
- 合理处理对象序列化/反序列化
4. 在 Spring Boot 中集成自定义存储
最后,将我们的实现注入到 LangChain4j 的对话系统中:
@Configuration public class ChatConfig { @Bean public ChatMemoryProvider chatMemoryProvider(MySQLChatMemoryStore memoryStore) { return memoryId -> MessageWindowChatMemory.builder() .id(memoryId) .maxMessages(20) .chatMemoryStore(memoryStore) .build(); } @Bean public Assistant assistant(ChatLanguageModel model, ChatMemoryProvider provider) { return AiServices.builder(Assistant.class) .chatLanguageModel(model) .chatMemoryProvider(provider) .build(); } public interface Assistant { String chat(@MemoryId String sessionId, @UserMessage String message); } }实际测试时,您会发现即使重启应用,对话历史也能完整恢复:
@SpringBootTest class ChatMemoryTest { @Autowired Assistant assistant; @Test void testPersistence() { String sessionId = "test-session-1"; // 第一次对话 String reply1 = assistant.chat(sessionId, "我叫张三"); assertThat(reply1).contains("你好"); // 模拟应用重启... // 第二次对话 String reply2 = assistant.chat(sessionId, "我叫什么名字?"); assertThat(reply2).contains("张三"); } }对于需要处理高并发的场景,可以在实现类中加入 Redis 缓存层,形成"MySQL + Redis"的双存储架构。缓存策略建议采用写穿透模式,确保数据一致性。
