Spring AI Function Calling 实战:从原理到智能订单助手完整落地
Function Calling 这个词这两年在 AI 应用开发圈子里出现的频率越来越高但真正动手把它跑通、跑稳的人其实没想象中那么多。我最初接触它的时候脑子里想的是“不就是让大模型调个接口吗”结果真上手才发现从模型返回的 JSON 结构解析、参数校验、异常兜底到多轮对话里上下文怎么维护每一步都有坑。Spring AI 这套框架把很多脏活累活封装掉了但封装得越深出问题时越难排查所以“吃透”比“会用”重要得多。这篇内容面向的是有 Java 和 Spring Boot 基础、想在自己的项目里落地 Function Calling 的开发者。我会从最基础的概念讲起一路走到一个能跑通的完整实战案例中间穿插我自己踩过的坑和排查思路。读完之后你应该能独立完成一个“模型理解用户意图、自动调用后端 Java 方法、再把结果组织成自然语言返回”的闭环。关键词涉及 Function Calling、Spring AI、Spring Boot、Java、MySQL这些都会在实战里真实出现不是摆设。1. 先把 Function Calling 这件事的本质想清楚1.1 它到底解决了什么问题很多人第一次听到 Function Calling会误以为是大模型自己去执行代码。不是的。大模型从头到尾只做一件事根据你的描述和用户的输入判断“现在该不该调用某个工具”如果要调用就输出一个结构化的调用请求。真正执行这个工具的永远是你自己的后端代码。举个生活化的类比。你是一个公司的前台用户打电话进来问“帮我查一下上个月的报销到账没有”。前台大模型本身查不了财务系统但它知道“查报销”这件事应该找财务部你的 Java 方法。于是前台把用户的诉求整理成一张工单写上“查询报销员工张三月份上月”然后转给财务部。财务部查完把结果告诉前台前台再用自然语言回复用户。这个“整理成工单”的动作就是 Function Calling 的核心。它把自然语言的模糊意图转成了程序能精确处理的参数结构。没有它你只能靠正则或者关键词匹配去猜用户想干嘛稍微复杂一点就崩。1.2 为什么是 Spring AI 而不是自己拼 HTTP理论上你完全可以自己调模型的 HTTP 接口手动拼 tools 参数、手动解析返回的 function call 字段。我早期就这么干过代码大概长这样构造一个巨大的 JSON里面塞工具定义发请求拿到响应后判断 finish_reason 是不是 tool_calls再手动反序列化。能跑但问题很明显。第一不同模型厂商的工具定义格式不完全一样OpenAI 一套、通义一套、其他家又一套你要写适配层。第二多轮对话里工具调用结果怎么回填、消息历史怎么组织这些逻辑每次都要重写。第三Spring 生态里你本来就有依赖注入、有 Bean 管理工具方法天然就是一个个 Service自己拼 HTTP 等于把这些优势全扔了。Spring AI 的价值在于它把这些统一了。你只需要用Tool注解标记一个方法框架自动帮你生成工具描述、处理调用请求、把结果回填到对话上下文。切换模型时大部分代码不用动。这就是“站在框架肩膀上”的意义。1.3 一次完整的调用链路长什么样在动手写代码前脑子里要有一张清晰的流程图。虽然这里不能用图表但我用文字把链路拆开用户发消息 → Spring AI 把消息和所有已注册的工具描述一起发给模型 → 模型判断需要调用工具返回工具名和参数 → Spring AI 解析出工具名找到对应的 Java 方法 → 通过反射调用该方法传入参数 → 方法执行可能查数据库、调外部接口→ 返回结果 → Spring AI 把结果作为一条特殊消息追加到对话历史 → 再次发给模型 → 模型基于工具返回的数据生成自然语言回复 → 返回给用户。注意这里有个关键点模型可能被调用两次。第一次是为了决定调不调工具第二次是拿到工具结果后生成最终回复。这个“两次调用”是很多新手困惑的地方也是 token 消耗比预期高的原因。理解这一点后面做成本优化时才有方向。2. 环境搭建里那些文档不会告诉你的细节2.1 版本选择别一上来就追最新Spring AI 的版本迭代速度相当快1.0 之前 API 变动频繁很多网上搜到的教程用的是老版本你照着抄会发现类名都对不上。我的建议是先确定你用的 Spring Boot 版本再选对应的 Spring AI 版本。截至我写这篇内容时比较稳的组合是 Spring Boot 3.2.x 或 3.3.x 搭配 Spring AI 1.0.x 的正式版。如果你用的是 Spring Boot 3.4 以上注意有些 starter 的坐标变了。用 Maven 的话核心依赖大概是这样dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-starter-model-openai/artifactId /dependency这里有个坑要提醒早期版本 artifactId 是spring-ai-openai-spring-boot-starter后来改成了spring-ai-starter-model-openai。如果你从旧教程复制依赖编译报“找不到 artifact”八成就是这个原因。另外 Spring AI 的正式版依赖已经进了 Maven 中央仓库但如果你用的是里程碑版本需要在 pom 里额外配置 milestone 仓库否则下载不下来。2.2 配置文件里的几个关键项application.yml 里至少要配三样东西API 地址、API Key、模型名称。spring: ai: openai: base-url: https://your-api-endpoint/v1 api-key: ${AI_API_KEY} chat: options: model: your-model-name temperature: 0.7base-url这一项很多人会忽略。如果你用的是兼容 OpenAI 协议的服务地址一定要带对路径通常结尾是/v1。少了这个后缀请求会 404而且报错信息不一定直观可能只告诉你“连接失败”让你以为是网络问题。API Key 千万别硬编码在 yml 里然后提交到代码仓库。用环境变量${AI_API_KEY}引用本地开发时在 IDE 的运行配置里设置环境变量。这个习惯看着小但真出过事的人都知道疼。temperature在 Function Calling 场景下建议调低一点0.1 到 0.3 之间比较合适。因为工具调用需要的是“准确判断意图”不是“发挥创造力”。温度太高模型可能该调工具的时候不调或者参数填得离谱。2.3 依赖注入的坑ChatClient 和 ChatModel 的区别Spring AI 里有两个容易混淆的类ChatModel和ChatClient。ChatModel是底层接口直接调它你要自己处理消息列表ChatClient是上层封装提供了流式 API写起来更顺手。我的经验是做 Function Calling优先用 ChatClient。它对新手的友好度更高链式调用清晰而且对工具注册的支持更自然。注入方式Service public class AiService { private final ChatClient chatClient; public AiService(ChatClient.Builder builder) { this.chatClient builder.build(); } }注意这里注入的是ChatClient.Builder而不是ChatClient本身。Builder 是原型作用域的每次注入都是新实例这样你可以针对不同的场景构建不同的 Client比如一个带工具、一个不带。如果你直接注入 ChatClient可能会遇到工具注册不生效的问题因为单例的 Client 在构建时就固定了配置。3. 用 Tool 注解把 Java 方法变成模型能调用的工具3.1 一个最小可用的工具方法假设我们要做一个“查询订单状态”的功能。先定义一个普通的 ServiceService public class OrderService { Tool(description 根据订单号查询订单的当前状态返回状态描述和预计送达时间) public String queryOrderStatus( ToolParam(description 订单号通常是10位数字) String orderId) { // 实际业务里这里查数据库 if (1234567890.equals(orderId)) { return 订单已发货预计明天下午送达; } return 未找到该订单; } }就这么简单。Tool的 description 是给模型看的模型靠这段文字判断“什么时候该调用这个方法”。所以 description 写得好不好直接决定调用准确率。我见过有人把 description 写成“查询订单”结果模型在用户问“我的快递到哪了”的时候不调用因为“快递”和“订单”在语义上模型没关联起来。改成“根据订单号查询订单状态和物流信息”之后命中率明显提升。description 要覆盖用户可能用的各种说法这是经验之谈。ToolParam的 description 同样重要它告诉模型这个参数是什么、什么格式。如果参数格式有要求比如必须是数字、必须是特定前缀一定要写清楚否则模型可能传个“我的订单”这种自然语言进来你的方法直接抛异常。3.2 工具方法的返回值怎么设计返回值的设计有个原则返回给模型的内容要“可读且信息完整”但不要塞太多无关数据。我一开始图省事直接把数据库查出来的整个实体对象toString()返回结果里面几十个字段模型被淹没了生成的回复又长又乱。后来改成只返回关键字段的简短描述回复质量立刻上来了。另一个坑是返回 null。如果方法返回 null某些版本的 Spring AI 处理时会有问题模型收到的工具结果为空可能反复调用同一个工具。所以永远返回一个非空的字符串查不到就返回“未找到相关记录”别返回 null。如果工具执行过程中抛异常Spring AI 默认会把异常信息传给模型。这有好有坏好处是模型知道出错了可以告诉用户坏处是异常堆栈可能包含敏感信息。建议在工具方法内部自己 try-catch把异常转成友好的提示文字返回。3.3 把工具注册到 ChatClient定义好工具方法后要显式注册this.chatClient builder .defaultTools(orderService) .build();defaultTools接收的是包含Tool方法的对象。Spring AI 会扫描这个对象里所有带注解的方法生成工具定义。这里有个容易踩的坑如果你注册的对象是通过代理增强过的比如加了事务注解 TransactionalTool 注解可能扫描不到。因为 Spring 的 AOP 代理会生成一个子类注解在父类方法上某些扫描逻辑会漏掉。解决办法是把工具方法单独抽到一个没有事务代理的类里或者用接口方式暴露。这个问题排查起来很费劲因为不报错只是工具静默地不生效模型永远说“我无法查询”。4. 多轮对话与上下文管理Function Calling 最容易翻车的地方4.1 为什么单轮能跑通多轮就乱套单轮对话很简单用户问一句模型调工具返回结果结束。但真实场景往往是多轮的。用户先问“帮我查订单 1234567890”模型调用工具返回“已发货”用户接着问“那大概几点到”这时候模型需要知道“那”指的是刚才那个订单。如果你每轮都新建一个 ChatClient 调用历史消息全丢了模型根本不知道“那”是什么。所以必须维护对话上下文。Spring AI 提供了ChatMemory接口常用的实现是MessageWindowChatMemory它保留最近 N 条消息。配置方式Bean public ChatMemory chatMemory() { return MessageWindowChatMemory.builder() .maxMessages(20) .build(); }然后在构建 ChatClient 时挂上this.chatClient builder .defaultTools(orderService) .defaultAdvisors(MessageChatMemoryAdvisor.builder(chatMemory).build()) .build();4.2 窗口大小设多少合适maxMessages这个值不是越大越好。设太大每次请求携带的历史消息多token 消耗高、响应慢设太小模型记不住上下文。我的经验值是 10 到 20 条。但要注意工具调用的请求和结果也算消息。一次工具调用至少产生两条消息模型的调用请求 工具的执行结果所以如果你设 20实际能记住的用户对话轮次可能只有五六轮。还有个细节窗口是按“消息条数”截断的不是按“对话轮次”。如果某一轮里工具调用特别多可能一下子就把窗口占满了导致更早的对话被挤出去。做长对话应用时这个要特别注意必要时得自己实现更智能的记忆策略比如按 token 数截断或者对历史做摘要压缩。4.3 会话隔离多用户场景下的必答题上面那个 ChatMemory 是单例 Bean意味着所有用户共享同一份记忆。本地测试没问题一上线就出大事A 用户问的订单B 用户接着问“那个订单到哪了”模型会把 A 的订单信息告诉 B。这是严重的数据泄露。正确做法是按会话 ID 隔离。Spring AI 的 Advisor 支持传入 conversationIdchatClient.prompt() .user(message) .advisors(a - a.param(ChatMemory.CONVERSATION_ID, sessionId)) .call() .content();sessionId 可以从用户登录态里取或者前端生成一个 UUID 传过来。每个 sessionId 对应一份独立的记忆。这个点我在项目里踩过测试阶段两个人同时用发现对话串了排查了半天才定位到是记忆没隔离。5. 一个完整的实战案例智能订单助手5.1 需求拆解与技术选型我们来做一个稍微完整点的东西一个智能订单助手用户可以用自然语言查询订单、查询物流、申请退款。数据存在 MySQL 里通过 Spring Boot 提供接口Spring AI 负责理解意图和调用工具。技术栈Spring Boot 3.3.x Spring AI 1.0.x MySQL 8 MyBatis-Plus或者 Spring Data JPA看个人习惯。我选 MyBatis-Plus因为国内项目用得多写起来快。数据库表设计简单点一张订单表CREATE TABLE orders ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE, user_id BIGINT NOT NULL, status TINYINT NOT NULL COMMENT 1待付款 2已付款 3已发货 4已完成 5已退款, amount DECIMAL(10,2) NOT NULL, create_time DATETIME NOT NULL, update_time DATETIME NOT NULL ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;5.2 工具方法的具体实现订单查询工具Service public class OrderToolService { Autowired private OrderMapper orderMapper; Tool(description 根据订单号查询订单详情包括状态、金额和下单时间) public String queryOrder( ToolParam(description 订单号32位以内的字符串) String orderNo) { try { Order order orderMapper.selectByOrderNo(orderNo); if (order null) { return 没有找到订单号为 orderNo 的订单请确认订单号是否正确; } String statusText switch (order.getStatus()) { case 1 - 待付款; case 2 - 已付款等待发货; case 3 - 已发货; case 4 - 已完成; case 5 - 已退款; default - 未知状态; }; return String.format(订单 %s 当前状态%s金额 %.2f 元下单时间 %s, orderNo, statusText, order.getAmount(), order.getCreateTime()); } catch (Exception e) { return 查询订单时出现异常请稍后重试; } } }退款申请工具Tool(description 为指定订单申请退款只有已付款且未发货的订单可以申请) public String applyRefund( ToolParam(description 订单号) String orderNo, ToolParam(description 退款原因用户描述的原因) String reason) { Order order orderMapper.selectByOrderNo(orderNo); if (order null) { return 订单不存在无法申请退款; } if (order.getStatus() ! 2) { return 当前订单状态不支持退款只有已付款未发货的订单可以申请; } // 执行退款逻辑 orderMapper.updateStatus(orderNo, 5); return 退款申请已提交订单 orderNo 将在1-3个工作日内原路退回; }注意退款工具里的状态校验。业务规则一定要在 Java 代码里兜底不能指望模型判断。模型可能会在订单已发货的情况下也调用退款工具因为用户说“我要退款”模型觉得该调。但实际能不能退得你的代码说了算。这是 Function Calling 的一个重要原则模型负责理解意图代码负责执行规则。5.3 组装 ChatClient 与对外接口Configuration public class AiConfig { Bean public ChatClient chatClient(ChatClient.Builder builder, OrderToolService orderToolService, ChatMemory chatMemory) { return builder .defaultSystem(你是一个专业的订单助手负责帮用户查询订单和处理退款。 回答要简洁友好涉及金额和时间要准确。) .defaultTools(orderToolService) .defaultAdvisors(MessageChatMemoryAdvisor.builder(chatMemory).build()) .build(); } }对外接口RestController RequestMapping(/api/assistant) public class AssistantController { Autowired private ChatClient chatClient; PostMapping(/chat) public String chat(RequestBody ChatRequest request) { return chatClient.prompt() .user(request.getMessage()) .advisors(a - a.param(ChatMemory.CONVERSATION_ID, request.getSessionId())) .call() .content(); } }system prompt 里我特意强调了“涉及金额和时间要准确”。因为模型在组织语言时有时候会把工具返回的“明天下午”改写成“大概这两天”这种模糊化在订单场景里是灾难。通过 system prompt 约束能减少这类问题。5.4 实测效果与调优过程第一版跑起来查询订单没问题但退款场景经常出岔子。用户说“这个订单我不要了”模型有时候不调用退款工具而是回复“好的请问您要退哪个订单”。原因是“不要了”和“退款”在语义上模型没直接关联。解决办法是在工具的 description 里补充同义表达“为指定订单申请退款用户说不要了、取消订单、退货时都应调用此工具”。改完之后命中率大幅提升。这个技巧很实用把用户可能用的口语化表达写进 description。另一个问题是参数提取。用户说“帮我退一下 1234567890 这个单”模型有时候会把“这个单”也当成订单号的一部分。后来在ToolParam的 description 里明确“订单号是纯数字或字母组合不包含中文”问题基本解决。6. 上线前必须处理的异常与边界情况6.1 模型不调用工具怎么办这是最常见的“不生效”问题。排查顺序建议这样先确认工具真的注册上了。可以在启动日志里找找有没有工具注册相关的输出或者写个单元测试直接断言工具列表。如果工具没注册检查Tool注解的类是不是被 Spring 管理、有没有被 AOP 代理。如果工具注册了但模型不调八成是 description 写得不够清楚。把 description 改得更具体覆盖更多用户表达方式。还可以在 system prompt 里加一句“当用户询问订单相关信息时优先使用提供的工具查询不要凭记忆回答”。还有一种情况是模型能力问题。小参数量的模型在工具调用上的准确率确实不如大模型。如果业务对准确率要求高别在这上面省钱。6.2 工具调用死循环我遇到过一次工具返回“未找到订单”模型不甘心又调了一次同样的工具还是没找到再调……直到达到最大迭代次数。Spring AI 有默认的最大工具调用轮次限制但触发限制后返回的是一句比较生硬的提示。避免死循环的关键是让工具返回的信息足够明确让模型知道“再试也没用”。比如返回“订单号 XXX 在系统中不存在请让用户核对后重新提供”比单纯返回“未找到”效果好得多。模型看到“请让用户核对”就知道该转向问用户了而不是自己重试。6.3 敏感操作的人工确认退款、删除、支付这类操作直接让模型调用风险很大。用户可能只是随口一说“这单真烦”模型理解成要退款就执行了。稳妥的做法是加一层确认机制。工具方法不直接执行而是返回一个“待确认”的状态前端弹出确认框用户点确认后再真正执行。或者用两阶段工具第一个工具负责“准备退款”返回确认信息第二个工具负责“确认执行退款”需要用户明确说“确认”才调用。这个设计在 demo 里可以省但真上线一定要有。我见过因为没做确认用户测试时误触发退款流程的案例虽然金额小但流程上的漏洞很吓人。6.4 超时与降级模型接口调用是有网络延迟的工具方法里如果再查数据库、调外部服务整体响应时间可能到好几秒。用户等太久体验很差。建议给工具方法设置超时比如数据库查询超过 2 秒就返回“查询超时请稍后重试”。同时整个对话接口也要有超时控制超时后返回一个友好的降级提示而不是让请求一直挂着。另外模型服务本身也可能不可用。做好熔断降级模型挂了的时候至少让用户能通过传统的方式比如输入订单号查询完成核心操作而不是整个功能瘫痪。7. 关于成本和性能的一些实在话Function Calling 的 token 消耗比普通对话高不少因为每次请求都要带上所有工具的定义。工具越多这部分开销越大。我实测过一个场景注册了 8 个工具光工具定义就占了将近 1000 个 token每轮对话都要重复发送。优化方向有几个。一是按场景拆分工具集不要把所有工具都注册到一个 Client 上。订单相关的对话只注册订单工具售后相关的只注册售后工具。二是工具 description 别写太长够用就行精简的描述能省不少 token。三是合理设置记忆窗口别让历史消息无限增长。响应速度方面工具调用意味着至少两次模型请求延迟天然比普通对话高。如果对响应速度要求高可以考虑流式输出让用户先看到“正在查询”的反馈减少等待焦虑。8. 我踩过的几个印象深刻的坑第一个坑是中文参数乱码。工具方法接收中文参数时某些环境下会出现乱码。排查后发现是请求编码的问题在配置文件里显式指定 UTF-8 后解决。这个问题不常见但一旦遇到很迷惑因为英文参数完全正常。第二个坑是工具方法的事务问题。我在工具方法上加了Transactional结果发现工具调用后数据没更新。原因是 Spring AI 通过反射调用方法时如果方法是被代理的调用链路可能绕过了事务代理。后来把事务逻辑抽到单独的内部方法里通过 self-injection 或者编程式事务解决。第三个坑是模型返回的参数类型不匹配。工具方法参数是Long模型传了个字符串123反序列化直接失败。解决办法是把工具方法的参数类型都设成String在方法内部自己做类型转换和校验。虽然不够优雅但最稳。第四个坑是并发下的记忆串扰。前面提过这里再强调一次ChatMemory 一定要按会话隔离而且要注意并发场景下同一个 sessionId 的请求要串行处理否则两个请求同时读写同一份记忆可能互相覆盖。9. 后续可以怎么扩展跑通基础版本之后有几个方向值得继续深入。一是接入更多数据源比如把物流接口、库存接口都做成工具让助手能力更完整。二是引入 RAG把商品详情、售后政策这些文档向量化让助手在回答时能引用准确的政策条款而不是靠模型自己编。三是做工具调用的可观测性记录每次调用了哪个工具、参数是什么、耗时多少、结果如何这些数据对优化 description 和排查问题极有价值。Spring AI 本身也在快速演进工具调用的 API 可能会继续调整。建议关注官方文档的更新但也不要盲目追新生产项目还是以稳定为主。我个人的习惯是新版本出来先在测试项目里跑一遍确认核心功能没问题再升级。最后分享一个小心得调试 Function Calling 的时候把每次请求和响应的完整消息列表打印出来包括工具调用的中间过程。虽然日志会很长但这是定位问题最快的方式。很多“模型不听话”的问题看一眼完整的消息流就明白了。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →