Spring AI的工具调用循环,本质就是ReAct:一次源码级拆解
在ChatModel实现类里打断点调试Spring AI的工具调用,你会碰到一件怪事:日志里明明看到模型被连续吐了好几次tool_call,可断点只跳进来一次。是断点打错了地方吗?不是。循环压根不在ChatModel里,这是Spring AI 2.0相对1.x最容易让老用户懵的一处架构变化。
这篇文章想说清楚三件事:Spring AI的调用链到底长什么样;工具调用的循环具体藏在哪一层、怎么实现的;这套机制是不是标准的ReAct,Reason、Act、Observation三段一样不少,而不只是长得像。全文过了一遍Spring AI 2.0.0的源码,结论都能对着代码验证,但正文只提类名,不贴文件路径和行号,方便阅读。
一张图看全貌
Spring AI的调用链看着乱,是因为它把三件事叠在了一起:责任链(Advisor Chain)、四层嵌套的可观测性埋点、藏在其中一个Advisor里的ReAct循环。三件事拆开看,每一件都不复杂。

图上从左到右是一次调用的静态结构:ChatClient接进来一句.prompt(msg).call(),DefaultChatClient把它组装成请求,攒出一条责任链,链上依次挂着用户自己加的Advisor(Memory、RAG、日志之类,可选)、ToolCallingAdvisor、最后是链的终结节点ChatModelCallAdvisor,由它去调具体的ChatModel发起一次真正的模型API调用。
真正有意思的是ToolCallingAdvisor和ChatModelCallAdvisor之间那两条来回的箭头,那才是运行时真正的循环。一次.call()背后可能是好几轮模型调用,日志里看到的是同一次调用,背地里模型API可能被打了三五次。这也是文章开头那个「断点为什么只跳一次」的答案:循环发生在更外层的ToolCallingAdvisor,断点得下在那一层才看得全。
顾问链是责任链,不是一份线性列表
ChatClient.prompt(msg).call()落地到具体代码,是DefaultChatClient构造出请求规格,调buildAdvisorChain()把顾问链攒出来,再触发链的执行。
链的执行方式是Deque + pop(),不是for循环遍历一份List。链的实现类每次弹出下一个Advisor,把「链的剩余部分」传给它,Advisor自己决定要不要继续往下走。这跟Servlet的Filter链、Spring MVC的HandlerInterceptor是同一种写法:每个Advisor都能同时拦截「请求进去」和「响应出来」两端,顺序由Ordered决定,数值越小越靠外层。
链的组装规则也值得记一下:用户手动加的Advisor(Memory、RAG、日志......)放前面,没手动指定就自动注册一个ToolCallingAdvisor;链的最底部固定压两个终结Advisor:非流式的ChatModelCallAdvisor和流式的ChatModelStreamAdvisor,它们的getOrder()都是最低优先级,永远排最后,负责真正调ChatModel.call(prompt)。
Tool Calling循环:2.0把它从ChatModel搬到了Advisor层
这是本文开头那个「断点只跳一次」的根源。循环主体在ToolCallingAdvisor里,是一个显式的do...while(isToolCall),不是递归:
java
// ToolCallingAdvisor 核心循环(节选,改写自源码,去掉了部分细节)
do {
processedChatClientRequest = this.doBeforeCall(processedChatClientRequest, callAdvisorChain);
chatClientResponse = callAdvisorChain.copy(this).nextCall(processedChatClientRequest); // 调下游 → ChatModelCallAdvisor → ChatModel
chatClientResponse = this.doAfterCall(chatClientResponse, callAdvisorChain);
isToolCall = this.toolExecutionEligibilityChecker.isToolCallResponse(chatResponse);
if (isToolCall) {
ToolExecutionResult toolExecutionResult =
this.toolCallingManager.executeToolCalls(processedChatClientRequest.prompt(), chatResponse);
if (toolExecutionResult.returnDirect()) break; // 工具标了"直接返回"就跳出
instructions = this.doGetNextInstructionsForToolCall(...); // 拼下一轮消息
}
} while (isToolCall);
具体某个ChatModel(比如DeepSeekChatModel)现在只干一件事:把工具定义塞进请求,发一次调用,原样把响应(哪怕里面带着还没处理的tool_call)交回去。从头到尾没有while,没有执行工具的逻辑。循环完全不是它的职责了。
流式版本没法用阻塞的do-while,改用递归:检测到tool_call就在内部方法里递归调用自己,工具执行本身是阻塞操作,会被扔到弹性调度线程池上跑,不占用响应式主线程。
这就是ReAct,不是「像ReAct」
Reason(模型决定动作)、Act(真正执行)、Observation(把结果喂回去),这三段循环是不是真的落到了Spring AI的代码里,而不是我强行往上套的概念?看下面这张图就知道了。

关键证据在处理工具执行结果的那段代码里,逻辑压缩下来就两行:
java
messages.add(assistantMessage); // Act:模型决定调哪个工具、传什么参数
messages.add(toolResponseMessage); // Observation:工具真实执行的结果
这两条消息被塞回对话历史,变成下一轮Prompt的输入。模型在下一轮里,能看到自己刚才「决定调用工具」这个动作本身,以及这个动作换来的真实结果,然后接着推理,直到判定器认为不用再调工具为止。
循环载体从头到尾就是ToolCallingAdvisor这一个类,没有分散在别的地方。所以答案是肯定的:Spring AI的工具调用机制就是ReAct,只是把「循环」这件事封装成了一个可插拔的Advisor,而不是一个独立的Agent抽象。这也是它和LangGraph这类框架的根本区别:LangGraph把循环显式建模成图,节点加边;Spring AI把循环隐式塞进一个拦截器里,默认你看不见,但留了口子让你介入。
拓扑能不能自定义?能,而且官方自己就示范过
Advisor顺序会真实影响行为
spring-ai-client-chat自带内存管理、安全防护、日志记录几个Advisor;spring-ai-rag有做检索增强的Advisor;向量存储相关模块还有做问答和记忆管理的Advisor。这些都能自由插拔、自由排序。
顺序不是摆设。官方把记忆管理Advisor的默认优先级刻意排在ToolCallingAdvisor外面。记忆Advisor在更外层,只看得到「最终结果」,不会把工具调用循环里那几轮中间态的消息也存进聊天记忆。如果顺序反过来,聊天记忆里会混进一堆中间轮次的噪音,下次对话模型看到的上下文就是脏的。
循环本身可以被子类化改写
ToolCallingAdvisor把循环的几个关键切入点设计成受保护的钩子方法,专门留给子类覆盖。官方自己就给了一个例子:一个继承出来的子类覆盖了其中两个钩子,每一轮调模型前先用关键词或向量检索,从整个工具库里挑出当下最相关的一小撮工具塞给模型,而不是无脑把全部工具定义都发过去。工具一多,光工具描述就能把上下文占满,模型选择也会变差。这个子类注册后会直接替换掉自动配置里默认的ToolCallingAdvisor。
拓扑到底能不能自定义,答案就在这:拓扑 = 你注册的Advisor集合 + 顺序 + 是否用自定义的循环子类,框架没有把这套东西封死。
工具来源被统一抽象,MCP和本地方法没区别
注解标记的方法、手写的工具回调,最终都被统一转成同一种工具抽象对象。MCP(Model Context Protocol)走另一条接入路径,但落地到同一个抽象:对ToolCallingAdvisor而言,MCP server上的远程工具和本地方法完全一样,都只是工具列表里的一项。区别在别处:MCP工具是跨进程调用,本地方法是进程内直接调用,延迟和失败模式不一样,出错处理要分开考虑。
一轮多个tool_call,是顺序执行不是并发
模型一轮确实可以同时吐出多个tool_call,这是主流模型API本身就支持的能力。但Spring AI这边是顺序跑完再拼一条汇总的响应消息,不是开线程池并发跑。如果工具之间互不依赖、又比较慢,这是个值得注意的性能点,目前得自己在工具实现里做并发,框架不替你做。
没有自动/手动两个开关了,也没有内置的最大轮数
1.x时代有个开关,控制「库自动帮你把循环跑完」还是「库只跑一轮、剩下的交还给你自己处理」。2.0里这个开关被彻底删掉了,升级说明里写明了移除原因。现在这两种模式变成了两条不同的调用路径,而不是一个配置项:默认路径走ChatClient加自动注册的ToolCallingAdvisor,循环全自动;手动路径绕开ChatClient,直接拿ChatModel调,框架不替你执行工具,得自己判断响应里有没有tool_call,自己拿工具调用管理器手写循环。
终止条件也不是「跑够N轮就停」,而是前面提到的那个判定器,默认逻辑是「响应不为空且带工具调用」。这个判定器可以自定义传入,官方升级说明里给的例子是叠加供应商的结束原因一起判断。
这里要特别提醒一句:如果模型一直吐tool_call,ToolCallingAdvisor的do-while理论上会一直转下去,没有内置的最大迭代次数保护。这不是框架的疏漏,是它把「什么时候该停」这个决定权完全交给了你自定义的判定器。生产环境这里必须自己加保护。自己实现一个判定器,内部包一个计数器,超过阈值就强制判定为不再调用工具,构造ToolCallingAdvisor(或者对应的自动配置)时传进去。不要假设框架默认值里有这个兜底。
关键类速查
| 类 | 职责 |
|---|---|
ChatClient / DefaultChatClient |
fluent门面,.prompt().call()/.stream()入口 |
DefaultAroundAdvisorChain |
责任链实现:Deque.pop()驱动,每步包一层可观测性埋点 |
ToolCallingAdvisor |
ReAct循环本体,do-while驱动,非递归 |
ChatModelCallAdvisor / ChatModelStreamAdvisor |
链的终结节点,真正调ChatModel |
ToolCallingManager |
执行单次工具调用、拼装工具响应消息、判断是否直接返回 |
ChatModel(如DeepSeekChatModel) |
单次模型API调用,不含循环 |
ToolCallback / ToolCallbackProvider |
工具统一抽象,本地方法和MCP工具都归一到这里 |
ToolExecutionEligibilityChecker |
判定要不要继续循环,可自定义 |
写在最后
Spring AI没有把ReAct包装成一个显眼的Agent概念让你去学,它把循环压缩进了一个Advisor,默认情况下你甚至感觉不到自己在用Agent框架,只是在调一个会自动执行工具的聊天客户端。这种「藏起来」的设计对写业务代码的人友好,代价是调试的时候容易懵:断点打错层、以为循环丢了、不知道该去哪改最大轮数,都是这套设计换来的认知成本。搞清楚循环在哪、由谁驱动、什么时候停,这几个问题想明白了,Spring AI的工具调用机制就不再是一个黑盒。