尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Spring AI Function Call实战:让大模型自动查订单查物流

Spring AI Function Call实战:让大模型自动查订单查物流 纯手工把一个客服项目从模型只会聊天调到模型会自己查订单、查物流的状态前后折腾了快两周。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参数是orderIdA1001。这个结构化的声明才是关键。宿主程序解析出函数名和参数去调用真正的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给出统管版本再加starterdependencyManagement dependencies dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-bom/artifactId version1.0.0-M6/version typepom/type scopeimport/scope /dependency /dependencies /dependencyManagement注意如果是GA版本这里的version直接写正式版本号比如1.1.0。BOM的意义是保证spring-ai相关依赖之间版本一致避免出现API错配的诡异问题。然后是starterdependency groupIdorg.springframework.ai/groupId artifactIdspring-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 groupIdorg.springframework.ai/groupId artifactIdspring-ai-ollama-spring-boot-starter/artifactId /dependency另一条就是前面写的OpenAI兼容接口方式。二者的本质区别在于Ollama专用starter会使用Ollama自定义的某些请求结构OpenAI兼容方式则是走标准的OpenAI协议。对我个人来说更推荐OpenAI兼容方式因为将来切换模型服务商时base-url一变就能换代码和配置文件大部分不用动灵活得多。3. 核心细节解析用Tool注解定义模型手中的工具3.1 为什么推荐Tool而不是手动拼JSON SchemaSpring AI早期版本需要手动构建ToolDefinition里面要写清楚函数的名称、描述、参数类型、每个参数字段的意义。后来官方提供了Tool注解直接在方法上标注框架自动把你的Java方法描述成一个标准的function定义再发送给模型。这个改进看起来只是减少了一些样板代码实际价值比想象中大。手动拼JSON Schema的时候参数一多就容易漏字段、写错类型。而Tool由字节码反射自动生成SchemaJava方法里的参数名、类型、注释都能被读取准确率远超手写。你只要把精力花在描述写得准确上剩下的框架搞定。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 ResponseEntityString 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就是那张把所有外部能力串起来的调度网。
返回列表