Spring AI的工具调用循环,本质就是ReAct:一次源码级拆解

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调用。

真正有意思的是ToolCallingAdvisorChatModelCallAdvisor之间那两条来回的箭头,那才是运行时真正的循环。一次.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_callToolCallingAdvisordo-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的工具调用机制就不再是一个黑盒。

相关推荐
可触的未来,发芽的智生1 小时前
发现-元认知技能,激起神经符号系统跃变
javascript·人工智能·python·程序人生·自然语言处理
JackHCC1 小时前
Meta-WHALE:逐层融合统一推荐模型
人工智能·机器学习
墨舟的AI笔记1 小时前
微前端隔离边界:qiankun 与 Module Federation 的取舍之道
人工智能
字节逆旅1 小时前
有哪些工作不可以交给AI
人工智能·程序员
一路向北North1 小时前
Spring AI(6) :对话机器人-会话历史
java·人工智能·spring
2zcode1 小时前
基于MATLAB神经网络的心力衰竭预测与临床辅助决策系统研究
人工智能·神经网络·matlab
ACP广源盛139246256731 小时前
国产算力互联IX8024@ACP#Kimi K3 开源后的端侧部署硬件架构分析
大数据·人工智能·分布式·单片机·嵌入式硬件
江边风声2 小时前
从薄板到厚板、从吸盘到夹板边——坤鹏伯爵的取放技术体系是怎样覆盖全制程的
人工智能·科技·自动化·制造·pcb工艺
商业模式源码开发2 小时前
小米新车未发布即遭 AI 谣言攻击:黑色 GEO 的运作原理与企业正规防御方案
大数据·人工智能·ai·geo