“或跃在渊”这句话,放到 Agent 开发这事儿上,莫名贴切。九四爻辞讲龙进退有度、伺机而动,这不就是 ReAct 模式下大模型该有的状态——先想清楚,再动手,观察结果,继续想。我这是在“降SpringAI阿里”系列第九篇里,借着 ReActAgent 这个项目,把一套真正能落地的智能体搭建思路完整捋一遍。如果你正在用 Java 做 AI 应用,想搞懂 Spring AI Alibaba 里的 Agent 到底怎么用、ReAct 模式怎么从概念变成代码,这篇就是给你准备的。
1. 内容整体设计与思路拆解
1.1 从“ReAct”这个名字说起:模型不是机器,是带脑子的执行者
很多朋友第一次看到“ReactAgent”会误以为是 React 前端框架相关的机器人,实际上这里指的是 ReAct 模式下的 Agent。ReAct 是 Reasoning + Acting 的缩写,核心想法很简单:让大模型在解决问题的过程中,交替进行“推理”和“行动”。
想象一个场景:你请助理帮忙安排一场跨城出差,助理不会不说一句话直接下单订票,而是先分析——你要去哪个城市、什么时间出发、预算多少、有没有偏好航班——然后决定第一步先查航班信息,拿到结果后再决定第二步订哪一班,最后还要跟你确认。ReAct 模式下的 Agent 就是这样一个助理,大模型是那个“大脑”,负责分析、思考、决策;工具调用是那个“手”,负责查天气、查数据库、调接口、写文件。
在结构化实现上,这个循环是:
- 接收用户请求,生成初始推理。
- 模型判断是否需要调用工具;如果不需要,直接输出最终答案。
- 如果需要,从模型返回的函数调用请求中解析出工具名和参数。
- 执行对应工具,拿到结构化结果。
- 将工具结果反馈给模型,让模型结合结果继续推理。
这个循环会一直持续到模型给出最终答复,或者达到最大轮数限制为止。
1.2 为什么Spring AI Alibaba适合做这件事
Spring AI Alibaba 是阿里云基于 Spring AI 规范做的适配层,最大的价值在于:你只需要写很少的配置和代码,就能把通义千问系列模型接入 Spring 应用,并且用上 Spring AI 标准化的 ChatClient API。和裸调模型 API 相比,它解决了三个核心痛点。
首先是依赖管理。框架内部封装了模型服务接入、错误重试、流式响应、工具描述生成等底层逻辑,不需要你自己处理 JSON Schema 的拼接和转换,也不用关心 API 签名细节。
其次是Spring 生态融合。@Tool 注解可以直接把 Spring Bean 的方法暴露成工具函数,服务类可以注入其他 Bean,天然支持依赖注入、配置管理、日志链路这些成熟机制。这意味着你可以在 Agent 里直接调用已有的业务服务,比如订单查询、库存判断、用户画像模块,Agent 成为业务系统的“对话式网关”。
最后是可扩展性。DashScope 模型里对 function calling 的支持比较成熟,Spring AI Alibaba 把这种能力抽象成统一的 Tool Calling 机制,后续想换其他模型或者加新工具,改动成本都很小。
这也是我在系列文章里逐步推进到这一篇的原因。前面的内容更多是讲模型接入和提示词工程,到了 ReactAgent 这一掌,相当于从“能对话”跨进了“能干活”的阶段。别小看这一步,它决定了你的 AI 应用是聊天玩具,还是真正能解决业务问题的助手。
2. 核心细节解析与实操要点
2.1 Agent 运行时的三大核心组件
要搭建一个可用的 ReactAgent,你需要先理清三个组件各自扮演的角色。它们组合在一起,才构成完整的 Agent 运行链路。
ChatClient:这是 Spring AI Alibaba 中跟模型打交道的一等公民入口。所有提示词组装、工具注册、调用模式(同步/流式)都是围绕它来配置的。你可以把 ChatClient 理解为“请求分发器”,每个 Agent 请求都会经过它,带着系统提示词和用户消息去问模型。
@Tool 注解与工具注册器:工具是 Agent 的“行动清单”。通过 @Tool 注解标注方法后,Spring AI Alibaba 会在应用启动时扫描这些 Bean,自动生成大模型可识别的函数描述 JSON,并在每次请求时把这些描述注入到系统消息中。模型通过阅读描述,决定是否调用、调用哪个工具、传什么参数。
ChatMemory:ReAct 循环中,模型需要感知“刚才已经查到了什么,现在还需要什么”。ChatMemory 就是把每一轮推理结果、工具返回结果按顺序存起来,在下一轮请求时一并发给模型。没有它,模型每次都是“失忆”状态,无法完成多轮推理任务。
这三者的关系可以这样理解:ChatClient 是通道,工具是能力,ChatMemory 是记忆。缺了任何一环,ReActAgent 都跑不起来。
2.2 ReAct 循环中的消息组装策略
实现 ReAct Agent 时,最容易出问题的地方往往不是模型本身,而是消息上下文的组装方式。Spring AI Alibaba 在底层帮你处理了大部分消息转换,但你仍然需要理解“当前这一轮模型看到的到底是什么”。
ReAct 的一个标准循环,在消息层面大致是这样展开的:
- 系统消息:包含角色定义、任务规则、工具描述。
- 用户消息:初始问题。
- 模型返回内容:模型输出的推理文本,或者一段 function call 请求。
- 工具消息:工具执行结果,通常以 ToolResponseMessage 的形式追加到上下文中。
- 模型再次返回:基于工具结果继续推理或输出最终答案。
关键点在于:每一轮模型请求,发送给模型的消息列表必须是累积的完整对话历程,而不是只发最后一轮的内容。如果只把最新工具结果发给模型,模型会丢失之前的推理链路,很容易出现“明明刚查过北京天气又问北京天气”的诡异行为。
我在实际项目中会维护一个 ArrayList 作为对话窗口,每一轮交互之后把模型输出、工具调用记录、工具结果都 add 进去。这样做还有个额外好处:方便审计。整个 Agent 的推理过程一目了然,出了问题可以直接回溯。
2.3 工具描述与参数设计的门道
工具描述写得好不好,直接决定模型“会不会用、用得对不对”。有句话说得很真实:给模型的工具描述,就像面试官面前的简历描述,写得含糊,面试官(模型)就会瞎猜。
我总结了几条比较实用的经验:
- 工具名用动词短语:比如
query_weather比weather更清晰,calculate_delivery_fee比fee更明确。 - 描述里写清“什么时候该用”:不要只写“查询天气”,而要写“当用户询问某城市的天气情况时使用,参数需要完整城市名称”。
- 参数说明要带约束:比如“城市名称必须是中文全称,如
北京,不能用缩写BJ”。模型在解析参数时,会优先遵循这类显式约束。 - 返回值格式要尽量结构化:无论是 JSON 还是纯文本,要让模型容易从结果里提取关键信息。比如返回
{"city":"北京","temp":25,"condition":"晴"},模型能直接引用这些字段生成答案;如果返回一坨无规律文本,模型往往要猜。
3. 实操过程与核心环节实现
3.1 项目初始化与依赖配置
这一步我会直接给出可复制的配置方式。假设你用的是 Spring Boot 3.2.x 或者更高版本,JDK 17+,Maven 构建。在 pom.xml 里加入:
<dependency> <groupId>com.alibaba.cloud.ai</groupId> <artifactId>spring-ai-alibaba-starter</artifactId> <version>1.0.0</version> </dependency>然后在 application.yml 中配置模型接入参数:
spring: application: name: react-agent-demo ai: dashscope: api-key: ${DASHSCOPE_API_KEY} chat: options: model: qwen-plus temperature: 0.7这里有个细节值得提一下:api-key 不要写在配置文件里,用环境变量占位符,避免密钥泄露。qwen-plus 在 function calling 方面表现稳定,如果你预算有限,qwen-turbo 也可以,但复杂推理任务建议还是用 qwen-plus。
3.2 定义一个真正干活的 Agent Service
下面是一个完整的 Agent 服务类,结合了工具注册和 ReAct 循环处理。为了简洁,我抽掉了具体业务实现,保留核心链路。
@Service public class ReactAgentService { private final ChatClient chatClient; public ReactAgentService(ChatClient.Builder builder) { this.chatClient = builder .defaultSystem(""" 你是一个乐于助人的智能助手,能够调用工具完成用户请求。 请严格按照以下步骤思考: Thought: 分析用户需求,判断是否需要调用工具。 Action: 如果需要,使用工具获取信息;每次只调用一个工具。 Observation: 观察工具结果。 ...重复以上步骤... 当你确定已有足够信息回答用户时,直接输出最终答案。 """) .build(); } public String execute(String userMessage) { // 维护完整上下文 List<Message> messages = new ArrayList<>(); messages.add(new UserMessage(userMessage)); int maxIterations = 5; for (int i = 0; i < maxIterations; i++) { ChatResponse response = chatClient.prompt() .messages(messages) .tools(new Object[] { new OrderTool(), new WeatherTool() }) .call(); String content = response.getResult().getOutput().getText(); // 如果模型直接输出了答案 if (content != null && !content.isBlank()) { return content; } // 获取模型请求的工具调用 List<FunctionCallingToolResponse> toolCalls = response.getResult().getOutput().getToolCalls(); if (toolCalls.isEmpty()) { // 模型既没有输出文本,也没有请求工具,说明异常,直接返回提示 return "抱歉,我暂时无法处理这个请求。"; } for (FunctionCallingToolResponse toolCall : toolCalls) { String toolName = toolCall.name(); String arguments = toolCall.arguments(); // 调用对应工具并构造 ToolResponseMessage String result = invokeTool(toolName, arguments); messages.add(new ToolResponseMessage(toolName, result)); } } return "经过多轮尝试后仍未得到结果,请调整问题描述后重试。"; } private String invokeTool(String toolName, String arguments) { // 这里根据 toolName 分发到具体的工具 Bean 方法 return "工具调用结果"; } }这段代码演示了两个核心点:最大迭代限制(避免模型陷进死循环)和手动消息累积(每一轮工具结果都追加到上下文)。你别小看这两点,缺了任何一个,Agent 都可能变成“脱缰野马”。
Spring AI Alibaba 新版本里,@Tool 方法可以直接注入 Spring Bean,所以我更推荐把工具也做成品类清晰、职责单一的服务类,然后在 execute 里通过 ApplicationContext 动态获取。这样每个工具的逻辑都独立,测试起来也方便。
3.3 工具定义实战:从查询天气到调用业务接口
用一个常见场景来演示工具定义。假设你的 Agent 需要给用户提供“目的地天气 + 穿衣建议”,你就要注册一个天气工具。
@Component public class WeatherTool { @Tool(name = "query_weather", description = "查询指定城市的实时天气情况,当用户询问某地天气、温度、是否适合出行时使用") public String queryWeather( @ToolParam(description = "城市中文全称,例如:杭州") String city) { // 调用第三方天气接口或者本地数据源 WeatherInfo info = weatherApi.getByCity(city); return "城市:" + city + ",天气:" + info.getCondition() + ",温度:" + info.getTemperature() + "℃"; } }实际生产环境中,工具不一定只是查天气。我在一个订单场景里,Agent 需要调用订单系统的查询接口来判断用户订单状态,这时候工具的返回值应该直接封装成 JSON 字符串返回,方便模型提取。
@Tool(name = "query_order_status", description = "查询用户的订单配送状态,当用户询问订单进度、物流信息时使用") public String queryOrderStatus(@ToolParam(description = "订单编号") String orderId) { OrderDO order = orderService.getByOrderId(orderId); return new ObjectMapper().writeValueAsString(Map.of( "orderId", order.getOrderId(), "status", order.getStatus(), "expectedArrival", order.getExpectedArrival() )); }有一点要切记:工具方法内部不要做太复杂的业务逻辑,最好只做“数据获取”这一件事。模型需要的,是清晰、可信、能被再次解读的结果;如果你在工具里塞了十几层判断,最终返回一句模棱两可的提示,模型反而不知道该信什么。
3.4 配置多轮对话记忆与流式输出
ReAct Agent 如果只做单轮问答,很多价值发挥不出来。生产环境中,用户往往会追问:“杭州天气怎么样?那上海呢?”这种场景下,Agent 需要记住上一轮讨论的城市。
Spring AI Alibaba 提供了 ChatMemory 接口,可以直接基于内存或者 Redis 来存储历史会话。最简单的接入方式:
@Service public class ChatAgentService { private final ChatClient chatClient; private final ChatMemory chatMemory; public ChatAgentService(ChatClient.Builder builder, ChatMemory chatMemory) { this.chatClient = builder.build(); this.chatMemory = chatMemory; } public String chat(String sessionId, String userMessage) { // 获取或创建会话 ChatMemory memory = this.chatMemory; memory.add(sessionId, List.of(new UserMessage(userMessage))); String response = chatClient.prompt() .chatMemory(memory, sessionId) .tools(...) .call() .getResult().getOutput().getText(); memory.add(sessionId, List.of(new AssistantMessage(response))); return response; } }流式输出也非常推荐,特别是生成较长回复时,用户不用盯着转圈等全部生成完。Spring AI Alibaba 里用.stream()返回 Flux:
Flux<String> flux = chatClient.prompt() .user(userMessage) .stream() .content();把流式输出跟 ReAct 循环结合时,有个技巧:流式输出一般用于最终答案阶段。工具调用过程建议还是用同步模式一步一步拿结果,等确认 Agent 已经准备输出最终答复时,再切到流式给用户展示。这样既保证了循环逻辑的可控性,又兼顾了交互体验。
4. 常见问题与排查技巧实录
4.1 死循环:模型反复调用同一个工具
这是 ReAct Agent 最经典的坑。模型有时候会“钻牛角尖”,拿着工具结果继续调同一个工具,或者工具返回错误信息后,模型不理解,还尝试用相同参数再调一次。
我的排查思路很简单:给工具调用加上日志和计数器。每次工具调用,打印出轮次、工具名、参数。如果发现同一工具在超3轮里重复出现,基本就是下面三种情况之一:
一是工具返回结果太模糊,模型不知道该信什么。解决方法是把返回值写得更明确,比如“查询失败,原因是城市不存在,请尝试其他城市”。
二是系统提示词里没有说明“一次最多调用一个工具,结果足够时直接回答”。把这句话加进去,模型通常就不会再多轮折腾。
三是温度参数设置太高,模型在随机性中打转。把 temperature 降到 0.5 以下,能明显减少不必要的发散推理。
更稳妥的办法是硬性限制。就是我在前面代码里写的 maxIterations,固定轮数上限,超了就终止。这个限制是 Agent 工程里必须有的“安全带”。
4.2 工具参数解析错误:模型传了奇怪的值
模型生成的工具参数是基于 JSON 的,偶尔会出现类型不匹配、字段缺失、传入空字符串的情况。比如你要求城市名传中文,它传了个“beijing”,或者直接传{}空对象。
处理思路有两种。第一种是在工具方法里做容错,比如:
if (city == null || city.isBlank()) { return "城市参数为空,请提供城市中文名称"; }第二种是优化描述。把参数约束写得更直白,比如“必须提供具体的城市名,禁止使用拼音或英文”。别觉得这是在小题大做,实测下来,描述里多一句约束,参数错误率能明显下降一个量级。
还有一种兜底办法:自己写一个 JSON Schema,在框架默认描述的基础上附加正则校验。Spring AI Alibaba 的 @ToolParam 注解目前还支持有限,但你可以通过工具方法的入参校验来实现同样的效果。
4.3 上下文爆炸:多轮工具调用后消息超长
模型上下文窗口有限,即使是最长的模型也有上限。ReAct 循环每多一轮,就会多出模型推理文本、工具结果等一堆 token。用户问题再稍微长一点,很容易在第五六轮把上下文挤爆。
我在生产项目里的做法是:
- 设定上下文窗口上限,比如最多保留最近10条消息,更早的可以压缩成摘要。
- 工具结果做截断,只保留关键字段,比如状态、数值、简短描述,不要返回几百行的原始数据。
- 如果确实需要长文档信息,考虑走 RAG 而不是让 Agent 自己去读全文。
这里可以简单算一笔账:假设每轮工具调用消耗约800 token,5轮就是4000 token,加上系统提示词、工具描述、用户原问题,一次完整 Agent 执行可能消耗 6000 以上的 token。用 qwen-plus 勉强够用,但如果你是逐字把整份合同丢进上下文让模型“自己看”,那个量级会迅速爆掉。精简单上下文,既是性能优化,也是成本优化。
4.4 工具执行报错导致 Agent 链路中断
有时候工具业务代码本身会抛异常(比如下游接口挂了、数据库超时),如果不处理,异常会直接把 ReAct 循环打断,用户就看到一个 500 错误。
我的建议是在工具方法内部就把异常“消化”成模型能理解的文本。比如 catch 住异常,返回“查询订单系统异常,请稍后重试”这类信息。这样模型会把这句当成工具执行结果,结合场景给用户一个合理的答复,甚至自动换个工具试试。
但要注意,千万不要把原始异常堆栈直接返回给模型。堆栈里有大量无用信息,模型很难从其中提炼有用信号,而且可能泄露内部实现细节。
5. 从 demo 到生产的进阶建议
5.1 给工具调用加观测与审计
生产环境里的 Agent,任何一个决策失误都可能带来真实影响。要不要给用户退款、要不要锁定库存,这种动作如果 Agent 自己判断错了,代价不小。
我建议在 Agent 执行链路里埋好观测点。至少要做到:
- 记录每一轮模型的 Thought、Action、Observation。
- 记录工具调用的入参和出参,并对敏感字段脱敏。
- 对 Agent 执行耗时、token 消耗做 metrics 上报。
- 对于高影响的工具(涉及资金、权限、隐私的操作),增加人工确认环节,Agent 生成动作后先挂起,等人点确认再执行。
这一步不只是为了保护自己,也是建立 Agent 信任感的基础。用户不敢用黑盒子做决策,但一套行为透明、可回溯的 Agent,即使犯了错,也能让问题定位和修正变得清晰。
5.2 多工具协作与动态工具选择
ReAct Agent 的魅力在于工具可以越来越多。你可以注册一个查天气的、一个查订单的、一个发消息的、一个算运费的……但工具太多以后,模型反而会出现选择困难。
解决办法是给工具打标签,在描述里写清适用场景和优先级。比如同一件事有两个工具都能做,就要明确“优先使用 A”。另外,也可以在系统提示词中增加“工具决策规则”段落,比如“当用户查询订单状态时,直接使用 query_order_status,不要尝试从其他工具中推断”。
如果你想让 Agent 能动态决定启用哪些工具,可以结合模型返回的文本做二次路由:先让模型分析用户意图,再由你的代码根据意图把对应工具集合塞给 ChatClient。这也是一种很实用的“动态工具选择”模式。
5.3 从单 Agent 到多 Agent 协调
当你把 ReactAgent 的逻辑跑通之后,下一步自然是想拆多个 Agent 协作:一个当主管负责拆解任务,两个下属 Agent 分别处理不同领域的问题。Spring AI Alibaba 对多 Agent 编排也提供了支持,但我的建议是:先别急着上编排框架。
先把单个 Agent 的工具能力打磨扎实,把错误处理、上下文管理、观测机制都做顺了。多 Agent 的本质,还是每个 Agent 内部跑同样的 ReAct 循环,只是消息路由和结果汇聚的复杂度上来了。基础不牢,Agent 多了只会让问题更多。
我在实际项目里的习惯是:单 Agent 能解决的,绝不用多 Agent;必须有多个领域专家系统时,才考虑拆分。因为每多一个 Agent,就多一层上下文消耗、多一套失败链路、多一堆需要排查的状态。
“或跃在渊”这四个字,我越来越觉得不只是讲龙的状态,也是在讲 Agent 的开发节奏。模型在渊中蓄力,思考清楚了才跃出水面调用工具;工具结果回来了,它又回到渊中继续权衡。这套循环本身就有某种“易经的智慧”在里面。我们做工程的人,与其追逐花哨的框架和名词,不如先把这“一思一行”的循环做扎实。
最后说个私藏的调优习惯:把 maxIterations 从 5 改成 8,从 8 改成 3,分别跑同一批测试问题,记录成功率、平均耗时、token 消耗,你会发现一个很明显的拐点。超出拐点之后,增加轮数只会徒增开销。每个业务场景的拐点都不一样,但跑过一轮数据,你心里就有数了,Agent 应该有多少耐心,既不是模型说了算,也不是产品经理拍脑袋说了算,是数据说了算。