Spring AI Function Calling实战:让大模型从“聊天”到“办事”

发布时间:2026/10/2 14:59:46
Spring AI Function Calling实战:让大模型从“聊天”到“办事”
大家有没有遇到过这种尴尬AI助手聊得头头是道但一问帮我查一下这个订单的物流它只能回一句我暂时无法访问实时数据。模型的知识再大也拿不到你系统里的真实数据更不会替你去调接口、改状态、下单付款。Spring AI的Function Calling机制解决的就是这个断层。它让大模型不再停留于生成文字而是能在对话中判断这里该调用一个函数然后以结构化参数的方式要求你的应用去执行真实操作再把结果回填到对话里。整个链路打通之后AI才真正从聊天机器人变成了能办事的助手。这篇文章我会从零开始完整拆解Spring AI里Function Calling的原理和实战路径。内容包括它背后的交互协议是怎么回事、项目怎么搭、模型怎么选、单函数和多函数场景分别怎么写、实测中会遇到哪些边界问题、以及几个我用下来觉得值得单独拎出来讲的坑。适合正在用Spring Boot做AI应用、想把Agent能力落到真实业务的Java开发者。即使你对Function Calling零基础按着文章走一遍也能跑通。1. 为什么你的AI应用需要Function Calling1.1 大模型的能力边界只会说不会做先想清楚一个本质问题大模型本质上是一个基于概率的文本生成器。你给它一句问题它给你一段回答。这个回答可以引经据典、逻辑严密但它没有一个执行环境——没有你的订单数据库没有你的业务接口没有文件系统权限。这就导致两个痛点拿不到实时数据。你和模型说帮我看看深圳明天天气它可以凭记忆告诉你一个大概但那是训练集中某个时间点的数据可能早就过时了。做不了实际操作。你让它帮我给这个用户发一条优惠券短信模型只能告诉你一段文案真正发的动作它做不了。很多团队早期解决这些问题的方式是在Prompt里让模型输出特定的JSON再由后端代码去解析这个JSON、执行对应操作。听起来可行但实现起来非常痛苦——模型经常输出不合法JSON、字段名自己编、参数类型搞错解析逻辑越写越复杂一场对话里只要涉及多个接口就乱成一团。1.2 Function Calling是什么Function Calling做的事情是把怎么调用函数这件事从Prompt里的人工约定变成了模型的原生能力。具体来说你在请求里以JSON Schema的形式声明一批函数每个函数告诉模型我叫什么、我用一句话描述是干什么的、我接收哪些参数、每个参数是什么类型。模型在理解用户意图后会主动判断这个问题需要借助哪个外部函数然后不是在生成的文字里暗示你而是直接输出一个结构化结果里面带上了函数名和完整参数。这里有个很多人第一次接触时容易误解的点整个过程中真正执行函数的一直是你的应用不是模型。模型只负责决定调用哪个函数、参数填什么执行完的结果再交给它让它基于结果组织最终回答。这个过程给两端都带来了明显的好处。对模型来说它不需要绞尽脑汁编造没有见过的数据只需要把手上的参数整理好递出去。对你来说你不再需要把业务逻辑塞进Prompt里赌模型的稳定性业务代码该怎么样还是怎么样模型只是多了一个指挥你的能力。1.3 我为什么在Spring AI里选择Function Calling市面上具备Function Calling能力的框架不少Python生态有LangChain、LlamaIndexJava生态里Spring AI目前是把这件事做得最顺手的。原因有三点。第一它是Spring官方生态的一部分自然承接Spring Boot的自动装配、配置管理、Bean生命周期不需要引入一套割裂的新框架第二Function注册机制平滑写一个普通的Java方法加上描述注解就能被模型识别不像很多框架需要单独维护一套JSON Schema文件第三接入国产大模型生态也方便DeepSeek、通义千问、智谱、MiniMax这些厂商都兼容OpenAI的Function Calling协议Spring AI统一做了适配换模型基本不需要改业务代码。这篇文章后面所有例子都基于Spring AI代码里会用到Spring AI的ChatModel、FunctionCallback等核心类型。接下来先搞清楚它的底层交互协议再上手写代码否则你调半天都不明白返回值为什么长那个样子。2. 一个真实的Function Calling交互流程长什么样2.1 三个角色拆解表面上看一次Function Calling调用只有你和模型两方参与。实际上把它拆开看这里面有三个角色模型负责语义理解与决策判断现在该不该调外部函数。你的应用负责收集函数定义并交给模型接收模型返回的调用请求执行对应的Java方法再把执行结果送回模型。函数本身就是你的业务方法比如查天气、查订单、计算价格它是普通代码和模型没有直接关联。这三个角色的关系可以类比成你和助理配合做事。助理模型不亲手干活但会写一张条子告诉你你需要去库房取A货架第三层的蓝色盒子取来之后告诉我。你应用看到条子取了货回来把结果告诉他他再基于这个结果给你最终答复。2.2 一次完整调用的三次往返很多初学者以为Function Calling是模型直接调用你的接口这是错误的。真实过程是一轮对话里的多次请求往返我拆成三步来看。第一步应用向模型发起带函数声明的请求。你在请求里除了发用户消息还要带上一批函数定义告诉模型你有这些工具可用。第二步模型返回函数调用请求。模型读完用户消息判断应该调用哪个函数然后返回一条特殊响应里面有一个tool_calls字段包含函数名和模型自己填充的参数。这个响应不是最终答案而是我需要你帮我执行这个。第三步应用执行函数并把结果回传。你拿到函数名和参数在自己的Java代码里找到对应方法执行然后把执行结果作为一条工具消息塞进对话历史再次发给模型。模型看到函数返回值后组织出一段自然语言回答这次才是最终回复。我见过不少第一次调试的人第二步返回一个tool_calls的时候就开始懵了怎么结果没有content其实这是正常的你要做的是检查tool_calls里的内容执行完函数再把这轮对话发给模型。2.3 和硬编码调用的本质差异有些人会说这绕了一圈不就是模型输出一个JSON、后端解析一下去调用对应接口吗确实有点像但底层逻辑完全不同。硬编码方案里模型输出什么靠的是Prompt约束你告诉它必须输出JSONkey是function_namevalue是参数。模型经常不听话输出格式漂移、参数类型不对、甚至直接开始唠嗑。你需要在外面套一堆解析、校验、重试逻辑。Function Calling方案里函数名和参数格式是模型在训练阶段就被教会的能力它知道tool_calls这个字段必须带上已有的函数名参数必须符合你声明的JSON Schema。从底层把格式化输出这件事变成了模型的内置技能可靠性比Prompt约束高一个量级。所以Spring AI里你不需要自己解析函数名和参数SDK已经替你处理了你的任务是定义好函数、注册给模型、在第三轮把结果传回去。搞清楚这个流程后面写代码就有底了。3. 搭建项目与选中模型前必须想清楚的几件事3.1 版本怎么选Spring AI的版本演进很快早期用的是1.0.0-M1这种里程碑版本后来出了1.0.0正式版现在又是2.0时代。选版本这件事比你想的重要得多因为不同版本的API差异很大网上搜到的大多数案例可能都不能直接运行。我当前推荐的组合是Spring Boot 3.4.x配合Spring AI 1.0.0正式版或者直接上用2.0.x的新版本。如果你在主题是Spring AI Alibaba相关的项目里还需要额外引入spring-ai-alibaba-starter。有一点踩坑经验别直接照抄GitHub最新示例。Spring AI的主仓库在快速迭代API经常变动我在升级时吃过不少亏。稳妥的做法是先看你选的版本对应的Release文档尤其是FunctionCallback和ChatClient的用法不同版本差异主要在它们身上。以Spring AI 1.0.0为例核心依赖长这样dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-bom/artifactId version1.0.0/version typepom/type scopeimport/scope /dependency dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-openai-spring-boot-starter/artifactId /dependency如果要接通义千问引入对应的starterdependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-alibaba-spring-boot-starter/artifactId /dependency版本冲突是这类项目最常见的启动失败原因。建议用spring-ai-bom统一管理版本别手动指定各子模块版本号否则Maven会给你上一课。3.2 模型选型兼容性比性能更关键Function Calling依赖模型的原生能力所以不是所有模型都能跑。GPT-4o、Claude 3.5、DeepSeek、通义千问、智谱GLM这些主流模型都支持但不同模型对参数Schema的容忍度差异很大。我的建议是如果你只是学习和验证优先选OpenAI接口兼容的云服务配置最简单。如果你在国内环境用通义千问或智谱的接入方式也不会比OpenAI复杂多少。Spring AI把所有兼容OpenAI协议的模型都封装在同一个ChatModel接口后面换模型通常意味着换一个配置前缀。spring: ai: openai: api-key: ${OPENAI_API_KEY} chat: options: model: gpt-4o temperature: 0.7有一点要提醒Function Calling场景下temperature不建议设太高。它是采样随机性参数太高会让函数调用选择变得不稳定偶尔明明该调天气函数却去调了别的函数。实务中我一般设0.2到0.4结构化场景会更可靠。3.3 工程结构与配置清单为了方便直接跑我建议你建一个普通的Spring Boot工程只要一个模块就够了。整个项目的结构如下controller/提供HTTP入口接收用户输入调用ChatClientservice/放业务函数本体比如天气查询、订单查询config/做Bean装配注册函数resources/application.yml配置api-key、模型、超时参数你需要准备的东西也很少一个支持Function Calling模型的API Key、一个Spring Boot 3.4的空工程、以及一点耐心。整个项目跑通不会超过两百行代码。4. 第一阶段实战手写一个天气查询函数4.1 定义一个能被模型识别的函数在Spring AI里定义一个能被模型识别的函数非常简单写一个普通Java方法加上Description注解描述这个函数的用途和参数含义。为什么这个注解这么重要因为模型不具备读你代码的能力它只能看到你在注解里写的描述和JSON Schema。描述写得越清楚模型的调用准确率就越高。下面这个例子模拟一个天气查询函数Description(根据城市名查询实时天气用于回答用户关于天气的提问) public record WeatherFunction() implements FunctionWeatherFunction.Request, WeatherFunction.Response { Override public Response apply(Request request) { // 这里实际上是调用外部天气API为了演示返回固定数据 return new Response(request.city(), 多云, 26, 东南风3级); } public record Request( JsonProperty(required true, value city) String city, JsonProperty(required false, value unit) String unit) { } public record Response(String city, String condition, int temperature, String wind) { } }注意这段代码的几处细节Request里的JsonProperty注解定义了参数的名称、是否必填这是Spring AI生成JSON Schema的基础。所谓工具定义其实主要就是把Request这个record转成JSON Schema传给模型所以字段命名要直观别用晦涩缩写。4.2 注册函数属性声明和编程式注册函数定义好之后需要把它告诉模型。Spring AI提供了两种方式原理都一样只是配置位置不同。第一种配置文件声明。在application.yml里指定函数名和对应的Bean名称spring: ai: chat: client: tool-callbacks: - name: weatherFunction bean-name: weatherFunction description: 根据城市名查询实时天气 input-type: cn.example.functions.WeatherFunction$Request output-type: cn.example.functions.WeatherFunction$Response第二种编程式注册。在Java配置类里直接构造FunctionCallbackBean public FunctionCallback weatherFunction() { return FunctionCallback.builder() .description(根据城市名查询实时天气) .function(weatherFunction, new WeatherFunction()) .inputType(WeatherFunction.Request.class) .build(); }两种方式我建议你用编程式原因很明显不用写一长串类名到YAML里类型安全重构的时候编译器能帮你发现问题。配置文件方式适合不想动代码的场景但调试起来比较麻烦。4.3 跑通第一轮完整对话函数定义好、也注册了接下来写一个HTTP接口把对话流程串起来。RestController public class ChatController { private final ChatClient chatClient; public ChatController(ChatClient.Builder builder) { this.chatClient builder .defaultFunctions(weatherFunction) .build(); } PostMapping(/chat) public String chat(RequestBody String userMessage) { return chatClient.prompt(userMessage) .call() .content(); } }核心就是defaultFunctions(...)它告诉这个ChatClient实例模型可用的工具里有weatherFunction。当你发送深圳今天天气怎么样Spring AI会自动完成我之前讲的两次请求往返最终返回的是一句自然语言。一个典型的输出如下深圳今天多云气温26度东南风3级。如果你打开日志会看到第二次请求里有一个tool_calls字段内容是{ name: weatherFunction, arguments: { city: 深圳 } }看到这个结构说明你的Function Calling已经完全打通了。剩下的事情都是在这个基础上做扩展。5. 第二阶段进阶让AI自主决策的多函数路由5.1 多函数注册模型如何自己选真实业务里一个函数通常不够用。典型场景是用户问深圳天气怎么样顺便把明天北京到上海的机票价格也算一下这时候需要两个函数配合一个是天气查询一个是机票比价。在Spring AI里注册多函数非常直接defaultFunctions接收一个可变参数this.chatClient builder .defaultFunctions(weatherFunction, airfareFunction, hotelFunction) .build();模型会读取所有函数的定义根据用户问题的语义自行判断调用哪个必要时还会并行调用多个。比如上面的问题模型可能同时返回两个tool_calls一个查天气一个算机票。这就是多函数路由最省心的地方——你不用写任何路由逻辑。但自由选择也意味着可能选错。这个问题的根源通常在函数描述写得有歧义。举个例子如果你的天气函数描述里写了支持查询气温、降雨、风力用户问深圳明天要不要带伞模型可能优先调用降雨相关描述的函数而不是天气总查询函数。所以函数描述要写得边界清晰必要时用标点突出擅长范围。5.2 参数Schema类型、枚举、必填一点也不能含糊多函数场景下参数定义直接决定了调用的成败。我见过一个翻车案例一个电商客服的Agent商品查询函数的category参数定义为String模型自由发挥填了男装夹克、春季外套这类五花八门的写法后端精确匹配完全对不上。解决这个问题的最好办法是给参数加上枚举约束。Spring AI支持用JsonClassDescription配合Java的枚举类型来生成枚举Schemapublic record ProductQuery( JsonProperty(required true, value category) Category category, JsonProperty(required true, value keyword) String keyword) { public enum Category { 上衣, 裤装, 鞋靴, 配饰 } }一旦声明了枚举模型在生成参数时就会尽量从枚举值里选不会自由发挥。同理对于数值参数能用JsonProperty定义取值范围最好比如价格区间可以定义为int在描述里写明单位元避免模型给出一堆带小数点的价格。参数Schema的规则总结起来就几条必填参数务必标required true能用枚举的地方别用String单位、格式写在描述里比如日期格式YYYY-MM-DD参数数量控制在5个以内太多模型容易填写混乱5.3 多轮上下文与函数结果回填多函数场景还有个常见问题函数调用不止一轮。比如用户先说帮我对比上海和杭州的天气模型调用两个天气函数给出对比结论用户接着说那哪个城市更适合明天出行这句话表面上不涉及任何函数但如果你不带上前面的上下文模型根本无法作答。Spring AI的ChatClient在这里帮了大忙。ChatClient内部维护着对话记忆你只需要确保函数执行结果作为消息被放进了历史即可。实际使用中如果你用ChatClient的prompt()方式它会自动帮你把工具调用和工具结果拼进消息列表。不过要注意ChatClient默认不持久化上下文。如果你的应用是无状态接口每次请求都是全新对话用户连续追问就会失忆。要解决这个问题可以在Starter里开启ChatMemory的持久化或者自己引入Redis做会话消息存储。这个设计要提前想清楚我遇到过很多团队在功能联调通过后才回头补上下文存储改起来很别扭。6. 实测中的边界问题与性能优化6.1 函数返回结果太长的风险进入实测阶段头号遭遇战往往不是模型不调用函数而是函数返回了巨大数据模型不知道该怎么办。假设你做了一个订单查询函数返回结构里带上了订单明细、商品快照、物流轨迹、售后记录。这个结果会被完整塞进对话历史发给模型。结果就是token消耗暴涨、响应变慢、甚至因为超出上下文窗口直接报错。我的处理经验是函数返回给模型的数据只保留模型需要用来组织回答的摘要字段而不是完整业务对象。比如订单查询函数返回前先把数据裁剪成订单状态商品名称下单时间金额至于订单内部冗长的明细列表别一股脑丢给模型。模型不需要知道每件商品的内部编码它只需要信息足以生成您的订单已发货包含3件商品预计后天送达这句话就好。Spring AI里做这个很自然你只需要让函数返回一个精简DTO对象public record OrderSummary(String orderNo, String itemBrief, String status, String eta) { }6.2 无限函数调用Loop的防护另一个奇怪的问题是死循环。某些情况下模型拿到函数返回结果后可能觉得结果不理想又去调用同一个函数如此反复。每次调用都产生token费用接口迟迟不返回。这听起来像极端情况但多函数场景下确实发生过。比如你有一个查询库存的函数用户问的是这个库存数据哪里来的模型可能理解为需要调用库存查询函数拿到结果后又开始解释解释过程中再次调用陷入循环。防止这个问题有几招给函数执行总次数设置上限。Spring AI底层提供拦截机制可以自己计数达到5次直接终止循环。限制函数返回内容的表达精度。很多循环调用是因为函数返回的字段语义模糊模型反复确认尽量让返回数据结论化而不是数据化。把不合适的意图通过描述排除掉。对非业务类元问题比如你从哪获知的数据明确描述此函数仅用于XX不做数据分析。.overrideToolCallbacksAfterCompletion(5) // 最多允许5次工具调用实测下来这个参数非常有用建议直接加上。6.3 Token与延迟怎么省Function Calling会显著增加token消耗它与普通对话的区别就是两次请求都携带大量函数定义和工具消息。有个简单的实测数据注册5个函数、每个函数带3个参数每次对话光函数定义就要消耗几百token。优化手段有三个方向。方向一缩小函数定义体。Spring AI生成的FunctionCalling定义里包含你定义的DTO的结构描述字段注释和描述都会转成Schema。如果描述很长每次调用都带着这些文本。在保证模型能理解的前提下描述写得精简一点长期使用能省不少token。方向二缓存函数定义。函数Schema在业务运行期间基本不变没必要每次请求都重新生成一遍。Spring AI本身有缓存但你如果每次动态注册函数就享受不到。尽量把函数定义为固定Bean别在请求里临时创建。方向三按需注入函数。不需要把全部函数一古脑注册给模型。比如用户进入售后页面时只注入售后相关函数减少无关函数的干扰也减少token开销。Spring AI可以在运行时通过runtimeFunctions动态指定函数集合这就够用了。延迟这块最大瓶颈往往不在模型而在你的函数执行速度。一个例子查天气的函数如果调用外部天气API要3秒用户体感上就是回答问题前卡了3秒。建议把高频外部查询函数做成缓存优先或把执行步骤放到后台返回即时状态给模型组织话术。7. 几个容易被忽视但影响巨大的细节坑7.1 一次事故并发与本地变量状态Function Calling在Spring AI里的执行方式并不是简单反射调用一个方法而是要经过FunctionCallback的包装。默认情况下Spring AI在每次调用时会复用你注册的Bean。我自己踩过一个很深的坑在函数Bean里加了一个private ListString history字段想记录该函数的所有调用历史。本意是方便追溯结果在高并发测试时发现多个用户的调用历史互相串了。因为Spring的Bean默认是单例的所有请求共享同一个Bean实例我的history是个共享状态。这种问题的教训是函数Bean设计时要无状态。所有临时数据都放在方法参数或函数内部创建不要挂在成员变量上。如果确实要记录状态请使用外部存储比如Redis或数据库。Component public class OrderFunction implements FunctionOrderQuery.Request, OrderQuery.Response { // 禁止这样的写法 // private MapString, Object state new ConcurrentHashMap(); Override public Response apply(Request request) { // 业务逻辑 } }7.2 金额与换算不该用doubleFunction Calling是AI与业务系统之间的桥梁这个桥最容易出现问题的地方之一是数值精度。一个订单金额模型以为是99.99但你的数据库存的是分9999。模型直接传参可能会传成9999.0或10000后端入库就错了。我的建议是所有金额类参数在函数DTO里用整数分或者BigDecimal定义并且在描述里明确单位。比如public record RefundRequest( JsonProperty(required true, value orderId) String orderId, JsonProperty(required true, value amountFen) long amountFen) { }这里amountFen是分模型看到的Schema会明确这是一个整数单位是分。即使模型从用户的退款100元里解析出10000也正好是10000分不会出现精度问题。千万别用double接金额浮点误差一旦发生审计都对不上账。7.3 超时、重试与流式对话的坑Function Calling天然比普通对话慢因为多了一轮模型返回工具调用→执行函数→再次请求模型的往返。这意味着HTTP超时设置和流式输出需要特殊处理。先说超时。如果你给接口设置的超时只有10秒而函数本身要执行3秒加上模型两轮生成时间极容易超时。我通常在网关层对这类接口放宽到30秒以上同时内部给模型调用设置合理超时比如OpenAI的socketTimeout设为30秒。再说流式。StreamingChatClient模式下如果用户问了一个需要函数调用的问题第一轮流式输出的内容往往是空的因为模型没有直接生成回答而是在生成tool_calls。这会让前端表现为半天没反应。处理办法有两种一是前后端交互时遇到空流式块展示正在思考状态二是把Function Calling场景下流式输出阶段分清楚工具执行完成后再做最终流式回答。spring: ai: openai: client: connect-timeout: 10s read-timeout: 60s7.4 权限思维让函数最小化暴露业务能力最后一个我要重点强调的Function Calling是一个入口模型选择什么函数去调用那个函数就会真正执行。这等于把系统的部分操作能力暴露给了AI那AI的入口层也就是前端聊天框就成了一个新的安全边界。如果你把所有函数都注册给模型用户说给全体用户发优惠券模型真的会去执行对应函数。这听起来是科幻场景但真实发生过。我的做法是给函数调用设计三层防线函数权限等级。非敏感操作随意调用涉及资金、用户资产、批量操作的函数必须加上二次确认步骤。参数校验前置。模型生成的参数不可信函数入口处用Spring Validation或手动校验比如金额不能为负、手机号格式必须正确。操作审计。每次函数调用记录用户标识、函数名、参数、结果存日志或数据库方便追踪。一个Agent系统能不能安全落地关键不在模型能力而在这些外围防线。Function Calling本身是通道通道打通了之后业务安全规则一个都不能省。我自己的经验是功能上线前后拿出半天时间专门做函数调用的权限梳理把模型可以做的清单和模型绝对不可以做的清单分开比后续补救省心得多。