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 只把当前需要的工具发给模型。

相关推荐
budingxiaomoli2 小时前
Spring统一功能处理
java·后端·spring
budingxiaomoli11 小时前
Spring AOP
java·spring·java-ee
wang_shu_mo_ran1 天前
MyBatis 从入门到进阶(一)
java·spring·mybatis
瑞雪兆丰年兮1 天前
JavaWeb-day14-SpringBootWeb原理篇
java·开发语言·spring boot·spring·bean·mybatis·javaweb
杨凯凡2 天前
Spring AI RAG 实战:向量检索、租户过滤、引用溯源与防幻觉边界
java·人工智能·spring
SL_staff3 天前
JVS-BI 实战:业务人员如何用零代码 ELT + 可视化配置构建实时经营分析闭环
java·spring·全栈
nice_lcj5203 天前
【从零理解 Spring AI · 12】MCP 的两种角色:做客户端还是做服务端
java·后端·spring·spring ai·ai应用开发
杨凯凡3 天前
Spring Framework 7 HTTP Interface 实战:声明式 RestClient、超时与测试
java·spring·http
Java码农也是农4 天前
Spring 事务详解:@Transactional 如何生效、何时回滚、传播行为怎么选
java·spring boot·spring·事务
驭渊的小故事5 天前
Spring AOP 核心概念与通知类型详解
java·后端·spring