前言
如果你只用过大模型自带的聊天接口,那么你拥有的只是一个"会说话的函数";而当你开始让模型调用你应用里的方法——查天气、读数据库、发邮件、订机票——你才真正开始构建所谓的智能体(Agent)。官方文档对这件事的定义非常干脆:只会生成文本的模型是聊天机器人;能发现信息、采取行动、并循环往复直到达成目标的模型才是 Agent。而让这一切落地的核心机制,就是工具调用(Tool Calling)。
这个主题值得系统学的原因很简单:它是 Agentic AI 系统最基本的构件,也是绝大多数生产事故的源头。文档里有一句被很多人忽略但极其重要的话——"虽然我们把工具调用说成模型的能力,但真正拥有工具调用逻辑的是应用程序"。模型只能提出调用某个工具的请求并给出参数;执行工具、把结果回灌给模型的每一步,都在你的代码里发生。模型永远不会直接接触工具背后的 API,这既是能力的边界,也是安全的边界。
本文以"官方文档带读"的方式,逐节拆解 Spring AI 2.0(以当前版本官方文档为准,稳定版 2.0.1)中工具调用与结构化输出两大主题的完整知识链路:从一次工具调用的协议流程,到 @Tool 注解、ToolCallback、ToolContext、JSON Schema 映射,再到错误处理、调用上限、结构化输出与 BeanOutputConverter,最后落到如何把业务能力安全地暴露给模型。读完你应当对 Spring AI 的工具调用体系有一个端到端的、能落地的认识。
整体架构与核心概念
在展开细节之前,先建立整体心智模型。Spring AI 2.0 相对 1.x 最大的架构变化是:工具调用循环从每个 ChatModel 内部的黑盒,变成了 ChatClient 的 Advisor 链(advisor chain)上的一等、可组合的组件。官方称之为 ToolCallingAdvisor 驱动的工具调用循环(tool calling loop)。
┌─────────────────────────────────────────────┐
│ ChatClient │
│ ┌───────────────────────────────────────┐ │
用户请求 ────────────►│ │ Advisor 链: 记忆 / 可观测性 / 重试 │ │
│ │ │
│ │ ┌───────────────────────────────────┐ │
│ │ │ ToolCallingAdvisor (循环驱动者) │ │
│ │ │ ① 携带全部工具定义发给模型 │ │
│ │ │ ② 模型决定是否调用 → 返回请求 │ │
│ │ │ ③ 交给 ToolCallingManager 执行 │ │
│ │ │ ④ 把结果追加进对话历史 │ │
│ │ │ ⑤ 回灌模型 → 回到 ② 直到无工具调用│ │
│ │ └───────────────────────────────────┘ │
│ └───────────────────────────────────────┘ │
└─────────────────────┬───────────────────────┘
▼
ChatModel / 大模型 API
这张图对应官方文档的四个核心概念:
第一,工具(Tool)。官方把工具的用途归为两大类:信息检索(查天气、查客户记录、查新闻)和采取行动(发邮件、订机票、更新记录、触发工作流)。两种用途在代码上没有任何区别,但在安全分级上必须区别对待。
第二,ToolCallback。这是工具的统一抽象接口,它同时携带两样东西:模型看到的"工具定义"(名称、描述、入参 JSON Schema),以及模型真正调用它时执行的逻辑。无论你用声明式注解还是编程式 API,最终产物都是 ToolCallback 实例,走同一个循环。
第三,ToolCallingAdvisor。它是工具循环的"驱动者",由 DefaultChatClient 自动注册到 Advisor 链上。它负责把工具定义发给模型、检测响应里的工具调用请求、交给 ToolCallingManager 执行、把结果追加回对话历史,然后带着完整历史再次请求模型,直到模型不再要求调用工具。
第四,Advisor 排序(ordering)。循环里的记忆、可观测性、重试等其他 Advisor,通过排序这一个"旋钮"与循环组合。文档强调:DefaultChatClient 强制链上恰好有一个 ToolAdvisor,注册第二个会直接报错。这也解释了为什么 1.x 那种在 ChatModel 内部跑循环的方式被移除——可组合性取代了一切尽在内部的紧耦合。
你现在应该掌握了:Spring AI 2.0 把工具调用收敛为 Advisor 链上一个可组合、可替换、可观测的循环组件,而所有工具最终都归约为 ToolCallback 这一统一抽象。
工具调用的完整协议流程
先读官方 Quick Start,感受最小闭环。定义工具只需给一个方法加上 @Tool 注解:
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.context.i18n.LocaleContextHolder;
class DateTimeTools {
@Tool(description = "Get the current date and time in the user's timezone")
String getCurrentDateTime() {
return LocalDateTime.now()
.atZone(LocaleContextHolder.getTimeZone().toZoneId())
.toString();
}
@Tool(description = "Set a user alarm for the given time, provided in ISO-8601 format")
void setAlarm(String time) {
LocalDateTime alarmTime = LocalDateTime.parse(time, DateTimeFormatter.ISO_DATE_TIME);
System.out.println("Alarm set for " + alarmTime);
}
}
然后把它交给 ChatClient,问一个必须用到它的问题:
ChatModel chatModel = ...;
String response = ChatClient.create(chatModel)
.prompt("Can you set an alarm 10 minutes from now?")
.tools(new DateTimeTools())
.call()
.content();
官方描述的运行时序是:模型先决定调用 getCurrentDateTime() 拿到当前时间,再基于算出的时间调用 setAlarm(...)。Spring AI 自动处理完整往返。把这个例子展开,就是完整的六步协议:
- 请求阶段:Advisor 把"工具定义 + 对话消息 +(可选的)工具上下文"一并发给模型。注意:发给模型的只有工具定义,即名称、描述、入参 JSON Schema;工具上下文不会发给模型。
- 决策阶段:模型阅读工具定义和用户问题,决定"现在该调用哪个工具、参数是什么",然后在响应里返回一个或多个工具调用请求(tool call request)。注意此时工具还没有被执行。
- 分发阶段:
ToolCallingManager根据工具名找到匹配的ToolCallback,把模型给出的 JSON 参数传进去执行。 - 回灌阶段:工具返回结果,框架将其序列化为字符串、追加为对话历史中的工具响应消息(
ToolResponseMessage)。 - 循环阶段:带着"原始问题 + 工具调用记录 + 工具结果"的完整历史再次请求模型。如果模型又要求调用工具,回到第 2 步;否则进入第 6 步。
- 收尾阶段:模型产出不含工具调用的最终响应,返回给调用方。
阻塞式 .call() 和流式 .stream() 都完整支持这个循环。还有一点值得留意:ToolCallingAdvisor 是一个递归 Advisor——它在每一轮迭代中重新进入下游的 Advisor 链,结构化输出的校验重试也复用同一机制。这意味着你在链上挂的任何 Advisor,都会按顺序参与循环的每一轮,而不只是参与第一次请求。
把工具传给 ChatClient 有两个入口,语义不同:
// 每次调用 —— 工具只对这一次请求生效
chatClient.prompt(...)
.tools(new WeatherTools(), currentWeather)
.call();
// 默认工具 —— 从这个 client 构建的每个请求都可用
ChatClient client = ChatClient.builder(chatModel)
.defaultTools(new WeatherTools(), currentWeather)
.build();
这两个方法是"异构"的:POJO 实例、ToolCallback 实例、ToolCallbackProvider 实例以及它们的数组或集合可以混在一起传。一个关键细节:调用级的 .tools(...) 是追加到默认工具上的,两者取并集,而不是替换。文档特别提醒:默认工具对同一 Builder 产出的所有请求共享,有风险等级或破坏性的工具应该用每次调用级的方式挂载,而不是作为默认值。
你现在应该掌握了:工具调用不是模型"远程执行函数",而是一次"模型请求 → 应用执行 → 结果回灌 → 再请求"的应用内循环;Spring AI 把这个循环封装在 Advisor 链里,你可以逐层观察、替换和扩展它。
@Tool 声明与 ToolCallback
官方给出三种定义工具的方式,从最高层到最显式:
| 风格 | 适用场景 | 产出 |
|---|---|---|
声明式 @Tool |
方法由你掌控,想要最少样板代码 | MethodToolCallback |
编程式 MethodToolCallback |
需要对一个方法支撑的工具做程序化控制(如不掌握源码的类、运行时动态构建) | MethodToolCallback |
编程式 FunctionToolCallback |
想暴露 Function / Supplier / Consumer / BiFunction 这样的 lambda 或方法引用 |
FunctionToolCallback |
三者最终都产出 ToolCallback,可以混用。先读声明式方式:@Tool 可以标注在任何方法上——public、包私有、私有,静态或实例方法都可以。注解支持四个属性:
name:工具名,默认取方法名,在提供给模型的工具集合内必须唯一;description:工具做什么、什么时候用。官方用词是"强烈建议"——没有描述,模型就完全没有依据判断该不该调用它;returnDirect:把结果直接返回给调用方,而不是回灌给模型;resultConverter:指定结果转换器。
再看 MethodToolCallback 的编程式写法,适合"你不控制源码类"或"运行时动态注册"的场景:
Method method = ReflectionUtils.findMethod(WeatherTools.class, "getWeather", String.class);
MethodToolCallback callback = MethodToolCallback.builder()
.toolDefinition(ToolDefinitions.builder(method)
.description("Get the current weather for a given city")
.build())
.toolMethod(method)
.toolObject(new WeatherTools())
.build();
toolObject 对实例方法是必需的,静态方法则可省略。而 FunctionToolCallback 则把 lambda 直接变成工具:
FunctionToolCallback callback = FunctionToolCallback.builder("currentWeather", weatherService::getWeather)
.description("Get the weather in location")
.inputType(WeatherRequest.class)
.build();
inputType 用于生成入参 JSON Schema,是模型理解如何调用这个 lambda 的唯一依据。
2.0 还有一个重要的 Spring 集成点:ToolCallback Bean 自动发现。Spring AI 会自动发现应用上下文里所有的 ToolCallback Bean,通过 ToolCallbackResolver 提供按名查找:
@Configuration(proxyBeanMethods = false)
class WeatherToolsConfig {
@Bean
ToolCallback currentWeather(WeatherService weatherService) {
return FunctionToolCallback.builder("currentWeather", weatherService::getWeather)
.description("Get the weather in location")
.inputType(WeatherRequest.class)
.build();
}
}
需要特别注意的是迁移点:Spring AI 2.0 移除了 1.x 的 SpringBeanToolCallbackResolver 模式——裸的 Function Bean 按名字通过 toolNames(...) 解析的玩法已经不存在,工具必须注册为显式的 ToolCallback Bean。
为了把抽象说透,我们直接读 ToolCallback 接口本身,它是整个体系的"最小公倍数":
public interface ToolCallback {
/** 模型用来决定何时、如何调用工具的定义。 */
ToolDefinition getToolDefinition();
/** 控制工具如何处理方式的元数据(例如 return-direct)。 */
ToolMetadata getToolMetadata();
/** 用给定的 JSON 输入执行并返回结果。 */
String call(String toolInput);
/** 用给定的输入和工具上下文执行。 */
String call(String toolInput, ToolContext toolContext);
}
配套的 ToolDefinition 就是"模型看到的契约"——name()、description()、inputSchema() 三件套,其中 inputSchema() 是入参的 JSON Schema 字符串。对你自己的 ToolCallback 实现(例如代理一个远程工具源,MCP 集成正是这么做的),可以直接用 ToolDefinition.builder() 手写 Schema,也可以在方法工具场景下用 ToolDefinitions.from(method) 让框架自动生成。
你现在应该掌握了:三种定义方式只是通往同一个 ToolCallback 抽象的不同路径;选择声明式还是编程式,取决于你对方法源码的控制力和对工具定义的运行时控制需求。
方法参数与 JSON Schema 映射
模型不是"读你的 Java 方法签名"来理解工具的——它读的是入参 JSON Schema。生成这个 Schema 的是 Spring AI 的 JsonSchemaGenerator,它从方法或函数的参数列表出发,支持若干定制注解。这一节是"模型调对工具"的关键,值得逐条精读官方文档。
参数描述支持四种注解,Spring AI 自有注解优先级最高:
| 注解 | 来源 | 作用 |
|---|---|---|
@ToolParam(description = "...") |
Spring AI | 参数描述,优先级最高 |
@JsonClassDescription(description = "...") |
Jackson | 参数描述 |
@JsonPropertyDescription(description = "...") |
Jackson | 参数描述 |
@Schema(description = "...") |
Swagger | 参数描述 |
必填与可选同样有多层注解,按优先级排列:@ToolParam(required = false)(Spring AI)→ @JsonProperty(required = false)(Jackson)→ @Schema(required = false)(Swagger)→ @Nullable(Spring Framework / JSpecify)。默认所有参数都是必填的。
官方在这里有一句非常实战的提醒:正确定义必填状态对减少幻觉至关重要。标记为必填的参数会强制模型给出值;如果一个参数标了必填、但模型根本没有途径确定它的值,模型就会编造一个。比如你的工具有个"用户身份证号"参数标成必填,模型手里根本没有这个信息,它大概率会瞎编一个看起来像身份证号的东西填进去。解决之道是:要么把参数改成可选,要么通过工具上下文(下一节)把真实值传进去。
配合 @ToolParam 描述单个参数的例子:
class WeatherTools {
@Tool(description = "Get the weather for a city at a specific time")
public String getWeather(
@ToolParam(description = "City name") String city,
@ToolParam(description = "Time in ISO-8601 format", required = false) String at) {
return weatherService.fetch(city, at);
}
}
顺带要掌握的是"方法工具的局限":文档明确列了几类不能作为 MethodToolCallback 参数或返回值的类型——Optional(请改用 @Nullable 或 @ToolParam(required = false))、异步类型(CompletableFuture、Future)、响应式类型(Flow、Mono、Flux)、函数式类型(Function、Supplier、Consumer)。原因写得很直白:这些类型无法序列化给模型,也没有模型能理解的 Schema 表示。
那么你可能会问:工具方法本身就是同步阻塞的,耗时操作怎么办?这里需要诚实说明:当前官方工具调用文档中没有提供工具执行超时的内建配置项,超时控制需要你自己在工具方法内部实现(比如用 CompletableFuture.get(timeout) 包一层,或对接口调用方设置超时)。这一细节以当前版本官方文档为准。
你现在应该掌握了:模型对工具的全部理解来自自动生成的 JSON Schema;参数描述和必填标注不是"注释",而是直接决定模型调用质量(乃至是否幻觉)的提示词工程。
工具执行上下文 ToolContext
工具经常需要"模型不该看见、但工具执行必须有"的数据:租户 ID、用户 ID、请求作用域信息。官方文档用一整节说明 ToolContext 的定位:它是纯工具内部使用的通道,这些数据不会发给模型。
协议上,工具上下文走的是一条与工具调用结果完全分离的路径。回顾整体流程:聊天请求同时携带工具定义和工具上下文(步骤 1),但 Spring AI 只把工具定义发给模型(步骤 2 只发生在模型侧);模型返回工具调用请求后,框架分发工具(步骤 3),并在调用时直接把工具上下文递交给工具方法(步骤 3'')——上下文全程不经过模型。工具返回结果(步骤 4),结果回灌模型(步骤 5),最终回答返回(步骤 6)。
代码层面只需在工具方法里声明一个 ToolContext 参数:
class CustomerTools {
@Tool(description = "Retrieve customer information")
Customer getCustomerInfo(Long id, ToolContext toolContext) {
return customerRepository.findById(id, toolContext.getContext().get("tenantId"));
}
}
调用侧通过 .toolContext() 传入:
String response = ChatClient.create(chatModel)
.prompt("Tell me more about the customer with ID 42")
.tools(new CustomerTools())
.toolContext(Map.of("tenantId", "acme"))
.call()
.content();
这里的 tenantId 从请求侧直达工具方法,模型对此一无所知——这既是多租户隔离的正确姿势,也是防止敏感 ID 泄漏进模型上下文的正确姿势。文档还交代了一个合并语义:如果工具上下文同时设了默认值和调用时值,两个 Map 会合并,运行时值优先于默认值。
回到上一节的幻觉问题,现在你有两条互补的手段:不该让模型知道的值(租户 ID、用户身份)走 ToolContext;该让模型知道但可能拿不到的值(可选的时间、筛选条件)标 required = false。这两条合起来,就是你控制模型输入质量的主要抓手。
你现在应该掌握了:ToolContext 是一条绕过模型、直抵工具的旁路数据通道,多租户、用户身份这类敏感信息应当走这里而不是塞进工具参数。
错误处理、调用上限与循环控制
工具会失败,模型会失控,这两件事官方都给了明确的机制。
错误处理。工具抛异常时,框架把它包装成 ToolExecutionException,交给 ToolExecutionExceptionProcessor 处理。这个处理器只有两条路:要么把错误信息作为消息回灌给模型(模型可以自己恢复或道歉),要么重新抛出让调用方处理。默认实现 DefaultToolExecutionExceptionProcessor 的策略是:任何 RuntimeException 的消息回灌给模型;受检异常和 Error 一律重新抛出。可以通过属性或替换 Bean 调整:
spring:
ai:
tools:
throw-exception-on-error: false # true = 所有工具错误抛给调用方;false = 回灌给模型
自己实现 ToolCallback 时,执行失败要从 call() 里抛 ToolExecutionException——这是处理器约定的异常类型。
调用上限。防失控循环靠 DefaultToolCallingManager 的上限机制:
ToolCallingManager toolCallingManager = ToolCallingManager.builder()
.maxCallsPerTool(40) // 每个工具的默认上限
.maxCallsPerTool("search", 10) // 针对单个工具覆盖
.excludeToolFromLimit("getCurrentWeather") // 这个工具不计上限
.maxTotalToolCalls(150) // 所有工具合计上限
.onLimitExceeded(ToolCallLimitBehavior.THROW) // 或 RETURN_ERROR_RESPONSE
.build();
这些默认值(DEFAULT_MAX_CALLS_PER_TOOL 为 40,DEFAULT_MAX_TOTAL_TOOL_CALLS 为 150)即使你不调用这些方法也生效;unlimitedCallsPerTool() / unlimitedTotalToolCalls() 才能彻底关闭。计数只统计当前轮次内最后一条 UserMessage 之后的 ToolResponseMessage,所以 Advisor 回放历史不会污染计数;也因为计数是从 prompt.getInstructions() 每次现算的,单个 ToolCallingManager 实例可以安全地跨并发请求共享。超限时的行为有两种:THROW(默认)抛出携带工具名、上限值和已执行部分结果的 ToolCallLimitExceededException,由 ToolCallingAdvisor 转成带专用 finish reason 的最终响应;RETURN_ERROR_RESPONSE 则合成一个错误工具响应回灌模型,让对话继续而不是终止。也可以全部走配置项:spring.ai.tools.limits.max-calls-per-tool-default、spring.ai.tools.limits.max-total-tool-calls、spring.ai.tools.limits.on-limit-exceeded 等。
循环的所有权。多数场景用自动循环就够了,但有些场景你需要自己拥有每一轮:工具执行前要挂一道外部审批、要把中间进度转发到 SSE/WebSocket、轮次之间要加条件逻辑、要根据旁路信号停循环。这时可以在请求级用 AdvisorParams.toolCallingAdvisorAutoRegister(false) 关闭自动注册,自己检测 ChatResponse 里的工具调用、通过 ToolCallingManager.executeToolCalls(...) 逐轮执行——每一轮都可观察、可中断。要全局关闭则设 spring.ai.chat.client.tool-calling.enabled=false。
文档同时给出了一个重要权衡:自己驱动循环会绕过可观测性、Advisor 组合和"单一 ToolAdvisor"约束。多数需求——包括把中间进度推到 UI——用在循环内放自定义 Advisor(实现 ToolAdvisor 标记接口并继承 ToolCallingAdvisor 的四个钩子方法 doInitializeLoop / doBeforeCall / doAfterCall / doFinalizeLoop,以及对应的 stream 变体)更简单也更可组合。通过注册一个自定义 ToolCallingAdvisor.Builder<?> Bean(自动配置带 @ConditionalOnMissingBean),你的自定义循环就能无缝接入自动配置体系——ToolSearchToolCallingAdvisor 正是用这个机制实现的"渐进式工具披露"。
你现在应该掌握了:错误默认回灌模型、超限默认抛异常,两个默认值背后都有明确的扩展点;只有当你真的要审批、转发进度这类场景时,才值得接管循环,否则优先用循环内的自定义 Advisor。
记忆与工具循环的位置关系
这是一个 Spring AI 2.0 新引入、且极易踩坑的话题,值得单独一节。工具循环要追加工具调用和工具响应消息,记忆 Advisor 要在某个时刻读写对话历史——两者谁在循环内、谁在循环外,直接决定记忆存到什么。
ToolCallingAdvisor 的默认顺序是 HIGHEST_PRECEDENCE + 300,而 MessageChatMemoryAdvisor 的默认顺序是 HIGHEST_PRECEDENCE + 200——比工具循环低,因此默认位于循环外。行为是:循环开始前加载一次历史;循环结束后只持久化最终的用户消息和最终助手消息;工具调用请求和工具响应消息它根本看不到。这是安全默认,兼容所有 ChatMemoryRepository 实现,也与 1.x 的行为一致。
如果你希望模型在后续轮次能看到完整的工具调用记录——它之前试过什么、调了什么工具、返回了什么——把记忆 Advisor 的 order 调到大于 ToolCallingAdvisor.DEFAULT_ORDER,即放进循环内:
var chatMemoryAdvisor = MessageChatMemoryAdvisor.builder(chatMemory)
.order(BaseAdvisor.HIGHEST_PRECEDENCE + 400) // 循环内(ToolCallingAdvisor 之后)
.build();
var chatClient = ChatClient.builder(chatModel)
.defaultAdvisors(chatMemoryAdvisor)
.build();
放进循环内有三个必须知道的前提。其一,为避免重复写入,ToolCallingAdvisor 的内部对话历史必须关闭;使用自动注册的那个时 DefaultChatClient 检测到循环内有 MemoryAdvisor 会自动关闭,手动构建则要自己调 .disableInternalConversationHistory()。其二,关闭内部历史只影响转发给链上其余部分的内容,调用上限的计数不受影响——它始终能看到当前轮次的完整历史。其三,后端兼容性:不是所有 ChatMemoryRepository 都能存工具消息,存储层必须知道如何序列化 ToolResponseMessage 和工具调用请求消息。截至 2.0,内置支持完整消息集合的只有 InMemoryChatMemoryRepository、RedisChatMemoryRepository 和 Neo4jChatMemoryRepository,放进循环内才是安全的。
你现在应该掌握了:记忆 Advisor 的 order 值是它相对于工具循环的位置开关;默认在循环外(只存最终消息),需要完整工具记录时显式调大 order 进循环内,但要同时满足"关闭内部历史"和"存储层支持工具消息"两个前提。
结构化输出与 BeanOutputConverter
工具有了,如何让模型的自由文本回答变成你代码里可直接使用的 Java 对象?这就是结构化输出(Structured Output)。官方文档把这件事拆成"调用前"和"调用后"两段:调用前,转换器把格式指令追加到提示词里,引导模型生成期望的结构;调用后,转换器解析模型文本并映射为目标类型的实例。高层 API 的 .entity(...) 就建立在这套抽象之上。
核心接口 StructuredOutputConverter<T> 同时继承了 Converter<String, T> 和 FormatProvider:
public interface StructuredOutputConverter<T> extends Converter<String, T>, FormatProvider {
/**
* 返回这次 LLM 调用的结构化输出的 JSON Schema,
* 如果不可用则返回 NO_JSON_SCHEMA ("")。
*/
default String getJsonSchema() {
return NO_JSON_SCHEMA;
}
}
FormatProvider 提供的格式指令长这样(官方原文):要求响应为 JSON 格式、数据结构匹配指定 Java 类、不要任何解释、只输出符合 RFC8259 的 JSON。内置实现有五个:AbstractConversionServiceOutputConverter(预配置 GenericConversionService,无默认 FormatProvider)、AbstractMessageOutputConverter(预配置 MessageConverter,无默认 FormatProvider)、BeanOutputConverter(核心)、MapOutputConverter(产出 Map<String, Object>)、ListOutputConverter(产出逗号分隔的 List)。
重点是 BeanOutputConverter:给它一个目标 Java 类(或 ParameterizedTypeReference),它的 FormatProvider 会引导模型生成符合从该类推导出的 DRAFT_2020_12 JSON Schema 的 JSON 响应,随后用 JsonMapper 把 JSON 反序列化成目标类的实例。典型用法:
record ActorsFilms(String actor, List<String> movies) {}
// 高层 ChatClient API
ActorsFilms actorsFilms = ChatClient.create(chatModel).prompt()
.user(u -> u.text("Generate the filmography of 5 movies for {actor}.")
.param("actor", "Tom Hanks"))
.call()
.entity(ActorsFilms.class);
底层 ChatModel 路径需要你自己拼模板,把 {format} 占位符替换为 converter.getFormat():
BeanOutputConverter<ActorsFilms> beanOutputConverter = new BeanOutputConverter<>(ActorsFilms.class);
String format = beanOutputConverter.getFormat();
String template = """
Generate the filmography of 5 movies for {actor}.
{format}
""";
Generation generation = chatModel.call(
PromptTemplate.builder()
.template(template)
.variables(Map.of("actor", "Tom Hanks", "format", format))
.build().create()).getResult();
ActorsFilms result = beanOutputConverter.convert(generation.getOutput().getText());
几个实用细节。其一,生成 Schema 的属性顺序可以用 @JsonPropertyOrder({"actor", "movies"}) 精确控制,对 record 和普通类都有效。其二,泛型目标用 ParameterizedTypeReference 构造器,例如 new BeanOutputConverter<>(new ParameterizedTypeReference<List<ActorsFilms>>() {}),.entity(new ParameterizedTypeReference<List<ActorsFilms>>() {}) 同样支持。其三,文档明确提醒:StructuredOutputConverter 是尽力而为,模型不保证按要求返回,建议结合 Schema 校验使用;并且工具调用场景不需要它——工具调用本身就默认提供结构化输出。其四,2.0 给接口加了 getJsonSchema() 默认方法,它是转换器参与 useProviderStructuredOutput() 和 validateSchema() 两个可靠性开关的桥梁:实现了它,两个开关对你的自定义转换器同样生效;保持默认,则两个开关对该转换器是空操作。
自定义转换器的经典场景:模型经常把 JSON 包在 markdown 代码围栏里,而 BeanOutputConverter 非常严格,碰到 "Here's..." 的第一个字符就抛异常。官方给出的解法是包一层宽容转换器,用正则剥掉围栏后再委托给默认解析器:
public class LenientJsonOutputConverter<T> implements StructuredOutputConverter<T> {
private static final Pattern FENCE = Pattern.compile("```(?:json)?\\s*([\\s\\S]*?)```");
private final BeanOutputConverter<T> delegate;
public LenientJsonOutputConverter(Class<T> targetType) {
this.delegate = new BeanOutputConverter<>(targetType);
}
@Override
public String getFormat() { return delegate.getFormat(); }
@Override
public String getJsonSchema() { return delegate.getJsonSchema(); }
@Override
public T convert(String source) {
var matcher = FENCE.matcher(source);
String json = matcher.find() ? matcher.group(1).trim() : source.trim();
return delegate.convert(json);
}
}
调用时把转换器实例传给 .entity(...) 而不是传 Class。由于它把 getJsonSchema() 也委托给了底层 BeanOutputConverter,validateSchema() 和 useProviderStructuredOutput() 两个开关照常生效。如果需要 YAML、CSV 这类非 JSON 格式,则需要从零实现 StructuredOutputConverter——自己写 getFormat() 提示词和自己的 convert(...) 解析器;此时保持 getJsonSchema() 为默认,两个可靠性开关自动让位给纯提示词路径。
你现在应该掌握了:结构化输出 = 提示词格式指令 + 响应解析,二者通过 StructuredOutputConverter 接口合流;BeanOutputConverter 以 DRAFT_2020_12 Schema 为核心,配合 ParameterizedTypeReference、@JsonPropertyOrder 和宽容的装饰器转换器,覆盖绝大多数落库前的结构化需求。
把业务能力暴露给模型的安全边界
最后落到安全。官方文档反复强调一个事实和一个姿态:模型永远拿不到工具背后的 API 直接访问权,所有执行都在你的应用内;而你要做的,是主动管理"模型能请求什么、能执行什么"。
几条文档中明示的安全手段:
- 工具清单本身即边界。通过
.tools(...)/.defaultTools(...)精确控制模型在本次请求里能看到哪些工具。文档建议:风险等级高的工具、有破坏性的工具,用每次调用级挂载,绝不作为defaultTools全局暴露。 - 慎用解析回退(resolution fallback)。默认情况下,只有挂在请求上的工具才能被执行。开启
spring.ai.tools.resolution.fallback.enabled=true后,凡是解析器能按名找到的都可被模型点名执行——无论请求是否挂载,包括高风险工具。文档的警告很直白:只有当你完全掌控解析器暴露的工具时才开启。 - MCP 工具要过滤。MCP provider 不会被自动注册进
ChatClient(避免启动时对所有 MCP 服务器发网络请求),需要显式注入接线;对接外部 MCP 服务器时,用McpToolFilter按服务器身份、工具名或描述筛选进入命名空间的工具,限制不可信服务器的"爆炸半径"。本地@Tool和远程 MCP 工具共享同一ToolCallback接口可混用,但重名只会在 MCP 一侧处理(DefaultMcpToolNamePrefixGenerator给跨服务器重名加前缀),本地工具与远程工具重名需要你自行改名或过滤。 - 敏感数据走
ToolContext,绝不进参数。租户 ID、用户 ID、内部令牌走工具上下文;必要时给工具方法加参数校验和业务级权限检查(框架把工具调用视作一次普通的应用内方法调用,你已有的权限体系完全适用)。 - 审计与可观测性。Spring AI 以
spring.ai.tool为观测名发出 Micrometer observation,包含工具名、定义元数据、执行时长和 tracing 上下文;工具调用的参数与结果默认不导出为 span 属性(出于敏感性考虑),需要显式开启。所有主操作在 DEBUG 级别打日志(logging.level.org.springframework.ai=DEBUG)。 - 结合调用上限做纵深防御。即使模型行为异常,每个工具 40 次、总计 150 次的默认上限也能把失控循环的代价限制在一轮对话之内。
踩坑经验
以下每组都来自官方文档明示的约束或常见误用。
坑 1:以为模型会直接"执行"工具。
❌ 错误做法:把 ChatModel 当工具执行器,直接调用 chatModel.call(prompt) 后期待工具已经被跑完并拿到业务结果。
✅ 正确做法:走 ChatClient,由自动注册的 ToolCallingAdvisor 驱动"请求→执行→回灌"循环;只有需要完全控制每一轮时才用 AdvisorParams.toolCallingAdvisorAutoRegister(false) 自己调 ToolCallingManager.executeToolCalls(...)。注意 1.x 时代 ChatModel 内部执行工具循环的机制已移除——直接调 ChatModel 带工具的请求只会把工具定义发给模型并返回响应,响应里的工具调用不会自动执行。
坑 2:给参数乱标必填,诱发模型幻觉。
❌ 错误做法:工具方法参数全部默认必填(不写任何 required 标注),包括模型根本无从得知取值的字段,例如内部租户号、用户证件号。
✅ 正确做法:必填状态即提示词工程——模型拿不到的值要么标 @ToolParam(required = false) 或 @Nullable 设为可选,要么干脆不放进参数签名,改用 ToolContext 从请求侧注入。文档原话:标了必填但模型无法确定取值的参数,模型会编一个。
坑 3:记忆 Advisor 放进循环内却忘了存储层不支持。
❌ 错误做法:为了"让模型看到历史工具记录",把 MessageChatMemoryAdvisor 的 order 调到 ToolCallingAdvisor.DEFAULT_ORDER 之上,但底层 ChatMemoryRepository 仍是只支持普通用户/助手消息的 JDBC 实现,结果工具消息序列化失败或静默丢失。
✅ 正确做法:进循环前先确认存储层——截至 2.0,内置只有 InMemoryChatMemoryRepository、RedisChatMemoryRepository、Neo4jChatMemoryRepository 支持完整消息集合;自动注册的 ToolCallingAdvisor 检测到循环内有 MemoryAdvisor 会自动关闭内部历史,手动构建则需自己调 .disableInternalConversationHistory()。
坑 4:工具抛异常导致整个请求 500。
❌ 错误做法:把工具里的业务异常直接抛给框架,期望调用方拿到清晰错误——结果默认处理器只把 RuntimeException 的消息回灌给模型,受检异常和 Error 直接向上抛,调用方收到的是包装过的异常。
✅ 正确做法:先想清楚每个工具失败的语义——可恢复的错误(如参数无效)保持默认回灌让模型自我纠正;不可恢复的错误设 spring.ai.tools.throw-exception-on-error=true 或替换 ToolExecutionExceptionProcessor Bean,让调用方统一兜底。自己实现 ToolCallback 时,失败统一抛 ToolExecutionException。
坑 5:默认工具挂全局,把危险操作暴露给所有请求。
❌ 错误做法:ChatClient.builder(chatModel).defaultTools(new AllToolsIncludingDeleteUser()),所有请求共享完整工具集。
✅ 正确做法:常规、只读工具进 defaultTools;有风险等级或破坏性的工具(删除、转账、发送)每次调用用 .tools(...) 显式挂载,并按需叠加工具白名单/权限检查。同理,非必要不开启 spring.ai.tools.resolution.fallback.enabled——它会让解析器里所有工具(包括没挂到本次请求的)都可被模型点名执行。
坑 6:把敏感上下文塞进工具参数。
❌ 错误做法:为了让工具拿到 tenantId,把它声明为 @ToolParam 参数,模型每次调用都必须"填"它——既污染 Schema 又泄露数据。
✅ 正确做法:.toolContext(Map.of("tenantId", "acme")) 传入,工具方法签名里声明 ToolContext 参数读取。上下文直达工具、不经过模型;默认值与调用值按 Map 合并,运行时优先。
坑 7:MCP 工具与本地工具重名。
❌ 错误做法:本地 @Tool 方法和远程 MCP 服务器工具同名,指望框架自动消重。
✅ 正确做法:文档明确重名只会在 MCP 一侧被处理(DefaultMcpToolNamePrefixGenerator 给跨服务器重名加前缀),本地与远程重名要你自己改名,或用 McpToolFilter 把远程那个过滤掉。混合场景始终记得先过滤再暴露。
最佳实践清单
- 定义工具优先用
@Tool声明式,描述写清楚"做什么 + 什么时候用";需要动态注册或 lambda 时再退到MethodToolCallback/FunctionToolCallback。 - 所有提供给模型的工具名在工具集合内保持唯一;Spring Boot 项目把工具定义成
ToolCallbackBean,一处定义、注入复用。 - 参数描述用
@ToolParam,必填状态想清楚再标;模型无法推断取值的参数标可选或改走ToolContext。 - 耗时工具自己实现超时(当前版本官方文档无内建超时配置),并在工具内部做业务校验。
- 错误处理显式决策:可恢复错误回灌模型,不可恢复错误交给调用方(属性或 Bean 两个入口二选一)。
- 保留默认调用上限,只对确有高频调用需求的工具做豁免;
RETURN_ERROR_RESPONSE适合希望对话不中断的场景。 - 高风险工具每次调用显式挂载,绝不做
defaultTools;不必要时关闭 resolution fallback。 - 记忆 Advisor 默认放在循环外;确需完整工具记录时再进循环内,并核对存储层能力。
- 工具数量上规模(30+,或 MCP 聚合数百个)时启用
ToolSearchToolCallingAdvisor:一个属性spring.ai.chat.client.tool-search-advisor.enabled=true开启渐进式工具披露。 - 生产环境开启可观测性(
spring.ai.toolobservation),谨慎再谨慎地开启参数/结果的 span 导出;排障时临时开 DEBUG 日志。 - 结构化输出优先
.entity(Class)/.entity(ParameterizedTypeReference);解析失败时考虑宽容的装饰器转换器而不是换模型。 - 记住:工具调用本身就是结构化输出,工具场景不要用
StructuredOutputConverter多此一举。
总结
- Spring AI 2.0 把工具调用循环从
ChatModel内部黑盒重构为ChatClientAdvisor 链上的ToolCallingAdvisor,循环可组合、可观测、可替换,且链上强制只有一个 ToolAdvisor。 - 一切工具归约为
ToolCallback:@Tool声明式、MethodToolCallback、FunctionToolCallback三条路径自由混用;模型看到的只有ToolDefinition(名称、描述、入参 JSON Schema)。 - 参数描述与必填标注是直接作用于模型行为的提示词工程,标错必填会诱发幻觉;
ToolContext提供绕过模型的旁路数据通道,是多租户与敏感数据的正确承载方式。 - 错误默认回灌模型、调用上限默认生效(单工具 40 次、总计 150 次),两个默认行为都有属性与 Bean 两级扩展点;自己接管循环前先考虑循环内的自定义 Advisor。
- 结构化输出以
BeanOutputConverter为核心:DRAFT_2020_12 Schema 引导 + 反序列化解析,配合getJsonSchema()桥接validateSchema()与useProviderStructuredOutput()两个可靠性开关。 - 安全边界是主动的:工具清单即边界、危险工具按调用挂载、MCP 工具先过滤、敏感数据走
ToolContext、异常语义显式决策——模型永远只是"提请求的人",执行权与责任都在你的应用里。
参考资料
- Spring AI 官方文档 — Tool Calling: https://docs.spring.io/spring-ai/reference/api/tools.html
- Spring AI 官方文档 — Output Converters: https://docs.spring.io/spring-ai/reference/api/structured-output-converter.html
- 版本说明:文中 API 与配置项以当前稳定版(2.0.1)官方文档为准;2.1 引入的变更(如社区项目 spring-ai-session 纳入计划)以后续版本文档为准。