Spring AI Function Calling 实战:从注册到异常处理完整落地
发布时间:2026/10/29 17:47:38 锦皓数字建站

1. 为什么 Function Calling 值得单独拿出来讲Function Calling 这个词这两年在 Java 后端圈子里出现的频率越来越高尤其是 Spring AI 发布之后很多原本只写 CRUD 的兄弟也开始琢磨怎么把大模型能力接进自己的业务系统。但我在实际带项目和帮人排查问题的过程中发现大部分人第一次接触 Function Calling 时都会陷入一个误区以为它就是个“让 AI 调接口”的玩具功能跑通一个 demo 就完事了。真正落到生产环境里参数怎么传、异常怎么兜、多轮对话里上下文怎么保持、模型返回的 JSON 解析失败怎么办这些问题一个都绕不开。这篇内容我打算从零开始把 Spring AI 体系下 Function Calling 的完整落地路径拆开讲清楚。不管你是刚接触 Spring Boot 不久的新人还是已经写过几个 AI 接口但总觉得不够稳的老手都能从里面找到可以直接抄作业的代码结构和踩坑经验。核心会围绕Function Calling的注册机制、参数映射、调用链路、异常处理这几个关键环节展开中间会穿插Spring Boot的自动配置原理和MySQL做持久化的实际案例。读完之后你至少能做到两件事第一自己搭一个能跑通完整链路的 Function Calling 服务第二知道哪些地方容易出问题以及怎么提前规避。我个人的习惯是学任何新东西先搞清楚它解决的是什么问题然后再看它怎么解决。Function Calling 本质上解决的是“大模型如何可靠地触发外部业务逻辑”这个问题。没有它的时候你只能让模型输出一段文本然后自己写正则去解析再手动路由到对应的 Service 方法。这种方式在简单场景下能用但一旦参数变多、模型输出格式不稳定维护成本就会指数级上升。Function Calling 把“模型决定调什么”和“代码决定怎么执行”这两件事解耦了模型只负责输出结构化的调用意图具体的执行逻辑仍然牢牢握在 Java 代码手里。这个设计思路我觉得是整个功能里最值得品味的地方。2. Spring AI 中 Function Calling 的整体设计思路2.1 核心角色拆解谁在什么时候做了什么在 Spring AI 的体系里Function Calling 的链路可以拆成四个角色调用发起方你的业务代码、模型比如通义千问、GPT 系列等、函数注册中心Spring 容器里的 Bean、函数执行器实际处理业务逻辑的 Java 方法。这四个角色的协作流程大致是这样的业务代码把用户输入和可用函数列表一起发给模型模型判断是否需要调用函数如果需要就返回一个包含函数名和参数的调用请求Spring AI 拿到这个请求后去注册中心找到对应的 Bean反射调用目标方法把执行结果再回传给模型模型根据结果生成最终的自然语言回复。这个流程听起来不复杂但里面有几个关键设计点值得注意。第一函数列表是随每次请求一起发给模型的也就是说模型并不知道你系统里有哪些函数它只知道你这次告诉它的那些。这意味着你可以在不同场景下动态控制哪些函数对模型可见这是一个很实用的权限控制手段。第二模型返回的函数调用请求是结构化的通常包含函数名和 JSON 格式的参数Spring AI 负责把这个 JSON 反序列化成 Java 对象。第三函数执行结果会作为一条特殊消息追加到对话历史里模型看到结果后再决定是继续调用其他函数还是直接生成回复。我画不出图但你可以想象成一条流水线输入进来模型判断路由到函数执行结果回传模型再判断直到不需要调函数为止。这个循环在 Spring AI 里是自动处理的你不需要手动写 while 循环去轮询。2.2 为什么选 Spring AI 而不是自己手搓有人可能会问Function Calling 的协议又不复杂我自己用 HTTP 客户端调模型接口自己解析 JSON自己反射调用方法不也行吗理论上当然可以但实际做下来你会发现有几个地方特别烦。首先是不同模型厂商的 Function Calling 协议格式不完全一样字段名、嵌套结构、错误码都有差异你要为每个厂商写一套适配逻辑。其次是多轮对话中函数调用结果的拼接方式不同模型的期望格式也不同。再就是 Spring AI 帮你处理了函数注册、参数类型转换、异常包装这些琐碎但容易出错的事情。用 Spring AI 的另一个好处是它和 Spring Boot 的生态无缝集成。你的函数就是一个普通的Bean或者Component依赖注入、AOP、事务管理这些全都能直接用。比如你的函数需要查 MySQL直接注入JdbcTemplate或者 MyBatis 的 Mapper 就行不需要额外搞一套初始化逻辑。这一点在真实项目里省下来的时间远超你的想象。2.3 函数注册的两种方式与选型建议Spring AI 里注册函数主要有两种方式。一种是用Bean定义一个Function、Supplier或者Consumer类型的 BeanSpring AI 会自动扫描并注册。另一种是通过FunctionCallbackWrapper手动包装这种方式更灵活可以自定义函数名、描述和参数 schema。我个人的建议是如果你的函数逻辑比较简单参数就是几个基本类型用Bean方式最省事如果参数是复杂的嵌套对象或者你需要对模型隐藏某些参数那就用FunctionCallbackWrapper手动控制。这里有一个容易踩的坑函数名默认取的是 Bean 的名称如果你用Bean方式注册Bean 名就是方法名。模型看到的函数名就是这个所以命名要清晰不要用func1、test这种。另外函数描述非常重要模型是根据描述来判断什么时候该调用这个函数的描述写得含糊模型就可能在该调的时候不调或者不该调的时候乱调。我一般会把描述写成“当用户询问 XXX 时调用此函数参数 YYY 表示 ZZZ”这种格式实测下来命中率明显更高。3. 从零搭建一个可运行的 Function Calling 服务3.1 环境准备与依赖引入先把基础环境列一下。JDK 用 17 或以上Spring Boot 用 3.2 以上版本Spring AI 的版本我写这篇内容时用的是 1.0.0-M6 左右的里程碑版本不同版本 API 可能有微调你以官方文档为准。构建工具用 Maven 就行Gradle 也可以看个人习惯。数据库用 MySQL 8.0主要是演示函数里怎么查库。Maven 依赖核心是这两个spring-ai-core和具体模型厂商的 starter。如果你用的是通义千问就引spring-ai-alibaba-spring-boot-starter或者对应的 dashscope starter。这里要注意版本对齐Spring AI 的里程碑版本之间 API 变动比较频繁不要混用不同版本的 starter。dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-core/artifactId version1.0.0-M6/version /dependency dependency groupIdcom.alibaba.cloud.ai/groupId artifactIdspring-ai-alibaba-spring-boot-starter/artifactId version1.0.0-M6.1/version /dependency配置文件里把模型相关的 key 和 endpoint 配好这部分不同厂商差异较大照着官方示例填就行。我建议把模型配置单独放在一个application-ai.yml里方便不同环境切换。3.2 定义第一个函数查询用户订单假设我们要做一个电商客服场景用户问“我的订单到哪了”模型需要调用一个查订单的函数。先定义请求和响应对象public record OrderQueryRequest(String orderNo, String userId) {} public record OrderQueryResponse(String orderNo, String status, String logistics, String estimatedArrival) {}然后写函数实现。这里我用FunctionOrderQueryRequest, OrderQueryResponse的形式Configuration public class OrderFunctionConfig { Bean Description(根据订单号和用户ID查询订单状态和物流信息。当用户询问订单进度、物流状态时调用此函数。) public FunctionOrderQueryRequest, OrderQueryResponse queryOrder(OrderService orderService) { return request - { Order order orderService.findByOrderNoAndUserId(request.orderNo(), request.userId()); if (order null) { return new OrderQueryResponse(request.orderNo(), NOT_FOUND, null, null); } return new OrderQueryResponse( order.getOrderNo(), order.getStatus(), order.getLogisticsInfo(), order.getEstimatedArrival() ); }; } }注意Description注解这个就是给模型看的函数说明。我前面说过描述很重要这里再强调一次描述里要包含“什么时候调用”和“参数含义”两个信息。上面这个描述里“当用户询问订单进度、物流状态时调用”就是调用时机“根据订单号和用户ID”就是参数说明。3.3 把函数挂到 ChatClient 上Spring AI 里发起对话主要用ChatClient。把函数挂上去的方式有几种我常用的是在构建ChatClient时通过defaultFunctions指定或者在单次请求时通过functions动态指定。动态指定的好处是不同接口可以暴露不同的函数集合。RestController public class ChatController { private final ChatClient chatClient; public ChatController(ChatClient.Builder builder, OrderFunctionConfig orderFunctionConfig) { this.chatClient builder .defaultFunctions(queryOrder) .build(); } PostMapping(/chat) public String chat(RequestBody ChatRequest request) { return chatClient.prompt() .user(request.message()) .call() .content(); } }这里defaultFunctions(queryOrder)里的字符串就是 Bean 名称。Spring AI 会自动把这个函数的信息转换成模型能理解的 schema随请求一起发出去。3.4 数据库层用 MySQL 存订单数据函数里查订单最终要落到数据库。我用 MyBatis 做演示表结构大概这样CREATE TABLE order_info ( id bigint NOT NULL AUTO_INCREMENT, order_no varchar(64) NOT NULL, user_id varchar(64) NOT NULL, status varchar(32) NOT NULL, logistics_info varchar(512) DEFAULT NULL, estimated_arrival varchar(64) DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;Mapper 接口和 XML 就不贴了常规写法。这里有一个细节要注意函数执行是在模型调用链路里同步发生的如果数据库查询慢整个对话响应就会慢。所以函数里的查询一定要走索引order_no和user_id上最好有联合索引。另外建议给函数执行加一个超时控制避免数据库卡住导致整个请求挂死。4. 参数映射、异常处理与多轮对话的实战细节4.1 模型返回的参数是怎么变成 Java 对象的这是很多人第一次用 Function Calling 时最困惑的地方。模型返回的其实是一段 JSONSpring AI 内部用 Jackson 把它反序列化成你函数定义的入参类型。这里有几个关键点第一你的入参类型必须是可反序列化的record 或者有默认构造器的 POJO 都行。第二字段名要和你在函数 schema 里声明的一致Spring AI 会根据泛型信息自动生成 schema字段名默认取 Java 字段名。第三如果模型返回的 JSON 里缺少某个字段反序列化后该字段就是 null你的函数实现里要做好空值判断。我遇到过一个典型问题模型有时候会把数字类型的参数返回成字符串比如把123返回成123。Jackson 默认能处理这种转换但如果你的字段是int而不是Integer且模型返回了空字符串就会抛异常。所以我的习惯是函数入参尽量用包装类型并且在函数实现里对每个参数做校验。4.2 异常处理函数抛异常了怎么办函数执行过程中抛异常是必然会遇到的情况。数据库连不上、参数不合法、业务逻辑校验失败这些都会抛异常。Spring AI 默认的行为是把异常包装后返回给调用方但这样模型就收不到函数执行结果了对话会中断。更好的做法是在函数内部捕获异常返回一个包含错误信息的响应对象让模型知道发生了什么然后模型可以用自然语言告诉用户。Bean Description(根据订单号查询订单状态。当用户询问订单进度时调用。) public FunctionOrderQueryRequest, OrderQueryResponse queryOrder(OrderService orderService) { return request - { try { if (request.orderNo() null || request.orderNo().isBlank()) { return new OrderQueryResponse(null, INVALID_PARAM, 订单号不能为空, null); } Order order orderService.findByOrderNoAndUserId(request.orderNo(), request.userId()); if (order null) { return new OrderQueryResponse(request.orderNo(), NOT_FOUND, 未找到该订单, null); } return new OrderQueryResponse(order.getOrderNo(), order.getStatus(), order.getLogisticsInfo(), order.getEstimatedArrival()); } catch (Exception e) { log.error(查询订单失败, orderNo{}, request.orderNo(), e); return new OrderQueryResponse(request.orderNo(), ERROR, 系统繁忙请稍后重试, null); } }; }这样模型拿到的是一个正常的函数返回结果它可以根据status字段决定怎么回复用户。实测下来这种方式比让异常直接抛出去稳定得多。4.3 多轮对话中函数上下文的保持Function Calling 在多轮对话里的行为需要特别注意。假设用户先问“我的订单到哪了”模型调用查订单函数返回结果后模型回复“您的订单正在配送中”。然后用户接着问“那大概什么时候到”这时候模型需要知道上一轮查的是哪个订单。如果订单号是用户在第一轮提供的那没问题对话历史里有。但如果订单号是模型从上下文推断出来的就要确保这个信息被正确保留在对话历史里。Spring AI 的ChatClient默认会把函数调用和结果都追加到对话历史中所以多轮对话基本能正常工作。但有一个坑如果你用的是无状态的 HTTP 接口每次请求都新建一个ChatClient那对话历史就丢了。解决办法是把对话历史存在服务端用conversationId关联每次请求时把历史消息一起传给模型。Spring AI 提供了ChatMemory接口来做这件事可以基于内存、Redis 或者数据库实现。生产环境我建议用 Redis设置合理的过期时间。4.4 函数数量多了怎么管理当你的系统里有几十个函数时全部塞给模型会导致两个问题一是 token 消耗大二是模型选择困难容易调错函数。我的做法是按业务域分组不同接口暴露不同的函数集合。比如客服接口只暴露订单和售后相关的函数内部管理接口暴露数据统计相关的函数。Spring AI 支持在单次请求时通过functions方法动态指定函数列表这就很灵活了。另外函数命名要有规范我一般用“动词名词”的形式比如queryOrder、cancelOrder、refundOrder这样模型更容易理解。避免用缩写和拼音模型对拼音的理解能力很有限。5. 常见问题排查与性能优化经验5.1 模型不调用函数怎么办这是最高频的问题。模型不调函数通常有三个原因函数描述不够清晰、参数 schema 有问题、或者模型本身的能力限制。排查顺序我一般是这样的先看日志里发给模型的函数 schema 长什么样确认字段名和描述都正确然后手动构造一个明显该调函数的输入看模型返回什么如果模型返回的是自然语言而不是函数调用请求那就是描述的问题把描述改得更具体一些。还有一个容易被忽略的点有些模型对函数调用的支持需要显式开启比如在请求参数里设置tool_choice为auto或者required。Spring AI 的默认配置一般是auto但如果你用的模型版本较老可能需要手动设置。5.2 函数调用结果太长导致 token 超限如果函数返回的数据量很大比如查询订单返回了完整的物流轨迹几十条记录这些内容都会作为消息追加到对话历史里很容易把 token 撑爆。解决办法是在函数内部做裁剪只返回模型需要的关键信息。比如物流轨迹只返回最近三条订单商品只返回名称和数量。模型不需要知道所有细节它只需要足够生成合理回复的信息。我一般会在函数响应对象里加一个summary字段把关键信息浓缩成一句话模型优先用这个字段生成回复。完整数据可以另外存起来如果用户追问再通过其他方式展示。5.3 并发场景下的线程安全问题函数 Bean 默认是单例的如果函数实现里用了可变的成员变量就会有线程安全问题。我见过有人在函数里用一个List缓存中间结果结果并发调用时数据串了。函数实现应该是无状态的所有需要的数据要么从参数来要么从注入的 Service 来不要在函数对象里存状态。另外数据库连接池的配置也要注意。Function Calling 的调用链路比普通接口长一个对话请求可能触发多次函数调用每次调用都占一个数据库连接。如果连接池太小高并发时会出现等待。建议把连接池的最大连接数适当调大并且给函数里的数据库操作设置合理的超时时间。5.4 常见问题速查表问题现象可能原因排查方向解决建议模型不调用函数描述不清晰或 schema 错误查看发给模型的函数定义完善描述检查字段类型函数调用参数为 null模型未提取到参数检查对话历史是否包含参数信息在提示词中引导用户提供参数反序列化失败模型返回格式与 schema 不匹配打印模型原始返回内容入参用包装类型加容错逻辑对话响应慢函数内数据库查询慢检查 SQL 执行计划加索引设置查询超时token 超限函数返回数据过长统计对话历史 token 数裁剪返回数据只保留关键信息多轮对话丢失上下文对话历史未持久化检查 ChatMemory 配置用 Redis 存储对话历史6. 一些个人体会和后续扩展方向Function Calling 这个功能我前前后后在不同项目里用了大半年最大的感受是它不是一个“配好就能用”的功能而是一个需要持续调优的工程问题。函数描述怎么写、参数怎么设计、异常怎么兜底、上下文怎么管理这些都没有标准答案得根据你的业务场景和用的模型反复试。我建议刚开始的时候函数数量控制在五个以内把链路跑通、把异常处理做好再逐步增加。后续如果要往深了做有几个方向可以考虑。一是把函数调用日志完整记录下来包括模型返回的原始请求、函数执行耗时、执行结果这些数据可以用来分析哪些函数经常被调用、哪些从来没被调用过从而优化函数集合。二是结合 Spring Boot 的监控体系把函数调用的成功率、平均耗时暴露成指标接入现有的监控面板。三是探索多函数编排让模型在一次对话中连续调用多个函数完成复杂任务比如先查订单再查物流再查售后政策。这些在 Spring AI 的框架下都有对应的支持只是需要更多的实践来打磨细节。数据库这块如果函数调用量大了可以考虑把函数执行结果做一层缓存同样的参数在短时间内重复查询直接走缓存。但要注意缓存失效策略订单状态这种数据变化频繁缓存时间不能太长。我一般设 30 秒到 1 分钟具体看业务容忍度。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。