☰
Spring AI Function Calling实战:从注册机制到流式输出全流程拆解
2026/10/27 15:36:42 网站建设 项目流程

做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的流程可以浓缩成五个环节:

  1. 你定义好一个函数(比如queryOrder(String orderId)),并把它注册到AI客户端上。
  2. 用户提问,系统把这个问题的上下文连同可用函数的“说明书”一起发给模型。
  3. 模型判断该调用哪个函数、传什么参数,返回一个结构化调用请求,而不是直接回答。
  4. 你的程序执行这个函数,拿到真实数据。
  5. 把函数返回结果回传给模型,模型基于真实结果生成最终回答。

这里面最微妙的一点是:函数执行是在你的代码里完成的,模型只是“发号施令”。也就是说,函数里能连数据库、能调第三方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、ToolCallbacks

Spring 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> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-openai-spring-boot-starter</artifactId> <version>1.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 Function<WeatherRequest, 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 Function<OrderQueryRequest, 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构建时注册对应的ToolCallbacks:

this.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要能够:

  1. 从订单号中识别业务意图。
  2. 调用订单查询函数,拿到订单当前金额和状态。
  3. 调用优惠券计算函数,算一下还能再减多少。
  4. 综合结果,用自然语言回答用户。

这个例子同时用到了两个函数,两种不同类型参数的处理,以及一个相对完整的回传流程。

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;charset=utf-8") public Flux<String> 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调试过程中最省时间的做法。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询