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*/
@Slf4j
public class SimpleLogAdvisor implements CallAdvisor {@Overridepublic String getName() {return "SimpleLogAdvisor";}@Overridepublic int getOrder() {return 0;}@Overridepublic 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;//省略构造函数@Overridepublic 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);}@Overridepublic List<Message> get(String conversationId) {return this.chatMemoryRepository.findByConversationId(conversationId);}@Overridepublic 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);}
在<font style="color:#080808;background-color:#ffffff;">MessageWindowChatMemory中使用的ChatMemoryRepository是InMemoryChatMemoryRepository,从名字中就可以看出这是一个基于内存的聊天记录存储介质。核心是第一个ConcurrentHashMap</font>
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*/
@Component
public class MySqlChatMemoryRepository implements ChatMemoryRepository {@Resourceprivate ChatMemoryMapper chatMemoryMapper;@Overridepublic List<String> findConversationIds() {final List<ChatMemoryEntity> chatMemoryEntities = chatMemoryMapper.selectList(Wrappers.lambdaQuery(ChatMemoryEntity.class));return chatMemoryEntities.stream().map(ChatMemoryEntity::getConversationId).distinct().collect(Collectors.toList());}@Overridepublic 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.*/@Overridepublic 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);}@Overridepublic 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: 8080
spring:application:name: spring-ai-demodatasource:driver-class-name: com.mysql.cj.jdbc.Driverurl: jdbc:mysql://${MYSQL_HOST:127.0.0.1}:${MYSQL_PORT:3306}/learn?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=UTCpassword: zaige806username: rootai:chat:memory:repository:jdbc:initialize-schema: alwaysplatform: mysqlschema: 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) {//生成自定义的会话IDreturn 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();//配置JdbcChatMemoryRepositoryfinal 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 隔离等问题,以实现稳定可靠的对话记忆功能。希望对你有所帮助