Spring AI Tool Calling 实战:让 Java Agent 调用本地 Bean 工具方法

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);
}
`

生产建议:

  1. 不要给模型开放任意 SQL、任意 URL、任意 Shell 命令。
  2. 不要把 userIdtenantIdrole 完全作为模型可控参数。
  3. limit、时间范围、状态枚举做上限和白名单。
  4. 返回字段做脱敏,不返回手机号、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=acmeoperatorId=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

职责拆开后,工具层只做三件事:

  1. 定义给模型看的工具边界。
  2. 把模型参数转成受控的业务调用。
  3. 做日志、权限、异常和返回值收敛。

真正的业务规则仍然放在 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 专栏,后面会继续整理这类可落地的工程实践。

相关推荐
AI人工智能+电脑小能手2 小时前
【大白话说Java面试题 第110题】【并发篇】第10题:CAS 存在哪些问题?
java·开发语言·面试
瀚高PG实验室2 小时前
java中间件无法连接数据库
java·数据库·中间件·瀚高数据库
东南门吹雪2 小时前
JAVA TCP socket编程框架
java·高并发·socket·tcp·nio
xingyuzhisuan2 小时前
缓存命中率提升方案:从 30% 优化至 82% 全流程优化记录
java·开发语言·缓存·ai
guyoung2 小时前
BoxAgnts 工具系统(6)——多 Provider 适配与 Agent 查询循环
rust·agent·ai编程
一条泥憨鱼2 小时前
Java开发效率神器:Lombok从入门到精通!
java·后端·学习·开发·lombok
Jinkxs2 小时前
Python基础 - 初识内置函数 Python自带的便捷工具
android·java·python
熠熠仔2 小时前
Spring Boot 与 MyBatis-Plus 空间几何数据集成指南
spring boot·后端·mybatis
奥利奥夹心脆芙3 小时前
零基础调试 Java 代码:Gemini 报错排查完整实操指南
java