Spring AI 2.0 Tool Calling 实战:用一个订单查询跑通整个调用链

做 AI 订单客服,最先遇到的问题往往很实际。

用户发来一条消息:

帮我查一下订单 A1024 到哪了。

订单数据存在业务系统里,模型手里没有这条记录。要给它提供一个可以调用的 Java 方法:

java 复制代码
queryOrder("A1024")

这就是 Tool Calling。

我把本次查询的每一轮消息都打印出来了。结果就是:1 次用户请求,内部调用 2 次模型,执行 1 次 Java 工具。

这篇就用这个订单案例跑通 Spring AI 2.0 Tool Calling,看清楚两次模型调用之间发生了什么。

先看运行结果

我在 ToolCallingAdvisor 循环内外各放了一个日志 Advisor,运行之后得到了下面这条轨迹:

text 复制代码
[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:

java 复制代码
@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

然后创建一个订单客服接口:

java 复制代码
@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 已经接上了。

两次模型调用做了什么

第一次模型请求会包含:

text 复制代码
SystemMessage + UserMessage + ToolDefinition

模型读到"订单 A1024"后,就会发出一个 tool call:

json 复制代码
{
  "name": "queryOrder",
  "arguments": {
    "orderId": "A1024"
  }
}

此时还没有查询订单表。这个 JSON 表示模型建议应用执行 queryOrder。

ToolCallingAdvisor 检查到 hasToolCalls=true 后,就会把响应交给 ToolCallingManager。Manager 根据名称找到工具,把 JSON 转成 Java 参数,然后执行方法。

工具返回:

json 复制代码
{
  "orderId": "A1024",
  "status": "SHIPPED",
  "carrier": "顺丰",
  "trackingNo": "SF1234567890"
}

Spring AI 同时把这两条消息追加到当前调用的历史中:

text 复制代码
AssistantMessage(tool_call: queryOrder)
ToolResponseMessage(order status JSON)

所以,第二轮请求的消息类型是:

text 复制代码
messages=[user, assistant, tool]

第二次调用会读取工具结果,把 JSON 整理成用户可以理解的客服回答。如果这一轮仍然产生 tool call,ToolCallingAdvisor 还会进入下一轮。模型返回不包含 tool call 的回复后,循环就结束了。

整条链路可以简化为下面六步:

text 复制代码
用户问题
   ↓
第一次调用模型:选择工具,生成参数
   ↓
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,可以设置为:

java 复制代码
@Tool(
    name = "queryOrderDirect",
    description = "返回订单原始状态",
    returnDirect = true
)
String queryOrderDirect(String orderId) {
    return """
        {"orderId":"%s","status":"SHIPPED"}
        """.formatted(orderId);
}

对照测试的结果是:

text 复制代码
[return-direct] {"orderId":"A1024","status":"SHIPPED"}

这条路径只调用 1 次模型、执行 1 次工具,原始结果直接返回客户端。对话式客服还需要第二轮模型来组织自然语言回答。

回到开头

用户只问了这么一句,Spring AI 内部调用了两次模型。

现在可以给出确切的答案:

  1. 第一次调用负责选择工具并生成参数。
  2. ToolCallingManager 在应用中调用 Java 方法。
  3. ToolResponseMessage 将查询结果放入消息历史。
  4. 第二次调用读取工具结果,再整理好答案。

把这四步记下来,之后再看多工具 Agent、Tool Search、Chat Memory 和 MCP,就会有一条明了的主线。

下一篇文章会解决一个实际问题:当 Agent 里有几十个工具时,怎么用 ToolSearchToolCallingAdvisor 只把当前需要的工具发给模型。

相关推荐
xieliyu.6 小时前
【JavaEE进阶】Spring Boot 日志:从 System.out 到 @Slf4j,6 个级别 + 4 类配置一篇讲透
java·spring boot·spring·java-ee·springboot·logback·slf4j
IT_Octopus6 小时前
Apollo 配置中心入门:一所“小学校“读懂 Portal、Meta Server、Config Service
java·后端·spring
ly768910 小时前
Spring Security 过滤器链定制:认证、CORS 与 CSRF 的先后顺序
java·spring boot·spring·spring security·csrf·cors·过滤器链
ly768912 小时前
Kafka 消费者重试与死信:Spring for Apache Kafka 的边界设计
spring·kafka·apache·并发消费·消费者重试·死信主题·位移提交
OxYGC1 天前
Spring 面经系列 · 第 6 篇:Spring MVC 请求全流程、父子容器与零配置原理
java·spring·mvc
小尹哥-程序员1 天前
第9集:Spring AI Advisor链:像Spring MVC拦截器一样优雅
人工智能·spring·mvc
9527!到!1 天前
Springboot @value读取配置参数踩坑&推荐解决方案
java·spring boot·spring
萧瑟余晖1 天前
Spring Data JPA高级特性详解
后端·spring
frjc1 天前
Spring AI 里 ChatClient 和 ChatModel 有什么区别?什么时候该用哪个?
java·开发语言·spring·ai