Skip to content

L02:Advisor 与 Memory 的真实执行时机

学习时长:约 45 分钟
本节唯一核心:Advisor 是 Around Chain,Memory 只是其中一个会在请求前后修改数据的 Advisor。

1. 本节解决什么问题

很多人对 Advisor 的理解停留在:

text
类似拦截器

这句话没错,但太粗。

真正排错时必须回答:

  • Advisor 按什么顺序进入?
  • order 越小越先执行还是越后执行?
  • before 修改后的 Prompt 会不会传给下一个 Advisor?
  • after 的执行顺序为什么反过来?
  • Memory 在模型调用前做什么?
  • 用户消息何时保存?
  • Assistant 消息何时保存?
  • 流式返回为什么要先聚合后才能保存完整历史?

2. 先预测 Advisor 顺序

假设有两个 Advisor:

text
LoggingAdvisor order = 100
MemoryAdvisor order = 200

执行模型前,顺序是什么?

text
A. Logging before → Memory before → Model
B. Memory before → Logging before → Model

模型返回后,顺序是什么?

text
A. Logging after → Memory after
B. Memory after → Logging after

正确答案:

text
Logging before
→ Memory before
→ Model
→ Memory after
→ Logging after

它是一层包一层的 Around 结构。

3. Advisor 链在哪里创建

上一节看到:

java
public CallResponseSpec call() {
    BaseAdvisorChain advisorChain = buildAdvisorChain();
    ...
}

buildAdvisorChain() 会在链底加入两个特殊 Advisor:

java
this.advisors.add(
    ChatModelCallAdvisor.builder()
        .chatModel(this.chatModel)
        .build()
);

this.advisors.add(
    ChatModelStreamAdvisor.builder()
        .chatModel(this.chatModel)
        .build()
);

这说明:

text
ChatModel 不是 Advisor 链外面的独立步骤

而是:

text
模型调用被包装成链底的终点 Advisor

同步链最后是 ChatModelCallAdvisor,流式链最后是 ChatModelStreamAdvisor

4. Advisor 怎样排序

DefaultAroundAdvisorChain.Builder 会:

text
收集 Advisor
→ 分离 CallAdvisor 与 StreamAdvisor
→ 使用 OrderComparator 排序
→ 按顺序放入 Deque

执行时:

java
var advisor = this.callAdvisors.pop();
return advisor.adviseCall(request, this);

因此:

text
order 数值越小
→ 越早进入 before
→ 越晚执行 after

常见心智图:

text
order 100  Advisor A before
    order 200  Advisor B before
        LOWEST_PRECEDENCE  ChatModel
    order 200  Advisor B after
order 100  Advisor A after

5. 一个最小 Around Advisor

java
package com.example.springai;

import org.springframework.ai.chat.client.ChatClientRequest;
import org.springframework.ai.chat.client.ChatClientResponse;
import org.springframework.ai.chat.client.advisor.api.CallAdvisor;
import org.springframework.ai.chat.client.advisor.api.CallAdvisorChain;

public final class TraceCallAdvisor implements CallAdvisor {

    private final String name;
    private final int order;

    public TraceCallAdvisor(String name, int order) {
        this.name = name;
        this.order = order;
    }

    @Override
    public ChatClientResponse adviseCall(
        ChatClientRequest request,
        CallAdvisorChain chain
    ) {
        System.out.println(name + " before");

        ChatClientResponse response = chain.nextCall(request);

        System.out.println(name + " after");
        return response;
    }

    @Override
    public String getName() {
        return name;
    }

    @Override
    public int getOrder() {
        return order;
    }
}

使用:

java
chatClient.prompt()
    .user("你好")
    .advisors(
        new TraceCallAdvisor("A", 100),
        new TraceCallAdvisor("B", 200)
    )
    .call()
    .content();

预期日志:

text
A before
B before
B after
A after

模型调用发生在 B beforeB after 之间。

6. Advisor 怎样修改请求

Advisor 不应直接随意修改原对象,而是通常创建一个新请求:

java
ChatClientRequest changedRequest = request.mutate()
    .prompt(
        request.prompt().mutate()
            .messages(newMessages)
            .build()
    )
    .build();

return chain.nextCall(changedRequest);

核心是:

text
当前 Advisor 收到 request
→ 生成 changedRequest
→ 把 changedRequest 交给下一个 Advisor

如果你修改了请求,却仍然调用:

java
chain.nextCall(request)

那么修改不会进入后续链路。

7. ChatClientRequest.context() 是什么

ChatClientRequest 中有两类关键数据:

text
prompt
context

prompt

真正发给模型的内容:

  • Messages;
  • ChatOptions;
  • 工具相关参数最终也可能进入 options。

context

Advisor 链中的共享上下文:

  • conversationId;
  • 结构化输出格式;
  • 自定义租户信息;
  • trace 辅助信息;
  • Advisor 之间需要传递的数据。

不要把所有业务参数都塞进 Prompt。

例如 conversationId 的主要作用是找到历史记录,它通常不应该作为自然语言直接发给模型。

8. Memory 不是模型能力

大模型 API 本身是无状态的。

第二次请求:

text
我叫什么?

模型之所以能知道第一次说过:

text
我叫老板

不是因为 DeepSeek 在服务器上替你记住了,而是应用在第二次请求时重新发送了历史消息。

Spring AI 的 Memory 主链是:

text
ChatMemoryRepository
→ 保存原始消息

MessageWindowChatMemory
→ 控制窗口、淘汰策略

MessageChatMemoryAdvisor
→ 在 ChatClient 调用前后读写 Memory

三个角色不要混为一谈。

9. MessageChatMemoryAdvisor.before() 精确做了什么

Spring AI 1.1.3 的核心步骤是:

text
1. 获取 conversationId
2. chatMemory.get(conversationId)
3. 历史消息放在当前 Prompt 消息前面
4. 确保 SystemMessage 位于第一位
5. 创建新的 ChatClientRequest
6. 把本次最新 UserMessage 或 ToolResponseMessage 写入 Memory
7. 把新请求交给后续 Advisor

可以简化为:

java
List<Message> memoryMessages = chatMemory.get(conversationId);

List<Message> processedMessages = new ArrayList<>(memoryMessages);
processedMessages.addAll(request.prompt().getInstructions());

ChatClientRequest changedRequest = request.mutate()
    .prompt(
        request.prompt().mutate()
            .messages(processedMessages)
            .build()
    )
    .build();

Message userMessage = changedRequest.prompt()
    .getLastUserOrToolResponseMessage();

chatMemory.add(conversationId, userMessage);

return changedRequest;

10. 用户消息为什么在模型调用前保存

准确时间线:

text
Memory before
→ 读取旧历史
→ 拼接本次用户消息
→ 保存本次用户消息
→ 调用模型

这样即使模型调用失败,也可能已经保存了用户消息。

这是一个重要工程边界:

text
Memory 中可能出现只有 UserMessage,没有对应 AssistantMessage 的半轮对话。

生产系统需要明确:

  • 模型失败后是否保留用户问题;
  • 重试时是否会重复保存;
  • 是否需要给消息增加 requestId;
  • 是否需要区分成功、失败、中断状态;
  • 是否把 ChatMemory 当审计记录使用。

Spring AI 的 ChatMemory 目标是上下文记忆,不是完整业务审计系统。

11. MessageChatMemoryAdvisor.after() 精确做了什么

模型返回后:

text
ChatClientResponse
→ ChatResponse
→ List<Generation>
→ 每个 Generation.getOutput()
→ AssistantMessage
→ chatMemory.add(conversationId, assistantMessages)

核心逻辑:

java
List<Message> assistantMessages = response.chatResponse()
    .getResults()
    .stream()
    .map(generation -> (Message) generation.getOutput())
    .toList();

chatMemory.add(conversationId, assistantMessages);

所以历史保存的不是简单字符串,而是 Message 对象。

对 DeepSeek 来说,最终可能保存:

text
DeepSeekAssistantMessage

它除了普通文本,还可能包含:

  • reasoningContent;
  • toolCalls;
  • metadata。

但具体 Repository 是否完整序列化这些供应商扩展字段,需要结合实现验证,不能想当然。

12. conversationId 的 1.1.3 特殊行为

在 Spring AI 1.1.3 中,MessageChatMemoryAdvisor.Builder 默认使用:

java
ChatMemory.DEFAULT_CONVERSATION_ID

并且 before() 会从:

text
request.context 中的 conversationId

Advisor 的默认 conversationId

选择一个。

这意味着代码即使不显式传 conversationId,也可能运行。

但生产环境绝对不应依赖默认值:

text
用户 A → 默认会话
用户 B → 默认会话
用户 C → 默认会话

结果就是串历史。

推荐每次调用显式传递:

java
chatClient.prompt()
    .user(question)
    .advisors(advisor -> advisor.param(
        ChatMemory.CONVERSATION_ID,
        conversationId
    ))
    .call()
    .content();

conversationId 应来自可信业务边界,例如:

text
tenantId + userId + sessionId

不要直接使用前端随意传入且未经校验的值。

后续版本已经进一步收紧 conversationId 行为。当前课程以 1.1.3 标签源码为准,但工程设计仍建议始终显式传递。

13. Memory Advisor 加入历史后的消息顺序

假设旧历史:

text
User:我叫老板
Assistant:好的

当前初始 Prompt:

text
System:你是助手
User:我叫什么?

Memory Advisor 拼接后会先得到:

text
旧历史
+
当前 Prompt

然后把第一个 SystemMessage 移到首位:

text
System:你是助手
User:我叫老板
Assistant:好的
User:我叫什么?

为什么要确保 system 在第一位?

因为多数模型协议要求或推荐系统指令位于消息序列前部。

14. 流式 Memory 为什么更复杂

非流式模型一次返回完整 ChatResponse

text
模型完成
→ after(response)
→ 保存 AssistantMessage

流式返回的是:

text
chunk 1
chunk 2
chunk 3
...

如果每个 chunk 都直接保存,会得到大量碎片消息:

text
"你"
"好"
","
"老板"

所以 MessageChatMemoryAdvisor.adviseStream() 会:

text
before(request)
→ nextStream(request)
→ ChatClientMessageAggregator 聚合完整响应
→ 聚合完成后执行 after(aggregatedResponse)

源码结构:

java
return Mono.just(request)
    .publishOn(scheduler)
    .map(value -> before(value, streamAdvisorChain))
    .flatMapMany(streamAdvisorChain::nextStream)
    .transform(flux ->
        new ChatClientMessageAggregator()
            .aggregateChatClientResponse(
                flux,
                response -> after(response, streamAdvisorChain)
            )
    );

重点:

text
下游仍然可以持续收到流式 chunk
同时框架在旁路聚合出完整 AssistantMessage
聚合完成后再写入 Memory

15. 流式中断时会发生什么

如果用户在中途关闭连接:

text
浏览器断开
→ 下游取消订阅
→ 流可能没有正常完整结束

你必须验证:

  • 聚合 handler 是否执行;
  • Memory 是否保存部分消息;
  • DeepSeek 远端请求是否真正被取消;
  • 是否留下只有 UserMessage 的半轮对话;
  • 恢复时应该重新生成还是继续使用部分内容。

这不是 Memory Advisor 自动替你解决的问题。

16. Advisor 顺序对 Memory 的影响

假设有:

text
脱敏 Advisor
Memory Advisor
RAG Advisor
日志 Advisor

不同顺序会产生不同结果。

方案 A:脱敏在 Memory 前

text
原始用户消息
→ 脱敏
→ 保存脱敏后的消息
→ 发给模型

Memory 中不保留敏感原文。

方案 B:Memory 在脱敏前

text
原始用户消息
→ 保存原文
→ 后续才脱敏
→ 发给模型

模型没看到原文,但数据库已经保存了原文。

RAG 与 Memory 顺序

text
Memory 先
→ RAG 可以基于带历史的请求检索

RAG 先
→ 检索可能只基于当前问题

所以 Advisor order 不是代码美观问题,而是业务语义。

17. 一个观察 Memory 前后的 Advisor

java
public final class MemoryProbeAdvisor implements CallAdvisor {

    @Override
    public ChatClientResponse adviseCall(
        ChatClientRequest request,
        CallAdvisorChain chain
    ) {
        System.out.println("进入时消息:");
        printMessages(request);

        ChatClientResponse response = chain.nextCall(request);

        System.out.println("返回结果:");
        if (response.chatResponse() != null) {
            response.chatResponse().getResults().forEach(generation ->
                System.out.println(generation.getOutput().getText())
            );
        }

        return response;
    }

    private void printMessages(ChatClientRequest request) {
        request.prompt().getInstructions().forEach(message ->
            System.out.printf(
                "%s -> %s%n",
                message.getMessageType(),
                message.getText()
            )
        );
    }

    @Override
    public String getName() {
        return "memory-probe";
    }

    @Override
    public int getOrder() {
        return 300;
    }
}

要观察 Memory 之前还是之后,调整它与 Memory Advisor 的 order。

不要只看注册顺序:

java
.advisors(advisorA, advisorB)

最终顺序以 getOrder() 排序结果为准。

18. 常见误区

误区一:配置 ChatMemory 后自动生效

只有它被某个 Memory Advisor 使用,并且 Advisor 加入 ChatClient 链,才会参与调用。

误区二:Memory 就是数据库

ChatMemory 管理对话窗口;ChatMemoryRepository 才负责底层存储。

误区三:历史消息在 Controller 中手动拼字符串

推荐保留类型化 Message,不要把所有历史压成一大段 system 文本。

误区四:Assistant 消息在第一个流式 chunk 到达时保存

内置 Advisor 会等待聚合后的完整响应再执行 after。

误区五:默认 conversationId 可以用于多用户系统

能运行不代表隔离正确。

误区六:Advisor after 与 before 同顺序

Around 链返回时顺序反转。

19. 工程边界

教学 Demo

text
内存 Repository
+ MessageWindowChatMemory
+ MessageChatMemoryAdvisor

适合单元测试和理解执行过程。

工程可用方案

至少需要:

  • 显式 conversationId;
  • tenant 隔离;
  • 消息窗口限制;
  • TTL;
  • 消息序列化兼容;
  • 失败轮次处理;
  • 重试去重;
  • 隐私脱敏;
  • 查询与清理接口。

生产级方案

还要考虑:

  • 多实例并发写同一会话;
  • 消息顺序号;
  • 乐观锁或原子追加;
  • 工具中间消息是否持久化;
  • 流式取消后的部分响应;
  • 会话归档;
  • 长对话摘要;
  • Memory 与业务审计记录分离;
  • 数据删除合规。

20. 简短练习

练习 1

Advisor:

text
A order = 50
B order = 100
C order = 200

写出完整 before、Model、after 顺序。

练习 2

MessageChatMemoryAdvisor.before() 保存的是哪条消息?

text
A. 全部旧历史
B. 当前最新 UserMessage 或 ToolResponseMessage
C. AssistantMessage
D. SystemMessage

练习 3

为什么模型调用失败后,Memory 中仍可能存在本次 UserMessage?

练习 4

流式模式为什么不能把每个 chunk 直接当成一条 AssistantMessage 保存?

练习 5

设计一个 conversationId:

text
租户 tenant-01
用户 user-88
会话 session-20260722-01

并说明为什么仅使用 user-88 可能不够。

21. 本节掌握标准

不看源码,能够画出:

text
Advisor A before
→ Memory before
   → 读取历史
   → 拼接 Prompt
   → 保存 UserMessage
→ ChatModel
→ Memory after
   → 保存 AssistantMessage
→ Advisor A after

并能解释同步和流式的 AssistantMessage 保存时机为什么不同。

下一节进入模型边界:ChatModelCallAdvisorChatModelStreamAdvisor 怎样把请求交给 DeepSeek,DeepSeek 返回后又怎样包装成 Spring AI 对象。

22. 对应源码

Built with VitePress. Deployed on Cloudflare Pages.