
你有没有遇到过这种需求业务方说“报表页面别搞那么复杂让用户直接问系统就行比如帮我查一下最近一周的订单金额”。表面上看是个 NLP 需求落到 Spring Boot 后端本质是让大模型在对话里调用你自己的 Api。Spring AI 这个项目最吸引我的点就是它把这条链路封装得足够干净我们不用再去手拼 Prompt、解析 JSON、维护函数注册表只要把一个普通 Service 方法暴露成模型能认识的工具剩下的事情交给框架处理。这篇文章我会用自己的实际项目经验讲清楚 Spring AI 调用自己 API 的完整链路从函数调用的原理、最小工程搭建、Tool 定义到上线前绕不开的超时、流式、限流和可观测性。里面所有代码都是可以照着落地的踩过的坑也会逐一说明适合正在做 Java 后端、想把 LLM 能力接进 Spring Boot 项目的同学参考。1. 从自然语言到大模型调本地接口链路到底是怎么走的1.1 大模型不会 HTTP它只负责“决定调用谁”先纠正一个常见的误解大模型本身不会发 HTTP 请求也不会查你的数据库。它只是一个文本生成模型你给它一句“帮我查最近一周订单金额”它能做的最多是在回复里写出一段结构化的 JSON告诉你“我想调用某个函数参数是什么”。真正发起调用、执行业务逻辑、访问数据库的仍然是你自己的 Java 代码。这就是 Function Calling工具调用的协议意义模型不关心你的 API 怎么实现它只负责在合适的时机输出“我要调用 queryRecentOrders参数 days7”。你的程序收到这段结构化内容后再去调用真实的 Service 方法最后把方法返回值塞回给模型模型再把它组织成用户能看懂的自然语言回复。1.2 一次完整函数调用的四步链路我把 Spring AI 里的函数调用拆成四步用户输入自然语言例如“最近三天订单总额是多少”。应用把用户消息和当前可用的工具描述函数名、参数结构、功能说明一起发给大模型。大模型判断这个问题需要调用工具于是返回一个“工具调用请求”里面包含函数名和参数值。Spring AI 接收到这个请求通过反射调用你定义的 Tool 方法拿到方法返回值再连同之前的对话上下文一起发给大模型让模型生成最终回复。很多人第一次接触时容易把第 3 步和第 4 步搞混以为模型返回了函数名就等于调用完成。实际上模型只负责“点菜”真正“做菜”的是你的代码。整个过程中Spring AI 承担了协议转换、参数绑定、结果回传这些体力活。1.3 用 Spring AI 之前先看下裸写 Prompt 会多麻烦在我用 Spring AI 之前公司里的做法是自己在代码里拼一套 JSON Schema手动塞进 Prompt然后让模型以固定格式回复“函数调用结果”再用正则或者 JSONPath 从回复里抠参数。这样做的痛点很明显模型回复不稳定偶尔多一句解释JSON 解析就挂了每加一个新工具都要同步改 Prompt 模板、参数校验、结果解析代码多轮对话里工具调用结果很容易污染上下文上下文一长模型就开始“胡说八道”。Function Calling 协议把“工具描述”和“对话消息”分离模型本身经过专门训练它在需要调用工具时输出的是标准化的 tool_calls 结构而不是杂糅在自然语言里。Spring AI 在这个协议之上又做了 Java 注解和反射封装让我们可以用普通方法定义工具这比裸写 Prompt 省心得多。2. 搭起最小工程Spring Boot 3 Spring AI 需要避开的配置坑2.1 版本与依赖别再用老旧的 0.8.x 示例Spring AI 的版本迭代非常快网上大量教程还停留在 0.8.x 甚至更早的 snapshot 版本等你照着写完后发现包名、类名、API 全变了。我这里用的是Spring Boot 3.3.x Spring AI 1.0.x这一套在 2025 年中已经算稳定可用不需要再碰 Maven 的 milestone 仓库。Maven 依赖建议直接用 BOM 管理版本避免子依赖各自为政dependencyManagement dependencies dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-bom/artifactId version1.0.0/version typepom/type scopeimport/scope /dependency /dependencies /dependencyManagement dependencies dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-starter-model-openai/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency /dependencies这里重点说明一下为什么是spring-ai-starter-model-openai它不是只能接 OpenAI所有提供 OpenAI 兼容/chat/completions接口的大模型都可以用这个 starter。国内很多模型服务商都实现了 OpenAI 兼容协议这让我们切换模型时基本不需要改 Java 代码。2.2 配置一个 OpenAI 兼容的模型网关我实际项目里用的是 DeepSeek 的接口因为它的 OpenAI 兼容模式非常标准配置只需要三行spring: ai: openai: api-key: ${DEEPSEEK_API_KEY} base-url: https://api.deepseek.com chat: options: model: deepseek-chat temperature: 0.2把base-url指到兼容网关的根地址model填它在文档里声明的模型名就完成了。这里有两个容易踩的坑不要想当然把 model 写成公司内部代号。我亲眼见过同事把模型名写错日志里报 400网关提示“supported api model names are ...”排查半天才发现是配置问题。temperature 建议调低一点。模型做函数调用时我们希望它输出稳定、少发散0.2 到 0.4 是比较合理的区间。太高的话同一个问题可能这次调工具、下次不调工具用户体验很不稳定。2.3 Tool 是怎么变成模型认识的 JSON Schema 的刚开始我很好奇Spring AI 怎么知道要往请求里塞什么工具描述答案在注解和反射上。你在方法上标注Tool框架启动时扫描 Spring 容器里的这些方法通过方法名、参数名、注解里的 description 自动生成 JSON Schema。举个例子一个参数是int days框架会生成类似这样的结构{ name: queryRecentOrders, description: 查询最近 N 天的订单总金额和订单数量, parameters: { type: object, properties: { days: { type: integer, description: 要查询的天数比如 7 代表最近一周 } }, required: [days] } }这个 Schema 会被放进发给模型的请求里。模型看到它之后才知道有这么一个函数可以用、参数需要填什么。理解这一点很重要因为后面排查 400 报错时最终看的就是这份自动生成的 JSON。3. 写一个真正可用的订单查询 Tool从 Service 到模型可调用3.1 先有一个普通 Service 方法假设你已经有一个老订单查询接口是标准的三层架构。为了演示我把它简化成一个直接返回 Map 的 ServiceService public class OrderService { public MapString, Object queryRecentOrders(int days) { // 实际项目里这里可能是 JPA、MyBatis 查数据库 double totalAmount 12800.5; int count 42; return Map.of( days, days, totalAmount, totalAmount, count, count ); } }在我自己的项目里这里是直接从订单表按create_time聚合查询。你可以先跑通这个简化版再把真正的查询逻辑替换进来。3.2 用 Tool 注解把它暴露给模型接下来是核心不要直接在 Controller 里暴露这个 Service而是写一个专门的 Tool 组件层。这样模型相关的方法和业务 Service 解耦也方便以后在 Tool 层做权限、埋点、降级逻辑。Component public class OrderTools { private final OrderService orderService; public OrderTools(OrderService orderService) { this.orderService orderService; } Tool(name queryRecentOrders, description 查询最近 N 天的订单总金额和订单数量) public MapString, Object queryRecentOrders( ToolParam(description 要查询的天数比如 7 代表最近一周) int days) { return orderService.queryRecentOrders(days); } }有几个细节我后来才体会到description一定要写清楚并且最好带上正面和反面的例子。模型是靠文本描述来理解函数用途的描述越具体误调用越少。比如“查询订单金额”和“查询最近 N 天的订单总金额和数量”对模型来说信息量完全不同。ToolParam的 description 会被写进 schema 的字段描述里同样会影响模型填参。参数含义不明确时模型会猜一猜就容易填错。方法名注意不要用中文、不要带特殊符号。OpenAI 兼容协议里函数名有严格的字符限制后面我会详细说这个坑。3.3 在 ChatClient 里启用 Tool 并完成调用Spring AI 1.0 的入口是ChatClient。在配置类里注入ChatClient.Builder然后通过toolNames指定要启用哪些工具RestController RequestMapping(/api/ai) public class ChatController { private final ChatClient chatClient; public ChatController(ChatClient.Builder builder) { this.chatClient builder.build(); } PostMapping(/chat) public String chat(RequestBody String message) { return chatClient.prompt() .user(message) .toolNames(queryRecentOrders) .call() .content(); } }请求POST /api/ai/chatbody 传最近三天订单总额是多少返回的内容就是模型基于 orderService 返回结果组织出来的自然语言答案。如果你用的 Spring AI 版本比较老可能会看到.functions(queryRecentOrders, new OrderTools())这种写法这是旧版的 API。升级到 1.0 之后Tool方法只要被 Spring 容器管理用toolNames指定名字即可不需要手动传实例。我个人认为这是一版很好的简化工具注册终于回归到了 Spring 容器管理的直觉。4. 实测 400 invalid schema函数定义被网关拒绝的一次排查4.1 报错现场一个看起来没问题的 Tool我印象最深的一次报错是项目里加了一个处理“产物文件”的工具工具本身很简单但一调用就返回api error: 400 invalid schema for function artifact第一反应是代码问题但检查了半天方法签名没问题、参数类型是基本类型、注解描述也写得很正常。后来把请求体完整打出来才发现问题出在函数名和参数校验上artifact这个函数名里包含了产品代号中的连字符而 OpenAI 兼容网关要求函数名只能包含字母、数字、下划线和连字符且必须以字母开头。我们的产品代号是artifact-report方法名照抄了产品代号中间的下划线没问题但某些自动生成的内部命名带了特殊前缀导致 schema 校验直接失败。另一个常见版本是参数描述里藏着一段看起来像正则表达式的字符串被框架原样搬进了 JSON Schema 的pattern字段而网关的 schema 校验器要求pattern必须是合法正则。我们曾在一个数据清洗工具里为参数写了“不能包含 __ 开头的字段”这种描述里面带了正则片段结果 gateway 返回的正是 400 invalid schema。4.2 排查链路先还原请求体再定位 schema 问题遇到这类问题不要盯着代码看要把实际发给模型网关的请求体捞出来。我的排查链路一直是三步第一步把 Spring AI 的日志级别打开logging: level: org.springframework.ai: DEBUG第二步如果日志还不够直观我习惯在本地用 WireMock 起一个假网关把base-url指到 WireMock让它把收到的请求体落盘。这样能看到 Spring AI 自动生成的 tools 数据结构到底是什么样而不是靠猜。第三步把落盘的 JSON 拿到 JSON Schema 校验器里过一遍。取工具描述里的关键信息检查以下项目函数名是否满足[a-zA-Z][a-zA-Z0-9_-]{0,63}这类命名规范参数名是否有空格、点号、中文字符参数描述里是否意外包含了pattern、$、\p{...}这类正则符号是否有重复定义的 tool name。4.3 schema 书写的安全边界与修复建议经过这次排错我给自己定了几条铁律函数名只用 Java 方法名不要拼业务代号。如果要让模型可读性好一点可以通过Tool(name queryArtifactReport)显式指定一个合规的名字而不是直接拿产品代号当名字。参数描述里写纯文本不要写正则、不要写 JSON 示例。如果确实需要模型按格式生成数据把格式约束放在方法内部做二次校验而不是放在 schema 描述里。避免复杂泛型和深层嵌套对象。参数类型越简单schema 生成出错的概率越低。我遇到过MapString, ListMapString, Object这种类型Spring AI 生成的 schema 结构非常臃肿某些网关解析直接超时。遇到 400 先抓请求体。大部分函数调用问题都不是“模型不听话”而是“我们发给模型的协议就有问题”。5. 从 Demo 到可上线超时、流式与限流这些事不能装看不见5.1 超时设置模型接口一慢Tomcat 线程就悬空函数调用的实时性取决于大模型接口的响应速度而大模型接口经常是几百毫秒到几秒的延迟高峰期甚至更久。如果你的 Controller 是同步阻塞的一个请求就会占住一个 Tomcat 线程线程池被打满之后整个应用表现为假死。我的做法是分两层处理把 Spring AI 底层 HTTP 客户端的超时时间调大。默认超时对聊天场景偏短我一般设置 connect timeout 10 秒、read timeout 60 秒。对于耗时超过 3 秒的调用不要在同步接口里死等。建议直接把任务丢给线程池或消息队列前端轮询或走 WebSocket 接收结果。函数调用本身往往比普通聊天更耗时因为可能要经历“模型返回工具调用 - 执行方法 - 再次调用模型”两轮甚至三轮网络请求延迟会成倍叠加。5.2 流式输出下的函数调用行为和应对聊天场景一般会做流式输出不然用户要盯着空白屏幕等好几秒。但流式 函数调用有一个很容易踩的坑当模型决定调用函数时第一段流式输出的“内容”其实不是自然语言而是一个工具调用的事件。如果你在流式管道里直接消费文本内容并推给前端用户会看到半个 JSON 或者莫名其妙的符号。Spring AI 的ChatClient在底层已经帮你把 tool_calls 聚合好了。你只需要在stream()模式下仍然启用toolNames框架会等到函数调用完成后把最终结果继续以流式方式返回。但如果你是自己手动解析 SSE就必须在代码里处理事件类型可能是 content 事件也可能是 toolCall 事件遇到 toolCall 时要先等参数攒齐、执行本地方法、再发起第二轮模型调用。我吃过一次亏第一版为了“省事”没有处理 toolCall 事件结果用户问订单金额时前端收到的是一堆残缺的 JSON 片段整个对话体验完全崩掉。5.3 并发与限流API Key 被限制了怎么办大模型 API 的 Key 都有速率限制不是按并发就是按 TPM每分钟 token 数。函数调用场景因为是两轮或者多轮请求消耗量会被放大一个用户连续问几个问题Key 可能就触发了限流。我的应对策略很简单用一个Semaphore限制同时进行的模型调用数量比如 10 个避免瞬时把 Key 打满老接口返回的 429 错误不要直接抛给前端而是做一次带退避的重试同一用户的相似问题可以加一个简单的缓存命中缓存就不走模型。缓存这里要注意函数调用涉及真实业务数据订单金额这种数据是不能缓存过期的。我一般只对“不敏感且允许短暂过期”的查询做缓存比如商品分类、地区列表时间控制在 1 分钟以内。6. 多 Tool 编排、日志埋点和安全收口进阶落地要点6.1 Tool 的粒度设计太粗和太细都会让人头疼当你开始暴露第二个、第三个 Tool 时粒度问题就会浮现。工具太粗比如把整个OrderService暴露成一个叫handleOrder的万能方法模型根本不知道什么时候该调用因为参数列表又长又复杂schema 会非常臃肿模型反而不容易生成合法参数。工具太细比如拆成getOrderTotal、getOrderCount、getCustomerName十几个方法模型在需要一组数据时可能会连续调用七八次延迟和成本都会上升。我自己的经验是按“业务目标”来切分而不是按“数据库字段”来切分。比如“查询最近 N 天订单聚合数据”就是一个不错的粒度它包含金额、数量、同比变化模型只需要传入一个 days 参数。如果一个方法需要超过三个参数我通常会觉得粒度有问题应该拆成更贴近用户意图的小工具。6.2 多轮工具调用模型会先查列表再查详情函数调用的高级形态是模型连续调用多个工具。比如用户问“这个月订单量最大的客户是谁”模型可能会调用getMonthlyTopCustomers(30)拿到客户编号列表再调用getCustomerDetail(customerId)拿到客户名称和联系方式。这两次调用不是用户分开发起的而是模型在第一步返回后Spring AI 把函数结果重新喂给模型模型再决定是否需要第二次调用。整个过程是框架自动处理的不需要我们在业务代码里写编排逻辑。但这里有个成本问题多轮调用意味着每次都把之前所有上下文再发给模型token 消耗是快速上升的。所以我在设计工具时会在 description 里尽量写全“这个函数已经能覆盖哪些问题”避免模型不必要的多轮探索。比如“查询最近 N 天的订单总金额和订单数量”这个描述就直接告诉模型这是一个聚合查询它就不会想着先查列表再累加了。6.3 日志埋点没有可观测性的 Agent 等于盲人摸象函数调用链路涉及用户、模型、业务方法三个参与者任何一环出问题都很难定位。我强烈建议在 Tool 层加一个切面记录每一次函数调用的入参、出参、耗时和调用的模型。我用Around注解实现了一个最简单的日志记录大概思路是这样Aspect Component public class ToolLogAspect { private static final Logger log LoggerFactory.getLogger(ToolLogAspect.class); Around(annotation(org.springframework.ai.tool.annotation.Tool)) public Object logToolCall(ProceedingJoinPoint pjp) throws Throwable { String methodName pjp.getSignature().getName(); Object[] args pjp.getArgs(); log.info([tool-call] enter {}({}), methodName, Arrays.toString(args)); long start System.currentTimeMillis(); try { Object result pjp.proceed(); log.info([tool-call] exit {} cost{}ms, result{}, methodName, System.currentTimeMillis() - start, result); return result; } catch (Throwable e) { log.error([tool-call] error in {}, methodName, e); throw e; } } }有了这份日志就可以清楚看到用户问了一个问题时模型到底选择了哪个工具、参数是什么、我们执行了多久、返回值是否合理。这对于后续优化工具描述、调低模型的误调用率都是非常有用的数据。7. 几个值得长期保留的实践习惯7.1 权限与安全不是所有 Service 都能直接给模型我之前做过一个内部数据助手一开始图省事把好几类查询工具全部暴露给了模型。结果发现模型虽然不会主动做坏事但在用户提示词“帮我调用删除接口”这种诱导下如果删除工具存在它真的可能触发。函数调用的本质是让模型获得调用本地方法的能力所以必须把安全边界想清楚。我的原则是默认只暴露只读查询写操作必须单独走人工确认。如果业务上确实需要模型触发写操作我会把工具设计成“生成操作草稿并返回给用户确认”由用户在 UI 上点击真正执行而不是让模型直接调删除方法。7.2 控制成本减少无效 tool call模型并不是每次都会正确调用工具。有时候它会觉得情况模糊于是用一个默认参数去调有时候用户问的问题根本不在工具能力范围内它也会硬调一个最接近的。控制成本的有效办法有三个第一在系统 Prompt 里明确告诉模型“只有问题涉及订单数据时才调用工具否则直接说明自己无法回答”第二给工具描述加上“适用场景”和“不适用场景”降低误调率第三在业务层对参数做兜底过滤比如 days 不能超过 365避免模型传一个 9999 进去触发慢查询白白浪费后续请求时间。7.3 测试习惯把工具调用当成接口来测最后再说一个我坚持到现在的小习惯每个 Tool 方法都要写单元测试而且测试时要 Mock 掉大模型接口只验证本地方法。因为工具方法的入参会由模型生成参数类型、边界值都可能非常离谱如果没有本地单测保护上线后很容易出现“模型传了个负数金额”这种意外。我会用一个固定的大模型返回来模拟“模型决定调用 queryRecentOrders”然后断言本地方法是否被正确调用、返回值是否能被框架正确序列化。这样只要本地验证通过线上出问题时就能更快确定是模型行为问题还是业务代码问题。Spring AI 把大模型接入的门槛降低了很多但真正困难的从来不是“跑通”而是把函数调用放在真实业务场景里让它稳定、安全、可控。上面这些经验和踩坑记录希望能在你接入自己的 API 时少走几步弯路。