ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Java接入大模型:Spring AI Function Calling实战

Java接入大模型:Spring AI Function Calling实战 先说结论Function Calling不是一个新概念但从“知道”到“真正在自己的Java项目里跑通”中间隔着一整条链路。我最初看官方文档时觉得很简单无非是定义几个工具方法再注册进对话上下文结果一上手就踩了工具描述不生效、JSON Schema解析失败、模型就是不肯调用函数这些坑。这篇文章就是我完整趟过一遍之后的实战笔记从Spring AI的设计逻辑到可落地的代码、从参数绑定到排查思路全部展开讲。这篇内容不是抄文档而是按我实际调试的顺序来写的。适合两类人一类是Java后端想接入大模型但不想自己写HTTP调用封装的同学另一类是已经在用Python生态比如Dify、LangChain做Agent想切到Java/Spring AI上的人。原则上你在Dify里编排过“大模型节点工具节点”那你已经懂Function Calling的业务逻辑了剩下只差Spring AI的API表达方式。这篇文章会把这个翻译过程写明白。1. Function Calling解决的不是“能”的问题而是“准”的问题1.1 让模型“调用”而不是“背诵”大模型的本质是概率预测文本它并不知道你的系统里有哪个订单、仓库里还有多少库存、物流走到哪一步。你问它“订单OD20250101到哪了”它如果直接回答大概率是编的。普通提示词也可以命令模型“调用某个接口之后回答”但模型没有执行力也没有你的数据库权限它只能靠猜。这就是为什么需要Function Calling让模型输出一个结构化的“调用意图”由你的代码真正执行然后把结果再交回给模型组织语言。你可以这样理解模型像一个前台它不掌握所有答案但它知道公司里有一个“王工”能处理订单问题。你问前台订单状态前台不瞎猜而是给你一张纸条上面写着“去找王工附上订单号”。你拿着纸条找到王工拿到真实状态再回来告诉前台前台基于这个真实信息给你一个完整答复。这里的关键点是“结构化”。如果让模型用自然语言说“请帮我查一下订单”你的代码还得做实体抽取、意图判断麻烦且不可控。Function Calling的协议把这一切压缩成固定的JSON结构函数名、参数。模型输出这段JSON你的程序按图索骥执行过程完全可控。这就是它比普通Prompt更“准”的核心原因。1.2 为什么选择Spring AI来做这件事如果你直接用OpenAI、DashScope这类大模型SDK当然也能手动实现Function Calling但你需要自己维护会话历史、函数定义列表、工具调用结果的回填逻辑。写一次可以写多了就会发现这些代码极度重复而且每家厂商的API格式略有差异换模型厂商就要重写一版。Spring AI做的事是像Spring JDBC封装数据库操作那样把“与大模型对话”这件事封装成一套适合Java生态的模板API。对Function Calling来说它负责了三个核心环节从Java方法自动推导出模型所需的JSON Schema在对话循环中自动调用对应的Spring Bean方法把执行结果按对话协议回传给模型。你需要关心的是“业务怎么实现”而不是“JSON协议怎么构造”。实际项目里我在Spring Boot 3.4上集成了Spring AI代码量比我之前用Python手搓的版本少了很多而且和事务、缓存、权限这些Java生态的东西无缝对接。另外Spring AI 2.0开始提供了Tool注解可以在任意Spring Bean方法上直接标记为可调用工具这对Java开发者来说很自然——你写一个普通的Service方法加一个注解它就变成了模型能使用的函数。2. Spring AI里Function Calling的设计逻辑2.1 一条完整的Tool Calling调用链我先给你画一条逻辑链这样后面写代码时你就知道自己处于哪个环节。平时我们用ChatClient提问就像发起一次普通的网络请求。一旦你在ChatClient里注册了toolsSpring AI就会把当前可用的函数定义函数名、描述、参数JSON Schema放进每次对话请求的tools字段。模型收到后如果判断需要调用工具返回“工具调用指令”而非普通回复Spring AI的ToolCallingManager捕获到这个指令根据函数名找到对应的Java方法并执行执行结果被包装成一条工具消息加入到对话历史中Spring AI再次调用模型模型根据工具结果生成最终答案。这个循环可能会发生多次。比如你问“帮我取消订单”模型先调用查订单工具确认状态发现可以取消再调用取消订单工具然后汇总返回。Spring AI内部会循环处理直到模型不再返回工具调用指令。官方把这个能力叫ToolCallingManager的自动循环你不需要自己写while循环。链路里的每一步都有对应的扩展点。想拦截工具调用前后可以实现ToolCallbacks的包装或使用Advisor想看到中间过程把日志级别调低即可。实际诊断问题的时候看懂链路往往会事半功倍。2.2 Tool注解和ToolCallback接口Spring AI 1.x时代实现一个工具需要手动写ToolCallback实现类重写getDescription()、getInputType()然后写一个call(String input)方法。这种写法很灵活但样板代码多参数反序列化也要自己处理。Spring AI 2.0引入了Tool注解直接标记在Spring Bean的普通方法上框架通过方法名、参数类型、注解里的描述信息自动生成方法描述和JSON Schema。这确实舒服太多了代码可读性也强适合团队维护。Tool注解的核心属性有name、description。我的建议是name要稳定不要随重构随意变化因为对话历史里可能残留旧的函数名description要写清楚“什么时候调用”以及“不要什么时候调用”比如“当用户询问订单物流状态时调用仅限已发货订单”模型会更准确理解触发条件参数上可以用ToolParam(description ...)补充每个参数的含义对模型理解很有帮助。工具方法必须定义在Spring容器管理的Bean里因为框架要通过ApplicationContext找到它。如果你写在一个普通new出来的对象上运行时会报找不到对应的函数。2.3 Spring AI Alibaba停更了吗版本怎么选热词里有朋友问“spring ai alibaba停更了吗”。我可以负责任地说从当前的发布记录和维护节奏看它并没有停更。Spring AI Alibaba是阿里巴巴开源的Spring AI适配层把手百炼/DashScope这些国内模型厂商的API封装成了Spring AI的接口标准。它本质上是Spring AI官方生态的一个补充专门解决“国内直连更顺畅、模型厂商API差异大”的问题。版本选择上如果你要用DashScope的qwen系列模型我更推荐直接用spring-ai-alibaba-starter。它的好处是内置了百炼的OpenAI兼容端点配置不用自己拼base-url对qwen系列模型的Function Calling兼容性做了适配和官方Spring AI 2.0.x的API对齐ChatClient、Tool这些都能直接用。如果你做的是国际化业务或主要面向OpenAI、Anthropic这类模型直接用官方spring-ai-starter-model-openai更省心。两条路线底层都兼容不用太纠结。我个人的做法是项目里有国内业务和海外业务两套环境代码层抽象好模型接口切换成本并不高。3. 完整实战从零搭一个可用的Function Calling模块3.1 环境准备和依赖选择我用的是Spring Boot 3.4.3 Spring AI 2.0.1JDK 17。如果你看下面的代码版本号不必完全一致但依赖结构和配置方式大体相同。先加依赖。这里以接百炼DashScope的qwen系列模型为例用Spring AI Alibaba作为接入层dependencyManagement dependencies dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-bom/artifactId version2.0.1/version typepom/type scopeimport/scope /dependency dependency groupIdcom.alibaba.cloud.ai/groupId artifactIdspring-ai-alibaba-bom/artifactId version1.0.0.0/version typepom/type scopeimport/scope /dependency /dependencies /dependencyManagement注意BOM用import方式引入多个BOM可以共存Spring AI Alibaba的BOM会覆盖部分官方模型适配器的版本。业务模块里加dependency groupIdcom.alibaba.cloud.ai/groupId artifactIdspring-ai-alibaba-starter/artifactId /dependency再在application.yml里配置模型连接。用百炼的OpenAI兼容模式时配置如下spring: ai: dashscope: api-key: ${DASHSCOPE_API_KEY} chat: options: model: qwen3.7api-key一定要用环境变量的方式注入别写死在配置文件里。我见过不止一次把key推到Git仓库的案例那不是技术问题是安全事故。如果你已经用其他非阿里云模型的OpenAI兼容服务请把dashscope替换为openai并配置base-url。3.2 定义业务工具方法一个查订单的例子我以一个电商系统的场景来写用户询问订单状态、用户要求取消订单。这两个动作都是Function Calling的典型场景模型本身没有任何订单数据必须由你的系统提供查询和执行业务操作。先定义一个订单查询的工具Component public class OrderToolService { private final OrderRepository orderRepository; public OrderToolService(OrderRepository orderRepository) { this.orderRepository orderRepository; } Tool(name queryOrderStatus, description 根据订单号查询订单当前状态。当用户询问订单配送进度、是否发货、是否签收时调用。若订单号不存在则返回提示信息。) public String queryOrderStatus( ToolParam(description 用户提供的订单号形如OD20250101) String orderNo) { Order order orderRepository.findByOrderNo(orderNo); if (order null) { return 未找到订单号 orderNo 对应的订单; } return 订单状态 order.getStatus() 物流节点 order.getLatestLogisticsMessage() 更新时间为 order.getUpdateTime(); } Tool(name cancelOrder, description 取消指定订单。仅当用户明确表达取消订单的意图且订单处于待发货状态时调用。已发货订单需要走售后流程不要调用此方法。) public String cancelOrder( ToolParam(description 要取消的订单号形如OD20250101) String orderNo, ToolParam(description 用户填写的取消原因可以为空字符串) String reason) { Order order orderRepository.findByOrderNo(orderNo); if (order null) { return 订单不存在; } if (!待发货.equals(order.getStatus())) { return 当前订单状态为 order.getStatus() 不允许取消; } orderRepository.updateStatus(orderNo, 已取消); return 订单 orderNo 已成功取消; } }这里有几个值得说的点。第一返回值尽量是一个完整的、人话可读的字符串。模型会把返回值当作“工具的执行结果”直接放进对话上下文里你看得到的文本和模型看到的基本一致。你在字符串里塞一段JSON不是不行但模型还要二次解析费用和出错概率都上升。第二ToolParam的description会影响模型对参数的理解写“用户提供的订单号”比只写“订单号”要好模型更容易正确的从对话里抽取。第三既然注释了“已发货订单不要调用”模型就会更谨慎地判断。这听起来夸张但实测下来清晰的描述对调用准确率影响巨大。如果你的方法返回一个对象Spring AI会序列化成JSON字符串返回给模型。某些场景下合理但请确认序列化结果不会把大对象整个塞进上下文。后面我会单独讲这个问题。3.3 注册工具集并发起对话工具方法定义好之后需要注册到对话客户端。Spring AI的ChatClient构建器支持defaultTools()方法把工具Bean的名字传进去就行。下面这段代码演示了如何创建一个带工具能力的ChatClientService public class CustomerService { private final ChatClient chatClient; public CustomerService(ChatClient.Builder builder, OrderToolService orderToolService) { this.chatClient builder .defaultSystem(你是一个电商客服助手回答用户问题要简洁、专业、使用中文。) .defaultTools(orderToolService) .build(); } public String answer(String userMessage) { return chatClient.prompt() .user(userMessage) .call() .content(); } }注意我传的不是工具名字符串而是orderToolService这个实例。Spring AI会自动扫描这个Bean里的Tool方法注册为可调用工具。如果你偏好用名字也可以这样写.defaultTools(orderToolService)但前提是Bean的名字恰好是类名首字母小写。如果Bean名字和类名不一致用名字注册就会失败。传实例比较稳。调用测试String answer customerService.answer(我的订单OD20250101现在到哪了);运行之后模型会先走一次queryOrderStatus工具调用拿到订单状态后再组织成口语化的回答返回。你在日志里能看到全部过程Received ToolCallingMessage ... Calling function: queryOrderStatus with arguments: {orderNo:OD20250101} Tool result: 订单状态已发货物流节点杭州转运中心更新时间为...看到这四条日志就说明整条链路已经跑通了。如果直接在call().content()里拿到了回答说明工具调用成功最终回答是在工具结果基础上生成的。3.4 多轮对话中的工具调用状态管理实际线上场景不会是一问一答用户往往连续追问。比如先问“OD20250101到哪了”再问“那OD20250102呢”之后又说“帮我取消第二单吧”。这种多轮场景下Spring AI默认把每次prompt()调用作为独立对话不保留上一轮上下文。你需要自行管理会话历史。我通常的做法是不用Spring AI内置的会话存储而是自己把历史消息存在Redis里每次把历史一并传给ChatClientchatClient.prompt() .messages(historyMessages) // ListMessage .user(userMessage) .call() .content();historyMessages里会包含用户消息、助手消息以及工具消息。你可能会问工具调用的中间结果要不要存起来答案是要存。如果把工具消息去掉模型就只看到用户问题不知道上一轮查询的结果二次决策容易“失忆”。Spring AI在ToolCallingManager执行完自动循环后会生成完整的工具链消息对象你可以原样追加进历史记录。很多团队在这里会选择偷懒只存用户和助手文本。我踩过一次场景是用户先问库存再问“那刚才的库存还够吗”模型完全不知道“刚才”指什么回答质量瞬间下降。所以我的建议是中间工具结果必须入存储哪怕只保留最近几轮。3.5 关于Dify工作流迁移到Spring AI的思路热词里有朋友提到“dify工作流转成spring ai java代码github”我多说几句。Dify的工作流本质上也是“大模型节点 工具节点”的组合大模型节点收到用户输入判断是否需要调用工具工具节点执行HTTP请求或代码再把结果返回到大模型节点。这和Spring AI的Function Calling在逻辑上几乎一模一样。所以迁移的正确方式不是在GitHub找一个一键转换的项目而是把Dify里每个节点的功能映射成Spring AI的代码结构Dify里的“工具描述”对应Tool注解的descriptionDify里的“输入参数Schema”对应Java方法的参数和ToolParamDify里的“代码节点/HTTP节点”对应你Tool方法里的业务逻辑Dify里的大模型模型配置对应application.yml里的模型参数。如果你在Dify里调试过一个流程逻辑上搬到Spring AI并不难。真正的差异在于Java代码里需要处理事务、异常、返回值结构这些更底层的东西。这也是为什么我建议你先跑通一个最小Demo再逐步迁移业务工具。4. 我踩过的坑和排查心得4.1 描述写不好AI就是不用你的函数我之前写过一个工具方法description只有一句话“查询订单状态”。结果用户问“我的货发了没有”模型完全没走工具调用直接编了一句“您的订单已发货”。排查的时候我发现模型看到“查询订单状态”需要自己去判断“货发了没有”是否等价于“查询订单状态”这个推理链太长模型容易跳过。解决办法是扩大描述范围把触发条件写全“当用户询问订单是否已发货、物流走到哪里、什么时候送到时调用此工具查询订单真实状态”。像这样把同义表达都列进去模型几乎不会漏判。另外description不要写模型无法获知的信息。比如写“仅在VIP客户提问时调用”模型并不知道谁是VIP触发条件就不成立。条件必须基于模型能看到的上下文比如用户的原话、参数的内容。4.2 JSON Schema解析失败参数类型别太花哨Tool方法会基于Java方法签名生成JSON Schema。Spring AI内置支持的类型比较有限基本类型、String、数字、布尔、List、Map和简单的POJO。我遇到过两个问题第一LocalDate类型。框架默认生成的JSON Schema比较复杂模型生成的参数却往往给的是“2025-01-01”这样的字符串反序列化和类型映射经常出错。我的做法是统一用String接收在方法内部用LocalDate.parse()转换。第二枚举。如果你让模型直接给枚举字符串基本都还能匹配但如果还加了“如果参数值不在枚举中则返回错误”这种描述模型就开始犯难。我更推荐直接接收String在方法内校验。还有一个容易忽略的方法的参数使用了接口类型或泛型比如MapString, Object。F框架对这种类型生成Schema时只能退化成object模型不知道该填什么字段导致参数缺失。解决办法是定义一个具体参数的POJO类作为入参字段命名清晰再配ToolParam。4.3 工具返回结果太长上下文直接被塞爆有一次我把订单明细、商品列表、售后记录全塞进了一个工具方法的返回值结果模型生成的回复质量没变高我的Token消耗却翻了五倍响应时间也明显变长。这是一个非常常见的误区工具返回值不是越多越好模型不需要看所有中间数据她只需要生成用户回答所需的关键结论。我的原则是工具方法返回的是一个“摘要级”字符串只包含最终结论和必要数据。比如查订单状态只需要“当前状态物流节点时间”不需要把完整商品表、价格历史全部返回。如果业务上确实需要详细数据可以返回一个“查询成功共找到N条售后记录”的简要结论模型再根据用户的需要决定是否追问而不是一次性全倒出来。4.4 对方说“帮我取消订单”你确认了吗Function Calling最容易被误解的一点是模型“说”要调用函数不等于函数被授权执行。整个流程里模型只是生成了一串JSON真正去执行你代码的还是系统自己这里没有任何“用户授权”的语义。比如用户说“帮我取消订单”模型会乖乖返回调用cancelOrder的参数Spring AI也会立刻执行。在开发环境这没事到了生产环境取消订单这种操作对用户是敏感的。我现在的做法是将工具分为“查询类”和“操作类”。查询类工具直接执行操作类工具在ToolCallingManager拦截器里加人工确认或者返回“已为您生成取消请求正在等待确认”的文案然后走一遍用户确认流程再真正执行。这样既发挥了模型理解意图的能力又守住了业务安全的底线。关于拦截器Spring AI提供了ToolCallbacks包装的机制但简单场景不需要。你可以在Tool方法的实现里直接判断一个上下文标志位或者把参数设计成待确认状态都比一开始就引入复杂拦截框架更可控。4.5 日志怎么看排查问题时把下面这些包级别的日志打开你会看到完整的过程logging: level: org.springframework.ai: DEBUG com.alibaba.cloud.ai: DEBUG重点关注几个关键节点请求发出前打印的tools列表是否包含你定义的函数模型返回的内容里是否有toolCalls字段执行之后是否构造了工具消息。只要这三点对齐问题基本定位得很快。我最常遇到的情况是工具列表里有函数但模型不触发调用。这时候回去改描述而不是调代码。反之如果toolCalls出现了但执行报错多半是参数类型或方法反序列化的锅按上面说过的类型检查一遍即可。最后再分享一个调试技巧我习惯在项目里准备一组“工具调试Prompt集”用断言脚本批量测试每个Tool方法是否被正确触发。比如针对queryOrderStatus我会输入“我的订单什么时候到”“发货了吗”“麻烦查下OD20250101物流”这类变体问题检查模型是否都走了工具调用。批量跑完扫一眼日志你会发现有些描述在口语中根本覆盖不到然后就能快速改进。还有一个我个人觉得很实用的点先把模型的temperature调低调试工具调用的阶段不要加入太多创造力。工具调用是一个偏“确定性”的过程temperature0配合清晰的描述触发准确率会明显提高。等功能稳定的差不多了再按客服场景优化回答的语气这时再调高temperature也不迟。Function Calling在Spring AI里的门槛并不高难的是把“每个工具描述的边界、每个返回值的粒度、每个参数的格式”都调教好。把基础链路跑透把每一个坑都踩一遍后面再接复杂的Agent编排就顺手多了。
返回列表