1. 为什么 Function Calling 值得你花时间吃透
Function Calling 这个词,这两年在 Java 后端圈子里出现的频率越来越高。很多人第一次听到它,以为是什么新出的 RPC 框架或者某种远程调用协议,其实不是。它解决的是一个非常具体的问题:让大语言模型在对话过程中,能够主动调用你预先定义好的本地函数或外部接口,拿到真实数据后再继续生成回答。
举个最直观的例子。你问模型“帮我查一下订单号 20240513001 的物流状态”,如果模型只靠训练数据,它只能编一个看起来像模像样的答案。但有了 Function Calling,模型会先判断“这个问题需要调用查询物流的函数”,然后输出一个结构化的调用请求,你的 Java 代码收到请求后去数据库或第三方接口拿真实数据,再把结果喂回给模型,模型最终生成一句人话回答。整个过程模型不直接碰你的数据库,它只负责“决定调用什么”和“怎么把结果说清楚”。
Spring AI 把这个能力封装得相当顺手。它提供了一套基于注解的声明式写法,你只需要在方法上打一个@Tool注解,框架就会自动扫描、生成 JSON Schema、注册到模型可调用的工具列表里。对于写惯了 Spring Boot 的 Java 开发者来说,上手成本比想象中低很多。
这篇文章适合谁看?如果你已经会用 Spring Boot 写接口,懂基本的 MySQL 增删改查,但对 Function Calling 只停留在“听说过”的阶段,那这篇内容就是为你准备的。我会从零开始,把环境搭建、工具定义、调用链路、参数校验、异常处理、多轮对话里的工具编排,以及实际踩过的坑,全部拆开讲一遍。代码可以直接抄,思路可以直接复用。
需要提前说明的是,Function Calling 本身不神秘,它本质上是“模型输出结构化 JSON + 你的代码解析 JSON + 执行本地逻辑 + 把结果回传”这一套流程的标准化封装。理解了这一点,后面所有细节都会变得顺理成章。
2. 环境准备与项目骨架搭建
2.1 版本选型:Spring Boot 与 Spring AI 的搭配
版本这块我踩过坑,必须先说清楚。Spring AI 在 1.0 之前经历过多次 API 调整,早期版本里叫@Function,后来改成了@Tool,包路径也变过。如果你在网上搜到的是老教程,代码大概率跑不起来。
我目前实测比较稳的组合是:
| 组件 | 版本 | 说明 |
|---|---|---|
| JDK | 17 或 21 | Spring Boot 3.x 最低要求 17 |
| Spring Boot | 3.2.x 及以上 | 3.2 对虚拟线程支持更好 |
| Spring AI | 1.0.x 稳定版 | API 已趋于稳定 |
| MySQL | 8.0.x | 用于演示真实数据查询 |
| 构建工具 | Maven 3.9+ | Gradle 也可以,本文用 Maven |
为什么强调 JDK 17?因为 Spring Boot 3 全面转向了 Jakarta EE 命名空间,javax.*变成了jakarta.*,如果你还在用 JDK 8 加 Spring Boot 2.x,那 Spring AI 基本用不了。这不是劝你升级,而是事实如此。
提示:如果你公司项目还锁在 Spring Boot 2.3.x 或 2.6.x,想用 Spring AI 就得单独起一个服务,通过 HTTP 调用,不要硬塞进老项目里,依赖冲突会让你怀疑人生。
2.2 Maven 依赖配置
核心依赖其实不多,主要是 Spring AI 的 starter 和对应模型厂商的适配包。这里我用一个通用的 OpenAI 兼容接口来演示,因为国内很多模型服务都兼容这套协议,切换成本低。
<properties> <java.version>17</java.version> <spring-ai.version>1.0.0</spring-ai.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-model-openai</artifactId> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-jdbc</artifactId> </dependency> </dependencies> <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>注意spring-ai-bom一定要放在dependencyManagement里,否则各个子模块版本对不上,会出现NoSuchMethodError这种运行时才暴露的问题。
2.3 application.yml 关键配置
配置文件里最容易出错的是模型地址和 API Key 的写法。不同厂商的字段名略有差异,但 OpenAI 兼容协议基本一致。
server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/demo_db?useSSL=false&serverTimezone=Asia/Shanghai username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver ai: openai: api-key: ${AI_API_KEY} base-url: https://your-model-endpoint/v1 chat: options: model: your-model-name temperature: 0.7api-key强烈建议用环境变量注入,不要硬编码在 yml 里。我见过有人把 Key 提交到公开仓库,第二天就收到账单预警。
注意:
base-url末尾的/v1不能少,很多 404 错误都是因为路径拼错。另外temperature在 Function Calling 场景下建议调低一点,0.2 到 0.7 之间比较合适,太高会让模型在“要不要调用工具”这件事上摇摆。
2.4 数据库准备
为了让演示贴近真实业务,我建一张简单的订单表:
CREATE TABLE `orders` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `order_no` VARCHAR(32) NOT NULL UNIQUE, `customer_name` VARCHAR(64) NOT NULL, `status` VARCHAR(16) NOT NULL, `amount` DECIMAL(10,2) NOT NULL, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP ); INSERT INTO `orders` (order_no, customer_name, status, amount) VALUES ('20240513001', '张三', '已发货', 299.00), ('20240513002', '李四', '待付款', 158.50), ('20240513003', '王五', '已完成', 899.00);表结构故意做得简单,因为本文重点不在 SQL,而在于模型如何触发对这些数据的查询。
3. Function Calling 核心机制拆解
3.1 模型到底是怎么“调用”函数的
很多人对 Function Calling 有个误解,以为模型真的执行了你的 Java 方法。不是的。模型做的事情只有一件:根据你的问题,输出一段符合约定格式的 JSON,告诉你的程序“我想调用哪个函数,参数是什么”。
真正的执行发生在你的 Java 进程里。完整链路是这样的:
- 用户提问:“订单 20240513001 现在什么状态?”
- 你的程序把问题 + 可用工具列表(含参数 Schema)一起发给模型
- 模型返回一个
tool_calls结构,里面写着function.name = queryOrderStatus,arguments = {"orderNo": "20240513001"} - Spring AI 框架解析这个结构,反射调用你标注了
@Tool的方法 - 方法执行,查数据库,返回结果
- 框架把结果作为一条
tool角色消息追加到对话历史 - 再次请求模型,模型基于真实数据生成最终回答:“订单 20240513001 目前状态是已发货,金额 299 元。”
关键点在于第 3 步和第 7 步是两次独立的模型请求。中间那次工具执行完全在你的掌控之中,模型看不到你的数据库连接、看不到你的内部逻辑,它只看到你愿意返回给它的那部分结果。这个边界非常重要,既是安全边界,也是设计边界。
3.2 @Tool 注解背后的自动装配
Spring AI 的@Tool注解做的事情比表面看起来多。当你把一个 Bean 的方法标注为@Tool,框架在启动时会:
- 扫描所有标注了
@Tool的方法 - 根据方法签名和
@ToolParam注解生成 JSON Schema - 把工具名称、描述、参数结构注册到
ToolCallbackResolver - 在每次对话请求时,把这些信息序列化后塞进请求体
方法名默认就是工具名,但你可以通过@Tool(name = "xxx")覆盖。描述字段尤其重要,它是模型判断“什么时候该用这个工具”的唯一依据。我见过太多人描述写得含糊,结果模型该调用的时候不调用,不该调用的时候乱调用。
@Component public class OrderTools { private final JdbcTemplate jdbcTemplate; public OrderTools(JdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; } @Tool(name = "queryOrderStatus", description = "根据订单编号查询订单的当前状态、客户名称和金额。当用户询问某个具体订单的情况时使用此工具。") public OrderInfo queryOrderStatus( @ToolParam(description = "订单编号,格式为14位数字字符串,例如 20240513001") String orderNo) { String sql = "SELECT order_no, customer_name, status, amount FROM orders WHERE order_no = ?"; return jdbcTemplate.queryForObject(sql, (rs, rowNum) -> new OrderInfo( rs.getString("order_no"), rs.getString("customer_name"), rs.getString("status"), rs.getBigDecimal("amount") ), orderNo); } }OrderInfo是一个普通的 record 或 POJO,框架会自动把它序列化成 JSON 回传给模型。这里有个细节:返回对象字段名会直接影响模型的理解,所以字段名要语义清晰,不要用f1、f2这种。
3.3 工具描述怎么写才有效
这是全文最容易被低估的部分。工具描述不是写给人看的文档,是写给模型看的“使用说明书”。写得好的描述能让模型准确率提升一大截。
我的经验是描述里要包含三要素:做什么、什么时候用、参数约束。
反面例子:“查询订单”。模型看到这个描述,不知道是查状态还是查物流,也不知道参数该传订单号还是客户名。
正面例子:“根据订单编号查询订单的当前状态、客户名称和金额。当用户询问某个具体订单的情况时使用此工具。参数必须是14位数字的订单编号。”
再比如参数描述,也要写清楚格式和示例。模型对格式敏感,你写“订单编号”,它可能传"订单20240513001",你写“14位数字字符串,例如 20240513001”,它基本就传对了。
实操心得:工具描述建议控制在 100 字以内,太长会占用上下文窗口,太短模型理解不到位。如果工具有多个参数,每个参数的描述都要单独写清楚,不要偷懒。
4. 完整实战:从接口到多轮对话
4.1 ChatClient 的构建与工具注册
Spring AI 提供了ChatClient作为对话入口,构建方式很 Spring 风格:
@Configuration public class ChatConfig { @Bean public ChatClient chatClient(ChatClient.Builder builder, OrderTools orderTools) { return builder .defaultSystem("你是一个订单查询助手,回答要简洁准确。") .defaultTools(orderTools) .build(); } }defaultTools会把orderTools这个 Bean 里所有@Tool方法注册进去。如果你有多个工具类,可以链式调用多次,或者传多个对象。
然后写一个 Controller:
@RestController @RequestMapping("/api/chat") public class ChatController { private final ChatClient chatClient; public ChatController(ChatClient chatClient) { this.chatClient = chatClient; } @GetMapping("/ask") public String ask(@RequestParam String question) { return chatClient.prompt() .user(question) .call() .content(); } }启动项目,访问http://localhost:8080/api/chat/ask?question=订单20240513001现在什么状态,你应该能看到模型返回真实的订单状态。
4.2 多轮对话中的工具调用
单轮问答只是入门,真实业务里往往是多轮对话。比如用户先问“订单 20240513001 什么状态”,接着问“那这个客户还买过别的吗”。第二句话里没有订单号,但模型需要结合上下文知道“这个客户”指的是张三。
Spring AI 通过ChatMemory来维护对话历史:
@Bean public ChatMemory chatMemory() { return new InMemoryChatMemory(); } @Bean public ChatClient chatClient(ChatClient.Builder builder, OrderTools orderTools, ChatMemory chatMemory) { return builder .defaultSystem("你是一个订单查询助手。") .defaultTools(orderTools) .defaultAdvisors(new MessageChatMemoryAdvisor(chatMemory)) .build(); }InMemoryChatMemory适合演示,生产环境要换成基于 Redis 或数据库的实现,否则重启就丢历史。这里要注意,对话历史会随着轮次增加不断变长,最终可能超出模型的上下文窗口。我的做法是设置一个最大保留轮数,比如保留最近 10 轮,更早的做摘要压缩。
4.3 参数校验与异常兜底
模型传参不是百分百可靠的。它可能传空字符串、传错格式、甚至传一个不存在的订单号。如果你不在工具方法里做校验,异常会直接抛到框架层,最终用户看到一句莫名其妙的报错。
我的做法是在工具方法内部做防御性校验:
@Tool(name = "queryOrderStatus", description = "...") public OrderInfo queryOrderStatus(@ToolParam(description = "...") String orderNo) { if (orderNo == null || !orderNo.matches("\\d{14}")) { throw new IllegalArgumentException("订单编号格式不正确,应为14位数字"); } try { String sql = "SELECT ... WHERE order_no = ?"; return jdbcTemplate.queryForObject(sql, rowMapper, orderNo); } catch (EmptyResultDataAccessException e) { throw new IllegalStateException("未找到订单编号为 " + orderNo + " 的订单"); } }抛出的异常信息会被框架捕获并回传给模型,模型通常会基于这个信息给用户一个友好的解释,比如“抱歉,没有找到该订单,请确认编号是否正确”。这比直接返回 500 错误体验好得多。
注意:不要在工具方法里抛
NullPointerException这类无意义的异常,异常消息要写成人能看懂的话,因为模型会读它。
4.4 多工具编排的实际场景
真实业务里往往不止一个工具。比如再加一个“查询客户所有订单”的工具:
@Tool(name = "queryOrdersByCustomer", description = "根据客户姓名查询该客户的所有订单列表。当用户想了解某个客户的全部购买记录时使用。") public List<OrderInfo> queryOrdersByCustomer( @ToolParam(description = "客户姓名,例如 张三") String customerName) { String sql = "SELECT order_no, customer_name, status, amount FROM orders WHERE customer_name = ?"; return jdbcTemplate.query(sql, rowMapper, customerName); }当用户问“张三都买过什么”,模型会自动选择第二个工具。当用户问“订单 20240513001 什么状态”,模型选第一个。如果用户问“张三的订单 20240513001 状态怎么样”,模型可能会先调第二个确认归属,再调第一个查状态,这就是多工具编排。
实测下来,模型选择工具的准确率和工具描述的清晰度强相关。描述写得越具体,选错的概率越低。
5. 常见问题与排查技巧实录
5.1 模型不调用工具怎么办
这是最高频的问题。用户明明问的是订单状态,模型却直接编了一个答案,根本没触发工具调用。排查顺序如下:
第一,检查工具描述是否足够明确。如果描述写的是“查询订单”,模型可能觉得它自己也能回答,就不调用了。改成“查询数据库中真实订单的状态,必须调用此工具获取准确信息”,强制意味更强。
第二,检查defaultTools是否真的注册成功。可以在启动日志里搜索工具名,或者写个单元测试断言ToolCallbackResolver里能解析到你的工具。
第三,检查模型本身是否支持 Function Calling。不是所有模型都支持,有些轻量模型或者老版本模型不支持工具调用,这种情况下无论你怎么配置都没用。
第四,降低temperature。温度太高时模型倾向于“自由发挥”,调低到 0.2 左右会明显改善。
5.2 参数传递错误的典型表现
模型传参错误通常有几种形态:参数名对不上、参数类型不对、参数值格式不对。
参数名对不上,往往是因为你用了@ToolParam但没写name,框架默认用参数名,而 Java 编译后参数名可能丢失(除非加了-parameters编译参数)。解决办法是在 Maven 里显式开启:
<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <configuration> <parameters>true</parameters> </configuration> </plugin>参数类型不对,比如你定义的是Integer,模型传了"123"字符串。Spring AI 会尝试转换,但复杂类型可能失败。建议工具参数尽量用String,在方法内部自己转换和校验,这样最稳。
参数值格式不对,前面已经说过,靠参数描述来约束,靠方法内校验来兜底。
5.3 常见问题速查表
| 问题现象 | 可能原因 | 解决方向 |
|---|---|---|
| 模型不调用工具 | 描述不清晰、未注册、模型不支持 | 改描述、查注册、换模型 |
| 调用时报参数缺失 | 参数名丢失、Schema 生成异常 | 开启 -parameters、检查注解 |
| 工具执行抛异常 | 数据不存在、格式错误 | 方法内 try-catch 并抛业务异常 |
| 多轮对话丢失上下文 | ChatMemory 未配置或失效 | 检查 Advisor 配置 |
| 返回结果模型不理解 | 返回对象字段名语义不清 | 重命名字段、加描述 |
| 响应特别慢 | 工具内部耗时、多次模型请求 | 优化 SQL、加缓存、减少工具数 |
5.4 几个我踩过的坑
第一个坑:工具方法必须是 public 的。我一开始写了个 private 方法加@Tool,启动没报错,调用时静默失败,排查了半天。
第二个坑:工具类必须是 Spring Bean。如果你直接new一个对象,框架扫描不到。要么加@Component,要么在配置类里用@Bean注册。
第三个坑:返回对象不要有循环引用。我有次返回的实体里嵌套了一个关联对象,序列化时直接栈溢出。工具返回值保持扁平、简单,是最稳妥的做法。
第四个坑:不要在工具方法里做耗时操作。模型调用工具是有超时的,你查一个慢 SQL 查了 30 秒,整个对话就卡死了。该加索引加索引,该加缓存加缓存。
实操心得:开发阶段建议把模型的原始响应打出来看看,Spring AI 提供了日志级别配置,能看到完整的
tool_calls结构。看几次之后,你对整个链路的理解会深刻很多。
6. 性能与安全层面的几点考量
工具数量不是越多越好。每多一个工具,请求体里就多一段 Schema 描述,上下文窗口占用就多一分。我实测下来,单次对话注册 5 到 10 个工具是比较舒服的范围,超过 20 个之后模型选择准确率会下降,响应也变慢。如果业务确实需要很多工具,可以考虑按场景分组,不同场景用不同的 ChatClient 实例。
安全方面,工具方法内部一定要做权限校验。模型不知道当前用户是谁,它只是根据问题决定调用什么。你需要在工具方法里拿到当前登录用户,判断他有没有权限查这个订单。这一步不能省,否则就是越权漏洞。
另外,工具返回给模型的数据要脱敏。手机号、身份证号、完整地址这些敏感字段,要么不返回,要么打码。模型会把返回内容原样复述给用户,你返回什么它就可能说什么。
数据库连接这块,工具方法用的是应用本身的连接池,和普通接口没区别。但要注意,模型可能在一轮对话里连续调用多次工具,如果每次都查库,压力会叠加。对于查询类工具,加一层本地缓存或者 Redis 缓存是值得的。
最后说一个容易被忽略的点:工具方法的幂等性。查询类工具天然幂等,无所谓。但如果你的工具是“创建订单”“发送通知”这类写操作,一定要考虑模型重复调用的情况。我的建议是写操作工具加一个幂等键,或者干脆不让模型直接触发写操作,而是让它生成一个待确认的指令,由用户二次确认后再执行。这个设计上的取舍,比技术实现更重要。
7. 后续可以继续深挖的方向
Function Calling 跑通之后,往上还有不少可以玩的东西。比如把工具调用和 RAG 结合起来,让模型先检索知识库再决定调用哪个工具;比如做工具的动态注册,根据用户权限在运行时决定暴露哪些工具;再比如把工具执行链路做成可观测的,记录每次调用的入参、出参、耗时,方便排查线上问题。
还有一个方向是流式输出。目前演示的是同步返回,用户要等模型把话说完才看到结果。改成 SSE 流式之后,模型一边生成一边推给前端,体验会好很多。Spring AI 对流式有支持,但和工具调用结合时有一些细节要注意,比如工具执行阶段是没有流式内容的,需要处理好前端的加载状态。
我自己在实际项目里最大的体会是:Function Calling 的价值不在于技术多复杂,而在于它把“模型理解意图”和“程序执行逻辑”这两件事清晰地分开了。模型负责理解,程序负责执行,边界清楚,各司其职。想清楚这个边界在哪里,比会写几行注解重要得多。