夜雨聆风学习资料网

ARTICLE · 1196595

AI 工具调用写了 3 个月,Java 后端踩的 5 个坑都在这

AI 工具调用写了 3 个月,Java 后端踩的 5 个坑都在这

AI 工具调用写了 3 个月,Java 后端踩的 5 个坑都在这

◆先说结论:工具调用,是把「一句话」变成「一次函数调用」

工具调用,老名字叫 Function Calling,做的事其实很朴素:你告诉模型「我这有几个方法能用」,模型在回答前先判断「这题要不要动手、动哪个手」,要动手就吐出一次调用意图(调哪个工具、传什么参数),框架替你执行,再把结果喂回模型,最后由模型组织成一句人话。

举个例子。用户问「帮我查下订单 12345678 到哪了」。模型不会去编一个假物流,它会回一个意图:我要调 `getOrderStatus("12345678")`。Spring AI 接住这个意图,真的去调你的 Java 方法,把返回值塞回上下文,模型再据此回你「这单昨天已从杭州发出,预计后天到」。

这就是它为什么是 Agent 的地基:模型从「会聊天」升级成「能动手」。客服自动查单、工单自动改状态、BI 里自动拉数——共同点都是「模型得去碰你系统里的真实数据」,光靠嘴是碰不到的。

好消息是,Spring AI 把这层做成了注解,你压根不用自己拼 schema。你写的方法上打一个 `@Tool`,参数上打 `@ToolParam`,框架负责两件事:把你的方法信息生成一段「说明书」塞进提示词给模型看;拿到模型返回后,把 JSON 里的参数映射成你方法要的类型再执行。

于是你要写的业务代码,就剩三件事:写方法、打注解、把它注册进 ChatClient。注册那步就一行:

ChatClient chatClient = ChatClient.builder(chatModel)        .defaultTools(new OrderTools())   // 把工具注册进来,一行搞定        .build();

看着简单,对吧?坑全在细节里。下面 5 个,一个比一个贵。

◆坑 1:@Tool 的描述当注释写,模型就当没看见

这是最便宜、也最致命的坑。

很多人把 `@Tool` 后面那个字符串,当成给人看的注释随手写:「查询订单」。你猜模型看到这个描述会怎么理解?它不知道这工具是「用户给了订单号才能用」还是「任何时候都能用」,也不知道返回的是状态还是明细。于是在该调的时候它不调,不该调的时候乱调。

记住一句话:`@Tool` 的 description 不是注释,是给模型看的指令。你写「何时用」,它才会在该用的时候用。

// 反例:模型看不出「啥时候该用」,只会瞎猜@Tool("查询订单")public String query(String id) { ... }// 正例:把「什么时候用、参数长啥样」写进描述@Tool(description = "根据订单号查询订单当前状态;当用户提到订单号、"        + "想查发货进度或是否签收时调用")public String getOrderStatus(        @ToolParam(description = "订单号,纯数字,例如 12345678") String orderId) { ... }

描述写得好不好,直接决定模型「选对工具」的概率。我的土办法是:把它当成在跟一个刚入职的实习生交代事情——「什么情况下你该干这个活,需要什么东西」。你越像交代人,模型越准。

◆坑 2:参数类型对不上,模型递给你的是字符串

第二个坑,是 Java 人最容易本能踩的:觉得「我方法参数当然要用最合适的类型」。

错。模型吐回来的是 JSON,里面的值首先都是字符串或数字。你方法参数写成 `int`、`LocalDate`、枚举、甚至自定义对象,框架就得替你做一轮类型转换。转得成就转,转不成就抛异常(或者模型直接收到一个 400 不知道怎么办)。日期格式、枚举取值、布尔值的真假表达,全是重灾区。

我的经验是一条土律:工具入参尽量收成 `String`,进方法后自己转。你要的严格校验,放在 Java 里做,写在方法第一行;别指望模型「乖乖按格式给你」。

@Tool(description = "按日期区间统计新增订单数")public String countOrders(        @ToolParam(description = "开始日期,格式必须是 yyyy-MM-dd,例如 2026-10-01") String startDate,        @ToolParam(description = "结束日期,格式必须是 yyyy-MM-dd") String endDate) {    // 自己在方法里转 + 校验,别让模型替你保证格式    LocalDate start = LocalDate.parse(startDate);    LocalDate end = LocalDate.parse(endDate);    if (end.isBefore(start)) {        return "结束日期早于开始日期,请让用户重新确认时间范围"; // 把问题说给模型听    }    return String.valueOf(orderService.countBetween(start, end));}

如果是枚举,别偷懒。要么参数收 String、在方法里 `Enum.valueOf`,要么在 `@ToolParam` 的描述里把可选值一个个列出来,让模型只能在给定的几个里挑。你少写这一句,模型就可能给你传一个「订单状态是"已完成"还是"complete"」的薛定谔值。

◆坑 3:工具返回值太大,一次把上下文顶爆

这个坑专坑「数据多」的业务。

有个兄弟写了个工具 `listOrders(userId)`,返回 `List<Order>` 直接 JSON 序列化,一个用户 800 单,序列化出来几万字符,全被 Spring AI 塞回上下文。结果就是:token 账单暴涨,模型还被一堆冗余字段(创建时间、更新时间、内部 id)淹没,回答质量肉眼可见地塌。更糟的是,几次调用叠起来,直接把模型的上下文窗口撑爆,接口开始报错。

工具返回值的正确姿势是:只返回「模型做这次决策需要的最小信息」。大结果别原样回,先在你的 Java 层做裁剪、汇总,或者落库、给一个 id + 摘要。

@Tool(description = "查询用户最近一次订单的概要;用户问‘我最近买的啥’时调用")public String lastOrderBrief(        @ToolParam(description = "用户 ID,纯数字") String userId) {    Order o = orderService.findLatest(userId);    if (o == null) return "该用户暂无订单";    // 只回模型需要的几项,别把整个对象序列化回去    return "订单号 %s,状态 %s,金额 %s 元,下单时间 %s"            .formatted(o.id(), o.status(), o.amount(), o.createdAt());}

一句话原则:工具返回值是「给模型看的简报」,不是「给程序解析的数据」。想给程序解析的,走另一个接口,别混进工具返回值里。

◆坑 4:工具一抛异常,整条链路直接 500

第四个坑,是线上最容易被投诉的:工具方法里一个 `RuntimeException`,用户那头直接看到 500。

为什么会这样?因为工具是被框架在执行你 `.call()` 的线程里同步调用的。你的方法一抛异常,异常就顺着调用栈一路穿到最外层,`call()` 抛出去,你的 Controller 没人接,就是 500。整个过程里,模型压根没机会「知道出错了、然后自己换个说法或换个参数」。

正确做法是:把「可预期的失败」在工具内部消化掉,返回一段人话给模型,让它去决定怎么办。只有那种真的没法继续的系统级异常,才往外抛。

@Tool(description = "根据订单号查询订单状态")public String getOrderStatus(        @ToolParam(description = "订单号,纯数字") String orderId) {    try {        return orderService.findStatus(orderId);    } catch (NotFoundException e) {        // 不抛出去,把「为什么没查到」讲给模型,模型会去追问用户        return "未找到订单 " + orderId + ",请向用户确认订单号是纯数字且无误";    } catch (Exception e) {        // 其它异常也别裸抛,给模型一个可继续的兜底        return "订单查询服务暂时不可用,请稍后再试";    }}

区别在哪?第一种写法,用户看到冷冰冰的 500;第二种写法,用户看到的是模型顺着你的提示回一句「没有找到这个订单号,方便我再确认一下号码吗」。同一份数据、同一个模型,体验差出一个档次。这就是为什么我说,工具调用的质量,一大半是「错误处理」的质量。

◆坑 5:工具描述重叠,又没有步数上限,模型原地打转

最后一个坑,通常出现在「工具变多之后」。

两个问题叠在一起:一是工具描述彼此重叠——你有一个 `getOrderStatus`「查订单状态」,又有一个 `getOrderTracking`「查订单物流」,两个描述都写了「查订单」,模型面对用户一句「查下我的订单」就懵了,来回试;二是你没有任何轮数上限,模型拿到一样的结果还会接着调同一个工具,陷入死循环,token 哗哗烧。

解法有两个层面。工具层面:每个工具的描述必须划清边界,用「只在……时调用」把它的适用场景钉死。

// 边界清晰:一个只管状态,一个只管物流轨迹,描述里互相排斥@Tool(description = "查询订单当前状态(如已发货、已签收);仅在用户问状态时调用")public String getOrderStatus(@ToolParam(description = "订单号,纯数字") String orderId) { ... }@Tool(description = "查询订单的物流轨迹明细;仅在用户明确要看物流节点时调用")public String getOrderTracking(@ToolParam(description = "订单号,纯数字") String orderId) { ... }

工程层面:别让模型无限调。一是在业务侧给一次对话设个「最多调 N 次工具」的闸,超了就强制收口让它用已有信息回答;二是别一次给模型挂上几十个工具,工具越多,选错的概率越高——按业务场景分组,聊天入口只挂这一组要用的。我见过有人一口气挂 40 个工具,模型基本全程在那抽奖。

顺带说个认知:工具幂等也很重要。你把「创建工单」这种有副作用的工具暴露给模型,又没有幂等保护,模型一次重试就可能给你建两张工单。有副作用的工具,一定要能防重。

◆最小可跑代码(贴进 IDE 能编译)

前面 5 个坑讲完,给你一份把「正确姿势」全写进去的最小可跑版本。三个文件:工具类、组装 ChatClient 的服务、一个普通 Controller。

// 1) 工具类:把「能做的事」写成方法,打上 @Tool@Servicepublic class OrderTools {    private final OrderService orderService;    public OrderTools(OrderService orderService) {        this.orderService = orderService;    }    // 描述写清「何时用」;参数收 String,方法内自己转    @Tool(description = "根据订单号查询订单当前状态;用户提到订单号、"            + "想查发货进度或是否签收时调用")    public String getOrderStatus(            @ToolParam(description = "订单号,纯数字,例如 12345678") String orderId) {        try {            return orderService.findStatus(orderId);          // 返回给模型的一段简短文本        } catch (NotFoundException e) {            return "未找到订单 " + orderId + ",请让用户确认订单号是否正确"; // 别抛出去        }    }    // 与上一个工具描述划清边界,避免模型选错    @Tool(description = "查询订单的物流轨迹明细;仅在用户明确要看物流节点时调用")    public String getOrderTracking(            @ToolParam(description = "订单号,纯数字") String orderId) {        List<String> nodes = orderService.trackingNodes(orderId);        return nodes.isEmpty() ? "该订单暂无物流信息" : String.join(" / ", nodes);    }}
// 2) 把它注册进 ChatClient,模型才知道有这些工具@Servicepublic class AssistantService {    private final ChatClient chatClient;    public AssistantService(ChatClient.Builder builder, OrderTools orderTools) {        this.chatClient = builder                .defaultTools(orderTools)   // 一行注册,工具类是个 Bean 就行                .build();    }    public String ask(String question) {        return chatClient.prompt()                .user(question)                .call()                .content();    }}
// 3) 一个普通 Controller,接口就能对外了@RestControllerpublic class AssistantController {    private final AssistantService assistantService;    public AssistantController(AssistantService assistantService) {        this.assistantService = assistantService;    }    @GetMapping("/ask")    public String ask(@RequestParam("q") String q) {        return assistantService.ask(q);    }}

跑起来之后,命令行验证一下就行:

curl "http://localhost:8080/ask?q=帮我查下订单12345678到哪了"

你会看到模型先调 `getOrderStatus`,拿到状态后再组织成人话回你。到这一步,工具调用就算真正跑通了——你后面做 Agent、做 RAG 里的「按需取数」,长的都是这根主枝。

◆依赖版本清单(照抄,别乱升)

版本冲突是 Java 人收藏一篇文章的第一个理由,我直接给你一组在 2026 年 10 月验证能跑的版本,照抄,别自己升。

组件版本说明
JDK
17(LTS)
Spring Boot 3.x 的下限,别用 8
Spring Boot
3.4.5
父工程版本,starter 跟着它走
Spring AI
1.0.0(GA)
BOM 统一管子模块版本,认 Spring Boot 3.4.x
spring-ai-openai-spring-boot-starter
不写版本
由 spring-ai-bom 接管,写死易撞 NoSuchMethodError
Jackson(databind)
2.18.2
工具参数/返回值序列化靠它,别降到 2.15 以下
Maven
3.9.x
低于 3.6 对 BOM 的 import 处理有坑

两个必须记住的铁律。第一,`@Tool` 的注解包名在 GA 版是 `org.springframework.ai.tool.annotation.Tool`,你在网上搜到的老博客如果写的是 M 版(M1–M6)的包路径,照抄必挂——认准 1.0.0 GA,见「M」字样的版本号绕道。第二,Spring AI 的大版本必须和 Spring Boot 对得上,1.0.0 认 3.4.x,你硬上 3.2 或 3.5 都可能踩到 API 微调,先在 release notes 里翻一眼 Breaking Changes,能省一下午。

◆一页速查表:@Tool 这套 API 的坑位对照

这张表把你 90% 会踩到的地方覆盖了,建议截图存下来。

你想干的事怎么写坑位(踩过)
注册工具
builder.defaultTools(new XxxTools())
工具方法要 public,类得是个 Bean,否则扫不到
声明一个工具
方法上 @Tool(description="…")
描述是给模型的「何时用」,不是给人看的注释
说明参数
参数上 @ToolParam(description="…")
不写描述,模型爱塞错值
参数类型
尽量收 String,方法内自己转
int/enum/日期/LocalDate 直接当入参容易翻车
返回内容
只返回决策需要的最小文本
返回整个 List<对象> → 上下文顶爆
工具报错
try/catch 后返回人话
直接抛 RuntimeException → 整条链路 500
多个工具
描述划清边界 + 限制调用轮数
描述重叠 / 无上限 → 选错或死循环
有副作用的工具
加幂等 / 防重键
模型重试 → 重复建单
换模型再换厂商
工具代码一行不动,只改 yml
@Tool 是框架级注解,与厂商无关

记住一句话:工具调用的坑,八成不在「模型聪不聪明」,在「有没有把方法名、描述、参数、返回值、异常这五样交代清楚」。你把工具当接口设计,模型才用得顺。

◆跑通之后别急着上生产

这篇给的是「能跑、能验证」的版本,生产里你还得补两件事:一是给工具加权限校验,别让模型调到你没授权的接口(模型的调用意图不可信,落库、扣款、改状态这类工具,必须再过一遍你后端的鉴权);二是给整条链路配超时和轮数上限,模型偶尔会「上头」,不设闸就会一直调下去烧你的 token。

最后说句掏心窝的:技术号我从来不看点赞。你愿意收藏,才是真觉得这些东西能抄走用。这篇的 5 个坑,你要是踩过其中任何一个,底下扣个字母,我下一篇照着你最容易栽的那个往深了写。

关于我:Java 讲师,一人公司实践中。知识星球「AI搞钱实验室」里整理了 100+ 个真实的 AI 搞钱案例和可复制的 SOP,68 元/年。

📊 本期投票

最后说句掏心窝的:技术号我从来不看点赞。你愿意收藏,才是真觉得这些东西能抄走用。这篇的 5 个坑,你要是踩过其中任何一个,底下扣个字母,我下一篇照着你最容易栽的那个往深了写

A:我想要一份能直接跑的 GitHub 模板(OrderTools + ChatClient 注册 + Controller)

B:我卡在「多个工具互相打架」,想看多工具怎么编排、怎么防模型选错

C:我卡在「工具返回值太大 / 上下文爆炸」,想看大结果怎么裁剪

👉 评论区扣字母 + 聊聊你的看法

本文数据来自公开资料,仅供参考,不构成任何投资建议。

作者:秋天

关注「程序员秋天」

扫码关注「程序员秋天」,完整版在星

相关学习资料