Spring AI Tool Calling 实战:让 Java Agent 调用本地 Bean 工具方法
摘要:很多 Java 后端已经听过 Agent、MCP、工具调用,但真正落地到 Spring Boot 项目时,第一步通常不是接一套复杂的远程工具协议,而是先把本地服务里的一个 Bean 方法安全地暴露给模型调用。本文基于 Spring AI 官方 Tool Calling 文档,演示如何用 @Tool 把普通 Java 方法注册成工具,并补充参数、返回值、异常、上下文、日志和生产边界。读完后,你应该能搭出一个最小可用的 Spring AI Tool Calling 骨架,并知道哪些代码不能直接交给模型调用。
验证状态:本文 API 机制来自 Spring AI 官方文档
Tool Calling / Tools章节(当前访问到的参考文档版本显示为 Spring AI 1.1.7)。本机当前没有 JDK / Maven,未做本地编译运行;代码按官方示例和工程骨架整理,具体依赖版本、模型 starter、方法签名请以你项目实际 Spring AI 版本和 IDE 提示为准。
1. Tool Calling 解决的到底是什么问题
在传统后端系统里,用户发起请求后,代码自己决定调用哪个 Service、哪个 Repository、哪个三方接口。
进入 Agent 场景后,流程会变成这样:
用户自然语言请求
|
v
大模型判断是否需要工具
|
v
Spring AI 把工具定义发给模型
|
v
模型选择工具并生成参数
|
v
Spring AI 调用本地 Java 方法
|
v
工具结果返回给模型
|
v
模型生成最终回答
这和普通函数调用的区别在于:工具是否被调用、调用哪个工具、参数如何组织,先由模型根据工具描述做一次决策。
所以 Tool Calling 的工程重点不是"加一个注解就完了",而是下面几个问题:
| 问题 | 后端要负责什么 |
|---|---|
| 模型什么时候该调用工具 | 工具名称和描述必须清楚 |
| 模型能传什么参数 | 参数类型、字段含义、边界要明确 |
| 工具执行失败怎么办 | 异常要可控,不能把内部错误直接泄露 |
| 多租户/权限如何处理 | 不要把租户、用户权限完全交给模型参数 |
| 结果怎么给模型 | 返回值要稳定、简短、可解释 |
| 生产怎么排查 | 记录工具名、耗时、结果摘要和失败原因 |
本文用一个订单查询工具做例子,因为它足够贴近 Java 后端项目,又不会依赖复杂业务。
2. 环境版本和依赖示例
一个最小 Spring Boot + Spring AI 项目通常需要:
| 组件 | 示例版本 / 状态 | 说明 |
|---|---|---|
| JDK | 17 或 21 | 以项目实际 Spring Boot 版本要求为准 |
| Spring Boot | 3.x | Spring AI 新版本通常更适配 Boot 3 生态 |
| Spring AI | 1.1.x | 本文按官方 Tool Calling 文档整理 |
| 模型 | OpenAI / Anthropic / Ollama 等 | 取决于你使用的 starter 和配置 |
依赖示例仅作为骨架,版本建议跟随 Spring AI 官方 BOM:
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>${spring-ai.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
</dependencies>
配置示例:
spring:
ai:
openai:
api-key: ${OPENAI_API_KEY}
chat:
options:
model: gpt-4o-mini
如果你用的是 Ollama、Anthropic、Azure OpenAI 或其他模型,需要替换对应 starter 和配置项。本文重点在 Tool Calling 的 Java 侧设计,不绑定某一个模型厂商。
3. 第一个工具:把 Spring Bean 方法暴露给模型
Spring AI 官方文档给出的核心方式很直接:用 @Tool 标注一个方法,然后在调用 ChatClient 时通过 .tools(...) 提供给模型。
先定义一个订单查询工具:
java
`package com.example.agent.tool;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.stereotype.Component;
@Component
public class OrderTools {
private final OrderService orderService;
public OrderTools(OrderService orderService) {
this.orderService = orderService;
}
@Tool(description = "根据订单 ID 查询订单状态、金额和物流状态。只用于查询当前用户有权限访问的订单。")
public OrderView getOrderStatus(Long orderId) {
return orderService.findOrderView(orderId);
}
}
`
返回对象可以是一个简单 DTO:
java
`public record OrderView(
Long orderId,
String status,
String payStatus,
String deliveryStatus,
String latestMessage
) {
}
`
再在业务入口里使用:
java
`package com.example.agent.web;
import com.example.agent.tool.OrderTools;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class AgentController {
private final ChatClient chatClient;
private final OrderTools orderTools;
public AgentController(ChatClient.Builder builder, OrderTools orderTools) {
this.chatClient = builder.build();
this.orderTools = orderTools;
}
@GetMapping("/agent/ask")
public String ask(@RequestParam String question) {
return chatClient.prompt()
.user(question)
.tools(orderTools)
.call()
.content();
}
}
`
一次请求可以这样测试:
curl 'http://localhost:8080/agent/ask?question=帮我查一下订单 10086 现在到哪了'
理想情况下,模型会判断需要调用 getOrderStatus,生成 orderId=10086,Spring AI 调用本地 Bean 方法,然后模型把工具返回结果整理成自然语言。
4. @Tool 不是装饰品,描述写不好会直接影响调用质量
官方文档特别强调,工具描述会帮助模型判断何时、如何调用工具。实际项目里,很多 Tool Calling 不稳定,不是模型不会调工具,而是工具描述太像内部方法名。
不推荐这样写:
java
`@Tool(description = "query order")
public OrderView query(Long id) {
return orderService.findOrderView(id);
}
`
更推荐这样写:
java
`@Tool(
name = "getOrderStatus",
description = "根据订单 ID 查询订单当前状态。适用于用户询问订单是否已支付、是否已发货、物流到哪一步。参数 orderId 必须是订单编号,不要传手机号、用户名或商品编号。"
)
public OrderView getOrderStatus(Long orderId) {
return orderService.findOrderView(orderId);
}
`
对 Java 后端来说,可以按下面这张表检查工具描述:
| 描述项 | 要写清楚什么 | 示例 |
|---|---|---|
| 工具用途 | 解决哪类问题 | 查询订单状态、金额、物流状态 |
| 调用条件 | 用户问什么时才调用 | 询问订单进度、发货、支付状态 |
| 参数边界 | 参数代表什么,不代表什么 | orderId 是订单编号,不是手机号 |
| 权限边界 | 是否只能查当前用户 | 只能查询当前登录用户有权限的订单 |
| 结果含义 | 返回值如何解释 | deliveryStatus 表示物流阶段 |
工具描述越像给另一个后端同事看的接口文档,模型越不容易乱用。
5. 参数设计:不要把危险自由度交给模型
Tool Calling 的参数来自模型生成,这意味着参数设计要比普通 Controller 更保守。
下面这种工具很危险:
java
`@Tool(description = "执行数据库查询")
public String queryDatabase(String sql) {
return jdbcTemplate.queryForList(sql).toString();
}
`
它的问题不是能不能工作,而是自由度太高:模型可能生成任意 SQL,甚至误删、误查敏感数据。
更安全的做法是把工具收敛成有限动作:
java
`@Tool(description = "根据订单 ID 查询订单状态,只返回当前用户可见的摘要信息")
public OrderView getOrderStatus(Long orderId) {
return orderService.findOrderView(orderId);
}
@Tool(description = "查询订单最近 5 条状态变更记录,只用于排查订单状态流转")
public List<OrderEventView> getOrderRecentEvents(Long orderId) {
return orderService.findRecentEvents(orderId, 5);
}
`
如果参数复杂,建议使用结构化请求对象:
java
`public record OrderSearchRequest(
Long orderId,
String payStatus,
String deliveryStatus,
Integer limit
) {
}
@Tool(description = "按有限条件搜索当前用户的订单。limit 最大为 20,不能用于全量导出。")
public List<OrderView> searchOrders(OrderSearchRequest request) {
int limit = request.limit() == null ? 10 : Math.min(request.limit(), 20);
return orderService.search(request.orderId(), request.payStatus(), request.deliveryStatus(), limit);
}
`
生产建议:
- 不要给模型开放任意 SQL、任意 URL、任意 Shell 命令。
- 不要把
userId、tenantId、role完全作为模型可控参数。 - 对
limit、时间范围、状态枚举做上限和白名单。 - 返回字段做脱敏,不返回手机号、Token、身份证、内部备注等敏感信息。
6. 用 ToolContext 传租户和用户上下文
官方文档里有一个很关键的能力:ToolContext。它允许调用方把额外上下文传给工具方法,而且这些上下文不会作为普通工具参数交给模型自由生成。
示例:
java
`import org.springframework.ai.tool.ToolContext;
import org.springframework.ai.tool.annotation.Tool;
@Component
public class CustomerTools {
private final CustomerRepository customerRepository;
public CustomerTools(CustomerRepository customerRepository) {
this.customerRepository = customerRepository;
}
@Tool(description = "查询客户基础信息,只能查询当前租户下的客户")
public CustomerView getCustomerInfo(Long customerId, ToolContext toolContext) {
String tenantId = (String) toolContext.getContext().get("tenantId");
String operatorId = (String) toolContext.getContext().get("operatorId");
return customerRepository.findViewByTenantAndId(tenantId, operatorId, customerId);
}
}
`
调用时传入上下文:
java
`String response = chatClient.prompt()
.user("帮我看一下客户 42 的基本情况")
.tools(customerTools)
.toolContext(Map.of(
"tenantId", currentTenantId,
"operatorId", currentUserId
))
.call()
.content();
`
这个设计很重要。
模型可以生成 customerId=42,但不应该让模型自己决定 tenantId=acme 或 operatorId=admin。租户、登录用户、角色、数据权限应该来自后端认证上下文,而不是来自自然语言。
7. 异常处理:别让工具失败变成一串内部堆栈
官方文档提到,工具执行异常会被包装成 ToolExecutionException,并可通过 ToolExecutionExceptionProcessor 控制处理方式。Spring Boot starter 默认有异常处理实现,默认策略会把部分运行时异常消息返回给模型,而检查异常和 Error 通常会继续抛出。
项目里建议做两层处理。
第一层,在工具方法里把业务异常转成可读结果:
java
`@Tool(description = "查询订单状态。订单不存在或无权限时返回明确提示。")
public OrderView getOrderStatus(Long orderId) {
try {
return orderService.findOrderView(orderId);
} catch (OrderNotFoundException ex) {
return OrderView.notFound(orderId, "订单不存在或当前用户无权访问");
}
}
`
第二层,对系统异常统一兜底:
java
`import org.springframework.ai.tool.execution.DefaultToolExecutionExceptionProcessor;
import org.springframework.ai.tool.execution.ToolExecutionExceptionProcessor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class ToolCallingConfig {
@Bean
ToolExecutionExceptionProcessor toolExecutionExceptionProcessor() {
// 示例:生产环境更倾向于抛给调用方或返回统一错误,不把内部异常细节交给模型
return new DefaultToolExecutionExceptionProcessor(true);
}
}
`
日志里可以保留内部异常,但给模型或用户的内容要克制:
工具调用失败:订单服务暂时不可用,请稍后重试。
不要把下面这类信息原样返回:
SQLSyntaxErrorException: Unknown column internal_xxx in table order_secret
8. 加一层工具调用日志,后面排查会轻松很多
Agent 接入生产后,最难排查的不是"工具完全不工作",而是:模型偶尔不调用、偶尔参数错、偶尔调用了但结果不符合预期。
建议每次工具调用至少记录:
| 字段 | 说明 |
|---|---|
| traceId | 和接口请求打通 |
| conversationId | 多轮对话定位 |
| toolName | 被调用的工具名 |
| argumentsSummary | 参数摘要,敏感字段脱敏 |
| tenantId / operatorId | 来自后端上下文 |
| durationMs | 工具耗时 |
| resultType | success / business_empty / denied / system_error |
| resultSummary | 结果摘要,不打完整敏感数据 |
示例日志:
INFO tool.call traceId=8f3a tool=getOrderStatus orderId=10086 tenant=t1 operator=u7 durationMs=42 result=success
WARN tool.call traceId=8f3b tool=getOrderStatus orderId=10010 tenant=t1 operator=u7 durationMs=18 result=denied message="not_found_or_no_permission"
ERROR tool.call traceId=8f3c tool=getOrderStatus orderId=10011 tenant=t1 operator=u7 durationMs=3000 result=system_error error="order_service_timeout"
注意不要为了排查方便把完整 Prompt、完整工具返回、用户隐私字段全部打进日志。Agent 系统的日志本身也可能成为敏感数据源。
9. 一个更完整的项目结构建议
如果只是 demo,把工具方法写在 Controller 旁边也能跑。但真实项目建议分层:
com.example.agent
├── web
│ └── AgentController.java
├── tool
│ ├── OrderTools.java
│ ├── CustomerTools.java
│ └── ToolAuditLogger.java
├── service
│ ├── OrderService.java
│ └── CustomerService.java
├── security
│ └── CurrentUserContext.java
└── config
└── ToolCallingConfig.java
职责拆开后,工具层只做三件事:
- 定义给模型看的工具边界。
- 把模型参数转成受控的业务调用。
- 做日志、权限、异常和返回值收敛。
真正的业务规则仍然放在 Service,不要为了 Agent 单独复制一份业务逻辑。
10. 常见坑:Tool Calling 能跑,不代表能上线
最后总结几个生产边界。
| 坑 | 表现 | 建议 |
|---|---|---|
| 工具描述太短 | 模型不调用或乱调用 | 描述写清用途、参数、边界 |
| 参数自由度过高 | 生成任意 SQL / URL / 命令 | 设计有限动作和白名单参数 |
| 权限靠模型传参 | 用户可在自然语言里伪造租户 | 租户和用户来自后端上下文 |
| 返回值太大 | 模型上下文被工具结果挤爆 | 返回摘要,分页和限制数量 |
| 异常直接透出 | 泄露表名、字段、内部接口 | 统一错误消息,内部日志保留 |
| 没有调用日志 | 无法解释模型为什么这么答 | 记录 toolName、参数摘要、耗时、结果 |
| 工具副作用太强 | 模型误调用导致真实修改 | 写操作先做人审、确认或幂等保护 |
我的建议是:第一批 Tool Calling 不要从"下单、退款、删除、审批通过"这类强副作用动作开始。
更稳的起点是:
- 查询订单状态;
- 查询知识库条目;
- 查询库存摘要;
- 查询告警说明;
- 生成只读报表;
- 做有限范围的诊断建议。
等只读工具跑稳、日志和权限体系补齐后,再考虑加入写操作,并且要有人工确认、幂等键、审计日志和回滚路径。
11. 总结
Spring AI Tool Calling 给 Java 后端接 Agent 提供了一个很自然的入口:把普通 Spring Bean 方法包装成模型可调用的工具。
但在真实工程里,@Tool 只是开始。真正决定系统能不能长期运行的是:工具描述是否清楚、参数是否收敛、权限是否后端控制、异常是否可控、日志是否可追踪、强副作用操作是否有人审和回滚。
如果你正在做 Java AI 工程落地,可以先从一个只读 Bean 工具开始:用最小范围验证模型是否会正确选工具、Spring AI 是否能正确调用、本地服务是否能稳定返回、日志是否能解释每次调用。这个小闭环跑稳以后,再扩展到 MCP、远程工具、复杂 Agent 工作流,会比一开始就追求"大而全"的 Agent 平台更可靠。
如果你关注 Java 后端、Spring AI、MCP/Agent 落地和线上问题排查,可以关注我的 CSDN 专栏,后面会继续整理这类可落地的工程实践。