怎么把记忆、日志、成本监控、工具调用这些非业务逻辑,从 Agent 代码里彻底抽出去。
Spring AI Advisor:多 Agent 系统的调用链增强设计
做一个简单的 AI Demo:
chatClient.prompt()
.user(message)
.call()
.content();
就够了。
但生产环境里的 Agent,在真正调用模型之前和之后,还要处理很多事情:
加载聊天历史
记录请求日志
统计 Token
计算模型费用
调用工具
权限校验
异常处理
任务上下文注入
这些逻辑并不属于“岗位推荐”“简历优化”这样的业务本身。
如果每个 Agent 都自己实现一遍,代码很快就会失控。
Spring AI 的 Advisor,就是用来解决这个问题的。
1. Advisor 是什么?
可以把 Advisor 理解成:
LLM 调用链上的拦截器。
和 Spring MVC 的 Interceptor、Servlet 的 Filter 非常像。
正常调用:
User
↓
ChatClient
↓
LLM
加入 Advisor:
User
↓
Logger Advisor
↓
Memory Advisor
↓
ReAct Advisor
↓
LLM
每个 Advisor 都可以在模型调用之前和之后执行逻辑。
基本结构类似:
ChatClientResponse adviseCall(
ChatClientRequest request,
CallAdvisorChain chain) {
// 请求前处理
ChatClientResponse response =
chain.nextCall(request);
// 响应后处理
return response;
}
这里最关键的是:
chain.nextCall(request)
它表示:
把请求继续交给下一个 Advisor。
如果不调用它,也可以直接结束整个调用。
2. Advisor Chain 怎么执行?
Advisor 是链式执行的。
假设有三个 Advisor:
A → B → C → LLM
请求进入时:
A before
↓
B before
↓
C before
↓
LLM
模型返回以后顺序反过来:
LLM
↓
C after
↓
B after
↓
A after
所以它本质是一个:
责任链
+
洋葱模型
不同 Advisor 就可以分别负责:
Memory
Logging
ReAct
Tracing
Security
而业务 Agent 完全不用关心。
3. 为什么 Memory 很适合做 Advisor?
用户:
帮我找武汉 Java 岗位
接着:
25K 以上的
第二句话必须依赖第一句话。
MessageChatMemoryAdvisor 就可以在请求模型之前:
读取 ChatMemory
↓
加载历史消息
↓
和当前消息合并
↓
发送给 LLM
模型回答以后再:
保存 User Message
+
保存 Assistant Message
所以这里一定要区分:
ChatMemory
负责保存聊天记录
Memory Advisor
负责把聊天记录加入模型调用
这就是 Advisor 最大的价值之一:
让业务代码感知不到记忆系统的存在。
4. 为什么还要自己写 ReActAdvisor?
Spring AI 可以自动执行 Tool Calling。
但是复杂 Agent 往往不是:
LLM
→ Tool
→ 完成
而是:
Reason
↓
调用岗位搜索
↓
Observation
↓
继续 Reason
↓
调用岗位详情
↓
Observation
↓
继续 Reason
↓
最终回答
这就是 ReAct:
Reason
→ Act
→ Observation
→ Reason
生产环境通常还需要控制:
每一轮用了多少 Token
工具执行多久
工具执行前是否有权限
最多循环多少次
某一轮失败怎么处理
整个调用花多少钱
如果工具执行全部封装在框架内部,这些细节就很难控制。
因此可以关闭自动工具执行:
internalToolExecutionEnabled(false)
然后自己实现:
ReActAdvisor
由它完全接管:
推理
→ 工具执行
→ 再推理
→ 再执行
5. ReActAdvisor 最值得学的设计
这里最关键的设计不是循环本身,而是:
第一次推理走完整 Advisor Chain,后面的 ReAct 推理直接调用 ChatModel。
第一次:
User
↓
Logger
↓
Memory
↓
ReActAdvisor
↓
LLM
这样 Memory Advisor 能正常加载历史。
如果模型要求调用工具:
执行 Tool
↓
Tool Result
↓
ChatModel.call()
↓
继续推理
后面的循环不再重新走:
Memory Advisor
Logger Advisor
...
为什么?
假设每一次 ReAct 都重新经过 Memory Advisor:
第一次:
历史 + 当前消息
第二次:
又加载历史
+
上一次已有历史
很容易出现:
消息重复
上下文膨胀
日志重复
Token 浪费
所以更合理的设计是:
第一次
走完整基础设施链
后续 ReAct
只维护当前推理上下文
这是这篇最值得沉淀的地方之一。
6. ReAct 循环要有安全阀
模型有可能出现:
调用 A
↓
调用 B
↓
再次调用 A
↓
再次调用 B
↓
……
理论上可能一直循环。
因此必须:
maxIterations = 10;
最终逻辑类似:
第一次 Reason
while 有 Tool Call:
if iterations > max:
break
执行 Tool
加入 Tool Result
再次 Reason
所以生产级 Agent 一定要记住:
所有 Agent Loop 都必须存在最大迭代次数。
不能完全相信模型自己知道什么时候结束。
7. Advisor 和 Middleware 为什么要分两层?
这是整篇第二个非常值得学习的设计。
Advisor 拦截的是:
一次完整 LLM 调用
但是 ReAct 里面可能发生:
Reason 1
Tool 1
Reason 2
Tool 2
Reason 3
如果我们想监控每一轮,就需要比 Advisor 更细的粒度。
因此内部再设计:
ReActMiddleware
架构变成:
Advisor Chain
│
┌─────────────┼─────────────┐
↓ ↓ ↓
Memory Logger ReActAdvisor
│
↓
ReAct Middleware
│
┌───────────┼───────────┐
↓ ↓ ↓
Logging Monitor PlanHint
简单理解:
Advisor
管“整次调用”
Middleware
管“ReAct 内部每一轮”
这个职责划分非常清楚。
8. Middleware 可以有哪些生命周期?
一个 ReAct Loop 可以抽象成:
Loop Start
↓
Before Reasoning
↓
LLM Reasoning
↓
After Reasoning
↓
Before Acting
↓
Tool Execution
↓
After Acting
↓
……
↓
Complete / Error
这样就可以插入不同能力。
例如日志 Middleware:
记录这一轮模型输出
记录 Tool 名称
记录参数
记录 Tool Result
监控 Middleware:
Input Tokens
Output Tokens
Latency
Cost
Plan Middleware:
在每轮 Reasoning 前
注入当前执行计划
这样新增监控能力时,不需要修改 ReActAdvisor。
只需要新增:
@Component
class MyMiddleware implements ReActMiddleware {
}
然后自动发现。
这其实就是:
插件化架构。
9. 为什么 Token 和 Cost 也应该放 Middleware?
ReAct 和普通聊天最大的不同是:
一次用户请求可能调用模型很多次。
例如:
用户请求
↓
Reason 1 → 1000 tokens
↓
Tool
↓
Reason 2 → 800 tokens
↓
Tool
↓
Reason 3 → 600 tokens
用户只发送了一条消息,但实际上:
LLM 调用了 3 次
所以真正成本应该是:
Total Cost
=
Reason1 Cost
+
Reason2 Cost
+
Reason3 Cost
这时候就在:
afterReasoning()
里读取:
input tokens
output tokens
model price
计算:
cost =
inputTokens × inputPrice
+
outputTokens × outputPrice
最终在:
onComplete()
汇总。
所以:
Agent 成本监控必须看整个 ReAct Loop,而不是只统计最外层的一次请求。
10. ToolContext 解决什么问题?
Tool 参数有两类。
模型决定的:
keyword
city
salary
jobName
应用决定的:
userId
tenantId
conversationId
权限
当前消息
后面这些不能让 LLM 自己生成。
因此调用时:
.toolContext(Map.of(
"userId", userId,
"conversationId", conversationId
))
ReActAdvisor 在真正执行工具时继续把它传下去:
ChatClient
↓
ReActAdvisor
↓
ToolContext
↓
Tool
最终工具:
@Tool
String createTask(
String taskName,
ToolContext context) {
String userId =
context.getContext().get("userId");
}
所以:
ToolContext 是业务上下文进入 Tool Execution 的安全通道。
11. 最终调用架构
把整个设计压缩一下:
Agent
↓
LlmCaller
↓
ChatClient
↓
Advisor Chain
┌──────────┼──────────┐
↓ ↓ ↓
Memory Logger ReActAdvisor
↓
ReAct Loop
↓
Middleware Chain
┌────────┼─────────┐
↓ ↓ ↓
Logging Monitor Plan
│
↓
Reason → Tool
↑ ↓
└─────┘
↓
Final Answer
这样业务 Agent 最后可能只剩:
llmCaller.call(user, prompt);
剩下:
记忆
日志
工具循环
成本
监控
计划
全部属于基础设施层。
12. 这篇真正应该沉淀什么?
核心可以压缩成这几个概念:
Advisor
→ LLM 调用级拦截器
Advisor Chain
→ 横切能力责任链
ReActAdvisor
→ 控制 Reason + Tool 循环
Middleware
→ ReAct 内部生命周期插件
ToolContext
→ 给 Tool 传业务上下文
maxIterations
→ 防止 Agent 死循环
Monitoring
→ 每轮统计 Token / Cost
其中最重要的是这个分层:
外层 Advisor
负责:
Memory / Logger / ReAct
内层 Middleware
负责:
Reason / Tool / Cost / Monitor
最值得记住的一句话
Advisor 解决的是“整次 AI 调用怎么增强”,Middleware 解决的是“Agent 每一轮 ReAct 怎么增强”。
最终架构思想就是:
业务逻辑
不要关心
Memory
Logging
Monitoring
Tool Loop
Cost
而应该:
Agent
只负责业务
Advisor
负责调用链横切能力
ReActAdvisor
负责 Agent 循环
Middleware
负责循环内部监控和增强
把它和你前面几篇串起来,就更加完整了:
Model Routing
→ 用哪个模型
ChatMemory
→ 记住什么
ChatClient
→ 怎么组织调用
Advisor
→ 调用过程怎么增强
Function Call
→ 怎么调用工具
ReAct
→ 怎么连续推理和使用工具
MCP
→ 外部工具怎么标准化接入
Structured Output
→ 最终结果怎么变成 Java 对象