Java程序员学从0学AI(六)
一、前言
在上一篇文章中我们学习了如何使用Spring AI的 Structured Output Converter 让大模型格式化输出。今天我们将继续学习SpringAI的Chat Memory(聊天记忆)
二、Chat Memory
大模型和Http很相似都是无状态的,也就是说大模型其实是没办法知道上下文的。例如我告诉大模型我叫“哈迪”,下次再问他他依旧不知道我是谁。这个和Http很相似,无状态。这将极大的限制我们使用大模型;好在Spring AI提供了对话记忆功能(说白了,就是将之前对对话给到大模型,让大模型有更充足的上下文)
三、代码演示
1、不使用Chat Memory
我们先尝试一下不使用Chat Memory,代码如下
/** * @Author hardy(叶阳华) * @Description * @Date 2025/5/16 14:08 * @Modified By: Copyright(c) cai-inc.com */@RestController@RequestMapping(\"/chat\")public class ChatController { private final ChatClient client; public ChatController() { client = ChatClient.builder( DeepSeekChatModel.builder().deepSeekApi(DeepSeekApi.builder().apiKey(\"换成自己的APIKey\").build()).build()).build(); } @GetMapping(\"/chatWithoutMemory\") public String chatWithoutMemory(String msg) { return client.prompt(msg).call().content(); }}
第一轮对话:我们告诉大模型我叫hardy
第二轮对话:询问大模型我叫什么名字
可以看到,即使我们告诉了大模型我叫什么名字,但是在下一轮对话中大模型依旧无法知道我是谁。正如上文所说,大模型是无状态的。
2、使用Chat Memory
我们先试用最简单的基于内存的Chat Memory
/** * @Author hardy(叶阳华) * @Description * @Date 2025/5/16 14:08 * @Modified By: Copyright(c) cai-inc.com */@RestController@RequestMapping(\"/chat\")public class ChatController { private final ChatClient client; public ChatController(ChatMemory chatMemory) { client = ChatClient.builder(DeepSeekChatModel.builder() .deepSeekApi(DeepSeekApi.builder().apiKey(\"替换成自己的APIKEY\").build()).build()) .defaultAdvisors(MessageChatMemoryAdvisor.builder(chatMemory).build()) .build(); } @GetMapping(\"/chatMemory\") public String chatWithoutMemory(String msg) { return client.prompt(msg).call().content(); }}
第一轮对话:我们告诉大模型我叫hardy
第二轮对话:询问大模型我叫什么名字
神奇的事情发生了,大模型知道我叫哈迪。
四、知其然也知其所以然
上面的案例我们演示了一下使用最简单的Chat Memory实现了聊天记忆功能。那他是如何实现的呢?我们可以在请求前后做一层拦截,看看发生了什么,这里使用Advisors (之前的文章介绍过,笔记二中记录)。对上述代码稍加修改,我在请求前做了一次打印,接下来我们把刚才的实验重头再来一次
/** * @Author hardy(叶阳华) * @Description * @Date 2025/5/16 15:48 * @Modified By: Copyright(c) cai-inc.com */@Slf4jpublic class SimpleLogAdvisor implements CallAdvisor { @Override public String getName() { return \"SimpleLogAdvisor\"; } @Override public int getOrder() { return 0; } @Override public ChatClientResponse adviseCall(final ChatClientRequest chatClientRequest, final CallAdvisorChain callAdvisorChain) { log.info(\"chatClientRequest:{}\", JSON.toJSONString(chatClientRequest)); return callAdvisorChain.nextCall(chatClientRequest); }}
第一轮对话:我们告诉大模型我叫哈迪,打印出来的请求参数如下:
{\"context\": {},\"prompt\": {\"contents\": \"你好我叫哈迪\",\"instructions\": [{\"media\": [],\"messageType\": \"USER\",\"metadata\": {\"messageType\": \"USER\"},\"text\": \"你好我叫哈迪\"}],\"options\": {\"model\": \"deepseek-chat\",\"temperature\": 0.7},\"systemMessage\": {\"messageType\": \"SYSTEM\",\"metadata\": {\"messageType\": \"SYSTEM\"},\"text\": \"\"},\"userMessage\": {\"media\": [],\"messageType\": \"USER\",\"metadata\": {\"messageType\": \"USER\"},\"text\": \"你好我叫哈迪\"},\"userMessages\": [{\"media\": [],\"messageType\": \"USER\",\"metadata\": {\"messageType\": \"USER\"},\"text\": \"你好我叫哈迪\"}]}}
第二轮对话:我们询问大模型我叫什么名字,打印出来的请求参数如下:
{\"context\": {},\"prompt\": {\"contents\": \"你好我叫哈迪你好哈迪!很高兴认识你~ 😊 我是DeepSeek Chat,可以叫我小深或者DeepSeek。有什么我可以帮你的吗?无论是聊天、解答问题,还是需要一些建议,我都会尽力帮你哦!✨我叫什么名字\",\"instructions\": [{\"media\": [],\"messageType\": \"USER\",\"metadata\": {\"messageType\": \"USER\"},\"text\": \"你好我叫哈迪\"}, {\"media\": [],\"messageType\": \"ASSISTANT\",\"metadata\": {\"finishReason\": \"STOP\",\"index\": 0,\"id\": \"363fafe6-81b0-434c-b922-4c616f174fb7\",\"role\": \"ASSISTANT\",\"messageType\": \"ASSISTANT\"},\"text\": \"你好哈迪!很高兴认识你~ 😊 我是DeepSeek Chat,可以叫我小深或者DeepSeek。有什么我可以帮你的吗?无论是聊天、解答问题,还是需要一些建议,我都会尽力帮你哦!✨\",\"toolCalls\": []}, {\"media\": [],\"messageType\": \"USER\",\"metadata\": {\"messageType\": \"USER\"},\"text\": \"我叫什么名字\"}],\"options\": {\"model\": \"deepseek-chat\",\"temperature\": 0.7},\"systemMessage\": {\"messageType\": \"SYSTEM\",\"metadata\": {\"messageType\": \"SYSTEM\"},\"text\": \"\"},\"userMessage\": {\"media\": [],\"messageType\": \"USER\",\"metadata\": {\"messageType\": \"USER\"},\"text\": \"我叫什么名字\"},\"userMessages\": [{\"media\": [],\"messageType\": \"USER\",\"metadata\": {\"messageType\": \"USER\"},\"text\": \"你好我叫哈迪\"}, {\"media\": [],\"messageType\": \"USER\",\"metadata\": {\"messageType\": \"USER\"},\"text\": \"我叫什么名字\"}]}}
可以看到SpringAI 是将本轮对话的上下文一股脑的发送到了大模型,所以“聊天记忆”功能靠的是让大模型知道所有的聊天上下文。
五、ChatMemory代码分析
1、接口介绍
ChatMemory是一个接口,接口定义的方法也非常简单分别是
- 添加聊天记录
- 获取聊天记录
- 清空聊天记录
2、默认实现
SpringAI为我们提供了默认的ChatMemory实现MessageWindowChatMemory,从名字中不难看出这是一个有窗口的聊天记录(所谓的窗口,就是可以记录多少条数据),同时由于没有定义任何外部存储介质,所以聊天记录是存在内存中。Talk is Cheap ,Show Me the Code。我们看一下具体的实现代码(部分)
public final class MessageWindowChatMemory implements ChatMemory { //默认最大记录条数 private static final int DEFAULT_MAX_MESSAGES = 20; //存储介质 private final ChatMemoryRepository chatMemoryRepository; //最大记录条数 private final int maxMessages; //省略构造函数 @Override public void add(String conversationId, List<Message> messages) { List<Message> memoryMessages = this.chatMemoryRepository.findByConversationId(conversationId); List<Message> processedMessages = process(memoryMessages, messages); this.chatMemoryRepository.saveAll(conversationId, processedMessages); } @Override public List<Message> get(String conversationId) { return this.chatMemoryRepository.findByConversationId(conversationId); } @Override public void clear(String conversationId) { this.chatMemoryRepository.deleteByConversationId(conversationId); } ...忽略...}
可以看MessageWindowChatMemory 大多数的逻辑是基于ChatMemoryRepository实现的,我们继续查看ChatMemoryRepository,ChatMemoryRepository也是一个接口,定义了一系列的方法
public interface ChatMemoryRepository {List<String> findConversationIds();List<Message> findByConversationId(String conversationId);/** * Replaces all the existing messages for the given conversation ID with the provided * messages. */void saveAll(String conversationId, List<Message> messages);void deleteByConversationId(String conversationId);}
在MessageWindowChatMemory中使用的ChatMemoryRepository是InMemoryChatMemoryRepository,从名字中就可以看出这是一个基于内存的聊天记录存储介质。核心是第一个ConcurrentHashMap
3、实现自定义ChatMemory
众所周知内存是宝贵的,我们不可能把大量的数据存放到内存中,那么是否可以存储到外部的介质中呢,比如Mysql、ES等等?当然可以,既然ChatMemory是一个接口,我们只要自定义实现即可。咱么这里还是使用MessageWindowChatMemory只不过我们替换其中的Repository,将数据持久化到Mysql中
1、首先我们创建一张表
CREATE TABLE `chat_memory` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT \'主键\', `conversation_id` varchar(100) DEFAULT NULL COMMENT \'会话ID\', `message` text COMMENT \'消息\', PRIMARY KEY (`id`) USING BTREE) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT=\'聊天记录表\';
2、引入Mysql+Mybatis Plus依赖
<dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId></dependency><dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus</artifactId> <version>3.5.12</version></dependency>
3、编写实体类
@Data@TableName(\"chat_memory\")public class ChatMemoryEntity { @TableId(type = IdType.AUTO) private Long id; private String conversationId; private String message;}
4、编写Mapper接口
/** * @Author hardy(叶阳华) * @Description * @Date 2025/7/26 14:04 * @Modified By: Copyright(c) cai-inc.com */public interface ChatMemoryMapper extends BaseMapper<ChatMemoryEntity> {}
5、编写自定义
/** * @Author hardy(叶阳华) * @Description * @Date 2025/7/26 13:38 * @Modified By: Copyright(c) cai-inc.com */@Componentpublic class MySqlChatMemoryRepository implements ChatMemoryRepository { @Resource private ChatMemoryMapper chatMemoryMapper; @Override public List<String> findConversationIds() { final List<ChatMemoryEntity> chatMemoryEntities = chatMemoryMapper.selectList( Wrappers.lambdaQuery(ChatMemoryEntity.class)); return chatMemoryEntities.stream().map(ChatMemoryEntity::getConversationId).distinct().collect(Collectors.toList()); } @Override public List<Message> findByConversationId(final String conversationId) { final List<ChatMemoryEntity> chatMemoryList = chatMemoryMapper.selectList( Wrappers.lambdaQuery(ChatMemoryEntity.class) .eq(ChatMemoryEntity::getConversationId, conversationId)); return chatMemoryList.stream().map(memory-> JSON.parseObject(memory.getMessage(),Message.class)).collect(Collectors.toList()); } /** * Replaces all the existing messages for the given conversation ID with the provided messages. */ @Override public void saveAll(final String conversationId, final List<Message> messages) { List<ChatMemoryEntity> memoryEntities = new ArrayList<>(); messages.forEach(message->{ ChatMemoryEntity entity = new ChatMemoryEntity(); entity.setMessage(JSON.toJSONString(message)); entity.setConversationId(conversationId); memoryEntities.add(entity); }); chatMemoryMapper.insert(memoryEntities); } @Override public void deleteByConversationId(final String conversationId) { chatMemoryMapper.delete(Wrappers.lambdaQuery(ChatMemoryEntity.class) .eq(ChatMemoryEntity::getConversationId,conversationId)); }}
6、编写接口
/** * @Author hardy(叶阳华) * @Description * @Date 2025/5/16 14:08 * @Modified By: Copyright(c) cai-inc.com */@RestController@RequestMapping(\"/chat\")public class ChatController { private final ChatClient client; public ChatController(MySqlChatMemoryRepository mySqlChatMemoryRepository) { MessageWindowChatMemory memory = MessageWindowChatMemory.builder() .chatMemoryRepository(mySqlChatMemoryRepository).maxMessages(10).build(); client = ChatClient.builder(DeepSeekChatModel.builder() .deepSeekApi(DeepSeekApi.builder().apiKey(\"APIKEY\").build()).build()) .defaultAdvisors(MessageChatMemoryAdvisor.builder(memory).build(), new SimpleLogAdvisor()).build(); } @GetMapping(\"/chatMemory\") public String chatWithoutMemory(String msg) { return client.prompt(msg).call().content(); }}
7、调用接口
8、查看数据库
可以看到数据库里存入了我们的数据,但是这么做是有问题的!!!!再次调用会发现反序列化失败
4、正确的打开方式
刚才自定义方式是有问题,那么正确的打开方式是什么呢?使用SpringAI为我们提供的JdbcChatMemoryRepository接口。
1、引入如下依赖
<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-model-chat-memory-repository-jdbc</artifactId></dependency>
2、修改配置
server: port: 8080spring: application: name: spring-ai-demo datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://${MYSQL_HOST:127.0.0.1}:${MYSQL_PORT:3306}/learn?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=UTC password: zaige806 username: root ai: chat: memory: repository: jdbc: initialize-schema: always platform: mysql schema: classpath:schema/schema-@@platform@@.sql
3、修改接口
/** * @Author hardy(叶阳华) * @Description * @Date 2025/5/16 14:08 * @Modified By: Copyright(c) cai-inc.com */@RestController@RequestMapping(\"/chat\")public class ChatController { private final ChatClient client; public ChatController(JdbcTemplate jdbcTemplate) { // 创建聊天记忆存储库,使用JDBC实现并配置MySQL方言 ChatMemoryRepository chatMemoryRepository = JdbcChatMemoryRepository.builder() .jdbcTemplate(jdbcTemplate) .dialect(new MysqlChatMemoryRepositoryDialect()) .build(); // 创建消息窗口聊天记忆,限制最多保存10条消息 ChatMemory memory = MessageWindowChatMemory.builder() .chatMemoryRepository(chatMemoryRepository) .maxMessages(10) .build(); // 构建聊天客户端,使用DeepSeek大模型并配置API密钥 // 同时添加消息聊天记忆顾问以启用对话历史功能 client = ChatClient.builder(DeepSeekChatModel.builder() .deepSeekApi(DeepSeekApi.builder().apiKey(\"sk-2f18dc5852134ed19d614f9ba09febe7\").build()).build()) .defaultAdvisors(MessageChatMemoryAdvisor.builder(memory).build(),new SimpleLogAdvisor()).build(); } @GetMapping(\"/chatMemory\") public String chatWithoutMemory(String msg,String conversationId) { //生成自定义的会话ID return client.prompt(msg).advisors(advisor->advisor.param(ChatMemory.CONVERSATION_ID,conversationId)).call().content(); }}
4、添加schema:位置可以随意,但是要和配置文件里的保持一致
CREATE TABLE IF NOT EXISTS SPRING_AI_CHAT_MEMORY( `conversation_id` VARCHAR(36) NOT NULL, `content` TEXT NOT NULL, `type` ENUM (\'USER\', \'ASSISTANT\', \'SYSTEM\', \'TOOL\') NOT NULL, `timestamp` TIMESTAMP NOT NULL, INDEX `SPRING_AI_CHAT_MEMORY_CONVERSATION_ID_TIMESTAMP_IDX` (`conversation_id`, `timestamp`));
5、启动项目
可以看到Spring为我们创建了一张表
6、调用接口
小插曲:官方Bug!!!
什么居然报错了?部分错误信息如下:
[spring-ai-demo] [nio-8080-exec-1] o.a.c.c.C.[.[.[/].[dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: org.springframework.jdbc.BadSqlGrammarException: PreparedStatementCallback; bad SQL grammar [SELECT content, type FROM SPRING_AI_CHAT_MEMORY WHERE conversation_id = ? ORDER BY `timestamp` DESC LIMIT ?]] with root causejava.sql.SQLException: No value specified for parameter 2at com.mysql.cj.jdbc.exceptions.SQLError.createSQLException(SQLError.java:130) ~[mysql-connector-j-8.0.33.jar:8.0.33]at com.mysql.cj.jdbc.exceptions.SQLExceptionsMapping.translateException(SQLExceptionsMapping.java:122) ~[mysql-connector-j-8.0.33.jar:8.0.33]at com.mysql.cj.jdbc.ClientPreparedStatement.executeQuery(ClientPreparedStatement.java:989) ~[mysql-connector-j-8.0.33.jar:8.0.33]at com.zaxxer.hikari.pool.ProxyPreparedStatement.executeQuery(ProxyPreparedStatement.java:52) ~[HikariCP-5.1.0.jar:na]
这个是官方bug,简单的说就是官方默认的MysqlChatMemoryRepositoryDialect指定了两个参数,但是实际只传入了一个参数
那该如何解决呢?当然是自己写一个了Dailect
package com.cmxy.springbootaidemo.memory;import org.springframework.ai.chat.memory.repository.jdbc.JdbcChatMemoryRepositoryDialect;/** * @Author hardy(叶阳华) * @Description * @Date 2025/7/26 23:13 * @Modified By: Copyright(c) cai-inc.com */public class CustomChatMemoryRepositoryDialect implements JdbcChatMemoryRepositoryDialect { private static final int DEFAULT_MAX_MESSAGES = 50; public String getSelectMessagesSql() { return \"SELECT content, type FROM SPRING_AI_CHAT_MEMORY WHERE conversation_id = ? ORDER BY `timestamp` DESC \" + \"LIMIT \" + DEFAULT_MAX_MESSAGES; } public String getInsertMessageSql() { return \"INSERT INTO SPRING_AI_CHAT_MEMORY (conversation_id, content, type, `timestamp`) VALUES (?, ?, ?, ?)\"; } public String getSelectConversationIdsSql() { return \"SELECT DISTINCT conversation_id FROM SPRING_AI_CHAT_MEMORY\"; } public String getDeleteMessagesSql() { return \"DELETE FROM SPRING_AI_CHAT_MEMORY WHERE conversation_id = ?\"; }}
5、再次修改接口
package com.cmxy.springbootaidemo.chat;import com.cmxy.springbootaidemo.advisor.SimpleLogAdvisor;import com.cmxy.springbootaidemo.memory.CustomChatMemoryRepositoryDialect;import org.springframework.ai.chat.client.ChatClient;import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;import org.springframework.ai.chat.memory.ChatMemory;import org.springframework.ai.chat.memory.ChatMemoryRepository;import org.springframework.ai.chat.memory.MessageWindowChatMemory;import org.springframework.ai.chat.memory.repository.jdbc.JdbcChatMemoryRepository;import org.springframework.ai.chat.memory.repository.jdbc.JdbcChatMemoryRepositoryDialect;import org.springframework.ai.chat.memory.repository.jdbc.MysqlChatMemoryRepositoryDialect;import org.springframework.ai.deepseek.DeepSeekChatModel;import org.springframework.ai.deepseek.api.DeepSeekApi;import org.springframework.jdbc.core.JdbcTemplate;import org.springframework.web.bind.annotation.GetMapping;import org.springframework.web.bind.annotation.RequestMapping;import org.springframework.web.bind.annotation.RestController;/** * @Author hardy(叶阳华) * @Description * @Date 2025/5/16 14:08 * @Modified By: Copyright(c) cai-inc.com */@RestController@RequestMapping(\"/chat\")public class ChatController { private final ChatClient client; public ChatController(JdbcTemplate jdbcTemplate) { //使用自定义方言 final JdbcChatMemoryRepositoryDialect dialect = new CustomChatMemoryRepositoryDialect(); //配置JdbcChatMemoryRepository final JdbcChatMemoryRepository jdbcChatMemoryRepository = JdbcChatMemoryRepository.builder().jdbcTemplate(jdbcTemplate) .dialect(dialect).build(); // 创建消息窗口聊天记忆,限制最多保存10条消息 (其实这里的10条配置已经没有意义了,因为在dialect默认了50条) ChatMemory memory = MessageWindowChatMemory.builder().chatMemoryRepository(jdbcChatMemoryRepository) .maxMessages(10) .build(); // 构建聊天客户端,使用DeepSeek大模型并配置API密钥 final DeepSeekApi deepSeekApi = DeepSeekApi.builder().apiKey(\"替换成自己的APIKEY\").build(); // 同时添加消息聊天记忆顾问以启用对话历史功能 client = ChatClient.builder(DeepSeekChatModel.builder().deepSeekApi(deepSeekApi).build()) .defaultAdvisors(MessageChatMemoryAdvisor.builder(memory).build(), new SimpleLogAdvisor()).build(); } @GetMapping(\"/chatMemory\") public String chatWithoutMemory(String msg, String conversationId) { return client.prompt(msg).advisors(advisor -> advisor.param(ChatMemory.CONVERSATION_ID, conversationId)) .call().content(); }}
6、测试
1、对话ID 001:告诉大模型我叫哈迪
数据库:
2、对话ID 001:问大模型我叫什么名字,可以看到大模型“记住了”
数据库:
3、换一个对话ID 002,预期:大模型不知道我是谁
可以看到大模型并不知道我是谁。
六、总结
本文围绕 Spring AI 的 Chat Memory(聊天记忆)功能展开,从实际需求出发,详细介绍了其作用、实现方式及扩展方案,具体可归纳为以下几点:
- Chat Memory 的核心作用
大模型本身是无状态的,类似 HTTP 协议,无法直接记住上下文。Chat Memory 通过存储历史对话记录,在每次请求时将上下文一并发送给大模型,从而实现 “聊天记忆” 效果,解决了大模型无法关联历史对话的问题。 - 使用方式与效果验证
- 不使用 Chat Memory 时,大模型无法记住用户的历史信息(如用户姓名),两次独立对话之间无关联。
- 集成基于内存的 Chat Memory 后,通过MessageChatMemoryAdvisor`拦截并携带历史对话,大模型能准确回应依赖上下文的问题(如 “记住” 用户姓名)。
- 实现原理剖析
Spring AI 的 Chat Memory 核心逻辑是通过ChatMemory接口及其默认实现MessageWindowChatMemory完成的:
接口定义了添加、获取、清空对话记录的基础方法。默认实现MessageWindowChatMemor通过内存存储InMemoryChatMemoryRepository管理对话,支持设置最大记录条数(窗口大小),避免内存溢出。
本质是通过拦截器(Advisors)在请求时拼接历史对话,让大模型获得完整上下文。 - 自定义与持久化方案
内存存储不适用于生产环境,需将对话记录持久化到外部介质(如 MySQL):- 直接自定义ChatMemoryRepository可能存在反序列化问题,推荐使用 Spring AI 提供的JdbcChatMemoryRepository
- 需引入相关依赖、配置数据库连接,并处理官方方言(Dialect)的潜在 Bug(可通过自定义 Dialect 解决),确保对话记录正确存储和读取。
- 关键结论
Chat Memory 是构建连续对话能力的核心组件,其本质是 “上下文拼接” 而非大模型自身的记忆能力。在实际开发中,需根据场景选择合适的存储方案(内存用于测试,数据库用于生产),并注意处理序列化、多对话 ID 隔离等问题,以实现稳定可靠的对话记忆功能。希望对你有所帮助