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 应用的关键。

<think>我们正在讨论SpringAI框架中多轮会话的ConversationId传递问题。根据提供的引用,特别是引用[2]和[4],我们可以知道在SpringAI中,多轮会话记忆功能是通过ChatMemory来实现的,而ConversationId是用来区分不同会话的关键。在引用[2]中,我们看到了一个使用MyBatis-Plus实现的ChatMemory接口,其中每个方法都接收一个conversationId参数。在引用[4]中,也提到了使用jdbc存储聊天历史信息,并且需要保存到MySQL中,其中也涉及到conversationId。因此,要传递ConversationId,我们需要在每次请求时都携带这个ID,以便能够从存储中获取对应的历史消息。在SpringAI中,这通常是在调用ChatModel的方法时,通过传递一个包含ConversationId的Prompt对象来实现的。具体步骤:1.在客户端发起请求时,需要生成或传递一个唯一的ConversationId(如果是一个新的会话,则生成一个新的ID;如果是继续已有会话,则使用已有的ID)。2.在服务端,我们将这个ConversationId与当前的用户请求一起保存到ChatMemory中(通过ChatMemoryService)。3.然后,我们使用这个ConversationId从ChatMemory中获取历史消息(通常是最近的几条),并将这些历史消息与当前用户的新消息一起构成一个Prompt,发送给AI模型。4.模型返回响应后,再将这次交互的消息(用户消息和AI的响应)保存到ChatMemory中,关联到同一个ConversationId。在代码层面,我们可以参考引用[2]中的MybatisPlusChatMemory实现,它实现了ChatMemory接口,其中的方法都是基于conversationId来操作的。另外,在引用[3]中,有一个简单的内存实现InMemoryChatMemory,它使用一个Map来存储每个conversationId对应的消息列表。这说明了ConversationId的核心作用。因此,传递ConversationId的关键在于客户端每次请求都要携带这个ID,服务端则根据这个ID来存储和检索历史消息。下面是一个简化的示例,说明如何在服务端使用ConversationId:假设我们有一个Controller处理用户的消息:```java@RestControllerpublicclassChatController{privatefinalChatModelchatModel;privatefinalChatMemorychatMemory;publicChatController(ChatModelchatModel,ChatMemorychatMemory){this.chatModel=chatModel;this.chatMemory=chatMemory;}@PostMapping("/chat")publicStringchat(@RequestParamStringmessage,@RequestParamStringconversationId){//将用户消息添加到当前会话记忆中List<Message>userMessages=List.of(newHumanMessage(message));chatMemory.add(conversationId,userMessages);//获取当前会话的历史消息(例如最近的5条)List<Message>history=chatMemory.get(conversationId,5);//构建Prompt,包括历史消息和当前消息Promptprompt=newPrompt(history);//调用模型ChatResponseresponse=chatModel.call(prompt);Stringresult=response.getResult().getOutput().getContent();//将AI的响应也添加到会话记忆中List<Message>aiMessages=List.of(newAiMessage(result));chatMemory.add(conversationId,aiMessages);returnresult;}}```注意:上面的代码是一个简化的示例,实际中可能需要更严谨的设计,比如消息的封装、异常处理等。另外,ConversationId的生成可以由客户端在第一次请求时生成(比如使用UUID),并在后续请求中携带。如果是Web应用,也可以使用Session等方式来维护,但如果是移动端或跨平台,通常需要客户端来维护这个ID。总结:在SpringAI中传递ConversationId的方法就是由客户端在每次请求时提供,服务端使用这个ID来关联和操作对应的会话记忆。根据以上内容,我们可以生成相关问题。§§相关问题§§1.在SpringAI中,如何生成唯一的ConversationId?2.如果使用数据库存储会话记忆,如何设计数据表结构?3.除了ConversationId,还有哪些方法可以管理多轮对话的状态?4.SpringAIChatMemory接口支持哪些存储方式?</think>在 Spring AI 框架中实现多轮会话时,传递 `ConversationId` 是关键机制。以下是具体实现方法和技术要点: ### 一、核心实现原理 1. **会话标识作用** `ConversationId` 是区分不同对话的唯一标识符,用于关联同一会话中的多次交互。大型语言模型(LLM)本身无状态,需通过此标识实现上下文关联[^4]。 2. **传递流程** ```mermaid graph LR A[客户端请求] --> B{携带 ConversationId} B -->|新会话| C[生成新ID] B -->|已有会话| D[使用现有ID] C & D --> E[ChatMemory 存储/检索] E --> F[LLM 处理] F --> G[返回响应] ``` ### 二、具体实现方法 #### 方案1:HTTP请求参数传递 ```java @RestController public class ChatController { private final ChatMemoryService memoryService; @PostMapping("/chat") public Response chat(@RequestParam String message, @RequestParam String conversationId) { // 获取历史消息 List<Message> history = memoryService.getMessages(conversationId, 10); // 构建新Prompt(含历史上下文) Prompt prompt = new Prompt(history, new HumanMessage(message)); // 调用AI模型 ChatResponse response = chatModel.call(prompt); // 存储新消息 memoryService.addMessages(conversationId, response.getMessages()); return response; } } ``` **客户端调用示例**: `POST /chat?message=你好&conversationId=session_12345` #### 方案2:Header传递(推荐) ```java @PostMapping("/chat") public Response chat(@RequestParam String message, @RequestHeader("X-Conversation-Id") String conversationId) { // 实现逻辑同方案1 } ``` **优势**: - 避免URL参数暴露 - 符合RESTful规范 - 支持中间件统一处理 ### 三、技术组件集成 1. **ChatMemory 存储实现** ```java @Component public class JdbcChatMemory implements ChatMemory { private final JdbcTemplate jdbc; @Override public void add(String conversationId, List<Message> messages) { // 存储MySQL jdbc.update("INSERT INTO chat_history VALUES (?, ?)", conversationId, serialize(messages)); } @Override public List<Message> get(String conversationId, int lastN) { // 从DB查询最近N条 return jdbc.queryForObject( "SELECT messages FROM chat_history WHERE id=? ORDER BY time DESC LIMIT ?", (rs, row) -> deserialize(rs.getString(1)), conversationId, lastN ); } } ``` 2. **MyBatis-Plus 增强实现**(参考引用[2]) ```java @Component public class MybatisPlusChatMemory implements ChatMemory { private final ChatMemoryService service; @Override public List<Message> get(String conversationId, int lastN) { return service.getMessages(conversationId, lastN); } // 其他方法实现... } ``` ### 四、最佳实践建议 1. **ID生成策略** - 新会话:客户端生成UUID(如 `UUID.randomUUID().toString()`) - 服务端生成:使用分布式ID生成器(Snowflake等) 2. **安全考虑** - 对 `ConversationId` 签名验证防止篡改 - 设置会话过期时间(如30分钟无交互自动清除[^3]) 3. **性能优化** - 使用Redis缓存热点会话 - 限制获取的历史消息条数(如 `lastN=10`) > **关键点**:Spring AI通过 `ChatMemory` 接口抽象存储层,开发者可选择内存、JDBC、Redis等实现方式,而 `ConversationId` 始终作为会话关联的核心纽带[^2][^4]。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值