☰
Spring AI 手动实现 ReAct Agent:掌握工具调用循环与生产级设计
2026/10/5 19:01:24 网站建设 项目流程

1. “或跃在渊”与 ReactAgent:为什么这一招是分水岭

这一篇既然是“降 Spring AI”系列的第九篇,对应的状态正好是易经里的“或跃在渊”。放在 Spring AI 的学习路线上,这个阶段很微妙:你已经不是只会调 chatModel 接口的初学者,但也还没到能放心把 Agent 扔进生产环境的程度,就在“跃起”和“待定”之间反复横跳。我自己的体验是,真正把 Spring AI 从“包装模型”升级成“业务系统里的自主决策单元”,分水岭就是把 ReactAgent 这层循环想明白。

先说结论:ReactAgent 不是一个开源组件名,也不是 Spring AI 独有的功能。它是 ReAct 模式的工程化落地。ReAct 这个词来自 Reasoning + Acting,最早是论文里提出的 Prompt 策略,后来被 LangChain 的 Agent 机制带火,核心逻辑只有一句话:让模型先想一步,再动一步,看到工具执行结果后继续想,直到它确认信息足够、可以给出最终答案。

为什么我强调“先想明白它是什么”?因为很多人在 Spring AI 项目里看到@Tool注解就觉得自己写了 Agent,看到 ChatClient 自动执行工具调用就觉得是 ReAct。实际上,工具调用只是“手”,ReAct 循环才是“脑”。举个业务例子你自己感受一下:

用户问:杭州未来三天哪天适合夜跑?

这类问题最关键的地方在于,模型自己并不知道该先查天气还是先算时间。它需要规划:先调用天气工具拿到三天数据,再推理哪天温度、湿度和降水最适合运动,最后输出结论。这个“决策-调用-观察-再决策”的闭环,才是 ReactAgent 的核心价值。

再看“或跃在渊”这四个字,我觉得它特别贴合这个阶段的工程状态。Agent 模型已经具备自主性,但它还没真正飞龙在天,下面可能是深渊——这个深渊就是工具调用失控、死循环、上下文被撑爆、权限被滥用。所以这一篇不只是告诉你 ReactAgent 怎么写,更重要的是教会你把它控制在安全边界里。

2. 技术选型:Spring AI 里的三种 ReAct 实现路线

接到“做 Agent”这种需求的时候,先别急着写代码,把路线定下来比什么都重要。我在实际项目里见过三种做法,成本、可观测性和可控性差别很大。

2.1 路线一:ChatClient 自动工具调用

这是成本最低的一条路,也是大多数人的第一反应。Spring AI 的 ChatClient 支持直接在 prompt 链路里绑定工具对象:

String answer = chatClient.prompt() .system("你是业务助手,擅长使用工具解决问题") .tools(new WeatherTool(), new DateTool()) .user("杭州明天气温多少?") .call() .content();

这样写的优点是代码量极小,框架内部会自动完成“模型返回工具调用 -> Spring AI 执行本地方法 -> 把结果回传给模型”的闭环。对于单轮对话、工具调用链很短、模型能力够强的场景,它完全够用。

但它的缺点也明显:这个闭环对业务代码来说是个黑盒。我看不到模型到底调了几次工具、每次参数是什么、哪一步开始跑偏,也无法在中间插入权限校验或者结果审计。一旦 Agent 进入死循环,你只能靠外部超时兜底。所以这条路适合做原型验证,不适合做严肃的生产 Agent。

2.2 路线二:固定流程编排

有些团队会把 Agent 做成硬编码流程:先调工具 A,拿到结果后调工具 B,最后把结果拼进 Prompt 让模型总结。这种做法的优势是可预测,但严格来说它不叫 Agent,叫工作流。固定流程解决不了动态规划问题。

比如用户的问题从“今天天气”变成“对比两个城市后推荐一个周末目的地”,你的硬编码流程就得重写。模型没有参与决策,它只是在你拼好的步子里做了一小段总结。生产系统里可以保留这种方式处理明确、稳定的流程,但它不是 ReAct,也不是这一篇要讲的重点。

2.3 路线三:接管 ToolCall 循环,自己当 Agent 控制器

这是我最终选择的做法:使用 Spring AI 暴露的 ChatModel、ToolCallback 和 ToolResponseMessage 等底层 API,自己实现 ReAct 循环。先亮一下核心思路:每次只让模型走一步;如果它认为需要工具就返回 ToolCall,我们执行工具后再把结果放回消息列表;如果它认为信息足够就直接输出文本。所有中间环节都在我们可控范围内。

这样做有三个直接收益:第一,轮次上限可以由我们自己设置,不会无限循环;第二,每一轮的工具参数和返回值都能记录日志,Agent 做了什么是可审计的;第三,可以在工具执行前插入白名单校验、敏感操作拦截这类逻辑。

2.4 三种路线怎么选

路线动态规划可观测性开发成本典型场景
ChatClient 自动闭环依赖模型能力低最低快速验证、单工具问答
固定流程编排无中中步骤明确的业务流程
手动 ReAct 循环高高较高多工具决策、生产级 Agent

如果你只是 demo,选路线一,别浪费时间。但如果你想把这个 Agent 放到真实业务里,并且之后还要接权限、审计、监控,那就老老实实走路线三。这一篇后面的代码基本都是路线三的产物。

3. 环境准备:Spring AI 连接通义千问

先把环境搭起来。这整篇示例我用的是 Spring Boot 3.3 + Java 17 + Spring AI 1.0,模型接入用的是阿里云百炼上的通义千问。之所以选它,一是因为国内访问和部署都比较方便,二是因为通义千问对工具调用的支持在国产模型里属于第一梯队,做 ReAct 示例很合适。

3.1 依赖、BOM 与配置文件

在 pom.xml 里引入 Spring AI 的 BOM 和 OpenAI 兼容协议的 Starter。通义千问在阿里云百炼上提供了 OpenAI 兼容的 endpoint,所以不需要单独引入特殊 Starter:

<dependencyManagement> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-bom</artifactId> <version>1.0.0</version> <type>pom</type> <scope>import</scope> </dependency> </dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-model-openai</artifactId> </dependency> </dependencies>

然后在 application.yml 里配置 base-url、api-key 和默认模型:

spring: ai: openai: base-url: https://dashscope.aliyuncs.com/compatible-mode/v1 api-key: ${DASHSCOPE_API_KEY} chat: options: model: qwen-plus temperature: 0.2

这里有两个细节值得注意。第一,DASHSCOPE_API_KEY必须放在环境变量或者配置中心里,不要写死在代码仓库,这个没得商量。第二,ReAct 不需要模型太“有创意”,temperature 我习惯压低到 0.2 甚至 0.1,让模型的决策更稳定。

3.2 模型选择与 API Key 建议

通义千问家族里,qwen-turbo 便宜但工具调用稳定性一般,适合做低成本验证;qwen-plus 是我在 ReAct 场景里的主力,工具调用准确率足够;qwen-max 更强但 token 成本更高。如果你的任务链路很长、工具很多,选 qwen-max 会让你少踩很多“模型不听话”的坑。

还有一个容易被忽略的点:阿里云百炼控制台里申请 API Key 之后,最好再配置一下限流和预算告警。Agent 一旦进入异常循环,token 消耗速度比普通问答快得多,没有预算告警等于把钱包敞着口跑。

4. 动手实现一个可运行的 ReactAgent

理论聊完,直接进入代码。我分三层讲:工具层、循环控制器、接口暴露层。你可以直接抄,但建议顺着我的注释看一下为什么这么写。

4.1 先写工具:让模型有“手”

工具方法必须被@Tool注解标记。参数上用@ToolParam写清楚说明,模型靠这个生成符合格式的 JSON 参数。我用一个天气工具和一个日期计算工具做演示:

@Component public class WeatherTools { @Tool(description = "获取指定城市的实时天气,返回JSON字符串") public String getWeather( @ToolParam(description = "城市名称,比如杭州") String city) { // 这里可以用你的真实天气服务,示例直接返回固定值 return """ {"city":"%s","temperature":28,"weather":"多云"} """.formatted(city); } }

注意返回值的格式。我的经验是:工具返回的内容越结构化,模型越容易稳定解析。如果你返回一大段散文,模型要么截断,要么把不相干的信息当成事实录进去。工具层能返回 JSON 就返回 JSON,不要返回给人看的文案。

然后是日期工具,这个在 Agent 任务里非常容易踩坑。很多模型对“明天”“后天”这种相对时间概念并没有可靠的感知,与其让它算,不如直接提供工具:

@Component public class DateTools { @Tool(description = "计算两个日期之间相隔的天数") public int daysBetween( @ToolParam(description = "开始日期,格式yyyy-MM-dd") String start, @ToolParam(description = "结束日期,格式yyyy-MM-dd") String end) { return (int) ChronoUnit.DAYS.between(LocalDate.parse(start), LocalDate.parse(end)); } }

工具写完后,需要把它们注册成 Spring AI 的 ToolCallback Bean。我这里用的是ToolCallbacks.from(Object... tools)这个工具类:

@Configuration public class AgentToolConfig { @Bean ToolCallback weatherToolCallback(WeatherTools weatherTools) { return ToolCallbacks.from(weatherTools)[0]; } @Bean ToolCallback dateToolCallback(DateTools dateTools) { return ToolCallbacks.from(dateTools)[0]; } }

为什么我不直接把工具对象塞给 ChatClient?因为手动 ReAct 循环里,我需要拿到每个工具的定义和 executor,把它们统一管理起来。注册成独立的 ToolCallback Bean 之后,Spring 容器就能自动收集到所有可用工具。

4.2 核心循环:手动 ReAct 实现

这是全篇最关键的一段代码。上代码:

@Service public class ReactAgentService { private static final String SYSTEM_PROMPT = """ 你是一个企业业务助手。对于复杂问题,你不必一次性算出最终答案。 你可以主动调用工具获取事实数据。拿到工具结果后继续推理。 每一步只做两个判断: 1. 信息还不够 -> 调用工具; 2. 信息已经足够 -> 直接输出最终答案。 不要编造工具返回的结果,不要重复调用已经拿到相同结果的工具。 """; private final ChatModel chatModel; private final ToolCallback[] toolCallbacks; private final int maxIterations = 6; public ReactAgentService(ChatModel chatModel, ObjectProvider<ToolCallback> provider) { this.chatModel = chatModel; this.toolCallbacks = provider.stream().toArray(ToolCallback[]::new); } public String execute(String question) { List<Message> messages = new ArrayList<>(); messages.add(new SystemMessage(SYSTEM_PROMPT)); messages.add(new UserMessage(question)); for (int step = 0; step < maxIterations; step++) { ToolCallingChatOptions options = ToolCallingChatOptions.builder() .toolCallbacks(toolCallbacks) .internalToolExecutionEnabled(false) .build(); ChatResponse response = chatModel.call(new Prompt(messages, options)); AssistantMessage assistantMessage = response.getResult().getOutput(); messages.add(assistantMessage); List<ToolCall> toolCalls = assistantMessage.getToolCalls(); if (toolCalls == null || toolCalls.isEmpty()) { return assistantMessage.getText(); } List<ToolResponse> toolResponses = new ArrayList<>(); for (ToolCall call : toolCalls) { ToolCallback callback = findCallback(call.name()); String result = callback.call(call.arguments()); toolResponses.add(new ToolResponse(call.id(), call.name(), result)); } messages.add(new ToolResponseMessage(toolResponses, Map.of())); } throw new IllegalStateException("Agent 超过最大工具调用轮次:" + maxIterations); } private ToolCallback findCallback(String name) { return Arrays.stream(toolCallbacks) .filter(cb -> cb.getToolDefinition().name().equals(name)) .findFirst() .orElseThrow(() -> new IllegalArgumentException("找不到工具: " + name)); } }

代码不长,但没有一行是多余的,逐条解释一下:

internalToolExecutionEnabled(false)是最容易被忽略、也最关键的选项。它让 ChatModel 不再自动替我们执行工具调用,而是把 ToolCall 原样返回给我们。只有关掉它,我们才能在中间插入日志、权限校验和轮次控制。不同版本的 Spring AI 对这个配置的命名可能略有差异,升级版本的时候先查一眼 API 变更说明。

messages.add(assistantMessage)是把模型这一步的“决策结果”放入上下文。下一个循环里,模型看得到自己刚才说了什么,它才能基于新来的工具结果继续推理。如果不加这一步,模型就失忆了,ReAct 循环根本转不起来。

工具执行结果用ToolResponseMessage封装。模型看到的不只是“工具返回了一个字符串”,还能定位到它对应的是哪次工具调用。如果工具执行出现异常,你也应该把异常信息作为结果返回,让模型自己决定是换一种方式调用还是直接给用户一个错误说明。

4.3 接口封装与实测效果

最后暴露一个 HTTP 接口:

@RestController @RequestMapping("/agent") public class ReactAgentController { private final ReactAgentService reactAgentService; public ReactAgentController(ReactAgentService reactAgentService) { this.reactAgentService = reactAgentService; } @GetMapping("/chat") public String chat(@RequestParam String message) { return reactAgentService.execute(message); } }

启动应用后,直接在浏览器里访问:

curl "http://localhost:8080/agent/chat?message=帮我看看杭州和上海哪个城市当前温度适合跑步"

我实测时,模型的第一步通常不会直接给最终答案,而是先调用getWeather,然后把两个城市的天气结果放入上下文,再输出“杭州 28 度多云更适合跑步”这类结论。整个过程在日志里看得到两次模型响应和一次工具调用,这正是 ReAct 循环的样子:推理 -> 行动 -> 观察 -> 再推理。

5. 生产环境必看的避坑清单

代码跑通只是第一步,真正让人头疼的问题全在边界情况里。以下都是我在实际项目里踩过、或者帮别人排查过的坑,按重要程度排序。

5.1 轮次上限与死循环

Agent 最常见的生产事故就是死循环。模型可能因为同一件事反复调用同一个工具,或者在一个错误前提下来回打转。所以maxIterations是必须有的,我一般控制在 4 到 8 之间。6 轮不够用的情况非常少见,超过 8 轮基本能判断是系统问题而不是任务复杂。

还有一种更隐蔽的情况:工具返回的结果每次都不一样,导致模型不断尝试。比如查询增长数据,结果带有时间戳,模型以为“下次会有新数据”,于是一直重复调用。我的解法是在系统提示词里加一句:如果某个工具的结果没有带来新增信息,必须停止调用并基于已有信息作答。这招治标也治本,因为问题的根源经常是 Prompt 没有约束模型的行为边界。

5.2 工具参数与返回值的“胖瘦”

工具方法参数越少越稳定。我之前把一个查询条件特别多的工具暴露给 Agent,模型频繁拼错 JSON。后来我把多个可选参数合并成一个 JSON 字符串参数,模型反而成功率上去了。工具设计要遵循“小步快跑”原则:一个工具只做一件事,参数不超过三到五个。

返回值也要控制体积。工具返回 100 行数据,模型不一定能全部消化,还容易把上下文撑爆。更合理的方式是在工具方法内部先做聚合,只返回关键字段。比如查 SQL 先返回 COUNT 和 AVG,而不是把明细行全塞给模型。如果确实需要明细,再提供“查询某条明细”的第二个工具。

5.3 会话隔离与并发安全

这个坑藏得很深。如果你的ReactAgentService里用成员变量保存 messages,那一旦收到并发请求,两个用户的消息就会互相污染。手动 ReAct 循环里,messages 属于请求级状态,必须放在方法内部,或者用 RequestScope 的 Bean 来保存。示例代码里execute方法内部的List<Message> messages就是安全的。

再加上 ChatModel 底层连接池的处理,Spring AI 本身就是线程安全的,并发不是问题。问题永远出在你自己的状态管理上。记住一句话:Agent 会话状态和 HTTP 请求生命周期绑定,不要试图搞一个全局限值。

5.4 可观测性:给 Agent 加日志

黑盒 Agent 上线等于埋雷。我在项目里强制要求每轮循环都记录结构化日志,至少包含三个信息:当前步数、模型决定调用的工具名、工具返回结果摘要。这样可以快速定位“哪一步开始跑偏”。

log.info("step={}, toolName={}, arguments={}, result={}", step, call.name(), call.arguments(), result);

如果日志量太大,可以只记录前 200 个字符的工具结果。有全链路追踪系统的话,把 traceId 也放进去,后续排查会省很多力气。

5.5 安全边界:工具权限分级

ReAct 最危险的地方在于,模型会“积极地”调用工具。如果你把一个删除数据库的工具暴露给模型,它可能在某个中间步骤把它当清理工具用了。这不是模型坏,是你的边界没设好。

我的经验是:默认暴露只读工具,写操作工具必须二次确认。给工具增加危险等级,在执行前判断是否允许该用户调用该工具。比如“删除订单”这类动作,不应该在 Agent 循环里自动执行,而是要返回一个“需要人工确认”的结果给用户。安全边界这一块没有捷径,越早设计越好。

6. 后续扩展:从 ReAct 到更复杂的 Agent 架构

如果你已经把上面的代码跑通了,可以尝试几个升级方向,这些我都验证过,复杂度是递增的。

6.1 让 Agent 自己规划任务清单

手动 ReAct 循环还能再进一步:让模型先输出一个 JSON 格式的任务清单,然后由我们的代码按清单逐个执行工具。这就是 Plan-and-Execute 模式。它的好处是模型不用在每一步都重新规划,执行阶段更稳定,也更容易做中间检查。

我实现过的最小版本是:第一轮只让模型调用一个createPlan工具,把计划拆成多个 JSON 步骤;后续每一轮只处理一个步骤。这样做牺牲了一点灵活性,换来了非常好的可预测性。

6.2 多工具并行与结果合并

某些任务里,模型会一次性返回多个 ToolCall。比如同时查杭州和上海的天气,这两个调用互相独立。前面示例里是串行执行的,其实可以改成CompletableFuture并发执行,然后把结果一起塞进 ToolResponseMessage。

不过这里要小心:工具之间如果有依赖,并行反而会翻车。我一般会根据返回的所有 ToolCall 做一次分组,互相依赖的保持串行,完全没有依赖的才并发。这个优化对响应耗时的改善非常明显。

再往后走,你会接触到多 Agent 协作:一个 Agent 做规划,另一个 Agent 做执行,各配各的系统提示词和工具白名单。我也在项目里试过用 Spring AI 的 Advisor 机制切入限流和记忆清理,效果不错。这个方向没有标准答案,但核心始终是 ReAct 这套思考框架。把这一篇的循环逻辑和边界意识吃透,后面不管怎么包装,你都不会跑偏。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询