Spring AI Function Calling实战:从注册机制到流式输出全流程拆解
发布时间:2026/10/27 15:36:57 锦皓数字建站

做AI应用的人应该都遇到过这个问题模型再聪明也说不出实时股价、查不到你自己的订单库、算不了你们公司的内部数据。你费劲用RAG检索增强生成把资料塞进上下文结果token烧得快不说稍微复杂点的逻辑模型还是只能给你一段“看起来很有道理”的废话。要真正让大模型变成一个能干活、能办事的引擎Function Calling函数调用/工具调用是绕不开的一环。这篇文章就从零开始用Spring AI完整地走一遍Function Calling的实战流程。不是只贴代码核心的注册机制、参数Schema生成、调用链路、流式输出、踩坑点都会拆开讲。适合刚接触Spring AI的Java后端同学也适合那些已经用了ChatClient但还没搞明白functions()和Tool到底怎么配合的人。看完全文你能搭出一个真正的“AI能调你接口”的完整demo并且知道出了问题该往哪个方向查。1. Function Calling到底解决了什么问题1.1 大模型的“知识天花板”和“行为即兴发挥”先想一个问题你直接问OpenAI的模型“我这个月的销售漏斗转化是多少”它凭什么能答出来它既没连你的数据库也没跑过你们的报表。训练数据截止之前的事情它能聊截止之后的和私有领域的数据它一行都不知道。这是模型的“知识天花板”。那RAG不是能解决吗能解决一半。你把文档切片、embedding、检索然后把相关内容塞进prompt模型确实能“记住”这些资料。但RAG的本质是把信息喂进上下文而不是让模型去执行操作。你想让它来一句“好的我帮你查一下订单状态”它嘴上这么说实际上什么都没查直接顺着你的话编了一段结果。这就是“行为即兴发挥”——模型在幻觉而且非常理直气壮。Function Calling就是给模型装了一双手。它知道自己不掌握实时数据但它知道“我手上有个工具调用这个工具能拿到答案”。当用户的问题落在某个工具的覆盖范围内模型会先调用工具拿到真实结果再基于这个结果组织回答。这一步改变的不只是回答质量是模型的工作方式。1.2 一句话版的概念说明Function Calling的流程可以浓缩成五个环节你定义好一个函数比如queryOrder(String orderId)并把它注册到AI客户端上。用户提问系统把这个问题的上下文连同可用函数的“说明书”一起发给模型。模型判断该调用哪个函数、传什么参数返回一个结构化调用请求而不是直接回答。你的程序执行这个函数拿到真实数据。把函数返回结果回传给模型模型基于真实结果生成最终回答。这里面最微妙的一点是函数执行是在你的代码里完成的模型只是“发号施令”。也就是说函数里能连数据库、能调第三方API、能算你要的任何逻辑模型只负责决定“什么时候该调用传什么参数”。这就是安全边界和灵活性的交点。在Spring AI里这套流程被封装得很干净。你不需要手动解析模型返回的工具调用JSON也不需要自己拼接第二轮的请求。定义好函数、注册到ChatClient剩下的交给框架。1.3 什么样的场景才真正需要它我见过不少团队上来就把所有业务逻辑塞给模型处理这其实不太对。Function Calling最适合这四类需求实时数据查询天气、股价、航班状态、库存余量这类数据模型不可能知道。私域业务操作查自己的订单、创建工单、改配置RAG根本覆盖不了这种结构化操作。计算与校验单位换算、优惠计算、表单校验模型的算术能力不稳定交给代码最稳妥。行动执行发邮件、推送消息、调第三方服务这类直接产生副作用的操作必须由代码控制。按这个标准判断你的需求到底该上RAG、该上Function Calling、还是干脆让模型瞎聊会很清晰。做AI应用最重要的事就是分清模型“能说”和“能做”的边界。2. Spring AI的工具调用机制拆解2.1 为什么选Spring AI而不是裸调SDK我最早做AI功能的时候直接用OpenAI SDK自己拼请求体、自己解析tool_calls字段、自己维护多轮对话的状态。能跑但非常啰嗦换一个模型厂商就要重写一遍。Spring AI的价值在于它把“大模型接入”做成了Spring风格ChatClient就是你的门面模型提供商通过starter自动配置进来切换模型只需要改配置和依赖业务代码基本不动。而且Spring AI对工具调用做了很好的抽象。不管是OpenAI的function calling还是Ollama的tool calling又或者是Anthropic的工具用法在Spring AI里统一成了FunctionCallback和Tool两套表达方式。你写一遍多种模型通用。这一点在多模型切换的项目里非常省心。需要提醒的是Spring AI的生命周期比较“刺激”。从0.8.x到1.0.0正式版API改了好几次。最明显的变化是ChatClient成为主入口Advisor的概念范围也在调整。如果你看网上的教程发现代码长得不一样大概率是版本差异。这篇文章以Spring AI 1.0.0及以上版本为准较早版本的写法我会在涉及的地方说明。2.2 核心概念ChatClient、FunctionCallback、Tool、ToolCallbacksSpring AI 1.0里工具调用主要围绕这几个东西转ChatClient你和大模型对话的入口也是你注册函数的入口。prompt()构建请求call()同步调用stream()流式调用。FunctionCallback工具函数的抽象接口。你可以用lambda实现也可以用FunctionCallbackWrapper包装一个已有方法。它描述了函数名、描述、输入类型Schema和具体执行逻辑。Tool方法级注解。加在某个Service的方法上Spring会自动扫描并生成对应的工具描述适合直接把现有业务方法暴露给模型。ToolCallbacks一个工具回调集合。你可以把多个函数打包成一个回调数组在构建ChatClient时通过.defaultToolCallbacks()注册。从底层看Spring AI启动时会把每个工具转成JSON Schema描述随请求发给模型。模型返回工具调用意图后Spring负责解析、执行、把结果回传。这部分我建议你用DEBUG日志看一次完整的请求响应体看过一次就理解整个机制了。后面我会专门讲怎么开日志。2.3 一个函数的“说明书”是怎么生成的模型并不知道你的Java方法内部是怎么实现的它只知道一份“说明书”。这份说明书就是一个JSON Schema包含函数名、作用描述、参数名、参数类型、是否必填、枚举值等。举个例子你定义这样一个函数public record WeatherRequest(String city, String unit) {}Spring AI在生成Schema时会根据你的record字段自动推断出参数结构大致长这样{ type: object, properties: { city: { type: string, description: 城市名称如北京 }, unit: { type: string, enum: [celsius, fahrenheit] } }, required: [city, unit] }模型拿到这份说明书后如果用户说“北京今天多少度”它会输出一个类似{name: currentWeather, arguments: {city: 北京, unit: celsius}}的结构。你的代码收到这个结构执行函数把结果放回对话模型再给出最终回答。这段链路里最容易出差错的是描述不清晰。city字段没有说清楚格式模型就可能把“北京市”和“北京”混传函数描述写得模糊模型就不知道该不该用这个工具。所以给每个函数、每个参数都写清楚说明不是文档洁癖是Function Calling能不能好用的决定性因素。3. 从0到1搭建一个可运行的项目3.1 项目初始化和依赖引入先用Spring Initializr创建一个空项目Java 17或21基础环境都可以。唯一需要重点引入的依赖是模型提供商对应的starter。以OpenAI为例dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-openai-spring-boot-starter/artifactId version1.0.0/version /dependency如果你用的是其他厂商替换成对应的starter即可比如spring-ai-ollama-spring-boot-starter、spring-ai-anthropic-spring-boot-starter。Spring AI的依赖管理一般在Spring每个版本的BOM里已经包含。建议直接用Initializr生成项目还能顺便引入Web支持方便后面提供HTTP接口测试。有个细节如果你在国内环境使用可能还需要配置镜像仓库。方法是在~/.m2/settings.xml或者项目的pom.xml中把spring-ai的仓库指向阿里云镜像或你公司的私有仓库。这一块不单独展开遇到底层依赖拉不下来的问题优先排查这一步。3.2 配置模型API信息在application.yml里配置你的API Key和模型名spring: ai: openai: api-key: ${OPENAI_API_KEY:your-key} base-url: ${OPENAI_BASE_URL:https://api.openai.com} chat: options: model: gpt-4o-mini注意base-url这个参数。如果你使用的是兼容OpenAI接口的中转服务或本地代理改这个地址就行。很多企业环境不允许直连外部API保留一个可配置的base-url是很实用的习惯。模型名我也建议配置成变量方便上线切换大模型。在application.yml里配好后Spring会自动创建一个ChatModelBean。你不需要手动new直接用即可。3.3 一个最小可用的Function Calling示例配置好了之后我们写一个最简单的示例。定义一个查询天气的“假实现”函数重点是跑通链路import java.util.function.Function; import org.springframework.ai.chat.client.ChatClient; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Description; import org.springframework.stereotype.Component; Component public class WeatherFunctionConfig { Bean Description(根据城市名称查询当前天气) // 这个描述会进入模型的函数说明书 public FunctionWeatherRequest, WeatherResponse currentWeather() { return request - new WeatherResponse( 26.5, request.city() 多云体感舒适 ); } public record WeatherRequest(String city, String unit) {} public record WeatherResponse(double temperature, String description) {} }然后在调用的地方通过ChatClient注册并使用它Service public class WeatherService { private final ChatClient chatClient; public WeatherService(ChatClient.Builder builder) { this.chatClient builder .defaultSystem(你是一个贴心的天气助手。回答要简洁用中文。) .defaultFunctions(currentWeather) // 注册函数名对应Bean名 .build(); } public String ask(String question) { return chatClient.prompt() .user(question) .call() .content(); } }跑起来试一下输入“北京今天多少度”正常的话模型会先调用currentWeather再给你一个准确的答案。注意一个关键点Bean方法名默认就是函数名。你要在defaultFunctions(currentWeather)里引用的就是这个Bean的名字。如果不喜欢方法名当函数名可以在FunctionCallbackWrapper里自定义名称。Description注解里的描述直接决定了模型是否会调用这个函数一定要写得具体、无歧义这个我在后面章节还会重点说。4. 三种主流的函数注册方式对比4.1 方式一Bean Function Description上面已经演示过这种写法。它的好处是声明式、集中管理适合把一组工具函数放在独立的配置类里。如果需要更精细的控制比如指定函数名、写更详细的参数描述用FunctionCallbackWrapper会更顺手Bean public FunctionCallback orderStatusFunction(OrderQueryService orderService) { return FunctionCallbackWrapper.builder(new FunctionOrderQueryRequest, OrderQueryResponse() { Override public OrderQueryResponse apply(OrderQueryRequest request) { return orderService.queryStatus(request.orderId()); } }) .withName(queryOrderStatus) .withDescription(根据订单号查询订单当前状态) .withInputType(OrderQueryRequest.class) .build(); }FunctionCallbackWrapper能直接指定输入类型Schema的生成和控制都会更明确。如果你的函数逻辑比较复杂或者需要做参数清洗、异常处理优先选这个灵活度高很多。4.2 方式二Tool注解直接暴露Service方法Tool注解是我现在个人最常用的方式。它的好处是零配置直接在业务代码上加注解就能暴露给模型。Service public class OrderService { Tool(name queryOrderStatus, description 根据订单号查询订单当前状态支持格式ORD-2024-0001) public String queryOrderStatus(String orderId) { return switch (orderId) { case ORD-2024-0001 - 已发货预计明天送达; case ORD-2024-0002 - 待付款; default - 订单不存在请确认订单号是否正确; }; } }然后在ChatClient构建时注册对应的ToolCallbacksthis.chatClient ChatClient.builder(chatModel) .defaultToolCallbacks(ToolCallbacks.from(orderService)) .build();ToolCallbacks.from(orderService)会自动扫描OrderService里的所有Tool方法并生成对应的工具描述。需要注意的是Tool注解默认会把方法参数用作Schema如果参数是复杂对象最好确保有恰当的字段描述否则模型生成JSON参数容易出错。4.3 方式三在prompt上按需注册单个函数前两种方式注册的是“全局默认函数”。如果你希望某个对话单独使用某些工具可以在每次请求时指定String answer chatClient.prompt() .user(question) .functions(currentWeather, queryOrderStatus) .call() .content();这种方式特别适合动态工具场景。比如你有一个工具箱里有20个函数但某类请求只需要其中2个。按需注册不仅节省token还能显著提高模型的调用准确率——函数越多模型选错函数的概率就越大。我建议的原则是常用函数放defaultFunctions场景化函数按请求动态添加。三种方式没有绝对的高低之分可以混用。我的经验是复杂逻辑、需要包装的函数用FunctionCallbackWrapper业务Service方法直接用Tool涉及多变的工具组合时用第二种方式的ToolCallbacks注册一批然后在每个prompt里按需选定。5. 实战案例让AI帮你查订单并计算优惠5.1 场景设计基于上面这些基础我们来做一个比较完整的案例。假设你是一个电商平台的后端开发需要实现这样的智能客服能力用户发一句“帮我看看订单ORD-2024-0001有没有优惠可以用”AI要能够从订单号中识别业务意图。调用订单查询函数拿到订单当前金额和状态。调用优惠券计算函数算一下还能再减多少。综合结果用自然语言回答用户。这个例子同时用到了两个函数两种不同类型参数的处理以及一个相对完整的回传流程。5.2 定义两个工具函数第一个函数查订单详情Service public class OrderToolService { Tool(name queryOrder, description 根据订单号查询订单金额、商品名称和当前状态。订单号格式为 ORD- 开头) public OrderInfo queryOrder(OrderQueryRequest request) { return orderRepository.findByOrderId(request.orderId()); } public record OrderQueryRequest(String orderId) {} public record OrderInfo(String orderId, String productName, double amount, String status) {} }第二个函数计算优惠参数主动设计成结构化的Service public class CouponToolService { Tool(name calcCoupon, description 根据订单金额和用户会员等级计算最高可优惠金额返回优惠金额和优惠说明) public CouponResult calcCoupon(CouponRequest request) { double discount switch (request.memberLevel()) { case gold - request.amount() * 0.2; case silver - request.amount() * 0.1; default - 0; }; return new CouponResult(discount, 会员折扣 request.memberLevel()); } public record CouponRequest(double amount, String memberLevel) {} public record CouponResult(double discount, String desc) {} }这里有一个实战心得不要让模型去“拼凑”参数。比如计算优惠需要知道会员等级你可以在订单查询函数里直接返回memberLevel这样模型在调用calcCoupon时就有充足的上下文。函数返回结果本身就是模型的上下文函数之间通过返回结果传递数据是常见做法。5.3 组装到ChatClient并打通流程配置类和入口Configuration public class AiConfig { Bean public ChatClient chatClient(ChatClient.Builder builder, OrderToolService orderToolService, CouponToolService couponToolService) { return builder .defaultSystem( 你是电商客服助手。查询订单后如果用户是会员主动计算可用优惠。 回答需简洁先给结论再过细节。 ) .defaultToolCallbacks( ToolCallbacks.from(orderToolService), ToolCallbacks.from(couponToolService) ) .build(); } }然后在Controller里用一行代码调用RestController public class AssistantController { private final ChatClient chatClient; public AssistantController(ChatClient chatClient) { this.chatClient chatClient; } GetMapping(/chat) public String chat(RequestParam String question) { return chatClient.prompt() .user(question) .call() .content(); } }试一下访问/chat?question帮我看看订单ORD-2024-0001能优惠多少模型会依次调用两个函数最终返回一个类似这样的回答“您的订单ORD-2024-0001金额为200元当前为已付款状态。根据您的黄金会员等级最高可再优惠40元实际应付160元。”整个调用链路里模型没有编造任何数据都来自你的真实代码。你可以开启日志看看一个完整的请求体长什么样。在application.yml里配置logging: level: org.springframework.ai: DEBUG日志里能看到模型返回的ToolCall结构以及Spring自动执行函数、回传结果的整个过程。强烈建议你亲眼看一次这个日志对你理解底层机制非常有帮助。5.4 流式输出场景怎么办如果客服回答你想一个字一个字地蹦出来就用stream()代替call()GetMapping(value /chat/stream, produces text/plain;charsetutf-8) public FluxString chatStream(RequestParam String question) { return chatClient.prompt() .user(question) .stream() .content(); }注意流式和工具调用在部分模型上是兼容的但行为会有些微妙差别。OpenAI的流式工具调用通常分两段第一段是工具的调用请求第二段是最终回答。Spring AI已经处理好这个过程你不需要额外拼接。但在个别模型上如果发现流式结果丢失工具信息可以退一步用call()或者确认你接入的模型服务商是否完整支持stream_options参数。6. 踩坑实录六个高频问题与排查方向6.1 函数压根没被调用这是最常遇到的问题第一反应先怀疑函数描述。描述含糊、参数示例缺失、或者函数名和实际场景对不上模型就可能不调工具直接瞎答。排查顺序先看DEBUG日志里发给模型的请求中是否包含你的函数Schema确认注册成功再检查你的问题描述是否命中函数描述中的关键词最后检查是不是函数太多导致选择困难——临时去掉一半函数试试。6.2 参数绑定失败模型传的参数对不上比如你的queryOrder需要orderId模型传成了{orderId: ORD-2024-0001}这没问题但如果传成了{id: ..., order_no: ...}说明你的参数描述没写清楚。解决方案是在record字段上补充JsonPropertyDescription或使用Schema注解给每个字段写文档public record OrderQueryRequest( JsonPropertyDescription(订单号格式如ORD-2024-0001) String orderId ) {}字段描述到位参数准确性会有质的提升。6.3 函数执行出现异常处理策略函数内抛异常框架的处理方式各个版本不完全一致。稳妥的做法是在函数内部捕获所有异常并返回一个友好的错误信息让模型有机会把这个信息组织成回答。比如Tool(name queryOrder, description 根据订单号查询订单) public OrderInfo queryOrder(OrderQueryRequest request) { try { return orderRepository.findByOrderId(request.orderId()); } catch (Exception e) { return new OrderInfo(request.orderId(), , 0, 查询失败 e.getMessage()); } }你主动吞掉异常比让异常冒泡到调用链外用默认处理策略兜底可控性高得多。6.4 token消耗变高函数描述太大函数Schema也是要算token的。如果你的函数有几十个字段每次对话都会带上成本会肉眼可见地涨。解决思路是精简字段不需要暴露给模型的字段不要让它在请求里出现按场景拆分函数把大而全的工具拆成小而专的工具默认函数只保留高频的低频函数按需传入。6.5 与RAG的冲突和协作RAG和Function Calling不是非此即彼的关系。RAG适合给模型喂知识Function Calling适合让模型动代码。我的习惯是知识问答类走RAG数据操作类走Function Calling。如果用户问的是“订单状态”就不需要先把一堆文档塞进上下文直接调函数最快最准。6.6 多模型切换时行为不一致OpenAI和Ollama都对工具调用有支持但支持的细节和风格不完全一样特别是流式场景和参数枚举这种复杂类型上。在项目早期就建立一个抽象层把ChatClient的配置和工具注册集中到config包换模型时只改配置不把functions()散落在业务代码各处迁移成本会低很多。7. 关于函数命名和描述的经验建议我发现很多团队的函数描述写得太随意比如“查询订单信息”然后没了。模型是依据这段描述来做决策的描述越具体决策越准。一个良好的函数描述模板功能描述这个函数能干什么最好带上业务场景关键词。触发条件什么时候调用它什么时候不调用。参数格式每个参数的具体含义、合法取值、示例。返回值返回什么结构特殊情况下返回什么。举个例子Tool(name queryOrder, description 根据订单号查询订单详情适用于用户询问订单状态、物流、金额的场景。 订单号以ORD-开头如果用户没有提供订单号先询问用户。 返回值包含订单状态、商品名称、金额。 )这段描述里包含了调用时机、前置条件、返回值结构模型对该工具的把握会高很多。我第一次用上一版简洁描述时模型经常用不存在的订单号来凑数加上触发条件说明后它的行为立刻规矩了。除此之外函数命名也有讲究。工具名最好简洁且包含业务关键词不要用f1、handler2这种毫无语义的名字。模型可能会根据工具名做联想同一个业务域的函数名保持前缀一致的风格也有帮助。8. 从调用结果反向优化业务逻辑Function Calling做到后面真正有意思的事情是把函数返回结果设计成“对模型友好”的结构。举例queryOrder函数返回订单状态如果你是直接返回一个状态码1, 2, 3模型还要去猜每个数字是什么意思它猜错的可能性不小。最好的做法是返回一个包含枚举含义的结构public record OrderInfo( String orderId, String productName, double amount, String status, // SHIPPED / PENDING_PAYMENT / CANCELLED String statusText // 已发货 / 待付款 / 已取消 ) {}同时给模型一个人类可读的statusText字段模型回答时直接引用即可既准确又减少输出token负荷。还有一点用户可能会在同一个问题里叠加多个意图比如“查一下订单A和所有待付款的订单”。这种场景单个函数往往覆盖不了。你可以定义一个“批量查询”工具也可以接受模型多次调用函数不同模型对多次工具调用的支持力度不同必要时在System Prompt里限制一次只处理一个意图。在System Prompt里明确“只处理订单查询其他问题礼貌拒绝”能有效减少模型乱调工具的概率。我自己在实际项目里还有一个习惯给每个工具函数都记一笔调用日志。模型调用了哪个函数、传了什么参数、耗时多少、最终回答是否被采纳全记下来。这些日志是优化Prompt和函数质量的原始素材。比如当发现某个函数的调用率特别低很可能是描述不清晰或者和另一个函数的功能重叠了那就要考虑合并或者改描述。最后再分享一个细节。给函数注册加一个“测试工具函数”的习惯非常好比如让模型调用一个echo函数原样返回传入的文字来验证工具调用链路是否通顺。我在搭建新项目时第一个做的不是业务函数而是先跑通echo确认底层的schema生成、函数执行、结果回传整条链路没问题再往上去接业务。一开始就把路子走稳后面接十几个函数都不会慌。这也是整个Function Calling调试过程中最省时间的做法。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。