Spring AI 多轮对话记忆(ChatMemory)保姆级教程:从内存版到 Redis 持久化:

https://blog.csdn.net/weixin_55772633/article/details/160626801?spm=1011.2415.3001.5331




        在 AI 应用开发中,多轮对话的记忆能力至关重要。Spring AI 提供了 ChatMemory 抽象,但默认的基于内存的实现仅适用于学习测试。在生产环境中,我们需要可靠、高性能的持久化存储方案。本文将带你从零实现一个基于 Redis 的 ChatMemoryRepository,解决序列化、查询等核心问题,并最终完成一个可用的会话记忆组件。

一、为什么需要自定义存储?

Spring AI 官方的 ChatMemory 默认实现是 InMemoryChatMemory,数据存于 JVM 堆内存,存在以下缺陷:

  • 无法持久化,应用重启后对话丢失

  • 不支持分布式部署,多实例间内存不共享

  • 内存占用随会话量线性增长,无法支撑生产规模

因此我们需要更换存储方案。可选的有 MySQL、MongoDB 或 Redis。本文选择 Redis,原因如下:

  • 读写速度快,适合高频对话场景

  • 数据结构丰富,List 天然适合存储聊天记录

  • 使用简单,Spring Data Redis 集成方便

遗憾的是,Spring AI 官方并未提供 Redis 的实现,需要我们自己动手。

二、理解核心接口:ChatMemoryRepository

要自定义存储,首先要实现 org.springframework.ai.chat.memory.ChatMemoryRepository 接口:

public interface ChatMemoryRepository {
    List<String> findConversationIds();
    List<Message> findByConversationId(String conversationId);
    void saveAll(String conversationId, List<Message> messages);
    void deleteByConversationId(String conversationId);
}

该接口定义了会话消息的 CRUD 操作。我们采用 Redis 的 List 结构来存储每个会话的消息列表,key 为 chat:memory:{conversationId},每条消息作为一个 JSON 字符串存放到列表中。

Spring AI 1.1.x 把存储层和裁剪逻辑拆成了两层:

  • ChatMemoryRepository:纯存储接口,只管读写全量消息,不做任何裁剪。

  • MessageWindowChatMemory:包装 Repository,对外暴露 ChatMemory,负责按条数裁剪窗口。

三、导入Redis相关配置

3.1 添加依赖

redis依赖:

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>

常用工具依赖:

        <dependency>
            <groupId>cn.hutool</groupId>
            <artifactId>hutool-all</artifactId>
            <version>5.8.34</version>
        </dependency>
        <dependency>
            <groupId>com.alibaba.fastjson2</groupId>
            <artifactId>fastjson2</artifactId>
            <version>2.0.54</version>
        </dependency>
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-collections4</artifactId>
            <version>4.4</version>
        </dependency>
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-lang3</artifactId>
        </dependency>

3.2 配置 Redis 连接

spring:
  data:
    redis:
      host: localhost
      password: xxx
      port: 6379
      timeout: 5000
      database: 7
      key-prefix: studyAi

3.3 配置Redis配置类

package com.edu.config;

import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.cache.RedisCacheWriter;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.*;

import java.time.Duration;

/**
 * Redis配置类
 * 负责配置RedisTemplate序列化方式、缓存管理器以及自定义key前缀策略
 */
@Configuration
@EnableCaching
public class RedisConfig {

    // 从配置文件中读取Redis key的统一前缀
    @Value("${spring.data.redis.key-prefix}")
    private String keyPrefix;

    /**
     * 配置RedisTemplate
     * - key使用带前缀的String序列化
     * - value使用Jackson JSON序列化
     */
    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(factory);

        // key采用带统一前缀的key序列化器
        template.setKeySerializer(prefixRedisSerializer());
        // hash的key也采用带统一前缀的key序列化器
        template.setHashKeySerializer(prefixRedisSerializer());
        // value序列化方式采用jackson
        template.setValueSerializer(valueSerializer());
        // hash的value序列化方式采用jackson
        template.setHashValueSerializer(valueSerializer());
        template.afterPropertiesSet();
        return template;
    }

    /**
     * 配置Spring Cache缓存管理器
     * - 使用非锁定的RedisCacheWriter
     * - 自定义缓存key前缀拼接规则
     * - value采用Jackson JSON序列化
     * - 默认永不过期
     */
    @Bean
    public CacheManager cacheManager(RedisConnectionFactory redisConnectionFactory) {

        RedisCacheWriter redisCacheWriter = RedisCacheWriter.nonLockingRedisCacheWriter(redisConnectionFactory);
        RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig()
                .computePrefixWith(name -> {
                    if (name.endsWith(":")) {
                        return keyPrefix.concat(":").concat(name);
                    }
                    return keyPrefix.concat(":").concat(name).concat(":");
                })
                .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(valueSerializer()));
        redisCacheConfiguration.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()));
        //过期时间设置为  Duration#ZERO永远不过期
        redisCacheConfiguration.entryTtl(Duration.ZERO);
        return new RedisCacheManager(redisCacheWriter, redisCacheConfiguration);
    }

    /**
     * 创建带统一前缀的key序列化器
     */
    private RedisSerializer<String> prefixRedisSerializer() {
        return new PrefixRedisSerializer(keyPrefix + ":");
    }


    /**
     * 自定义前缀序列化器
     * 在序列化时自动为key添加前缀,实现多应用共用Redis时的命名空间隔离
     */
    static class PrefixRedisSerializer implements RedisSerializer<String> {
        /**
         * 委托给默认的String序列化器处理实际的字节转换
         */
        private final RedisSerializer<String> delegate = RedisSerializer.string();

        /**
         * key前缀,如 "app:"
         */
        private final String prefix;

        public PrefixRedisSerializer(String prefix) {
            this.prefix = prefix;
        }

        @Override
        public byte[] serialize(String s) throws SerializationException {
            return delegate.serialize(prefix + s);
        }

        @Override
        public String deserialize(byte[] bytes) throws SerializationException {
            return delegate.deserialize(bytes);
        }
    }


    /**
     * 使用Jackson序列化器
     *
     * @return
     */
    private RedisSerializer<Object> valueSerializer() {
        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        objectMapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY);
        objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
        return new GenericJackson2JsonRedisSerializer(objectMapper);
    }

}

四、自定义 RedisChatMemoryRepository(简易版本)

4.1自定义 RedisChatMemoryRepository

package com.edu.chatMemory;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.commons.collections4.CollectionUtils;
import org.springframework.ai.chat.memory.ChatMemoryRepository;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.MessageType;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.data.redis.core.StringRedisTemplate;

import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.concurrent.TimeUnit;

/**
 * 基于Redis的聊天记忆存储实现
 * 将对话消息以List结构存储在Redis中,支持按会话ID进行增删查操作
 * 每个会话设置TTL自动过期,避免无限占用内存
 */
public class RedisChatMemoryRepository implements ChatMemoryRepository {

    /**
     * Redis key前缀,完整key格式为 chat:memory:{conversationId}
     */
    private static final String KEY_PREFIX = "chat:memory:";
    /**
     * 会话记录过期天数
     */
    private static final int TTL_DAYS = 3;
    /**
     * JSON序列化工具,忽略未知属性以增强兼容性
     */
    private static final ObjectMapper MAPPER = new ObjectMapper()
            .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);

    private final StringRedisTemplate stringRedisTemplate;

    public RedisChatMemoryRepository(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }


    /**
     * 获取所有会话ID
     * 注意:使用KEYS命令扫描,生产环境数据量大时应替换为SCAN
     */
    @Override
    public List<String> findConversationIds() {
        Set<String> keys = stringRedisTemplate.keys(KEY_PREFIX + "*");
        if (CollectionUtils.isEmpty(keys)) {
            return new ArrayList<>();
        }
        return keys.stream()
                .map(key -> key.substring(KEY_PREFIX.length()))
                .toList();

    }

    /**
     * 根据会话ID查询所有消息
     * 从Redis List中读取原始数据,反序列化为对应的Message子类(UserMessage/AssistantMessage)
     */
    @Override
    public List<Message> findByConversationId(String conversationId) {
        String key = KEY_PREFIX + conversationId;
        List<String> rawMessages = stringRedisTemplate.opsForList().range(key, 0, -1);
        if (CollectionUtils.isEmpty(rawMessages)) {
            return new ArrayList<>();
        }

        List<Message> messages = new ArrayList<>();
        rawMessages.forEach(raw -> {
            try {
                MessageRecord record = MAPPER.readValue(raw, MessageRecord.class);
                if (MessageType.USER.getValue().equals(record.role())) {
                    messages.add(new UserMessage(record.content()));
                } else if (MessageType.ASSISTANT.getValue().equals(record.role())) {
                    messages.add(new AssistantMessage(record.content()));
                }
            } catch (JsonProcessingException e) {
                throw new RuntimeException("Failed to deserialize chat message", e);
            }
        });
        return messages;

    }

    /**
     * 全量保存会话消息
     * 采用"先删后写"策略,确保每次都是完整覆盖而非追加,防止消息重复
     * 写入后刷新TTL过期时间
     */
    @Override
    public void saveAll(String conversationId, List<Message> messages) {
        String key = KEY_PREFIX + conversationId;
        stringRedisTemplate.delete(key);

        List<String> jsonList = messages.stream()
                .map(msg -> {
                    try {
                        return MAPPER.writeValueAsString(new MessageRecord(msg.getMessageType().getValue(), msg.getText()));
                    } catch (JsonProcessingException e) {
                        throw new RuntimeException("Failed to serialize chat message", e);
                    }
                })
                .toList();
        if (!jsonList.isEmpty()) {
            stringRedisTemplate.opsForList().rightPushAll(key, jsonList);
        }
        stringRedisTemplate.expire(key, TTL_DAYS, TimeUnit.DAYS);

    }

    /**
     * 删除指定会话的所有消息记录
     */
    @Override
    public void deleteByConversationId(String conversationId) {
        stringRedisTemplate.delete(KEY_PREFIX + conversationId);
    }

    /**
     * 消息记录DTO,用于Redis序列化/反序列化,role标识消息类型(user/assistant)
     */
    record MessageRecord(String role, String content) {
    }


}

4.2注册 ChatMemory Bean

package com.edu.config;

import com.edu.chatMemory.RedisChatMemoryRepository;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.memory.MessageWindowChatMemory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.core.StringRedisTemplate;

@Configuration
public class ChatMemoryConfig {
 
    @Bean
    public ChatMemory chatMemory(StringRedisTemplate stringRedisTemplate) {
        RedisChatMemoryRepository redisChatMemoryRepository = new RedisChatMemoryRepository(stringRedisTemplate);
        // 底层走 Redis 持久化,上层限制最多保留 20 条消息
        return MessageWindowChatMemory.builder()
                .chatMemoryRepository(redisChatMemoryRepository)
                .maxMessages(20)
                .build();
    }
 
}

4.3注册advisors

@Autowired
    private ChatMemory redisChatMemory;
    
    @Override
    public Flux<ChatEventVO> streamChatJson(String question, String sessionId) {

        return chatClient
                .prompt()
                .advisors(MessageChatMemoryAdvisor.builder(redisChatMemory)
                        .conversationId(sessionId)
                        .build())
                .system(x->x.text(aiPromptResources.getSystemChatMessage())
                        .param("now", LocalDateTime.now()
                                .format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))))
                .user(question)
                .stream()
                .chatResponse()
                .map(chatResponse -> {
                    var text = chatResponse.getResult().getOutput().getText();
                    return ChatEventVO.builder()
                            .eventType(ChatEventTypeEnum.DATA.getValue())
                            .eventData(text)
                            .build();
                })
                .doFirst(() -> GENERATE_STATUS.put(sessionId, true))   // 开始生成时置为 true
                .doOnError(throwable -> GENERATE_STATUS.remove(sessionId))
                .doOnComplete(() -> GENERATE_STATUS.remove(sessionId))
                .takeWhile(response -> GENERATE_STATUS.getOrDefault(sessionId, false))  // 关键:控制流是否继续
                .concatWith(Flux.just(ChatEventVO.builder()
                        .eventType(ChatEventTypeEnum.STOP.getValue()) //结束标识
                        .build()));
    }

4.4测试

聊天如下:

Redis存储如下:(正常存储)

4.5解决“停止生成”导致会话记忆丢失的 Bug

在构建 AI 对话应用时,我们通常会实现两个核心功能:

  • 停止生成:允许用户中途打断 AI 的长文本回复,提升交互体验。

  • 会话记忆:将多轮对话持久化到 Redis,实现跨请求的上下文记忆。

然而,当这两个功能同时启用时,一个隐蔽的 Bug 浮现了:用户点击“停止生成”后,AI 已经输出的内容不会被保存到会话记忆中

4.5.1重现步骤

  1. 在 RedisChatMemoryRepository.saveAll 方法上打断点。

  2. 在 ChatServiceImpl.chat 方法(流式调用入口)上也打断点。

  3. 发起一次对话请求,例如用户输入:“你好,请介绍一下自己”。

  4. 第一次进入 saveAll → 这是保存用户输入的消息(正常)。放行,Redis 中出现用户消息。

  5. 流式输出开始,ChatServiceImpl 断点命中多次,AI 逐字返回内容(例如“您好,我是在线教育平台的客服代表……”)。

  6. 在输出过程中,调用 /stop 接口。

  7. 放行后续断点,观察到:

    • 前端不再有新的数据返回(停止生效)。

    • Redis 中没有任何 AI 回复的消息,只有之前保存的用户问题。

结论:只要执行了停止操作,本次 AI 生成的回复就永久丢失了,不会存入会话记忆。

4.5.2原因分析

通过源码追踪,我们发现:

  • Spring AI 的 MessageWindowChatMemory 在每次完整对话结束后(即 Flux 正常完成时),会自动调用 add 方法将用户消息和 AI 回复一起存入 ChatMemoryRepository

  • 当用户主动中断(/stop)时,底层的 Flux 被取消(cancel),Spring AI 不会触发保存逻辑。这可能是当前版本的设计行为(未处理取消场景),或是一个待完善的边缘情况。

简单来说:正常结束 → 自动保存;异常取消 → 不保存。而我们的停止生成就是通过取消 Flux 实现的,因此造成了记忆丢失。

4.5.3解决方案

既然框架不处理取消场景,那我们就自己动手,在 Flux 取消时手动保存已生成的内容

技术要点

  • 使用 Flux.doOnCancel() 注册取消时的回调。

  • 在流式处理的 map 操作中,动态拼接已输出的文本(存于 StringBuilder)。

  • 取消时,调用 chatMemory.add() 手动将 AI 回复(即使不完整)存入 Redis。

关键doOnCancel() 必须放在 takeWhile() 等可能中断流的操作之前,否则取消事件无法被捕获。

4.5.4改造后的代码

    @Autowired
    private ChatMemory redisChatMemory;

    @Override
    public Flux<ChatEventVO> streamChatJson(String question, String sessionId) {
        // 大模型输出内容的缓存器,用于在输出中断后的数据存储
        var outputBuilder = new StringBuilder();

        return chatClient
                .prompt()
                .advisors(MessageChatMemoryAdvisor.builder(redisChatMemory)
                        .conversationId(sessionId)
                        .build())
                .system(x->x.text(aiPromptResources.getSystemChatMessage())
                        .param("now", LocalDateTime.now()
                                .format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))))
                .user(question)
                .stream()
                .chatResponse()
                .map(chatResponse -> {
                    var text = chatResponse.getResult().getOutput().getText();
                    outputBuilder.append(text);   // 累积文本
                    return ChatEventVO.builder()
                            .eventType(ChatEventTypeEnum.DATA.getValue())
                            .eventData(text)
                            .build();
                })
                .doFirst(() -> GENERATE_STATUS.put(sessionId, true))   // 开始生成时置为 true
                .doOnError(throwable -> GENERATE_STATUS.remove(sessionId))
                .doOnComplete(() -> GENERATE_STATUS.remove(sessionId))
                .doOnCancel(() -> {
                    // 当输出被取消时,保存输出的内容到历史记录中
                    redisChatMemory.add(sessionId, new AssistantMessage(outputBuilder.toString()));
                })
                .takeWhile(response -> GENERATE_STATUS.getOrDefault(sessionId, false))  // 关键:控制流是否继续
                .concatWith(Flux.just(ChatEventVO.builder()
                        .eventType(ChatEventTypeEnum.STOP.getValue()) //结束标识
                        .build()));
    }

4.6主动停止时,redis缓存的内存记录比实际输出多

如果遇到这个问题,可见本文5.5章节,此处不再赘述!!!

五、自定义 RedisChatMemoryRepository(复杂参数版本)

首先我们需要了解下Message接口的子类:

可以看到,有4个具体的子类,分别对应着4种消息:

  • AssistantMessage 大模型生成的消息
  • SystemMessage 系统消息
  • ToolResponseMessage 工具响应消息
  • UserMessage 用户消息

虽然,有4种消息,实际上我们只需要定义一个类来对应这4种消息即可,只需要通过messageType就行。

5.1创建实体类

package com.edu.memory;

import lombok.Data;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.ToolResponseMessage;
import org.springframework.ai.content.Media;

import java.util.List;
import java.util.Map;

/**
 * 自定义消息DTO,用于在Redis中统一存储各类型消息
 * 作为Spring AI Message体系与JSON序列化之间的中间转换对象
 */
@Data
public class MyMessage {

    /** 消息类型标识,对应 MessageType 枚举值(SYSTEM/USER/ASSISTANT/TOOL) */
    private String messageType;
    /** 消息元数据,存储附加键值信息 */
    private Map<String, Object> metadata = Map.of();
    /** 多媒体附件列表,用于携带图片等媒体内容(主要用于USER消息) */
    private List<Media> media = List.of();
    /** AI助手调用的工具列表(仅ASSISTANT消息使用) */
    private List<AssistantMessage.ToolCall> toolCalls = List.of();
    /** 消息文本内容 */
    private String textContent;
    /** 工具执行结果列表(仅TOOL消息使用) */
    private List<ToolResponseMessage.ToolResponse> toolResponses = List.of();
    /** 扩展参数 */
    private Map<String, Object> params = Map.of();

}

5.2编写MessageUtil工具类来实现序列化和反序列化方法:

package com.edu.memory;

import cn.hutool.core.bean.BeanUtil;
import cn.hutool.json.JSONUtil;
import org.springframework.ai.chat.messages.*;

/**
 * 消息转换工具类,提供消息对象与JSON字符串之间的转换功能,主要用于Redis存储格式转换
 */
public class MessageUtil {

    /**
     * 将Message对象转换为Redis存储格式的JSON字符串
     *
     * @param message 需要转换的原始消息对象
     * @return 符合Redis存储规范的JSON字符串
     */
    public static String toJson(Message message) {
        var myMessage = BeanUtil.toBean(message, MyMessage.class);
        // 设置消息内容
        myMessage.setTextContent(message.getText());
        if (message instanceof AssistantMessage assistantMessage) {
            myMessage.setToolCalls(assistantMessage.getToolCalls());
        }

        if (message instanceof ToolResponseMessage toolResponseMessage) {
            myMessage.setToolResponses(toolResponseMessage.getResponses());
        }

        return JSONUtil.toJsonStr(myMessage);
    }

    /**
     * 将Redis存储的JSON字符串反序列化为对应的Message对象
     *
     * @param json Redis存储的JSON格式消息数据
     * @return 对应类型的Message对象
     * @throws RuntimeException 当无法识别的消息类型时抛出异常
     */
    public static Message toMessage(String json) {
        var myMessage = JSONUtil.toBean(json, MyMessage.class);
        var messageType = MessageType.valueOf(myMessage.getMessageType());
        switch (messageType) {
            case SYSTEM -> {
                //return new SystemMessage(myMessage.getTextContent());
                return SystemMessage.builder()
                        .text(myMessage.getTextContent())
                        .build();
            }
            case USER -> {
                return UserMessage.builder()
                        .text(myMessage.getTextContent())
                        .metadata(myMessage.getMetadata())
                        .media(myMessage.getMedia())
                        .build();
            }
            case ASSISTANT -> {
               return AssistantMessage.builder()
                        .content(myMessage.getTextContent())
                        .media(myMessage.getMedia())
                        .toolCalls(myMessage.getToolCalls())
                        .build();
            }
            case TOOL -> {
                return ToolResponseMessage.builder()
                        .responses(myMessage.getToolResponses())
                        .metadata(myMessage.getMetadata())
                        .build();
            }
        }

        throw new RuntimeException("Message data conversion failed.");
    }

}

5.3自定义 RedisChatMemoryRepository

package com.edu.chatMemory;

import cn.hutool.core.collection.CollStreamUtil;
import cn.hutool.core.stream.StreamUtil;
import cn.hutool.core.util.StrUtil;
import com.edu.memory.MessageUtil;
import org.apache.logging.log4j.util.Strings;
import org.springframework.ai.chat.memory.ChatMemoryRepository;
import org.springframework.ai.chat.messages.Message;
import org.springframework.data.redis.core.StringRedisTemplate;

import java.util.List;
import java.util.Set;

/**
 * 基于Redis实现的ChatMemoryRepository
 */
public class RedisChatMemoryRepository implements ChatMemoryRepository {

    // 默认redis中key的前缀
    public static final String DEFAULT_PREFIX = "chat:memory:";

    private StringRedisTemplate stringRedisTemplate;


    public RedisChatMemoryRepository(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    @Override
    public List<String> findConversationIds() {
        Set<String> keys = this.stringRedisTemplate.keys(DEFAULT_PREFIX + "*");
        if (null == keys) {
            return List.of();
        }
        return StreamUtil.of(keys)
                .map(key -> StrUtil.replace(key, DEFAULT_PREFIX, Strings.EMPTY))
                .toList();
    }

    @Override
    public List<Message> findByConversationId(String conversationId) {
        var redisKey = this.getKey(conversationId);
        var listOps = this.stringRedisTemplate.boundListOps(redisKey);
        // 获取列表中所有的数据
        var messages = listOps.range(0, -1);
        return CollStreamUtil.toList(messages, MessageUtil::toMessage);
    }

    @Override
    public void saveAll(String conversationId, List<Message> messages) {
        // 注意:messages 是全量的消息列表
        var redisKey = this.getKey(conversationId);
        var listOps = this.stringRedisTemplate.boundListOps(redisKey);

        //将原有消息全部删除
        this.deleteByConversationId(conversationId);

        // 保存数据到redis
        messages.forEach(message -> listOps.rightPush(MessageUtil.toJson(message)));
    }

    @Override
    public void deleteByConversationId(String conversationId) {
        var redisKey = this.getKey(conversationId);
        this.stringRedisTemplate.delete(redisKey);
    }

    private String getKey(String conversationId) {
        return DEFAULT_PREFIX + conversationId;
    }


}

5.4注册 ChatMemory Bean

package com.edu.config;

import com.edu.chatMemory.RedisChatMemoryRepository;
import com.edu.chatMemory.RedisChatSimpleMemoryRepository;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.memory.MessageWindowChatMemory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.core.StringRedisTemplate;

@Configuration
public class ChatMemoryConfig {
 
    @Bean
    public ChatMemory chatMemory(StringRedisTemplate stringRedisTemplate) {
        //RedisChatSimpleMemoryRepository redisChatMemoryRepository = new RedisChatSimpleMemoryRepository(stringRedisTemplate);
        RedisChatMemoryRepository redisChatMemoryRepository = new RedisChatMemoryRepository(stringRedisTemplate);
        // 底层走 Redis 持久化,上层限制最多保留 20 条消息
        return MessageWindowChatMemory.builder()
                .chatMemoryRepository(redisChatMemoryRepository)
                .maxMessages(20)
                .build();
    }
 
}

5.5小问题注意事项

问题描述:主动停止时,redis缓存的内存记录比实际输出多。也就是说,当调用取消(cancel)时,doOnCancel 中会将 outputBuilder.toString() 保存到 redisChatMemory 中,但这个 outputBuilder 累积的文本可能比实际输出给客户端的要多。

观察代码

问题原因:outputBuilder.append(text) 发生在 takeWhile 判断之前,当主动取消(cancel)时,最后一个 chunk 的文本已被追加到 outputBuilder,但由于 takeWhile 的条件变为 false(如 GENERATE_STATUS 被外部设为 false 或取消导致流终止),该 chunk 实际上并没有被发射给客户端。最终 doOnCancel 保存了包含这个未发送 chunk 的完整 outputBuilder,导致 Redis 中存储的内容比客户端实际收到的多。

解决方案:将文本追加操作移到 takeWhile 之后,确保只有确认要发射的 chunk 才被累积到 outputBuilder 中。同时建议在 doOnComplete 中也保存历史记录(适用于优雅停止场景),并保持 doOnCancel 的保存逻辑。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐