☰
Spring AI + 阿里云DashScope构建生产级ReactAgent:从工具调用到提示词配置全解析
2026/10/6 6:42:45 网站建设 项目流程

Spring AI加上阿里云模型,做一个能自己决定“先查什么、再做什么”的ReactAgent,是我最近几篇文章一直在聊的主线。这个系列写到第9篇,标题借了《易经》乾卦的“或跃在渊”:九四爻那条龙卡在进退之间,往上一步是九五飞龙在天,停下来就会退回深渊。Agent项目做到这个阶段,处境其实差不多——基础接入通了、提示词调过几轮、工具也能被模型调用了,但离“生产可用”还差着一层窗户纸。这篇我打算把这层窗户纸捅破:为什么在Java侧我会选Spring AI做Agent底座,阿里云模型服务怎么用最省事的方式接进来,系统提示词到底怎么配才不拖后腿,以及工具调用中那些只有踩过坑才会懂的细节。适合已经跑通第一个Agent、正准备让它干点实事的Java开发同学。

1. 为什么是“或跃在渊”:ReactAgent的运行机制与选型思考

1.1 ReAct不是新概念,是“把思考变成步骤”

ReAct这个叫法来自Reasoning与Acting的组合,核心思想一句话:让模型别急着给答案,先拆任务,再调工具,拿到结果后继续思考,直到有足够依据才输出结论。我不是第一次提这个概念,但在这个系列里它值得重新讲一遍,因为这个模式决定了整个系统的行为方式。

拿一个实际场景举例。用户问:“刚才那个订单12345发没发货?如果发了就通知运营群。”如果是普通问答模型,它大概率会基于训练数据里的相似订单瞎编一个状态,因为模型本身不连接你的业务库。而ReactAgent会把这件事拆成两步:先调用订单查询工具拿到orderId对应的真实状态,发现是已发货后,再调用通知工具往群组里发一条消息,最后回给用户一句“已核实并完成通知”。整个过程里,模型每一步都在“思考接下来该做什么”,而不是凭记忆写答案。

我自己的理解是,ReAct本质上把“决策权”交给了模型,但把“手”限制在你能控制的工具集合里。它比传统的if-else规则引擎灵活得多,因为你不用提前枚举所有可能的用户表达,模型自己会去组合工具。但它也不是没有代价——模型可能选错工具、传错参数、或者在一个错误结果上不停打转,这些都需要靠提示词约束、工具设计和异常返回来兜底。所以“或跃在渊”这个阶段,玩的就是边界设计。

1.2 为什么我选择Spring AI而不是裸写HTTP

很多同学第一次接大模型时都会走一条路:用RestTemplate对着模型接口手写鉴权、拼Prompt、解析响应。说实话,只做一次单轮问答,这种方式并不差,甚至比引框架更轻。但一旦进入ReactAgent的Tool Calling流程,手写HTTP的痛苦指数会直线上升。

我踩过的坑可以列一串:tools参数要维护一套完整的JSON Schema,模型返回的tool_calls和消息历史必须保持严格的顺序,工具执行完要把结果以“工具角色”塞回对话里,多轮下来消息列表越来越长,序列化稍微一乱模型就“失忆”。这些逻辑不是不能写,而是写完后基本变成一坨只有自己能看懂的业务代码,换一个模型厂商又要重新适配。

Spring AI在这里做的事情是把“模型无关”的抽象层做出来了。你只需要在Spring Bean里写一个带@Tool注解的方法,框架会通过反射自动生成工具描述,组装多轮消息,解析模型返回的工具调用请求,甚至帮你维护会话历史。我在生产环境里对比过三种接法:裸HTTP适合快速验证,厂商SDK适合深度绑定某一家,能让我用最少的胶水代码把通义、DeepSeek、甚至私有化模型来回切换的,反而是Spring AI这类抽象层。真到了Agent要接入多个内部系统的阶段,会发现这个切换能力非常值钱。

1.3 阿里云DashScope在选型中的关键角色

阿里云在这场选型里的位置很特殊。国内能稳定跑通工具调用的模型服务,DashScope是绕不开的一个。我选择它的理由有三个。

第一,DashScope提供了OpenAI兼容的接口。这意味着我在Spring AI里只需要改一下base-url和api-key,就能把本来为OpenAI协议设计的客户端指向通义模型,不用引入额外的SDK方言。第二,模型的性价比梯度清晰:qwen-turbo便宜,适合批量日志分析和意图初筛;qwen-plus在工具调用上表现稳,我做大多数Agent场景都用它;qwen-max逻辑更强,适合一次要串四五个工具才能完成的复杂任务,但成本也高。第三,阿里云在认证、日志、限流这些基础设施上做得比较完整,Agent要上生产的阶段,这些能力比模型本身更救命。

我把三种接入方式的取舍放在一起对比过,列成表格会直观一些:

接法上手成本工具调用支持换模型成本适合阶段
裸HTTP低自己从头写高验证想法
厂商SDK中较全,但绑定厂商协议中单一模型深度使用
Spring AI抽象层中框架内统一处理低Agent多工具、多模型

2. 环境准备与依赖拉取:三座大山的翻越

2.1 JDK 17和Maven版本组合

这一章的标题有点夸张,但坦白说,从零把一个Agent工程搭起来,环境问题往往比代码更花时间。先说基础版本:JDK建议直接用17,因为Spring Boot 3和Spring AI的当前版本对JDK 8已经不太友好了。如果你还在用JDK 8,不是完全不能跑,但你可能会在依赖兼容性上花掉大量时间,不值得。

Maven建议3.9以上,Maven本身没有太多版本坑,但要注意IDEA里内置的Maven版本可能比较旧,最好手动配一下。构建工具用Gradle也行,但这个系列一直用Maven,下面所有配置我都按Maven写。

另外,如果你的团队有统一的代码规范,顺手把Maven的编码、编译参数固定住,避免同事机器上因为默认编码不一样导致乱码或者编译告警。这些细节和Agent本身无关,但会直接影响协作效率。

2.2 Maven阿里云仓库配置,让依赖下载不再卡死

国内拉依赖的老问题:中央仓库时快时慢,Spring AI的构件又多,等起来非常折磨人。解决办法很成熟,用阿里云的Maven公共仓库镜像,在全局settings.xml里加一段mirror配置就行。

<settings> <mirrors> <mirror> <id>aliyun</id> <name>Aliyun Public Mirror</name> <url>https://maven.aliyun.com/repository/public</url> <mirrorOf>*</mirrorOf> </mirror> </mirrors> </settings>

注意mirrorOf写了*,意思是所有仓库请求都走阿里云公共仓库。一般业务项目这样没问题,因为阿里云公共仓库同步了绝大多数中央仓库构件。遇到个别冷门构件拉不到时,再在pom.xml里单独补充release仓库,不要全局乱加。

还有一个容易忽略的点:Spring AI的里程碑版本和snapshot版本不在公共仓库里。如果用了里程碑版本,需要在pom.xml里显式加上Spring的里程碑仓库;如果你不想折腾,直接用发布版,省心很多。这块我在下一节展开。

2.3 Spring AI版本选择与BOM管理

Spring AI目前已经进入了1.x稳定阶段,但它的迭代速度仍然比普通Spring生态快,API会有微调。我的建议是:不要追最新,选一个你自己验证过的版本锁死。下面演示用的版本是1.0.0,配Spring Boot 3.4.x。

<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.4.1</version> <relativePath/> </parent> <properties> <java.version>17</java.version> <spring-ai.version>1.0.0</spring-ai.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-openai-spring-boot-starter</artifactId> </dependency> </dependencies> <dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-bom</artifactId> <version>${spring-ai.version}</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement>

选用spring-ai-openai-spring-boot-starter是因为DashScope的OpenAI兼容接口可以直接对接,不需要再加一套阿里专用starter。依赖拉完以后,看一眼IDEA右侧的Maven面板,如果spring-ai-core这些构件都顺利出现,环境这块就算过了。后续如果出现依赖下载失败,优先检查settings.xml是否生效、镜像仓库是否配置正确,再去考虑代码问题。

3. 系统提示词配置是Agent的“人设天花板”

3.1 提示词直接决定能不能“跃”起来

这个系列里我反复强调一句话:在Agent项目里,系统提示词不是“写一段说明文字”,而是“定义一套行为协议”。模型能不能正确调用工具、调用失败后能不能自救、最终回答会不会编数据,很大程度都取决于系统提示词怎么写。

刚接触Agent时,我犯过两个极端错误。第一次是几乎不写系统提示词,只给模型一堆工具,结果它经常基于常识编造订单状态;第二次是写了一篇两千字的角色设定,把语气、风格、企业文化全塞进去,结果模型反而忽略工具调用,光顾着扮演“创意文案大师”了。后来我总结出一个可复用的公式:系统提示词 = 身份约束 + 工作流程 + 工具边界 + 输出格式。四个部分都只做减法,不写废话。

在技术实现上,Spring AI对系统提示词没有特殊限制,你可以在每次请求时通过prompt().system()传入,也可以做成外部资源文件统一加载。但生产环境里,我强烈建议把它从代码里拆出来,因为提示词的修改频率远高于代码发布频率,放资源文件里才能快速热更新,也好让业务方同学直接review。

3.2 配置与加载方式的三种实践

先看第一种,最简单直接的方式,写在业务代码里:

ChatClient chatClient = builder.build(); String response = chatClient.prompt() .system("你是订单处理助手,必须基于工具返回结果作答,禁止编造数据。") .user("查一下订单12345") .call() .content();

这种方式适合本地调试,但不适合Agent项目,因为提示词一长,Java字符串的转义和拼接会很难维护。第二种方式是把提示词放到classpath资源文件里,通过Resource加载,这是我在项目里最常用的方式:

@Bean ChatClient chatClient(ChatClient.Builder builder) { return builder .defaultSystem(new ClassPathResource("prompts/react-agent.md")) .build(); }

第三种方式是把提示词模板放在配置中心或数据库里,配合Spring AI的PromptTemplate做变量渲染。比如某个Agent的提示词里需要动态注入用户角色、当前日期、可用工具列表,就可以用模板变量:

String systemPrompt = promptTemplate.render( Map.of("currentDate", LocalDate.now().toString()) );

三种方式不是互斥的。官方推荐的思路是:固定部分放资源文件,动态部分用模板变量,需要频繁调整的业务规则放配置中心。你可以根据团队情况组合使用。

3.3 我常用的ReAct人设模板

下面这个模板是从我自己项目里简化出来的,可以直接抄。它的特点是结构清晰、约束明确,尤其强调了“工具返回优先”和“失败重试边界”。

你是订单处理助手,运行在 ReAct 模式下。 工作流程: 1. 先理解用户请求,拆解为需要执行的子任务。 2. 判断哪些子任务可以通过工具完成,哪些需要你推理。 3. 调用工具时,参数必须严格按照工具描述填写,禁止省略必填项。 4. 工具返回结果后,基于结果继续推进;如果结果缺失,尝试换参数重试,最多3次。 5. 全部任务完成后,用自然语言给用户一个简洁结论。 可用工具: - queryOrderById:根据订单ID查询订单状态和明细。 - sendNotify:发送文本消息到指定群组。 - sendSms:通过阿里云短信发送通知。 行为边界: - 订单状态、金额、库存等数据必须来自工具返回,严禁根据常识编造。 - 如果工具调用全部失败,明确告知用户当前无法完成,不编造替代结果。 - 对于“已发货”这类状态判断,以工具返回的status字段为准,不要自行推断。

这个模板看起来不长,但它把三件最重要的事说清楚了:先想再动手、数据必须来自工具、失败时不许编造。在实际测试里,加了这几条之后,我们项目的模型编造率明显下降。

4. 核心实现:让模型真正“动手干活”

4.1 用@Tool注解暴露业务能力

Spring AI的Tool Calling机制,类比一下就是:你把业务系统里的能力包装成一个一个“按钮”,模型根据用户请求决定按哪个按钮,它不需要理解按钮背后的代码。实现方式就是在普通的Spring Bean方法上加上@Tool注解。

@Component public class OrderAgentTools { private static final Logger log = LoggerFactory.getLogger(OrderAgentTools.class); @Tool(name = "queryOrderById", description = "根据订单ID查询订单状态和明细") public String queryOrderById( @ToolParam(required = true, description = "订单ID,例如 12345") String orderId) { log.info("查询订单, orderId={}", orderId); // 真实场景这里会走 RDS 或内部服务,这里用固定结果演示 if ("12345".equals(orderId)) { return "{\"orderId\":\"12345\",\"status\":\"SHIPPED\",\"items\":[\"手机\",\"充电器\"],\"totalAmount\":4999.00}"; } return "{\"orderId\":\"" + orderId + "\",\"status\":\"NOT_FOUND\"}"; } @Tool(name = "sendNotify", description = "发送文本消息到指定群组") public String sendNotify( @ToolParam(required = true, description = "群组名称,例如 operations") String targetGroup, @ToolParam(required = true, description = "消息内容") String content) { // 这里可以接钉钉、企业微信等 webhook,先打印模拟 log.info("发送通知到 {}: {}", targetGroup, content); return "{\"result\":\"SUCCESS\"}"; } }

这里有两个细节值得专门说。第一,方法返回值尽量用String,并且内部返回规范化的JSON字符串,因为LLM接收到的本质是文本,结构一致的JSON比Java对象的toString容易解析得多。第二,@ToolParam里的description要写得像给实习生的操作说明,模型是根据描述来填参数的,你写得太模糊,它就会传错值。

4.2 接入DashScope并配置ChatClient

配置方法很简单,因为是OpenAI兼容协议,只需要在application.yml里把base-url和api-key指到DashScope即可。注意api-key不要硬编码在代码里,用环境变量注入。

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

然后是ChatClient的封装。这里要把系统提示词和工具类一起挂上默认配置,之后所有通过这个Client发起的请求都会自动携带。

@Configuration public class AgentConfig { @Bean ChatClient chatClient(ChatClient.Builder builder) { return builder .defaultSystem(new ClassPathResource("prompts/react-agent.md")) .defaultTools(OrderAgentTools.class) .build(); } }

注意defaultTools传的是Class,不是new出来的实例。这样Spring容器会管理工具类的依赖注入,比如你的工具方法里需要注入订单Service或者短信Client,都能正常工作。

4.3 一个完整的订单通知Agent闭环

现在把所有东西串起来。需求:用户用自然语言说“查一下订单12345,如果已发货就通知运营群”。完整的最小闭环如下。

@Service public class AgentService { private final ChatClient chatClient; public AgentService(ChatClient chatClient) { this.chatClient = chatClient; } public String handle(String userMessage) { return chatClient.prompt() .user(userMessage) .call() .content(); } }

Controller层就不重复写了,就是一个普通的POST接口。真正值得看的是模型内部的实际执行轨迹。我在日志里抓过几次,完整的调用链大致是这样:

user: 查一下订单12345,如果已发货就通知运营群 模型思考: 需要先查询订单状态 工具调用: queryOrderById({"orderId":"12345"}) 工具返回: {"orderId":"12345","status":"SHIPPED","items":["手机","充电器"],"totalAmount":4999.00} 模型思考: 订单已发货,符合通知条件,需要发送通知给运营群 工具调用: sendNotify({"targetGroup":"operations","content":"订单12345已发货,金额4999元,包含手机和充电器"}) 工具返回: {"result":"SUCCESS"} 最终回复: 已核实订单12345状态为已发货,并且已经通知运营群。

这个闭环看起来不复杂,但它验证了一个关键能力:模型不是只调用一次工具就结束,而是根据前一次工具返回的结果,自主决定是否进入下一步。我们的Agent能处理这种条件分支,生产价值就出来了。

在生产环境,我还会给ChatClient的请求设置几个参数:温度调到0.2左右,让模型输出更稳定;超时时间放到60秒以上,因为Agent链路过长时模型响应和工具执行都会变慢;另外在工具方法里加上审计日志,记录谁在什么时候调用了什么工具、参数是什么。Agent一旦出错,这些日志是唯一能回放问题的手段。

5. 常见问题与排查技巧实录

5.1 工具方法抛出异常后模型“原地打转”

这是我在ReactAgent里遇到的第一个大坑。工具方法内部如果直接throw RuntimeException,模型拿到的错误信息往往是一大段堆栈,它看完根本不知道下一步该怎么办,于是就会反复调用同一个工具,试图“碰运气”通过,结果每次都拿到同样的异常。

解决办法一句话:工具方法别抛异常,把错误转成结构化反馈返回给模型。

@Tool(name = "queryOrderById", description = "根据订单ID查询订单状态和明细") public String queryOrderById(@ToolParam(required = true, description = "订单ID") String orderId) { try { // 查询业务数据 return "{\"orderId\":\"" + orderId + "\",\"status\":\"SHIPPED\"}"; } catch (Exception e) { return "{\"error\":\"订单查询失败\",\"reason\":\"数据库连接超时\",\"suggest\":\"请稍后重试或检查订单ID格式\"}"; } }

这个返回里的suggest字段很关键,它相当于给模型递了一根救命稻草,模型看到后可能会换一种参数或换一个策略,而不是原地死循环。

5.2 阿里云SDK鉴权失败,别急着怀疑参数

我在项目接入DashScope时碰到过401,当时第一反应是api-key配置错了,检查了半天才发现是环境变量名冲突。很多人电脑上配置过OPENAI_API_KEY,如果你的应用读取的是这个变量,而实际key是DashScope的,就会鉴权失败。建议统一用DASHSCOPE_API_KEY命名,并且启动前确认环境变量真的生效。

还有一个容易被忽略的点:DashScope控制台里创建API-KEY之前,需要先开通百炼服务。没有开通,控制台能创建key,但实际调用时会返回403。这个顺序问题我不会踩第二次,每次都写进部署文档里。

如果排查时想绕开代码,直接用curl打兼容端点最干净:

curl -X POST https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions \ -H "Authorization: Bearer $DASHSCOPE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"model":"qwen-plus","messages":[{"role":"user","content":"你好"}]}'

这条通了,说明key和网络都没问题,问题就在代码配置上。

5.3 工具返回两万行JSON,模型根本吞不下

有同学问过“JSON parse成对象有两万行扛得住吗”这类问题,其实真正常踩的坑是:把两万行JSON直接塞给模型。模型的上下文窗口再大,也不该用来读这条数据。比如一个盘点工具返回了全量库存明细,几万条记录,模型光读系统提示词和工具返回就把上下文耗尽了,后续思考质量急剧下降。

正确做法是,工具层先做聚合和摘要,只把模型需要的关键结论返回。

return "{" + "\"totalCount\": 9210," + "\"lowStockItems\": [" + "{\"sku\":\"A1001\",\"stock\":2,\"level\":\"WARN\"}," + "{\"sku\":\"A1002\",\"stock\":0,\"level\":\"EMPTY\"}" + "]," + "\"summary\":\"共有2个SKU库存异常,建议立即补货\"" + "}";

模型不需要知道每个SKU的实时库存,它只需要知道异常项和结论。这条经验适合所有Agent工具设计:工具返回的粒度,应该服务于“让模型做出正确决策”这个目的,而不是服务于数据完整性。

5.4 短信API直通但Agent一调就失败

这个场景也很典型:单独写个接口调用阿里云短信服务,短信能正常发出去;但通过Agent工具调用,要么超时,要么一直失败。我排查后发现两个原因。

第一个原因是工具方法同步等待短信回执,而短信服务在高峰期的最终回执可能几秒甚至几十秒才返回,超过了模型调用工具的超时阈值。解决办法是发送端改成异步受理:只要短信平台返回“受理成功”,工具就立刻返回成功,最终送达状态通过回调或异步任务跟踪。

第二个原因是多线程并发下,工具方法里每次new短信Client,导致连接池被耗光。解决办法是把短信Client作为单例Bean注入,复用连接池。这两个问题单独看都不难,但叠在一起,就成了“Agent那边总超时”的玄学。我把这段写出来,是为了提醒你遇到Agent调外部服务失败时,先按“超时、并发、连接池”这个顺序排查,而不是怀疑模型。

5.5 常见问题速查表

现象常见原因处理办法
模型调用工具时参数总是缺工具描述没说明字段含义补全@ToolParam的description,写清示例
模型反复调用同一工具工具返回异常文本不友好捕获异常,返回结构化错误并给出suggest
401 Unauthorizedapi-key错误或未开通服务用curl直连验证,确认环境变量
Agent一调外部API就超时同步等待回执,连接池耗尽异步受理,复用Client单例
上下文越来越长后回答变差工具返回大JSON,塞满上下文工具层做摘要,只返回关键结论
提示词不生效默认system被后续覆盖检查是否同时使用defaultSystem和prompt().system

收尾:这一掌打完,聊聊我自己的一点体会

这个系列写到“或跃在渊”,我自己最大的感受是:Spring AI确实省掉了大量对接上的重复劳动,但Agent能不能真正“跃”起来,决定权从来不在框架,而在你对边界的设计。给模型的不是工具越多越好,而是把工具描述写到能让一个实习生看懂,把返回格式规范到一台机器能稳定解析,把所有异常都转化成模型能继续思考的反馈。我在实际项目里最后做的两件事:一是给Agent加了一层完整的操作审计,二是给“发消息”“下订单”这类敏感工具加了人工审批开关。如果你也正处在把自己的Agent推向生产的阶段,先把这两个动作做掉,后面的路会稳很多。这个系列的后半段我还会继续聊Agent的可观测性和多Agent协作,欢迎一起交流。

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

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

立即咨询