纯手工把一个客服项目从"模型只会聊天"调到"模型会自己查订单、查物流"的状态,前后折腾了快两周。Spring AI的Function Call帮了大忙,但中间踩的坑比文档里写清楚的多得多。这篇是系列的第8篇,专门聊工具使用,我会把整个落地过程拆开揉碎了讲:为什么需要Function Call、Spring AI里怎么定义和注册工具函数、真实场景下模型是怎么一步步把对话变成工具调用的,以及那些文档里不会写的坑。
1. 为什么需要Function Call:大模型的能力边界与工具化破局
1.1 大模型的三块公认短板
不管用哪家的开源模型,只要是纯文本对话,都有几个绕不开的硬伤:
第一个是知识截止时间。模型训练完的那一天就是它的"记忆终点",之后发生的事它一概不知道。你问一个在2025年上线的商品优惠活动,它只会一本正经地编一个不存在的活动规则。
第二个是无法访问私域业务数据。订单状态、库存数量、用户积分这些数据躺在你公司的数据库里,模型训练时根本接触不到。你去问模型"订单A1001发货了没",它知道的只有"订单"这个词的字面意思,除此之外一片空白。
第三个是模型无法主动执行操作。它不能替你调用支付接口,不能给你发工单,不能往数据库写一条记录。它能做的只是输出文本,哪怕它知道应该做什么,也没有"手"去执行。
这三点本质上是同一个问题:模型缺乏和真实世界连接的通道。Function Call就是把这个通道打通的标准做法。
1.2 Function Call的本质:模型不执行程序,它只表达意图
很多人第一次接触Function Call都会混淆一个概念:以为是模型在执行函数。实际上完全不是这么回事。
模型的产物永远是文本。Function Call的整个流程是这样的:
用户说"帮我查一下订单A1001的状态"。宿主程序把两样东西一起发给模型:用户的消息文本,外加一份工具清单。工具清单里写清楚了每个函数叫什么名字、是干什么的、需要哪些参数、参数是什么类型。模型读完这份清单之后,在生成回复前先做一个判断:"这个问题我需要调用queryOrderStatus这个函数才能回答"。然后它不是真的去执行这个函数,而是输出一个结构化的调用声明,大概意思是:"我要调用queryOrderStatus,参数是orderId='A1001'"。
这个结构化的声明才是关键。宿主程序解析出函数名和参数,去调用真正的Java方法,拿到字符串结果"订单A1001已发货"。接着宿主程序把这个结果作为一条工具消息回传给模型。模型拿到真实结果后,再组织成自然语言告诉用户:"您的订单A1001已发货"。
整个闭环里,模型做的始终只有一件事:理解和表达。真正干活的是我们自己的代码。这也是为什么Function Call被叫做"模型感知外部世界的眼睛和手臂"——感知靠函数返回的结果,行动靠函数背后的业务逻辑。
1.3 三种工具化路径的取舍
在Spring AI里给模型接工具,历史上试过几种方案,我都踩过一遍。
第一种是纯提示词约束。在system prompt里写"当用户询问订单状态时,请输出JSON:{"tool": "queryOrderStatus", "params": {"orderId": "..."}}"。看起来很灵活,实际很痛苦。模型偶尔会不按格式输出,偶尔会在JSON里多写一行注释,解析逻辑要写一堆容错分支。小规模demo可以玩,生产环境用这个,运维半夜能被解析异常叫醒好几次。
第二种是强制JSON模式。很多模型服务支持response_format设为json_object,但那只能保证输出是JSON,不能保证结构完全匹配。你仍然需要外部做一层映射,把JSON转成具体的调用,本质上和第一种差别不大。
第三种就是Function Call原生协议。模型服务商在API层面定义了标准的functions或tools参数,模型在推理时就明白"我有一批工具可用",并且能结构化地输出调用意图,而不是从文本里扒JSON。Spring AI从0.8.x开始正式支持这个特性,到了1.x版本推出了@Tool注解,开发体验才算真正顺滑起来。
我们拿三个维度横向对比一下:
| 方案 | 稳定性 | 开发成本 | 多轮对话支持 | 生产可用度 |
|---|---|---|---|---|
| 纯提示词约束 | 低 | 中 | 需要自己做状态维护 | 低 |
| 强制JSON模式 | 中 | 中高 | 需要自己做调度 | 中 |
| Function Call | 高 | 低 | 框架内置支持 | 高 |
我自己用下来的感受是:如果项目里只有一两个工具,三种方案都能跑;一旦工具数量上到五个以上,函数定义和参数约束会越来越复杂,没有框架级的协议支撑,代码会迅速腐化。所以这篇直接讲Spring AI的做法。
2. Spring AI项目初始化与开源模型接入
2.1 版本选择:Spring AI的版本劝退现场
Spring AI这个框架迭代非常快,版本之间的API差异大得离谱。网上搜资料经常看到两种截然不同的写法,其实都是对的,只是版本不同。
早期0.8.x系列的写法是:
ChatResponse response = chatModel.call( new Prompt( "帮我查一下订单A1001的状态", OpenAiChatOptions.builder() .withFunctionCallbacks( MethodToolCallback.builder() .toolDefinition(ToolDefinition.builder(...).build()) .toolFunction(orderService) .build() ) .build() ) );思路是把工具回调塞进每次请求的选项里,能用,但代码很冗余,而且功能回调和函数定义分开搞,心智负担重。
到了1.0.x系列,官方主推@Tool注解加ChatClient,代码清爽多了:
String answer = chatClient.prompt() .user("帮我查一下订单A1001的状态") .tools(orderService) .call() .content();一个Service Bean传进去,框架自动扫描里面标注了@Tool的方法,生成函数定义,处理调用过程。这才是工具使用的正确姿势。
我的建议是:新项目直接用Spring AI 1.0.x以上版本,别碰0.8.x。老项目要是已经在0.8.x上跑了,那篇文章就不展开了,API差异会把文章拉得很长。
另外提醒一句:Spring AI的版本号带有M后缀(M1、M2、M3)的属于里程碑版本,API可能随时变,生产环境尽量选GA正式发布版。热词里追到了Spring AI 2.0,迭代确实快,你在落地时建议直接查官方文档确认当前版本对应的配置前缀,下面样例以1.0.x为准。
2.2 依赖引入与配置:让项目先跑起来
因为我们用的是开源模型,需要把Spring AI的OpenAI兼容通道打开。这是目前接入成本最低的方式,因为大量本地模型服务(vLLM、Ollama、LM Studio、Xinference)都实现了OpenAI兼容的HTTP接口。
引入依赖分两步,先用BOM给出统管版本,再加starter:
<dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-bom</artifactId> <version>1.0.0-M6</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement>注意,如果是GA版本,这里的version直接写正式版本号,比如1.1.0。BOM的意义是保证spring-ai相关依赖之间版本一致,避免出现API错配的诡异问题。
然后是starter:
<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-openai-spring-boot-starter</artifactId> </dependency>这个starter能用的前提是模型服务端提供了OpenAI兼容的/v1/chat/completions接口。我本地用Ollama跑Qwen2.5系列,加上Ollama的OpenAI兼容层,完全无障碍。vLLM服务同样支持,而且控制并发的能力更强。
配置文件application.yml如下:
spring: ai: openai: base-url: http://localhost:8000/v1 api-key: not-needed chat: options: model: qwen2.5:14b temperature: 0.1这里几个参数的意图说一下:
base-url指向本地模型服务的根地址。Ollama默认是11434端口,vLLM可能监听8000端口,具体看你启动时的配置。api-key随意填一个占位符就行,本地服务不做鉴权。model填你本地拉取的模型名称,比如qwen2.5:14b、llama3.2:3b之类。temperature设为0.1,这一点特别重要。
为什么要压这么低的温度?因为Function Call本质是一个结构化的决策任务,模型需要稳定地输出"是否调用函数、调用哪个、参数填什么"。温度越高,随机性越强,模型越容易偏离工具调用协议,可能出现"用户问订单,模型不调用工具,直接编一个状态"的情况。我一开始用默认的0.7,连续遇到模型自己编物流信息,把temperature压到0.1之后,这种情况基本绝迹。
2.3 本地开源模型接入的两种方式
Spring AI接入本地模型有两条路。一条是直接走Ollama的ChatModel,配置文件换掉即可:
spring: ai: ollama: base-url: http://localhost:11434 chat: options: model: qwen2.5:14b依赖也从openai starter换成:
<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-ollama-spring-boot-starter</artifactId> </dependency>另一条就是前面写的OpenAI兼容接口方式。二者的本质区别在于:Ollama专用starter会使用Ollama自定义的某些请求结构,OpenAI兼容方式则是走标准的OpenAI协议。对我个人来说,更推荐OpenAI兼容方式,因为将来切换模型服务商时,base-url一变就能换,代码和配置文件大部分不用动,灵活得多。
3. 核心细节解析:用@Tool注解定义模型手中的"工具"
3.1 为什么推荐@Tool而不是手动拼JSON Schema
Spring AI早期版本需要手动构建ToolDefinition,里面要写清楚函数的名称、描述、参数类型、每个参数字段的意义。后来官方提供了@Tool注解,直接在方法上标注,框架自动把你的Java方法描述成一个标准的function定义,再发送给模型。
这个改进看起来只是减少了一些样板代码,实际价值比想象中大。手动拼JSON Schema的时候,参数一多就容易漏字段、写错类型。而@Tool由字节码反射自动生成Schema,Java方法里的参数名、类型、注释都能被读取,准确率远超手写。你只要把精力花在描述写得准确上,剩下的框架搞定。
3.2 一个完整工具方法的正确写法
下面是我在电商客服项目里真实用到的工具方法,直接拿来做范本:
import org.springframework.ai.tool.annotation.Tool; import org.springframework.stereotype.Service; @Service public class OrderQueryService { @Tool(description = "根据订单号查询订单的当前状态,包括待付款、已付款、已发货、已完成、已取消。") public String queryOrderStatus(String orderId) { // 真实项目中这里会调用订单服务或者查询数据库 return orderService.queryStatusByOrderId(orderId); } @Tool(description = "根据物流单号查询最新的物流轨迹信息,返回最近一条物流记录的时间和位置。") public String queryLogistics(String logisticsNo) { return logisticsService.queryLatestTrace(logisticsNo); } @Tool(description = "根据商品SKU ID查询当前可售库存数量,返回一个整数。") public Integer queryStock(String skuId) { return stockService.getAvailableStock(skuId); } }三个方法对应客服场景中最常用的三个查询类动作。这里面的门道全在description里。
模型决定是否调用某个工具,靠的就是description给它的语义描述。所以description第一句话必须是"做什么事情",最好再补充一个返回值特征的提示。比如queryLogistics结尾那句"返回最近一条物流记录的时间和位置",就是在告诉模型"调用这个函数你就可以拿到时间和位置,直接用这个回答用户",这会显著提高模型调用工具的意愿和准确性。
反过来,把description写成"物流查询业务服务方法",模型大概率懵,不知道这个函数能回答什么问题,调用率直线下降。
参数方面,尽量用String、Integer这类基础类型。Spring AI底层按照JavaBean和COLLECTION类型来解析参数,如果你的参数是一个复杂对象,务必确保对象的字段类型和JSON Schema能对得上。我在项目里吃过亏:方法参数用了自定义的QueryParam对象,字段是LocalDateTime,结果模型产出的参数值是字符串"2025-06-01 10:30:00",而框架解析LocalDateTime时直接报错,连个友好的错误信息都没有。后来把所有方法参数打平,全改成基础类型,一次通过。
另外注意,@Tool的方法不应该有重载。不要写两个同名方法一个查订单、一个查物流,工具名重复会让模型分不清该用哪个。每个方法职责单一,工具名用清晰的动作加对象命名,queryOrderStatus比doQuery好懂得多。
3.3 多个工具的注册与注入方式
方法定义好了,接下来是怎么让模型看到它们。在Spring AI 1.x里,方式很简单:把标注了@Tool的Service Bean直接传给ChatClient的.tools()方法即可。
项目里一般这样组织:
@Configuration public class AiConfig { private final ChatClient.Builder chatClientBuilder; public AiConfig(ChatClient.Builder chatClientBuilder) { this.chatClientBuilder = chatClientBuilder; } @Bean public ChatClient kfChatClient(OrderQueryService orderQueryService) { return chatClientBuilder .defaultSystem("你是电商客服助手,回答要简洁,使用顾客能看懂的语言。") .defaultTools(orderQueryService) .build(); } }这里把OrderQueryService注册成ChatClient的默认工具,之后所有通过这个ChatClient发起的对话,模型都能感知到这三个工具的存在。
一次对话临时需要用额外工具时,也可以在调用现场追加:
String answer = chatClient.prompt() .user("我的订单A1001现在到哪了?") .tools(orderQueryService, stockQueryService) .call() .content();要注意:手动传入的tools是追加还是覆盖,各版本行为不完全一致。稳妥起见,线上代码以defaultTools为主,临时追加工具的场景我遇到过工具覆盖导致模型看不到默认工具的怪问题,排查了很久才发现是版本行为差异,建议现场追加之前在测试环境看一眼。
4. 实操过程与核心环节实现:电商客服场景完整落地
4.1 场景设计与工具规划
为了演示一个能直接搬到业务里的案例,我把场景设定成"电商客服助手"。用户可能问三类问题:订单状态、物流轨迹、库存。对应三个工具,刚好覆盖Function Call的典型能力。
特别说明一点:工具不是越多越好。每个工具定义都会转成JSON Schema文本,拼进请求的上下文里,模型每次推理都要"读"一遍。工具越多,上下文占用越大,留给用户对话的空间越少。以我的经验,单次请求注册的工具不要超过十几个,能用三个解决的绝不放第五个。如果你有二十个工具,考虑按业务域拆成多个ChatClient,各自注册最常用的工具,而不是一股脑全塞进去。
4.2 完整代码实现与逐段说明
服务层我们已经在前文定义完了,这里把Controller和调用入口补全:
@RestController @RequestMapping("/kf") public class CustomerServiceController { private final ChatClient kfChatClient; public CustomerServiceController(ChatClient kfChatClient) { this.kfChatClient = kfChatClient; } @PostMapping("/chat") public ResponseEntity<String> chat(@RequestBody UserMessage userMessage) { String text = userMessage.text(); if (text == null || text.isBlank()) { return ResponseEntity.badRequest().body("消息内容不能为空"); } String answer = kfChatClient.prompt() .user(text) .call() .content(); return ResponseEntity.ok(answer); } }这个Controller够简单,实际的走向是:用户点击聊天窗口发送"帮我查一下订单A1001",请求进来后交给ChatClient。ChatClient内部把用户消息、系统提示、工具定义一起发给模型服务,模型判断需要调用queryOrderStatus。Spring AI用反射调起OrderQueryService.queryOrderStatus方法,拿到真实状态,把结果回传给模型,最终生成"您的订单A1001已经发货,预计明后天送达"这类回答。
整个过程用户感知不到工具调用的存在。这才是Function Call追求的体验:模型处理细节,用户拿到结果。
为了验证工具确实被打到了,我通常会在Service方法里打日志:
log.info("[Function Call] 调用工具 queryOrderStatus,参数 orderId={}", orderId);上线初期这是一个非常重要的可观测点。有这个日志,配合下行链路追踪,就能完全看清模型什么时候、以什么参数、调了哪把工具。
4.3 多工具并行调用与结果组织
用户可能一次性问好几个问题:"帮我查订单A1001的发货状态,顺便看看商品SKU8823还有没有货。"
遇到这种复合问题,模型可能同时输出多个tool_calls。Spring AI从1.x开始支持工具并行调用:框架按顺序执行每个工具,再把所有结果一起回传给模型,让模型综合成一段连贯的回答。底层并行还是串行执行,框架内有一个工具执行器,顺序执行,但多工具的定义本身互不干扰。
实测中,多工具并行调用的成功率与模型能力强相关。上百亿参数的模型经常能正确切开多个意图,小参数模型常常只识别第一个问题,漏掉第二个。项目里如果对复合问题的处理要求高,先把模型换成14B以上级别的,比任何提示词都管用。
4.4 调用链路追踪:看看模型到底发送了什么
很多同学调试Function Call时最头疼的是"模型到底有没有收到工具定义?它到底返回了什么?"
Spring AI的网络层是可以抓到请求体的。最简单的做法,在application.yml里开启详细日志:
logging: level: org.springframework.ai.chat.client: DEBUG org.springframework.ai.chat.model: DEBUG也可以直接在ChatClient的Customizer里做拦截器,把发送和接收的RawContent打出来。这样你能亲眼看到发给模型的payload里有一个functions数组,数组里每项包含name、description和parameters。
模型返回的响应里,如果决定调用工具,会有一个tool_calls字段,内容类似:
tool_calls: [{ "id": "call_abc123", "type": "function", "function": { "name": "queryOrderStatus", "arguments": "{\"orderId\":\"A1001\"}" } }]看到这个结构,就说明模型成功理解了函数并准备调用。如果这个字段一直不出现,说明工具定义或提示词那边出了问题,直接对照第5节的排查表找原因。
5. 常见问题与排查技巧实录
5.1 函数压根没被调用
这是出现频率最高的问题。模型对用户的问题完全没有调用工具的意图,直接凭想象回答。主要原因有三个:
第一个是模型能力不够。小参数模型(7B以下)的Function Call能力普遍偏弱,不是说完全不能用,而是在多意图和复杂参数场景下经常掉链子。我的建议是最低用14B级别的开源模型做函数调用类的生产场景,7B及以下更适合做纯文本对话和简单分类。
第二个是工具description写得不够清楚。你把description写成"查询订单服务"和"根据订单号查询订单的当前状态,包括待付款、已付款、已发货、已完成、已取消",模型的理解成本完全不同。后者的效果天差地别。
第三个是上下文被截断。工具定义本身就占上下文,如果用户的聊天历史很长,最早的几条消息可能会被截掉,但工具定义一般紧跟系统消息,被截的概率不大。倒是对话历史过长会把模型的注意力带偏,让它忘记"还有工具可以调用",这类情况建议限制一次性传入的历史轮数。
5.2 参数绑定失败:模型给出的参数无法映射到方法
模型返回工具的arguments是JSON字符串,例如{"orderId":"A1001"}。框架要把这个JSON绑定到Java方法的形参上,绑定失败最典型的情况有三个:
参数名对不上。模型基于你定义的description和字段名生成JSON键名,如果你方法定义的形参叫oid,而description里说的是"订单编号",模型可能会生成orderId,键对不上,绑定直接失败。所以形参名本身就要起得语义清晰,toString调试时一眼能对上字段。
复杂对象字段类型不兼容。前面提过的LocalDateTime问题就是典型案例。建议方法参数全部使用基础包装类型,嵌套对象尽量压平。
参数缺失。模型只传了部分参数,例如方法要求orderId和userId两个参数,模型只给了orderId。这种情况通常是你把两个参数放在一个description里,模型以为只填一个就行。解决方法是把每个参数单独一行描述清楚,让模型完整理解必填项。
5.3 执行层的真实工程问题
函数是宿主程序执行的,执行层出了错,模型根本不知道,只会把报错信息当成普通文本继续组织回答,甚至可能把一段Java异常堆栈当作答案直接输出给用户。这是线上最尴尬的场面之一。
解决方案有三条:
一是工具方法内部做好try/catch,任何异常都转成"查询失败,请稍后再试"这种安全的自然语言结果返回,而不是把异常抛给框架。
二是给工具执行加上超时控制。如果工具是调用第三方HTTP接口,外部服务慢到爆炸,你的应用线程也被拖死,最终用户等来一个大版本超时。Spring AI对工具执行没有默认超时保护,你需要自己在方法里加。
@Tool(description = "根据物流单号查询最新物流轨迹") public String queryLogistics(String logisticsNo) { return CompletableFuture .supplyAsync(() -> logisticsClient.queryLatestTrace(logisticsNo)) .get(3, TimeUnit.SECONDS) ; // 超时快速失败 }三是线程安全。ToolCallback和Service Bean默认是单例,多个会话会同时执行同一个工具方法。方法内的局部变量没有并发问题,但只要有成员变量存储了会话相关的状态,就会串会话。工具方法里严禁使用可变的成员变量,几个会话一交叉,状态全错乱。
5.4 避坑速查表
| 症状 | 最可能的原因 | 排查与解决 |
|---|---|---|
| 模型从不调用工具,直接瞎编 | dataset温度过高或模型能力偏弱 | temperature降到0.1;换14B以上模型 |
| 模型调用了一个不存在的工具名 | 工具名混乱或方法重载 | 每个方法唯一命名,避免重名 |
| arguments解析失败 | 形参名或类型不匹配 | 全部使用基础类型,形参名语义化 |
| 工具执行抛异常,用户看到一堆报错 | 工具方法没有异常兜底 | 内部try/catch,对外返回安全文本 |
| 工具结果明明返回了,模型还是答非所问 | 工具描述里没有说明返回值含义 | 在description里补充"返回XX,可用于回答用户" |
| 多轮对话后工具调用失效 | 上下文过长,模型注意力漂移 | 控制历史轮数,或压缩历史 |
| 并发环境下结果互相串 | 工具方法里使用了成员变量 | 全部用局部变量,保持无状态 |
排查工具调用时,最有力的抓手就是看日志。把HTTP层面的请求响应打开,你立刻能分清两类问题:请求里没有functions定义,是工具注册层的问题;请求里有函数但模型没返回tool_calls,是模型推理层的问题。两个方向完全不同,排查起来就有头绪了。
我在实际项目里还有个习惯:准备一个最小的丰调测试页面,不接任何业务逻辑,直接发一条"你有几个工具可用?分别是什么?"让模型自述工具清单。模型只要能把工具名称和用途准确说出来,说明工具注册和传递链路是通的;如果连这个都说不清楚,再往下查工具的description和模型能力。这个自述测试3分钟搞定,比盯着日志猜半天高效得多。
整个Function Call落地下来,我最大的感触是:这项技术把"模型的聪明"和"代码的可靠"真正拆开了。模型负责理解意图、拆解任务,代码负责执行和兜底,各干各擅长的事。把工具描述写得像给新人同事写交接文档一样清楚,模型的表现就会稳定得多。后续在这个基础上,你能继续扩展RAG检索、数据库查询、工单创建等等工具,客服机器人的能力圈会越来越大。到时候再回来想,Function Call就是那张把所有外部能力串起来的调度网。