Spring AI 源码解析:MessageChatMemoryAdvisor 是如何让大模型"记住你"的

Spring AI 源码解析:MessageChatMemoryAdvisor 是如何让大模型"记住你"的

本文基于 Spring AI 1.0.0 源码,深入分析 MessageChatMemoryAdvisor 的设计思路与实现细节。


一、背景:大模型天生"健忘"

LLM 本身是无状态的。每次调用都是一次全新的推理,不携带任何上下文。这意味着如果你问完"我叫张三",下一句问"我叫什么",模型一无所知。

要实现真正意义上的"多轮对话",开发者必须在每次请求时手动把历史消息带上。这个过程繁琐、易出错,而且和业务代码高度耦合。

Spring AI 通过 Advisor 机制 将这个问题优雅地封装起来,MessageChatMemoryAdvisor 就是这套机制下的核心实现之一。


二、Advisor 机制:Spring AI 的 AOP

在理解 MessageChatMemoryAdvisor 之前,必须先理解 Spring AI 的 Advisor 设计。

Advisor 是 Spring AI 对 LLM 调用链的横切关注点抽象,与 Spring AOP 的思想一脉相承:

css 复制代码
用户请求
    ↓
[Advisor1.before] → [Advisor2.before] → ... → LLM 调用
                                                    ↓
[Advisor1.after]  ← [Advisor2.after]  ← ... ← LLM 响应

核心接口定义在 BaseAdvisor

java 复制代码
// BaseAdvisor.java
public interface BaseAdvisor extends CallAdvisor, StreamAdvisor {

    // 请求前拦截
    ChatClientRequest before(ChatClientRequest request, AdvisorChain chain);

    // 响应后拦截
    ChatClientResponse after(ChatClientResponse response, AdvisorChain chain);

    // 默认使用 boundedElastic 调度器(流式场景)
    Scheduler DEFAULT_SCHEDULER = Schedulers.boundedElastic();
}

adviseCall 的默认实现骨架就是 before → 下一个 → after 的责任链:

java 复制代码
default ChatClientResponse adviseCall(ChatClientRequest request, CallAdvisorChain chain) {
    ChatClientRequest processed = before(request, chain);
    ChatClientResponse response  = chain.nextCall(processed);
    return after(response, chain);
}

三、整体架构

MessageChatMemoryAdvisor 的完整继承链如下:

scss 复制代码
Ordered (Spring)
  └── Advisor
        └── CallAdvisor / StreamAdvisor
              └── BaseAdvisor          ← 定义 before/after 骨架
                    └── BaseChatMemoryAdvisor   ← 提供 getConversationId()
                          └── MessageChatMemoryAdvisor  ← 本文主角

涉及的存储层类图:

scss 复制代码
ChatMemory (interface)
  └── MessageWindowChatMemory   ← 滑动窗口实现,默认最多 20 条消息
        └── uses ──→ ChatMemoryRepository (interface)
                          └── InMemoryChatMemoryRepository  ← ConcurrentHashMap 存储
                          └── (Redis / JDBC 等自定义实现)

这个分层设计非常清晰:ChatMemory 负责业务语义(窗口管理、消息淘汰),ChatMemoryRepository 负责物理存储,两者通过接口解耦,存储层随时可替换。


四、源码深度解析

4.1 before() --- 请求注入记忆

java 复制代码
@Override
public ChatClientRequest before(ChatClientRequest chatClientRequest, AdvisorChain advisorChain) {
    String conversationId = getConversationId(chatClientRequest.context());

    // Step 1: 从记忆存储中取出当前会话的历史消息
    List<Message> memoryMessages = this.chatMemory.get(conversationId);

    // Step 2: 历史消息 + 当前请求消息,合并成新的消息列表
    List<Message> processedMessages = new ArrayList<>(memoryMessages);
    processedMessages.addAll(chatClientRequest.prompt().getInstructions());

    // Step 3: 确保 SystemMessage 始终排在第一位
    for (int i = 0; i < processedMessages.size(); i++) {
        if (processedMessages.get(i) instanceof SystemMessage) {
            Message systemMessage = processedMessages.remove(i);
            processedMessages.add(0, systemMessage);
            break;
        }
    }

    // Step 4: 构造新的 ChatClientRequest(不可变对象,mutate 模式)
    ChatClientRequest processedRequest = chatClientRequest.mutate()
        .prompt(chatClientRequest.prompt().mutate().messages(processedMessages).build())
        .build();

    // Step 5: 将当前用户消息(或 ToolResponse)存入记忆
    Message userMessage = processedRequest.prompt().getLastUserOrToolResponseMessage();
    this.chatMemory.add(conversationId, userMessage);

    return processedRequest;
}

几个值得关注的设计细节:

① 历史在前,新消息在后

memoryMessages 先加,currentMessages 后加。这符合大多数模型对消息顺序的要求:先看历史上下文,最后处理当前输入。

② SystemMessage 置顶保证

合并后的消息列表中,SystemMessage(系统提示词)可能因为历史消息插入而"跑偏"。这里有一次 O(n) 扫描,找到后移到 index 0,确保模型角色设定始终生效。

③ 存入的是用户消息,不是所有消息

注意 Step 5,调用 getLastUserOrToolResponseMessage() 只存入最后一条用户消息或工具响应,而不是整个 processedMessages。这是因为历史消息已经在 ChatMemory 里,不需要重复存储。

④ 不可变对象 + mutate 模式

ChatClientRequest 是不可变的,通过 .mutate() 创建新实例,这是函数式/响应式编程中经典的不可变数据流模式,避免了并发状态共享问题。


4.2 after() --- 响应保存记忆

java 复制代码
@Override
public ChatClientResponse after(ChatClientResponse chatClientResponse, AdvisorChain advisorChain) {
    List<Message> assistantMessages = new ArrayList<>();
    if (chatClientResponse.chatResponse() != null) {
        assistantMessages = chatClientResponse.chatResponse()
            .getResults()
            .stream()
            .map(g -> (Message) g.getOutput())  // Generation → AssistantMessage
            .toList();
    }
    this.chatMemory.add(this.getConversationId(chatClientResponse.context()), assistantMessages);
    return chatClientResponse;
}

逻辑极简:从响应里提取 AssistantMessage 列表,存入 ChatMemory。下次 before() 调用时,这些消息就会被取出注入到新请求中。


4.3 adviseStream() --- 流式场景的特殊处理

流式场景下(SSE/Streaming),模型的回答是分 token 逐步推送的,不能在每个分片到来时就调用 after(),否则每个 token 都会存一次记忆,造成数据混乱。

MessageChatMemoryAdvisor 重写了 adviseStream()

java 复制代码
@Override
public Flux<ChatClientResponse> adviseStream(ChatClientRequest chatClientRequest,
        StreamAdvisorChain streamAdvisorChain) {
    return Mono.just(chatClientRequest)
        .publishOn(scheduler)                              // 切换到 boundedElastic 线程
        .map(request -> this.before(request, streamAdvisorChain))  // 注入记忆
        .flatMapMany(streamAdvisorChain::nextStream)       // 下发到模型,得到 Flux<chunk>
        .transform(flux -> new ChatClientMessageAggregator()
            .aggregateChatClientResponse(flux,
                response -> this.after(response, streamAdvisorChain)));  // 聚合完再存
}

关键在 ChatClientMessageAggregator:它会收集所有 chunk,等 Flux 完成后聚合成完整响应,然后才触发 after()。这保证了存入记忆的永远是完整的 AI 回复。

对比 BaseAdvisor 的默认流式实现:

BaseAdvisor 的默认实现是在 onFinishReason 触发时调 after()(即收到 [DONE] 信号时)。而 MessageChatMemoryAdvisor 选择通过 Aggregator 聚合后统一处理,语义更清晰,也便于处理多个 Generation 结果的场景。


4.4 ChatMemory 存储层:MessageWindowChatMemory

MessageWindowChatMemory 是 Spring AI 内置的滑动窗口实现,有几个值得关注的行为:

java 复制代码
private static final int DEFAULT_MAX_MESSAGES = 20;  // 默认最多保留 20 条

private List<Message> process(List<Message> memoryMessages, List<Message> newMessages) {
    // 如果新消息包含 SystemMessage,清除旧的所有 SystemMessage(角色切换场景)
    boolean hasNewSystemMessage = newMessages.stream()
        .filter(SystemMessage.class::isInstance)
        .anyMatch(message -> !memoryMessagesSet.contains(message));

    // 合并消息
    ...

    // 超出上限时,优先淘汰旧的非 SystemMessage 消息
    // SystemMessage 永远不会被淘汰(保护系统提示词)
    for (Message message : processedMessages) {
        if (message instanceof SystemMessage || removed >= messagesToRemove) {
            trimmedMessages.add(message);
        } else {
            removed++;
        }
    }
}

两个重要语义:

  1. SystemMessage 永远不会被窗口淘汰,确保模型角色设定始终有效
  2. 新加入的 SystemMessage 会覆盖旧的,支持动态切换角色

五、完整使用示例

5.1 基础用法

java 复制代码
@Configuration
public class ChatConfig {

    @Bean
    public ChatMemory chatMemory() {
        return MessageWindowChatMemory.builder()
            .chatMemoryRepository(new InMemoryChatMemoryRepository())
            .maxMessages(20)  // 保留最近 20 条
            .build();
    }

    @Bean
    public ChatClient chatClient(ChatModel chatModel, ChatMemory chatMemory) {
        return ChatClient.builder(chatModel)
            .defaultAdvisors(
                MessageChatMemoryAdvisor.builder(chatMemory).build()
            )
            .build();
    }
}
java 复制代码
@RestController
@RequestMapping("/chat")
public class ChatController {

    private final ChatClient chatClient;

    @PostMapping
    public String chat(@RequestParam String sessionId,
                       @RequestParam String message) {
        return chatClient.prompt()
            .user(message)
            .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, sessionId))
            .call()
            .content();
    }
}

5.2 流式对话

java 复制代码
@GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> streamChat(@RequestParam String sessionId,
                                @RequestParam String message) {
    return chatClient.prompt()
        .user(message)
        .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, sessionId))
        .stream()
        .content();
}

5.3 自定义 Redis 持久化存储

java 复制代码
@Component
public class RedisChatMemoryRepository implements ChatMemoryRepository {

    private final RedisTemplate<String, List<Message>> redisTemplate;
    private static final Duration TTL = Duration.ofHours(24);

    @Override
    public List<Message> findByConversationId(String conversationId) {
        List<Message> messages = redisTemplate.opsForValue().get(conversationId);
        return messages != null ? messages : List.of();
    }

    @Override
    public void saveAll(String conversationId, List<Message> messages) {
        redisTemplate.opsForValue().set(conversationId, messages, TTL);
    }

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

    @Override
    public List<String> findConversationIds() {
        return List.of();
    }
}

// 注入自定义 Repository
@Bean
public ChatMemory chatMemory(RedisChatMemoryRepository redisRepo) {
    return MessageWindowChatMemory.builder()
        .chatMemoryRepository(redisRepo)
        .maxMessages(30)
        .build();
}

5.4 自定义 Advisor 执行顺序

java 复制代码
// Advisor 的 order 越小,越先执行(HIGHEST_PRECEDENCE = Integer.MIN_VALUE)
// DEFAULT_CHAT_MEMORY_PRECEDENCE_ORDER = Integer.MIN_VALUE + 1000
// 通常不需要修改,但多个 Advisor 配合时可能需要调整

MessageChatMemoryAdvisor.builder(chatMemory)
    .order(Advisor.DEFAULT_CHAT_MEMORY_PRECEDENCE_ORDER)  // 默认值
    .build()

六、与 PromptChatMemoryAdvisor 的对比

Spring AI 提供了两种记忆注入策略,适用不同场景:

维度 MessageChatMemoryAdvisor PromptChatMemoryAdvisor
注入方式 历史消息以独立 Message 对象加入消息列表 历史拼接成文本,注入到 SystemMessage 中
原生多轮支持 是,充分利用模型原生 role: user/assistant 格式 否,历史是文本块
Token 效率 较高(模型原生理解) 较低(文本拼接有冗余)
适用模型 支持 Chat Completions 格式的模型 任意文本模型
可读性调试 消息结构清晰 历史混入 system prompt,调试稍复杂

结论: 现代 LLM(GPT-4、Claude、Qwen 等)均原生支持多轮消息格式,优先选择 MessageChatMemoryAdvisor


七、生产环境注意事项

7.1 conversationId 的生命周期管理

conversationId 是记忆隔离的唯一依据,通常与用户的 Session 或对话 ID 绑定:

java 复制代码
// 不要用固定字符串,每次对话都要用唯一 ID
advisors(a -> a.param(ChatMemory.CONVERSATION_ID, UUID.randomUUID().toString()))  // 错误:每次调用都是新会话

// 正确:与用户 session 关联
advisors(a -> a.param(ChatMemory.CONVERSATION_ID, userSessionId))

7.2 内存泄漏风险

使用 InMemoryChatMemoryRepository 时,历史会话数据常驻内存,不会自动清理。建议:

  • 使用 Redis 等带 TTL 的存储
  • 对话结束时主动调用 chatMemory.clear(conversationId)
java 复制代码
// 对话结束时清理
@DeleteMapping("/session/{sessionId}")
public void endSession(@PathVariable String sessionId) {
    chatMemory.clear(sessionId);
}

7.3 maxMessages 与 Token 限制的关系

maxMessages 控制保留的消息条数,但每条消息的 token 数不可控。建议:

  • 为业务场景估算平均消息长度
  • 选择合适的 maxMessages,留出足够 token 空间给模型输出
  • 或者实现基于 Token 计数的自定义 ChatMemory

7.4 线程安全

MessageWindowChatMemory 底层的 InMemoryChatMemoryRepository 使用 ConcurrentHashMap 保证线程安全,但 saveAll 是整体替换操作,高并发下同一 conversationId 的并发写入可能存在覆盖风险。生产环境建议使用数据库/Redis 并配合乐观锁或单会话串行处理。


八、扩展:自定义 ChatMemory 实现

如果需要对记忆进行更精细的控制(如按 Token 数裁剪、摘要压缩),可以直接实现 ChatMemory 接口:

java 复制代码
public class SummarizingChatMemory implements ChatMemory {

    private final ChatMemoryRepository repository;
    private final ChatClient summarizer;  // 用另一个 LLM 做摘要
    private final int maxMessages;

    @Override
    public void add(String conversationId, List<Message> messages) {
        List<Message> existing = repository.findByConversationId(conversationId);
        List<Message> merged = new ArrayList<>(existing);
        merged.addAll(messages);

        if (merged.size() > maxMessages) {
            // 对旧消息做摘要压缩
            String summary = summarizer.prompt()
                .user("请将以下对话总结为简短摘要:\n" + formatMessages(merged.subList(0, merged.size() - 5)))
                .call().content();

            List<Message> compressed = new ArrayList<>();
            compressed.add(new SystemMessage("历史对话摘要:" + summary));
            compressed.addAll(merged.subList(merged.size() - 5, merged.size()));
            merged = compressed;
        }
        repository.saveAll(conversationId, merged);
    }

    // ... get / clear 实现
}

九、总结

MessageChatMemoryAdvisor 的设计体现了几个值得借鉴的工程思想:

  1. 关注点分离:Advisor 链将记忆管理与业务代码完全解耦,业务层无感知
  2. 存储与策略分离ChatMemory(窗口策略)与 ChatMemoryRepository(物理存储)两层抽象,存储可插拔
  3. 不可变数据流ChatClientRequest 通过 mutate 模式传递,天然线程安全,与响应式编程契合
  4. 流式与同步统一 :通过 Aggregator 将流式回调统一收拢,对上层屏蔽异步细节
  5. 防御性排序 :合并消息后主动修正 SystemMessage 位置,消除因消息顺序导致的模型行为不一致

对于想要在 Spring Boot 应用中快速落地多轮对话能力的开发者,MessageChatMemoryAdvisor + MessageWindowChatMemory + 自定义持久化存储是目前最工程化的组合方案。


参考代码路径(基于 spring-ai 主干):

  • spring-ai-client-chat/.../advisor/MessageChatMemoryAdvisor.java
  • spring-ai-client-chat/.../advisor/api/BaseAdvisor.java
  • spring-ai-model/.../chat/memory/MessageWindowChatMemory.java
  • spring-ai-model/.../chat/memory/InMemoryChatMemoryRepository.java
相关推荐
止语Lab1 小时前
好的 DX 不等于少写代码——三种语言的摩擦力设计课
后端
吃饱了得干活1 小时前
别再手动解析 LLM 输出了!LangChain 四种结构化输出方案对比
后端·python·langchain
程序员天天困1 小时前
Arthas trace 命令怎么用?一行定位最慢那行代码
jvm·后端
Huiturn1 小时前
GPT 5.6 连续编码 10 小时,纯 Python 啃下 Word 二进制格式——doc2docx 实现拆解
后端
用户298698530141 小时前
Python 实现 Excel 与 Markdown 互转的实用指南
后端·python·excel
用户77283104908401 小时前
krono-job:零侵入、单二进制交付的分布式任务调度平台(开源)
后端
Conan在掘金1 小时前
鸿蒙报错速查:Function lacks ending return statement,返回路径漏 return 就炸,根因 + 真解法
后端
人间凡尔赛2 小时前
AI-Native 云原生架构:2026 年从容器编排到智能体编排的范式革命
后端·云原生·架构
IT_陈寒2 小时前
Vue的响应式让我加班到凌晨3点,原来问题出在这
前端·人工智能·后端
卷无止境2 小时前
提升 Python 代码健壮性的方法大盘点
后端·python