前提:
笔记一中有提到如何获取 API KEY 和如何构建简单的 AI 应用
Spring AI 相关相关依赖引入失败,请参考仓库配置
1. 项目构建
- 创建一个新的工程
- 引入依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud.ai</groupId>
<artifactId>spring-ai-alibaba-starter-memory</artifactId>
</dependency>
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>5.2.0</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.32</version>
</dependency>
<!-- 支持文件会话记忆持久化的序列化 -->
<!-- 此依赖并未上传到中央仓库 -->
<!-- 可见 https://github.com/jizuiba/spring-ai-demo/tree/main/spring-boot-kryo-pool-starter 下载安装-->
<dependency>
<groupId>cn.jizuiba</groupId>
<artifactId>kryo-pool-spring-boot-starter</artifactId>
<version>1.0.0</version>
</dependency>2. 开发
使用 硅基流动平台 的 Qwen/Qwen3-8B 模型 提供基于以下几种形式的对话记忆存储:
- 本地内存
- Redis
- MySQL
- File(文件形式,扩展部分)
2.1. 基础部分
- 创建启动类
// 无需使用数据库时,排除对应依赖
@SpringBootApplication(exclude = DataSourceAutoConfiguration.class)
public class ChatMemoryApplication {
public static void main(String[] args) {
SpringApplication.run(ChatMemoryApplication.class, args);
}
}- 创建配置文件
server:
port: 10005
spring:
application:
name: chat-memory-demo
profiles:
active: local
ai:
openai:
api-key: ${API_KEY}
base-url: https://api.siliconflow.cn
chat:
options:
model: Qwen/Qwen3-8B
memory:
redis:
host: ${REDIS_HOST}
port: 6379
# 没有设置password则无需填写
# password: ${REDIS_PASSWORD}
timeout: 4000
chat:
memory:
repository:
jdbc:
mysql:
jdbc-url: ${MYSQL_JDBC_URL}?useUnicode=true&characterEncoding=utf-8&useSSL=false&allowPublicKeyRetrieval=true&zeroDateTimeBehavior=convertToNull&transformedBitIsBoolean=true&allowMultiQueries=true&tinyInt1isBit=false&allowLoadLocalInfile=true&allowLocalInfile=true&allowUrl
username: ${MYSQL_USERNAME}
password: ${MYSQL_PASSWORD}
enabled: true
driver-class-name: com.mysql.cj.jdbc.Driver
# NOTE: 在使用数据库时,无需自己建表
# KryoPool 配置
kryo:
pool:
# 池中最大对象数量
max-total: 16
# 池中最大空闲对象数量
max-idle: 8
# 池中最小空闲对象数量
min-idle: 2
# 获取对象时的最大等待时间(毫秒)
max-wait-millis: 3000
# 是否在获取对象时验证对象有效性
test-on-borrow: false
# 是否在归还对象时验证对象有效性
test-on-return: false
# 是否在空闲时验证对象有效性
test-while-idle: true
# 空闲对象检测线程运行间隔时间(毫秒)
time-between-eviction-runs-millis: 30000
# 对象在池中空闲的最小时间(毫秒),达到此时间后可被回收
min-evictable-idle-time-millis: 600002.2. 配置文件部分
创建使用 Redis 和 MySQL 时所需要的 Bean
@Configuration
public class MemoryConfig {
@Value("${spring.ai.memory.redis.host}")
private String redisHost;
@Value("${spring.ai.memory.redis.password}")
private String redisPassword;
@Value("${spring.ai.memory.redis.port}")
private int redisPort;
@Value("${spring.ai.memory.redis.timeout}")
private int redisTimeout;
@Value("${spring.ai.chat.memory.repository.jdbc.mysql.jdbc-url}")
private String mysqlJdbcUrl;
@Value("${spring.ai.chat.memory.repository.jdbc.mysql.username}")
private String mysqlUsername;
@Value("${spring.ai.chat.memory.repository.jdbc.mysql.password}")
private String mysqlPassword;
@Value("${spring.ai.chat.memory.repository.jdbc.mysql.driver-class-name}")
private String mysqlDriverClassName;
@Bean
public RedisChatMemoryRepository redisChatMemoryRepository() {
return RedisChatMemoryRepository.builder()
.host(redisHost)
.password(redisPassword)
.port(redisPort)
.timeout(redisTimeout)
.build();
}
@Bean
public MysqlChatMemoryRepository mysqlChatMemoryRepository() {
DriverManagerDataSource dataSource = new DriverManagerDataSource();
dataSource.setDriverClassName(mysqlDriverClassName);
dataSource.setUrl(mysqlJdbcUrl);
dataSource.setUsername(mysqlUsername);
dataSource.setPassword(mysqlPassword);
JdbcTemplate jdbcTemplate = new JdbcTemplate(dataSource);
return MysqlChatMemoryRepository.mysqlBuilder()
.jdbcTemplate(jdbcTemplate)
.build();
}
}2.3. controller 部分
参数解释: MAX_MESSAGE:默认为20条上下文信息,达到后会删除旧消息
测试使用的 Controller 功能相似,主要区别在于,构造函数中
chatMemoryRepository使用的类型
2.3.1. 本地内存
public InMemoryChatController(ChatClient.Builder builder, ChatMemoryRepository chatMemoryRepository) {
this.messageWindowChatMemory = MessageWindowChatMemory.builder()
.chatMemoryRepository(chatMemoryRepository)
.maxMessages(MAX_MESSAGE)
.build();
this.chatClient = builder
.defaultAdvisors(
MessageChatMemoryAdvisor.builder(messageWindowChatMemory).build()
)
.build();
}2.3.2. Redis
public RedisChatMemoryController(ChatClient.Builder builder, RedisChatMemoryRepository redisChatMemoryRepository) {
this.messageWindowChatMemory = MessageWindowChatMemory.builder()
.chatMemoryRepository(redisChatMemoryRepository)
.maxMessages(MAX_MESSAGE)
.build();
this.chatClient = builder
.defaultAdvisors(
MessageChatMemoryAdvisor.builder(messageWindowChatMemory).build()
)
.build();
}2.3.3. MySQL
public MySQLChatMemoryController(ChatClient.Builder builder, MysqlChatMemoryRepository mysqlChatMemoryRepository) {
this.messageWindowChatMemory = MessageWindowChatMemory.builder()
.chatMemoryRepository(mysqlChatMemoryRepository)
.maxMessages(MAX_MESSAGE)
.build();
this.chatClient = builder
.defaultAdvisors(
MessageChatMemoryAdvisor.builder(messageWindowChatMemory).build()
)
.build();
}2.3.4. 扩展 File(文件)
Kryo 是一个高性能的 Java 序列化框架
- 需要自定义实现
ChatMemoryRepository、AutoCloseable, 完成FileChatMemoryRepository
public class FileChatMemoryRepository implements ChatMemoryRepository, AutoCloseable {
private static final Logger logger = LoggerFactory.getLogger(FileChatMemoryRepository.class);
private static final String FILE_PREFIX_DEFAULT = "kryo_";
private static final String FILE_EXTENSION_DEFAULT = ".kryo";
private final KryoPool kryoPool;
private final String filePath;
private FileChatMemoryRepository(String filePath, KryoPool kryoPool) {
Assert.notNull(kryoPool, "kryoPool cannot be null");
Assert.notNull(filePath, "filePath cannot be null");
this.filePath = filePath;
this.kryoPool = kryoPool;
}
public static FileBuilder builder() {
return new FileBuilder();
}
@Override
public List<String> findConversationIds() {
File directory = new File(filePath);
List<String> conversationIds = new ArrayList<>();
Pattern pattern = Pattern.compile("^kryo_(.*)\\.kryo$");
if (directory.isDirectory()) {
for (File file : Objects.requireNonNull(directory.listFiles())) {
String fileName = file.getName();
Matcher matcher = pattern.matcher(fileName);
if (matcher.matches()) {
conversationIds.add(matcher.group(1));
}
}
} else {
logger.warn("The path provided is not a valid directory");
}
return conversationIds;
}
@Override
@SuppressWarnings("unchecked")
public List<Message> findByConversationId(String conversationId) {
Assert.hasText(conversationId, "conversationId cannot be null or empty");
AtomicReference<List<Message>> messages = new AtomicReference<>(new ArrayList<>());
File file = getConversationFile(conversationId);
if (!file.exists()) {
return messages.get();
}
try (Input input = new Input(new FileInputStream(file))) {
kryoPool.execute(kryo -> {
messages.set(kryo.readObject(input, ArrayList.class));
});
} catch (IOException e) {
logger.error(e.getMessage());
}
return messages.get();
}
@Override
public void saveAll(String conversationId, List<Message> messages) {
Assert.hasText(conversationId, "conversationId cannot be null or empty");
Assert.notNull(messages, "messages cannot be null");
Assert.noNullElements(messages, "messages cannot contain null elements");
this.deleteByConversationId(conversationId);
try (Output output = new Output(new FileOutputStream(getConversationFile(conversationId)))) {
kryoPool.execute(kryo -> {
kryo.writeObject(output, messages);
});
} catch (IOException e) {
logger.error(e.getMessage());
}
}
@Override
public void deleteByConversationId(String conversationId) {
File file = getConversationFile(conversationId);
if (file.exists()) {
boolean delete = file.delete();
}
}
@Override
public void close() throws Exception {
if (this.kryoPool != null) {
this.kryoPool.close();
}
}
public static class FileBuilder {
private String filePath;
private KryoPool kryoPool;
public FileBuilder filePath(String filePath) {
this.filePath = filePath;
return this;
}
public FileBuilder kryoPool(KryoPool kryoPool) {
this.kryoPool = kryoPool;
return this;
}
public FileChatMemoryRepository build() {
return new FileChatMemoryRepository(filePath, kryoPool);
}
}
private File getConversationFile(String conversationId) {
return new File(filePath, FILE_PREFIX_DEFAULT + conversationId + FILE_EXTENSION_DEFAULT);
}
}- 使用
public FileChatMemoryController(ChatClient.Builder builder, KryoPool kryoPool) {
FileChatMemoryRepository fileChatMemoryRepository = FileChatMemoryRepository.builder()
.filePath("D:\\workSpace\\spring-ai-demo\\chat-memory-demo\\src\\main\\resources")
.kryoPool(kryoPool)
.build();
this.messageWindowChatMemory = MessageWindowChatMemory.builder()
.maxMessages(5)
.chatMemoryRepository(fileChatMemoryRepository)
.build();
this.chatClient = builder
.defaultAdvisors(
MessageChatMemoryAdvisor.builder(messageWindowChatMemory).build()
)
.build();
}2.3.5. 通用测试接口
@GetMapping("/simple")
public String simpleChat(@RequestParam(value = "message", defaultValue = "你好,我叫Jizuiba,请记住。") String message,
@RequestParam(value = "conversation_id", defaultValue = "jizuiba") String conversationId) {
return chatClient.prompt(message)
.advisors(
a -> a.param(CONVERSATION_ID, conversationId)
)
.call()
.content();
}
@GetMapping("/messages")
public List<Message> messages(@RequestParam(value = "conversation_id", defaultValue = "jizuiba") String conversationId) {
return messageWindowChatMemory.get(conversationId);
}