Spring AI Function Calling实战:Java后端工具调用与多轮对话 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包路径也变过。如果你在网上搜到的是老教程代码大概率跑不起来。我目前实测比较稳的组合是组件版本说明JDK17 或 21Spring Boot 3.x 最低要求 17Spring Boot3.2.x 及以上3.2 对虚拟线程支持更好Spring AI1.0.x 稳定版API 已趋于稳定MySQL8.0.x用于演示真实数据查询构建工具Maven 3.9Gradle 也可以本文用 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.version17/java.version spring-ai.version1.0.0/spring-ai.version /properties dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-starter-model-openai/artifactId /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-jdbc/artifactId /dependency /dependencies dependencyManagement dependencies dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-bom/artifactId version${spring-ai.version}/version typepom/type scopeimport/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?useSSLfalseserverTimezoneAsia/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 queryOrderStatusarguments {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方法注册进去。如果你有多个工具类可以链式调用多次或者传多个对象。然后写一个 ControllerRestController 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 ListOrderInfo 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 groupIdorg.apache.maven.plugins/groupId artifactIdmaven-compiler-plugin/artifactId configuration parameterstrue/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 的价值不在于技术多复杂而在于它把“模型理解意图”和“程序执行逻辑”这两件事清晰地分开了。模型负责理解程序负责执行边界清楚各司其职。想清楚这个边界在哪里比会写几行注解重要得多。