1. 为什么 Function Calling 值得花时间吃透
Function Calling 这个词这两年被聊得很多,但真正在 Java 后端项目里把它跑通、跑稳的人其实没那么多。我身边不少做 Spring Boot 的朋友,模型对话接得挺顺,一到"让模型去调用我系统里的真实接口"这一步就卡住了:要么模型死活不触发工具,要么参数拼得乱七八糟,要么返回结果对不上。说到底,Function Calling 不是一个"调个 API 就完事"的功能,它是一套让大模型和你的业务系统握手的协议。
我这次做的事情很明确:用 Spring AI 搭一个完整的 Function Calling 实战项目,从零把整条链路走通——模型识别意图、生成结构化调用参数、后端执行真实业务逻辑(查 MySQL、算数据、调服务)、再把结果回灌给模型生成自然语言回答。技术栈就是大家最熟的那套:Spring Boot + Spring AI + MySQL + Java。适合谁看?只要你会写 Spring Boot 的 Controller 和 Service,能看懂 MyBatis 的 Mapper,这篇就能直接抄作业。哪怕你之前没接触过 Spring AI,我也会把每一步为什么这么做讲清楚。
我踩过的坑不少,比如工具描述写得含糊导致模型不调用、参数类型对不上导致反序列化失败、多工具场景下模型选错工具等等。这些在官方文档里基本一笔带过,但在真实项目里天天遇到。所以这篇不只是"怎么跑通",更是"怎么跑稳"。
2. 整体设计与技术选型拆解
2.1 Function Calling 到底解决了什么问题
先把这个概念说透。大模型本身是个"语言天才 + 数据瞎子"——它知道怎么组织语言,但它不知道你数据库里今天有多少订单、不知道某个用户的会员等级、更不知道实时库存。传统做法是把这些数据塞进 Prompt 里喂给模型,问题是数据量大、时效性差、还容易超上下文。
Function Calling 的思路反过来:不让模型"知道"数据,而是让模型"知道去哪里拿数据"。你提前把系统里可用的能力(函数)注册给模型,模型在对话时判断"这个问题需要调用某个函数",然后输出一个结构化的调用请求(函数名 + 参数),你的后端拿到请求去执行真实逻辑,把结果返回给模型,模型再基于结果组织回答。
打个比方:模型是个很聪明的客服,但它没有系统权限。Function Calling 就是给它配了一本"操作手册",手册上写着"查订单找谁、算价格找谁",它需要的时候按手册发起请求,真正干活的是后台系统。这样模型负责理解和表达,业务系统负责执行和兜底,职责清晰。
2.2 为什么选 Spring AI 而不是自己手撸
有人会问,Function Calling 本质就是拼 JSON、解析 JSON,我自己用 HTTP 客户端调模型接口不就行了?理论上可以,但实际做起来你会发现要处理的东西非常多:不同模型厂商的工具描述格式不一样、流式响应下的工具调用分片、多轮工具调用的状态管理、参数 schema 的生成和校验……这些 Spring AI 都帮你封装好了。
Spring AI 的核心价值在于它提供了一套统一的抽象:@Tool注解声明工具、ChatClient统一对话入口、ToolCallback管理工具注册。你写业务逻辑,框架处理协议细节。而且它和 Spring Boot 的生态无缝衔接——你的 Service、Repository 直接就能变成模型可调用的工具,不用额外搭一层。
选型上我做了几个取舍,列个表更清楚:
| 方案 | 优势 | 劣势 | 适用场景 |
|---|---|---|---|
| Spring AI | 与 Spring 生态无缝、注解式声明、统一抽象 | 版本迭代快、部分高级特性文档少 | Spring Boot 项目首选 |
| 手撸 HTTP 调用 | 完全可控、无框架依赖 | 协议细节全自己处理、维护成本高 | 极简场景或特殊定制 |
| 其他语言 SDK | 生态成熟 | 与 Java 后端割裂、需跨服务调用 | 多语言架构 |
我的判断是:只要你是 Spring Boot 项目,Spring AI 就是当前最省心的选择。它把"模型调用"这件事变成了 Spring 风格的编程,学习成本主要花在理解 Function Calling 的机制上,而不是框架本身。
2.3 项目整体架构设计
整个项目的分层我设计得很清晰,避免把模型调用和业务逻辑搅在一起:
- Controller 层:对外暴露对话接口,接收用户消息,返回模型回答。
- ChatService 层:封装 ChatClient,负责组织对话、注册工具、处理多轮调用。
- Tool 层:用
@Tool注解声明的工具方法,每个方法对应一个业务能力。 - Business Service 层:真正的业务逻辑,比如订单查询、库存计算。
- Repository 层:MyBatis Mapper,对接 MySQL。
这样分层的好处是:工具层只是"薄薄的一层适配",把业务能力暴露给模型,真正的逻辑还在原来的 Service 里。以后要加新工具,只需要在 Tool 层加个方法,业务代码完全不用动。这也是我一直强调的——Function Calling 不应该侵入你的业务架构,它只是一个"对外接口"。
3. 核心细节解析与实操要点
3.1 环境准备与依赖配置
先把地基打好。我用的是 Spring Boot 3.x + Spring AI,JDK 要求 17 以上,这点要注意,Spring Boot 3 已经不支持 JDK 8 了。MySQL 用 8.0 就行,本地装或者 Docker 起一个都可以。
Maven 依赖核心是这几块:
<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-model-openai</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>3.0.3</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> </dependency>注意:Spring AI 的 starter 命名在不同版本间有过调整,早期是
spring-ai-openai-spring-boot-starter,新版本改成了spring-ai-starter-model-openai。如果你照着老教程配依赖报找不到,八成是命名变了,去官方仓库确认当前版本的正确 artifactId。
配置文件里配好模型连接信息和数据库:
spring: ai: openai: api-key: ${AI_API_KEY} base-url: ${AI_BASE_URL} chat: options: model: qwen-plus temperature: 0.7 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这里base-url和model是可配置的,方便你切换不同的模型服务。temperature我建议 Function Calling 场景下别设太高,0.7 以下比较稳,太高了模型容易"自由发挥",参数拼错。
3.2 工具声明:@Tool 注解的正确打开方式
这是整个项目最核心的部分。工具声明写得好不好,直接决定模型能不能正确调用。先看一个我实际用的例子——订单查询工具:
@Component public class OrderTools { private final OrderService orderService; public OrderTools(OrderService orderService) { this.orderService = orderService; } @Tool(description = "根据订单号查询订单详情,返回订单状态、金额、下单时间。订单号格式为ORD开头加12位数字") public OrderDetail queryOrder( @ToolParam(description = "订单号,例如 ORD202401010001") String orderNo) { return orderService.getByOrderNo(orderNo); } }看起来简单,但每个细节都有讲究。description是给模型看的,不是给人看的。模型完全靠这段描述判断"什么时候该调用这个工具"。所以描述里必须包含三要素:这个工具做什么、输入长什么样、返回什么。
我一开始把描述写成"查询订单",结果模型经常不调用,或者用户问"我的包裹到哪了"它也不触发。后来改成"根据订单号查询订单详情,返回订单状态、金额、下单时间",命中率立刻上来了。因为"包裹到哪了"本质是问订单状态,描述里点明了"订单状态",模型就能关联上。
@ToolParam的参数描述同样重要。模型要根据描述生成参数值,如果只写"订单号",模型可能生成"12345"这种不符合格式的值。写上"订单号格式为ORD开头加12位数字"并给个示例,模型生成的参数就规范多了。
3.3 参数类型与返回值的设计陷阱
这里有个很多人踩的坑:工具方法的参数和返回值类型,直接影响序列化和反序列化的成败。
参数类型上,尽量用简单类型(String、Integer、Long、Boolean)或者结构清晰的 POJO。别用 Map、List 这种泛型嵌套太深的,模型生成参数时容易出错,反序列化也容易失败。如果确实需要复杂参数,拆成多个简单参数更稳。
返回值类型上,我强烈建议返回结构化的 POJO 而不是裸字符串。原因有两个:一是模型能更好地理解结构化数据,生成更准确的回答;二是方便你后续做日志和调试。但要注意,返回的 POJO 字段别太多,字段名要语义清晰,否则模型读起来费劲。
public record OrderDetail( String orderNo, String status, BigDecimal amount, LocalDateTime createTime ) {}用 record 是个好选择,简洁且不可变。字段名用英文,但可以在@Tool描述里说明每个字段的含义,帮助模型理解。
实操心得:工具返回值里千万别塞敏感信息,比如用户手机号、身份证、完整地址。模型会把这些原样吐给用户,造成隐私泄露。需要展示时做脱敏处理,比如手机号中间四位打码。
3.4 多工具场景下的注册与选择
真实项目里不可能只有一个工具。我这次做了三个:订单查询、库存查询、价格计算。多工具注册时,模型需要从一堆工具里选对的那个,这时候工具描述的"区分度"就特别关键。
如果两个工具描述太像,模型就会选错。比如"查询订单"和"查询订单物流",描述里如果不点明区别,模型很容易混。我的做法是在描述里明确边界:"查询订单详情(不含物流信息)"和"查询订单物流轨迹(仅物流状态)",把各自的职责划清楚。
注册多个工具的方式:
@Configuration public class ToolConfig { @Bean public ChatClient chatClient(ChatClient.Builder builder, OrderTools orderTools, InventoryTools inventoryTools, PriceTools priceTools) { return builder .defaultTools(orderTools, inventoryTools, priceTools) .build(); } }defaultTools会把这三个工具注册到每次对话中。如果工具特别多(比如几十个),全量注册会拖慢响应、增加模型选择难度,这时候可以考虑按场景动态注册,或者用工具分组。我一般控制在 10 个以内,超过就说明该拆服务了。
4. 实操过程与核心环节实现
4.1 从一次对话看完整调用链路
光讲概念太虚,我带你走一遍真实的调用过程。假设用户问:"帮我查一下订单 ORD202401010001 现在什么状态?"
第一步,模型识别意图。用户消息连同所有工具的描述一起发给模型。模型分析后发现"查订单状态"匹配到了queryOrder工具,于是不直接回答,而是生成一个工具调用请求:
{ "tool": "queryOrder", "arguments": { "orderNo": "ORD202401010001" } }第二步,后端执行工具。Spring AI 拦截到这个请求,找到对应的queryOrder方法,把参数反序列化后调用。方法内部走OrderService→OrderMapper→ MySQL,拿到真实数据。
第三步,结果回灌。工具执行结果被序列化后,作为一条"工具消息"追加到对话历史里,再次发给模型。模型这次拿到了真实数据,组织成自然语言回答:"订单 ORD202401010001 当前状态为已发货,金额 299.00 元,下单时间是 2024 年 1 月 1 日。"
第四步,返回用户。最终回答通过 Controller 返回给前端。
整个链路里,模型被调用了两次(一次决定调工具,一次生成回答),工具被执行了一次。理解这个"两次模型调用"很关键,因为它直接关系到你的接口耗时和成本——一次对话可能产生两次 token 消耗。
4.2 ChatClient 的对话组织代码
核心的对话代码其实不长,但每行都有讲究:
@Service public class ChatService { private final ChatClient chatClient; public ChatService(ChatClient chatClient) { this.chatClient = chatClient; } public String chat(String userMessage) { return chatClient.prompt() .user(userMessage) .call() .content(); } }prompt()开启一次对话,user()传入用户消息,call()触发同步调用,content()取出文本结果。工具调用对这段代码是透明的——你不需要手动处理工具请求,Spring AI 在call()内部自动完成了"识别工具 → 执行 → 回灌 → 再生成"的全过程。
如果要支持多轮对话(带上下文),需要维护ChatMemory:
@Bean public ChatMemory chatMemory() { return new InMemoryChatMemory(); } @Bean public ChatClient chatClient(ChatClient.Builder builder, ChatMemory chatMemory, OrderTools orderTools) { return builder .defaultSystem("你是一个电商客服助手,回答要简洁准确") .defaultAdvisors(new MessageChatMemoryAdvisor(chatMemory)) .defaultTools(orderTools) .build(); }defaultSystem设置系统提示词,给模型定角色。MessageChatMemoryAdvisor负责把历史消息带上,实现多轮记忆。注意InMemoryChatMemory是内存实现,重启就丢,生产环境要换成基于 Redis 或数据库的实现。
4.3 数据库层与 MyBatis 对接
工具最终要落到真实数据上,我用 MyBatis 对接 MySQL。表结构很简单:
CREATE TABLE `t_order` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `order_no` VARCHAR(32) NOT NULL UNIQUE, `status` VARCHAR(16) NOT NULL, `amount` DECIMAL(10,2) NOT NULL, `create_time` DATETIME NOT NULL, KEY `idx_order_no` (`order_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;order_no上建了唯一索引,因为查询都是按订单号来的,索引能显著提升查询速度。Mapper 接口:
@Mapper public interface OrderMapper { @Select("SELECT order_no, status, amount, create_time " + "FROM t_order WHERE order_no = #{orderNo}") OrderDetail selectByOrderNo(@Param("orderNo") String orderNo); }这里用注解式 SQL 是为了演示简洁,实际项目里复杂查询还是建议用 XML。要注意字段名和 POJO 属性的映射,order_no到orderNo需要开启驼峰映射:
mybatis: configuration: map-underscore-to-camel-case: true注意:工具方法里查数据库一定要考虑异常情况。订单不存在时返回 null,模型拿到 null 可能会说"查询失败",体验不好。更好的做法是返回一个明确的"未找到"结构,或者在工具描述里说明"订单不存在时返回空",让模型知道怎么处理。
4.4 参数校验与容错处理
模型生成的参数不一定靠谱,必须做校验。我在工具方法入口加了防御:
@Tool(description = "根据订单号查询订单详情...") public OrderDetail queryOrder( @ToolParam(description = "订单号,例如 ORD202401010001") String orderNo) { if (orderNo == null || !orderNo.matches("^ORD\\d{12}$")) { return OrderDetail.notFound(orderNo); } OrderDetail detail = orderService.getByOrderNo(orderNo); return detail != null ? detail : OrderDetail.notFound(orderNo); }正则校验订单号格式,不符合的直接返回"未找到",避免拿脏参数去查库。这种防御在 Function Calling 场景下特别重要,因为参数是模型生成的,不是用户直接输入的,你没法在前端拦。
另一个容错点是超时。工具方法如果调用了外部服务,一定要设超时,否则模型那边一直等,整个对话就卡死了。我一般给工具方法设 3 到 5 秒超时,超时后返回一个"服务繁忙"的结果,让模型告诉用户稍后再试。
5. 常见问题与排查技巧实录
5.1 模型不调用工具怎么办
这是最高频的问题。用户明明问的是订单,模型却直接瞎编一个回答,根本不调工具。排查思路按优先级来:
第一,检查工具描述。九成的情况是描述写得太模糊。把描述改成"动词 + 对象 + 返回内容"的格式,比如"查询订单详情,返回状态、金额、时间"。描述里最好带上用户可能用的关键词,用户说"包裹",描述里就提一句"订单(包裹)"。
第二,检查系统提示词。如果系统提示词里写了"你只能回答电商相关问题",而用户问的稍微偏一点,模型可能就不调工具了。系统提示词要引导模型"遇到需要真实数据的问题优先调用工具"。
第三,检查模型能力。不是所有模型都擅长 Function Calling。有些小模型对工具调用的支持很弱,换个能力强的模型试试。这个用排除法很快能定位。
第四,看日志。Spring AI 会打印工具调用的请求和响应,把日志级别调到 DEBUG,能看到模型到底有没有发起工具调用请求。如果发了但没执行,那是注册问题;如果压根没发,那是描述或模型问题。
5.2 参数反序列化失败
报错通常是Cannot deserialize value of type ...。原因一般是模型生成的参数类型和你的方法签名对不上。比如你方法参数是Integer,模型生成了"123"字符串。
解决办法有两个:一是把参数类型放宽,用 String 接收再自己转换;二是在@ToolParam描述里明确类型,比如"数量,整数类型"。我倾向于第一种,更稳。因为模型生成参数时对类型的把控没那么精确,用 String 接收再校验转换,容错性更好。
还有一种情况是参数名对不上。模型生成的 JSON key 和你的参数名不一致,比如你叫orderNo,模型生成了order_no。这时候在@ToolParam里明确参数名,或者在描述里强调命名格式。
5.3 多轮对话中工具调用丢失
带上下文的多轮对话里,有时候第二轮模型就"忘了"有工具可用。这通常是 ChatMemory 配置的问题——历史消息里包含了工具调用记录,但格式不对,导致模型困惑。
排查方法是把完整的对话历史打出来看。如果发现工具调用的消息没有被正确保存,检查MessageChatMemoryAdvisor的配置。另外,历史消息太长也会稀释工具描述的影响力,必要时做历史截断,只保留最近几轮。
5.4 常见问题速查表
| 现象 | 可能原因 | 排查方向 | 解决方式 |
|---|---|---|---|
| 模型不调工具 | 描述模糊 | 看工具描述 | 补充动词、对象、返回内容 |
| 参数反序列化失败 | 类型不匹配 | 看报错类型 | 放宽参数类型,加校验 |
| 选错工具 | 描述区分度低 | 对比工具描述 | 明确各工具边界 |
| 多轮后工具失效 | 记忆配置问题 | 打印对话历史 | 检查 Memory 配置,截断历史 |
| 响应特别慢 | 工具超时 | 看工具耗时 | 加超时,异步化 |
| 结果不准确 | 返回值结构差 | 看返回 POJO | 结构化返回,字段语义清晰 |
5.5 几个我踩过的坑
第一个坑是工具方法用了@Transactional。工具执行和模型调用不在一个事务里,给工具方法加事务注解没意义,反而可能因为事务边界问题导致连接不释放。工具方法就老老实实做查询,别掺事务。
第二个坑是返回值里带了LocalDateTime,序列化时格式不对,模型读不懂。后来统一格式化成字符串再返回,问题解决。涉及时间、金额这类格式敏感的数据,返回前都做一次格式化。
第三个坑是工具太多导致模型"选择困难"。我一度注册了十几个工具,结果模型经常选错。后来按业务域拆成几个 ChatClient,每个只注册相关工具,准确率立刻回升。工具不是越多越好,聚焦才是王道。
6. 性能优化与生产落地建议
6.1 减少不必要的模型调用
前面说过,一次带工具的对话至少两次模型调用。如果工具内部还要再调模型(比如做二次总结),成本会翻倍。优化思路是:能在工具里用代码算出来的,就别再让模型算。模型只负责"理解和表达",计算和查询交给代码。
另外,简单问题可以走"快速通道"——先用规则或关键词判断,如果明显不需要工具,直接走普通对话,省掉工具识别那一次调用。这个优化在高并发场景下能省不少成本。
6.2 工具执行的异步化与缓存
工具方法如果查的是变化不频繁的数据(比如商品信息、配置项),加一层缓存能显著降低数据库压力。我用 Spring Cache 给查询方法加了缓存,命中率挺高。
对于耗时较长的工具,可以考虑异步执行,但要注意 Function Calling 的链路是同步等待的,异步化需要配合超时和回调,复杂度会上升。我的建议是:先保证功能正确,性能问题等有真实压力了再优化,别过早设计。
6.3 监控与日志
生产环境必须能观测到工具调用情况。我记录了几个关键指标:工具调用次数、成功率、平均耗时、模型调用 token 消耗。这些数据能帮你快速定位问题——比如某个工具成功率突然下降,可能是下游服务挂了;token 消耗暴涨,可能是工具描述太长或者对话历史没截断。
日志方面,把每次工具调用的入参和出参都记下来(注意脱敏),出问题时能完整复现。Spring AI 本身有日志,但建议在工具层再加一层业务日志,记录业务语义的信息。
6.4 安全边界
Function Calling 本质是让模型触发你的后端逻辑,安全边界必须划清楚。几条硬规矩:工具方法只做查询和幂等操作,绝不做删除、扣款这类不可逆操作;参数必须校验,不能直接拼 SQL;返回值脱敏,不泄露隐私;工具描述里不暴露内部实现细节,比如表名、字段名。
如果确实需要模型触发写操作,一定要加二次确认机制——模型发起请求后,先返回给用户确认,用户确认了再执行。别让模型直接改数据,这个风险太大。
7. 后续可以怎么扩展
跑通基础链路后,这个项目还有不少可以深挖的方向。比如把工具调用和 RAG 结合——先用向量检索找到相关文档,再让模型基于文档调用工具,适合知识库问答场景。再比如做工具的动态注册,根据用户权限决定哪些工具可见,实现细粒度的权限控制。
还有一个我觉得很有意思的方向:把工具调用链路可视化。每次对话把"用户问题 → 模型识别 → 工具调用 → 结果回灌 → 最终回答"整个链路画出来,方便调试也方便给非技术人员解释。这个用简单的日志加前端展示就能做。
我个人在实际操作中的体会是,Function Calling 的难点从来不在代码本身,代码量其实很少,难的是"让模型稳定地按你的预期工作"。工具描述怎么写、参数怎么设计、异常怎么兜底,这些才是真正花时间的地方。多测、多看日志、多调整描述,跑个几十次对话,你就能摸到门道了。