1、ReAct概念
2、技术栈
Java 21 + Spring Boot 4.1.0 + Spring AI 2.0.0 + qwen3.5
3、功能
查一下北京的天气,如果是晴天就帮我退掉张三订单 ABC123 的票。
3.1 为什么需要 ReAct
| 形态 | 做法 | 问题 |
|---|---|---|
| 纯推理(CoT) | 让模型一步步"想一想"再回答 | 不接触外部世界,天气、订单这些实时数据只能靠编 |
| 一次性工具调用 | 把工具清单丢给模型,让它一次选完 | 只能做"独立"的事,第二步依赖第一步结果时就抓瞎 |
| ReAct | 推理 → 行动 → 观察 → 再推理,循环直到能作答 | 需要框架支持多轮工具回调 |
关键差异就一句话:ReAct 的每一步决策,都建立在上一步的真实观察之上。
拿"查天气 + 条件退票"来说:
- 这不是两个可以并行的独立任务,退票与否取决于天气结果;
- 你没办法在代码里写死
if (天气 == 晴) { 退票(); }------因为"是否满足条件"这件事本身是自然语言描述的、由用户临时定义的; - 传统编排要写状态机 + 条件分支,换个说法("下雨就别退了")就得改代码。
ReAct 把"条件判断"交回给模型:模型先看天气,看到"晴"再决定调用退票,看到"小雨"就直接给结论。业务规则变了,只需改提示词或用户的话术,代码一行不动。
3.2 ReAct 的循环
ReAct = Rea soning(推理)+ Acting(行动),一轮循环四步:
- Thought:分析当前状态,判断下一步做什么;
- Action:调用一个工具,并给出参数;
- Observation:拿到工具返回的真实结果,作为观察到的事实;
- 回到 Thought......直到信息足够,输出 Final Answer。

注意 Observation 1 这条分叉:条件分支是模型在 Observation 之后自己走出来的,不是代码里的 if-else。
4、在 Spring AI 里落地
这个循环不用自己写 while 。Spring AI 的 ChatClient.defaultTools(...) 内部已经完成了"模型发起 tool call → 框架回调 @Tool 方法 → 结果回灌模型 → 模型继续推理"的闭环。我们要做的只有三件事:
- 定义工具
- 写好 ReAct 提示词
- 让过程可观测
4.1 定义工具:@Tool
工具就是普通 Spring Bean 的方法,加 @Tool 和 @ToolParam 即可。description 很重要------模型靠它决定"什么时候用、传什么参数"。
@Service
public class ReActToolService {
private static final Map<String, String> WEATHER_DATA = Map.of(
"北京", "晴,26℃,东南风 2 级,适合出行",
"上海", "小雨,21℃,东北风 3 级,出行请带伞",
"广州", "多云,30℃,湿度较高,注意防暑"
);
@Tool(name = "查询天气", description = "按城市名称查询当前天气,返回天气、温度与出行建议")
public String queryWeather(@ToolParam(description = "城市名称,例如:北京") String city) {
log.info("【ReAct-Action】查询天气,city:{}", city);
recorder.record("查询天气", city);
if (StrUtil.isBlank(city)) {
return "查询天气失败:城市名称为空";
}
String weather = WEATHER_DATA.get(city.trim());
if (StrUtil.isBlank(weather)) {
return city + ":暂无该城市天气数据";
}
return city + ":" + weather;
}
@Tool(name = "退票", description = "根据用户姓名和订单号办理退票,姓名与订单号缺一不可")
public String cancelTicket(@ToolParam(description = "用户姓名") String name,
@ToolParam(description = "订单号") String orderNo) {
log.info("【ReAct-Action】退票,name:{},orderNo:{}", name, orderNo);
recorder.record("退票", name + "/" + orderNo);
if (StrUtil.isBlank(name) || StrUtil.isBlank(orderNo)) {
return "退票失败:姓名或订单号缺失,请先向用户追问这两个信息";
}
ticketService.cancelTicket(name, orderNo);
return "退票成功:用户「" + name + "」的订单「" + orderNo + "」已办理退票";
}
}
两个细节:
- 工具返回"人类可读的结论" ,而不是 JSON 或原始对象。
北京:晴,26℃......让模型一眼能读懂并据此推理,{"weather":"sunny","temp":26}则容易让小模型理解偏差。 - 失败也要返回可读文本 。
退票失败:姓名或订单号缺失,请先向用户追问------ 这个字符串会被模型当成 Observation 读到,它就会自动追问用户,而不是抛异常中断链路。
4.2 提示词:约束思考方式
提示词不负责循环,只负责"教模型怎么思考、有哪些边界"。
public static final String REACT_SYSTEM = """
你是一个遵循 ReAct(Reasoning + Acting)范式的中文助手,必须按以下四步循环工作:
Thought:先用一到两句话分析用户问题,判断是否需要调用工具、调用哪个工具、参数是什么。
Action:需要外部信息或需要执行动作时,调用工具清单中的工具;工具结果由系统返回,你不需要自己执行。
Observation:把工具返回的内容当作观察到的事实,据此继续推理,必要时再次调用其它工具。
Final Answer:信息足够时用中文给出最终答案,并简要说明结论依据。
约束:
1. 全程使用中文回复。
2. 只允许调用下面工具清单中存在的工具,禁止编造工具名和参数。
3. 单轮对话最多调用 5 次工具,达到上限后必须基于已有的观察结果给出最终答案。
4. 工具返回失败或信息不足时,换一个工具或如实说明无法完成,禁止编造工具结果。
5. 不需要工具时(例如闲聊、概念解释),直接给出 Final Answer,不要调用工具。
6. 涉及退票这类写操作,必须拿到用户姓名和订单号才能调用,缺参数就先向用户追问。
可用工具清单:
"""
+ TOOL_LIST;
其中第 3 条(步数上限 )和第 6 条(写操作必须参数齐全)是生产级 ReAct 的必备护栏,小模型尤其需要。
4.3 装配 ChatClient
提示词 + 工具 + 记忆,三行装配搞定:
@Bean("reactChatClient")
ChatClient reactChatClient(ChatClient.Builder builder,
@Qualifier("agentChatMemory") ChatMemory chatMemory,
ReActToolService reactToolService) {
OllamaChatOptions.Builder ollamaChatOptions = OllamaChatOptions.builder()
.model("qwen3.5:4b")
// ReAct 依赖稳定的工具选择,关闭思考降低随机性
.disableThinking()
.temperature(0.3);
return builder
.defaultSystem(ReActPrompt.system()) // 思考方式
.defaultTools(reactToolService) // 可用工具
.defaultAdvisors(MessageChatMemoryAdvisor.builder(chatMemory).build()) // 多轮记忆
.defaultOptions(ollamaChatOptions)
.build();
}
temperature(0.3) + disableThinking() 不是玄学:ReAct 对工具选择的稳定性要求很高,温度一高,小模型就可能在"该退票"的时候跑去查天气,或者反复调用同一个工具打转。
4.4 一次调用跑完整条链
public ReActResponse chat(String question, String conversationId) {
String convId = (conversationId == null || conversationId.isBlank())
? UUID.randomUUID().toString() : conversationId.trim();
recorder.clear();
String answer = reactChatClient.prompt()
.user(question)
.advisors(spec -> spec.param(ChatMemory.CONVERSATION_ID, convId))
.call()
.content();
return new ReActResponse(convId, answer, recorder.take().stream()
.map(ReActToolRecorder.ToolCall::toolName).toList());
}
代码里没有任何 for/while,没有任何 if 判断天气 。call() 背后已经跑完了"Thought → 查询天气 → Observation → Thought → 退票 → Observation → Final Answer"的全过程。
ReActToolRecorder 是一个基于 ThreadLocal 的轨迹记录器,工具方法被调用时顺手 record 一下,调用结束后把本轮真实执行过的工具序列回传给上层------这既是可观测性,也是测试的断言依据。
5、运行效果:多步串联实录
测试用例:
@Test
public void testMultiStepWeatherThenRefund() {
ReActResponse response = reActService.chat(
"查一下北京的天气,如果是晴天就帮我退掉张三订单 ABC123 的票", UUID.randomUUID().toString());
assertTrue(response.toolCalls().contains("查询天气"));
assertTrue(response.toolCalls().contains("退票"));
assertTrue(response.toolCalls().indexOf("查询天气") < response.toolCalls().indexOf("退票"));
}
实际日志(模型输出有随机性,以下为典型轨迹):
===== ReAct 开始 =====
【ReAct-Question】问题:查一下北京的天气,如果是晴天就帮我退掉张三订单 ABC123 的票
【ReAct-Action】查询天气,city:北京 <- Thought 1 -> Action 1
【ReAct-Action】退票,name:张三,orderNo:ABC123 <- Observation 1 -> Thought 2 -> Action 2
【ReAct-Observation】本轮共调用 2 次工具:[查询天气, 退票]
【ReAct-FinalAnswer】北京今天晴,26℃,满足您设定的"晴天"条件,已为张三办理订单 ABC123 的退票,票款将于 1-3 个工作日退回原支付账户。
===== ReAct 结束 =====
把问题换成上海(数据中是"小雨"),同样的代码、同样的提示词,模型自己走另一条分支:
【ReAct-Question】问题:查一下上海的天气,如果是晴天就帮我退掉张三订单 ABC123 的票
【ReAct-Action】查询天气,city:上海
【ReAct-Observation】本轮共调用 1 次工具:[查询天气]
【ReAct-FinalAnswer】上海今天小雨,21℃,不满足"晴天"条件,因此我没有办理退票。如需强制退票请明确告知。
这就是 ReAct 的核心价值:条件分支由模型根据 Observation 动态决定,代码零改动。
还有一个容易忽略的用例------参数不全时的行为:
【ReAct-Question】问题:帮我退票
【ReAct-FinalAnswer】办理退票需要您提供姓名和订单号,请补充这两个信息。
工具一次都没被调用。这靠的是提示词第 6 条约束 + 工具返回文本里的"请先向用户追问",模型把"缺参数"当成了 Observation,于是转向追问而不是硬着头皮瞎传参数。
6、实践要点
- 不要手写循环 。Spring AI 的
defaultTools已经实现了工具调用闭环,手写while反而要自己管理消息拼接、工具分发和终止条件。 - Thought 去哪了? 严格来说,模型是以
tool_calls的形式输出 Action 的,中间的思考过程属于模型内部推理,框架层面拿不到显式的 Thought 文本。本 Demo 关闭了 thinking 模式,日志里能看到的只有 Action 轨迹与 Final Answer。如果确实需要显式思考过程,要么让模型先用文本输出 Thought 再发起调用(会占用输出、对工具选择有干扰),要么开SimpleLoggerAdvisor看原始报文。实践中更推荐用"工具调用序列 + 最终答案"来还原推理链,这也是本文断言的方式。 - 一定设步数上限。没有上限的模型可能在工具反复失败时无限打转。提示词里写"最多 5 次"是软约束,生产环境建议在框架层再加硬熔断(如自定义 Advisor 计数)。
- 工具返回值要"会说话"。成功给结论,失败给原因和补救建议,模型才知道下一步怎么走。
- 写操作要格外谨慎。退票这类有副作用的工具,务必在提示词里要求参数齐全,工具内部再做一次参数校验兜底;条件类写操作最好在提示词中明确"满足条件才执行"。
- 测试别断言文本 。LLM 输出不稳定,断言要落在工具调用的名称与顺序 上(本 Demo 用
ListAppender/ReActToolRecorder捕获),只有确定性的计算类用例才做精确文本匹配(如断言答案包含60)。 - 可观测性优先 。工具方法里打
log.info记录入参,服务层打印 Action 轨迹和 Final Answer,排查"为什么没调工具"这类问题时能省一半时间。
7、小结
ReAct 的本质不是"调工具",而是用真实观察不断校正推理。单步工具调用解决的是"能不能做",多步串联解决的是"该不该做、做了之后还要做什么"。
在 Spring AI 里实现它,代码量比较少:一个 @Tool 工具类、一段 ReAct 提示词、一个绑定了工具的 ChatClient。剩下的循环、回灌、终止,框架都替你做了。真正需要花心思的是提示词里的护栏(步数上限、参数校验、禁止编造)和可观测性设计------这些决定了程序能不能走到生产。