ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Spring AI实战:ReactAgent让大模型从对话到动手调用工具

Spring AI实战:ReactAgent让大模型从对话到动手调用工具

从第1掌打到第8掌,手里的Spring AI应用大概已经能从“配置模型+拼提示词”走到“能对话、能流式输出”的地步了。但说实话,这种状态下的AI应用还停留在“带记忆的搜索引擎”水平——用户说一句,模型回一段,信息永远停留在“生成”层面,碰不到真实业务。第9掌的“或跃在渊”其实说的就是这一步:让模型从只负责动嘴,变成能动手干活。这一掌要啃的是Spring AI里最核心的一个概念:ReactAgent,也就是基于ReAct模式构建的工具调用型智能体。

如果你还没接触过这个概念,我换个说法你立刻就能懂:让大模型在回答问题时,可以主动去调用你准备好的Java方法——查数据库、调外部API、做计算——然后把结果消化成最终答案。这件事在Spring AI里叫Tool Calling,在阿里云百炼那边叫“函数调用”,而把它串成一个完整的推理-行动-观察循环,就是你今天要练的ReactAgent。

这篇文章我不会只丢一个Demo给你,我会把我从框架选型、接入百炼、手写工具、编排多工具,到上线踩坑的完整链路拆开讲,每一个“为什么这么做”背后都是我实测过的结论。

1. "或跃在渊"是哪一步:从只会聊天到敢伸手干活

1.1 我理解的ReactAgent和ReAct的本来面目

“或跃在渊”是《易经》乾卦九四的爻辞,讲的是龙在深渊里蓄势,准备往上跃。放在这个系列里,恰好就是Agent这一步的处境:模型本身还很“虚”,但一旦给它工具,它就有了跃出水面的锚点。

ReAct这个词是Reason和Act的拼写——推理加行动。它描述的循环非常简单:

  1. 模型收到用户问题,先在心里想:要回答这个问题,我缺什么信息?
  2. 模型发现缺信息,于是输出一个特殊的“调用工具请求”,里面带上工具名和参数。
  3. Spring AI收到这个请求,去调用你注册的Java方法,拿到结果。
  4. 结果被回填进模型上下文,模型“看到”结果,继续推理。
  5. 如果还缺信息,就重复第2步;如果够了,就输出最终答案。

这个过程不是我的发明,它是ReAct论文里提出的Agent工作模式,Spring AI把它做成了框架级能力,阿里云百炼的通义千问系列模型对工具调用也做了专门优化。你可以在百炼控制台的功能配置里看到“工具调用”的开关,本质上就是给模型开放了一组可执行的函数描述。

我经常用一个类比来解释这件事:ChatClient默认情况下像一个只会侃侃而谈的顾问,你问他一件事,他能基于训练数据给你编一个大概齐的答案,但编的可能是错的;而ReactAgent是你给他配了一个秘书团——能查天气的、能查考勤的、能算费用的——他遇到自己拿不准的事,就不再硬编了,而是转手问秘书团要数据。

1.2 为什么ChatClient的"默认行为"堵在了一道坎上

直接把提示词写上“你可以调用工具”是不会生效的。模型不是海龟汤里那个万能答题者,它需要的是严格的函数签名和描述,并且在模型决定调用后,由你的代码来真正执行。

我见过不少人第一次写Agent时犯这个错:他们在System Prompt里写“如果你需要天气信息,就调用getWeather方法”。但Spring AI里只写提示词是接不上的,因为模型根本不知道getWeather长什么样、参数是什么、返回值是什么。模型需要的是OpenAI/百炼协议里的functions JSON Schema。

Spring AI对这件事的封装方式很优雅:你只需要在类的方法上打一个@Tool注解,写上description,框架就能在运行时自动扫描方法签名,生成模型需要的JSON Schema。模型调用工具时,框架自动反序列化参数,反射调用方法,把返回值写回对话上下文。

这一步的体验转折非常明显——我第8掌写的应用,用户问“今天杭州和上海最高气温差多少度”,模型只能抱歉地说“我无法获取实时天气”。加了@Tool之后,同样的问题,模型会先调两次工具,再输出一句“杭州今天最高31度,上海28度,差3度”。这就是ReactAgent帮你翻过的第一道坎:让模型敢承认自己不知道,也让它有能力去知道。

2. 为什么百炼+Spring AI没有让我纠结太久

2.1 三条路的对比:手写SDK调用、LangChain风格框架、Spring AI

在动手写代码之前,我先花了两天时间做选型对比。选择阿里云百炼作为模型服务这没什么可犹豫的——通义千问的qwen-plus、qwen-max系列模型对工具调用的支持非常稳定,而且百炼平台可以直接买资源包,对国内开发者来说无论是网络延迟、合规还是计费,都比接海外模型端点省心得多。

麻烦的是Java这边的框架。我比较过三条路:

第一条,直接拿百炼的SDK,手写工具调用循环。DashScope的SDK本身支持function calling,但你要自己维护循环——把模型返回的tool_call解析出来,执行方法,拼装结果,再发起下一次请求。代码不难,但多轮工具调用时很啰嗦,而且流式输出、上下文管理、失败重试都要自己写。我搭了个原型,光一个“查完天气再查日历”的双工具流程就写了200多行胶水代码。

第二条,用LangChain风格的Java移植版。这类框架对Agent编排的抽象确实更多,但问题也很现实:社区活跃度一般,很多生态组件年久失修,遇到问题只能自己读源码。对我这种“要用在生产环境”的人来说,风险偏高。

第三条,就是Spring AI。它最大的优势不是功能多,而是它是Spring官方生态的一部分——自动配置、ChatClient链式API、@Tool注解扫描、工具调用回调,全部是框架一等公民。更重要的是,Spring AI官方仓库里直接提供了DashScope的starter,同时阿里云也在com.alibaba.cloud.ai命名空间下维护了Spring AI Alibaba适配包。对已经在用Spring Boot的团队来说,接入成本最低,排查问题也有现成的社区和文档。

我的判断是:如果你的核心诉求是快速稳定地把Agent做进Spring Boot项目里,不想养一套自研Agent框架,Spring AI就是当前最合理的答案。它不花哨,但把Agent最关键的“模型输出→Java方法→上下文回填”这条链路焊得足够结实。

2.2 DashScope接入两种姿势:专用starter与OpenAI兼容端点

百炼平台提供两种接入方式,我都实际跑通过,这里给你做个对比。

第一种是DashScope专用starter。依赖坐标长这样:

<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-dashscope-spring-boot-starter</artifactId> <version>1.1.0</version> </dependency>

如果用的是Spring AI Alibaba的适配包,则是:

<dependency> <groupId>com.alibaba.cloud.ai</groupId> <artifactId>spring-ai-alibaba-starter-dashscope</artifactId> <version>1.0.0</version> </dependency>

专用starter的好处是配置项精简,模型名、API-KEY、base-url都已经帮你映射好了,参数命名也更贴合DashScope的习惯。我用它的时候,application.yml里几乎只需要写三行。

第二种是OpenAI兼容端点。百炼对外提供了一个OpenAI兼容的BaseURL,也就是https://dashscope.aliyuncs.com/compatible-mode/v1。这意味着你可以直接用Spring AI的OpenAI starter,把base-url指过去,模型名选qwen-plus或qwen-max即可。

两种方式的取舍我总结如下:

对比项DashScope专用starterOpenAI兼容端点
依赖复杂度依赖少但按DashScope定制依赖通用OpenAI starter,天然熟悉
配置项精简,fewer surprises有些参数语义不完全一致
版本适配跟随Spring AI Alibaba迭代跟随Spring AI主线,基本都兼容
流式/tool调用支持均支持均支持

我生产环境用的是专用starter,因为它的重试策略和限流回退是针对百炼的响应头做过适配的。你如果只是想快速验证,OpenAI兼容端点可以直接复用你已有的SDK经验,几分钟就能跑通。

3. 接入配置里真正影响成败的细节

3.1 API-KEY放哪、模型怎么选:qwen-plus还是qwen-max

先说API-KEY的管理。百炼平台创建的API-KEY有Access Key和主账号Key之分,建议用子账号的Key,权限只开模型推理相关。代码里一定不要硬编码Key,用环境变量或配置中心:

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

模型选型上我做了好几轮实测。qwen-turbo便宜但tool calling的稳定性差一些,表现在偶尔会把工具参数变成简写、JSON格式不规范;qwen-plus价格适中,工具签名理解准确率已经很高,适合生产环境的主力;qwen-max效果最好,但成本和响应延迟都上去了,我只在复杂推理场景才切到它。

实践建议是:先把模型名配成qwen-plus跑通Agent全链路,然后压测时故意换成qwen-turbo看报错,你就能直观体会到“模型能力下限”对Agent的影响。这对你评估线上模型水位很有帮助。

3.2 超时、重试与上下文长度:配置文件的隐藏参数

Agent场景比普通对话更容易触发超时——因为一次最终回答背后可能隐藏着多轮工具调用,每一轮都是一次完整的大模型请求。默认的HTTP超时在工具调用慢时往往不够用。

我这里给出一份生产环境参考配置:

spring: ai: dashscope: api-key: ${DASHSCOPE_API_KEY} chat: options: model: qwen-plus temperature: 0.5 max-tokens: 4096 client: connect-timeout: 30s read-timeout: 120s

read-timeout我故意调到120秒,因为包含工具调用的Agent请求,最长可能要等待模型把多轮工具结果都消化完。如果你用的是OpenAI兼容端点,注意在options里确认parallelToolCalls的行为,有些版本支持并行工具调用,有些模型一次只调用一个工具,这个会在第5章细说。

还有一个容易被忽略的点:工具调用结果会反复写回上下文。如果工具返回的数据量很大(比如查出来一张几十行的表),多轮之后Token消耗会膨胀得很快。我的做法是在第四、五章详细讲的:工具返回前做裁剪和摘要,而不是把原始结果一股脑塞给模型。

4. 写一个能查天气、能算日期的ReactAgent

4.1 用@Tool把本地方法变成模型的"手"

现在到了最激动人心的部分。我先给你看一个最小可用的Agent代码,它同时具备“查天气”和“计算日期”两个工具。

@Component public class WeatherCalendarTools { @Tool(description = "根据城市名称查询今天的最高气温和最低气温,返回单位为摄氏度") public String getTodayTemp(String city) { // 这里按道理要接真实天气API,我先用模拟数据演示结构 Map<String, String> mock = Map.of( "杭州", "最高31度,最低24度,多云", "上海", "最高29度,最低22度,小雨", "北京", "最高26度,最低18度,晴" ); return mock.getOrDefault(city, "暂无该城市数据"); } @Tool(description = "计算两个日期之间相差多少天,日期格式必须为yyyy-MM-dd,返回数字") public int diffDays(String startDate, String endDate) { DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyy-MM-dd"); LocalDate start = LocalDate.parse(startDate, fmt); LocalDate end = LocalDate.parse(endDate, fmt); return (int) ChronoUnit.DAYS.between(start, end); } }

注意几个关键点:

@Tool注解里的description极其重要。模型不会“看代码”,它只靠这段描述来决定什么时候调用、传什么参数。我实测过,description写得含含糊糊(比如只写“天气查询”),模型在判断“杭州今天适不适合穿短袖”这个问题时就容易犹豫;写清楚“返回单位为摄氏度”,模型就更敢直接拿返回值来推理“热不热”。

方法名本身也尽量语义完整。虽然Spring AI默认会序列化方法名给模型,但一个大写开头的getTodayTemp显然比temp1让模型更容易理解。

工具方法的返回值类型我推荐用String或简单对象,不要直接返回一个复杂的实体对象。模型对长文本JSON的解析能力虽然强,但简短的、结论式的返回值能显著降低后续推理的跑偏率。

4.2 tools()提交之后发生了什么:一次完整调用链

有了工具类,接下来把它绑定到ChatClient并调用:

@Service public class AgentService { private final ChatClient chatClient; public AgentService(ChatClient.Builder builder, WeatherCalendarTools tools) { this.chatClient = builder .defaultSystem("你是一个生活助手,工具信息可以能辅助你回答时就用工具,不要自己编造数据。") .defaultTools(tools) .build(); } public String ask(String question) { return chatClient.prompt() .user(question) .call() .content(); } }

这段代码跑起来后,当你输入“杭州和上海今天温差大吗”,框架内部发生的调用链大致是这样的:

  1. 框架把用户问题和System提示拼好后,附带两个工具的JSON Schema一起发给qwen模型。
  2. 模型返回一个tool_call,内容是“调用getTodayTemp,参数city=杭州”。
  3. Spring AI尝试验证这个调用,如果参数合法,就反射执行你的Java方法。
  4. 得到“最高31度,最低24度”之后,框架把它作为tool result追加进消息列表,再次发给模型。
  5. 模型又发出第二个tool_call:getTodayTemp,参数city=上海。
  6. 执行完毕,再次回填,模型这次拿到两组天气数据,开始做比较推理。
  7. 模型输出最终答案:“杭州31度,上海29度,相差不大。”

看到没有,模型不是一次性把两个城市都查完的,而是“查一个→看结果→再查一个→再比较”这种迭代方式。这正是ReAct循环的直观体现:Thought(该查杭州)→Action(调用工具)→Observation(31度)→Thought(还得查上海)→Action→Observation→Final Answer(对比温差)。

Spring AI把整个循环封装在call()里,同步阻塞直到拿到最终文本。这个设计对业务接入很友好——你感知不到背后转了几次,只要拿到最终结果。但我必须提醒你,这同时也意味着:在工具调用频次高的时候,你的HTTP接口耗时会是“模型多次请求耗时之和”,所以生产环境一定要配合第6章的异步化方案。

5. 多工具编排:让模型自己排工序

5.1 "先查天气再算日子"的实战案例

第4章的工具还只涉及“查一下就回答”的简单场景。真正让ReactAgent展现价值的,是“需要多个工具按顺序配合才能回答”的场景。

我举一个练手时觉得特别适合理解的例子:用户问“我想在5月20日之后一周内挑一天去杭州办户外活动,哪天比较合适?帮我算一下这几天分别是周几”。

这个问题里,模型至少要做这些事:

  • 先算出5月20日之后一周内是哪几天(需要日期工具);
  • 查出这几天的天气(需要天气工具);
  • 根据“户外活动”和“天气”做推理,选出最合适的一天。

想想看,如果模型没有工具,它只能基于训练数据瞎猜“5月22日应该天气不错吧”——这种答案你敢信吗。但有了工具,模型会自己排一个工序链。

由于我们上一章的工具类里getTodayTemp只能查“今天”,不具备“未来某天”查询能力,这里我把工具增强了一下:

@Tool(description = "查询指定城市指定日期的天气预报,日期格式yyyy-MM-dd,返回天气概况") public String getWeatherForecast(String city, String date) { Map<String, String> mock = Map.of( "杭州-05-21", "多云,24-31度,适合户外", "杭州-05-22", "中雨,22-27度,不适合户外", "杭州-05-23", "晴,25-33度,适合户外" ); return mock.getOrDefault(city + "-" + date.substring(8), "暂无数据"); } @Tool(description = "计算某个日期是星期几,日期格式yyyy-MM-dd,返回星期几") public String dayOfWeek(String date) { DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyy-MM-dd"); LocalDate d = LocalDate.parse(date, fmt); return d.getDayOfWeek().getDisplayName(TextStyle.FULL, Locale.CHINESE); }

注意工具描述里我写明了参数格式必须是yyyy-MM-dd,这个不起眼的细节决定了模型能不能“算对关键的一天”。实测中,模型第一次可能给你传2025/05/21,但如果你在description里明确“日期格式必须为yyyy-MM-dd”,qwen-plus基本能正确格式化日期后再调用。

完整案例跑下来,模型的输出大概是这样:

  • 第一次调用dayOfWeek("2025-05-21"),得到“星期三”;
  • 然后枚举性地计算21到27号分别是周几;
  • 再对21、22、23号分别查天气(因为周末才适合活动);
  • 最终把“适合户外、非雨天、气温适中”的日子推荐出来。

你不需要在代码里写任何“先调用A再调用B”的逻辑,模型自己在推理过程中搞定了工序编排。这就是ReactAgent的名字里“React”的含义——先想后做,边做边想。

5.2 并行工具调用与可控执行的取舍

这里要提一个进阶差异:并行工具调用。qwen-plus这类模型在一次响应里有时会同时发出多个tool_call,比如一口气让你查“杭州天气”和“上海天气”。

Spring AI对这种并行请求的处理方式是依次执行回调,再一起回填结果。好处是省掉一轮“逐个反馈再追问”的时间;坏处是当工具之间存在依赖关系(比如第二个工具要用第一个工具的结果)时,并行调用会出错。

我的经验是:在工具设计阶段,尽量让每个工具“自给自足”,不要设计那种“必须先查A才能调B”的强依赖工具。如果确实需要强依赖,宁可把它做成一个组合工具方法,比如calculateBestDate(city, startDate, days),在Java方法内部串起天气和日期两段逻辑,而不是让模型在多个工具之间维持全局状态——模型对依赖关系的理解还是不如代码里写死来得可靠。

另外,如果你发现模型频繁地同时调用太多工具导致上下文混乱,可以显式关闭并行:

spring: ai: dashscope: chat: options: parallel-tool-calls: false

关闭后模型一次只调用一个工具,观察它一步步“自言自语”地完成推理,调试时更清晰,逻辑也更稳。上线后如果对耗时敏感,再把它打开也不迟。

6. 从Demo到生产:那些不踩不算数的坑

6.1 模型不按契约返回时的三层兜底

工具调用最气人的事情就是模型不按你给的JSON Schema返回。我在用qwen-turbo测试时遇到过好几次,模型把描述里的示例当成了真实数据返回,或者干脆把工具名称编错。比如我明明只提供了getWeatherForecast,它却给我返回一个get_weather。

这种场景的三层兜底,是我把线上事故逼出来以后总结的:

第一层,参数校验。在@Tool方法入口做严格校验,不合法直接抛业务异常。Spring AI会把方法抛出的异常作为工具执行结果回填给模型,模型看到“参数格式错误”通常会自动纠正自己的参数。这一层要保证的是异常信息对模型足够友好,减小“诡异报错串”的复现概率。

第二层,模型兜底。如果你的核心链路对工具调用准确率要求高,就不要把模型选到最便宜的那档。qwen-plus和qwen-max的tool calling稳定度差距,在复杂参数场景下非常明显。省下的那几分钱,不够赔你排查一次错误调用的时间。

第三层,人工评审与超时熔断。给ChatClient调用包一层超时控制,超过N秒直接返回降级文案,而不是让请求无限排队。工具是帮你干活的,不是拖垮你接口的。

另外还有个安全细节值得提:工具方法里如果拼接了用户输入(比如拿city参数去请求外部API),要考虑参数注入风险。虽然大模型大概率不会恶意构造,但第二个工具把你带到本地文件读取或内网探测的场景你还是要防一手的——尤其是生产环境里Agent权限比较大的时候。

6.2 上下文膨胀、限流和工具副作用:上线前必查清单

最后分享一份上线前必查的清单,每一条我都实际踩过:

工具结果别原样塞回上下文。工具返回的字符串会被直接追加进对话历史。如果你的工具返回一个几KB的JSON,几轮对话下来上下文可能膨胀到上万Token。我在工具方法里加了一个裁剪工具方法,超过300字符就截断,必要时让模型自己请求完整数据——但这招要谨慎,截断了就查不到了。

限流处理必须做。百炼的API有QPS限制,Agent内部一次请求可能要打模型三次以上。我上线第一天就被429教育了,后来加的指数退避重试才稳住。Spring AI自带的重试策略一般够基础场景,但你需要关注一下提供商的限流头。

日志里要能看到完整的工具调用轨迹。我强烈建议开启ChatClient的观察日志,或者自己在工具方法里打点。线上排查“模型为什么得出这个结论”时,工具返回值和调用次序是最有价值的线索。

异步化是必然选择。Web接口同步等待一个可能触发5次工具调用的Agent请求,用户体验几乎是不可接受的。我最终是把Agent调用丢到线程池或异步队列里,前端通过轮询获取结果,见到的耗时立刻从“卡死感”变成“可接受”。

以上这些坑,不是我看文档看出来的,是亲手把Agent推到生产环境边界之后一点点磨出来的。如果你也能把这套流程完整跑下来,你自己的“或跃在渊”这掌就算练扎实了。

我个人现在的体会是:ReactAgent这个模式真正考验人的不是框架API,而是你能不能把“工具的边界”设计清楚。模型能调的工具越多,它表面上越聪明;但工具描述不清、参数不严、副作用不管,它也会非常稳定地把事情搞砸。这也是我把大量篇幅放在工具设计和踩坑上的原因——框架只是最后那一层纱,真正撑起Agent效果的,是那些能被模型调用的方法本身是否值得信赖。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进