Skip to content

Q18 · Spring AI 中如何实现工具调用(Tool Calling)? ​

用户问客服:“订单 A123 发货了吗?”聊天模型本身看不到订单库。如果它根据常识回答“应该发货了”,就是在猜。我们需要给它一种受控地取得订单事实的办法:模型可以提出“查询 A123”,但真正查库的是应用里的 Java 方法;查到的结果再交给模型组织答复。

在 Spring AI 中,可以用 @Tool 标记 Java 方法,用 ChatClient 把方法作为可用工具交给模型。模型返回的是工具调用请求,不是它亲自执行过数据库操作。Spring AI 在应用进程里找到并执行对应方法,把结果加入本轮对话,必要时再次调用模型,直到得到最终回答。Spring AI 2.0.1 官方 Tool Calling 文档描述了这条流程。

本文按 Spring AI 2.0.1 的稳定文档写。仓库里已有一篇较短的 Q3:Spring AI 工具调用与工具管理,主要介绍注解和基本接入;这篇 Q18 继续追到模型看见的参数结构、框架实际执行的位置、登录身份怎样传给工具、异常怎样处理和怎样测试,并明确 2.0 与 1.x 的执行位置差异。下面的 A123 与查询结果均为虚构教学数据。

先认清术语与代码名字 ​

词或名字直白解释本文订单例子中的对应物
大语言模型 / LLM根据输入生成文本,也可以按约定格式提出动作的模型读懂“发货了吗”,提出查订单
Tool Calling(工具调用)让模型提出工具名称和参数,由程序调用工具再把结果交回提议调用 getOrderStatus 查 A123
ChatModelSpring AI 中访问某个聊天模型的底层接口把问题和工具说明发给模型的能力
ChatClientSpring AI 的高层对话入口,可配置系统要求、工具和顾问链本例发起一次客服问答的对象
@Tool声明一个 Java 方法可以作为工具暴露标记查询订单方法
@ToolParam给工具参数写模型能理解的说明及必填状态解释 orderId 是订单编号
Tool Schema / JSON Schema用结构化规则描述工具名称、参数及必填项告诉模型 getOrderStatus 需要字符串 orderId
ToolCallbackSpring AI 内部统一的工具定义加执行入口把被 @Tool 标记的方法接入工具机制
ToolCallingAdvisorChatClient 中负责工具请求、执行、结果回传与继续调用的组件让“查 A123”走完一轮往返
ToolContext只给应用里的工具使用、不会当作工具参数发给模型的上下文已验证的当前用户 ID u7
OrderGateway本文给真实订单服务留的接口名用当前用户和订单号查询可见的状态
OrderTools本文承载 @Tool 方法的 Java 类校验参数并调用 OrderGateway
OrderLookupResult查询结果的结构,code 表示结果类别,status 表示订单状态code=OK、status=运输中

orderId 是模型可提议的订单编号;userId 是服务端认证得到的当前用户标识。两者不能互换。特别是 userId,不能让模型从用户文字里编一个,再拿它通过权限检查。

一次调用究竟由谁做了什么 ​

图中的蓝色“模型提议”只是一份调用意图。Spring AI 接到它后才在 Java 进程里执行已注册的方法。订单结果“运输中”来自业务服务,不是模型训练资料里的知识。

Spring AI 工具调用:模型提议方法与参数,由 Spring AI 执行并回传结果

按图从左到右看,流程是:

  1. 应用收到用户问题,并拿到已通过登录校验的用户标识 u7。
  2. ChatClient 把问题和允许使用的工具说明发给模型。工具说明含名称、描述、参数 Schema;服务端用户标识走 ToolContext,不放进模型可填的工具参数里。
  3. 模型决定是否需要工具。此例它提出 getOrderStatus,参数为 {"orderId":"A123"}。模型也可能直接回答或提错参数,所以程序不能把“提议”当作事实。
  4. ToolCallingAdvisor 把这份请求交给 ToolCallingManager,后者找到匹配的 ToolCallback,执行 OrderTools.getOrderStatus(...)。方法用 u7 和 A123 查询当前用户有权看到的订单。
  5. 方法返回 {"code":"OK","status":"运输中"}。默认结果转换器会把 Java 返回对象序列化为模型可读的文本;ChatClient 把结果放进本轮历史,再向模型发起下一次调用。官方调用循环
  6. 模型据查询结果回答:“A123 已发货,目前运输中。”这里的“已发货”来自本例业务约定:状态“运输中”意味着已经交给物流。若真实系统的状态词不同,应按真实业务规则处理,不能只看文字猜。

如果模型第二次还要求查工具,循环可以继续。工具可能一次都不调用,也可能连续调用多次;一次 .call() 不保证只发生一次模型请求或一次工具调用。这也是延迟和费用评估时要按实际轮次记录的原因。

写出工具,再按请求注册 ​

下面示例假设 Java 项目已通过相应的 Spring AI 模型 starter 提供 ChatModel,并使用 Spring AI 2.0.1 与兼容的 Spring Boot 4.0/4.1。本文不绑定某个模型供应商;选用的模型及其接入方式必须支持工具调用。Spring AI 的 Getting Started列出 BOM、starter 与版本对应方式。

先说明代码中的输入输出:OrderGateway.findStatusVisibleTo(userId, orderId) 应从真实订单服务读取当前用户可见的订单,返回 Optional<String>;有权限且查到时,String 是状态(本例为“运输中”),否则为空。Optional 在这里是 Java 表达“可能没有结果”的容器,不是 @Tool 方法参数。OrderLookupResult 是返回给模型的结果:code 是 OK、INVALID_ID、AUTH_REQUIRED 或 NOT_FOUND_OR_FORBIDDEN;status 只在 OK 时有值。record 是 Java 定义这种只装数据的结构的写法。

java
import java.util.Optional;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.stereotype.Component;

interface OrderGateway {
    Optional<String> findStatusVisibleTo(String userId, String orderId);
}

record OrderLookupResult(String code, String status) {}

@Component
class OrderTools {
    private final OrderGateway gateway;

    OrderTools(OrderGateway gateway) {
        this.gateway = gateway;
    }

    @Tool(
        name = "getOrderStatus",
        description = "只读查询当前登录用户可见的订单发货状态;用户询问具体订单是否发货时使用"
    )
    public OrderLookupResult getOrderStatus(
        @ToolParam(description = "订单编号,例如 A123") String orderId,
        ToolContext toolContext
    ) {
        Object identity = toolContext.getContext().get("userId");
        if (!(identity instanceof String userId) || userId.isBlank()) {
            return new OrderLookupResult("AUTH_REQUIRED", null);
        }
        if (orderId == null || !orderId.matches("A[0-9]{3}")) {
            return new OrderLookupResult("INVALID_ID", null);
        }
        return gateway.findStatusVisibleTo(userId, orderId)
            .map(status -> new OrderLookupResult("OK", status))
            .orElseGet(() -> new OrderLookupResult("NOT_FOUND_OR_FORBIDDEN", null));
    }
}

@Tool 的 name 是模型要请求的工具名,须在本次提供的工具集合里唯一;description 说清何时可用、它只读。@ToolParam 解释模型要提供的 orderId;默认参数为必填,确实可省略的参数才用 required = false。Spring AI 根据方法与参数生成输入 JSON Schema,不必为这个普通方法手写 Schema。注解、参数和 Schema 的官方说明

可以把模型看到的参数结构理解成下面的简化示意;实际生成的完整 Schema 以运行时的 ToolDefinition.inputSchema() 为准:

json
{
  "type": "object",
  "properties": {
    "orderId": {"type": "string", "description": "订单编号,例如 A123"}
  },
  "required": ["orderId"]
}

这里没有 userId:它通过 ToolContext 从应用传给 Java 方法,模型既不负责填写,也不应在提示中看到敏感身份值。Schema 约束能帮助模型形成正确参数,却不能代替 orderId.matches(...) 校验和 findStatusVisibleTo(...) 的服务端授权检查。即使模型写出了看似合法的 A123,也不能证明当前用户有权读取。

接下来把工具放进一次请求。OrderChatService 是问答服务;构造函数里的 ChatModel 是已经配置好的模型连接,OrderTools 是刚才的 Spring Bean。ask 收到服务端取得的 userId 和用户原话 question,产出最终答复。登录身份必须由外层认证系统传入,不接受聊天文本自称“我是 u7”。

java
import java.util.Map;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.stereotype.Service;

@Service
class OrderChatService {
    private final ChatClient chatClient;
    private final OrderTools orderTools;

    OrderChatService(ChatModel chatModel, OrderTools orderTools) {
        this.chatClient = ChatClient.builder(chatModel).build();
        this.orderTools = orderTools;
    }

    String ask(String userId, String question) {
        if (userId == null || userId.isBlank()) {
            return "请先登录再查询订单。";
        }
        return chatClient.prompt()
            .system("只依据工具结果回答订单状态。"
                + " code=OK 时解释 status;其他 code 不猜测状态。"
                + " 不能承诺尚未查到的物流进度。")
            .user(question)
            .tools(orderTools)
            .toolContext(Map.of("userId", userId))
            .call()
            .content();
    }
}

.tools(orderTools) 把工具只提供给这次调用;.toolContext(...) 放服务端身份;.call().content() 取最终文本。如果所有问答都需要同一只读工具,可以在创建 ChatClient 时用 .defaultTools(orderTools)。但官方文档指出:每次请求的 .tools(...) 会追加到默认工具集合,而不是替换默认工具。把退款、改地址等高风险工具放进默认集合,会让原本无关的请求也看见它们;应按请求明确选择。工具注册范围的官方说明

上述代码展示工具调用主路径,OrderGateway 的数据库实现、认证入口、模型供应商配置与对外 HTTP 控制器仍需按项目接上。示例仅做只读查询,没有替用户下单、退款或改地址。

沿 A123 再走一次,并看失败怎样结束 ​

正常输入为 ask("u7", "订单 A123 发货了吗?")。假设 OrderGateway 查到 u7 可见的 A123 状态为“运输中”:模型第一次响应提出 getOrderStatus({"orderId":"A123"});Java 方法读 ToolContext 得到 u7,校验编号后查询;返回 OrderLookupResult("OK", "运输中");结果被序列化并交回模型;第二次模型响应给出“已发货,目前运输中”。这些中间响应是可能的演示轨迹,具体模型是否调用工具及最终措辞要用实际运行和评测确认。

再看三个边界:

| 情况 | 程序应做什么 | 最终应如何回答 | |---|---| | 模型传 orderId="xxx" | 参数校验返回 INVALID_ID,不查库 | 请用户核对订单号,不编造状态 | | u8 询问只属于 u7 的 A123 | OrderGateway 按当前身份过滤,返回 NOT_FOUND_OR_FORBIDDEN | 不暴露“此单属于 u7”,只说明当前无法查询 | | 订单服务超时 | 由应用按明确策略记录与处理,不把异常堆栈交给模型 | 说明暂时无法核实,给出重试或人工查询办法 |

真实故障通常会抛异常,而不是返回业务 code。Spring AI 2.0.1 默认的 ToolExecutionExceptionProcessor 对运行时异常会把异常消息作为工具结果交回模型;对受检异常与 Error 则会重新抛出。生产工具的异常消息可能含内部地址或敏感参数,所以不能照单全收。可以配置 spring.ai.tools.throw-exception-on-error=true,由应用统一捕获并返回不泄漏细节的错误;也可替换处理器,给模型一条经过脱敏的失败结果。官方异常处理说明

另外要给调用循环设预算。Spring AI 2.0.1 的 ToolCallingManager 有每工具和总调用次数上限,配置项如 spring.ai.tools.limits.max-total-tool-calls;业务上还要设置接口超时、重试次数与请求成本预算。达到上限不是“模型终于查对了”,应把它当成失败状态处理,不直接把不完整答案显示为可信结果。官方调用限制说明

怎样验证 Schema、权限和真实调用链 ​

先测不用联网模型就能测的部分。JUnit 5 是 Java 的测试框架;下面的测试给 OrderGateway 一个只对 u7 返回结果的替身。ToolCallbacks.from(tools) 把注解方法转换成 ToolCallback;getToolDefinition().inputSchema() 读取框架实际生成的 Schema;callback.call(...) 模拟模型已经提出了 A123。ObjectMapper 是 Jackson 解析 JSON 文本的工具,测试用它读取结果字段,验证工具实际收到的服务端身份及返回值。ToolCallback 与 ToolContext API

java
import java.util.Map;
import java.util.Optional;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.support.ToolCallbacks;
import static org.junit.jupiter.api.Assertions.*;

class OrderToolsTest {
    @Test
    void schemaAndAuthorizationAreEnforced() throws Exception {
        OrderGateway gateway = (userId, orderId) ->
            userId.equals("u7") && orderId.equals("A123")
                ? Optional.of("运输中") : Optional.empty();
        OrderTools tools = new OrderTools(gateway);
        ToolCallback callback = ToolCallbacks.from(tools)[0];

        assertEquals("getOrderStatus", callback.getToolDefinition().name());
        String schema = callback.getToolDefinition().inputSchema();
        assertTrue(schema.contains("orderId"));
        assertFalse(schema.contains("userId"));

        String ownResult = callback.call(
            "{\"orderId\":\"A123\"}", new ToolContext(Map.of("userId", "u7")));
        ObjectMapper mapper = new ObjectMapper();
        assertEquals("运输中", mapper.readTree(ownResult).path("status").asText());

        String otherResult = callback.call(
            "{\"orderId\":\"A123\"}", new ToolContext(Map.of("userId", "u8")));
        assertEquals("NOT_FOUND_OR_FORBIDDEN",
            mapper.readTree(otherResult).path("code").asText());
        assertTrue(mapper.readTree(otherResult).path("status").isNull());
    }
}

这项测试检查工具声明和执行,不证明模型一定会选择它。再做一组集成测试:用可控的模型测试替身依次返回“请求 getOrderStatus”与“最终答复”,断言只调用一次授权查询、结果确实交回第二轮模型;另测模型不调用工具、参数非法、订单服务超时、重复调用触及上限。最后再用真实模型跑一小批有期望答案的样例,比较调用选择、权限和最终答复,不要只凭一次演示成功就认为可靠。

哪些行为属于 2.0,不能从旧文章直接推断 ​

Spring AI 1.x 的底层 ChatModel 曾包含内部工具执行循环。Spring AI 2.0 把这条自动循环放在 ChatClient 自动注册的 ToolCallingAdvisor 中。 因此本文的 ChatClient.prompt().tools(...).call() 会完成默认工具往返;若改成直接调用 ChatModel.call(...),模型返回的工具请求不会被自动执行,开发者要自己驱动循环或回到 ChatClient。官方升级说明、2.0 工具文档

Q3 适合快速记住 @Tool、@ToolParam 与 ChatClient 的入口。Q18 要多答四层:模型只提出调用;Schema 决定模型可以填什么;ToolContext 与业务服务负责身份和授权;异常、调用次数及回归测试负责工程边界。这样才不会把“框架帮我分发方法”误说成“框架自动替我保证安全与正确”。

面试时怎样回答 ​

可以这样说:“在 Spring AI 2.0 中,我先用 @Tool 声明 Java 工具方法,并用 @ToolParam 描述模型需要填写的参数。Spring AI 由方法签名生成 JSON Schema;我通过 ChatClient 的 .tools(...) 按请求注册工具。模型看到工具说明后可能返回工具名和参数,ToolCallingAdvisor 会交给 ToolCallingManager 执行对应的 ToolCallback,把结果放回对话,再让模型给最终答复。登录用户 ID 等可信数据经 ToolContext 传给工具,不让模型填写;工具方法里还要校验参数、查权限、处理超时,并限制调用次数。我会单测实际生成的 Schema 与工具授权,再用模拟模型和真实样例测完整循环。2.0 里若直接调底层 ChatModel,工具循环不会自动执行。”

若追问“@Tool 是否足以保证模型一定调用工具”,回答是否定的:模型决定是否请求,应用要用评测检查选择;关键业务动作更应由程序明确控制。若追问“能不能自动把工具结果返回用户”,默认是结果再给模型;@Tool(returnDirect = true) 才会走直接返回的不同路径,适用于工具输出已经是最终答复的情况。本例需要模型解释状态,所以保留默认行为。官方 returnDirect 说明

资料 ​

最后更新2026-09-26
难度P1
频率high
阅读20 min
主题spring-ai / tool-calling / chatclient
觉得有帮助?把这个链接转给正在求职的朋友 · 用 Ctrl + K 全站搜索其它题