Spring AI 工具调用实战:模型自主决策、@Tool 方法封装与 ReAct 模式

Spring AI 工具调用实战:模型自主决策、@Tool 方法封装与 ReAct 模式

前四篇文章中,我们的 AI 应用只能"说话",不能"做事"。用户问"帮我查一下订单"时,模型只能遗憾地表示无法访问数据库。为了让 AI 真正具备行动能力,我们需要让模型能够调用外部业务方法。这便是 Function Calling(工具调用)

本文将围绕三个核心问题展开:

  1. 模型如何自主决定何时调用哪个工具?
  2. 如何用 Spring AI 将业务逻辑封装为 @Tool方法?
  3. ReAct(Reason → Act → Observe)模式是如何在 Spring AI 中自动运转的?

一、从"生成文本"到"执行动作"

大模型本质上只是文本生成器。但通过 Function Calling,我们可以向模型暴露一组"可用的函数",让它根据用户意图选择调用。模型的输出不再只是自然语言,还可能包含工具调用指令,其中携带函数名和参数 JSON。

整个流程由应用侧负责执行:应用将指令翻译为实际的业务方法,将执行结果作为一条新消息回传给模型,让模型基于结果继续推理或生成最终回答。

因此,Function Calling 的核心模型是:

css 复制代码
用户请求 → 模型决定调用工具 A(参数) → 应用执行 A → 返回结果给模型 → 模型总结回答

Spring AI 将这套机制封装得极其简洁。在 ChatClient 中注册工具后,模型自动具备调用它们的能力。


二、模型如何"自主决定"调用哪个工具

让我们先理解底层机制。以上下文为例,当你向 OpenAI 发送请求时,可以在 tools参数中描述可用函数(名称、描述、参数 JSON Schema)。模型会根据对话内容判断是否需要调用某个函数。如果决定调用,响应中的 tool_calls数组会包含函数名与参数。

这个决策过程完全由模型自身的推理能力驱动:

  • 如果用户问"今天北京天气如何",而模型知道有一个 getWeather工具,它就会输出调用该工具且参数为 {"city":"北京"}的指令;
  • 如果用户只是闲聊,模型不会调用任何工具,仅生成文本。

这就是"自主决定"的含义。开发者的责任是提供优秀的工具描述,让模型能准确匹配意图与工具。

Spring AI 中,工具的注册与描述由 @Tool注解自动完成。我们无需手动编写 JSON Schema。


三、将业务逻辑封装为 @Tool方法

3.1 一个最简单的 @Tool

以一个获取城市天气的方法为例:

kotlin 复制代码
@Component
public class WeatherTools {

    @Tool(description = "查询指定城市的当前天气")
    public String getWeather(@ToolParam(description = "城市名称,如:北京、上海") String city) {
        // 模拟真实查询逻辑
        if ("北京".equals(city)) {
            return "北京:晴,25℃,东南风2级";
        } else if ("上海".equals(city)) {
            return "上海:多云,28℃,南风3级";
        }
        return "暂未收录该城市天气数据";
    }
}

关键点:

  • @Tool注解标注在公共方法上;
  • description描述了该方法的用途,供模型理解;
  • @ToolParam描述每个参数的语义,帮助模型正确填充参数;
  • 方法返回值最终会作为字符串传给模型,因此尽量返回简洁、易读的文本。

3.2 注册工具到 ChatClient

在构建 ChatClient 时,使用 .defaultTools()传入工具类的实例:

kotlin 复制代码
@Configuration
public class ChatConfig {

    @Bean
    public ChatClient chatClient(ChatModel chatModel, WeatherTools weatherTools) {
        return ChatClient.builder(chatModel)
                .defaultTools(weatherTools)   // 注册工具
                .build();
    }
}

如果工具有多个,可以这样传入:

scss 复制代码
.defaultTools(weatherTools, orderTools, productTools)

Spring AI 会自动扫描这些实例中所有标注 @Tool的方法,并生成模型可读的函数描述。

3.3 更丰富的工具:订单查询

假设我们要让 AI 充当智能客服,可以查询订单状态、修改订单备注:

less 复制代码
@Component
public class OrderTools {

    private final OrderRepository orderRepository;

    public OrderTools(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    @Tool(description = "根据订单号查询订单状态、商品明细、物流信息")
    public String getOrderInfo(@ToolParam(description = "订单号,例如 ORD20250101001") String orderId) {
        Order order = orderRepository.findByOrderId(orderId);
        if (order == null) {
            return "未找到订单:" + orderId;
        }
        return "订单 %s,当前状态:%s,商品:%s,配送进度:%s"
                .formatted(order.getOrderId(), order.getStatus(),
                        order.getItems(), order.getLogistics());
    }

    @Tool(description = "修改订单的收件人备注信息")
    public String updateOrderRemark(@ToolParam(description = "订单号") String orderId,
                                    @ToolParam(description = "新备注内容") String remark) {
        int rows = orderRepository.updateRemark(orderId, remark);
        return rows > 0 ? "备注已更新" : "订单不存在,无法更新";
    }
}

模型调用 getOrderInfo后,如果用户接着说"把备注改成尽快发货",模型会接着调用 updateOrderRemark,形成连续的多工具操作。

3.4 工具也可以返回结构化 JSON

如果工具执行后需要将结构化数据交给下游逻辑,可以让方法返回对象而非字符串。Spring AI 会将其序列化后传给模型。但要注意,传递给模型的内容最终是文本,因此复杂对象会变成 JSON 字符串。例如:

less 复制代码
@Tool(description = "查询用户账户余额")
public AccountInfo getAccountBalance(@ToolParam(description = "用户ID") Long userId) {
    return accountService.getBalance(userId);
}

返回的 AccountInfo对象会被序列化成 JSON,然后作为观察结果送给模型。这种方式便于模型读取多个字段,但也会消耗更多 token。对于简单结果,返回纯文本更节省。

3.5 实例方法 vs 静态方法

@Tool支持实例方法,也支持静态方法。如果工具类需要注入依赖(如 Repository、Service),必须使用实例方法并注册为 Spring Bean。静态方法适合无状态工具,但较少见。


四、理解 ReAct 雏形:Reason → Act → Observe

4.1 ReAct 的基本含义

ReAct 是"Reasoning + Acting"的缩写,最初来自一篇关于 LLM 与外部工具协同工作的论文。核心思想是让模型在推理和行动之间交替循环:

  1. Reason(推理) :模型分析当前问题,确定下一步应该做什么。它可能决定"用户想查询天气,我应该调用 getWeather 工具"。
  2. Act(行动) :应用执行模型指定的工具调用,得到执行结果。
  3. Observe(观察) :将工具返回的结果作为新的上下文反馈给模型。模型观察结果后,继续推理:要么再调用下一个工具,要么生成最终回答。

整个过程可以循环多轮,直到模型不再请求调用任何工具,直接输出最终答案。

4.2 Spring AI 中的自动 ReAct 循环

Spring AI 的 ChatClient内部已经实现了这个循环。当你使用 .tools()注册工具后,框架会自动处理多轮交互。让我们从代码层面观察这个过程。

假设用户请求:"帮我查一下订单 ORD123 的物流,如果还没发货,就把备注改成加急。"

对话流程:

  • 模型收到该消息,通过 getOrderInfo感知到需要查询订单。
  • 模型输出工具调用:getOrderInfo(orderId="ORD123")
  • Spring AI 执行该方法,得到"订单状态:已发货..."。
  • 将结果作为一条 "tool" 消息回传给模型。
  • 模型看到订单已发货,可能决策:不需要改备注,生成最终回答。
  • ChatClient 返回文本给用户。

如果订单是未发货状态:

  • 模型继续输出下一个工具调用:updateOrderRemark(orderId="ORD123", remark="加急")
  • Spring AI 再次执行工具,结果反馈给模型。
  • 模型生成最终回答:"订单已备注加急。"

整个过程对业务代码透明,你只需要注册工具并等待最终结果。

4.3 手动 ReAct 循环的实现

虽然 ChatClient 封装了自动循环,但理解底层机制有助于调试。下面用底层 API 手动实现一个 ReAct 循环:

scss 复制代码
@Service
public class ManualReActService {

    private final ChatModel chatModel;
    private final ToolCallingManager toolCallingManager;

    public ManualReActService(ChatModel chatModel, ToolCallingManager toolCallingManager) {
        this.chatModel = chatModel;
        this.toolCallingManager = toolCallingManager;
    }

    public String run(String userInput, Object... toolObjects) {
        // 1. 将工具对象转换为 ChatClient 注册所需的 ToolCallback
        List<ToolCallback> toolCallbacks = Arrays.stream(toolObjects)
                .map(ToolCallbacks::from)
                .flatMap(List::stream)
                .toList();

        // 2. 构建初始消息
        List<Message> messages = new ArrayList<>();
        messages.add(new UserMessage(userInput));

        // 3. 最多循环5次,防止死循环
        for (int i = 0; i < 5; i++) {
            ChatResponse response = chatModel.call(
                    new Prompt(messages, optionsWithTools(toolCallbacks))
            );

            // 4. 检查是否包含工具调用请求
            AssistantMessage assistantMessage = response.getResult().getOutput();
            messages.add(assistantMessage);

            List<ToolResponse> toolResponses = toolCallingManager.resolveToolCallRequests(
                    toolCallbacks,
                    assistantMessage.getToolCalls()
            );

            if (toolResponses.isEmpty()) {
                // 没有工具调用,说明模型已给出最终回答
                return assistantMessage.getText();
            }

            // 5. 将工具结果作为消息加入历史,再次循环
            for (ToolResponse toolResponse : toolResponses) {
                messages.add(new ToolResponseMessage(toolResponse.id(), toolResponse.responseData()));
            }
        }

        return "智能体执行轮次过多,已停止。";
    }

    private PromptOptions optionsWithTools(List<ToolCallback> toolCallbacks) {
        // Spring AI 1.0 中使用 ToolCallingChatOptions
        return ToolCallingChatOptions.builder()
                .toolCallbacks(toolCallbacks)
                .build();
    }
}

这个手动实现展示了 ReAct 的根本机制:

  • 发送消息给模型;
  • 模型返回 AssistantMessage,其中可能包含 ToolCalls;
  • 通过 ToolCallingManager解析并执行工具调用;
  • 将执行结果封装为 ToolResponseMessage 追加到对话;
  • 循环往复。

日常使用强烈建议直接用 ChatClient的内置循环,手动版本只作为学习或特殊定制场景。

4.4 多工具场景下的选择

假设注册了 getWeathergetOrderInfoupdateOrderRemark三个工具。模型如何决定用哪个?

  • 取决于工具描述与用户问题的语义匹配;
  • 多个工具的描述应责任分明,避免歧义;
  • 如果模型犹豫,可能要求用户澄清,而不是乱调用。

然后我们可以观察实际调用日志。Spring AI 会记录工具调用信息。我们也可以在工具方法里打印日志:

typescript 复制代码
@Tool(description = "查询订单信息")
public String getOrderInfo(String orderId) {
    log.info("调用 getOrderInfo, orderId={}", orderId);
    // ...
}

通过日志,我们可以分析模型决策是否符合预期,进而优化工具描述。


五、函数调用中的消息角色

当工具执行完毕后,工具结果回传给模型时需要符合多轮消息格式。在 Spring AI 底层,工具结果对应的角色通常是 ToolFunction消息。ChatClient 自动处理这些细节,但如果你手动编写循环,需要注意消息类型。

常见消息类型:

  • UserMessage:用户输入;
  • AssistantMessage:模型回复,其中可能附带工具调用;
  • ToolResponseMessage:工具执行结果;
  • SystemMessage:系统提示。

通过将这几类消息按顺序放进列表,模型才能正确理解哪条工具结果对应哪个工具调用。


六、与 ChatMemory 结合:有记忆的工具调用

工具调用天然适合与 ChatMemory 配合。例如:

less 复制代码
@GetMapping("/chat/with-tools")
public String chat(@RequestParam String conversationId, @RequestParam String message) {
    return chatClient.prompt()
            .user(message)
            .tools(weatherTools)                 // 每次请求指定工具
            .memory(conversationId)              // 保持会话记忆
            .call()
            .content();
}

memory(conversationId)会在自动的 ReAct 循环中保存历史,并在每次迭代时同步更新。用户可以在连续对话中引用之前的工具查询结果,模型仍能记住。


七、工程实践建议

7.1 工具描述是核心质量指标

模型是否正确调用工具,很大程度上取决于 @Tool@ToolParam的描述质量。撰写时注意:

  • 使用动词开头描述方法:查询创建更新删除
  • 包含使用的业务上下文,例如"根据订单号查询订单状态";
  • 参数描述要具体,避免歧义;
  • 对于可选参数,在描述中注明"如果用户未提供,请询问";
  • 如果参数存在枚举值,尽量在描述中列出。

7.2 控制工具数量与 token 消耗

每注册一个工具,模型接收到的 functions 描述都会占用 token。工具越多,上下文越拥挤。建议:

  • 只注册当前可能用到的工具;
  • 将多个相关操作合并为一个工具,用参数区分;
  • 对于庞大的业务系统,根据用户意图动态加载不同工具集。

7.3 防御恶意工具调用

模型可能被用户提示词诱导去调用危险工具。必须做好权限控制:

  • 工具方法内部严格校验用户身份与权限;
  • 禁止模型调用未注册的工具;
  • 敏感操作(转账、删除)需要用户二次确认,甚至要求前端展示确认面板后再执行;
  • 对工具参数做白名单校验,防止注入。

7.4 错误处理与重试

工具可能执行失败(网络异常、数据不存在)。失败信息应作为观察结果反馈给模型,让模型决定下一步。工具方法自身可以返回错误描述,而不必抛出大堆异常:

typescript 复制代码
@Tool(description = "查询订单")
public String getOrder(String orderId) {
    try {
        return orderService.query(orderId);
    } catch (Exception e) {
        return "查询订单失败:" + e.getMessage();
    }
}

这样模型能够理解并生成友好的用户提示。

7.5 设置最大迭代轮数

无论是自动还是手动 ReAct 循环,都要防止模型反复调用工具。ChatClient 的默认循环有内部保护,但建议在业务侧设置超时或最大轮数。手动实现时尤其重要。

7.6 日志与审计

工具调用涉及真实业务操作,必须记录:

  • 谁在何时调用了哪个工具;
  • 参数是什么;
  • 执行结果如何;
  • 模型最终生成的回复。

日志不仅用于排查问题,也是优化提示词和工具描述的数据基础。


八、完整示例:智能客服工具集成

下面整合一个可运行的示例。假设场景:用户可以通过对话查询天气、查询订单、更新订单备注。

less 复制代码
@RestController
@RequestMapping("/api/agent")
public class AgentController {

    private final ChatClient chatClient;
    private final WeatherTools weatherTools;
    private final OrderTools orderTools;

    public AgentController(ChatModel chatModel,
                           WeatherTools weatherTools,
                           OrderTools orderTools) {
        this.chatClient = ChatClient.builder(chatModel)
                .defaultSystem("""
                        你是智能客服助手。当你需要查询信息或执行操作时,请优先使用提供的工具。
                        所有工具执行结果应如实告知用户。如果无法完成,请说明原因。
                        """)
                .defaultTools(weatherTools, orderTools)
                .build();
        this.weatherTools = weatherTools;
        this.orderTools = orderTools;
    }

    @GetMapping("/chat")
    public String chat(@RequestParam String message) {
        return chatClient.prompt()
                .user(message)
                .call()
                .content();
    }

    @GetMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<String> chatStream(@RequestParam String message) {
        return chatClient.prompt()
                .user(message)
                .stream()
                .content();
    }
}

用户对话示例:

  • "今天北京天气怎么样?"
  • "帮我查订单 ORD123 的状态"
  • "查一下 ORD456,如果还没支付就取消"

模型会自主完成工具选择、参数提取与多步调用。


九、总结

本文从三个层面递进解读了 Spring AI 的函数调用机制:

  1. 模型自主决策:模型根据工具描述和用户意图,在输出中生成工具调用指令。开发者无需编写 if-else 判断何时调用哪个工具,模型自己决定。
  2. @Tool 方法封装:通过注解即可将 Spring Bean 方法暴露给模型,框架自动生成函数描述、解析参数、执行调用、回传结果。
  3. ReAct 循环:Spring AI 内部实现了 Reason → Act → Observe 的迭代循环。每次工具调用结果都会作为新上下文被模型观察,直到模型认为任务完成。

实际应用中,请务必重视:

  • 工具描述的质量;
  • 调用权限与安全性;
  • 错误信息的设计;
  • 日志与可观测性。
相关推荐
只爱喝胡辣汤2 小时前
05-JVM 监控与故障排查工具
后端
geovindu2 小时前
CSharp: Task Scheduler
开发语言·后端·c#·.net·.netcore
Zane19942 小时前
两个线程算两遍循环,为什么只快了一点点不是两倍?GIL连环追问
后端·python
只爱喝胡辣汤2 小时前
04-JVM 性能调优参数
后端
Bs_MoneyMagnet2 小时前
基于springboot+vue的家居生活商城平台的设计与实现 源码+文档
java·vue.js·spring boot·后端·spring
爱勇宝3 小时前
《道德经》第 11 章:真正有用的,常常是你没写出来的那部分
前端·后端·产品
微三云 - 廖会灵 (私域系统开发)3 小时前
Spring Boot实战:五级分销定价与自动分佣系统设计与实现
java·spring boot·后端
钱栈up3 小时前
mvn compile卡住半小时:定位并修复隐藏的类型不匹配编译错误
java·后端·maven
妙码生花3 小时前
golang 应用服务端部署(使用 systemd 服务)
开发语言·人工智能·后端·golang·node.js·php·gin