Function Calling 这个词在过去一年里被聊得很多,但真正落到 Java 后端项目里跑通的人其实没想象中多。大部分资料要么停留在 Python 示例,要么只讲概念不讲工程落地。我最近用 Spring AI 完整走了一遍从环境搭建到生产级 Function Calling 的链路,踩了不少坑,也积累了一些文档里不会写的经验。这篇内容就是把这套流程完整拆开,从依赖选型、接口设计、参数映射、异常处理到和 MySQL 的结合,一步步讲清楚怎么在 Spring Boot 项目里把 Function Calling 真正用起来。适合有 Java 基础、想在自己的后端服务里接入大模型能力的开发者,也适合正在做 AI Agent 相关项目、需要让模型调用本地业务方法的朋友。读完你应该能独立搭出一套可运行的 Function Calling 服务,并且知道哪些地方容易翻车。
1. 先把 Function Calling 这件事的本质说透
1.1 它到底解决了什么问题
很多人第一次接触 Function Calling,会以为这是模型在"执行代码"。其实不是。模型本身不会去连你的数据库,也不会去调你的 Service 方法。它做的事情只有一件:根据用户的自然语言输入,判断"现在需要调用哪个函数、传什么参数",然后输出一段结构化的 JSON。真正执行函数的,永远是你自己的 Java 代码。
这个认知非常关键。因为一旦你误以为模型会帮你执行,就会在架构设计上走偏,比如把数据库连接信息塞进 prompt,或者指望模型自己处理事务。正确的分工是:模型负责"意图识别 + 参数抽取",你的后端负责"参数校验 + 业务执行 + 结果回传"。
举个具体场景。用户说"帮我查一下订单号 20240512001 的物流状态"。传统做法你要写正则去解析订单号,要写意图分类模型去判断这是查询物流。有了 Function Calling,你只需要定义一个queryLogistics(String orderNo)函数,把函数签名和描述告诉模型,模型就会返回{"orderNo": "20240512001"}这样的结构化参数。剩下的查询逻辑还是你自己写。
1.2 Spring AI 在其中的角色
Spring AI 本质上是把上面这套"告诉模型有哪些函数、解析模型返回的函数调用、执行后把结果再喂回模型"的流程做了封装。它提供了一套统一的抽象,让你不用直接去拼各家大模型的 HTTP 请求体。你定义好@Bean形式的 Function,Spring AI 会自动把它转换成模型能理解的工具描述(tool schema),并在模型返回 tool_calls 时帮你路由到对应的 Java 方法。
这里有个容易忽略的点:Spring AI 的 Function 注册机制是基于 Spring 容器的。也就是说,你的函数得是一个 Spring Bean,或者至少能被容器管理。这跟直接写 Python 脚本里随手定义一个函数完全不是一个思路。理解这一点,后面配置的时候就不会迷糊。
1.3 和 Agent、工作流的边界
现在热词里经常出现 "spring ai agent"、"dify 工作流转 spring ai java 代码" 这类词。我的理解是:Function Calling 是 Agent 的底层能力之一,但 Agent 还包含规划、记忆、多轮决策等更上层的东西。如果你只是想让模型调用一两个业务方法,Function Calling 就够了,不用上 Agent 框架。反过来,如果你要做多步骤任务编排,那 Function Calling 只是其中一环,还需要考虑状态管理和循环控制。
我个人的建议是:先把单轮 Function Calling 跑稳,再考虑往上叠 Agent。很多项目一上来就搞复杂编排,结果连最基本的参数映射都没搞对,调试起来非常痛苦。
2. 环境搭建:依赖选型和版本坑
2.1 Spring Boot 版本与 Spring AI 的匹配关系
Spring AI 对 Spring Boot 版本是有要求的。目前主流的 Spring AI 1.0.x 系列建议搭配 Spring Boot 3.2 及以上。如果你还在用 Spring Boot 2.3.x 或 2.6.x,那基本没法直接用,得先升级。这一点在热词里也有人问 "spring boot 2.3.x 2.6.x",我的建议是别硬扛,升级到 3.x 是更省事的路。
JDK 方面,Spring Boot 3.x 要求 JDK 17 起步。如果你本地还是 JDK 8,IntelliJ IDEA 社区版里配置项目 SDK 的时候记得切到 17。我见过有人编译报错半天,最后发现是 IDEA 里项目结构还挂着 1.8。
依赖引入大概是这样:
<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-openai-spring-boot-starter</artifactId> <version>1.0.0</version> </dependency>如果你用的是国内模型平台,比如百炼上的 qwen 系列,那 starter 要换成对应的适配包。热词里提到 "spring ai 2.0 连接百炼 qwen3.7",说明这块需求确实存在。不同平台的 starter 名字不一样,引入之前先确认清楚,别拿 OpenAI 的包去连别的平台,那样请求格式对不上。
2.2 配置文件里最容易写错的三处
第一处是base-url。很多平台的接口地址不是默认的https://api.openai.com,你得改成平台给的地址。写错的话报错通常是 401 或者 404,但错误信息不一定直白。
第二处是模型名称。不同平台模型名差异很大,有的叫qwen-plus,有的叫gpt-4o-mini。名字写错会直接返回模型不存在的错误。
第三处是temperature和max-tokens这类参数。Function Calling 场景下,temperature 建议调低一点,比如 0.1 到 0.3。因为你要的是稳定的参数抽取,不是创意写作。temperature 太高,模型可能给你返回格式飘忽的 JSON,解析起来很头疼。
spring: ai: openai: base-url: https://your-platform-endpoint api-key: ${API_KEY} chat: options: model: your-model-name temperature: 0.2提示:api-key 千万别硬编码在配置文件里提交到仓库。用环境变量或者配置中心注入,这是基本的安全习惯。
2.3 本地要不要装 MySQL
Function Calling 本身不依赖数据库。但如果你要做的场景是"让模型查订单""让模型查用户信息",那数据库就是必须的。热词里大量出现 mysql 安装、mysql 8.0 安装教程、mysql 在 windows10 上怎么安装,说明很多人的卡点其实在环境。我的经验是:本地开发用 Docker 起一个 MySQL 最省事,一条命令搞定,不用折腾安装向导。
docker run -d --name mysql-dev \ -e MYSQL_ROOT_PASSWORD=root123 \ -e MYSQL_DATABASE=demo \ -p 3306:3306 \ mysql:8.0如果 Docker 拉镜像失败,检查一下镜像源配置。这个问题在热词里也有人提到 "docker安装mysql失败",多半是网络或者镜像源的问题,换个源基本能解决。
3. 定义你的第一个 Function:从接口设计开始
3.1 函数签名怎么设计才合理
这是整个 Function Calling 里最考验功力的地方。模型能不能正确抽取参数,很大程度上取决于你的函数签名和描述写得清不清楚。
先说命名。函数名要语义明确,用动词开头,比如queryOrderStatus、calculateShippingFee、getUserProfile。别用doSomething、handleData这种含糊的名字,模型看了也不知道什么时候该调。
再说参数。参数类型尽量用基础类型或者简单的 POJO,别搞嵌套很深的泛型结构。模型对复杂结构的理解能力有限,参数一复杂,抽取准确率就掉。如果确实需要多个参数,宁可拆成多个函数,也别硬塞进一个。
public record OrderQueryRequest(String orderNo, String phoneLast4) {} @Bean public Function<OrderQueryRequest, String> queryOrderStatus() { return request -> { // 实际查询逻辑 return orderService.queryStatus(request.orderNo(), request.phoneLast4()); }; }这里用 record 是个好选择,因为它是不可变的,而且字段语义清晰。Spring AI 会通过反射读取字段名和类型,生成对应的 JSON Schema。
3.2 函数描述:模型判断"要不要调"的唯一依据
很多人只写函数名不写描述,结果模型要么不调,要么乱调。函数描述(description)是模型判断"当前用户输入是否需要调用这个函数"的核心依据。描述要写清楚三件事:这个函数做什么、什么情况下该调、参数分别代表什么。
我一般会这样写:
查询订单的物流状态。当用户询问某个订单的配送进度、快递位置、预计到达时间时调用此函数。orderNo 是订单编号,phoneLast4 是下单手机号后四位,用于身份校验。
这段描述里,"当用户询问……时调用"就是给模型的触发条件。写得越具体,模型判断越准。如果你有多个功能相近的函数,描述之间的区分度就更重要,否则模型容易调错。
3.3 参数校验不能省
模型返回的参数不能直接信。它可能返回空字符串,可能返回格式不对的订单号,甚至可能幻觉出一个不存在的参数。所以函数内部第一件事就是校验。
return request -> { if (request.orderNo() == null || !request.orderNo().matches("\\d{11,20}")) { return "订单号格式不正确,请提供有效的订单编号"; } // 继续业务逻辑 };注意这里返回的是给模型看的提示文本,不是抛异常。因为抛异常会中断整个调用链,而返回一段说明文字,模型可以据此告诉用户"订单号格式不对,请重新提供"。这个区别很关键,是 Function Calling 里处理错误的基本姿势。
4. 把 Function 注册进 ChatClient 并跑通第一轮
4.1 注册方式与常见报错
Spring AI 里注册 Function 有几种方式。一种是通过@Bean定义 Function 类型的 Bean,然后在构建 ChatClient 时用.functions("beanName")指定。另一种是用@Description注解配合方法引用。我倾向于用 Bean 方式,因为依赖注入更自然,测试也方便。
@Configuration public class FunctionConfig { @Bean @Description("查询订单物流状态,用户询问配送进度时调用") public Function<OrderQueryRequest, String> queryOrderStatus(OrderService orderService) { return request -> orderService.queryStatus(request.orderNo(), request.phoneLast4()); } }构建 ChatClient:
ChatClient chatClient = ChatClient.builder(chatModel) .defaultFunctions("queryOrderStatus") .build();常见报错是 "No function found with name xxx"。这通常是 Bean 名字和注册时写的名字对不上。Bean 默认名字是方法名,如果你用了@Bean("customName")改了名字,注册时就得用改后的名字。
4.2 一次完整的调用链路拆解
用户输入"帮我查下订单 20240512001 到哪了",整个链路是这样的:
第一步,Spring AI 把你的问题和已注册函数的 schema 一起发给模型。schema 里包含函数名、描述、参数结构。
第二步,模型判断需要调用queryOrderStatus,返回一个 tool_call,参数是{"orderNo": "20240512001"}。
第三步,Spring AI 拦截这个 tool_call,找到对应的 Bean,把参数反序列化成OrderQueryRequest,执行你的 lambda。
第四步,你的函数返回结果字符串,Spring AI 把这个结果作为 tool 消息追加到对话历史,再次发给模型。
第五步,模型根据函数返回结果,生成自然语言回复给用户。
理解这五步,调试的时候就知道问题出在哪一环。比如模型没调函数,那是第一步 schema 或描述的问题;参数不对,那是第二步抽取的问题;执行报错,那是第三步你代码的问题。
4.3 多函数场景下的选择逻辑
当你有多个函数时,模型需要自己选。这时候描述之间的边界就很重要。我做过一个测试,同时注册queryOrderStatus和queryRefundStatus两个函数,如果描述都写得很笼统,模型经常把退款查询走到订单查询上。后来我把描述改成"查询订单的物流配送状态"和"查询订单的退款处理进度",区分度上来了,准确率明显提升。
注意:函数数量不是越多越好。我实测下来,单次注册超过 10 个函数后,模型选择准确率会下降。如果业务函数很多,考虑按场景分组,或者用路由层先做一轮筛选。
5. 和 MySQL 结合:让模型真正查到数据
5.1 数据访问层的设计
Function 内部要查数据库,走的就是常规的 Spring Boot + MyBatis 或者 JPA 那套。热词里 "spring boot + mybatis 的 java 开源多商户跨境商城源码" 说明 MyBatis 在业务系统里用得很多。我这边用 MyBatis 举例。
@Mapper public interface OrderMapper { @Select("SELECT status, logistics_info FROM orders WHERE order_no = #{orderNo} AND phone_last4 = #{phoneLast4}") OrderInfo selectByOrderNo(@Param("orderNo") String orderNo, @Param("phoneLast4") String phoneLast4); }注意 SQL 里带了phone_last4条件,这是身份校验的一部分。Function Calling 场景下,模型可能被诱导去查别人的订单,所以权限校验必须在数据层做,不能只靠模型自觉。
5.2 返回结果怎么组织给模型看
函数返回给模型的内容,直接影响模型最终回复的质量。如果你返回一大坨 JSON,模型可能抓不住重点。我的做法是返回一段结构清晰的文本,把关键字段列出来。
public String queryStatus(String orderNo, String phoneLast4) { OrderInfo info = orderMapper.selectByOrderNo(orderNo, phoneLast4); if (info == null) { return "未找到该订单,请确认订单号和手机号后四位是否正确"; } return String.format("订单状态:%s,物流信息:%s,更新时间:%s", info.getStatus(), info.getLogisticsInfo(), info.getUpdateTime()); }这样模型拿到的是人话,它转述给用户的时候也更自然。如果你返回原始 JSON,模型有时候会直接把 JSON 念出来,体验很差。
5.3 事务和超时控制
Function 执行是在模型调用链路里的,如果函数里做了耗时操作,整个响应会很慢。所以数据库查询要加超时,复杂操作要考虑异步。另外,Function 内部如果涉及写操作,事务边界要自己控制好,别指望模型帮你管事务。
我一般会给 Function 内部的数据库操作设一个 3 秒超时,超过就返回"查询超时,请稍后重试"。这样即使用户体验有损,也不会把整个请求拖死。
6. 踩坑实录:那些文档里不会写的问题
6.1 参数反序列化失败
最常见的一个坑是模型返回的参数类型和你的 POJO 对不上。比如你定义的是Integer count,模型返回了"count": "3"字符串。Spring AI 底层用的 Jackson 有时候能自动转换,有时候不能,取决于配置。我的做法是参数尽量用 String,在函数内部自己转,这样最稳。
还有一个坑是模型返回了额外的字段。比如你只定义了orderNo,模型返回了{"orderNo": "123", "reason": "用户查询"}。如果 Jackson 配置了FAIL_ON_UNKNOWN_PROPERTIES,就会直接报错。建议在 ObjectMapper 里关掉这个选项。
6.2 模型不调用函数
有时候用户明明问的是订单,模型却直接编了个答案,没调函数。原因通常有三个:一是函数描述没写触发条件;二是 temperature 太高;三是系统提示词里没强调"涉及订单查询必须调用工具"。
我的解决办法是在 system prompt 里明确写:"当用户询问订单、物流、退款相关问题时,必须调用相应工具获取真实数据,不得凭空回答。" 这句话加上之后,不调用的情况明显减少。
6.3 多轮对话里的上下文丢失
Function Calling 在多轮对话里有个细节:函数调用的结果需要作为消息历史的一部分保留下来。如果你每轮都新建 ChatClient 或者清空历史,模型就记不住上一轮查了什么。Spring AI 的 ChatMemory 可以帮忙管理,但要注意配置合适的窗口大小,太小会丢上下文,太大又费 token。
6.4 中文参数的处理
中文场景下,模型抽取的中文参数有时候会带多余空格或者标点。比如用户说"查一下订单 12345 的物流",模型可能返回"orderNo": "12345 "带个尾空格。函数内部记得 trim 一下,不然数据库查不到。
7. 从能跑到好用:几个提升稳定性的实践
7.1 给函数加日志和埋点
Function 执行是黑盒,出问题不好查。我习惯在每个 Function 入口打一条日志,记录入参和出参。这样线上出问题的时候,能快速判断是模型抽取错了,还是业务逻辑错了。
return request -> { log.info("Function queryOrderStatus called with: {}", request); String result = orderService.queryStatus(request.orderNo(), request.phoneLast4()); log.info("Function queryOrderStatus returned: {}", result); return result; };7.2 降级和兜底
模型服务本身可能不稳定,超时或者限流都可能发生。Function Calling 链路里,如果模型这一步挂了,整个功能就不可用。我的做法是加一层降级:模型调用失败时,返回一个引导用户走传统表单查询的提示,而不是直接报错。
7.3 参数白名单校验
对于枚举类参数,比如订单状态查询,模型可能返回一个不存在的状态值。函数内部要做白名单校验,只接受预定义的几个值,其他一律拒绝。这既是安全考虑,也是数据质量考虑。
7.4 控制函数粒度
我见过有人把一个函数写成"万能查询",参数里带个 type 字段,根据 type 走不同分支。这种设计模型很难用对,因为描述里说不清楚每种 type 的适用场景。正确做法是按业务语义拆成独立函数,每个函数职责单一。
8. 关于 Spring AI 版本演进的一点个人观察
Spring AI 这个项目迭代挺快的,从早期版本到 1.0 再到现在的 2.0.x,API 有不小变化。热词里有人问 "spring ai alibaba 停更了吗",也有人关注 "spring ai 2.0.1"。我的建议是:生产项目锁定一个稳定版本,别追最新。因为 Function Calling 这块的 API 在不同版本间改过好几次,升级一次可能要改不少代码。
如果你现在开始一个新项目,用 1.0.x 的稳定版就够了,功能完全够用。等 2.0 生态成熟了再考虑迁移。迁移的时候重点看 Function 注册方式和 ChatClient 构建方式这两块,这两处变动最大。
另外,不同模型平台对 Function Calling 的支持程度不一样。有的平台支持并行调用多个函数,有的只支持单个。选平台的时候要确认这一点,不然设计多函数协作的时候会受限。
9. 一个完整的可运行示例结构
把上面的东西串起来,一个典型的项目结构大概是这样:
config/FunctionConfig.java:定义所有 Function Beanservice/OrderService.java:业务逻辑mapper/OrderMapper.java:数据访问controller/ChatController.java:对外接口config/ChatClientConfig.java:构建 ChatClient
Controller 层大概长这样:
@RestController public class ChatController { private final ChatClient chatClient; public ChatController(ChatClient chatClient) { this.chatClient = chatClient; } @PostMapping("/chat") public String chat(@RequestBody String message) { return chatClient.prompt() .user(message) .call() .content(); } }这个结构跑通之后,你就可以往里加更多函数、加记忆、加流式输出。但基础链路一定是先跑稳。
我在实际项目里最大的体会是:Function Calling 的难点不在模型,而在工程。模型能力已经够用了,真正花时间的是参数设计、错误处理、权限校验、日志埋点这些"脏活累活"。把这些做扎实,功能才敢上生产。另外一个小技巧是,调试阶段把模型的原始返回打出来看,很多时候问题一眼就能定位,比猜快得多。