前提:

  • 笔记一中有提到如何获取 API KEY 和如何构建简单的 AI 应用

  • Spring AI 相关相关依赖引入失败,请参考仓库配置


1. 项目构建

  1. 创建一个新的工程
  2. 引入依赖
xml
<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. 基础部分

  1. 创建启动类
java
// 无需使用数据库时,排除对应依赖
@SpringBootApplication(exclude = DataSourceAutoConfiguration.class)  
public class ChatMemoryApplication {  
  
    public static void main(String[] args) {  
        SpringApplication.run(ChatMemoryApplication.class, args);  
    }  
}
  1. 创建配置文件
yaml
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: 60000

2.2. 配置文件部分

创建使用 Redis 和 MySQL 时所需要的 Bean

java
@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. 本地内存

java
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

java
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

java
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 序列化框架

  1. 需要自定义实现 ChatMemoryRepositoryAutoCloseable, 完成FileChatMemoryRepository
java
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);  
    }  
}
  1. 使用
java
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. 通用测试接口

java
@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);  
}

详细代码可见: https://github.com/jizuiba/spring-ai-demo