你有没有遇到过这种需求:业务方说“报表页面别搞那么复杂,让用户直接问系统就行,比如帮我查一下最近一周的订单金额”。表面上看是个 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,参数 days=7”。你的程序收到这段结构化内容后,再去调用真实的 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> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-bom</artifactId> <version>1.0.0</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-model-openai</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-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 的 Service:
@Service public class OrderService { public Map<String, 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 Map<String, 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/chat,body 传最近三天订单总额是多少,返回的内容就是模型基于 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 生成出错的概率越低。我遇到过
Map<String, List<Map<String, 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 时少走几步弯路。