做 AI 订单客服,最先遇到的问题往往很实际。
用户发来一条消息:
帮我查一下订单 A1024 到哪了。
订单数据存在业务系统里,模型手里没有这条记录。要给它提供一个可以调用的 Java 方法:
queryOrder("A1024")
这就是 Tool Calling。
我把本次查询的每一轮消息都打印出来了。结果就是:1 次用户请求,内部调用 2 次模型,执行 1 次 Java 工具。
这篇就用这个订单案例跑通 Spring AI 2.0 Tool Calling,看清楚两次模型调用之间发生了什么。
先看运行结果
我在 ToolCallingAdvisor 循环内外各放了一个日志 Advisor,运行之后得到了下面这条轨迹:
[outside-loop] before #1 messages=[user]
[inside-loop] before #1 messages=[user]
[model] call #1
[inside-loop] after #1 hasToolCalls=true
[tool] queryOrder(A1024), tenantId=shop-001
[inside-loop] before #2 messages=[user, assistant, tool]
[model] call #2
[model] received tool result: {"orderId":"A1024","status":"SHIPPED","carrier":"顺丰","trackingNo":"SF1234567890"}
[inside-loop] after #2 hasToolCalls=false
[outside-loop] after #1 hasToolCalls=false
[answer] 查到了:订单 A1024 已发货,承运商是顺丰,运单号 SF1234567890。
案例使用 Java 21、Spring Boot 4.0.8 和 Spring AI 2.0.1。为了固定两轮响应,测试使用 ScriptedOrderChatModel 驱动 Spring AI 调用链。本文结论覆盖框架执行过程;真实模型选择工具的质量还需要另外测试。
第一步:创建订单工具
项目引入 spring-boot-starter-webmvc、spring-ai-starter-model-openai 和 Spring AI 2.0.1 BOM 后,先创建一个普通的 Spring Bean:
@Component
public class OrderTools {
@Tool(
name = "queryOrder",
description = "根据订单号查询物流状态、承运商和运单号"
)
public OrderSnapshot queryOrder(
@ToolParam(description = "订单号,例如 A1024")
String orderId,
ToolContext toolContext) {
String tenantId = (String) toolContext
.getContext()
.get("tenantId");
System.out.printf(
"[tool] queryOrder(%s), tenantId=%s%n",
orderId,
tenantId);
return orderService.query(tenantId, orderId);
}
}
这里有三个重要的地方。
@Tool 让模型知道这个方法能做什么
Spring AI 会读取工具名称、描述和方法签名,然后生成包含 JSON Schema 的 Tool Definition。工具描述会影响模型的选择,所以要写清适用场景和返回数据。
@ToolParam 说明参数应该怎么填
模型会把用户的提问转成 JSON 参数。订单号、日期和金额格式,都可以写进参数说明里。
ToolContext 传递本地上下文
tenantId 来自当前登录会话,由应用确定。官方文档提到,ToolContext 数据不会发给模型。用户 ID、租户 ID、权限和 Trace ID 都可以放在里面。
第二步:把工具交给 ChatClient
然后创建一个订单客服接口:
@RestController
public class OrderController {
private final ChatClient chatClient;
OrderController(
ChatClient.Builder builder,
OrderTools orderTools) {
this.chatClient = builder
.defaultTools(orderTools)
.build();
}
@GetMapping("/orders/ask")
String ask(@RequestParam String message) {
return chatClient.prompt()
.system("""
你是订单客服。
涉及订单状态时必须调用查询工具,
根据工具返回结果回答。
""")
.user(message)
.toolContext(Map.of("tenantId", "shop-001"))
.call()
.content();
}
}
.defaultTools(orderTools) 会把订单工具注册到这个 ChatClient 的每次请求中。某次请求才需要工具时,也可以在 prompt() 后调用 .tools(orderTools)。
Spring AI 2.0 的 ChatClient 会自动注册 ToolCallingAdvisor,常规业务代码不需要手动创建它。
到这里,最小的 Tool Calling 已经接上了。
两次模型调用做了什么
第一次模型请求会包含:
SystemMessage + UserMessage + ToolDefinition
模型读到“订单 A1024”后,就会发出一个 tool call:
{
"name": "queryOrder",
"arguments": {
"orderId": "A1024"
}
}
此时还没有查询订单表。这个 JSON 表示模型建议应用执行 queryOrder。
ToolCallingAdvisor 检查到 hasToolCalls=true 后,就会把响应交给 ToolCallingManager。Manager 根据名称找到工具,把 JSON 转成 Java 参数,然后执行方法。
工具返回:
{
"orderId": "A1024",
"status": "SHIPPED",
"carrier": "顺丰",
"trackingNo": "SF1234567890"
}
Spring AI 同时把这两条消息追加到当前调用的历史中:
AssistantMessage(tool_call: queryOrder)
ToolResponseMessage(order status JSON)
所以,第二轮请求的消息类型是:
messages=[user, assistant, tool]
第二次调用会读取工具结果,把 JSON 整理成用户可以理解的客服回答。如果这一轮仍然产生 tool call,ToolCallingAdvisor 还会进入下一轮。模型返回不包含 tool call 的回复后,循环就结束了。
整条链路可以简化为下面六步:
用户问题
↓
第一次调用模型:选择工具,生成参数
↓
ToolCallingManager:执行本地 Java 方法
↓
ToolResponseMessage:追加工具结果
↓
第二次调用模型:组织最终回答
↓
没有新 tool call,结束
四个组件,记住它们各自的作用
| 组件 | 在订单案例中的职责 |
|---|---|
ChatClient | 组织 Prompt、工具和 Advisor 链 |
ToolCallingAdvisor | 检查 tool call,控制工具循环 |
ToolCallingManager | 解析参数并执行 Java 工具 |
ToolResponseMessage | 把工具结果交给下一轮模型 |
Spring AI 2.0 把工具执行循环提到 Advisor 链上,日志、Memory、重试和观测代码都可以根据顺序进入循环内外。
ToolCallingAdvisor 的默认顺序是 Ordered.HIGHEST_PRECEDENCE + 300。本次测试中,它前面的日志执行了 1 次,后面的日志跟着模型请求执行了 2 次。
请求审计应该放在循环外。要检查每轮模型延迟、工具参数和失败原因时,日志就要进入循环,同时对订单号和工具结果做好脱敏。
一个有用的对照:returnDirect
有些工具已经返回了前端可以直接使用的 JSON,可以设置为:
@Tool(
name = "queryOrderDirect",
description = "返回订单原始状态",
returnDirect = true
)
String queryOrderDirect(String orderId) {
return """
{"orderId":"%s","status":"SHIPPED"}
""".formatted(orderId);
}
对照测试的结果是:
[return-direct] {"orderId":"A1024","status":"SHIPPED"}
这条路径只调用 1 次模型、执行 1 次工具,原始结果直接返回客户端。对话式客服还需要第二轮模型来组织自然语言回答。
回到开头
用户只问了这么一句,Spring AI 内部调用了两次模型。
现在可以给出确切的答案:
- 第一次调用负责选择工具并生成参数。
ToolCallingManager在应用中调用 Java 方法。ToolResponseMessage将查询结果放入消息历史。- 第二次调用读取工具结果,再整理好答案。
把这四步记下来,之后再看多工具 Agent、Tool Search、Chat Memory 和 MCP,就会有一条明了的主线。
下一篇文章会解决一个实际问题:当 Agent 里有几十个工具时,怎么用 ToolSearchToolCallingAdvisor 只把当前需要的工具发给模型。