Spring AI Function Calling实战:让大模型学会调用Java函数

发布时间:2026/10/7 4:32:43
Spring AI Function Calling实战:让大模型学会调用Java函数 刚开始接触大模型应用开发时我做过一个很挫败的智能客服用户问我的订单到哪了它永远回答抱歉我无法实时查询订单进度。后来我才意识到问题不在模型而在我的设计——我让它当一个上知天文、下知地理的答题机器却没给它任何对外求助的通道。Function Calling 就是打通这一环的关键技术模型在需要时可以调用你预先定义好的函数查订单、问天气、算价格都行函数执行完把结果交回模型再由模型加工成用户能听懂的自然语言。而在 Java 生态里Spring AI 把这件事做成了近乎声明式的开发体验定义 Bean、描述用途、挂到 ChatClient 上剩下交给框架。这篇文章的目标读者有两类一是刚接触 Spring AI、对 Function Calling 还停留在概念的 Java 开发者二是已经在接聊天接口、但被模型一本正经编数据折磨过的后端工程师。我会从底层原理讲到可复制的代码再讲到我实际踩过的坑和完整的排查思路全程基于 Spring AI 1.0 正式版。1. 为什么模型总爱编先看懂 Function Calling 诞生的原因1.1 大模型的知识定格训练完成那一刻世界就静止了大模型本质上是一个参数化的概率模型。它并不是实时连接着一个数据库而是把训练阶段见过的文本规律压缩进了参数里。训练完成那一刻它对外部世界的认知就被定格了。你问它今天是几号、当前股价多少、我的订单到哪了它内部根本没有这些动态信息只能靠语言习惯去补全一个听起来合理的答案。这就是幻觉的根源。我刚开始用对话 API 的时候做过一个实验问 GPT 模型深圳现在适合穿短袖吗没有工具辅助的情况下它回了一段很顺滑的天气预报式应答——预计多云局部有阵雨体感闷热建议携带雨具。句子通顺语气正确但它并没有查询任何气象数据。它只是在模仿天气回答应该长什么样。这种效果在闲聊里也许能蒙混过关放到业务系统里就是明显的事故。1.2 Function Calling 的三段式协作模型只做决策执行权在你手里Function Calling 的全流程可以拆成四步用户把自然语言请求发给模型。模型判断这个问题我手头的数据不够需要调用某个函数然后输出一段结构化的调用请求——函数名加上 JSON 参数。注意模型内部并没有执行任何代码它只是做了一个调用决策。你的应用侧拿到这份请求真正去执行对应的函数比如查数据库、调外部 API、做金额计算。执行结果作为一条新消息回填给模型模型基于真实结果组织最终的自然语言回答。我用一个生活化的类比领导问项目经理这个项目下周能上线吗项目经理手头没有排期表但他马上给研发组长发消息问进度拿到回复后再转述给领导。大模型就是那个项目经理Function Calling 就是它发给研发组长的那条消息。模型不负责执行只负责知道该问谁、问什么以及拿到答案后怎么汇报。1.3 Function Calling 与 RAG 的区别别把两个问题混在一起很多人会把 Function Calling 和 RAG检索增强生成弄混。我的判断标准很简单RAG 解决的是知识不在模型参数里的问题把文档切块、向量化、检索后再喂给模型Function Calling 解决的是需要实时数据或主动执行动作的问题。订单查询、天气查询、库存扣减、表单提交这些是 Function Calling 的典型场景规章制度问答、产品手册查询、历史文档总结这些适合 RAG。两者可以组合使用但定位完全不同如果你的需求是查实时订单状态上 RAG 只会得到一份更厚的编造材料。1.4 为什么 Java 后端选 Spring AI而不是另起炉灶作为 Java 后端团队接入大模型能力最顺的路就是 Spring AI。理由很现实公司核心业务都在 Spring Boot 里订单、库存、用户服务都是现成的 BeanFunction Calling 的函数可以直接注入这些 Service等于把旧系统能力以一种受控的方式暴露给模型。不需要为了一个聊天功能去引入 Python 服务再跨服务折腾接口。Spring AI 的上层抽象也做得不错开发者面向 ChatClient 写代码底层对接哪家模型厂商由配置决定换模型时业务代码基本不用动。2. 先跑通一个不带工具的基线Spring AI 1.0 环境搭建与核心 API2.1 依赖、版本与模型配置Spring AI 1.0.0 已经在 2025 年 5 月发布正式版可以直接从 Maven Central 拉取。我这里用的组合是 Spring Boot 3.4 JDK 17这是当前比较稳的搭配。新建一个 Spring Boot 工程pom.xml 里加入 BOM 和依赖parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version3.4.6/version /parent dependencyManagement dependencies dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-bom/artifactId version1.0.0/version typepom/type scopeimport/scope /dependency /dependencies /dependencyManagement dependencies dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-starter-model-openai/artifactId /dependency /dependencies配置文件里设置 API Key 和默认模型spring.ai.openai.api-key${OPENAI_API_KEY} spring.ai.openai.base-url${OPENAI_BASE_URL:https://api.openai.com} spring.ai.openai.chat.options.modelgpt-4o-mini spring.ai.openai.chat.options.temperature0.3这里有两个细节值得说。第一API Key 一定要走环境变量提交到仓库等于把账单交给别人。第二temperature我习惯调低到 0.3 左右。工具调用本质上是一个决策任务不是创意生成温度太高会让模型该调函数的时候不调不该调的时候乱调。2.2 认识 ChatClientSpring AI 的门面 APISpring AI 1.0 里最常用的入口是ChatClient它的设计灵感明显来自 Spring 家族的RestClient通过方法链把请求拼装好再一次性调用。最基础的玩法是这样import org.springframework.ai.chat.client.ChatClient; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; RestController public class ChatController { private final ChatClient chatClient; public ChatController(ChatClient.Builder builder) { this.chatClient builder.build(); } GetMapping(/chat) public String chat(RequestParam String message) { return chatClient.prompt() .user(message) .call() .content(); } }这个ChatClient.Builder是 Spring Boot 自动配置提供的直接注入就能用。对于刚从 Spring AI 早期版本迁移过来的同学可以这么理解旧版的AiClient更像一个发请求的工具新版的ChatClient更像一个完整的业务会话入口它对工具调用、多轮上下文、流式输出的支持都更自然。2.3 先记住哑巴模型的基线表现趁还没加任何工具先调用一次上面那个接口问一句北京天气怎么样。你大概率会得到类似抱歉我无法获取实时天气信息建议您查询天气预报应用的回答或者一段看似合理、实则没有数据支撑的泛泛而谈。这一步不是浪费时间而是给自己立一个基线这就是模型手里没有工具时的表现。后面我们接入 Function Calling 之后同样的问题会返回真实的天气数据两相对比你就知道函数调用到底改变了什么。在实际项目里我也建议你先把最简单的链路跑通再考虑加复杂工具这样后面出了问题也容易判断是模型配置的问题还是函数本身的问题。3. 让模型学会叫人帮忙第一个 Function Calling 完整实战3.1 定义一个值得被调用的函数现在我们来写第一个工具函数。我用的方式是在配置类里声明一个java.util.function.Function的 Bean并加上Description注解。这里先定义入参和出参的 record再实现函数体import org.springframework.ai.tool.annotation.Description; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import java.util.function.Function; Configuration public class WeatherTools { Bean Description(根据用户提供的城市名查询该城市当前天气返回温度、天气状况、风力等级和更新时间。当用户询问今天/明天/实时天气、气温、是否需要带伞时调用。) public FunctionWeatherRequest, WeatherResponse currentWeather() { return request - { // 演示代码这里替换成真实天气 API 调用 return new WeatherResponse( request.city(), 26, 多云转晴, 微风, 2025-06-12 15:30); }; } public record WeatherRequest(String city) { } public record WeatherResponse(String city, int temperature, String condition, String wind, String updatedAt) { } }这里要注意入参 record 的字段名很关键。Spring AI 会基于 record 的字段自动生成 JSON Schema然后把这个 Schema 发给模型模型看到用户说北京就会在请求里填入city: 北京。如果字段名起得像data、id这种没有语义的模型很可能不知道该填什么最终放弃调用这个工具。Description则是写给模型看的功能说明书。它不需要解释技术实现而是要说明清楚两件事这个函数是干什么的什么情况下应该调用。甚至可以在描述里带上不要调用的边界条件比如仅当用户明确询问实时天气时使用如果用户只是问季节温度建议不要调用此函数。3.2 挂载到 ChatClient 并完成一次真实对话函数定义好之后使用方式很直接。在prompt()链上调用.functions(currentWeather)这里的字符串就是currentWeather这个 Bean 的名字import org.springframework.ai.chat.client.ChatClient; public String askWeather(String question) { ChatClient chatClient ChatClient.builder(chatModel).build(); return chatClient.prompt() .system(你是生活助手回答简短自然不要重复用户的输入。) .user(question) .functions(currentWeather) .call() .content(); }拿北京今天天气怎么样去问模型的回答会变成类似这样北京今天多云转晴气温 26 度左右风力不大整体比较舒适适合户外活动。这个回答并不是模型算出来的而是它先发起了对currentWeather的调用应用侧在 Java 代码里执行了函数把{city:北京, temperature:26, condition:多云转晴, ...}这个结果回填给模型模型再组织成上面那句人话。3.3 模型内部发生了什么一次看不见的两次往返很多人第一次跑通之后会觉得有点懵代码里根本没有出现调用函数的代码怎么模型就用上了其实在ChatClient.call()的内部模型和大模型 API 之间可能发生了两轮通信第一轮用户问题发给模型模型返回一个结构化请求内容大约是我要调用 currentWeather 函数参数是 {city: 北京}。应用侧收到这个请求找到名为currentWeather的工具执行里面的 Java 代码拿到 WeatherResponse 对象。第二轮把函数执行结果作为一条消息再次提交给模型模型根据这条真实数据生成最终回答。Spring AI 在底层帮我们完成了这两轮调度的封装所以你只写了一行.functions(currentWeather)但网络层面实际发生了两次模型请求。理解这一点对你后面排查模型一直调工具不结束的问题会非常有帮助。4. 升级实战一个订单助手让模型在多个函数里自己选4.1 三个函数同时挂载模型自动路由单函数的演示好写真实的业务项目里往往有几十个函数。Spring AI 的用法是一样的把多个函数全挂到.functions(...)上模型根据用户的问题和每个函数的描述来做路由选择。我做一个电商客服助手的例子。现有服务有OrderService和UserService我在配置类里把它们注入进来定义三个工具import org.springframework.ai.tool.annotation.Description; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import java.util.function.Function; Configuration public class OrderTools { private final OrderService orderService; private final UserService userService; public OrderTools(OrderService orderService, UserService userService) { this.orderService orderService; this.userService userService; } Bean Description(根据订单号查询订单当前状态包括待支付/已支付/已发货/已完成。当用户询问订单进度、发货状态、物流信息时调用。) public FunctionOrderQuery, OrderStatus queryOrder() { return query - orderService.queryStatus(query.orderId()); } Bean Description(根据会员ID查询用户当前积分余额。当用户问我有多少积分或积分能干什么时调用。) public FunctionUserPointQuery, UserPoint queryPoints() { return query - userService.queryPoint(query.memberId()); } public record OrderQuery(String orderId) {} public record OrderStatus(String orderId, String status, String logisticsCompany, String trackingNo) {} public record UserPointQuery(String memberId) {} public record UserPoint(String memberId, int points, String validUntil) {} }调用时同时挂载两个函数String answer chatClient.prompt() .user(帮我看看订单 20250611001 发货了没有) .functions(queryOrder, queryPoints) .call() .content();模型拿到订单 20250611001 发货了没有这句话会对比两个函数的描述queryPoints明显不相关queryOrder的描述与问题完全吻合于是它就会发起{orderId: 20250611001}的调用。这就是所谓让模型自己选函数的实现方式本质上是靠描述文本的语义匹配来路由。4.2 函数内部错误处理永远不要向模型抛异常这个坑我踩过跟各位说结论函数实现里不要随便抛异常一旦异常被 Spring AI 捕获反馈给模型的信息往往是一段技术化的错误堆栈模型并不擅长把NullPointerException at line 38翻译成亲没查到这笔订单。更可控的做法是——函数内部自己捕获业务异常返回一个结构化的、可被模型直接转述的结果。我建议定义一个通用包装类型public record ResultT(int code, T data, String message) { public static T ResultT ok(T data) { return new Result(0, data, success); } public static T ResultT fail(String message) { return new Result(1, null, message); } }订单查询函数改成这样public FunctionOrderQuery, ResultOrderStatus queryOrderSafe() { return query - { try { OrderStatus status orderService.queryStatus(query.orderId()); return Result.ok(status); } catch (OrderNotFoundException e) { return Result.fail(未查询到订单 query.orderId() 请核对订单号后重试); } }; }这样模型拿到的是一个干净的 JSON{code:1, data:null, message:未查询到订单 20250611001请核对订单号后重试}它就能很自然地组织出安抚用户的话。记住一个原则函数返回的是给模型转述的素材不是给开发者看的调试信息。4.3 多轮对话的上下文衔接别让你的助手失忆工具调用接入后多轮对话是下一个必踩的点。用户先问查询订单 20250611001模型调函数返回了结果然后用户紧接着问那这个订单什么时候发货如果你每次只把当前这句话发给模型模型并不知道这个订单指的是哪一个要么会再次调用函数但参数为空要么直接瞎编。我的处理方式有两种。简单一点的是自己在内存里维护最近几轮消息把历史消息拼进prompt().messages(...)。更省心的是使用 Spring AI 提供的ChatMemory与MessageChatMemoryAdvisor让框架自动携带历史。这两种方案的核心都是同一个道理工具调用的中间状态只存在于那一轮会话里跨轮次的引用需要靠历史消息来重建否则函数参数就是无源之水。5. 踩坑实录模型不调用、乱调用、参数错乱我的完整排查链路5.1 模型完全不理你的函数从日志到配置的四步排查我遇到最多的问题就是函数写好了也挂载了但模型死活不调用回答还是老一套。这时候不要慌按照下面的顺序排查。排查点检查内容修复方向函数是否真正挂载functions(currentWeather)里的名字是否拼错、Bean 是否存在确认 Bean 名字和字符串严格一致描述是否清晰可触发Description是否只说获取天气没说何时调用补上当用户询问天气时调用这类触发条件模型是否支持工具调用某些精简版模型接口不带 tools 参数换成 gpt-4o-mini 及以上支持工具调用的模型System 提示词是否冲突system 里写了你不要调用任何工具移除冲突指令或调整 system 表达优先看最后一个但最容易忽略。我第一次排查时就在 system 提示词里写了你是一个纯文本助手结果模型真的把这个当成了一级禁令工具全部失效。那种感觉就像是函数写得再好门口站了个保安不让进。5.2 参数歧义与类型错乱字段名、描述与示例值第二个高频问题是模型倒是调了函数但传的参数不对。比如订单号明明是ORD-202506-001这种带前缀的格式用户只说查一下 202506 的订单模型就把202506直接传给了orderId字段导致查不到。这类问题的根因在于模型拿到的 JSON Schema 里只有字段名和类型并不知道订单号的正确格式是什么。我的解法是在Description里明确写清楚参数格式同时给字段加上更细粒度的注解。Spring AI 支持在 record 字段上使用JsonSchema注解补充描述比如public record OrderQuery( JsonSchema(description 订单号格式为 ORD-202506-XXX, example ORD-202506-001) String orderId) { }这样的字段描述会随 JSON Schema 一起发给模型模型传给参数前就有了格式参照。这个方法对日期格式、金额精度、枚举取值都有奇效强烈建议在复杂参数上全面使用。5.3 工具循环与调用上瘾给模型设好护栏Function Calling 还有一个很折磨人的问题模型会连续发起多次函数调用。有时候是因为第一次的执行结果不满足它的预期它想再试试有时候单纯是模型调用上瘾了用户问一句怎么下单它就直接调起了创建订单函数。针对调用上瘾我的经验是写操作和读操作不要混在同一个 Assistant 会话里。下单、退款、删数据这类写操作要么单独一个 ChatClient 实例要么在函数描述里明确边界仅当用户明确表达下单意图并给出收货地址时调用。针对无限循环的问题可以在应用层设置一个最大工具调用次数比如 Spring AI 的ToolCallingChatOptions里限制或者在自己的封装里数着超过 5 轮就强制终止返回兜底话术这个问题我需要人工介入处理。5.4 调试利器打开工具调用日志工具调用的链路在代码层面是透明的但网络层面是两次模型请求。想看穿内部发生了什么最直接的办法是打开调试日志logging.level.org.springframework.ai.chat.clientDEBUG logging.level.org.springframework.ai.model.toolDEBUG logging.level.org.springframework.aiDEBUG开启后你会在日志里看到模型返回的原始响应其中会包含tool_calls字段里面列出了函数名、参数 JSON以及函数执行完成后的tool消息回填情况。这相当于把模型内部的决策过程脱敏展示了一遍它到底选没选你的函数、传了什么参数、结果回填后它如何组织回答全部一目了然。我在排查问题时几乎每次都靠这组日志定位。有些问题光看最终输出根本猜不出来比如模型调用了天气函数但忘了把结果用上这时候只有日志能告诉你真相。6. 让 Function Calling 在真实项目里站得稳安全、性能与可观测性6.1 函数白名单不要把所有 Bean 暴露给模型Spring AI 的自动注册工具很便利但危险性也在这里。如果你的容器里有很多Function类型的 Bean注意它们并不会都被自动导出只有你通过.functions(...)显式指定的才会挂到模型上。这个默认不暴露的设计其实是安全边界。我在生产环境里的习惯是做一个白名单配置把允许模型调用的函数名列一个清单代码里遍历清单动态挂载。这样即使有人新加了一个危险的写操作 Bean只要不进白名单模型就碰不到。对写操作类函数还要额外做人工确认比如下单函数执行前生成确认链接而不是让模型一步操作到底。6.2 返回值精简 结果缓存为 token 成本考虑函数调用的成本不只在执行本身还在于函数返回值要作为上下文再发给模型一次。如果函数返回一个 30 个字段的大对象模型和你都要为这些 token 买单而模型真正需要的可能只是其中三五个字段。我的做法是为工具调用单独设计精简的返回视图字段名短、值短、只包含最终回答需要的信息。比如查询用户信息模型最终只需要姓名、等级、最近积分那就不要返回邮箱、地址、注册来源这些无关字段。另外天气、价格这类实时性要求不高的数据可以加一层短 TTL 缓存不要让同一个函数在会话里重复执行。6.3 事务、异步与链路埋点工具函数如果直接注入 Spring Service要注意事务边界。ChatClient的调用链可能在任意一个工作线程里执行函数内的Transactional能不能生效取决于你的切入点设置。我遇到过一次函数里更新了数据库但外层捕获异常后事务回滚了模型还拿着旧数据回答用户两边对不上。从那以后事务操作我都单独封装一个方法明确REQUIRES_NEW传播级别。异步也是同理。如果函数里要做远程调用不要直接阻塞模型调用线程太久考虑塞进线程池。但要注意函数执行完后的回填消息仍然要回到模型调用线程所以异步只能优化函数内部的耗时不能把整个调用链变成异步结束。最后给每个函数调用加一个埋点日志记录函数名、入参、耗时和结果码这对线上排查和成本统计都很有价值。6.4 多模型切换的一个提醒Spring AI 向上抽象了统一的工具调用接口但不同模型厂商的底层实现仍然有差异。OpenAI 走的是tools协议Anthropic 走的是tool_use协议国产模型的工具调用在 JSON Schema 细节上也各有各的脾气。Spring AI 已经把大部分差异消化在了框架层但你在切换模型供应商时还是要重点回归测试两件事复杂参数的 schema 是否被正确识别以及模型是否会在某些边界场景下疯狂调工具。版本升级慢一点、切换前做一轮工具专项回归比什么配置都靠谱。我自己做 Function Calling 项目时最后沉淀下来一套固定习惯Description一定写两段一段说功能一段说触发场景函数返回值统一走Result包装日志里的工具调用链路必须长期开着。这套习惯不能说零事故但它让绝大多数模型不正常的问题都能在五分钟内定位到根因。如果你正准备给自己的 Spring Boot 应用接上 Function Calling不妨就先从今天这个天气助手开始把链路跑通再去接真实的业务 Service。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询