Spring AI 会话记忆实战:从内存存储到 MySQL + Redis 双层缓存架构 - 教程

为什么需要聊天记忆?

大语言模型(LLM)本质上是无状态的。这意味着每次向模型发送请求时,它都“忘记”了之前的对话内容。这在需要多轮交互的场景中是致命的——比如用户说:“我叫张三”,接着问:“你能复述我的名字吗?”,模型大概率会回答“我不知道”。

为了解决这个问题,Spring AI 提供了 ChatMemory 抽象,允许我们在多次与 LLM 的交互中存储和检索对话历史,从而实现“记忆”功能。

本文将带你:

  1. 快速上手 Spring AI 的基础聊天记忆功能;
  2. 深入理解 ChatMemory 的设计原理;
  3. 实战:使用 阿里云通义千问(DashScope) 模型;
  4. 进阶:不使用默认的 JDBC 存储,而是构建一个 MySQL + Redis 双层缓存 的高性能会话存储系统。

准备工作

1. 搭建 Spring Boot 项目

创建一个标准的 Spring Boot 项目,确保使用较新的 Spring Boot 版本以兼容 Spring AI。

2. 添加 Spring AI 与通义千问依赖

由于我们使用的是阿里云的通义千问模型,需引入对应的 Starter:

<properties>
<java.version>21</java.version>
<spring.ai.version>1.0.0</spring.ai.version>
<spring.ai.alibaba.version>1.0.0.3</spring.ai.alibaba.version>
</properties>
<!-- Spring AI 阿里云通义千问 Starter -->
  <dependency>
  <groupId>com.alibaba.cloud.ai</groupId>
  <artifactId>spring-ai-alibaba-starter-dashscope</artifactId>
  <version>${spring.ai.alibaba.version}</version>
  </dependency>
  <!-- 聊天记忆 JDBC 支持 -->
    <dependency>
    <groupId>com.alibaba.cloud.ai</groupId>
    <artifactId>spring-ai-alibaba-starter-memory-jdbc</artifactId>
    <version>${spring.ai.alibaba.version}</version>
    </dependency>
    <!-- MySQL 驱动 -->
      <dependency>
      <groupId>mysql</groupId>
      <artifactId>mysql-connector-java</artifactId>
      <version>8.0.32</version>
      </dependency>
      <!-- Redis 缓存支持 -->
        <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <dependency>
        <groupId>org.apache.commons</groupId>
        <artifactId>commons-pool2</artifactId>
        </dependency>

3. 配置通义千问 API Key

spring:
ai:
dashscope:
api-key: ${DASHSCOPE_API_KEY}

提示:请将 DASHSCOPE_API_KEY 设置为环境变量,避免密钥泄露。

Spring AI 聊天记忆基础

Spring AI 会自动注册一个 ChatMemory 的默认实现——MessageWindowChatMemory。它使用内存中的 ConcurrentHashMap(通过 InMemoryChatMemoryRepository)存储消息,默认保留最近的 20 条消息。

聊天记忆功能通过 Advisor(顾问/拦截器) 实现。MessageChatMemoryAdvisor 是 Spring AI 提供的默认顾问,负责在请求前后与 ChatMemory 交互。

@RestController
@RequestMapping("/chat")
public class ChatController {
private final ChatClient chatClient;
private final ChatMemory chatMemory; // Spring AI 自动注入默认实现
public ChatController(ChatClient.Builder chatClientBuilder, ChatMemory chatMemory) {
this.chatClient = chatClientBuilder.build();
this.chatMemory = chatMemory;
}
@GetMapping("/memory")
public String chatWithMemory(String userInput) {
return chatClient.prompt()
.advisors(MessageChatMemoryAdvisor.builder(chatMemory).build()) // 启用记忆功能
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, "session-007")) // 指定会话ID
.user(userInput)
.call()
.content();
}
}

测试:

  1. 访问 http://localhost:8080/chat/memory?userInput=你好,我叫李雷
  2. 再访问 http://localhost:8080/chat/memory?userInput=请告诉我你的名字

如果第二次请求模型能正确回答“你叫李雷”,说明聊天记忆已生效!

ChatMemory 框架设计

Spring AI 的 ChatMemory 设计非常优雅,采用分层架构

+------------------+
|   ChatMemory     |  <- 接口,定义 add/get/clear 行为
+------------------+
         |
         v
+--------------------------+
| MessageWindowChatMemory  |  <- 默认实现,管理消息窗口(如保留最近20条)
+--------------------------+
         |
         v
+---------------------------+
|  ChatMemoryRepository     |  <- 接口,定义数据持久化行为
+---------------------------+
         |
   +------+------+
   |             |
   v             v
+-----------+   +------------------+
| InMemory  |   | JdbcChatMemory   |
| Repository|   | Repository       |
+-----------+   +------------------+
                |
                v
           +-------------+
           |  Database   |
           +-------------+
  • ChatMemory: 顶层接口,定义了 add, get, clear 等核心方法。
  • MessageWindowChatMemory: 默认实现,内部持有 ChatMemoryRepository 实例,负责管理消息的窗口大小。
  • ChatMemoryRepository: 数据存储抽象层。
    • InMemoryChatMemoryRepository: 使用 ConcurrentHashMap 存储在内存中(默认)。
    • JdbcChatMemoryRepository: 使用关系型数据库存储。

关键点:当项目中引入了 spring-ai-starter-model-chat-memory-repository-jdbc 依赖时,Spring AI 会自动将 JdbcChatMemoryRepository 注册为 ChatMemoryRepository 的 Bean,从而替代内存实现。

构建 MySQL + Redis 双层缓存架构

虽然 JdbcChatMemoryRepository 可以直接将数据存入 MySQL,但在高并发场景下,频繁的数据库读写会影响性能。

我们不满足于此,目标是:使用 Redis 作为高速缓存,MySQL 作为持久化存储,实现读写分离的双层架构

设计思路

  1. 写入流程:新消息 → 同时写入 RedisMySQL
  2. 读取流程:优先从 Redis 读取;若未命中,则从 MySQL 读取,并回填到 Redis。
  3. 容量控制:使用 Redis 的 LIST 结构 + TRIM 命令,确保每个会话只保留最近 N 条消息。

实现步骤

1. 创建自定义 ChatMemory 实现
@Service
public class CachedChatMemoryService implements ChatMemory {
private final JdbcTemplate jdbcTemplate;
private final RedisTemplate<String, Object> redisTemplate;
  private final int messageWindowSize;
  private static final String KEY_PREFIX = "chat:history:";
  public CachedChatMemoryService(JdbcTemplate jdbcTemplate,
  RedisTemplate<String, Object> redisTemplate) {
    this.jdbcTemplate = jdbcTemplate;
    this.redisTemplate = redisTemplate;
    this.messageWindowSize = 20; // 可配置
    }
    @Override
    public void add(String conversationId, List<Message> messages) {
      String key = KEY_PREFIX + conversationId;
      // 1. 写入 Redis
      messages.forEach(msg -> {
      redisTemplate.opsForList().rightPush(key, new ChatMemoryEntity(msg.getText(), msg.getMessageType().name()));
      });
      // 2. 修剪 Redis 列表,只保留最近 messageWindowSize 条
      redisTemplate.opsForList().trim(key, -messageWindowSize, -1);
      // 3. 批量写入 MySQL
      batchSaveToDatabase(conversationId, messages);
      }
      @Override
      public List<Message> get(String conversationId) {
        String key = KEY_PREFIX + conversationId;
        // 1. 先查 Redis
        List<Object> cached = redisTemplate.opsForList().range(key, 0, -1);
          if (cached != null && !cached.isEmpty()) {
          return convertToMessages(cached);
          }
          // 2. Redis 无数据,查 MySQL
          List<Message> dbMessages = jdbcTemplate.query(
            "SELECT content, type FROM ai_chat_memory WHERE conversation_id = ? ORDER BY timestamp DESC LIMIT ?",
            new MessageRowMapper(),
            conversationId, messageWindowSize
            );
            // 3. 回填 Redis
            if (!dbMessages.isEmpty()) {
            dbMessages.forEach(msg -> redisTemplate.opsForList().rightPush(key, new ChatMemoryEntity(msg.getText(), msg.getMessageType().name())));
            redisTemplate.opsForList().trim(key, -messageWindowSize, -1);
            }
            return dbMessages;
            }
            @Override
            public void clear(String conversationId) {
            String key = KEY_PREFIX + conversationId;
            redisTemplate.delete(key);
            jdbcTemplate.update("DELETE FROM ai_chat_memory WHERE conversation_id = ?", conversationId);
            }
            private void batchSaveToDatabase(String conversationId, List<Message> messages) {
              String sql = "INSERT INTO ai_chat_memory (conversation_id, content, type, timestamp) VALUES (?, ?, ?, ?)";
              List<Object[]> batchArgs = new ArrayList<>();
                long baseTimestamp = Instant.now().toEpochMilli();
                for (int i = 0; i < messages.size(); i++) {
                Message message = messages.get(i);
                Object[] args = new Object[] {
                conversationId,
                message.getText(),
                message.getMessageType().getValue().toUpperCase(),
                new Timestamp(baseTimestamp + i) // 确保每条消息时间戳不同
                };
                batchArgs.add(args);
                }
                jdbcTemplate.batchUpdate(sql, batchArgs);
                }
                /**
                * 数据库行到 Message 对象的映射器
                */
                private static class MessageRowMapper implements RowMapper<Message> {
                  @Nullable
                  public Message mapRow(ResultSet rs, int i) throws SQLException {
                  String content = rs.getString(1);
                  MessageType type = MessageType.valueOf(rs.getString(2));
                  Message message;
                  switch (type) {
                  case USER -> message = new UserMessage(content);
                  case ASSISTANT -> message = new AssistantMessage(content);
                  case SYSTEM -> message = new SystemMessage(content);
                  case TOOL -> message = new ToolResponseMessage(List.of());
                  default -> throw new IncompatibleClassChangeError();
                  }
                  return message;
                  }
                  }
                  }
2. 配置 ChatClient 使用自定义 ChatMemory
@Configuration
public class ChatClientConfig {
private static final String DEFAULT_PROMPT = "你是一个博学的智能聊天助手,请根据用户提问回答!";
@Bean
public ChatClient chatClient(ChatModel chatModel, ChatMemory chatMemory) {
return ChatClient.builder(chatModel)
.defaultSystem(DEFAULT_PROMPT)
.defaultAdvisors(
MessageChatMemoryAdvisor.builder(chatMemory).build(),
new SimpleLoggerAdvisor()
)
.build();
}
@Bean
public ChatMemory chatMemory(CachedChatMemoryService service) {
return service; // 使用我们自定义的服务
}
}
3.数据库表结构
CREATE TABLE ai_chat_memory (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
conversation_id VARCHAR(255) NOT NULL,
content TEXT NOT NULL,
type VARCHAR(50) NOT NULL, -- USER, ASSISTANT, SYSTEM
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_conversation (conversation_id)
);

继续测试,查看Redis中的数据:
在这里插入图片描述

Spring AI 的扩展性极强,它不仅提供了开箱即用的解决方案,更鼓励开发者根据业务需求进行深度定制。掌握其设计思想,是构建企业级 AI 应用的关键。

posted @ 2025-11-07 10:08  yangykaifa  阅读(6)  评论(0)    收藏  举报