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

一、关键点分析

  1. 核心痛点:大模型 API 本身是无状态的,每次请求都是独立的。网页版 ChatGPT 能"记住"是因为在每次请求时把历史消息都塞进了上下文。

  2. 手动实现的麻烦 :需要自己维护 List<Message>,并且要处理会话 ID、上下文窗口超限、服务重启丢历史等问题。

  3. Spring AI 的解决方案 :通过 ChatMemory + Advisor 自动管理对话历史。

    • MessageWindowChatMemory(内存版,按条数裁剪)

    • MessageChatMemoryAdvisor(注入到 ChatClient 的调用链中)

  4. 控制消息数量maxMessages 参数,权衡记忆长度与 Token 消耗。

  5. 生产级持久化

    • 架构分层:ChatMemoryRepository(纯存储) + MessageWindowChatMemory(包装、裁剪)

    • 以 Redis 为例:实现 RedisChatMemoryRepository,注入 StringRedisTemplate,存储为 List,设置 TTL。

    • 注册为 Bean 后,Controller 代码与内存版完全一致。

  6. 会话管理 :提供 clear(conversationId) 接口。

  7. Token 预算问题

    • 简单策略:限制保留条数。

    • 精确策略:按字符数/Token 数截断(示例 TokenBudgetChatMemory)。

    • 高级策略:摘要压缩(后续 Agent 涉及)。




模型本身是无状态的------每次 API 请求对它来说都是全新的,上一次聊了什么它完全不知道。

"但我在 ChatGPT 网页上聊天,它明明能记住前面的内容啊?"

对,那是因为网页前端每次都把历史记录一起发给了模型,不是模型自己有记忆,而是历史消息被塞进了这次请求的上下文里。

Spring AI 的 ChatMemory 就是帮你做这件事的------自动管理对话历史,每次发请求时自动带上前面的消息。

二、不用 ChatMemory 时,多轮对话要怎么做

先看看手动实现多轮对话是什么样的:

复制代码
@RestController
@RequestMapping("/manual-chat")
public class ManualChatController {

    private final ChatClient chatClient;
    // 手动维护每个会话的历史(演示用,生产不推荐)
    private final Map<String, List<Message>> sessions = new ConcurrentHashMap<>();

    public ManualChatController(ChatClient.Builder builder) {
        this.chatClient = builder.build();
    }

    @PostMapping
    public String chat(@RequestBody ChatRequest request) {
        // 获取或创建该会话的历史
        List<Message> history = sessions.computeIfAbsent(request.conversationId(), id -> {
            List<Message> list = new ArrayList<>();
            list.add(new SystemMessage("你是一个 Java 技术助手"));
            return list;
        });

        // 追加用户消息
        history.add(new UserMessage(request.message()));

        // 带完整历史调用模型
        String reply = chatClient.prompt()
                .messages(history)
                .call()
                .content();

        // 把模型回复也追加进历史
        history.add(new AssistantMessage(reply));

        return reply;
    }

    record ChatRequest(String conversationId, String message) {}
}

能实现,但有明显痛点:

  • 历史列表需要调用方自己维护,接口无状态,每次请求都要传完整历史。

  • 上下文窗口有限,对话一长,总 Token 数超出限制就会报错。

  • 没有持久化,服务重启历史就丢了。

ChatMemory 解决的就是这些问题。

三、ChatMemory 基础用法(内存版)

Spring AI 内置了基于 AdvisorChatMemory 支持。

复制代码
@RestController
@RequestMapping("/memory-chat")
public class MemoryChatController {

    private final ChatClient chatClient;
    private final MessageWindowChatMemory chatMemory;

    public MemoryChatController(ChatClient.Builder builder) {
        // 保留最近 10 条消息
        this.chatMemory = MessageWindowChatMemory.builder().maxMessages(10).build();
        this.chatClient = builder
                .defaultSystem("你是一个 Java 技术助手")
                .build();
    }

    @GetMapping
    public String chat(
            @RequestParam String message,
            @RequestParam(defaultValue = "default") String conversationId) {

        return chatClient.prompt()
                .user(message)
                .advisors(MessageChatMemoryAdvisor.builder(chatMemory)
                        .conversationId(conversationId)
                        .build())
                .call()
                .content();
    }
}

测试效果:

复制代码
# 第一轮
curl "http://localhost:8080/memory-chat?message=我叫大王&conversationId=user123"
# 模型回复:你好,大王!有什么可以帮你的?

# 第二轮(同一个 conversationId)
curl "http://localhost:8080/memory-chat?message=你还记得我叫什么吗&conversationId=user123"
# 模型回复:记得,你叫大王。

# 换一个 conversationId(新会话,不记得之前的内容)
curl "http://localhost:8080/memory-chat?message=你还记得我叫什么吗&conversationId=user456"
# 模型回复:抱歉,我不知道你的名字,你可以告诉我吗?
  • MessageWindowChatMemory 默认基于内存存储,重启应用后记忆会丢失。生产环境可替换为持久化实现(如 Redis、数据库)。

四、控制保留的消息数量

默认保留最近 20 条消息,你可以自定义:

复制代码
@RestController
@RequestMapping("/long-chat")
public class LongChatController {

    private final ChatClient chatClient;
    private final MessageWindowChatMemory chatMemory;

    public LongChatController(ChatClient.Builder builder) {
        // 保留最近 20 条消息
        this.chatMemory = MessageWindowChatMemory.builder().maxMessages(20).build();
        this.chatClient = builder
                .defaultSystem("你是一个 Java 技术助手")
                .build();
    }

    @GetMapping
    public String chat(...) {
        // 与上例完全相同,只是 maxMessages 改了
        return chatClient.prompt()
                .user(message)
                // 1.1.x 新 API:按 conversationId 构建 Advisor
                .advisors(MessageChatMemoryAdvisor.builder(chatMemory)
                        .conversationId(conversationId)
                        .build())
                .call()
                .content();
    }
}

消息数量的权衡:

  • 太少:模型忘得快,早期说的内容就不记得了。

  • 太多:每次发送的 Token 增多,费用上升,超出上下文窗口也会报错。

一般客服/聊天场景保留 10~20 条够用;如果是长文档处理,需要配合 RAG 或用长上下文模型。

五、持久化存储(生产环境必备)

MessageWindowChatMemory 只在内存里,服务一重启历史就没了,生产环境不能用。

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

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

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

4.1 添加依赖

复制代码
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

4.2 配置 Redis 连接

复制代码
spring:
  data:
    redis:
      host: localhost
      port: 6379
      database: 0

4.3 自定义 RedisChatMemoryRepository

复制代码
package com.studying.chatMemory;

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.RedisTemplate;

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

public class RedisChatMemoryRepository implements ChatMemoryRepository {

    private static final String KEY_PREFIX = "chat:memory:";
    private static final int TTL_DAYS = 3;
    private static final ObjectMapper MAPPER = new ObjectMapper()
            .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);

    private final RedisTemplate<String, Object> redisTemplate;

    public RedisChatMemoryRepository(RedisTemplate<String, Object> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }


    @Override
    public List<String> findConversationIds() {
        // 实现不推荐在生产环境使用,"KEYS *" 命令可能导致性能问题
        Set<String> keys = redisTemplate.keys(KEY_PREFIX + "*");
        if (CollectionUtils.isEmpty(keys)) {
            return new ArrayList<>();
        }
        return keys.stream()
                .map(key -> key.substring(KEY_PREFIX.length()))
                .toList();

    }

    @Override
    public List<Message> findByConversationId(String conversationId) {
        String key = KEY_PREFIX + conversationId;
        List<Object> rawMessages = redisTemplate.opsForList().range(key, 0, -1);
        if (CollectionUtils.isEmpty(rawMessages)) {
            return new ArrayList<>();
        }

        List<Message> messages = new ArrayList<>();
        rawMessages.stream()
                .forEach(raw -> {
                    MessageRecord record = MAPPER.convertValue(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()));
                    }
                });
        return messages;

    }

    @Override
    public void saveAll(String conversationId, List<Message> messages) {
        String key = KEY_PREFIX + conversationId;
        // 先清除旧数据,再全量写入,避免重复追加
        redisTemplate.delete(key);

        List<MessageRecord> recordList = messages.stream()
                .map(msg -> new MessageRecord(msg.getMessageType().getValue(), msg.getText()))
                .toList();
        redisTemplate.opsForList().rightPushAll(key, recordList.toArray());
        redisTemplate.expire(key, TTL_DAYS, TimeUnit.DAYS);

    }

    @Override
    public void deleteByConversationId(String conversationId) {
        redisTemplate.delete(KEY_PREFIX + conversationId);
    }

    record MessageRecord(String role, String content) {
    }


}

4.4 注册 ChatMemory Bean

复制代码
@Configuration
public class ChatMemoryConfig {

    @Bean
    public ChatMemory chatMemory(RedisTemplate<String, Object> redisTemplate) {
        RedisChatMemoryRepository repository = new RedisChatMemoryRepository(redisTemplate);
        // 底层走 Redis 持久化,上层限制最多保留 20 条消息
        return MessageWindowChatMemory.builder()
                .chatMemoryRepository(repository)
                .maxMessages(20)
                .build();
    }

}

4.5 Controller 使用(与内存版完全一样)

复制代码
@RestController
@RequestMapping("/redis-chat")
public class RedisChatController {

    private final ChatClient chatClient;
    private final ChatMemory chatMemory;

    public RedisChatController(ChatClient.Builder builder, ChatMemory chatMemory) {
        this.chatMemory = chatMemory;
        this.chatClient = builder
                .defaultSystem("你是一个 Java 技术助手")
                .build();
    }

    @GetMapping
    public String chat(
            @RequestParam String message,
            @RequestParam(defaultValue = "default") String conversationId) {

        return chatClient.prompt()
                .user(message)
                .advisors(MessageChatMemoryAdvisor.builder(chatMemory)
                        .conversationId(conversationId)
                        .build())
                .call()
                .content();
    }
}

测试效果: 服务重启后历史依然保留(数据存在 Redis 里)

复制代码
# 第一轮
curl "http://localhost:8080/redis-chat?message=我叫大王&conversationId=user123"

# 重启服务后再发第二轮
curl "http://localhost:8080/redis-chat?message=你还记得我叫什么吗&conversationId=user123"
# 模型回复:记得,你叫大王。

六、会话管理:清除历史

用户退出登录、开启新对话时,需要清除历史:

复制代码
@RestController
@RequestMapping("/session")
public class SessionController {

    private final ChatMemory chatMemory;

    public SessionController(ChatMemory chatMemory) {
        this.chatMemory = chatMemory;
    }

    @DeleteMapping("/{conversationId}")
    public void clearHistory(@PathVariable String conversationId) {
        chatMemory.clear(conversationId);
    }
}

七、完整的多轮对话 Controller

复制代码
@RestController
@RequestMapping("/api/conversation")
public class ConversationController {

    private final ChatClient chatClient;
    private final ChatMemory chatMemory;

    public ConversationController(ChatClient.Builder builder, ChatMemory chatMemory) {
        this.chatMemory = chatMemory;
        this.chatClient = builder
                .defaultSystem("""
                        你是一个智能助手。
                        记住用户告诉你的所有信息,在后续对话中灵活运用。
                        回答简洁,除非用户要求详细解释。
                        """)
                .build();
    }

    @PostMapping("/message")
    public MessageResponse sendMessage(@RequestBody MessageRequest request) {
        String reply = chatClient.prompt()
                .user(request.message())
                .advisors(MessageChatMemoryAdvisor.builder(chatMemory)
                        .conversationId(request.conversationId())
                        .build())
                .call()
                .content();

        return new MessageResponse(reply, request.conversationId());
    }

    @DeleteMapping("/{conversationId}")
    public void clearConversation(@PathVariable String conversationId) {
        chatMemory.clear(conversationId);
    }

    record MessageRequest(String conversationId, String message) {}
    record MessageResponse(String reply, String conversationId) {}
}

八、上下文窗口和 Token 预算

多轮对话最容易踩的坑是 Token 超限。每个模型都有最大上下文长度限制(比如 DeepSeek-V3 是 128K Token)。历史消息越多,每次请求的 Token 数就越多。

策略一:限制保留消息数(最简单,已介绍)

策略二:按 Token 数限制(更精确)

实现一个按 Token 预算裁剪的 ChatMemory

复制代码
public class TokenBudgetChatMemory implements ChatMemory {

    private static final int CHARS_PER_TOKEN = 4;  // 粗估:4个字符≈1 Token
    private final int maxTokenBudget;
    private final ConcurrentHashMap<String, List<Message>> store = new ConcurrentHashMap<>();

    public TokenBudgetChatMemory(int maxTokenBudget) {
        this.maxTokenBudget = maxTokenBudget;
    }

    @Override
    public void add(String conversationId, List<Message> messages) {
        store.computeIfAbsent(conversationId, k -> new ArrayList<>()).addAll(messages);
    }

    @Override
    public List<Message> get(String conversationId) {
        List<Message> all = store.getOrDefault(conversationId, List.of());
        if (all.isEmpty()) return List.of();

        List<Message> result = new ArrayList<>();
        int tokenCount = 0;
        for (int i = all.size() - 1; i >= 0; i--) {
            int msgTokens = all.get(i).getText().length() / CHARS_PER_TOKEN;
            if (tokenCount + msgTokens > maxTokenBudget) break;
            result.add(all.get(i));
            tokenCount += msgTokens;
        }

        Collections.reverse(result);
        return result;
    }

    @Override
    public void clear(String conversationId) {
        store.remove(conversationId);
    }
}

使用:

复制代码
ChatMemory tokenBudgetMemory = new TokenBudgetChatMemory(2000); // 预算 2000 Token

策略三:摘要压缩(高级)

定期把历史消息压缩成摘要,用摘要替代原始历史,大幅减少 Token 占用。这个方案比较复杂,适合长期对话场景,后续 Agent 模块会涉及。

八、总结

  • 模型无状态,多轮对话需要自行管理历史消息。

  • Spring AI 的 ChatMemory 通过 Advisor 机制自动帮你做这件事。

  • 内存版 切换到 Redis 持久化 只需要实现 ChatMemoryRepository 并注入 MessageWindowChatMemory Bean,Controller 代码零更改。

  • 注意控制 消息数量Token 预算,防止超出模型上下文窗口。

  • 提供 clear 接口让用户能够主动清除对话历史。

相关推荐
Thanks_ks7 小时前
穿透海量数据的迷雾:深入理解布隆过滤器的架构哲学与工程权衡
redis·高并发·缓存穿透·架构设计·布隆过滤器·分布式系统·海量数据
tjl521314_217 小时前
01C++ 类定义与访问控制(封装)
java·开发语言·c++
无籽西瓜a7 小时前
【西瓜带你学Kafka | 第七期】Kafka 日志存储体系:保留清理、消息格式与分段刷新策略(文含图解)
java·分布式·后端·kafka·消息队列·mq
空中海7 小时前
第四章:Maven专家篇 — 企业级实践与 CI/CD 集成
java·maven
lifewange7 小时前
CNode API v1 完整接口文档(JSON 规范整理)
java·前端·json
lee_curry16 小时前
第四章 jvm中的垃圾回收器
java·jvm·垃圾收集器
九转成圣17 小时前
Java 性能优化实战:如何将海量扁平数据高效转化为类目字典树?
java·开发语言·json
直奔標竿17 小时前
Java开发者AI转型第二十七课!Spring AI 个人知识库实战(六)——全栈闭环收官,解锁前端流式渲染终极技巧
java·开发语言·前端·人工智能·后端·spring
金銀銅鐵18 小时前
[java] 编译之后的记录类(Record Classes)长什么样子(上)
java·jvm·后端