目录

一、为什么我开始研究 Spring AI 的对话记忆

二、理论准备

ChatMemory 是什么?

如何工作

三、项目环境 & 依赖

四、配置 Redis 环境

五、自定义 RedisChatMemory 实现

六、整合到 Spring AI ChatClient

七、Controller 层接口设计

八、测试效果验证

九、总结


💡 一次从“金鱼记忆”到“长期记忆”的实践之旅

一、为什么我开始研究 Spring AI 的对话记忆

最近在做一个 AI 问答 Demo 时,我遇到一个尴尬的问题:

每次我和AI聊得正开心,它突然忘了我是谁 😭。

这让我开始思考:
大模型能不能像人一样,记住我们之前说过的话?

于是我开始研究 Spring AI 框架
它是 Spring 官方出的 AI 集成框架,不仅封装了调用大模型的细节,还引入了一个很妙的概念 —— ChatMemory(对话记忆)。

接下来,我就记录一下我的探索过程:
从理解理论到自己写出 基于 Redis 的持久化记忆系统

二、理论准备

ChatMemory 是什么?

它像一个“会话管理器”,存储对话历史(List),确保每次调用 LLM 时注入上下文。原理基于:

  • 上下文窗口(Context Window):LLM 有 Token 限制(e.g., 4096 Tokens),ChatMemory 负责截取最近 N 条消息,避免超出窗口。

  • 消息角色分离:Spring AI 的 Message 接口有实现类:

    • SystemMessage:系统提示,定义 AI 角色。

    • UserMessage:用户输入。

    • AssistantMessage:AI 输出。

    • ChatMemory 存储这些 Message 对象,保持顺序(FIFO)。

  • 设计模式:ChatMemory 是接口,遵循 Spring 的 SPI(Service Provider Interface),允许自定义实现(如内存、Redis)。它像 Spring Session 的存储后端,管理会话 ID(conversationId)。

原理上,ChatMemory 解决了“幻觉”(Hallucination)和“上下文丢失”问题:通过注入历史消息,让 LLM “回忆”对话,生成更准确的响应。

如何工作

ChatMemory 通过 Advisor 机制集成到 ChatClient 中。工作流程如下(结合 Spring AI 的源码分析):

1、接口定义(ChatMemory.java):

  • default void add(String conversationId, Message message) {
        this.add(conversationId, List.of(message));
    }:添加单条消息,但还是包装成 List<Message> 后调用核心的批量添加方法

  • add(String conversationId, List<Message> messages):追加消息到指定会话。

  • get(String conversationId, int lastN):获取最近 N 条消息。

  • clear(String conversationId):清理会话。

2、集成到 ChatClient

  • 使用 MessageChatMemoryAdvisor(一个 AOP-like 切面)。

  • 在 ChatClient.prompt() 时:

    • get 历史消息,注入到 Prompt 中(作为 User/Assistant 角色)。

    • 调用 LLM 生成响应。

    • add 新消息(用户输入 + AI 输出)到 ChatMemory。

示例流程:

  • 用户请求 1:Prompt = System + User1。

  • AI 响应:Assistant1。

  • add(User1, Assistant1)。

  • 用户请求 2:Prompt = System + User1 + Assistant1 + User2。

  • 以此类推。

3、内置实现:

  • InMemoryChatMemoryRepository:默认使用,用ConcurrentHashMap来管理,简单但不持久,重启丢失。

  • JdbcChatMemoryRepository:它使用 JDBC 将消息存储在关系数据库中,支持 PostgreSQL、MySQL、SQL Server 等。

<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-starter-model-chat-memory-repository-jdbc</artifactId>
</dependency>
  • CassandraChatMemoryRepository:使用 Apache Cassandra 来存储消息,支持 TTL、时间序列审计

<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-starter-model-chat-memory-repository-cassandra</artifactId>
</dependency>
  • Neo4jChatMemoryRepository:它使用 Neo4j 将聊天消息作为节点和关系存储在属性图数据库中,以图结构存储消息、会话、工具调用等。

<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-starter-model-chat-memory-repository-neo4j</artifactId>
</dependency>

详细内存存储库见:官方内存存储库文档

Spring AI 默认实现的是 内存型 ChatMemory(程序重启就全忘光)。
但我希望能让它记在 Redis 里——这样即使服务重启,对话历史也不会丢失。

三、项目环境 & 依赖

组件 版本
Spring Boot 3.6.5
Spring AI 1.0.0-M6
Redis 6.2.6
JDK 17

pom.xml

<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-openai-spring-boot-starter</artifactId>
    <version>1.0.0-M6</version>
</dependency>
<!--swagger doc 用于管理接口文档-->
<dependency>
    <groupId>org.springdoc</groupId>
    <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
    <version>2.8.12</version>
</dependency>

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

application.yml

spring:
  application:
    name: qwen-springai-demo
  ai:
    openai:
      api-key: "换成你的秘钥即可"
      base-url: https://dashscope.aliyuncs.com/compatible-mode # 这里使用的通义千问的模型,所以需要指定端点
      chat:
        options:
          model: qwen-turbo # 指定模型名称
          temperature: 0.7
  data:
    redis:
      host: "替换成你的地址"
      port: 6379
      password: "redis开启了密码鉴权就输入你的密码即可,没有删除就行"
      database: 1 "默认使用id为0的数据库,不需要指定数据库的话,删除即可"

四、配置 Redis 环境

Redis 是高性能的键值数据库,非常适合存储会话类数据。

在本项目中,我们通过 RedisTemplate 来操作 Redis,由于这里是要对Message进行序列化,在RedisTemplate模版中直接指定类型为Message即可

@Configuration
public class RedisConfig {

    /**
     * 创建一个RedisTemplate实例,并设置相应的序列化方式
     * @param factory Redis连接工厂
     * @param builder Jackson2ObjectMapperBuilder实例
     * @return RedisTemplate实例
     */
    @Bean
    public RedisTemplate<String, Message> messageRedisTemplate(RedisConnectionFactory factory, Jackson2ObjectMapperBuilder builder) {
        RedisTemplate<String, Message> template = new RedisTemplate<>();
        template.setConnectionFactory(factory);

        // 使用String序列化器作为key的序列化方式
        template.setKeySerializer(new StringRedisSerializer());
        // 使用自定义的Message序列化器作为value的序列化方式
        template.setValueSerializer(new MessageRedisSerializer(builder.build()));

        // 设置hash类型的key和value序列化方式
        template.setHashKeySerializer(new StringRedisSerializer());
        template.setHashValueSerializer(new MessageRedisSerializer(builder.build()));

        template.afterPropertiesSet();
        return template;
    }

    /**
     * 创建一个ObjectMapper实例,并注册JavaTimeModule以支持Java 8的日期和时间类型
     * @return ObjectMapper实例
     */
    @Bean
    public ObjectMapper objectMapper() {
        return new ObjectMapper().registerModule(new JavaTimeModule());
    }
}

这里我们需要自定义Message序列化器,以便于序列化和反序列化

public class MessageRedisSerializer implements RedisSerializer<Message> {
    private final ObjectMapper objectMapper;
    private final JsonDeserializer<Message> messageDeserializer;

    /**
     * 创建一个 MessageRedisSerializer,使用指定的 ObjectMapper 对象进行序列化和反序列化。
     * @param objectMapper 用于进行序列化和反序列化的 ObjectMapper 对象
     */
    public MessageRedisSerializer(ObjectMapper objectMapper) {
        this.objectMapper = objectMapper;
        this.messageDeserializer = new JsonDeserializer<>() {
            @Override
            public Message deserialize(JsonParser jp, DeserializationContext ctx)
                    throws IOException {
                ObjectNode root = jp.readValueAsTree();
                String type = root.get("messageType").asText();

                return switch (type) {
                    case "USER" -> new UserMessage(root.get("text").asText());
                    case "ASSISTANT" -> new AssistantMessage(root.get("text").asText());
                    default -> throw new UnsupportedOperationException("未知的消息类型");
                };
            }
        };
    }

    /**
     * 将 Message 对象序列化为字节数组。
     * @param message 要进行序列化的 Message 对象
     * @return 序列化后的字节数组
     */
    @Override
    public byte[] serialize(Message message) {
        try {
            return objectMapper.writeValueAsBytes(message);
        } catch (JsonProcessingException e) {
            throw new RuntimeException("无法序列化", e);
        }
    }

    /**
     * 将字节数组反序列化为 Message 对象。
     * @param bytes 要进行反序列化的字节数组
     * @return 反序列化后的 Message 对象
     */
    @Override
    public Message deserialize(byte[] bytes) {
        if (bytes == null || bytes.length == 0) {
            return null;
        }
        try {
            return messageDeserializer.deserialize(objectMapper.getFactory().createParser(bytes), objectMapper.getDeserializationContext());
        } catch (Exception e) {
            throw new RuntimeException("无法反序列化", e);
        }
    }
}

五、自定义 RedisChatMemory 实现

以下是基于 Redis 的 ChatMemory 实现类

@Component
public class RedisChatMemory implements ChatMemory {

    private static final String REDIS_KEY_PREFIX = "chat:history:"; // Redis 键的前缀

    @Autowired
    private RedisTemplate<String, Message> redisTemplate;

    /**
     * 添加消息到 Redis
     * @param conversationId 会话ID
     * @param messages 消息列表
     */
    @Override
    public void add(String conversationId, List<Message> messages) {
        String key = REDIS_KEY_PREFIX + conversationId; // 生成 Redis 键
        redisTemplate.opsForList().rightPushAll(key, messages); // 添加消息到 Redis
        redisTemplate.expire(key, Duration.ofDays(7)); // 设置过期时间 7 天
    }

    /**
     * 获取 Redis 中的消息
     * @param conversationId 会话ID
     * @param lastN 最后N条消息
     * @return 消息列表
     */
    @Override
    public List<Message> get(String conversationId, int lastN) {
        String key = REDIS_KEY_PREFIX + conversationId;
        // 从 Redis 获取最新的 lastN 条消息
        List<Message> serializedMessages = redisTemplate.opsForList().range(key, -lastN, -1);
        if (serializedMessages != null) {
            return serializedMessages;
        }
        return List.of(); // 如果没有消息,则返回一个空列表
    }

    /**
     * 清空 Redis 中的消息
     * @param conversationId 会话ID
     */
    @Override
    public void clear(String conversationId) {
        redisTemplate.delete(REDIS_KEY_PREFIX + conversationId); // 删除 Redis 中的消息
    }
}

💬 思路解析:

  • 用 Redis 的 List 结构来保存消息历史,能够保持顺序并高效追加。

  • 每个会话的 Key 是 chat:history:{conversationId},作为唯一标识,便于区分不同用户或会话。

  • 消息按顺序压入列表(保证对话顺序不乱)。

  • 想清空会话时,只要删掉这个 Key。

六、整合到 Spring AI ChatClient

Spring AI 的 ChatClient 支持通过 Advisor 集成记忆模块。我们将 RedisChatMemory 注入其中

@Configuration
public class CommonConfig {

    /**
     * 创建一个 ChatMemory 实例
     * @return ChatMemory 实例
     */
    @Bean
    public ChatMemory chatMemory() {
        return new RedisChatMemory();
    }

    /**
     * 创建一个 ChatClient 实例
     * @param chatClientBuilder ChatClient.Builder 实例
     * @param chatMemory ChatMemory 实例
     * @return ChatClient 实例
     */
    @Bean
    public ChatClient chatClient(ChatClient.Builder chatClientBuilder, ChatMemory chatMemory) {
        return chatClientBuilder
                .defaultAdvisors(new MessageChatMemoryAdvisor(chatMemory))// 注入上下文记忆
                .build();
    }
}

这样,每次调用大模型时,它都会自动带上 Redis 里保存的上下文,实现真正的多轮对话记忆

七、Controller 层接口设计

设计一个简单的聊天接口,支持根据 conversationId 管理上下文

@RestController
@Tag(name = "对话(含提示词模版)", description = "通义千问")
public class SmartChatController {

    @Autowired
    private ChatClient chatClient;

    
    /**
     * 流式输出由模型生成的内容
     * @param userInput 用户输入
     * @param language 语言
     * @param conversationId 会话ID
     * @return 模型生成的内容
     */
    @Operation(summary = "流式输出由模型生成的内容")
    @GetMapping(value = "/smart-chat-stream", produces = "text/html;charset=utf-8")
    public Flux<String> streamResponse(
            @RequestParam String userInput,
            @RequestParam(defaultValue = "en") String language,
            @RequestParam String conversationId
    ) {
        String systemPrompt = """
                你是一家科技公司的专业客服代理。
                请用{language}语言,以礼貌和简洁的语调回复。
                当被询问时,请提供关于Spring AI的准确信息。
                """;
        String userPromptTemplate = """
                用户问题: {userInput}
                请清晰且专业地回答这个问题。
                """;

        PromptTemplate promptTemplate = new PromptTemplate(userPromptTemplate); // 创建 PromptTemplate 对象
        Map<String, Object> promptParams = Map.of(
                "userInput", userInput,
                "language", language.equals("zh") ? "Chinese" : "English"
        ); // 创建参数, 用于替换 PromptTemplate 中的占位符

        // 使用 stream() 返回 Flux,实时输出
        return chatClient.prompt()
                .system(systemPrompt.replace("{language}", promptParams.get("language").toString())) // 设置系统提示,使用参数替换占位符
                .user(promptTemplate.create(promptParams).getContents())  // 设置用户提示,使用参数替换占位符
                .advisors(advisorSpec -> advisorSpec.param(CHAT_MEMORY_CONVERSATION_ID_KEY, conversationId).param(CHAT_MEMORY_RETRIEVE_SIZE_KEY, 10)) // 限制上下文为最近 10 条消息
                .stream()
                .content();
    }
}

这里使用了Prompt模版,有利于Ai扮演一个特定角色,提升回复专业性和一致性,以及输出质量和风格一致性。

八、测试效果验证

第一次请求

第二次请求(同 ID)

到这里,Redis + ChatMemory 的对话记忆功能完美实现!

九、总结

Spring AI 不是“又一个 SDK”,而是一套让 AI 能像 Spring Boot 一样优雅运行的框架。

它帮我把“AI 记忆”从概念变成了代码,也让我重新理解了企业级 AI 开发该有的样子。

“让 AI 开发融入 Spring 生态,而不是另起炉灶。”

如果你觉得这篇文章对你有帮助,别忘了点赞 👍 收藏 ⭐ 支持一下!你的支持就是我学习最大的动力

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐