Spring AI 对话记忆深度实践:无状态本质、ChatMemory 抽象、上下文管理与会话隔离

Spring AI 对话记忆深度实践:无状态本质、ChatMemory 抽象、上下文管理与会话隔离

前两篇文章我们解决了两个问题:

  • 如何用三层架构接入大模型,并支持同步/流式调用;
  • 如何用 System / User / Assistant 角色和 Prompt Template 管理提示词。

但到目前为止,每次请求都是独立的:用户问"Spring 是什么?"然后追问"那它和 Spring Boot 有什么区别?",第二次请求时模型完全不记得第一次说了什么。要构建真正的智能对话系统,就必须让模型具备记忆

本文将从大模型的"无状态"本质出发,深入讲解 Spring AI 中的 ChatMemory 抽象、多轮对话上下文管理策略,以及如何用 conversationId 实现精准的会话隔离。


一、理解 Agent 的"无状态"问题

1.1 大模型本身是无状态的

无论是 OpenAI、通义千问还是 Ollama 中的本地模型,底层的大语言模型(LLM)本质上是一个 函数:输入一段 token 序列,输出一段 token 序列。它不保存任何跨请求的历史信息。

当你调用 chatModel.call("你好")时,模型只是根据你的输入生成了一个回复;当你再次调用 chatModel.call("我刚刚说了什么?")时,如果没有把之前的对话拼接到输入中,模型什么都不知道。

用公式表示:

ini 复制代码
output = LLM(input)

没有记忆、没有状态、没有会话概念。

1.2 Agent 为什么需要状态

Agent(智能体)的任务是完成复杂用户目标。它需要:

  • 记住用户已经提供的信息(如姓名、偏好、订单号);
  • 记住自己已经回答过的内容,避免重复;
  • 在多步推理中保持逻辑一致;
  • 在对话中提供连续、自然的体验。

因此,我们必须把记忆"外置"到应用层,由开发者在每次请求时,把历史消息和当前新消息一起提交给模型。这正是对话记忆(Chat Memory)要解决的问题。


二、Spring AI 中的 ChatMemory 抽象

Spring AI 提供了 org.springframework.ai.chat.memory.ChatMemory接口,用来统一管理以 conversationId区分的消息历史。

2.1 ChatMemory 接口核心方法

arduino 复制代码
public interface ChatMemory {

    /**
     * 向指定会话追加消息
     */
    void add(String conversationId, List<Message> messages);

    /**
     * 获取指定会话的所有历史消息
     */
    List<Message> get(String conversationId);

    /**
     * 清理指定会话
     */
    void clear(String conversationId);
}

接口极其简洁,本质上就是一个以 conversationId 为键的消息列表存取器。

2.2 内置实现

Spring AI 默认提供了几个实现:

实现类 特点
InMemoryChatMemory 基于 ConcurrentHashMap,无大小限制,适合简单场景
MessageWindowChatMemory 滑动窗口,只保留最近 N 条消息,自动丢弃最旧消息
ReadOnlyChatMemory 只读实现,多用于测试

下面是 InMemoryChatMemory的典型用法:

csharp 复制代码
ChatMemory chatMemory = InMemoryChatMemory.builder().build();

chatMemory.add("conversation-001", List.of(
    new UserMessage("你好"),
    new AssistantMessage("你好!有什么可以帮你?")
));

List<Message> history = chatMemory.get("conversation-001");
System.out.println(history.size()); // 2

2.3 ChatClient 如何绑定记忆

ChatClient 提供了 .memory()方法,将 ChatMemory 和 conversationId 绑定到当前请求。

java 复制代码
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.memory.InMemoryChatMemory;

@Configuration
public class ChatConfig {

    @Bean
    public ChatClient chatClient(ChatModel chatModel) {
        ChatMemory chatMemory = InMemoryChatMemory.builder().build();
        return ChatClient.builder(chatModel)
                .defaultMemory(chatMemory)
                .build();
    }
}

在 Controller 中调用时,只需传入会话 ID:

less 复制代码
@RestController
public class MemoryController {

    private final ChatClient chatClient;

    public MemoryController(ChatClient chatClient) {
        this.chatClient = chatClient;
    }

    @GetMapping("/memory/chat")
    public String chat(@RequestParam String conversationId, @RequestParam String message) {
        return chatClient.prompt()
                .user(message)
                .memory(conversationId)
                .call()
                .content();
    }
}

memory(conversationId)会自动完成下面几件事:

  1. 从默认 ChatMemory 中读取该会话历史;
  2. 将历史消息(System 除外)和当前用户消息拼接为完整的消息列表;
  3. 发送给模型得到回复;
  4. 将用户消息和助手回复写入 ChatMemory,更新历史。

整个过程对业务代码完全透明。

2.4 手动管理记忆

如果你希望更精细地控制,可以绕过 ChatClient 的自动拼接,自己从 ChatMemory 读取历史,再传给 Prompt:

arduino 复制代码
@Service
public class ManualMemoryService {

    private final ChatModel chatModel;
    private final ChatMemory chatMemory;

    public ManualMemoryService(ChatModel chatModel, ChatMemory chatMemory) {
        this.chatModel = chatModel;
        this.chatMemory = chatMemory;
    }

    public String chat(String conversationId, String userInput) {
        // 1. 获取历史
        List<Message> history = chatMemory.get(conversationId);

        // 2. 构建消息列表:系统消息 + 历史 + 当前用户消息
        List<Message> messages = new ArrayList<>();
        messages.add(new SystemMessage("你是智能助手"));
        messages.addAll(history);
        messages.add(new UserMessage(userInput));

        // 3. 调用模型
        ChatResponse response = chatModel.call(new Prompt(messages));

        // 4. 提取回复
        String assistantContent = response.getResult().getOutput().getText();

        // 5. 写回记忆
        chatMemory.add(conversationId, List.of(
            new UserMessage(userInput),
            new AssistantMessage(assistantContent)
        ));

        return assistantContent;
    }
}

这种方式让我们能够自定义历史长度、token 截断、异常回滚等策略,是学习 ChatMemory 底层机制的绝佳途径。


三、多轮对话上下文管理策略

默认的 InMemoryChatMemory会无限增长。如果用户聊了 100 轮,每轮平均 300 token,最终消息列表会非常庞大,导致 token 超限、费用膨胀、响应变慢。因此,生产环境必须采用适当的上下文管理策略。

3.1 策略一:固定窗口记忆(Message Window)

MessageWindowChatMemory是 Spring AI 提供的窗口式记忆实现。它只保留最近 N 条消息,丢弃更早的消息。

scss 复制代码
ChatMemory windowedMemory = MessageWindowChatMemory.builder()
        .maxMessages(20)        // 最多保留20条消息
        .build();

也可以按 token 数限制:

scss 复制代码
ChatMemory tokenWindow = MessageWindowChatMemory.builder()
        .maxTokens(2000)        // 最多保留2000个token
        .build();

优点:实现简单、内存占用稳定、行为可预测。

缺点:一旦超出窗口,早期关键信息会被永久丢弃;模型对超出窗口的部分"失忆"。

适用场景:客服机器人、闲聊对话。

3.2 策略二:摘要记忆(Summarization Memory)

当窗口无法容纳所有历史时,可以对旧消息生成摘要,将摘要作为一条"压缩记忆"保留。

Spring AI 没有直接提供内置的摘要组件(截至当前版本),但我们可以用 ChatModel 自己实现:

typescript 复制代码
@Service
public class SummarizingChatMemory implements ChatMemory {

    private final ChatModel chatModel;
    private final Map<String, List<Message>> storage = new ConcurrentHashMap<>();
    private static final int MAX_MESSAGES = 10;

    public SummarizingChatMemory(ChatModel chatModel) {
        this.chatModel = chatModel;
    }

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

    @Override
    public List<Message> get(String conversationId) {
        List<Message> all = storage.getOrDefault(conversationId, List.of());
        if (all.size() <= MAX_MESSAGES) {
            return all;
        }
        // 如果超出窗口,生成摘要并返回摘要 + 最近几条消息
        List<Message> oldMessages = all.subList(0, all.size() - MAX_MESSAGES / 2);
        String summary = summarize(oldMessages);
        List<Message> recent = new ArrayList<>();
        recent.add(new SystemMessage("以下是之前的对话摘要,请基于此继续聊天:\n" + summary));
        recent.addAll(all.subList(all.size() - MAX_MESSAGES / 2, all.size()));
        return recent;
    }

    private String summarize(List<Message> messages) {
        StringBuilder sb = new StringBuilder();
        for (Message m : messages) {
            sb.append(m.getMessageType()).append(": ").append(m.getText()).append("\n");
        }
        String prompt = "请将以下对话压缩为不超过200字的摘要,保留关键信息:\n" + sb;
        return chatModel.call(prompt);
    }

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

优点:上下文信息保留度高,token 成本低。

缺点:摘要本身可能丢失细节;每次生成摘要会额外消耗一次模型调用。

适用场景:长时间会话、角色扮演、深度咨询。

3.3 策略三:向量检索记忆(RAG Memory)

对于跨会话的长期记忆,可以配合向量数据库(如 PgVector、Milvus、Redis Vector),将历史消息向量化存储。每次请求时通过相似度搜索召回与当前问题最相关的历史片段。

这里给出一个简化版思路:

arduino 复制代码
@Service
public class VectorMemoryService {

    private final VectorStore vectorStore;
    private final ChatClient chatClient;

    public VectorMemoryService(VectorStore vectorStore, ChatModel chatModel) {
        this.vectorStore = vectorStore;
        this.chatClient = ChatClient.builder(chatModel).build();
    }

    public String chatWithLongMemory(String conversationId, String userMessage) {
        // 1. 从向量库搜索相关历史
        List<Document> relatedDocs = vectorStore.similaritySearch(
                SearchRequest.query(userMessage)
                        .withFilter("conversationId", conversationId) // 过滤同一会话
                        .withTopK(5)
        );

        // 2. 将历史拼入 system prompt
        String historyContext = relatedDocs.stream()
                .map(Document::getText)
                .reduce((a, b) -> a + "\n" + b)
                .orElse("无历史记录");

        // 3. 使用 ChatClient 传入上下文
        return chatClient.prompt()
                .system("以下是该用户的历史对话片段,供参考:\n" + historyContext)
                .user(userMessage)
                .call()
                .content();
    }
}

优点:支持跨会话长期记忆,检索精准,几乎不限制历史长度。

缺点:架构复杂,需要维护向量库、embedding 管道,检索质量受嵌入模型影响。

适用场景:个人助理、知识库问答、客服历史分析。


四、会话隔离(conversationId)

4.1 为什么需要会话隔离

在一个多用户系统中,所有用户共享同一个 ChatMemory。如果没有 conversationId 做隔离,用户 A 的对话历史可能会被用户 B 读取,导致严重的信息泄露和逻辑混乱。

ChatMemory 的设计天然支持隔离:它的所有方法都要求传入 conversationId。这个 ID 可以是:

  • 用户 ID;
  • 会话表主键;
  • UUID;
  • 前端 WebSocket 连接 ID。

4.2 全局会话 ID 的生成与传递

推荐在进入 Controller 前就为每个新会话生成唯一的 conversationId,通常使用 UUID:

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

    private final ChatClient chatClient;
    private final ChatMemory chatMemory;

    public ConversationController(ChatClient chatClient, ChatMemory chatMemory) {
        this.chatClient = chatClient;
        this.chatMemory = chatMemory;
    }

    // 创建新会话,返回 conversationId
    @PostMapping("/conversations")
    public Map<String, String> createConversation() {
        String conversationId = UUID.randomUUID().toString();
        return Map.of("conversationId", conversationId);
    }

    // 根据 conversationId 进行聊天
    @GetMapping("/conversations/{conversationId}/messages")
    public String chat(@PathVariable String conversationId,
                       @RequestParam String message) {
        return chatClient.prompt()
                .user(message)
                .memory(conversationId)
                .call()
                .content();
    }

    // 查看某会话的历史记录
    @GetMapping("/conversations/{conversationId}/history")
    public List<Message> getHistory(@PathVariable String conversationId) {
        return chatMemory.get(conversationId);
    }
}

4.3 请求头传递 conversationId

对于 RESTful 接口,更标准的方式是将会话 ID 放在请求头中:

less 复制代码
@GetMapping("/chat")
public String chat(@RequestHeader("X-Conversation-Id") String conversationId,
                   @RequestParam String message) {
    return chatClient.prompt()
            .user(message)
            .memory(conversationId)
            .call()
            .content();
}

前端可以通过 axios 拦截器统一注入:

csharp 复制代码
axios.get('/api/chat', {
  headers: { 'X-Conversation-Id': localStorage.getItem('conversationId') }
})

4.4 内存清理与过期策略

如果使用 InMemoryChatMemory,随着用户增长,内存会不断膨胀。必须设置过期清理策略。

最简单的方式是启动一个定时任务,定期清除 N 天前的会话:

typescript 复制代码
@Component
public class MemoryCleaner {

    private final ChatMemory chatMemory;

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

    @Scheduled(cron = "0 0 2 * * ?") // 每天凌晨2点
    public void cleanExpiredMemories() {
        // 假设我们有会话访问时间表
        // 也可以用 redis 或数据库记录
        // 这里演示:得到需要清理的会话ID列表
        List<String> expiredIds = queryExpiredConversations();
        for (String id : expiredIds) {
            chatMemory.clear(id);
        }
    }

    private List<String> queryExpiredConversations() {
        // 模拟:实际应该查询数据库/Redis
        return List.of("expired-1", "expired-2");
    }
}

生产环境中更推荐使用 Redis + TTL 来实现分布式记忆存储,天然支持过期时间:

typescript 复制代码
// 以 Spring Data Redis 为例,自己实现 ChatMemory
@Component
public class RedisChatMemory implements ChatMemory {

    private final StringRedisTemplate redisTemplate;
    private static final String KEY_PREFIX = "chat:memory:";

    public RedisChatMemory(StringRedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    @Override
    public void add(String conversationId, List<Message> messages) {
        String key = KEY_PREFIX + conversationId;
        messages.forEach(message ->
            redisTemplate.opsForList().rightPush(key, toJson(message))
        );
        redisTemplate.expire(key, Duration.ofHours(24)); // 24小时有效
    }

    @Override
    public List<Message> get(String conversationId) {
        List<String> jsonList = redisTemplate.opsForList()
                .range(KEY_PREFIX + conversationId, 0, -1);
        return jsonList.stream().map(this::fromJson).toList();
    }

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

    private String toJson(Message message) {
        return """
                {"role":"%s","content":"%s"}
                """.formatted(message.getMessageType(), message.getText().replace(""", "\""));
    }

    private Message fromJson(String json) {
        // 用 Jackson 解析,略
        return null;
    }
}

五、综合示例:隔离 + 记忆 + 流式响应的完整对话接口

下面整合前文成果,构建一个支持会话隔离、多轮记忆、流式响应的 REST 接口。

5.1 配置类

typescript 复制代码
@Configuration
public class AiConfig {

    @Bean
    public ChatMemory chatMemory() {
        // 使用窗口记忆,保留最近30条
        return MessageWindowChatMemory.builder()
                .maxMessages(30)
                .build();
    }

    @Bean
    public ChatClient chatClient(ChatModel chatModel, ChatMemory chatMemory) {
        return ChatClient.builder(chatModel)
                .defaultMemory(chatMemory)
                .defaultSystem("你是一位专业的AI助手,请保持友好、准确的回答。")
                .build();
    }
}

5.2 控制器

less 复制代码
@RestController
@RequestMapping("/api/v1/chat")
public class ChatController {

    private final ChatClient chatClient;
    private final ChatMemory chatMemory;

    public ChatController(ChatClient chatClient, ChatMemory chatMemory) {
        this.chatClient = chatClient;
        this.chatMemory = chatMemory;
    }

    // 用户身份 + 会话隔离
    @GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<String> streamChat(@RequestHeader("X-User-Id") String userId,
                                   @RequestParam String message) {
        String conversationId = userId;  // 以用户ID作为会话隔离标识

        return chatClient.prompt()
                .user(message)
                .memory(conversationId)
                .stream()
                .content()
                .doOnCancel(() -> System.out.println("用户 " + userId + " 取消连接"));
    }

    // 获取某用户的历史记录(仅限本人)
    @GetMapping("/history")
    public List<Message> getHistory(@RequestHeader("X-User-Id") String userId) {
        return chatMemory.get(userId);
    }

    // 清空记忆
    @DeleteMapping("/history")
    public ResponseEntity<Void> clearHistory(@RequestHeader("X-User-Id") String userId) {
        chatMemory.clear(userId);
        return ResponseEntity.noContent().build();
    }
}

调用示例:

arduino 复制代码
curl -N -H "X-User-Id: alice" \
  "http://localhost:8080/api/v1/chat/stream?message=我叫小明,记住我的名字"

curl -N -H "X-User-Id: alice" \
  "http://localhost:8080/api/v1/chat/stream?message=我叫什么名字?"

第二次请求时,模型会正确回答"你叫小明",因为 Alice 的历史记录已被自动注入。


六、最佳实践与注意事项

6.1 始终以 conversationId 为边界

  • 不要使用 static变量存储记忆;
  • 同一用户不同设备应使用不同 conversationId,除非你打算跨设备同步;
  • 权限校验时,确保 conversationId 属于当前用户。

6.2 控制消息数量与 Token 预算

估算 token 公式:每 1 个汉字约 1.5~2 token。假设模型上下文上限 8K token,System + 历史 + 当前输入 + 输出需要总和在 8K 内。建议:

  • 设置历史窗口 maxMessages为 10~20 条;
  • 单条消息截断至 500 token 以内;
  • 长文档使用摘要或检索,而非直接拼接。

6.3 正确处理多轮中的 System 消息

ChatMemory 会自动剥离历史中的 System 消息(因为它只应出现在消息序列的开头)。手动构造时注意:

csharp 复制代码
List<Message> allMessages = new ArrayList<>();
allMessages.add(systemMessage); // 只放一次
allMessages.addAll(chatMemory.get(conversationId)); // 只包含 User/Assistant
allMessages.add(new UserMessage(userInput));

6.4 流式调用时的记忆写入时机

使用 ChatClient.stream()时,Spring AI 会等到整个流结束再一次性写入 ChatMemory。如果客户端中途断开,可能不会写入。若需要更精细的控制,可以手动监听流完成事件:

scss 复制代码
chatClient.prompt()
        .user(message)
        .memory(conversationId)
        .stream()
        .content()
        .doOnComplete(() -> {
            // 可以在此处手动触发一些后处理
        })
        .subscribe();

6.5 并发场景下的线程安全

InMemoryChatMemory基于 ConcurrentHashMapadd操作是线程安全的。但读-改-写序列仍可能产生竞争。例如两个请求同时读取同一会话历史,可能导致消息乱序。建议:

  • 在一个会话内做并发限制,例如使用 @Lock或队列;
  • 或使用数据库事务保证顺序;
  • 或让 ChatClient 执行串行化。

6.6 持久化方案选择

场景 推荐方案
单机测试 / Demo InMemoryChatMemoryMessageWindowChatMemory
多实例部署 Redis(RedisChatMemory自定义)
企业级高可靠 MySQL / PostgreSQL 消息表,配合 JPA 实现 ChatMemory
长期记忆 向量库 + ChatMemory 双层架构

七、总结

回到最初的问题:Agent 是无状态的,但应用可以是有状态的。

Spring AI 用一套极其简洁的 ChatMemory接口解决了这个矛盾:

  • add()写入历史;
  • get()读取历史;
  • clear()清空历史;
  • ChatClient.memory(conversationId)自动完成一切。

而 conversationId 则像一把钥匙,将记忆安全地分配给不同用户、不同会话。

在实际项目中,请记住:

  1. 理解无状态是大模型应用设计的前提;
  2. 选择合适记忆策略:窗口、摘要、向量检索,按需取舍;
  3. 始终做会话隔离,并设计过期清理机制;
  4. 对流式、并发、Token 预算做精心处理;
  5. 将记忆视为一等公民,像处理数据库事务一样处理它。
相关推荐
ShuiShenHuoLe1 小时前
golang-jwt v5 入门
开发语言·后端·golang
码事漫谈1 小时前
DeepSeek V4.1 Flash:一次把自家旗舰送走的发布
后端
LXMXHJ1 小时前
springboot中的线程操作
java·spring boot·后端·线程
Sinclair2 小时前
MCP 功能详解:内置 108 个工具,让 AI 直接帮你管理网站
后端·mcp
用户233376852182 小时前
接口卡死排查实录-缺失return的UB死循环
前端·后端
用户489148799762 小时前
Go 系统服务开发实战:systemd+sdnotify + 看门狗-agent与系统服务
后端
拾光师2 小时前
Python 模式匹配:从 in 到正则再到 match-case,一招对一招
后端
只爱喝胡辣汤2 小时前
JUC 并发工具与线程安全源码深度解析
后端
只爱喝胡辣汤2 小时前
03-KafkaProducer 源码分析
后端