
如果你一路跟到了“第9掌”说明前面关于Spring AI的基础用法、模型接入、Prompt模板这些你都玩得差不多了。这个阶段再去做简单的问答调用已经没什么成长空间。真正让Spring AI从“玩具”变成“生产力工具”的是Agent机制——而ReactAgentReAct风格的Agent正是那个把“会对话”升级成“会干活”的关键跨越。这篇就用“或跃在渊”这个临界点来打比方你现在的代码已经能稳定调用模型只差临门一脚让模型自己决定什么时候调用工具、怎么根据工具结果继续推理。这一篇不是讲API文档是我实际把ReAct Agent跑通、调优、踩坑之后的全过程记录。1. 从“会对话”到“会干活”ReAct Agent解决的真正痛点1.1 只是Prompt问答为什么不够用先说个扎心的事实单靠大模型问答你的应用边界非常窄。模型再聪明它不知道你家库存还有多少不知道某个接口今天返回了什么更没法帮你执行“查完天气再算一下要不要带伞”这种需要两步以上的操作。以前我们怎么绕过这个限制最常见的是“上下文注入”写代码把数据查出来拼进Prompt里再让模型回答。这种做法本质上是你把思考的活抢过来自己干了模型只是换个方式复述你的拼装结果。数据一多、逻辑一复杂这套模式就崩了——你不可能把所有可能的查询都提前枚举一遍用户的需求也不是固定的几条if-else能覆盖的。ReActReasoning Acting的思路恰好反过来让模型自己发现问题、自己决定调什么工具、自己根据工具返回结果推理下一步。说人话就是你给模型一支工具箱和一张流程图它自己走流程。使用者只负责把最终目标丢给它。1.2 ReAct循环的基本运转逻辑ReAct这个词我第一次听也觉得玄乎拆开就清楚了。它让模型在一个循环里交替做两件事Reasoning思考模型分析当前局面写下类似“我目前掌握的信息不足需要查询天气”这样的推理文本。Acting行动根据思考结果模型输出一个结构化的工具调用请求比如getWeather(city北京)。Observation观察系统执行这个工具调用把返回值作为新的上下文输入回模型。模型看到观察结果决定是继续调用工具还是给出最终答案。这就像一个真实的员工在干活先想清楚缺什么信息去问了拿到结果再想下一步直到确认任务可以交了才把结论报给你。整个决策过程不是一次性的“问-答”而是一个动态闭环。1.3 SpringAI如何把ReAct藏进框架里Spring AI没有要求你手动维护这个循环。它内部的Tool Calling机制已经把“模型输出工具调用请求→框架执行工具→结果回传模型”这条链路串起来了。你只要做两件事把工具用Tool注解暴露出来让ChatClient知道这些工具的存在。剩下的循环次数、调用格式拼接、工具返回结果的回传框架会在一次call()过程中自动完成。这大大降低了Agent的入门门槛——你不需要自己写一套while循环 JSON解析 多次对话拼接的胶水代码。不过必须提醒一句框架自动完成的只是“调用链路”不等于“决策正确”。模型会不会在合适的时机用合适的工具很大程度取决于系统提示词怎么写、工具描述怎么设计。这也是为什么我把提示词单独开一章它不是你随便粘贴一句“你是智能助手”就能应付的事。2. 环境与依赖选型跑通一个最小可复现项目2.1 版本矩阵和依赖引入Spring AI的版本迭代速度非常快网上教程经常对不上号。我写这篇的时候用的是Spring AI 1.0 GA版本Spring Boot 3.4.x这个组合在Maven Central可以直接拉取不需要额外配置仓库。先上pom.xml的关键部分parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version3.4.5/version relativePath/ /parent dependencyManagement dependencies dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-bom/artifactId version1.0.0/version typepom/type scopeimport/scope /dependency /dependencies /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如果不想走OpenAI官方接口而是接国内可选的大模型服务Spring AI也有对应的starter比如spring-ai-starter-model-dashscope阿里云百炼相关或者直接走兼容OpenAI协议的网关地址。我的建议是优先用OpenAI兼容协议接入因为这类接口的tool calling支持最成熟踩坑最少后续想切换模型只改配置不改代码。2.2 模型接入配置在application.yml里配置模型端点spring: ai: openai: base-url: https://你的网关地址/v1 api-key: ${MODEL_API_KEY} chat: options: model: qwen-plus # 选一个支持tool calling的模型如果你在本地装了Ollama想完全离线跑配置类似spring: ai: ollama: base-url: http://localhost:11434 chat: options: model: qwen2.5:7b这里有个关键点不是所有模型都适合跑Agent。小参数量模型和早期版本模型对tool calling的支持参差不齐经常出现“模型不输出工具调用请求”或者“乱传参数”的情况。我实测下来7B左右的本地模型做简单工具调用可以做复杂多步推理会明显吃力。生产环境多步Agent任务建议用支持function calling的中大型模型。2.3 一个最简单的Agent能跑起来的样子在写复杂代码之前先看一个能跑的最小闭环。定义一个工具import org.springframework.ai.tool.annotation.Tool; import org.springframework.stereotype.Component; Component public class SimpleTools { Tool(description 计算两个数字的和返回一个整数) public int add(int a, int b) { return a b; } }然后配置ChatClient并调用import org.springframework.ai.chat.client.ChatClient; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; Configuration public class AgentConfig { Bean public ChatClient reactAgentChatClient(ChatClient.Builder builder, SimpleTools tools) { return builder .defaultSystem(你是执行计算任务的智能代理。当需要运算时调用add工具。) .defaultTools(tools) .build(); } }调用String result chatClient.prompt() .user(帮我算23加45等于多少) .call() .content();如果一切正常你会得到68并且从日志里能看到模型先输出工具调用意图、框架执行add(23, 45)、再把结果返回给模型生成最终回答的过程。有些模型会直接口算出结果不调工具这是提示词约束不够的问题后面会讲。3. 系统提示词怎么配置决定Agent行为边界的第一道关卡系统提示词是Spring AI项目里最容易被低估的配置项。很多人的写法就是“你是一个有用的助手”六个字然后发现Agent完全不听指挥。在ReAct模式下提示词不再只是“设定人设”它承担着行为协议的作用。模型什么时候该调工具、什么时候该停止、如何判断信息足够——这些全在提示词里约定。3.1 提示词的组成结构我给生产环境写Agent系统提示词时固定分四块角色定义明确说明这个Agent是做什么的、服务对象是谁、说话风格是什么。工具清单列出有哪些工具每个工具是干什么的具体参数是什么。Spring AI虽然会自动把工具信息喂给模型但提示词里再列一遍可以强化模型对工具使用场景的理解。决策规则什么情况下必须调用工具、什么情况下直接回答、什么情况下不能编造数据。终止条件明说“当所有必要信息收集齐全后停止调用工具并给出最终结论”避免模型陷入无意义的反复调用。3.2 一套可直接套用的ReAct System Prompt模板下面这份模板是我实际在用的简化版可以直接贴进defaultSystemString REACT_SYSTEM_PROMPT 你是一名工作区智能代理负责通过调用工具完成任务。 可用工具信息 - add(a: int, b: int)计算两个整数的和 - getWeather(city: string)查询城市天气 - searchDocs(keyword: string)检索知识库文档 工作规则 1. 先分析任务确认现有信息是否足够回答。 2. 信息不足时必须调用工具获取不得编造数据。 3. 每次调用工具后等待观察工具返回结果再继续推理。 4. 当必要信息全部收集完成立即输出最终答案不要再调用额外工具。 5. 如果工具调用失败尝试换一种参数或换一个工具最多重试两次。 最终答案必须引用工具返回的真实数据并标注信息来源。 ;注意第4条和第5条。第4条解决的是“模型答完还顺手调了一堆无关工具”的问题第5条解决的是“工具报错后模型直接放弃”的问题。3.3 提示词与工具描述的配合原则这里有个很多人不知道的细节模型选择工具天然依赖“工具描述”和“提示词描述”的一致性。如果提示词里说“要用getWeather查天气”但Tool注解的description是“获取温度信息”模型就会犹豫甚至不调。我的经验是Tool描述里写清楚功能是什么、适合什么场景、输入参数含义是什么。比如Tool(description 查询指定城市当天实时天气包含温度、天气现象和风力。适合回答关于天气、出行装备、穿衣建议等问题。参数city为城市名称例如北京、上海。) public String getWeather(String city) { return weatherService.getNow(city); }同时在系统提示词的工具清单里保持同样的语义。两边对齐了模型才敢放心用。还有一点动态场景下提示词要拼参数。比如用户身份、当前时间、业务上下文这些不应该写死。Spring AI里可以用PromptTemplate渲染import org.springframework.ai.chat.prompt.PromptTemplate; MapString, Object vars Map.of( userName, userName, toolList, toolList ); String systemText new PromptTemplate(REACT_SYSTEM_PROMPT).render(vars);模板里用{{userName}}、{{toolList}}占位。这样同一个Agent可以服务不同角色和业务流程。4. 核心实现写一个会在思考中调用工具的ReactAgent4.1 工具定义用Tool注解暴露能力Tool注解是Spring AI暴露工具的主要方式。我强烈建议一个工具类只放同一领域的工具方法名要有业务语义参数不要用复杂对象。看一个多工具示例import org.springframework.ai.tool.annotation.Tool; import org.springframework.stereotype.Component; Component public class BusinessTools { Tool(description 根据客户编号查询该客户的订单总额参数customerId为字符串类型的客户编号) public String queryOrderTotal(String customerId) { // 实际场景走数据库或远程接口 return 客户 customerId 订单总额为12800元; } Tool(description 计算订单总额打折后的金额参数total是原金额数字discount是折扣率例如0.8表示八折) public String applyDiscount(String total, String discount) { double t Double.parseDouble(total.replaceAll([^0-9.], )); double d Double.parseDouble(discount); return 折后金额为 (t * d) 元; } }我特意把参数写成String而不是double原因后面讲坑位时会细说。这里先记住一点工具方法签名不够规范模型就传不对参。4.2 ChatClient装配把工具注册进Agent在Spring AI 1.0中注册工具的推荐方式是构建ChatClient时通过defaultTools传入。上面代码里已经出现了这里再展开讲import org.springframework.ai.chat.client.ChatClient; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; Configuration public class AgentConfig { Bean public ChatClient reactAgentChatClient(ChatClient.Builder builder, BusinessTools businessTools) { return builder .defaultSystem(REACT_SYSTEM_PROMPT) .defaultTools(businessTools) .build(); } }defaultTools方法会自动扫描BusinessTools里所有标注了Tool的方法把它们注册成工具回调。一次注册所有对话都能用。如果你需要在某个具体请求里临时追加工具也可以在prompt()时指定String result chatClient.prompt() .system(systemPrompt) .user(task) .tools(queryOrderTotal) // 按工具名称指定 .call() .content();两者的差别是defaultTools是全局默认.tools()是单次请求临时策略。我的建议是全局只注册稳定可用的工具临时工具按请求挂载这样能减少模型选错工具的概率。4.3 完整代码串讲一次多步调用的执行过程我把完整的最小Agent跑通场景写出来大家直接复制就能看效果。假设任务是“查询客户C001的订单总额并按八折计算折后金额”。import org.springframework.ai.chat.client.ChatClient; import org.springframework.stereotype.Service; Service public class ReactAgentService { private final ChatClient chatClient; public ReactAgentService(ChatClient.Builder builder, BusinessTools businessTools) { this.chatClient builder .defaultSystem(REACT_SYSTEM_PROMPT) .defaultTools(businessTools) .build(); } public String execute(String task) { return chatClient.prompt() .user(task) .call() .content(); } }调用String answer agentService.execute(查一下C001客户的订单总额然后按八折帮我算最终金额); System.out.println(answer);正常的执行链路应该是这样的模型阅读任务发现需要订单总额数据输出工具调用请求queryOrderTotal(customerIdC001)框架执行该方法拿到返回值“客户C001订单总额为12800元”框架将这个返回值作为Observation拼接进上下文再次请求模型模型看到总额是12800还需计算折扣于是输出applyDiscount(total12800, discount0.8)框架再次执行拿到“折后金额为10240元”模型综合两步观察结果生成最终答案。整个过程用户只发起一次请求但模型内部实际经历了两轮工具调用。Spring AI的chatClient.prompt().call()会自动处理完这个循环你拿到的content()就是最终答案不会把中间的“思维链”直接吐出来。如果想把中间过程暴露出来做展示Spring AI有ChatResponse相关的API或者你可以配置StreamCallResponse按流式返回这些属于进阶玩法后面可以单独写。5. 工具调用循环的边界控制与防失控设计5.1 为什么必须限制循环次数ReAct循环不是无限转的。模型在几种情况下会失控模型陷入“思考→调用→再思考→再调用”的循环始终不给出最终结论某个工具调用报错后模型反复重试同一个调用任务本身模棱两可模型为了“完整”不停地调用多个工具。我见过最离谱的一次本地小模型在“查询天气并建议穿搭”这种简单任务上连续调了9次工具把能用的工具全点了一遍最后也没给出结论。这本质上是模型决策能力弱加上没有终止机制兜底。Spring AI里有一个DefaultToolCallingManager可以限制单次请求内的最大工具调用轮数。配置方式如下import org.springframework.ai.model.tool.DefaultToolCallingManager; import org.springframework.ai.model.tool.ToolCallingManager; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; Configuration public class ToolCallingLimitConfig { Bean public ToolCallingManager toolCallingManager() { return DefaultToolCallingManager.builder() .maxToolTurnIterations(5) .build(); } }设成5的意思是最多允许5轮“工具调用→观察反馈”循环超过就强制停止由模型基于已有信息给出结论。如果你的模型是强推理模型可以放宽到8如果是本地小模型建议设成3宁可提前收敛也不要让它在循环里烧Token。5.2 工具执行的容错设计工具方法本身要具备容错意识。不要指望模型总是传合法参数。比如查询订单的工具模型可能传空字符串、带单位的价格文本、甚至把两个工具的参数搞混。我常用的防御性写法是Tool(description 计算订单打折金额total为原金额字符串discount为折扣小数如0.8) public String applyDiscount(String total, String discount) { try { double t Double.parseDouble(total.replaceAll([^0-9.], )); double d Double.parseDouble(discount.replace(%, )) / 100.0; return 折后金额为 (t * d) 元; } catch (Exception e) { return 参数解析失败请确认金额和折扣格式; } }这样做的好处是工具不会抛异常而是返回一个可读的错误信息。对ReAct循环来说一次Observing得到“参数解析失败”比得到一个Exception更有利于模型自我纠错。模型看到错误描述可能会重新组织参数再调一次而不是整个链路崩掉。5.3 调试Agent行为观察Thought/Action/Observation链路前面这些设计再完善你还是会遇到“模型就是不按预想走”的时刻。这时候不要急着改提示词先看链路。Spring AI的日志里默认会打工具调用信息。把日志级别调低logging: level: org.springframework.ai: DEBUG你会看到类似这样的输出模型本轮生成的文本、工具调用请求、工具执行结果。沿着这条日志你能定位到三个关键问题模型是否发起了工具调用——如果没有说明提示词或工具描述有问题工具调用参数是否正确——如果不正确多数是工具描述没说清参数格式工具返回结果是否被模型正确使用——如果正确返回了但答案还是错的那就是模型推理能力不行该换模型。我的调试顺序是先看日志确认工具确实被调了再调提示词提示词调两轮没效果再检查工具描述最后才怀疑模型选型。很多人一上来就疯狂改提示词改完发现模型连工具都不调用纯属方向错了。6. 真实场景实测让Agent完成一次“资料收集计算结论”任务前面代码已经能跑了但能不能处理真实任务还得看一次完整的实测。我用一个我们内部经常用来验证Agent能力的例子统计某个知识库文档里提到的城市数量并计算这些城市的总人口用模拟数据。6.1 任务设计定义了三个工具Component public class ReportTools { Tool(description 从知识库检索包含关键词的文档摘要返回文档ID和标题列表。参数keyword为检索关键词。) public String searchDocs(String keyword) { return 找到2篇文档\n[1] 华东市场分析报告提到杭州、苏州、上海\n[2] 华南市场分析报告提到广州、深圳; } Tool(description 查询城市的人口数量参数cityName为城市中文名。只在城市确实存在时返回数据。) public String queryPopulation(String cityName) { MapString, String pop Map.of( 杭州, 1200万, 苏州, 1300万, 上海, 2500万, 广州, 1800万, 深圳, 1700万 ); return pop.getOrDefault(cityName, 未找到 cityName 的人口数据); } Tool(description 计算一系列城市的总人口参数populations为英文逗号分隔的人口数字字符串例如1200,1300) public String sumPopulation(String populations) { double sum Arrays.stream(populations.split(,)) .map(String::trim) .mapToDouble(Double::parseDouble) .sum(); return 总人口为 sum 万; } }注意queryPopulation只返回真实城市数据没有的城市返回“未找到”。这个细节很重要它模拟了一条真实约束工具会拒绝模型编造的输入。6.2 实测到的Agent决策路径任务提示词“检索华东市场报告列出里面出现的城市并求这些城市总人口。”模型的实际决策路径记录如下调用searchDocs(华东市场)拿到文档列表观察结果确定城市是杭州、苏州、上海依次调用queryPopulation(杭州)、queryPopulation(苏州)、queryPopulation(上海)拿到三组人口数字后调用sumPopulation(1200,1300,2500)得到总人口5000万输出结论。整个链路是教科书级的ReAct检索→提取→查询→聚合→回答。没有多余的调用也没有编造数据。但同一天我用同一个系统测试过另一个模型表现完全不一样它没有先检索文档而是直接凭“常识”列出了几个城市然后挨个查询人口最后聚合成一个“看似合理”的结果。这再次印证了我前面说的框架是好的模型会拖后腿。你写再好的提示词模型本身不会遵循也没用。6.3 碰到的坑与调优记录最后把这几天测Agent踩到的坑集中列一下都是网上文档里不太会写的东西。坑一给工具传JSON对象字符串有个经验是工具方法参数越复杂模型越容易传错。我第一次把查询接口设计成接收一个QueryRequest对象包含city、unit、date三个字段结果模型传参时经常缺字段、字段名对不上。后来改成三个独立的String参数成功率立刻上来了。坑二工具描述里写了“如果……请……”反而干扰模型模型对工具描述的理解是线性的。描述越长越容易被无关信息带偏。我试过在description里写“如果用户问天气请调用此工具如果用户问温度也可以调用”结果模型老是犹豫。最后精简成“查询城市当前天气返回温度与天气现象”效果反而好了。坑三循环次数设太大烧钱没配maxToolTurnIterations之前一个简单任务最多能触发七八次工具调用。每次调用都要把历史上下文重新喂给模型Token开销翻倍。限制到4次之后成本降了一半答案质量没明显变化。坑四系统提示词里让模型“逐步思考”反而暴露了内部推理这里要特别提醒ReAct模式下不要在System Prompt里写“请逐步展示你的思考过程”除非你的业务就是要给用户展示步骤。模型会把内部的思考文本直接输出到最终答案里导致回答冗长且不可控。正确做法是思考过程交给框架内部处理最终回答只输出结论和必要的数据来源。我在提示词里加了一句“最终回答保持简洁不要输出内部思考细节”这个问题就解决了。这些坑单独看都算小问题但叠在一起足以让一个Agent从“能用”滑向“不可用”。我个人的体会是调试Agent不能只盯着代码逻辑要把提示词、工具描述、模型能力当成一个整体来调。每次改动只动一个变量改完立刻看日志验证。这套方法虽然朴素但确实是让ReAct Agent稳定落地最有效的方式。你现在手头如果已经有一个能跑通基础对话的Spring AI项目不妨直接按这个思路加上你的业务工具让模型替你完成一次“查数据、算结果、给结论”的完整流程。跑通之后再回来体会“或跃在渊”这四个字——Agent这层窗户纸捅破之后后续面对的就不再是“能不能调用”的问题而是“怎么调得更聪明”的问题了。