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
相关推荐
传说之后2 小时前
分布式事务指南:从二阶段锁到两阶段提交,了解核心设计
后端
代码丰2 小时前
Spring Boot 做 RAG 文档上传:1GB 文件会不会打爆内存?
后端
蝎子莱莱爱打怪2 小时前
我花两年业余时间做了个IM系统,然后呢😂??
后端·flutter·面试
叫我少年2 小时前
.NET 11 来了:Kestrel 提速 40%,还有这些你可能不知道的变化
后端
用户2279584482872 小时前
医生问“现在还在吃吗”:EHR 用药 RAG 先看 effectivePeriod,别先信 note
后端
geovindu3 小时前
go: Read-Write Lock Pattern
开发语言·后端·设计模式·golang·读写锁模式
百珏3 小时前
AI 应用技术演进串讲大纲
人工智能·后端·架构
Bacon3 小时前
装上就回不去了:CodeGraph 让 AI 编程效率飙升 92%,它到底做了什么?
前端·人工智能·后端
Xiacqi13 小时前
Spring全局异常处理
java·后端