spring ai实现ReAct

1、ReAct概念

AI 智能体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 + "」已办理退票";
    }
}

两个细节:

  1. 工具返回"人类可读的结论" ,而不是 JSON 或原始对象。北京:晴,26℃...... 让模型一眼能读懂并据此推理,{"weather":"sunny","temp":26} 则容易让小模型理解偏差。
  2. 失败也要返回可读文本退票失败:姓名或订单号缺失,请先向用户追问 ------ 这个字符串会被模型当成 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、实践要点

  1. 不要手写循环 。Spring AI 的 defaultTools 已经实现了工具调用闭环,手写 while 反而要自己管理消息拼接、工具分发和终止条件。
  2. Thought 去哪了? 严格来说,模型是以 tool_calls 的形式输出 Action 的,中间的思考过程属于模型内部推理,框架层面拿不到显式的 Thought 文本。本 Demo 关闭了 thinking 模式,日志里能看到的只有 Action 轨迹与 Final Answer。如果确实需要显式思考过程,要么让模型先用文本输出 Thought 再发起调用(会占用输出、对工具选择有干扰),要么开 SimpleLoggerAdvisor 看原始报文。实践中更推荐用"工具调用序列 + 最终答案"来还原推理链,这也是本文断言的方式。
  3. 一定设步数上限。没有上限的模型可能在工具反复失败时无限打转。提示词里写"最多 5 次"是软约束,生产环境建议在框架层再加硬熔断(如自定义 Advisor 计数)。
  4. 工具返回值要"会说话"。成功给结论,失败给原因和补救建议,模型才知道下一步怎么走。
  5. 写操作要格外谨慎。退票这类有副作用的工具,务必在提示词里要求参数齐全,工具内部再做一次参数校验兜底;条件类写操作最好在提示词中明确"满足条件才执行"。
  6. 测试别断言文本 。LLM 输出不稳定,断言要落在工具调用的名称与顺序 上(本 Demo 用 ListAppender / ReActToolRecorder 捕获),只有确定性的计算类用例才做精确文本匹配(如断言答案包含 60)。
  7. 可观测性优先 。工具方法里打 log.info 记录入参,服务层打印 Action 轨迹和 Final Answer,排查"为什么没调工具"这类问题时能省一半时间。

7、小结

ReAct 的本质不是"调工具",而是用真实观察不断校正推理。单步工具调用解决的是"能不能做",多步串联解决的是"该不该做、做了之后还要做什么"。

在 Spring AI 里实现它,代码量比较少:一个 @Tool 工具类、一段 ReAct 提示词、一个绑定了工具的 ChatClient。剩下的循环、回灌、终止,框架都替你做了。真正需要花心思的是提示词里的护栏(步数上限、参数校验、禁止编造)和可观测性设计------这些决定了程序能不能走到生产。

相关推荐
中间件XL2 天前
agent框架langgraph4j原理源码分析-agent和集成 I Agent
ai agent·spring ai·langgraph4j
光依旧3 天前
MCP实战手记系列(五):让工具返回一个能点的界面(文末附github源码链接)
java·spring ai·前端集成·mcp·mcp apps
学编程就要猛3 天前
基于Spring AI 的智能聊天机器人
java·spring ai·chat robot
光依旧4 天前
MCP实战手记系列(二):跑通第一个 MCP Server(文末附github源码链接)
java·spring ai·mcp·ai 开发·源码实战
YDS8295 天前
AI Agent 脚手架 —— 脚手架工程化和Maven私服
ai·agent·spring ai
ba_pi5 天前
springAI2.0接入mcp读取mysql
java·agent·spring ai
YDS8297 天前
AI Agent 脚手架 —— Service层和Trigger层接口实现
ai·agent·spring ai
猫吻鱼8 天前
【AI 01】【Spring AI 基础使用】
java·spring·spring ai
醉古意9 天前
给AI一个角色设定
intellij-idea·spring ai