ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Spring AI 手动实现 ReAct Agent:掌握工具调用循环与生产级设计

Spring AI 手动实现 ReAct Agent:掌握工具调用循环与生产级设计 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所以不需要单独引入特殊 StarterdependencyManagement dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-bom/artifactId version1.0.0/version typepom/type scopeimport/scope /dependency /dependencyManagement dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.ai/groupId artifactIdspring-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, ObjectProviderToolCallback provider) { this.chatModel chatModel; this.toolCallbacks provider.stream().toArray(ToolCallback[]::new); } public String execute(String question) { ListMessage 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); ListToolCall toolCalls assistantMessage.getToolCalls(); if (toolCalls null || toolCalls.isEmpty()) { return assistantMessage.getText(); } ListToolResponse 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方法内部的ListMessage 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 这套思考框架。把这一篇的循环逻辑和边界意识吃透后面不管怎么包装你都不会跑偏。
返回列表