ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Spring AI Function Calling 实战:Java 接入大模型工具调用

Spring AI Function Calling 实战:Java 接入大模型工具调用 1. 为什么 Function Calling 值得单独拿出来讲Function Calling 这个词这两年在 AI 应用开发圈子里出现的频率越来越高但很多人第一次听到会有点懵模型不是负责聊天和生成文本的吗怎么还跟“调用函数”扯上关系了说白了Function Calling 就是让大模型在对话过程中能够主动判断“这个问题我需要去查一下数据库”或者“这个请求我得调用一个外部接口才能回答”然后输出一个结构化的调用意图由我们后端的 Java 代码去真正执行再把结果喂回给模型让它组织成自然语言回复给用户。这件事的价值在于它把大模型从“只会聊天”变成了“能干活”。比如用户问“帮我查一下订单号 20240512 的物流状态”纯聊天模型只能瞎编或者告诉你它不知道而接了 Function Calling 之后模型会输出一个类似getOrderLogistics(orderId20240512)的调用请求你的 Spring Boot 服务拿到这个请求去查 MySQL把真实结果返回模型再总结成一句人话。整个过程用户感知不到背后发生了什么但答案是真的、可用的。我这次用 Spring AI 把这套链路完整跑了一遍从环境搭建到工具注册、从参数校验到多轮对话状态管理踩了不少坑也总结了一些比较实用的经验。这篇文章适合已经有 Spring Boot 基础、想在自己的 Java 项目里接入大模型能力的后端开发也适合正在做 AI Agent 方向、想找一个稳定工程化落地方案的同学。下面我会把整个实现过程拆开讲包括为什么这么设计、关键参数怎么定、遇到问题怎么排查尽量让你看完能直接抄作业。2. 整体方案设计与技术选型思路2.1 为什么选 Spring AI 而不是自己裸写 HTTP 调用很多人第一反应是调用大模型不就是发个 HTTP 请求吗我用 RestTemplate 或者 WebClient 自己封装一下不就行了短期看确实可以但一旦涉及 Function Calling事情就复杂了。你需要处理工具描述的定义、模型返回的调用意图解析、多轮对话中工具调用结果的回传、不同模型厂商返回格式的差异这些如果全部手写代码量会迅速膨胀而且很难维护。Spring AI 的核心价值就在于它把这些抽象成了统一的 API。你只需要用Tool注解或者FunctionCallback注册一个 Java 方法框架会自动帮你生成符合模型要求的工具描述JSON Schema模型返回调用意图后框架也会自动路由到对应的方法执行。我实测下来同样的功能用 Spring AI 写大概能省掉百分之六七十的胶水代码而且换模型厂商的时候改动很小。选型上我建议 Spring Boot 3.x 搭配 Spring AI 1.0 以上的版本JDK 至少 17。这里有个坑要提前说Spring AI 的版本迭代非常快不同小版本之间 API 可能有破坏性变更所以一定要在pom.xml里锁定版本不要用动态版本号否则哪天构建突然编译不过你会很崩溃。2.2 工具注册的两种方式与取舍Spring AI 里注册工具主要有两种方式一种是用Tool注解直接标在方法上另一种是实现FunctionCallback接口手动构造。我两种都用过说说各自的适用场景。Tool注解方式最省事方法签名清晰参数用ToolParam描述框架自动反射生成 schema。适合工具数量不多、逻辑相对独立的场景。但它有个限制注解方式对复杂嵌套参数的支持不如手动构造灵活而且工具方法的描述文字是写死在注解里的如果你想根据配置动态调整描述就不太方便。手动实现FunctionCallback的方式更灵活你可以完全控制工具的名称、描述、参数 schema甚至可以在运行时动态生成。代价是代码量大一些需要自己构造FunctionCallback.builder()那一套。我的建议是如果工具数量在十个以内、参数结构简单直接用注解如果工具很多或者需要动态注册就用手动方式或者两者混用。2.3 数据库交互层的设计考量Function Calling 的工具方法最终大多要落到数据查询上所以 MySQL 这一层怎么设计很关键。我见过一些实现把 SQL 直接拼在工具方法里这样写起来快但后面维护会很痛苦。我的做法是工具方法只负责参数校验和调用 Service真正的数据访问交给 MyBatis 的 Mapper保持和普通业务代码一样的分层。这里有个细节值得注意工具方法的执行时间会直接影响用户等待时长。如果模型决定调用工具整个链路是“模型思考→返回调用意图→后端执行工具→结果回传→模型再思考→生成回复”工具执行慢的话用户会明显感觉到卡顿。所以工具方法里的查询一定要加索引、控制返回数据量别一个查询返回几万行模型处理不了用户也等不起。3. 环境搭建与核心依赖配置3.1 项目骨架与依赖清单我用的是一套比较标准的 Spring Boot 3.2 加 MyBatis 的结构。核心依赖除了常规的spring-boot-starter-web、mybatis-spring-boot-starter、mysql-connector-j还需要加 Spring AI 的 starter。具体是哪个 starter 取决于你接的模型我这边用的是通用的 OpenAI 兼容协议那一套因为很多模型服务都支持这个协议切换成本低。dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-openai-spring-boot-starter/artifactId version1.0.0-M6/version /dependency版本号这里我特意写了一个里程碑版本因为正式版和里程碑版在 API 上确实有差异你如果用的是别的版本工具注册那块代码可能要微调。数据库驱动建议用mysql-connector-j而不是老的mysql-connector-java后者已经停止维护了。3.2 配置文件里的关键参数application.yml里有几个参数必须配好否则要么连不上模型要么工具调用不生效。spring: ai: openai: api-key: ${AI_API_KEY} base-url: https://your-model-endpoint/v1 chat: options: model: your-model-name temperature: 0.7 datasource: url: jdbc:mysql://localhost:3306/ai_demo?useSSLfalseserverTimezoneAsia/Shanghai username: root password: ${DB_PASSWORD} driver-class-name: com.mysql.cj.jdbc.Drivertemperature这个参数在 Function Calling 场景下建议不要设太高0.7 左右比较合适。设太高模型容易“发挥”可能该调用工具的时候不调用或者参数填得乱七八糟。设太低又会让回复显得很死板。base-url记得带上/v1后缀很多兼容协议的端点都需要这个路径漏了会报 404。注意api-key 和数据库密码千万不要硬编码在配置文件里提交到代码仓库用环境变量或者配置中心注入这是基本的安全习惯。3.3 数据库表结构准备为了演示工具调用我建了一张简单的订单表。字段不用多够用就行。CREATE TABLE orders ( id bigint NOT NULL AUTO_INCREMENT, order_no varchar(32) NOT NULL, user_id bigint NOT NULL, status tinyint NOT NULL DEFAULT 0, amount decimal(10,2) NOT NULL, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_user_id (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;order_no上加了唯一索引因为工具方法里大概率会按订单号查询这个索引能显著提升查询速度。user_id上加了普通索引方便按用户维度查订单列表。字符集用utf8mb4避免以后存特殊字符出问题。4. 工具方法的实现与注册细节4.1 用 Tool 注解定义第一个工具先看一个最基础的例子查询订单状态。Component public class OrderTools { private final OrderMapper orderMapper; public OrderTools(OrderMapper orderMapper) { this.orderMapper orderMapper; } Tool(description 根据订单号查询订单的当前状态和金额) public String getOrderStatus( ToolParam(description 订单号格式为纯数字字符串) String orderNo) { if (orderNo null || !orderNo.matches(\\d)) { return 订单号格式不正确请提供纯数字订单号; } Order order orderMapper.selectByOrderNo(orderNo); if (order null) { return 未找到订单号为 orderNo 的订单; } return String.format(订单 %s 当前状态为 %s金额为 %s 元, orderNo, statusText(order.getStatus()), order.getAmount()); } }这里有几个设计上的小心思。第一参数校验放在方法开头因为模型生成的参数不一定靠谱可能传个空值或者带字母的字符串进来提前拦截能避免后面报错。第二返回值我用了自然语言字符串而不是 JSON因为工具结果最终是要给模型“读”的自然语言模型理解起来更顺也更容易组织成回复。第三找不到订单时返回的是友好提示而不是抛异常异常会导致整个调用链中断返回提示文字模型还能继续对话。4.2 工具描述怎么写才不容易翻车工具描述description是模型判断“要不要调用这个工具”的唯一依据写得好不好直接决定调用准确率。我踩过的坑是描述写得太笼统比如只写“查询订单”结果用户问“我的订单到哪了”模型有时候不调用因为它不确定这个工具能不能回答物流问题。好的描述应该包含三要素这个工具能做什么、什么情况下用、参数是什么含义。比如改成“根据订单号查询订单的状态、金额和创建时间适用于用户询问某个具体订单的详情时使用”调用准确率明显提升。参数描述也一样ToolParam里要写清楚格式要求像“订单号格式为纯数字字符串”就比“订单号”强很多。还有一个经验是工具名称尽量用动词开头、语义明确比如getOrderStatus、queryUserPoints别用orderTool1这种模型对名称也是有感知的。4.3 多工具注册与优先级控制实际项目里工具肯定不止一个这时候怎么组织就有讲究了。我一般按业务域拆成多个Component比如OrderTools、UserTools、LogisticsTools每个类里放相关的工具方法。注册的时候统一交给ChatClient。Configuration public class ChatClientConfig { Bean public ChatClient chatClient(ChatClient.Builder builder, OrderTools orderTools, UserTools userTools) { return builder .defaultSystem(你是一个电商客服助手可以帮用户查询订单和账户信息。) .defaultTools(orderTools, userTools) .build(); } }defaultSystem里我明确告诉模型它的角色和能做什么这能减少模型乱调用工具的情况。工具数量多的时候模型选择工具的准确率会下降所以系统提示词里最好把工具的能力范围说清楚。如果工具特别多可以考虑按场景拆分多个 ChatClient不同场景注册不同工具集这样每个场景下模型的选择空间小准确率更高。5. 完整调用链路与多轮对话处理5.1 一次完整的 Function Calling 发生了什么很多人对 Function Calling 的流程是模糊的我用一次实际请求把链路串一遍。用户输入“帮我查下订单 10086 的状态”后端收到后调用chatClient.prompt().user(...).call()。第一步Spring AI 把用户消息和所有已注册工具的描述一起发给模型。第二步模型判断需要调用getOrderStatus返回一个结构化的调用请求包含工具名和参数{orderNo: 10086}。第三步Spring AI 拦截到这个请求反射调用你的 Java 方法拿到返回值。第四步框架把工具返回值作为一条特殊消息追加到对话历史里再次发给模型。第五步模型基于工具返回的真实数据生成最终回复“订单 10086 当前状态为已发货金额为 299 元”。整个过程对业务代码是透明的你只需要写工具方法框架负责编排。但理解这个流程很重要因为出问题的时候你要知道是哪一步断了。5.2 多轮对话中的上下文保持单次调用简单但真实场景往往是多轮的。用户先说“查下订单 10086”模型调用工具返回结果用户接着说“那这个订单能退款吗”这时候模型需要记得上一轮的订单信息。Spring AI 里通过ChatMemory来管理对话历史。我一般用MessageWindowChatMemory限制保留最近若干条消息避免上下文无限增长导致 token 超限。Bean public ChatMemory chatMemory() { return MessageWindowChatMemory.builder() .maxMessages(20) .build(); }maxMessages设多少要看你的模型上下文窗口和消息平均长度。20 条是个比较稳妥的值大概能覆盖十轮左右的对话。设太大容易超 token设太小模型会“失忆”。这里有个坑工具调用的中间消息也会占用消息数量所以实际能记住的对话轮数比你想的少如果发现模型记不住前面说的先检查这个参数。5.3 工具执行结果的格式化技巧工具返回给模型的内容格式直接影响模型组织回复的质量。我试过返回 JSON也试过返回自然语言最后发现自然语言在大多数场景下效果更好因为模型不需要再去解析结构。但如果工具返回的是列表数据比如“查询用户的所有订单”返回一大段自然语言会很啰嗦。这时候我会做一层精简只返回关键字段并且控制条数。ListOrder orders orderMapper.selectByUserId(userId); if (orders.isEmpty()) { return 该用户暂无订单; } return orders.stream() .limit(10) .map(o - String.format(订单%s状态%s金额%s元, o.getOrderNo(), statusText(o.getStatus()), o.getAmount())) .collect(Collectors.joining());limit(10)是必须的不然用户有几百个订单全塞给模型既慢又容易超 token。返回格式用分号分隔模型读起来清晰也方便它总结。6. 常见问题排查与避坑经验6.1 模型不调用工具怎么办这是最常见的问题用户明明问的是订单相关模型却直接瞎编一个答案。排查思路按顺序来先看工具描述是不是太模糊模型没理解这个工具能干什么再看系统提示词有没有说清楚角色能力然后检查工具方法有没有正确注册到 ChatClient 上。我遇到过一次是工具方法所在的类没有被 Spring 扫描到因为包路径不在启动类的同级或子包下导致工具根本没注册。还有一次是Tool注解的版本和 Spring AI 版本不匹配注解没生效。这两个都是低级错误但很隐蔽建议启动后打日志确认一下注册了哪些工具。如果描述和注册都没问题可以适当降低temperature让模型更“保守”一点倾向于调用工具而不是自由发挥。6.2 工具参数传错或缺失模型生成的参数偶尔会出问题比如该传数字传了带空格的字符串或者干脆漏传了某个参数。防御性编程在这里特别重要每个工具方法开头都要做参数校验不合法就返回提示让模型重新组织。还有一种情况是参数类型不匹配比如你方法签名是Long userId模型传了个abc反射调用时会抛类型转换异常。解决办法是把参数类型都定义成String在方法内部自己转换和校验这样容错性最高。虽然看起来不够优雅但实际项目里最稳。6.3 工具执行超时或异常工具方法执行时间过长会拖垮整个对话体验。我的做法是给所有数据库查询加超时控制MyBatis 可以在配置里设defaultStatementTimeout。另外工具方法内部要 try-catch把异常转成友好的返回文字别让异常直接抛到框架层。try { Order order orderMapper.selectByOrderNo(orderNo); // ... } catch (Exception e) { log.error(查询订单失败, orderNo{}, orderNo, e); return 查询订单时出现系统异常请稍后重试; }这样即使数据库挂了模型也能给用户一个合理的回复而不是整个请求 500。6.4 常见问题速查表问题现象可能原因排查方向模型不调用工具描述模糊、未注册、提示词不清检查工具描述和注册日志参数类型转换异常方法签名类型过严参数统一用 String 接收对话记不住上下文maxMessages 太小调大窗口或精简消息响应特别慢工具查询慢或返回数据多加索引、限制返回条数工具调用死循环模型反复调用同一工具限制调用轮数、优化描述7. 性能优化与生产环境注意事项7.1 减少不必要的模型往返每次工具调用都意味着一次额外的模型请求延迟会叠加。优化思路是尽量让一次工具调用拿到足够的信息而不是让模型分多次调用。比如用户问“订单 10086 的状态和物流”与其注册两个工具让模型调两次不如注册一个工具一次返回状态和物流信息。工具粒度设计要贴合真实问法这个需要根据实际用户 query 分布来调整。7.2 工具结果的缓存策略有些工具查询的数据变化不频繁比如商品基本信息、用户等级这类结果可以加一层缓存。我用的是 Caffeine 本地缓存设置较短的过期时间既能减少数据库压力又不会返回太旧的数据。但要注意订单状态这种实时性要求高的数据不要缓存否则用户看到的状态是错的体验更差。7.3 日志与可观测性Function Calling 的链路比较长出问题时如果没有日志会很难排查。我建议在工具方法入口和出口都打日志记录入参和返回值同时记录模型返回的原始调用意图。Spring AI 本身有 debug 级别的日志可以打开能看到完整的请求响应报文排查问题时非常有用。logging: level: org.springframework.ai: DEBUG生产环境记得把这个级别调回 INFO不然日志量会很大。7.4 安全边界控制工具方法本质上是暴露给模型调用的接口必须做权限和边界控制。比如查询订单的工具一定要校验当前登录用户有没有权限查这个订单不能模型传个订单号就无条件返回。我一般会在工具方法里从安全上下文拿当前用户 ID和订单的 user_id 做比对不匹配就返回无权限提示。这一步千万不能省否则就是个越权漏洞。另外工具方法能访问的数据范围要收窄别写一个“执行任意 SQL”的工具那等于把数据库交给了模型。每个工具都应该是职责单一、边界清晰的。8. 我实际落地后的几点体会这套东西我在两个项目里实际用过一个是内部客服助手一个是面向用户的订单查询机器人。最大的体会是Function Calling 的难点不在技术实现而在工具的设计和描述的打磨。技术框架帮你把链路打通了但模型调不调用、调用得准不准全靠你怎么描述工具、怎么设计参数、怎么组织返回结果。我踩过最深的坑是早期工具描述写得太技术化用了很多数据库字段名结果模型理解不了调用准确率很低。后来改成用业务语言描述比如把“查询 orders 表的 status 字段”改成“查询订单当前处于什么状态比如待付款、已发货、已完成”准确率一下就上来了。模型是懂人话的你得用人话跟它说。还有一个建议是尽早建立一套测试用例把常见的用户问法收集起来每次调整工具描述或提示词后跑一遍看调用准确率有没有变化。靠感觉调优很容易反复有数据支撑才靠谱。这套用例不用很复杂二三十条覆盖主要场景就够了但能帮你省下大量来回试的时间。
返回列表