Appearance
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 |
ChatModel | Spring AI 中访问某个聊天模型的底层接口 | 把问题和工具说明发给模型的能力 |
ChatClient | Spring AI 的高层对话入口,可配置系统要求、工具和顾问链 | 本例发起一次客服问答的对象 |
@Tool | 声明一个 Java 方法可以作为工具暴露 | 标记查询订单方法 |
@ToolParam | 给工具参数写模型能理解的说明及必填状态 | 解释 orderId 是订单编号 |
| Tool Schema / JSON Schema | 用结构化规则描述工具名称、参数及必填项 | 告诉模型 getOrderStatus 需要字符串 orderId |
ToolCallback | Spring AI 内部统一的工具定义加执行入口 | 把被 @Tool 标记的方法接入工具机制 |
ToolCallingAdvisor | ChatClient 中负责工具请求、执行、结果回传与继续调用的组件 | 让“查 A123”走完一轮往返 |
ToolContext | 只给应用里的工具使用、不会当作工具参数发给模型的上下文 | 已验证的当前用户 ID u7 |
OrderGateway | 本文给真实订单服务留的接口名 | 用当前用户和订单号查询可见的状态 |
OrderTools | 本文承载 @Tool 方法的 Java 类 | 校验参数并调用 OrderGateway |
OrderLookupResult | 查询结果的结构,code 表示结果类别,status 表示订单状态 | code=OK、status=运输中 |
orderId 是模型可提议的订单编号;userId 是服务端认证得到的当前用户标识。两者不能互换。特别是 userId,不能让模型从用户文字里编一个,再拿它通过权限检查。
一次调用究竟由谁做了什么
图中的蓝色“模型提议”只是一份调用意图。Spring AI 接到它后才在 Java 进程里执行已注册的方法。订单结果“运输中”来自业务服务,不是模型训练资料里的知识。

按图从左到右看,流程是:
- 应用收到用户问题,并拿到已通过登录校验的用户标识
u7。 ChatClient把问题和允许使用的工具说明发给模型。工具说明含名称、描述、参数 Schema;服务端用户标识走ToolContext,不放进模型可填的工具参数里。- 模型决定是否需要工具。此例它提出
getOrderStatus,参数为{"orderId":"A123"}。模型也可能直接回答或提错参数,所以程序不能把“提议”当作事实。 ToolCallingAdvisor把这份请求交给ToolCallingManager,后者找到匹配的ToolCallback,执行OrderTools.getOrderStatus(...)。方法用u7和A123查询当前用户有权看到的订单。- 方法返回
{"code":"OK","status":"运输中"}。默认结果转换器会把 Java 返回对象序列化为模型可读的文本;ChatClient把结果放进本轮历史,再向模型发起下一次调用。官方调用循环 - 模型据查询结果回答:“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 说明
资料
- Spring AI 2.0.1:Tool Calling:工具声明、Schema、执行循环、ToolContext、异常与调用限制。
- Spring AI:ToolCallingAdvisor:2.0 自动工具循环的位置与扩展点。
- Spring AI:Upgrade Notes:1.x 到 2.0 的工具执行差异。
- Spring AI:Getting Started:Spring Boot 兼容范围、BOM 和模型 starter 的接入。