☰
Spring AI Function Calling 实战:Java 后端接入大模型工具调用完整指南
2026/10/2 21:06:19 网站建设 项目流程

1. 为什么 Function Calling 值得花时间吃透

Function Calling 这个词这两年在 AI 应用开发圈子里出现的频率越来越高,但很多人第一次听到会误以为它是某种“让模型直接执行代码”的黑魔法。其实不是。它的本质是:让大语言模型在对话过程中,主动判断“这个问题我需要调用某个外部工具才能回答”,然后输出一个结构化的调用请求,由你的后端代码去执行真正的业务逻辑,再把结果喂回给模型,让它组织成自然语言回复。

说白了,模型负责“决策和表达”,你的 Java 代码负责“执行和取数”。这个分工非常关键,因为它解决了大模型落地时最头疼的几个问题:模型不知道实时数据、模型不能操作你的数据库、模型会一本正经地胡说八道。有了 Function Calling,模型可以查天气、查订单、算税费、写数据库、调第三方接口,而这一切都在你可控的 Java 服务里完成。

Spring AI 是 Spring 生态里专门做 AI 应用集成的框架,它把 Function Calling 这套机制封装成了符合 Spring 开发者直觉的写法——你写一个普通的 Java 方法,加个注解或者注册成 Bean,剩下的协议拼装、参数解析、多轮调用编排,框架帮你兜底。对于已经熟悉 Spring Boot 的 Java 开发者来说,上手成本比直接怼各家大模型的原始 HTTP 接口低太多了。

这篇文章适合谁看?如果你是会写 Spring Boot、懂基本的 MySQL 操作、想把自己的业务系统接上大模型能力,但又被各种 SDK、协议、JSON Schema 搞得头大的 Java 后端,那这篇就是写给你的。我会从零开始,把 Function Calling 的原理、Spring AI 的接入方式、工具函数的定义与注册、多轮调用的完整链路、踩坑排查,全部用可复现的代码讲清楚。不堆概念,只讲能跑起来的东西。

2. Function Calling 到底在解决什么问题

2.1 大模型的三道硬伤

先把问题摆清楚,不然你不知道 Function Calling 为什么长这样。

第一道硬伤是知识截止。模型的训练数据有截止日期,你问它“我系统里订单号 20241123001 现在什么状态”,它不可能知道,因为这个数据在你的 MySQL 里,不在它的参数里。

第二道硬伤是无法执行副作用。你让它“帮我把这个用户的会员等级升到黄金”,它只能回你一句“好的,已为您升级”,但实际上什么都没发生。模型本身没有操作你系统的能力。

第三道硬伤是数值计算和精确逻辑不可靠。大模型本质是概率生成,你让它算一个复杂的税费或者做严格的分支判断,它可能给你一个看起来很像但实际错误的结果。

Function Calling 就是针对这三点的工程解法:把“取数据”“做操作”“算精确值”这些事,交给确定性的 Java 代码,模型只负责理解意图和调度。

2.2 一次完整的调用链路长什么样

我用一个订单查询的场景把链路串一遍,这样后面看代码就不会迷路。

用户在对话框输入:“帮我查一下订单 20241123001 现在到哪了”。

第一步,你的后端把这句话连同工具清单一起发给模型。工具清单里描述了“有一个叫 queryOrderStatus 的函数,它需要一个 orderId 参数,类型是字符串”。

第二步,模型读完这句话,判断出“用户想查订单状态,我手里正好有这个工具”,于是它不直接回答,而是输出一个结构化的调用意图,大概长这样:要调用的函数名是 queryOrderStatus,参数是 {"orderId": "20241123001"}。

第三步,Spring AI 框架拿到这个意图,反射找到你注册的那个 Java 方法,把参数传进去执行。你的方法去 MySQL 查订单,返回一个对象或者字符串。

第四步,框架把执行结果再发回给模型,模型基于这个真实结果,组织成“您的订单已于 11 月 24 日发出,目前在杭州转运中心”这样的自然语言。

整个链路里,模型碰不到你的数据库,它只是“说”要调什么,真正执行的是你的代码。这就是安全边界所在。

2.3 和 RAG、Agent 的关系别搞混

很多人把 Function Calling 和 RAG 混为一谈。RAG 是“检索增强生成”,核心是把相关文档片段塞进上下文让模型参考,偏向知识补充。Function Calling 是“让模型主动决定调什么工具”,偏向能力扩展和动作执行。

而 Agent,可以理解为在 Function Calling 基础上加了“多步规划和循环”的能力。一个 Agent 可能连续调用好几个工具,根据上一步结果决定下一步干什么。所以 Function Calling 是 Agent 的地基,地基没打牢,Agent 就是空中楼阁。Spring AI 里也有 Agent 相关的抽象,但那是后话,先把单次调用吃透。

3. Spring AI 环境搭建与依赖选型

3.1 版本选择和依赖引入

Spring AI 的版本迭代比较快,选版本的时候有个原则:优先选和你 Spring Boot 版本匹配的稳定版。如果你用的是 Spring Boot 3.2.x 及以上,Spring AI 1.0.x 系列是比较稳的选择。别一上来就追最新的快照版,快照版 API 可能明天就变了,踩坑成本极高。

Maven 里核心依赖是这几个:

<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-core</artifactId> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-openai-spring-boot-starter</artifactId> </dependency>

如果你用的是国内的模型服务,比如阿里系的百炼平台,那对应的 starter 换成spring-ai-alibaba-spring-boot-starter之类的适配包。这里要注意,不同厂商的 starter 在 Function Calling 的支持程度上可能有差异,有的对多轮工具调用支持得好,有的只支持单轮,选型前一定要看官方文档里关于 tool/function 那一节的说明。

数据库这边,MySQL 用mysql-connector-j就行,连接池用 Spring Boot 默认的 HikariCP 足够。别小看连接池配置,后面工具函数频繁查库的时候,连接数不够会直接卡死。

3.2 配置文件的关键项

application.yml里至少要配这几项:

spring: ai: openai: api-key: ${AI_API_KEY} base-url: https://your-endpoint/v1 chat: options: model: your-model-name temperature: 0.2 datasource: url: jdbc:mysql://localhost:3306/demo?useSSL=false&serverTimezone=Asia/Shanghai username: root password: ${DB_PASSWORD} driver-class-name: com.mysql.cj.jdbc.Driver

这里有个经验:temperature 调低一点。Function Calling 场景下你希望模型稳定地判断“该不该调工具、调哪个”,而不是发挥创意。0.1 到 0.3 之间比较合适。温度太高,模型可能该调工具的时候跟你闲聊,不该调的时候乱调。

base-url和model这两项,不同服务商差异很大,一定要对着你实际用的服务文档填,填错了报的错往往很隐晦,比如 404 或者模型不存在,排查起来费时间。

3.3 一个最小可运行的启动类

@SpringBootApplication public class FunctionCallingDemoApplication { public static void main(String[] args) { SpringApplication.run(FunctionCallingDemoApplication.class, args); } }

启动类本身没什么好说的,但我要提醒一句:先别急着写工具函数,先跑通一个最普通的对话。也就是注入ChatClient,发一句“你好”,看能不能正常返回。这一步能过,说明你的 key、endpoint、model 都是对的,再去加 Function Calling,出问题的时候就能快速定位是“基础对话的问题”还是“工具调用的问题”。我见过太多人一上来就写一堆工具,结果报错都不知道是哪一层的问题。

4. 定义和注册你的第一个工具函数

4.1 工具函数的三种写法

Spring AI 里注册工具函数,常见的有三种路子,我按推荐程度排一下。

第一种是用 @Tool 注解标注方法(新版本里比较主流)。你写一个普通方法,加上注解,框架自动扫描并生成对应的 JSON Schema。

@Component public class OrderTools { private final OrderRepository orderRepository; public OrderTools(OrderRepository orderRepository) { this.orderRepository = orderRepository; } @Tool(description = "根据订单号查询订单的当前状态和物流信息") public String queryOrderStatus( @ToolParam(description = "订单号,纯数字字符串") String orderId) { Order order = orderRepository.findByOrderNo(orderId); if (order == null) { return "未找到订单号为 " + orderId + " 的订单"; } return String.format("订单 %s 状态为 %s,最近更新:%s", order.getOrderNo(), order.getStatus(), order.getLastUpdate()); } }

第二种是实现 Function 接口,用Function<Request, Response>的形式注册成 Bean。这种方式类型安全,适合参数复杂的场景。

第三种是手动构建 ToolCallback,最灵活也最啰嗦,一般用不上。

新手我建议从 @Tool 注解开始,直观,改起来快。

4.2 description 写得好不好,直接决定调用准确率

这是我要重点强调的坑。模型判断该不该调这个工具,几乎完全依赖 description 的语义。你 description 写得含糊,模型就乱调或者不调。

对比一下:

差的写法:@Tool(description = "查询订单")

好的写法:@Tool(description = "根据订单号查询订单的当前状态、物流进度和预计送达时间。当用户询问某个具体订单的情况时使用此工具")

好的写法里,你告诉了模型三件事:这个工具能返回什么、需要什么输入、什么场景下该用它。最后那句“当用户询问某个具体订单的情况时使用”特别关键,它相当于给模型一个触发条件。

参数描述也一样。@ToolParam(description = "订单号")不如@ToolParam(description = "订单号,通常是 11 到 20 位的纯数字字符串,例如 20241123001")。给了格式示例,模型提取参数的时候准确率高很多。

4.3 参数类型和返回值的约束

参数类型尽量用简单类型:String、int、long、boolean,或者它们的包装类。复杂对象虽然也能支持,但生成的 Schema 会变复杂,模型解析出错的概率上升。

返回值这块,建议返回 String 或者结构清晰的简单对象。因为返回值最终是要拼进上下文给模型读的,你返回一个嵌套五层的 JSON,模型读起来也费劲,还占 token。我一般的做法是:数据库查出来是实体对象,但在工具方法里把它格式化成一段人类可读的文本再返回。

return String.format("订单号:%s,状态:%s,下单时间:%s,物流:%s", order.getOrderNo(), order.getStatus(), order.getCreateTime(), order.getLogistics());

这样模型拿到就能直接用,不用再做二次解析。

4.4 把工具注册到 ChatClient

@Configuration public class ChatConfig { @Bean public ChatClient chatClient(ChatClient.Builder builder, OrderTools orderTools) { return builder .defaultSystem("你是一个电商客服助手,回答要简洁准确。") .defaultTools(orderTools) .build(); } }

defaultTools把工具挂上去之后,每次对话框架都会把这些工具的 Schema 一起发给模型。注意,工具不是越多越好,后面我会专门讲这个问题。

5. 完整实战:从对话到数据库查询的闭环

5.1 建表和准备数据

先在 MySQL 里建一张订单表,字段不用多,够演示就行:

CREATE TABLE `t_order` ( `id` bigint NOT NULL AUTO_INCREMENT, `order_no` varchar(32) NOT NULL, `status` varchar(16) NOT NULL, `logistics` varchar(255) DEFAULT NULL, `create_time` datetime DEFAULT CURRENT_TIMESTAMP, `last_update` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

插两条测试数据:

INSERT INTO t_order (order_no, status, logistics) VALUES ('20241123001', '已发货', '杭州转运中心'), ('20241123002', '待付款', '暂无物流');

order_no上建唯一索引,因为工具函数里是按订单号查的,这个索引直接决定查询性能。别小看这一点,工具函数被高频调用的时候,一个全表扫描能把你的数据库拖垮。

5.2 Repository 层

用 Spring Data JPA 或者 MyBatis 都行,我这里用 JPA 演示,简洁:

public interface OrderRepository extends JpaRepository<Order, Long> { Order findByOrderNo(String orderNo); }

实体类Order对应表字段,注意@Column映射别写错,尤其是下划线转驼峰的地方,order_no对应orderNo,配错了查出来是 null,然后工具返回“未找到订单”,你还以为是模型的问题,其实是映射错了。

5.3 工具函数完整实现

@Component public class OrderTools { private static final Logger log = LoggerFactory.getLogger(OrderTools.class); private final OrderRepository orderRepository; public OrderTools(OrderRepository orderRepository) { this.orderRepository = orderRepository; } @Tool(description = "根据订单号查询订单的当前状态、物流进度。当用户询问某个具体订单的情况时使用") public String queryOrderStatus( @ToolParam(description = "订单号,11到20位纯数字字符串,例如 20241123001") String orderId) { log.info("工具被调用,orderId={}", orderId); if (orderId == null || !orderId.matches("\\d{11,20}")) { return "订单号格式不正确,请提供 11 到 20 位的数字订单号"; } Order order = orderRepository.findByOrderNo(orderId); if (order == null) { return "未查询到订单号为 " + orderId + " 的订单,请确认订单号是否正确"; } return String.format("订单号:%s,当前状态:%s,物流信息:%s,最后更新:%s", order.getOrderNo(), order.getStatus(), order.getLogistics(), order.getLastUpdate()); } }

这段代码里有几个我特意加的东西,都是实战经验。

第一,入口打日志。工具到底有没有被调用、传进来的参数是什么,日志一看便知。排查“模型没调工具”还是“调了但参数错了”这类问题,日志是第一手证据。

第二,参数校验。别完全信任模型提取的参数,它可能给你传个空、传个带字母的字符串。在工具方法里做一层校验,返回友好的错误提示,模型拿到这个提示还能自己纠正,比如重新问用户要正确的订单号。

第三,错误信息要可读。“未查询到订单”比抛一个 NullPointerException 好一万倍,因为前者模型能理解并转述给用户,后者直接让你的接口 500。

5.4 Controller 层暴露接口

@RestController @RequestMapping("/api/chat") public class ChatController { private final ChatClient chatClient; public ChatController(ChatClient chatClient) { this.chatClient = chatClient; } @PostMapping public Map<String, String> chat(@RequestBody Map<String, String> body) { String userMessage = body.get("message"); String answer = chatClient.prompt() .user(userMessage) .call() .content(); return Map.of("reply", answer); } }

用 Postman 或者 curl 发一句“帮我查一下订单 20241123001 到哪了”,正常的话你会看到模型返回了真实的物流信息。如果返回的是“我无法查询订单”之类的话,说明工具没被触发,往下看排查章节。

5.5 多工具场景下的编排

实际业务里你不可能只有一个工具。加一个“创建工单”的工具试试:

@Tool(description = "为用户创建售后工单。当用户明确表示要投诉、退货或申请售后时使用") public String createTicket( @ToolParam(description = "订单号") String orderId, @ToolParam(description = "问题描述,用户原话即可") String issue) { // 落库逻辑 return "工单已创建,工单号 T" + System.currentTimeMillis() + ",我们会在 24 小时内联系您"; }

现在模型手里有两个工具,它会根据用户意图自己选。用户说“订单 20241123001 怎么还没到”,它调查询;用户说“这个订单我要退货”,它调创建工单。这就是 Function Calling 的调度能力。

但要注意,工具数量增加后,误调率会上升。两个工具还好,十个工具的时候,模型可能把“查订单”和“查物流”搞混。解决办法有两个:一是 description 之间要有明确的区分度,二是工具特别多的时候考虑分组,或者用路由层先做一次意图分类。

6. 常见问题与排查技巧实录

6.1 模型根本不调用工具

这是最高频的问题。表现是:你问订单状态,模型回你“抱歉,我无法查询实时订单信息”。

排查顺序我整理成一张表:

排查项检查方法常见原因
工具是否注册看 ChatClient 构建时有没有 defaultTools忘了挂载
description 是否清晰读一遍你的注解描述太笼统,模型不知道何时用
模型是否支持查服务商文档部分小模型不支持工具调用
请求是否带工具开 debug 日志看请求体框架版本不匹配导致没带上
系统提示词是否冲突检查 defaultSystem提示词里写了“不要调用外部工具”之类

我踩过最坑的一次是系统提示词里写了“你只能基于已有知识回答”,结果模型老老实实不调工具。提示词和工具能力是会打架的,写系统提示词的时候要留出工具调用的空间。

6.2 参数提取错误

模型把订单号提取成了“订单20241123001号”,多了几个字。这种情况在参数描述不够精确时很常见。

解决办法:在@ToolParam的 description 里明确说“只返回数字部分,不要包含其他文字”,同时在工具方法里做正则清洗:

String cleaned = orderId.replaceAll("[^0-9]", "");

先清洗再校验,容错率高很多。别指望模型每次都完美,工程上要假设它会犯错。

6.3 多轮调用死循环

有时候模型调完工具,拿到结果,又觉得不满意,再调一次,来回好几次。这通常是因为工具返回的结果模型“读不懂”或者“觉得不完整”。

对策是让工具返回值尽量自包含、明确。比如查询没找到订单,别返回空字符串,返回“未找到该订单”,模型就知道这条路走不通了,不会再试。另外可以在系统提示词里加一句“如果工具已经返回了明确结果,直接基于结果回答,不要重复调用”。

6.4 数据库连接和性能问题

工具函数查库,如果 QPS 上来了,连接池配置不当会出问题。HikariCP 默认最大连接数是 10,工具调用频繁的时候容易排队。

spring: datasource: hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 3000

另外,工具函数里的查询一定要走索引,别写全表扫描的 SQL。模型调用工具是不定时的,你没法预测峰值,只能保证单次查询足够快。

6.5 超时和异常处理

工具执行时间过长,整个对话就卡住了。给工具方法加超时保护:

@Tool(description = "...") public String queryOrderStatus(String orderId) { try { return CompletableFuture .supplyAsync(() -> doQuery(orderId)) .get(3, TimeUnit.SECONDS); } catch (TimeoutException e) { return "查询超时,请稍后重试"; } catch (Exception e) { log.error("工具执行异常", e); return "查询过程中出现异常,请稍后重试"; } }

工具方法永远不要往外抛异常,一定要 catch 住并返回可读的错误文本。因为异常抛出去,框架可能直接中断整个对话,用户体验很差;返回错误文本,模型还能优雅地告诉用户“稍后再试”。

7. 一些让方案更稳的工程经验

7.1 工具粒度怎么把握

一个工具干一件事,别搞“万能工具”。我见过有人写一个工具叫handleEverything,参数里塞一个 action 字段,然后内部 switch 各种操作。这种设计模型很难用对,因为 description 没法说清楚它到底能干什么。

正确的做法是按业务动作拆分:查订单一个、创建工单一个、取消订单一个。每个工具的 description 都能写得具体,模型判断起来也准。

7.2 权限和审计不能省

工具函数是模型触发的,但执行的是真实业务操作。所以权限校验必须放在工具方法内部,不能依赖模型。比如创建工单,你得校验当前用户有没有这个订单的归属权。

if (!order.getUserId().equals(currentUserId)) { return "无权操作该订单"; }

同时,每次工具调用都记一条审计日志:谁、什么时候、调了什么工具、参数是什么、结果是什么。出了问题能追溯,这在生产环境是刚需。

7.3 测试策略

Function Calling 的测试分两层。一层是工具方法本身的单元测试,这跟普通 Java 方法没区别,Mock 掉 Repository 就行。另一层是集成测试,验证模型能不能正确触发工具。

集成测试比较麻烦,因为模型输出有随机性。我的做法是准备一批固定的用户问句和期望的工具调用,跑多次看命中率。命中率低于 90% 就说明 description 或者提示词需要优化。别追求 100%,模型本身有不确定性,工程上够用就行。

7.4 成本控制

每次对话都要把工具 Schema 发给模型,工具越多,token 消耗越大。而且 Function Calling 场景往往是两轮请求(一轮决策、一轮总结),成本是普通对话的两倍左右。

控制手段:精简 description,去掉冗余描述;工具按场景分组,不同业务用不同的 ChatClient 实例,各自挂载相关工具;对高频简单查询,考虑加缓存,同样的订单号短时间内不重复查库。

8. 从单次调用走向 Agent 的扩展思路

把单次 Function Calling 跑通之后,你会发现很多场景需要“连续调用”。比如用户说“帮我查下订单,如果还没发货就取消掉”,这需要先查、再判断、再取消,三步。

Spring AI 里可以通过在工具方法里返回引导性文本,或者用更上层的 Agent 抽象来实现多步编排。核心思路是:让模型在每一轮都能看到上一步的结果,并决定下一步动作,直到任务完成。

但我要泼盆冷水:别一上来就做复杂 Agent。多步调用的不确定性是叠加的,三步每步 90% 准确率,整体就只剩 73%。先把单工具、单轮调用做到稳定可靠,再逐步增加复杂度。我见过太多项目,Agent 框架搭得很花哨,结果单次工具调用都经常出错,最后没法上线。

真正落地的顺序应该是:单工具稳定运行 → 多工具调度准确 → 加多轮循环 → 加规划和反思。每一步都要有测试和监控兜底,别跳步。

9. 我在实际项目里踩过的几个坑

第一个坑是模型版本升级导致行为变化。同一个工具、同一段提示词,换个模型版本,调用行为可能完全不同。所以生产环境锁定模型版本,升级前一定要回归测试工具调用。

第二个坑是中文参数编码问题。工具参数里带中文的时候,某些链路会出现乱码,导致查询失败。解决办法是在配置里统一指定 UTF-8,并且在工具方法入口做一次编码检查。

第三个坑是并发下的上下文串扰。如果你的 ChatClient 是单例,而对话里带了会话状态,高并发下可能串。工具调用本身是无状态的,但如果你在工具里依赖了 ThreadLocal 存用户信息,一定要记得在异步执行时传递,否则拿不到当前用户。

第四个坑是日志里泄露敏感信息。工具参数可能包含手机号、身份证号,直接打进日志有合规风险。我现在的做法是日志里对敏感字段做脱敏,只记录工具名和调用结果状态。

这些坑,文档里基本不会写,但每一个都能让你排查半天。写出来就是希望后来的人少走点弯路。Function Calling 这套东西,原理不复杂,难的是工程细节的打磨。把工具描述写清楚、把异常兜住、把日志打全、把权限卡死,这四件事做到位,你的 AI 应用就稳了一大半。剩下的,就是在真实业务里不断迭代工具集,让它越来越懂你的场景。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询