Function Calling这个名字看着唬人,其实就是让大模型在需要的时候,调用你写好的Java方法,而不是只会对着聊天窗口说一堆正确的废话。我做SpringAI项目时第一次跑通Function Calling,最大的感受是:这才是把LLM从“聊天机器人”变成“业务系统一部分”的关键一步。这篇博文基于我自己的SpringAI实战项目,讲透Function Calling怎么用、背后发生了什么,以及哪些坑我已经替你先踩过了。
这个功能适合谁?后端Java工程师,想在Spring Boot项目里快速接入AI能力,尤其是需要让模型实时查数据库、调接口、算数据的团队。不需要你精通机器学习,会写Java方法就行。下面我直接从项目角度拆解,不是官方文档复读机,保证你能照着做。
1. 项目背景与核心概念拆解
1.1 为什么需要Function Calling
先说一个最朴素的场景:你问GPT“杭州今天要不要带伞”,如果模型只靠训练数据,它多半会说“我不能实时获取天气信息”。普通Prompt再怎么写,模型也碰不到你数据库里的订单、用户、库存这些私有数据。Function Calling要解决的就是这个断层——让模型在对话过程中,主动申请调用你的Java方法,由你的方法返回真实数据,模型再基于这些数据组织回答。
这里有个关键点:模型本身不执行任何代码。它只是根据你的“函数说明书”,生成一段结构化的JSON调用请求,比如“我要调用getWeather,参数是city=杭州”。真正执行getWeather的是Spring Boot应用,执行完把结果塞回给模型,模型再整理成用户能看懂的自然语言。这个解耦非常重要,意味着你所有函数都可以复用现有业务代码,不用为了AI单独重写一遍。
从我实际做过项目的角度看,Function Calling最大的价值有三个。第一,实时性,模型终于能拿到“此刻”的数据;第二,准确性,通过函数强约束格式,避免模型瞎编;第三,可操作性,函数执行的是你的系统行为,比如下单、查询、计算,AI从“建议者”变成了“操作员”。这三点直接决定了它是我做SpringAI项目时优先级最高的能力。
1.2 SpringAI里Function Calling的整体机制
SpringAI对Function Calling的封装,核心思路是把“模型如何知道函数、如何传参”这些繁琐细节,通过注解和自动配置藏起来。我用的版本是SpringAI 1.0.0,如果你还在用0.8.x,API差异不大,后面我会标注需要注意的差异点。
整个调用链路大致是这样的:
- 用户在聊天界面提问,问题进入ChatClient。
- SpringAI把你注册的Java方法转换成模型能理解的function列表,连同用户消息一起发给模型。
- 模型判断这个问题需要调用函数,就在响应里返回一个tool_call指令,包含函数名和参数JSON。
- SpringAI收到这个指令后,在本机调用对应的Java方法,拿到执行结果。
- SpringAI把函数结果作为附加消息再次发送给模型。
- 模型结合函数结果,生成最终的对话回复给用户。
注意第3步,模型“是否调用函数”是一个概率决策,不是每次都会走函数。如果你发现模型时不时自己直接回答,最简单的办法是在System Prompt里明确告诉它:“当涉及天气时必须调用getWeather,不要自己编造”。这一步属于Prompt层面的引导,配合函数注册一起用,可靠性会高很多。
SpringAI帮你处理了最麻烦的JSON Schema生成、参数绑定、多轮tool_call消息组装。你只需要定义普通Java方法,加上@Tool注解,然后注册到ChatClient,剩下的交给框架。但框架也不是万能的,参数类型设计不好、函数返回内容过大等问题,它不会替你兜底。这些我放到后面的排查章节细说。
2. 环境准备与依赖配置
2.1 创建Spring Boot工程并引入SpringAI
我建议直接用start.spring.io生成一个最小工程,Java 17以上,Spring Boot 3.3.x或3.4.x都行。SpringAI 1.0.0对Spring Boot版本有明确要求,最好用3.2.0以上的版本,避免依赖冲突。
在pom.xml里面引入核心依赖时,有两点要注意。第一,SpringAI的依赖管理并不在Spring Boot的BOM里,你需要额外引入spring-ai-bom,或者像我一样直接写上版本号。第二,如果你只用OpenAI模型,引入spring-ai-openai-spring-boot-starter就够,它会把OpenAI相关的自动配置全部带进来。下面是能用的最小配置:
<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-openai-spring-boot-starter</artifactId> <version>1.0.0</version> </dependency>如果你是0.8.x的老项目,groupId是一模一样的,但版本号会不一样。升级到1.0.0后,最大的改变是ChatClient成了推荐的主入口,原来OpenAiChatClient这种直接new的方式还在,但官方已经不鼓励了。如果你在网上的老教程里看到一堆ChatOptions.builder()加手动OpenAiChatClient的写法,建议尽量迁移到ChatClient上来。
2.2 配置OpenAI与ChatClient
这一步很简单,就是在application.yml里写模型访问参数。注意我只配置了模型接入最基本的key和模型名,其他像temperature、maxTokens这类参数可以放到代码里针对不同业务单独设置,不建议全局写死。
spring: application: name: springai-function-calling ai: openai: api-key: ${OPENAI_API_KEY} chat: options: model: gpt-4o环境变量OPENAI_API_KEY我建议放在启动脚本或者CI的secrets里,千万别直接提交到Git仓库。模型名我用的是gpt-4o,如果你有成本压力,换gpt-4o-mini也可以,Function Calling能力两者都支持,但复杂参数场景下gpt-4o的理解准确性更高。项目初期可以用mini先跑通,上线前再评估是否升级。
然后创建ChatClient的Bean。这个Bean是后面所有对话的入口,我选择直接注入ChatClient.Builder,而不是缓存一个单一实例,因为不同业务可能需要不同的默认系统提示词或不同的函数集合。
@Configuration public class ChatClientConfig { @Bean public ChatClient chatClient(ChatClient.Builder builder) { return builder.defaultSystem("你是一个乐于助人的助手,数据必须来自提供的函数返回值,禁止编造。") .build(); } }2.3 关键参数说明:模型、温度和超时
可能有人会问,配置一个ChatClient为什么这么折腾?因为Function Calling对模型参数比普通聊天更敏感。我踩过几个坑,分享出来:
temperature是第一个关键参数。函数调用场景下,我建议设置在0.2到0.5之间。温度越高,模型越“自由”,它可能不按你给的JSON格式走,甚至自己脑补函数参数。我做测试时用0.8的temperature跑天气查询,有一次模型把“浙江杭州”传成了“杭州市浙江省”,把结构化参数完全搞乱了。后来统一降到0.3,这类情况几乎消失。
maxTokens也要注意。函数调用的中间过程会消耗不少token,尤其是函数返回结果很长的时候。如果你设置太小,模型可能来不及把整个工具调用过程走完就被截断。我的习惯是至少给到1024,如果业务复杂,需要函数返回大列表数据,建议2048以上。当然这会增加成本,需要在体验和费用之间平衡。
还有超时配置。默认情况下,如果模型迟迟不返回结果,HTTP调用会一直挂着。在application.yml里加上超时控制会稳妥很多。
spring: ai: openai: api-key: ${OPENAI_API_KEY} chat: options: model: gpt-4o temperature: 0.3 max-tokens: 20483. SpringAI Function Calling实战:一个天气查询功能
3.1 编写工具方法并声明@Tool
理论说了半天,直接上一个我自己项目里跑通的功能:天气查询。这个例子的好处是足够简单、人人都能理解,而且它恰好体现Function Calling“模型拿实时数据”的核心。
先定义一个request的record,用来接收模型传入的参数。很多新手会忽略这一步直接写普通方法参数,但SpringAI在生成JSON Schema时依赖这个类型的结构,所以建议规范化定义参数对象:
public record WeatherRequest(String city) { }然后定义返回结果。同样的,返回值类型也会影响模型理解函数意图,名字要直观。我这里用一个简单record包住天气描述:
public record WeatherResponse(String city, String weather, int temperature) { }核心工具方法如下。注意@Tool注解里的描述,这个描述是给模型看的,一定要写清楚“什么时候用、参数是什么含义”。
import org.springframework.ai.tool.annotation.Tool; import org.springframework.stereotype.Component; @Component public class WeatherTools { @Tool(description = "根据城市名获取当前天气,参数city是城市中文名,例如:杭州、上海") public WeatherResponse getWeather(WeatherRequest request) { // 这里应该是真实调用天气服务,实际项目中可以写RestTemplate调用外部API // 示例直接返回模拟数据,让你先跑通链路 if (request.city().contains("杭州")) { return new WeatherResponse(request.city(), "小雨", 24); } return new WeatherResponse(request.city(), "晴", 27); } }有几点必须说明。@Tool方法默认只支持public方法,私有方法会被忽略。返回值类型、参数类型必须能被Jackson正确序列化/反序列化,也就是要有无参构造或默认构造,record天然满足条件。如果你用传统POJO,记得加getter/setter和无参构造,否则SpringAI在参数绑定环节会抛异常,这个是我见过的最高频报错。
3.2 将Function注册到ChatClient
方法定义好了,光有@Component还不行,SpringAI不会自动扫描所有@Tool方法,你必须显式告诉ChatClient“这个函数我要用”。这里有两种方式,按场景选择。
第一种是直接方法引用注册,适合函数不多、且每个调用场景固定的时候:
@RestController public class WeatherController { private final ChatClient chatClient; public WeatherController(ChatClient.Builder builder, WeatherTools weatherTools) { this.chatClient = builder .defaultFunction("getWeather", weatherTools) .build(); } }第二种是使用defaultFunctions注册多个函数,适合一次对话里模型需要从多个工具中选择的情况:
this.chatClient = builder .defaultFunctions("getWeather", "getStockPrice", "calcDistance") .build();我实际项目里更常用第一种,因为.defaultFunction(String, Object)会自动把对象里的@Tool方法解析成函数定义,而且在同一个对象里定义多个相关工具非常方便。比如我可以把WeatherTools里放两个方法,一个getWeather,一个getWind, 然后用.defaultFunction注册两次,指向同一个对象实例。
3.3 完整调用代码与运行效果
注册好之后,调用就非常平淡了,就是普通ChatClient接口:
@PostMapping("/chat") public String chat(@RequestBody String userMessage) { return chatClient.prompt() .user(userMessage) .call() .content(); }启动工程后,用Postman请求“杭州今天天气怎么样?”,你会在日志里看到整个Function Calling的真实调用过程:先是模型请求调用getWeather,然后SpringAI执行你的方法,再把结果回传模型。最终用户看到的是类似“杭州市今天小雨,气温24摄氏度”的自然语言回答。
如果不走HTTP接口,直接写个CommandLineRunner做验证也很快:
@Component public class FunctionCallingRunner implements CommandLineRunner { private final ChatClient chatClient; public FunctionCallingRunner(ChatClient.Builder builder, WeatherTools weatherTools) { this.chatClient = builder .defaultFunction("getWeather", weatherTools) .build(); } @Override public void run(String... args) { String answer = chatClient.prompt() .user("杭州今天要不要带伞?") .call() .content(); System.out.println(answer); } }第一次看到模型只会输出“我需要调用工具查询天气”这类中间消息时,不用慌,那是内部机制的一部分。SpringAI在框架层面已经自动完成了多轮消息组装,你看到的最终结果一定已经是整合后的自然语言回复。
4. 核心机制与实现原理
4.1 @Tool与JSON Schema的生成过程
很多人用Function Calling很顺利,却不理解为什么模型能准确知道Java方法的参数结构。这是因为SpringAI在启动时,通过反射扫描@Tool注解方法,把你的方法签名转换成OpenAI定义的tools格式,其中包含function的name、description和parameters的JSON Schema。
我打个比方:你写了一个getWeather方法,SpringAI就好比是你的“接口文档生成器”,自动把你的Java方法翻译成一份模型能读懂的说明书。说明书里写着“这个函数叫getWeather,作用是获取天气,它接收一个对象,对象里有个city字段,字段类型是string”。模型看了这份说明书,才知道要用什么格式调用你。
这段JSON Schema在框架内部会自动生成,但我建议你至少在开发阶段把它打印出来看一眼。方法如下:
toolCallingManager.getToolDefinitions()或者直接在日志级别配置DEBUG,观察请求体。这会让你快速理解为什么参数命名要规范、为什么中文描述会有用。我第一次打印出来时发现,一个Java方法里的布尔类型参数会被翻译成boolean,打开方式不直观,后来为了模型理解而改成了字符串“true/false”,准确率反而更高。
4.2 一次调用中模型和Java方法如何协作
完整的协作过程在SpringAI内部是分两步走的,但被封装成了一个连贯的调用。
第一步:模型收到用户消息后,判断是否需要函数调用。如果它判断需要,就会返回一个ChatResponse,里面携带toolCalls列表,每个toolCall包含一个id、要调用的函数名,以及一串参数JSON。这个过程中模型是没有产生最终文本回复的,或者只会产生类似“让我查一下”的过渡文本。
第二步:框架根据toolCall里的函数名,从当前ChatClient注册的Function列表里找到对应方法,然后使用JSON反序列化工具把参数转换成Java对象,执行方法,拿到返回值。这个返回值会被包装成ToolResponseMessage,再次发给模型。模型读到返回值后,生成最终回复。
我特别强调一下,这里的方法执行是在你的Java进程里完成的,不是模型服务器上。用户问“杭州天气”,模型服务器只负责调度,真正去查天气的是你自己的服务。这也意味着你的方法完全可以访问数据库、Redis、甚至调用其他公司的API。
4.3 多Function并存时模型的决策依据
实际项目里一个ChatClient往往不止一个函数。比如我可以同时注册getWeather、getStockPrice、getRoomPrice三个函数。这时候模型怎么知道该选哪个?
模型主要看两个东西:函数描述和参数Schema。描述越清晰,决策越准。我做过一次对比实验,把函数描述写得很模糊时,10次里有3次模型选错函数;把描述改精确后,10次只错了1次。这个误差不是SpringAI的问题,是大模型本身的能力边界。你无法彻底消除,但可以通过优化描述来降低到可接受水平。
另外,注册函数的顺序也有微小影响。OpenAI的API里tools列表的顺序会影响模型的注意力。我现在的习惯是简单、高频的函数放前面,复杂、低频的放后面。这个没有官方理论支持,是我自己试验的规律,但在多函数场景下实测确实有效。也可以限定每个场景的ChatClient只暴露相关函数,比如天气对话页就只注册天气函数,不要图省事把一个超级ChatClient到处用,函数越少,决策越稳定。
5. 常见问题与排查技巧
5.1 函数参数不匹配与类型转换报错
我在实战中最常遇到的是参数类型转换异常。典型报错长这样:
Cannot deserialize value of type java.lang.String from Array value问题通常出在参数定义上。比如你方法里写的是double temperature,但模型返回的JSON里temperature可能是"25"字符串,或者你定义为List ,模型却传了一个Integer。OpenAI的模型并不保证每次输出的参数类型和你的Schema完全一致,它是在“尽量匹配”而不是“严格匹配”。SpringAI底层通常用Jackson做反序列化,遇到不一致就直接抛异常了。
我的解决思路有三个。第一,参数类型尽量用宽松的包装类型,比如Double而不是double,允许缺失值。第二,所有参数对象字段都提供合理的默认值,哪怕模型漏传了某个字段也能兜底。第三,写工具方法时不要过度信任模型,方法内部加参数校验,比如city为空时返回“参数不完整”的固定提示,而不是直接NullPointerException。
5.2 返回结果过长导致上下文爆炸
有一次我做订单查询功能,函数返回了最近100条订单明细,结果下一次请求的token消耗直接翻倍,因为函数返回结果被完整带入了对话上下文。这是Function Calling一个非常隐蔽的问题:模型不记得函数内部逻辑,它只能把整个返回值当成新的消息继续处理。返回内容越大,后续每一轮对话的token成本越高。
解决方法是压缩输出。比如订单查询函数,不要返回全部字段,只返回id、状态、金额这种模型需要用来回答问题的关键字段。我在工具方法末尾会做一次裁剪,把不需要的长文本字段去掉。另外,函数返回值可以加一层“摘要化”,比如返回“共100条订单,总金额12000元,最近一笔订单号是20250101”,模型已经足够回答大部分用户问题。如果用户真的想逐条看,再走分页或详情接口。
5.3 Function调用没有触发
这是新手最容易困惑的问题:明明注册了函数,模型却自己乱答,函数完全不触发。我先说结论:这不是SpringAI的bug,很大概率是模型觉得“不调用函数也能回答”。
排查顺序我建议按下面这个来。第一步,确认ChatClient里真的注册了函数,别在Controller里new了一个新的ChatClient,忘记了注册。第二步,看请求日志里发给模型的tools列表,是否包含你的函数定义,如果没有,就是注册链路断了。第三步,检查System Prompt,如果模型觉得从你的Prompt里能猜到答案,它可能选择不调用。比如你问“杭州天气”,函数只给了城市参数,但你在Prompt里写了“如果查询不到就默认返回晴天”,模型就很可能直接返回一个编造的晴天。
解决办法是在System Prompt里强制约束,比如我常用的一句话是:“只有调用工具函数得到的结果才是事实,没有工具返回的数据不得编造。当用户询问天气时,必须调用getWeather。” 加上这句之后,触发率明显提升。
5.4 如何有效调试Function Calling链路
调试这种多轮调用的链路,最直观的方法是打印完整的请求和响应日志。SpringAI支持配置OpenAI的日志级别:
logging: level: org.springframework.ai: DEBUG打开后,你可以在日志里看到发送给模型的tools列表、模型返回的toolCalls内容,以及第二次发送时携带的函数结果。这个信息量很大,基本一眼就能定位问题是出在“模型没选函数”还是“参数转换报错”还是“返回值解析出错”。
我还习惯在工具方法内部加一个日志标记。比如getWeather方法一开始就log.info("函数被调用, city={}", request.city())。这样配合外层日志,能明确知道什么时候进入了你的代码,方便区分是框架问题还是业务方法问题。曾有一次我发现函数被连续调用了三次,后来排查是因为函数返回的数据不满足模型的期望,模型又请求调用了一次,这在多轮工具调用里是正常现象,但要注意避免无限循环,最好在System Prompt里说明“如果函数结果不包含答案,向用户说明暂时无法获取”。
6. 进阶技巧与个人心得
6.1 多轮对话中的状态保持
Function Calling的默认模型是无状态的,每一轮函数调用结束后,上下文里只累积消息文本和函数返回结果,函数本身的调用顺序并不会被记住。如果你做一个客服场景,用户先问“杭州天气”,再问“那北京呢”,模型大概率会直接调用getWeather("北京"),这没问题。但如果用户问“那后天呢?”,模型可能无法自动推断出要调用带日期的函数,除非你上一轮的函数返回结果里面包含了日期这个字段。
我的建议是,在设计工具函数时,尽量返回“完整上下文相关的字段”。比如天气函数接受city和date两个参数,即使模型只传了city,返回值里也带上当天日期。这样后续对话模型看到函数结果里有日期字段,才有依据回答“后天”这类问题。做不到的时候,就在Prompt里引导用户补充缺失信息,不要硬让模型猜。
6.2 在函数内做业务校验与权限控制
Function Calling把Java方法暴露给了模型,等于把系统的“手”借给了AI。这也会带来安全风险:用户可能诱导模型调用一个目的之外的函数,或者传入恶意参数。比如你有deleteOrder函数,模型可能因为Prompt注入被引导去调用它。
我的处理原则很简单:所有通过Function Calling暴露的方法,都要像处理外部HTTP接口一样做权限校验。首先,方法内部必须校验当前用户是否有权执行操作,不要依赖模型判断。其次,敏感操作函数不要注册到普通的对话ChatClient里,得单独起一个限权场景。最后,参数不要直接拼SQL或命令,比如工具方法里如果涉及数据库查询,坚持用PreparedStatement,避免模型生成的参数成为注入点。
我见过有人把“删除用户”功能直接加@Tool注解暴露给通用AI助手,这属于给自己埋雷。正确的做法是让函数只做“只读查询”,写操作必须经过额外的确认机制。
6.3 我优化Function Calling效果的几点经验
最后分享几条面对真实业务时摸索出来的小技巧,属于不一定写进文档但实际很有用的经验。
第一,函数名要动词+名词,清晰可读。模型对函数名的语义理解能力比很多人想象中强,命名尽量不要用handler1这种,要用getWeatherByCity。第二,描述里写清“何时使用”。比如“当用户想问某个商品是否有货时调用,不要用于促销活动查询”,这能大幅降低误调用。第三,不要注册太多函数。我在项目里试过一次注册8个函数,结果模型选择准确率明显下降,最后拆成两个ChatClient,各注册3到4个函数,效果才恢复。
还有一点是关于SpringAI版本迭代的。SpringAI现在迭代速度很快,我早期写的代码在升级版本后出现过不兼容的情况。如果你从0.8.x升到1.0.0,重点检查ChatClient的构建方式,ToolCallingManager的注入方式,以及@Tool注解的包名。用IDE全局搜索可以快速定位。升级前建议先看changelog,很多API变化在官方迁移文档里都有说明。
我个人在实际操作中最深的一个体会是:Function Calling不是“加个注解就结束”的玩具,它的质量上限由你的工具方法设计质量决定。你花在优化函数描述、精简返回结果、做异常兜底上的时间,最终都会反映在用户体验上。如果你做完第一个SpringAI Function Calling功能后,发现模型越来越会和你的业务系统“配合”,那说明你已经掌握了这个能力的真正用法。接下来可以试试把多个函数链式调用起来,做成一个能自主完成复杂任务的AI Agent,那会是一个更有意思的方向。