切换主题
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 after5. 一个最小 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 before 和 B 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
contextprompt
真正发给模型的内容:
- 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
聚合完成后再写入 Memory15. 流式中断时会发生什么
如果用户在中途关闭连接:
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 保存时机为什么不同。
下一节进入模型边界:ChatModelCallAdvisor 和 ChatModelStreamAdvisor 怎样把请求交给 DeepSeek,DeepSeek 返回后又怎样包装成 Spring AI 对象。