1. 为什么 Function Calling 值得单独拿出来讲
Function Calling 这个词这两年在 AI 应用开发圈子里出现的频率越来越高,但很多人第一次听到会有点懵——大模型不是负责聊天、写文案、生成代码的吗,怎么还跟“调用函数”扯上关系了?其实这正是大模型从“玩具”走向“生产力工具”的关键一步。简单说,Function Calling 就是让大模型在对话过程中,能够主动判断“这个问题我需要查一下数据库”或者“这个请求我得调用一个外部接口”,然后输出一个结构化的调用意图,由我们的后端代码去真正执行,再把结果喂回给模型,让它继续组织语言回答用户。
我拿一个真实场景举例。假设你做了一个智能客服,用户问“我上个月的订单发货了没”。如果没有 Function Calling,模型只能瞎猜或者告诉你“我无法查询订单信息”。但有了 Function Calling,模型会识别出这里需要调用一个叫queryOrderStatus的函数,参数是orderId和month,然后你的 Spring Boot 服务收到这个意图,去 MySQL 里查真实数据,把结果返回给模型,模型再用自然语言告诉用户“您上个月的订单已于 3 月 15 日发货,预计 3 月 18 日送达”。整个过程用户感知不到背后发生了什么,但体验直接从“人工智障”变成了“真能办事”。
Spring AI 是 Spring 生态里专门做 AI 应用开发的框架,它把 Function Calling 这套机制封装得相当顺手。你不需要自己去解析模型返回的 JSON,也不需要手动拼装工具描述,只要用@Bean定义一个Function或者Supplier,Spring AI 会自动帮你注册成模型可调用的工具。这对于 Java 开发者来说简直是福音——我们不用去学 Python 那一套 LangChain 的写法,直接用熟悉的 Spring 注解和依赖注入就能搞定。
这篇文章我打算从零开始,把 Spring AI 的 Function Calling 完整讲一遍。包括环境怎么搭、工具怎么定义、参数怎么传、多轮对话怎么处理、MySQL 怎么接、踩过哪些坑、怎么调试。适合已经有 Spring Boot 基础、想快速把 AI 能力集成到现有 Java 项目里的后端开发。如果你还在纠结“Java 能不能做 AI 应用”,看完这篇应该就有答案了。
2. 环境准备与项目骨架搭建
2.1 版本选型和依赖引入
Spring AI 的版本迭代挺快的,我写这篇内容时稳定可用的是 1.0.0 系列,对应的 Spring Boot 是 3.4.x。这里有个坑要提前说:Spring AI 对 Spring Boot 版本有硬性要求,如果你还在用 Spring Boot 2.x,那基本没法直接上,得先升级。我试过在 2.7 的项目里强行引入,结果一堆自动配置类加载失败,最后老老实实升到了 3.4。
Maven 依赖主要加这几个:
<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-openai-spring-boot-starter</artifactId> <version>1.0.0</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>3.0.4</version> </dependency>如果你用的是 Spring AI Alibaba 那一套,依赖坐标会不一样,但核心 API 基本兼容。我建议新手先用 OpenAI 兼容的 starter,因为大部分国内模型服务都提供了 OpenAI 兼容接口,改个base-url就能切换。
配置文件里至少要写这几项:
spring: ai: openai: api-key: ${AI_API_KEY} base-url: https://api.example.com chat: options: model: qwen-plus temperature: 0.7注意:api-key 千万别硬编码在 yaml 里提交到 Git,用环境变量或者配置中心。我见过有人直接把 key 推到公开仓库,结果第二天账单跑了几百块。
2.2 项目分层结构设计
我习惯把 AI 相关的代码单独放一个包,不要和业务代码混在一起。结构大概是这样:
com.example.ai ├── config // ChatClient、Function 注册配置 ├── function // 所有可被模型调用的工具类 ├── service // 业务服务,被 function 调用 ├── controller // 对外 HTTP 接口 └── model // DTO、请求响应对象这样分的好处是,当模型调用链出问题时,你能快速定位是工具定义的问题、还是业务逻辑的问题、还是模型本身理解错了。我踩过一次坑,把 Function 定义直接写在 Controller 里,结果调试时完全分不清是 HTTP 参数绑定错了还是模型传参错了,后来拆开就清晰多了。
2.3 数据库准备
Function Calling 最有价值的场景之一就是查数据库。我建了一张简单的订单表来演示:
CREATE TABLE `orders` ( `id` bigint NOT NULL AUTO_INCREMENT, `order_no` varchar(32) NOT NULL, `user_id` bigint NOT NULL, `product_name` varchar(128) DEFAULT NULL, `status` tinyint DEFAULT '0' COMMENT '0待付款 1已付款 2已发货 3已完成', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_user_id` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;插几条测试数据,后面演示查询用。MySQL 安装这块就不展开了,Windows 下用 installer 一路下一步就行,记得字符集选 utf8mb4,不然中文商品名会乱码。
3. Function Calling 核心机制拆解
3.1 模型是怎么“知道”该调用哪个函数的
很多人以为模型真的在执行函数,其实不是。模型做的事情是:你提前把可用的函数列表(包括函数名、描述、参数 schema)一起发给它,它在生成回复时,如果判断需要调用某个函数,就会输出一段结构化的 JSON,类似:
{ "name": "queryOrderStatus", "arguments": { "orderNo": "ORD20240315001" } }真正执行的是你的 Java 代码。Spring AI 在中间做了两层转换:第一层是把你的FunctionBean 转成模型能理解的工具描述;第二层是拿到模型返回的调用意图后,反射调用对应的 Bean,把结果再包装成消息发回给模型。
这里的关键在于函数描述的质量。模型判断要不要调用、调用哪个,完全依赖你写的 description。我试过把描述写成“查询订单”,结果用户问“帮我看看我买的东西到哪了”,模型有时候不调用。后来改成“根据订单号查询订单的发货状态和物流信息,当用户询问订单进度、发货情况时使用”,命中率明显提升。
3.2 Spring AI 中 Function 的三种定义方式
Spring AI 支持几种定义方式,我逐个说下适用场景。
第一种是java.util.function.Function,适合有输入有输出的场景:
@Bean @Description("根据订单号查询订单状态") public Function<OrderQueryRequest, OrderQueryResponse> queryOrderStatus() { return request -> orderService.queryByOrderNo(request.orderNo()); }第二种是Supplier,适合无参数的工具,比如“获取当前时间”:
@Bean @Description("获取当前系统时间") public Supplier<String> currentTime() { return () -> LocalDateTime.now().toString(); }第三种是Consumer,适合只执行不返回的场景,比如“发送通知”。不过实际用下来 Consumer 比较少,因为模型通常需要知道执行结果。
参数对象建议用 record,Spring AI 会根据 record 的字段自动生成 JSON Schema。字段上可以加@JsonPropertyDescription补充说明,模型对参数的理解会更准。
3.3 工具注册与 ChatClient 绑定
定义好 Function Bean 之后,需要在调用时注册给 ChatClient:
ChatClient chatClient = ChatClient.builder(chatModel) .defaultFunctions("queryOrderStatus", "currentTime") .build();也可以按次注册:
String answer = chatClient.prompt() .user("帮我查下订单 ORD20240315001 的状态") .functions("queryOrderStatus") .call() .content();我一般用 defaultFunctions 把常用工具都挂上,特殊场景再按次覆盖。注意函数名要和 Bean 名称一致,Spring AI 默认用 Bean 名作为工具名。如果你用@Bean("myFunc")改了名字,注册时也要用这个名字。
4. 完整实战:从接口到数据库的闭环
4.1 定义请求响应对象
先定义工具用的 DTO,用 record 最简洁:
public record OrderQueryRequest( @JsonPropertyDescription("订单编号,格式如 ORD20240315001") String orderNo ) {} public record OrderQueryResponse( String orderNo, String productName, String statusText, String createTime ) {}@JsonPropertyDescription这个注解很关键,它会进入发给模型的 schema 里,帮助模型正确填充参数。我试过不加描述,模型有时候会把订单号理解成用户 ID。
4.2 编写业务 Service
MyBatis 的 Mapper 就不贴了,标准写法。Service 层做个状态转换:
@Service public class OrderService { @Autowired private OrderMapper orderMapper; public OrderQueryResponse queryByOrderNo(String orderNo) { Order order = orderMapper.selectByOrderNo(orderNo); if (order == null) { return new OrderQueryResponse(orderNo, null, "订单不存在", null); } String statusText = switch (order.getStatus()) { case 0 -> "待付款"; case 1 -> "已付款"; case 2 -> "已发货"; case 3 -> "已完成"; default -> "未知状态"; }; return new OrderQueryResponse( order.getOrderNo(), order.getProductName(), statusText, order.getCreateTime().toString() ); } }这里有个细节:返回给模型的结果要尽量用自然语言友好的格式,不要返回一堆数字状态码。模型看到“2”不一定知道是已发货,看到“已发货”就能直接组织语言。
4.3 注册 Function Bean
@Configuration public class AiFunctionConfig { @Bean @Description("根据订单号查询订单的发货状态、商品名称和下单时间。当用户询问订单进度、发货情况、物流状态时调用此函数") public Function<OrderQueryRequest, OrderQueryResponse> queryOrderStatus(OrderService orderService) { return request -> orderService.queryByOrderNo(request.orderNo()); } @Bean @Description("获取当前系统时间,当用户询问现在几点、今天日期时调用") public Supplier<String> currentTime() { return () -> LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); } }注意 Function Bean 的方法参数里可以注入其他 Bean,Spring 会自动处理。这样就不用把 OrderService 写成静态或者手动 new 了。
4.4 Controller 对外接口
@RestController @RequestMapping("/api/chat") public class ChatController { private final ChatClient chatClient; public ChatController(ChatModel chatModel) { this.chatClient = ChatClient.builder(chatModel) .defaultFunctions("queryOrderStatus", "currentTime") .build(); } @PostMapping public String chat(@RequestBody ChatRequest request) { return chatClient.prompt() .user(request.message()) .call() .content(); } }启动项目,用 Postman 发一条“帮我查下订单 ORD20240315001 发货了没”,如果配置正确,你会看到模型先触发函数调用,然后返回类似“您的订单 ORD20240315001(商品:无线耳机)已于 2024-03-15 发货,当前状态为已发货”的回复。
4.5 多轮对话中的上下文保持
单轮调用简单,但真实场景往往是多轮。比如用户先说“查下我的订单”,模型问“请提供订单号”,用户再给订单号。这时候需要把历史消息带上:
String answer = chatClient.prompt() .user("查下我的订单") .call() .content(); // 第二轮 String answer2 = chatClient.prompt() .user("ORD20240315001") .call() .content();但这样第二轮模型不知道上下文。正确做法是用ChatMemory:
ChatMemory memory = MessageWindowChatMemory.builder() .maxMessages(20) .build(); ChatClient chatClient = ChatClient.builder(chatModel) .defaultFunctions("queryOrderStatus") .defaultAdvisors(new MessageChatMemoryAdvisor(memory)) .build();这样每次调用会自动带上历史消息,模型能理解“ORD20240315001”是在回答上一轮的追问。maxMessages控制窗口大小,太大费 token,太小会丢上下文,我一般设 20 条左右。
5. 常见问题与排查技巧实录
5.1 模型不调用函数怎么办
这是最高频的问题。排查顺序我总结成一张表:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 完全不调用 | 函数没注册 | 检查 defaultFunctions 名称是否和 Bean 名一致 |
| 偶尔不调用 | 描述不够清晰 | 在 @Description 里补充触发场景关键词 |
| 调用错函数 | 多个函数描述重叠 | 让每个函数的描述边界清晰,避免语义交叉 |
| 参数传错 | schema 描述缺失 | 给字段加 @JsonPropertyDescription |
| 报序列化错误 | 参数类型不匹配 | 检查 record 字段类型和模型传的值是否兼容 |
我遇到过一次,模型死活不调用,最后发现是 Bean 名用了驼峰queryOrderStatus,但注册时写成了query_order_status,Spring AI 找不到就静默忽略了。这种问题不会报错,只能靠日志。
5.2 函数执行异常怎么处理
如果函数内部抛异常,Spring AI 默认会把异常信息传给模型,模型可能会回复“查询失败”。但更好的做法是在函数内部捕获,返回一个友好的错误对象:
return request -> { try { return orderService.queryByOrderNo(request.orderNo()); } catch (Exception e) { log.error("查询订单失败", e); return new OrderQueryResponse(request.orderNo(), null, "查询失败,请稍后重试", null); } };这样模型拿到的是结构化结果,能组织出更自然的回复,而不是把堆栈信息暴露给用户。
5.3 调试 Function Calling 的实用技巧
打开 Spring AI 的 debug 日志,能看到完整的请求和响应:
logging: level: org.springframework.ai: DEBUG日志里会打印发给模型的 tools 定义、模型返回的 tool_calls、以及函数执行结果。我调试时基本靠这个,比猜快多了。
另一个技巧是先用curl直接调模型接口,确认模型本身支持 Function Calling。有些小模型或者老版本模型不支持这个能力,你代码写得再对也没用。
5.4 性能与 token 消耗优化
每次调用都把函数定义发给模型,会消耗额外 token。函数越多,消耗越大。我的经验是:
- 单个请求注册的函数不超过 5 个,多了模型选择困难,token 也浪费
- 函数描述控制在 50 字以内,说清楚“什么时候用”比“怎么实现”更重要
- 参数 schema 尽量简单,避免嵌套过深的对象
如果确实有很多工具,可以按业务域分组,根据用户意图先做一次路由,再注册对应组的函数。
6. 进阶玩法与扩展方向
6.1 结合 Spring AI Alibaba 接入国内模型
Spring AI Alibaba 对国内模型的支持更原生,配置方式略有不同,但 Function Calling 的 API 基本一致。切换时主要改依赖坐标和配置文件,业务代码几乎不用动。我实测下来,国内模型在中文场景下的函数调用准确率反而更高,尤其是涉及中文商品名、地址这类参数时。
6.2 把工作流引擎的流程转成 Function
现在很多团队用工作流引擎编排 AI 流程,比如 Dify。一个常见的需求是把工作流里的某个节点转成 Spring AI 的 Function。思路是:把工作流的输入参数定义成 record,把工作流的 HTTP 调用封装成 Function Bean,描述里写清楚这个工作流节点负责什么。这样模型就能在对话中触发工作流,实现“对话即编排”。
6.3 多商户跨境商城场景的落地思路
热词里提到多商户跨境商城,这个场景其实特别适合 Function Calling。比如用户问“我买的那个日本化妆品到哪了”,模型需要:先根据用户 ID 查订单,再根据订单查物流,物流可能还涉及跨境段和国内段。可以把这三个查询拆成三个 Function,让模型自己决定调用顺序。跨境场景还要注意时区和货币转换,这些都可以封装成独立 Function,模型按需调用。
6.4 监控与可观测性
生产环境一定要加监控。Spring Boot Admin 可以监控服务健康度,但 Function Calling 的调用成功率、平均耗时、失败原因这些需要自己埋点。我一般会在 Function 包装层加一个切面,记录每次调用的函数名、参数、耗时、结果状态,上报到监控系统。这样出问题时能快速定位是模型没调用、还是调用了但业务失败。
7. 我踩过的几个真实坑
第一个坑是函数返回值太大。有一次我直接把订单列表整个返回,结果 token 爆了,模型回复被截断。后来改成只返回摘要信息,或者分页返回,问题解决。
第二个坑是并发调用。模型有时候会一次性返回多个 tool_calls,Spring AI 默认是串行执行。如果函数里有耗时操作,整体响应会很慢。可以在配置里开启并行执行,但要注意线程安全和数据库连接池大小。
第三个坑是函数名冲突。不同模块定义了同名 Bean,Spring 启动时直接报错。解决办法是给 Bean 显式命名,或者用@Qualifier区分。
第四个坑是模型版本升级导致行为变化。同一个提示词,模型从旧版本升到新版本后,函数调用策略可能变了。所以生产环境要锁定模型版本,升级前充分回归测试。
8. 一些实用建议
如果你刚开始接触 Spring AI 的 Function Calling,我的建议是先跑通一个最简单的例子:一个 Supplier 返回当前时间,确认整条链路通了,再逐步加复杂的 Function。不要一上来就搞十几个工具,出了问题根本不知道是哪里的。
另外,函数描述值得反复打磨。我一般会拿十几条真实用户问法去测试,看模型命中率。命中率低于 80% 就回去改描述,通常改两三轮就能到 90% 以上。
最后,别忘了给 Function 加日志。模型调用是黑盒,日志是你唯一能看清内部发生了什么的手段。我现在的习惯是每个 Function 入口和出口都打日志,包含请求参数和返回摘要,排查问题时省了大量时间。
这个方向后续还可以往 Agent 编排走,让多个 Function 组成一个能自主规划步骤的智能体。Spring AI 在这方面也在持续迭代,值得持续关注。