调用过大模型api的都知道,维护一个聊天记录还是代码比较繁琐的,用springAi怎么使用记忆功能呢
@RestController
public class ChatController {
private static final ConcurrentHashMap < String, MessageWindowChatMemory > memoryMap = new ConcurrentHashMap < > ();
private static final Logger log = LoggerFactory.getLogger(ChatController.class);
@Resource(name = "openAiChatClient")
private ChatClient openAiChatClient;
/**
* OpenAI:带记忆上下文
*/
@GetMapping(value = "/openAi", produces = "text/stream;charset=UTF-8")
public Flux<String> openAiChat(@RequestParam("userPrompt") String userPrompt,
@RequestParam("userId") String userId) {
MessageWindowChatMemory chatMemory = memoryMap.computeIfAbsent(userId,
id -> MessageWindowChatMemory.builder().build());
chatMemory.add(userId, new UserMessage(userPrompt));
// 用于拼接完整输出
StringBuilder fullContent = new StringBuilder();
return openAiChatClient.prompt()
.user(userPrompt)
//这一步是把历史记录堆积到上下文聊天中
.messages(memoryMap.get(userId).get(userId))
.stream()
.content()
.doOnNext(fullContent::append)
.doOnComplete(() -> {
String answer = fullContent.toString();
log.info("完整返回结果:" + answer);
chatMemory.add(userId, new AssistantMessage(answer));
});
}
}实现效果
现在聊天记录是保存在jvm本地中的,项目重新启动就没有数据了,所以我们需要将记忆存储在数据库或者redis中
聊天记录存储数据库中(未完待续)
springAi有一个很坑爹的点就是他集成的太完善了,我如果直接用他封装的mysql存储数据的话,他会很贴心的用滑动窗口给我保存最近n条【1.0.0的版本,截止目前是最新版本】,也就是说他能直接删我数据库!所以我们需要自己封装一个mysql查询,删除,新增聊天记录的接口,具体方法如下
先把必要的配置给加上,包括依赖和配置文件
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-chat-memory-repository-jdbc</artifactId>
</dependency>
<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.28</version>
</dependency>spring:
# mysql 配置
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://127.0.0.1:3306/memory?rewriteBatchedStatements=true&useSSL=false&serverTimezone=UTC&useUnicode=true&characterEncoding=utf8&allowPublicKeyRetrieval=true
username: root
password: 12345678
type: com.zaxxer.hikari.HikariDataSource
hikari:
connection-timeout: 30000 # 等待连接池获取连接的最大时间(ms)
initialization-fail-timeout: -1 # 不在启动时报错,而是延迟容忍连接失败
validation-timeout: 5000 # 校验连接有效性的最大等待时间
max-lifetime: 1800000 # 连接最大生命周期(ms)
maximum-pool-size: 15
minimum-idle: 5
idle-timeout: 30000
# mybatis 配置内容
mybatis:
# 配置 Mapper XML 地址
mapper-locations: classpath:/mapper/*Mapper.xml
configuration:
# 使用驼峰命名法转换字段
map-underscore-to-camel-case: true
# 配置数据库日志
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
我自己创建了一个数据库,并且只放了一张聊天记录表
CREATE TABLE `chat_message` (
`id` int NOT NULL AUTO_INCREMENT COMMENT '主键',
`user_id` int DEFAULT NULL COMMENT '用户id',
`message` varchar(255) DEFAULT NULL COMMENT '聊天消息',
`type` varchar(255) DEFAULT NULL COMMENT 'user,assistant,system,tool',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`modify_time` datetime DEFAULT NULL COMMENT '修改时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=8 DEFAULT CHARSET=utf8mb3 COMMENT='聊天消息表';继承ChatMemory接口,重写里面方法(我写的稍微有点繁琐,定义了业务层,仓储层,mapper层,sql层,大家想测试怎么简单怎么来就行)
这里有一点要注意,要写一个类型转换工具,就是将自己数据库的聊天消息类转换成springAi的Message类,就不会有问题了,我自己写了一个简单的MessageUtil
package com.gyq;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import com.gyq.entity.ChatMessage;
import org.springframework.ai.chat.messages.*;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.UUID;
public class MessageUtil implements Serializable {
/**
* 将自定义 ChatMessage列表 转为 Spring AI Message列表
*/
public static List<Message> toAiMessageList(List<ChatMessage> list) {
List<Message> result = new ArrayList<>();
for (ChatMessage chatMessage : list) {
Message aiMessage = toAiMessage(chatMessage);
result.add(aiMessage);
}
return result;
}
/**
* 将自定义 ChatMessage 转为 Spring AI Message
*/
public static Message toAiMessage(ChatMessage cm) {
if (cm == null) throw new IllegalArgumentException("ChatMessage cannot be null");
String type = cm.getType() == null ? "" : cm.getType().trim().toLowerCase(Locale.ROOT);
String content = cm.getMessage();
return switch (type) {
case "system" -> new SystemMessage(content);
case "user" -> new UserMessage(content);
case "assistant" -> new AssistantMessage(content);
case "tool" -> new ToolResponseMessage(toToolResponses(content));
default -> throw new IllegalArgumentException("Unsupported message type: " + cm.getType());
};
}
/**
* 解析 tool 类型的 message 字符串为 ToolResponse 列表
*/
private static List<ToolResponseMessage.ToolResponse> toToolResponses(String content) {
if (content == null || content.isBlank()) {
return List.of(new ToolResponseMessage.ToolResponse(UUID.randomUUID().toString(), null, ""));
}
// JSON 数组
if (JSON.isValidArray(content)) {
JSONArray arr = JSON.parseArray(content);
List<ToolResponseMessage.ToolResponse> list = new ArrayList<>();
for (int i = 0; i < arr.size(); i++) {
JSONObject obj = arr.getJSONObject(i);
list.add(new ToolResponseMessage.ToolResponse(
obj.getString("id"),
obj.getString("name"),
obj.getString("responseData")
));
}
return list;
}
// JSON 对象
if (JSON.isValidObject(content)) {
return List.of(new ToolResponseMessage.ToolResponse(
UUID.randomUUID().toString(), null, content
));
}
// 普通文本
return List.of(new ToolResponseMessage.ToolResponse(
UUID.randomUUID().toString(), null, content
));
}
}
/**
* MysqlChatMemory类,继承ChatMemory,重写了新增,获取,删除方法
*/
package com.gyq.config;
import com.gyq.MessageUtil;
import com.gyq.entity.ChatMessage;
import com.gyq.repository.MysqlChatMemoryRepository;
import jakarta.annotation.Resource;
import org.jetbrains.annotations.NotNull;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.messages.Message;
import org.springframework.stereotype.Component;
import org.springframework.util.Assert;
import java.util.List;
@Component
public class MysqlChatMemory implements ChatMemory {
private final static Integer max_memory = 10;
@Resource
private MysqlChatMemoryRepository mysqlChatMemoryRepository;
@Override
public void add(@NotNull String conversationId, @NotNull List<Message> messages) {
Assert.notNull(conversationId, "conversationId must not be null");
Assert.notNull(messages, "messages must not be null");
mysqlChatMemoryRepository.insert(conversationId, messages.get(0));
}
@NotNull
@Override
public List<Message> get(@NotNull String conversationId) {
List<ChatMessage> list = mysqlChatMemoryRepository.queryByConversationIdAndMaxMemory(conversationId, max_memory);
return MessageUtil.toAiMessageList(list);
}
@Override
public void clear(@NotNull String conversationId) {
mysqlChatMemoryRepository.deleteByConversationId(conversationId);
}
}
package com.gyq.repository;
import com.gyq.entity.ChatMessage;
import com.gyq.mapper.ChatMessageMapper;
import jakarta.annotation.Resource;
import org.springframework.ai.chat.messages.Message;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.List;
@Repository
public class MysqlChatMemoryRepository {
@Resource
private ChatMessageMapper chatMessageMapper;
/**
* 批量存储到数据库
*
* @param conversationId 用户唯一标识
* @param messages 消息列表
*/
public void insert(String conversationId, Message messages) {
ChatMessage chatMessage = new ChatMessage();
chatMessage.setUserId(Integer.parseInt(conversationId));
chatMessage.setMessage(messages.getText());
chatMessage.setType(messages.getMessageType().getValue());
chatMessage.setCreateTime(LocalDateTime.now());
chatMessage.setModifyTime(LocalDateTime.now());
chatMessageMapper.insert(chatMessage);
}
/**
* 查询最新10条
*
* @param conversationId 用户id
* @param maxMemory limit
* @return 聊天记录列表
*/
public List<ChatMessage> queryByConversationIdAndMaxMemory(String conversationId, Integer maxMemory) {
return chatMessageMapper.queryByConversationIdAndMaxMemory(conversationId,maxMemory);
}
/**
* 删除某个用户的全部聊天记录
* @param conversationId 用户id
*/
public void deleteByConversationId(String conversationId) {
chatMessageMapper.deleteByUserId(conversationId);
}
}
package com.gyq.mapper;
import com.gyq.entity.ChatMessage;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 聊天消息表(ChatMessage)表数据库访问层
*
* @author makejava
* @since 2025-09-07 22:02:41
*/
@Mapper
public interface ChatMessageMapper {
void insert(ChatMessage chatMessage);
List<ChatMessage> queryByConversationIdAndMaxMemory(@Param("userId") String conversationId, @Param("size") Integer maxMemory);
void deleteByUserId(@Param("userId") String conversationId);
}
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.gyq.mapper.ChatMessageMapper">
<resultMap type="com.gyq.entity.ChatMessage" id="ChatMessageMap">
<result property="id" column="id" jdbcType="INTEGER"/>
<result property="userId" column="user_id" jdbcType="INTEGER"/>
<result property="message" column="message" jdbcType="VARCHAR"/>
<result property="type" column="type" jdbcType="VARCHAR"/>
<result property="createTime" column="create_time" jdbcType="TIMESTAMP"/>
<result property="modifyTime" column="modify_time" jdbcType="TIMESTAMP"/>
</resultMap>
<select id="queryByConversationIdAndMaxMemory" resultType="com.gyq.entity.ChatMessage">
select *
from chat_message where user_id = #{userId}
order by create_time desc limit 0,#{size}
</select>
<insert id="insert" keyProperty="id" useGeneratedKeys="true">
insert into chat_message(id, user_id, message, type, create_time, modify_time)
values (#{id}, #{userId}, #{message}, #{type}, #{createTime}, #{modifyTime})
</insert>
<delete id="deleteByUserId">
delete
from chat_message
where user_id = #{userId}
</delete>
</mapper>所有的准备都是为了调用的时候优雅一点
/**
* OpenAI:带记忆上下文
*/
@GetMapping(value = "/openAi", produces = "text/stream;charset=UTF-8")
public Flux<String> openAiChat(@RequestParam("userPrompt") String userPrompt,
@RequestParam("userId") String userId) {
mysqlChatMemory.add(userId,new UserMessage(userPrompt));
List<Message> list = mysqlChatMemory.get(userId);
// 用于拼接完整输出
StringBuilder fullContent = new StringBuilder();
return openAiChatClient.prompt()
.user(userPrompt)
//这一步是把历史记录堆积到上下文聊天中
.messages(list)
.stream()
.content()
.doOnNext(fullContent::append)
.doOnComplete(() -> {
String answer = fullContent.toString();
log.info("完整返回结果:" + answer);
mysqlChatMemory.add(userId,new AssistantMessage(answer));
});
}实际测试是可以上下文关联的,同时也是保存到数据库的,如下图
