Spring AI 2.0.1 深度解析-第 09 章 工具调用(上):@Tool 注解、ToolDefinition 与 JSON Schema 生成
本系列所有结论均基于
spring-ai-2.0.1(Spring AI 2.0.1)源码逐行取证。文件路径相对仓库根,行号对应源码实际行号。关键代码原样摘录,不作改写。
9.0 本章导读
工具调用(Tool Calling / Function Calling)是聊天机器人与智能体的分界线。官方文档 tools.adoc:5 的开篇第一句就点明了这一点:
Tool calling --- the ability for an AI model to invoke application-defined functions and act on their results --- is the essential building block of agentic AI systems. A model that can only generate text is a chatbot; a model that can discover information, take action, and loop until a goal is reached is an agent.
但这段话里有一句更值得琢磨的:
Although we speak of "tool calling" as a model capability, it is the application that owns the tool calling logic . The model can request a tool call and provide input arguments; the application is responsible for executing the tool and returning the result. The model never receives direct access to the APIs behind your tools --- a critical security consideration.
模型"调用"工具,是个拟人化的说法。真实情况是:模型返回一段 JSON 说"我想调 getWeather,参数是 {city: "北京"}",然后你的进程去执行,把结果拼回消息列表,再发给模型。整个过程模型碰不到你的 API、你的数据库、你的私钥。
Spring AI 把这件事拆成了泾渭分明的两半:
| 半边 | 职责 | 本章/下一章 |
|---|---|---|
| 描述(静态) | 把一个 Java 方法变成模型能理解的 name + description + inputSchema |
本章 09.1--09.9 |
| 执行(动态) | 收到模型请求后,解析参数、调用方法、转换结果、处理异常 | 第 10 章 |
| 循环(编排) | 拿结果回喂模型,迭代直到不再请求工具 | 第 11 章 |
本章覆盖第一半(描述),第 10 章覆盖第二半(执行:扫描、参数绑定、结果转换、异常处理),第 11 章覆盖第三半(ToolCallingManager 与 ToolCallingAdvisor 的递归循环)。
9.0.1 本章会纠正的几个直觉
在开始之前,先把本章将要证明的四条反直觉结论摆出来,你可以带着怀疑往下读:
结论一:同一个方法,@Tool 写了空 description 和完全不写 @Tool,得到的 description 不一样。 更离谱的是:@Tool 写了空 description 时,Spring AI 返回的是原始 camelCase 方法名 (getWeather),而 FunctionToolCallback 路径下返回的是切分后的 (Get weather)。两条路径的默认值处理逻辑完全不同。(§9.3.3)
结论二:required 判定有两套独立实现。 方法参数走 JsonSchemaGenerator.isMethodParameterRequired()(静态私有方法),POJO 字段走 AbstractSpringAiSchemaModule.checkRequired()(victools 的回调)。两者语义相似但代码完全独立,且只有后者处理 Kotlin 默认值。(§9.9)
结论三:工具方法抛受检异常,不仅不会变成给模型的错误消息,反而会直接穿透到调用方。 DefaultToolExecutionExceptionProcessor.process() 第 66-70 行:cause 不是 RuntimeException(包括 IOException、Error)时直接 throw exception 。官方文档强调的"错误回喂给模型"这个能力,只对 RuntimeException 生效。(第 10 章 §10.4.3)
结论四:void 工具方法的返回值是字符串 "Done",而不是 "null"。 DefaultToolCallResultConverter 第 48-51 行对 Void.TYPE 做了特判。这是全库唯一一处对返回类型(而非返回值)做分支的地方。(第 10 章 §10.3)
这四条都不是靠读文档能得到的------文档里 tools.adoc 对第一条的描述甚至是错的方向(§9.3.3 会对照)。
9.0.2 本章的地图
less
定义阶段(编译期/启动期)
@Tool / @ToolParam(注解)
↓ MethodToolCallbackProvider 扫描
MethodToolCallback { ToolDefinition + ToolMetadata + Method + toolObject }
↓ ToolDefinitions.from(method)
ToolDefinition { name, description, inputSchema }
↓ JsonSchemaGenerator.generateForMethodInput(method)
JSON Schema(Draft 2020-12 字符串)
执行阶段(运行期)
模型返回 arguments JSON 字符串
↓ MethodToolCallback.call(toolInput, toolContext)
extractToolArguments → Map<String,Object>
↓ buildMethodArguments(按 parameter.getName() 取值!)
Object[] methodArguments
↓ callMethod(反射 invoke)
Object result
↓ toolCallResultConverter.convert(result, returnType)
String → 塞回 ToolResponseMessage → 再发模型
9.1 五层类型体系
Spring AI 2.0.1 的工具体系由五个核心类型构成,全部 @since 1.0.0,到 2.0.1 签名零变更。这是全库最稳定的一块。
java
// spring-ai-model/src/main/java/org/springframework/ai/tool/ToolCallback.java:33
33→public interface ToolCallback {
34→
35→ Log logger = LogFactory.getLog(ToolCallback.class);
36→
40→ ToolDefinition getToolDefinition();
45→ default ToolMetadata getToolMetadata() {
46→ return ToolMetadata.builder().build();
47→ }
53→ String call(String toolInput);
59→ default String call(String toolInput, @Nullable ToolContext toolContext) {
60→ if (toolContext != null && !toolContext.getContext().isEmpty()) {
61→ if (logger.isInfoEnabled()) {
62→ logger.info("By default the tool context is not used, "
63→ + "override the method 'call(String toolInput, ToolContext toolcontext)' to support the use of tool context."
64→ + "Review the ToolCallback implementation for " + getToolDefinition().name());
65→ }
66→ }
67→ return call(toolInput);
68→ }
69→
70→}
9.1.1 三个方法的分工
| 方法 | 必需性 | 语义 |
|---|---|---|
getToolDefinition() |
抽象 | 给模型看的:名字、描述、入参 schema |
getToolMetadata() |
default | 给框架看的:目前只有一个 returnDirect |
call(String) |
抽象 | 执行,入参是 JSON 字符串 |
call(String, ToolContext) |
default | 执行,附带上下文 |
注意第 35 行:logger 是接口字段 。Java 接口字段隐含 public static final,所以这是所有 ToolCallback 共享的一个 Log 实例,名字叫 org.springframework.ai.tool.ToolCallback。
这意味着:你在日志配置里按 logger 名过滤时,所有工具回调的这条日志都归到同一个 logger 名下 ,看不出是哪个工具。想区分,得在实现类里用 getToolDefinition().name()------而 default 方法第 64 行确实这么做了,把名字拼进了消息末尾。
9.1.2 call(String, ToolContext) 的 default 是个"沉默的陷阱"
第 59-68 行:如果你只实现了 call(String),那么当框架带着 ToolContext 调用 call(String, ToolContext) 时:
- 第 60 行判断 context 非空 → 进入 if
- 第 62 行打一条 INFO 级别的日志,说"你的实现不支持 tool context,请 override"
- 第 67 行照样调用
call(toolInput),把 context 丢掉
它不抛异常。 只是打日志然后继续。
这在生产环境意味着什么?假设你用 ToolContext 传租户 ID,工具方法里靠它做数据隔离。如果你自定义的 ToolCallback 忘了 override 两参版本:
- 租户 ID 被静默丢弃
- 工具照常执行,但拿到的是无租户过滤的数据
- 日志里只有一条 INFO,混在上千条日志里
这是一条越权数据泄漏路径。 而且因为它是 default 方法,你根本不会想到要去 override。
正确做法是:自定义 ToolCallback 时总是 override 两参版本,哪怕你用不上 context:
java
@Override
public String call(String toolInput, @Nullable ToolContext toolContext) {
// 显式声明:本工具不使用 context,但也不允许被静默丢弃
if (toolContext != null && !toolContext.getContext().isEmpty()) {
throw new IllegalStateException(
"Tool " + getToolDefinition().name() + " does not accept a ToolContext, "
+ "but one was provided with keys: " + toolContext.getContext().keySet());
}
return call(toolInput);
}
注意 MethodToolCallback(第 10 章 §10.2)的处理方式完全不同------它是显式校验,不符合预期就抛异常。这才是正确的参照。
9.1.3 ToolCallbackProvider:一个方法,两个静态工厂
java
// spring-ai-model/src/main/java/org/springframework/ai/tool/ToolCallbackProvider.java:27
27→public interface ToolCallbackProvider {
28→
29→ ToolCallback[] getToolCallbacks();
30→
31→ static ToolCallbackProvider from(List<? extends ToolCallback> toolCallbacks) {
32→ return new StaticToolCallbackProvider(toolCallbacks);
33→ }
34→
35→ static ToolCallbackProvider from(ToolCallback... toolCallbacks) {
36→ return new StaticToolCallbackProvider(toolCallbacks);
37→ }
38→
39→}
注意返回的是数组 ToolCallback[],不是 List。 这个选择在 MethodToolCallbackProvider 里造成了一些别扭(第 10 章 §10.1.2 的 flatMap(Stream::of).toArray(...) 双重展平)。
@FunctionalInterface 没标注,但它只有一个抽象方法,可以写成 lambda:
java
ToolCallbackProvider p = () -> new ToolCallback[]{ cb1, cb2 };
两个 from 重载有个陷阱:传 List 走第一个,传可变参数走第二个。但如果你传一个 ToolCallback 数组:
java
ToolCallback[] arr = ...;
ToolCallbackProvider.from(arr); // 走 varargs 版本,正确
而如果传的是 List<ToolCallback> 变量,走 List 版本。都不会有问题------除非你传 null 字面量,此时两个重载都匹配,编译报错(歧义)。
9.1.4 StaticToolCallbackProvider 的"不可变"是假的
java
// spring-ai-model/src/main/java/org/springframework/ai/tool/StaticToolCallbackProvider.java:50
50→public class StaticToolCallbackProvider implements ToolCallbackProvider {
51→
52→ private final ToolCallback[] toolCallbacks;
61→ public StaticToolCallbackProvider(ToolCallback... toolCallbacks) {
62→ Assert.notNull(toolCallbacks, "ToolCallbacks must not be null");
63→ this.toolCallbacks = toolCallbacks;
64→ }
74→ public StaticToolCallbackProvider(List<? extends ToolCallback> toolCallbacks) {
75→ Assert.noNullElements(toolCallbacks, "toolCallbacks cannot contain null elements");
76→ this.toolCallbacks = toolCallbacks.toArray(new ToolCallback[0]);
77→ }
85→ @Override
86→ public ToolCallback[] getToolCallbacks() {
87→ return this.toolCallbacks;
88→ }
第 28-30 行的 Javadoc 明确写着:
This implementation is thread-safe as it maintains an immutable array of callbacks that is set during construction and cannot be modified afterwards.
"immutable array" 这个说法在 Java 里不成立。 数组本身永远可变:
java
ToolCallback[] mine = provider.getToolCallbacks();
mine[0] = evilCallback; // 编译通过,且修改了 provider 内部状态
第 86-88 行直接 return this.toolCallbacks; ------ 返回内部数组的引用,没有 clone()。Javadoc 第 82-84 行甚至承认了:
@return an array containing all function callbacks provided during construction. The returned array is a direct reference to the internal array, as the callbacks are expected to be immutable.
"as the callbacks are expected to be immutable" ------ 这个"expected"是约定 ,不是保证。没有任何代码强制它。
另外还有两处不对称:
- varargs 构造器(第 61-64 行)只
Assert.notNull,不检查元素为 null - List 构造器(第 74-77 行)只
Assert.noNullElements,不检查 list 本身为 null (toArray会 NPE)
所以 new StaticToolCallbackProvider((ToolCallback) null) 会存一个 null 元素进去,后续 getToolCallbacks() 返回含 null 的数组,调用方遍历时 NPE。
实践建议 :StaticToolCallbackProvider 适合内部短生命周期用途(如 ToolCallbackProvider.from(...) 的结果)。如果要作为长期持有的 bean,用 List.copyOf 自己包一层,或者干脆用 MethodToolCallbackProvider(它是 final 且构造时就做了全量校验,第 10 章 §10.1)。
9.1.5 ToolCallbacks:一个被官方文档用错的工具类
java
// spring-ai-model/src/main/java/org/springframework/ai/support/ToolCallbacks.java:28
28→public final class ToolCallbacks {
29→
30→ private ToolCallbacks() {
31→ }
32→
33→ public static ToolCallback[] from(Object... sources) {
34→ return MethodToolCallbackProvider.builder().toolObjects(sources).build().getToolCallbacks();
35→ }
36→
37→}
整个类只有 1 个方法,4 行代码。它的作用是"把任意带 @Tool 方法的对象转成回调数组"。
但它在 upgrade-notes.adoc:410 的示例代码里被当成了别的东西:
java
// upgrade-notes.adoc:409-412(迁移示例)
ChatOptions options = ToolCallingChatOptions.builder()
.toolCallbacks(ToolCallbacks.from(new MyTools()))
.build();
这里 ToolCallbacks.from(...) 返回 ToolCallback[],而 toolCallbacks(...) 的重载有 toolCallbacks(List<ToolCallback>) 和 toolCallbacks(ToolCallback...)。传数组给 varargs 是合法的,所以这段代码能编译。
但 org.springframework.ai.support.ToolCallbacks 在 2.0.1 里是否还存在? 我确认过:
css
spring-ai-model/src/main/java/org/springframework/ai/support/ToolCallbacks.java
文件确实存在。所以官方文档的示例是对的。但要注意包路径是 org.springframework.ai.support,不是 org.springframework.ai.tool ------ 很多人会 import 错。
9.2 @Tool 注解:四个属性
java
// spring-ai-model/src/main/java/org/springframework/ai/tool/annotation/Tool.java:34
34→@Target({ ElementType.METHOD, ElementType.ANNOTATION_TYPE })
35→@Retention(RetentionPolicy.RUNTIME)
36→@Documented
37→public @interface Tool {
38→
54→ String name() default "";
59→ String description() default "";
64→ boolean returnDirect() default false;
69→ Class<? extends ToolCallResultConverter> resultConverter() default DefaultToolCallResultConverter.class;
70→
71→}
四个属性,全部有默认值,全部 @since 1.0.0,2.x 无任何改动。
9.2.1 name() ------ 唯一有"推荐格式"的属性
第 39-53 行的 Javadoc 是全库少见的详细:
java
39→ /**
40→ * The name of the tool. If not provided, the method name will be used.
41→ * <p>
42→ * For maximum compatibility across different LLMs, it is recommended to use only
43→ * alphanumeric characters, underscores, hyphens, and dots in tool names. Using spaces
44→ * or special characters may cause issues with some LLMs (e.g., OpenAI).
45→ * </p>
46→ * <p>
47→ * Examples of recommended names: "get_weather", "search-docs", "tool.v1"
48→ * </p>
49→ * <p>
50→ * Examples of names that may cause compatibility issues: "get weather" (contains
51→ * space), "tool()" (contains parentheses)
52→ * </p>
53→ */
推荐正则在 ToolUtils.java:52:
java
52→ private static final Pattern RECOMMENDED_NAME_PATTERN = Pattern.compile("^[a-zA-Z0-9_\\.-]+$");
注意这是个 warn,不是 error。 ToolUtils.validateToolName()(第 128-134 行):
java
128→ private static void validateToolName(String toolName) {
129→ Assert.hasText(toolName, "Tool name cannot be null or empty");
130→ if (logger.isWarnEnabled() && !RECOMMENDED_NAME_PATTERN.matcher(toolName).matches()) {
131→ logger.warn("Tool name '" + toolName + "' may not be compatible with some LLMs (e.g., OpenAI). "
132→ + "Consider using only alphanumeric characters, underscores, hyphens, and dots.");
133→ }
134→ }
第 129 行 Assert.hasText 才是唯一的硬约束:名字不能为 null 或空。第 130 行的正则不匹配只打 warn。
这带来一个真实的坑 :如果你不写 name(),默认用方法名。Java 方法名几乎总是符合 [a-zA-Z0-9_$-]+($ 是合法标识符字符,但不在推荐正则里)。
什么情况下方法名会带 $?内部类 / lambda / Kotlin 的某些编译产物。例如:
java
class Outer {
class Inner {
@Tool String query(String q) { ... }
}
}
编译后 Inner 是 Outer$Inner,但方法名 query 本身不带 $,所以没事。
真正会出问题的是:你给 name() 写了 "get weather"(带空格)或者 "查询天气"(中文)。这两种都会:
- 通过
Assert.hasText(非空即可) - 正则不匹配 → 打 warn
- 照常注册
- OpenAI 收到后可能返回 400
中文工具名是个特别值得说的点。 OpenAI 的 function name 要求是 ^[a-zA-Z0-9_-]{1,64}$,中文直接拒绝。而 Spring AI 只打 warn。所以你会看到:
sql
WARN o.s.a.tool.support.ToolUtils - Tool name '查询天气' may not be compatible with some LLMs (e.g., OpenAI). ...
然后启动成功,第一次调用时拿到供应商的 400 错误。错误信息指向的是 HTTP 层,与工具名无关,排查方向会完全跑偏。
§9.20 会给一个启动期强制校验的方案。
9.2.2 description() ------ 最重要却被经常省略的属性
第 56-58 行的 Javadoc 只有一句:
java
56→ /**
57→ * The description of the tool. If not provided, the method name will be used.
58→ * <p>
"The description of the tool. If not provided, the method name will be used."
这句话是错的 ,或者说至少是严重误导------它省略了关键细节,且与实现不符。官方文档 tools.adoc:96 说得更准确:
description--- what the tool does and when to use it. Strongly recommended; without it the model has no guidance on when to call the tool.
真实行为见 §9.3.3。这里先剧透:不写 description,模型会拿到原始 camelCase 方法名(如 getWeather)作为描述 ,而不是切分后的 "Get weather"。对中文场景(方法名是英文)这意味着模型拿到的是一个没有空格的英文驼峰串,理解成本极高。
9.2.3 returnDirect() ------ 一个语义特殊的开关
java
61→ /**
62→ * Whether the tool result should be returned directly or passed back to the model.
63→ */
64→ boolean returnDirect() default false;
false(默认):工具结果塞回消息列表,再发模型,模型基于结果生成最终回答。
true:工具结果直接作为最终回答返回给调用方,不再经过模型。
这个开关的典型用途:
- 确定性转换:工具做的事本身就是最终答案(如"生成并返回一张图"),再让模型复述一遍是浪费
- 规避模型篡改:工具返回的是结构化数据(如 JSON 订单),你不希望模型"改写"它
- 省 token:跳过最后一轮模型调用
它的流转路径是:@Tool.returnDirect → ToolUtils.getToolReturnDirect(method)(ToolUtils.java:85-89)→ ToolMetadata.from(method)(ToolMetadata.java:49-52)→ DefaultToolMetadata.record → ToolCallback.getToolMetadata() → 被 ToolCallingManager 读取。
注意 ToolMetadata 目前只有 returnDirect 一个字段:
java
// spring-ai-model/src/main/java/org/springframework/ai/tool/metadata/ToolMetadata.java:30
30→public interface ToolMetadata {
35→ default boolean returnDirect() {
36→ return false;
37→ }
42→ static DefaultToolMetadata.Builder builder() {
43→ return DefaultToolMetadata.builder();
44→ }
49→ static ToolMetadata from(Method method) {
50→ Assert.notNull(method, "method cannot be null");
51→ return DefaultToolMetadata.builder().returnDirect(ToolUtils.getToolReturnDirect(method)).build();
52→ }
53→}
这是个"未来会扩展"的接口------目前装了个只有单字段的 record:
java
// spring-ai-model/src/main/java/org/springframework/ai/tool/metadata/DefaultToolMetadata.java:25
25→public record DefaultToolMetadata(boolean returnDirect) implements ToolMetadata {
9.2.4 resultConverter() ------ 用反射实例化的类型引用
java
66→ /**
67→ * The class to use to convert the tool call result to a String.
68→ */
69→ Class<? extends ToolCallResultConverter> resultConverter() default DefaultToolCallResultConverter.class;
注意这是 Class<...> 而不是实例------注解只能持有编译期常量。实例化在 ToolUtils.java:91-104:
java
91→ public static ToolCallResultConverter getToolCallResultConverter(Method method) {
92→ Assert.notNull(method, "method cannot be null");
93→ var tool = AnnotatedElementUtils.findMergedAnnotation(method, Tool.class);
94→ if (tool == null) {
95→ return new DefaultToolCallResultConverter();
96→ }
97→ var type = tool.resultConverter();
98→ try {
99→ return type.getDeclaredConstructor().newInstance();
100→ }
101→ catch (Exception e) {
102→ throw new IllegalArgumentException("Failed to instantiate ToolCallResultConverter: " + type, e);
103→ }
104→ }
三个细节:
(1)第 99 行要求无参构造器。 你的自定义 converter 必须有 public 无参构造,否则 IllegalArgumentException。而且错误信息(第 102 行)只说"实例化失败",不说明原因------如果你忘了无参构造器,会看到 Failed to instantiate ToolCallResultConverter: class Xxx 然后一个 NoSuchMethodException 作为 cause 藏在里面。
(2)每个工具类都会 new 一个 converter 实例。 第 99 行在 MethodToolCallbackProvider.getToolCallbacks()(第 99 行)里被调用,也就是说每个工具方法一个 converter 实例。如果一次请求有 20 个工具,就是 20 个 converter 对象。converter 通常是无状态的,这属于可接受但不必要的开销。
(3)无法注入 Spring 依赖。 因为是 newInstance() 而不是从容器取,你的 converter 不能 @Autowired 任何东西。想用 Spring 管理的 converter,只能绕过注解,用 MethodToolCallback.builder().toolCallResultConverter(bean) 编程式注册(第 10 章 §10.2.4)。
9.2.5 @ToolParam:两个属性,五种替代
java
// spring-ai-model/src/main/java/org/springframework/ai/tool/annotation/ToolParam.java:31
31→@Target({ ElementType.PARAMETER, ElementType.FIELD, ElementType.ANNOTATION_TYPE })
32→@Retention(RetentionPolicy.RUNTIME)
33→@Documented
34→public @interface ToolParam {
35→
39→ boolean required() default true;
44→ String description() default "";
45→
46→}
@Target 比 @Tool 多了 FIELD------因为 POJO 字段上也能用(用于 generateForType 生成的对象 schema)。
required() 默认是 true。 这个默认值是 Spring AI 的刻意选择,理由写在 JsonSchemaGenerator.java:81-87:
java
81→ /**
82→ * To ensure consistency and robustness across different model providers, all
83→ * properties in the JSON Schema are considered required by default. This behavior can
84→ * be overridden by setting the {@link ToolParam#required()},
85→ * {@link JsonProperty#required()}, or {@link Schema#requiredMode()}} annotation.
86→ */
87→ private static final boolean PROPERTY_REQUIRED_BY_DEFAULT = true;
以及官方文档 tools.adoc:696 的警告:
WARNING: Defining the correct required status is critical for reducing hallucinations. A parameter marked required forces the model to provide a value; an optional one lets it omit. If you mark a parameter required but the model has no way to determine its value, it will make one up.
最后一句是关键:标成 required 但模型无从得知值的参数,模型会编造。 这是工具调用幻觉的主要来源之一。
举例:
java
@Tool(description = "查询用户的订单")
List<Order> listOrders(@ToolParam(description = "用户ID") String userId) { ... }
如果用户说"查一下我的订单",而对话里从没出现过 userId,模型必须 给一个值(因为 required),于是它会编 "user123" 或者 "current_user"。
正确做法:userId 应该标 required = false,从 ToolContext 里取真实值(第 10 章 §10.6)。
9.3 ToolUtils:名字与描述的推导
ToolUtils 是工具体系的"元数据推导中心",136 行,6 个 public 方法。
java
// spring-ai-model/src/main/java/org/springframework/ai/tool/support/ToolUtils.java:43
43→public final class ToolUtils {
44→
45→ private static final Log logger = LogFactory.getLog(ToolUtils.class);
52→ private static final Pattern RECOMMENDED_NAME_PATTERN = Pattern.compile("^[a-zA-Z0-9_\\.-]+$");
54→ private ToolUtils() {
55→ }
9.3.1 getToolName:注解优先,方法名兜底
java
57→ public static String getToolName(Method method) {
58→ Assert.notNull(method, "method cannot be null");
59→ var tool = AnnotatedElementUtils.findMergedAnnotation(method, Tool.class);
60→ String toolName;
61→ if (tool == null) {
62→ toolName = method.getName();
63→ }
64→ else {
65→ toolName = StringUtils.hasText(tool.name()) ? tool.name() : method.getName();
66→ }
67→ validateToolName(toolName);
68→ return toolName;
69→ }
用了 AnnotatedElementUtils.findMergedAnnotation(而不是 AnnotationUtils.findAnnotation),意味着支持 @Tool 作为元注解的组合注解:
java
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Tool(description = "审计日志标记的只读工具")
public @interface AuditedReadOnlyTool {}
// 使用
@AuditedReadOnlyTool
String querySomething(String id) { ... }
findMergedAnnotation 会把 @AuditedReadOnlyTool 上的 @Tool 合并出来。但注意 :合并出来的是 @Tool 上的属性,@AuditedReadOnlyTool 自身的属性(如果有)不会自动映射。这是 Spring 的元注解语义,不是 Spring AI 特有的。
有意思的是 MethodToolCallbackProvider.isToolAnnotatedMethod(第 125-128 行)用的是 AnnotationUtils.findAnnotation:
java
125→ private boolean isToolAnnotatedMethod(Method method) {
126→ Tool annotation = AnnotationUtils.findAnnotation(method, Tool.class);
127→ return Objects.nonNull(annotation);
128→ }
而 ToolUtils 用的是 AnnotatedElementUtils.findMergedAnnotation。两者都支持元注解(AnnotationUtils.findAnnotation 在 Spring 5.2+ 也支持合并),所以行为基本一致。但代码里用了两种 API 做同一件事,属于不一致。
9.3.2 getToolDescriptionFromName:驼峰切分
java
71→ public static String getToolDescriptionFromName(String toolName) {
72→ Assert.hasText(toolName, "toolName cannot be null or empty");
73→ return ParsingUtils.reConcatenateCamelCase(toolName, " ");
74→ }
ParsingUtils.reConcatenateCamelCase(spring-ai-commons/.../ParsingUtils.java:74-80):
java
74→ public static String reConcatenateCamelCase(String source, String delimiter) {
76→ Assert.notNull(source, "Source string must not be null");
77→ Assert.notNull(delimiter, "Delimiter must not be null");
79→ return StringUtils.collectionToDelimitedString(splitCamelCaseToLower(source), delimiter);
80→ }
splitCamelCaseToLower 会把 getWeather 切成 ["get", "weather"],拼起来是 "get weather"(小写)。
等等------是小写还是保持原样?看 split(source, boolean toLower) 第 82 行。splitCamelCaseToLower 的名字里有 "ToLower",所以应该是 "get weather"。
但官方文档 tools.adoc 和 Tool 注解 Javadoc 都说"method name will be used"(即原样)。实际代码走的是 reConcatenate。这是第一处"文档与实现脱节"。
不过更重要的是下面这条------
9.3.3 ⚠️ 硬发现:getToolDescription 的逻辑不自洽
java
76→ public static String getToolDescription(Method method) {
77→ Assert.notNull(method, "method cannot be null");
78→ var tool = AnnotatedElementUtils.findMergedAnnotation(method, Tool.class);
79→ if (tool == null) {
80→ return ParsingUtils.reConcatenateCamelCase(method.getName(), " ");
81→ }
82→ return StringUtils.hasText(tool.description()) ? tool.description() : method.getName();
83→ }
仔细看这两条分支:
| 情况 | 第 79-80 行(无 @Tool) |
第 82 行(有 @Tool 但 description 空) |
|---|---|---|
| 返回值 | reConcatenateCamelCase(name, " ") → "get weather" |
method.getName() → "getWeather" |
两条分支的兜底值不同!
- 没有
@Tool注解:返回"get weather"(切分后的) - 有
@Tool但description留空:返回"getWeather"(原始 camelCase)
也就是说:加上 @Tool 注解反而让默认描述变差了。
这个不一致会一路传到 DefaultToolDefinition.Builder.build():
java
// spring-ai-model/src/main/java/org/springframework/ai/tool/definition/DefaultToolDefinition.java:69
69→ public ToolDefinition build() {
70→ Assert.state(this.name != null, "toolName cannot be null or empty");
71→ if (!StringUtils.hasText(this.description)) {
72→ this.description = ParsingUtils.reConcatenateCamelCase(this.name, " ");
73→ }
74→ Assert.state(this.description != null, "toolDescription cannot be null or empty");
75→ Assert.state(this.inputSchema != null, "inputSchema cannot be null or empty");
76→ return new DefaultToolDefinition(this.name, this.description, this.inputSchema);
77→ }
第 71-73 行:只有 description 为 null/空时才 reConcatenate。而 ToolUtils.getToolDescription 在"有 @Tool 但空 description"的分支返回 "getWeather"------非空 !所以第 71 行的 hasText 判定为 false,跳过 reConcatenate,最终 description = "getWeather"。
完整链条:
less
@Tool 无 description
→ ToolUtils.getToolDescription 返回 "getWeather"(非空)
→ DefaultToolDefinition.Builder.description("getWeather")
→ build() 第71行 hasText("getWeather") = true,不 reConcatenate
→ ToolDefinition.description() = "getWeather"
而 FunctionToolCallback 路径(FunctionToolCallback.java:228-230):
java
228→ var toolDefinition = DefaultToolDefinition.builder()
229→ .name(this.name)
230→ .description(StringUtils.hasText(this.description) ? this.description
231→ : ToolUtils.getToolDescriptionFromName(this.name))
第 230-231 行显式调用了 getToolDescriptionFromName → reConcatenateCamelCase → "get weather"。
结论:同一个工具名 getWeather,走 @Tool 方法路径得到描述 "getWeather",走 FunctionToolCallback 路径得到 "get weather"。
这不是理论问题。实践中:
java
// 路径 A:方法注解
class WeatherTools {
@Tool // 忘了写 description
String getWeather(String city) { ... }
}
// → description = "getWeather"
// 路径 B:函数式
FunctionToolCallback.builder("getWeather", (Request r) -> ...).build();
// → description = "get weather"
路径 A 的模型拿到的描述是一个无空格驼峰串。 对 OpenAI 等模型,这会显著降低"何时调用此工具"的判断质量。
规避方案只有一条:永远显式写 description。 不要依赖任何默认值。§9.20 会给出一个启动期检查所有工具 description 的方案。
顺带说一句:Tool 注解的 Javadoc(第 57 行)"If not provided, the method name will be used"在第 82 行 这条分支上是对的,在第 80 行 那条分支上是错的。Javadoc 描述的是其中一条分支,而没说明有 @Tool 和无 @Tool 走的是不同分支。
9.3.4 getToolCallResultConverter 的反射开销
已在 §9.2.4 分析。补充一点:第 94-96 行的 tool == null 分支返回 new DefaultToolCallResultConverter()------又一个新实例。整个方法没有缓存,每次调用都 new。
getToolCallbacks() 每次调用都会为每个方法 new 一个 converter(第 99 行),而 MethodToolCallbackProvider.getToolCallbacks() 是每次调用都重新扫描一遍(第 10 章 §10.1.2)。所以:
java
// 如果框架每次请求都调用 provider.getToolCallbacks()
// 20 个工具 × 1000 QPS = 每秒 20000 个 converter 对象
实际影响取决于框架调用 getToolCallbacks() 的频率(第 10 章会讲 ToolCallingAdvisor 是每次请求调用一次还是缓存)。但这是一个可以预见的对象分配热点 ,值得用 -XX:+UseG1GC 之类的参数或缓存 provider 结果来缓解。
9.3.5 getDuplicateToolNames ------ 重名检测
java
106→ public static List<String> getDuplicateToolNames(List<ToolCallback> toolCallbacks) {
107→ Assert.notNull(toolCallbacks, "toolCallbacks cannot be null");
108→ return toolCallbacks.stream()
109→ .collect(Collectors.groupingBy(toolCallback -> toolCallback.getToolDefinition().name(),
110→ Collectors.counting()))
111→ .entrySet()
112→ .stream()
113→ .filter(entry -> entry.getValue() > 1)
114→ .map(Map.Entry::getKey)
115→ .toList();
116→ }
标准的 group-count-filter。它有三个调用点:
MethodToolCallbackProvider.validateToolCallbacks(第 131 行)→ 抛IllegalArgumentExceptionToolCallingChatOptions.validateToolCallbacks(第 96 行)→ 抛IllegalStateException- 用户代码
注意异常类型不一致 :同样是重名,MethodToolCallbackProvider 抛 IllegalArgumentException,ToolCallingChatOptions 抛 IllegalStateException。都是 RuntimeException,但如果你想精确 catch,得知道走的是哪条路。
9.4 ToolDefinition 与 DefaultToolDefinition
java
// spring-ai-model/src/main/java/org/springframework/ai/tool/definition/ToolDefinition.java:25
25→public interface ToolDefinition {
30→ String name();
35→ String description();
40→ String inputSchema();
45→ static DefaultToolDefinition.Builder builder() {
46→ return DefaultToolDefinition.builder();
47→ }
49→}
三个字段,全是 String。inputSchema 是 JSON 字符串,不是对象。 这是刻意的------因为不同供应商对 schema 的处理差异极大(有的要内联,有的要 $defs,有的不支持 $ref),保持字符串让各家自行转换。
java
// spring-ai-model/src/main/java/org/springframework/ai/tool/definition/DefaultToolDefinition.java:31
31→ public record DefaultToolDefinition(String name, String description, String inputSchema) implements ToolDefinition {
32→
33→ public DefaultToolDefinition {
34→ Assert.hasText(name, "name cannot be null or empty");
35→ Assert.hasText(description, "description cannot be null or empty");
36→ Assert.hasText(inputSchema, "inputSchema cannot be null or empty");
37→ }
record 的紧凑构造器(第 33-37 行)做三重非空校验。这意味着不可能构造出 description 为空的 DefaultToolDefinition------这正是 §9.3.3 那条 bug 链条能"成功"的原因:Builder 兜底了一个非空值进去。
一个有意思的细节:DefaultToolDefinition 是 record,所以 equals/hashCode/toString 自动生成。而 toString() 会完整打印 inputSchema------一个可能几百字符的 JSON。在日志里看到会很长。
Builder 的 name 校验用 Assert.state 而不是 Assert.hasText(第 70 行):
java
70→ Assert.state(this.name != null, "toolName cannot be null or empty");
Assert.state 抛 IllegalStateException,且消息说 "cannot be null or empty" 但只检查了 null 。所以传空字符串 "" 会:
- 通过第 70 行(非 null)
- 第 71-73 行:
description为空 →reConcatenateCamelCase("", " ")→"" - 第 76 行
new DefaultToolDefinition("", "", schema)→ 第 34 行Assert.hasText(name)→ 抛 IAE
错误信息从 IllegalStateException("toolName cannot be null or empty") 变成了 IllegalArgumentException("name cannot be null or empty")。异常类型在两条路径上不一致,但至少都会失败。
Assert.state vs Assert.hasText:前者用于"状态"断言(语义:调用时序不对),后者用于"参数"断言。这里显然应该用 hasText。属于小瑕疵。
9.5 JsonSchemaGenerator:静态初始化的秘密
这是本章最核心的类,362 行。
java
// spring-ai-model/src/main/java/org/springframework/ai/util/json/schema/JsonSchemaGenerator.java:79
79→public final class JsonSchemaGenerator {
87→ private static final boolean PROPERTY_REQUIRED_BY_DEFAULT = true;
89→ private static final SchemaGenerator typeSchemaGenerator;
91→ private static final SchemaGenerator subtypeSchemaGenerator;
96→ static {
97→ Module jacksonModule = new JacksonSchemaModule(JacksonOption.RESPECT_JSONPROPERTY_REQUIRED,
98→ JacksonOption.RESPECT_JSONPROPERTY_ORDER);
99→ Module openApiModule = new Swagger2Module();
100→ Module springAiSchemaModule = PROPERTY_REQUIRED_BY_DEFAULT ? new SpringAiSchemaModule()
101→ : new SpringAiSchemaModule(SpringAiSchemaModule.Option.PROPERTY_REQUIRED_FALSE_BY_DEFAULT);
102→
103→ SchemaGeneratorConfigBuilder schemaGeneratorConfigBuilder = new SchemaGeneratorConfigBuilder(
104→ SchemaVersion.DRAFT_2020_12, OptionPreset.PLAIN_JSON)
105→ .with(jacksonModule)
106→ .with(openApiModule)
107→ .with(springAiSchemaModule)
108→ .with(Option.EXTRA_OPEN_API_FORMAT_VALUES)
109→ .with(Option.PLAIN_DEFINITION_KEYS);
110→
111→ if (KotlinDetector.isKotlinReflectPresent()) {
112→ schemaGeneratorConfigBuilder.with(new KotlinModule());
113→ }
114→
115→ SchemaGeneratorConfig typeSchemaGeneratorConfig = schemaGeneratorConfigBuilder.build();
116→ typeSchemaGenerator = new SchemaGenerator(typeSchemaGeneratorConfig);
117→
118→ SchemaGeneratorConfig subtypeSchemaGeneratorConfig = schemaGeneratorConfigBuilder
119→ .without(Option.SCHEMA_VERSION_INDICATOR)
120→ .build();
121→ subtypeSchemaGenerator = new SchemaGenerator(subtypeSchemaGeneratorConfig);
122→ }
9.5.1 三个 Module 的协作顺序
第 105-107 行依次注册了三个 victools Module,顺序决定优先级(victools 的规则:后注册的 resolver 会覆盖先注册的,如果都返回非 null)。
| Module | 作用 | 覆盖的注解 |
|---|---|---|
JacksonSchemaModule |
处理 Jackson 注解 | @JsonProperty(required)、@JsonPropertyDescription、@JsonClassDescription、@JsonPropertyOrder |
Swagger2Module |
处理 OpenAPI 注解 | @Schema(description)、@Schema(requiredMode) |
SpringAiSchemaModule |
处理 Spring AI 注解 | @ToolParam(description)、@ToolParam(required) |
Spring AI 的 Module 最后注册 → 优先级最高。 这符合文档 tools.adoc:666 的说法:
Use any of these (Spring AI's annotation has highest precedence)
但注意:这个"最高优先级"只在字段/ getter 层面 (AbstractSpringAiSchemaModule.applyToConfigBuilder 只作用于 builder.forFields(),见 §9.9.2)。方法参数层面是另一套逻辑(§9.9.1)。
9.5.2 OptionPreset.PLAIN_JSON + 四个 Option
第 103-109 行的配置:
SchemaVersion.DRAFT_2020_12:JSON Schema 2020-12 草案(最新稳定版)OptionPreset.PLAIN_JSON:victools 的预设,禁用一堆 JSON Schema 特有但 LLM 不认的特性(如if/then/else、patternProperties)JacksonOption.RESPECT_JSONPROPERTY_REQUIRED:让@JsonProperty(required=...)生效JacksonOption.RESPECT_JSONPROPERTY_ORDER:保持字段顺序Option.EXTRA_OPEN_API_FORMAT_VALUES:输出更多format值(如int32、date-time)Option.PLAIN_DEFINITION_KEYS:用简单类名做$defs的 key(而不是全限定名)
PLAIN_DEFINITION_KEYS 是双刃剑。 用简单类名意味着:两个不同包下的同名类(如 com.a.Order 和 com.b.Order)会产生key 冲突 。这是 §9.6.4 hoistDefsToRoot 存在的原因之一。
9.5.3 两个 Generator 的差别:只差一个 Option
第 115-121 行:
typeSchemaGenerator:完整配置,带SCHEMA_VERSION_INDICATOR(会在 schema 里加"$schema": "https://json-schema.org/draft/2020-12/schema")subtypeSchemaGenerator:without(Option.SCHEMA_VERSION_INDICATOR),不输出$schema
为什么需要两个?因为:
generateForType(Type)(用于FunctionToolCallback的整个输入类型)生成的 schema 是顶层 的,需要$schema声明generateForMethodInput(Method)里每个参数的 schema 是内嵌 在properties.<name>下的,不能有自己的$schema(会与外层的冲突)
所以第 165 行用的是 subtypeSchemaGenerator:
java
165→ ObjectNode parameterNode = generateSchema(subtypeSchemaGenerator, parameterType);
而第 198 行用的是 typeSchemaGenerator:
java
198→ ObjectNode schema = generateSchema(typeSchemaGenerator, type);
9.5.4 synchronized:一个被忽略的并发瓶颈
java
127→ private static ObjectNode generateSchema(SchemaGenerator generator, Type type) {
128→ synchronized (generator) {
129→ return generator.generateSchema(type);
130→ }
131→ }
每次生成 schema 都要抢锁。 锁对象是 generator 本身------而全进程只有两个 generator 实例(typeSchemaGenerator / subtypeSchemaGenerator)。
这意味着:所有 schema 生成在全进程内串行。
什么时候会触发?
- 每次
MethodToolCallbackProvider.getToolCallbacks()→ToolDefinitions.from(method)→JsonSchemaGenerator.generateForMethodInput(method)→ 每个参数调用一次generateSchema
如果 getToolCallbacks() 在请求路径上被频繁调用,这里就是个全局锁竞争点。
victools 的 SchemaGenerator 本身不是线程安全的(它内部有可变的配置状态和缓存),所以加锁是必要的。但正确的做法应该是:
- 在启动期一次性生成 schema 并缓存(而不是每次请求重算)
- 或者用
ThreadLocal<SchemaGenerator>
Spring AI 选择在 MethodToolCallbackProvider 层不做缓存(第 10 章 §10.1.2),等于把这个成本转嫁给了调用方。
实践建议 :如果你有 20 个以上工具,且 QPS 较高,务必把 ToolCallback[] 或 ToolCallbackProvider 缓存成单例,不要让它在请求路径上重复构造。§9.20 陷阱 12 会展开。
9.5.5 SchemaOption:两个开关
java
// JsonSchemaGenerator.java:348
348→ public enum SchemaOption {
352→ ALLOW_ADDITIONAL_PROPERTIES_BY_DEFAULT,
357→ UPPER_CASE_TYPE_VALUES
359→ }
ALLOW_ADDITIONAL_PROPERTIES_BY_DEFAULT:默认不加 (即默认 additionalProperties: false)。 UPPER_CASE_TYPE_VALUES:把 "type": "string" 变成 "type": "STRING"(某些供应商需要,如部分 Google 模型)。
9.6 generateForMethodInput 逐行剖析
这是把 Java 方法签名变成 LLM 可理解 schema 的唯一入口。
java
// JsonSchemaGenerator.java:136
136→ public static String generateForMethodInput(Method method, SchemaOption... schemaOptions) {
137→ ObjectNode schema = JacksonUtils.getDefaultJsonMapper().createObjectNode();
138→ schema.put("$schema", SchemaVersion.DRAFT_2020_12.getIdentifier());
139→ schema.put("type", "object");
140→ ObjectNode defs = schema.putObject("$defs");
141→
142→ ObjectNode properties = schema.putObject("properties");
143→ List<String> required = new ArrayList<>();
144→
145→ for (int i = 0; i < method.getParameterCount(); i++) {
146→ String parameterName = method.getParameters()[i].getName();
147→ Type parameterType = method.getGenericParameterTypes()[i];
148→ if (parameterType instanceof Class<?> parameterClass
149→ && ClassUtils.isAssignable(ToolContext.class, parameterClass)) {
150→ // A ToolContext method parameter is not included in the JSON Schema
151→ // generation.
152→ // It's a special type used by Spring AI to pass contextual data to tools
153→ // outside the model interaction flow.
154→ continue;
155→ }
156→ // A Kotlin suspend function carries a synthetic trailing Continuation
157→ // parameter that is not part of the tool contract and must not appear in
158→ // the generated schema.
159→ if (KotlinDetector.isSuspendingFunction(method) && i == method.getParameterCount() - 1) {
160→ continue;
161→ }
162→ if (isMethodParameterRequired(method, i)) {
163→ required.add(parameterName);
164→ }
165→ ObjectNode parameterNode = generateSchema(subtypeSchemaGenerator, parameterType);
166→ // victools generates self-contained schemas where $defs and the $ref
167→ // pointers into them are rooted at the sub-schema. Inlining the
168→ // sub-schema under properties.<paramName> re-parents existing
169→ // "#/$defs/<Name>" refs to the outer root, leaving them unresolvable.
170→ // Hoist $defs to the outer root so those refs resolve again.
171→ JsonSchemaUtils.hoistDefsToRoot(schema, parameterNode);
172→ // Remove OpenAPI format as some LLMs (like Mistral) don't handle them.
173→ parameterNode.remove("format");
174→ String parameterDescription = getMethodParameterDescription(method, i);
175→ if (StringUtils.hasText(parameterDescription)) {
176→ parameterNode.put("description", parameterDescription);
177→ }
178→ properties.set(parameterName, parameterNode);
179→ }
180→
181→ if (defs.isEmpty()) {
182→ schema.remove("$defs");
183→ }
184→
185→ var requiredArray = schema.putArray("required");
186→ required.forEach(requiredArray::add);
187→
188→ processSchemaOptions(schemaOptions, schema);
189→
190→ return schema.toPrettyString();
191→ }
9.6.1 骨架:手工拼装而非委托 victools
第 137-143 行手工创建了根节点:
json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"$defs": { },
"properties": { }
}
注意第 140 行无条件创建了 $defs,第 181-183 行才在空的时候移除。 这个"先建后删"是为了让 hoistDefsToRoot 有一个稳定的挂载点。
整个外层结构(type: object、properties、required)是手写的,只有每个参数的子 schema 才委托给 victools(第 165 行)。这样设计的原因:
- victools 原本是给"生成某个类型的 schema"用的,不直接支持"从方法参数列表生成"
- 手写外层可以精确控制
required数组的顺序(与参数声明顺序一致) - 可以插入
ToolContext排除、format移除等 Spring AI 特有的逻辑
9.6.2 ToolContext 参数被排除(第 148-155 行)
java
148→ if (parameterType instanceof Class<?> parameterClass
149→ && ClassUtils.isAssignable(ToolContext.class, parameterClass)) {
154→ continue;
155→ }
ToolContext 类型的参数完全不出现在 schema 里 。这是对的------ToolContext 是框架注入的,模型不该知道也不该填。
但注意一个陷阱 :这个判断是 ClassUtils.isAssignable(ToolContext.class, parameterClass),即"参数类型是否是 ToolContext 或其子类"。ToolContext 是 final class(ToolContext.java:43),所以不会有子类。判断等价于 parameterClass == ToolContext.class。
更深一层的陷阱:如果你的工具方法签名是
java
@Tool String query(String q, ToolContext ctx)
schema 里只有 q。但执行时 MethodToolCallback.buildMethodArguments(第 149-155 行)会按参数位置 为每个参数找值,遇到 ToolContext 类型就注入 toolContext。所以 schema 与执行是对齐的。
可如果你的方法有两个 ToolContext 参数?编译能过(虽然毫无意义),schema 都排除,执行时两个都注入同一个对象。不会报错,但也不会有人这么写。
9.6.3 Kotlin suspend 的尾参数排除(第 156-161 行)
java
159→ if (KotlinDetector.isSuspendingFunction(method) && i == method.getParameterCount() - 1) {
160→ continue;
161→ }
Kotlin 的 suspend fun 编译成 Java 后,会在参数列表末尾 追加一个 Continuation 参数。第 159-161 行把最后一个参数排除。
注意判断的是"最后一个参数",而不是"类型是 Continuation 的参数"。 这在绝大多数情况下等价(Continuation 总是在最后)。但如果 Kotlin 编译器版本变化导致位置变化,这里就会误删一个真实参数。
更稳妥的写法应该是检查参数类型名是否以 kotlin.coroutines.Continuation 开头。当前实现依赖的是"约定"而非"检查"。
9.6.4 hoistDefsToRoot:$defs 提升
第 166-171 行的注释是全库写得最详尽的注释之一,值得原样引用:
victools generates self-contained schemas where defsandtheref pointers into them are rooted at the sub-schema. Inlining the sub-schema under properties. re-parents existing "#/ defs/<Name>"refstotheouterroot,leavingthemunresolvable.Hoistdefs to the outer root so those refs resolve again.
问题场景:参数是复杂类型 Order:
java
@Tool void place(Order order, Customer customer)
victools 为每个参数生成自包含的 schema:
json
// order 的子 schema
{
"$ref": "#/$defs/Order",
"$defs": { "Order": {...}, "Address": {...} }
}
如果直接塞进 properties.order,引用路径 #/$defs/Order 会被解析成相对于根 的 $defs,而根下面没有------引用悬空。
hoistDefsToRoot 把子 schema 的 $defs 搬到根,并按需重命名冲突 key。
java
// JsonSchemaUtils.java:97
97→ public static void hoistDefsToRoot(ObjectNode rootSchema, ObjectNode subSchema) {
98→ JsonNode nestedDefs = subSchema.remove("$defs");
99→ if (nestedDefs == null || !nestedDefs.isObject()) {
100→ return;
101→ }
102→ ObjectNode rootDefs = rootSchema.has("$defs") ? (ObjectNode) rootSchema.get("$defs")
103→ : rootSchema.putObject("$defs");
104→ // Collect all keys from this batch upfront so uniqueDefsKey can avoid claiming
105→ // a suffix that another pending entry in the same batch already occupies.
106→ Set<String> batchKeys = new HashSet<>();
107→ ((ObjectNode) nestedDefs).properties().forEach(e -> batchKeys.add(e.getKey()));
108→ Map<String, String> renames = new LinkedHashMap<>();
109→ List<String> insertedKeys = new ArrayList<>();
110→ ((ObjectNode) nestedDefs).properties().forEach(entry -> {
111→ String key = entry.getKey();
112→ JsonNode value = entry.getValue();
113→ if (!rootDefs.has(key)) {
114→ rootDefs.set(key, value);
115→ insertedKeys.add(key);
116→ return;
117→ }
118→ if (rootDefs.get(key).equals(value)) {
119→ return;
120→ }
121→ String renamed = uniqueDefsKey(rootDefs, key, batchKeys);
122→ rootDefs.set(renamed, value);
123→ renames.put(key, renamed);
124→ insertedKeys.add(renamed);
125→ });
126→ if (renames.isEmpty()) {
127→ return;
128→ }
129→ rewriteDefsRefs(subSchema, renames);
130→ // Only rewrite $refs inside the definitions inserted by THIS call. Entries added
131→ // to rootDefs by earlier hoistDefsToRoot calls have already had their own renames
132→ // applied; rewriting them again with the current batch's rename map would corrupt
133→ // any $refs that legitimately point at the original (pre-rename) keys.
134→ for (String insertedKey : insertedKeys) {
135→ rewriteDefsRefs(rootDefs.get(insertedKey), renames);
136→ }
137→ }
逻辑分层:
- 第 113-117 行:key 不存在 → 直接放进去
- 第 118-120 行 :key 存在且结构相等 → 复用(不插入,不加
insertedKeys) - 第 121-125 行 :key 存在但结构不同 →
uniqueDefsKey生成Order2、Order3...,记录重命名映射
第 130-133 行的注释解释了第 134-136 行为什么只重写"本次插入的 key":
Entries added to rootDefs by earlier hoistDefsToRoot calls have already had their own renames applied; rewriting them again with the current batch's rename map would corrupt any $refs that legitimately point at the original (pre-rename) keys.
这是一个很容易写错的地方 :如果不区分"本次插入"和"历史插入",第二批的 rename map 会把第一批里指向 Order 的合法引用误改成 Order2。作者特意写了 4 行注释说明。
PLAIN_DEFINITION_KEYS 的冲突风险 (§9.5.2)在这里得到缓解:同名不同包的类会被重命名成 Order2。但重命名后的名字没有语义 ------模型看到 Order2 会困惑。所以最好还是避免同名类。
9.6.5 移除 format(第 172-173 行)
java
173→ parameterNode.remove("format");
注释:"Remove OpenAPI format as some LLMs (like Mistral) don't handle them."
注意:只移除顶层参数的 format,不递归。 如果参数是复杂类型 Order,Order 内部字段的 format(在 $defs 里)不会被移除。
这与 §9.5.2 第 108 行 Option.EXTRA_OPEN_API_FORMAT_VALUES 形成有趣对比:配置里打开 了扩展 format 值,生成后又移除顶层的。净效果是:
- 顶层参数的
format:被移除 $defs里嵌套对象的format:保留
这不一致。 如果 Mistral 真的不认 format,那 $defs 里的同样会出问题。这是一个潜在的兼容性隐患,尤其对嵌套 DTO 参数。
§9.20 陷阱 8 会给出递归清除的补丁方案。
9.6.6 required 数组总是被写入(第 185-186 行)
java
185→ var requiredArray = schema.putArray("required");
186→ required.forEach(requiredArray::add);
即使 required 列表为空,也会写入 "required": []。
这是一个刻意的决定吗?看 JSON Schema 规范:空 required 数组是合法的,等价于不写。但某些模型实现(尤其是严格模式)对空数组的处理不同于缺失字段。
OpenAI 的 strict 模式要求:required 必须包含所有 properties 的 key(不允许可选参数)。这意味着 Spring AI 生成的 schema 在 strict 模式下可能不合规 ------而这正是 第 10 章 §10.5 会讲到的 upgrade-notes.adoc:212-218 那个变更的根因。
9.6.7 toPrettyString() ------ 一个被低估的成本
第 190 行:
java
190→ return schema.toPrettyString();
schema 以"美化"格式(带缩进换行)输出。
美化格式的 JSON 比紧凑格式大 30%~60%(取决于嵌套深度)。对于工具调用,这个字符串会:
- 被塞进每个请求的 payload
- 计入 input token
- 每个工具、每次请求都发一次
举例:一个 5 参数工具的 schema,紧凑格式约 400 字符(~120 token),美化后约 600 字符(~180 token)。20 个工具就是 1200 token 的差异,每次请求。
按 1000 QPS、8 小时计算:
yaml
1200 token × 1000 QPS × 28800 秒 = 3.456e10 token/天
按 GPT-4o 输入价 2.5/1Mtoken算,约∗∗86,400/天**------只因为 toPrettyString()。
这是个真实的、可测量的成本。 当然实际不会这么极端(多数应用工具更少、QPS 更低),但方向是确定的:生产环境可以考虑把 schema 压缩掉。
§9.20 陷阱 14 会给出方案:在 ToolCallback 层做一次紧凑化包装(因为 ToolDefinition.inputSchema() 返回 String,可以拦截)。
不过要权衡:美化格式对调试极其友好(你能直接读日志里的 schema)。开发环境保留,生产环境压缩,是个合理策略。
9.7 additionalProperties: false 的递归与特例
java
// JsonSchemaGenerator.java:206
206→ private static void processSchemaOptions(SchemaOption[] schemaOptions, ObjectNode schema) {
207→ if (Stream.of(schemaOptions)
208→ .noneMatch(option -> option == SchemaOption.ALLOW_ADDITIONAL_PROPERTIES_BY_DEFAULT)) {
209→ forbidAdditionalProperties(schema);
210→ }
211→ if (Stream.of(schemaOptions).anyMatch(option -> option == SchemaOption.UPPER_CASE_TYPE_VALUES)) {
212→ convertTypeValuesToUpperCase(schema);
213→ }
214→ }
默认路径是 forbidAdditionalProperties(第 209 行) ------除非显式传 ALLOW_ADDITIONAL_PROPERTIES_BY_DEFAULT。
java
291→ /**
292→ * Recursively adds {@code "additionalProperties": false} to all object schemas (nodes
293→ * with a {@code "properties"} key) that do not already define
294→ * {@code "additionalProperties"}. The guard preserves {@code Map<K,V>} schemas where
295→ * {@code "additionalProperties"} is a type reference rather than a boolean.
296→ */
297→ private static void forbidAdditionalProperties(ObjectNode node) {
298→ if (node.has("properties") && !node.has("additionalProperties")) {
299→ node.put("additionalProperties", false);
300→ }
301→ node.properties().forEach(entry -> {
302→ JsonNode value = entry.getValue();
303→ if (value.isObject()) {
304→ forbidAdditionalProperties((ObjectNode) value);
305→ }
306→ else if (value.isArray()) {
307→ value.forEach(element -> {
308→ if (element.isObject()) {
309→ forbidAdditionalProperties((ObjectNode) element);
310→ }
311→ });
312→ }
313→ });
314→ }
9.7.1 为什么默认禁止额外属性
additionalProperties: false 告诉模型:"只能填我列出的这些字段,多填一个就是违规。"
这对减少幻觉极其有效:没有它,模型可能在调用 placeOrder(itemId, quantity) 时自作主张加一个 {"notes": "..."}。有了它,模型知道不能加。
但它对 Map<String, Object> 类型参数是灾难------因为 Map 的本质就是任意 key。
第 294-295 行的 Javadoc 说明了保护逻辑:
The guard preserves {@code Map<K,V>} schemas where {@code "additionalProperties"} is a type reference rather than a boolean.
Map<String,X> 被 victools 生成为:
json
{
"type": "object",
"additionalProperties": { "type": "string" }
}
此时 additionalProperties 是个对象 (类型引用),不是 boolean。第 298 行的 !node.has("additionalProperties") 判定为 false → 跳过,不覆盖。
这个 guard 是必需的,否则 Map 参数会被写成 additionalProperties: false,模型一个 key 都填不了。
9.7.2 递归的盲区
第 301-313 行递归遍历:
value.isObject()→ 递归(第 303-305 行)value.isArray()→ 遍历元素,元素是 object 就递归(第 306-312 行)
盲区:数组里的数组。
json
{ "items": [ [ {"type":"object","properties":{...}} ] ] }
第 307 行 element.isObject() 对内层数组判定为 false → 不递归。
嵌套数组(List<List<Order>>)在实际工具签名里罕见,所以影响有限。但这是个真实的未覆盖路径。
另一个盲区:第 301 行 node.properties() 遍历的是当前层的所有字段 ,包括 $defs(如果 hoistDefsToRoot 已经执行,根节点的 $defs 会被递归进去)。这实际上是期望行为 ------$defs 里的定义也需要 additionalProperties: false。
9.7.3 UPPER_CASE_TYPE_VALUES 的递归实现差异
对比 convertTypeValuesToUpperCase(第 316-343 行)与 forbidAdditionalProperties:
java
317→ if (node.isObject()) {
318→ node.properties().forEach(entry -> {
319→ JsonNode value = entry.getValue();
320→ if (value.isObject()) {
321→ convertTypeValuesToUpperCase((ObjectNode) value);
322→ }
323→ else if (value.isArray()) {
324→ value.forEach(element -> {
325→ if (element.isObject() || element.isArray()) {
326→ convertTypeValuesToUpperCase((ObjectNode) element);
327→ }
328→ });
329→ }
330→ else if (value.isString() && entry.getKey().equals("type")) {
331→ String oldValue = node.get("type").asString();
332→ node.put("type", oldValue.toUpperCase(Locale.ROOT));
333→ }
334→ });
335→ }
336→ else if (node.isArray()) {
337→ node.forEach(element -> {
338→ if (element.isObject() || element.isArray()) {
339→ convertTypeValuesToUpperCase((ObjectNode) element);
340→ }
341→ });
342→ }
两个差异:
(1) 第 325 行:element.isObject() || element.isArray() ------ 处理了数组里的数组 (比 forbidAdditionalProperties 更完整)。 (2) 第 336-342 行:有顶层 else if (node.isArray()) 分支,forbidAdditionalProperties 没有。
两个做同一类事情(递归遍历 schema 树)的方法,遍历逻辑不一致。 这是复制粘贴式开发的典型痕迹。
(3)一个真实的 bug:第 331-332 行
java
331→ String oldValue = node.get("type").asString();
332→ node.put("type", oldValue.toUpperCase(Locale.ROOT));
读取的是 node.get("type"),但 entry 才是当前遍历的键值对。如果 node 有多个 "type" 键(JSON 规范允许重复键,Jackson 的 ObjectNode 会去重)......实际上 ObjectNode 是 Map 语义,key 唯一,所以 node.get("type") 就是 entry.getValue()。功能上正确,但写法绕 ------应该直接用 entry.getValue().asString()。
而且第 330 行已经判断了 entry.getKey().equals("type"),此时 entry.getValue() 就是要的值,不需要再 node.get("type") 取一次。
9.8 generateForType 与 Void 特例
java
// JsonSchemaGenerator.java:196
196→ public static String generateForType(Type type, SchemaOption... schemaOptions) {
197→ Assert.notNull(type, "type cannot be null");
198→ ObjectNode schema = generateSchema(typeSchemaGenerator, type);
199→ if ((type == Void.class) && !schema.has("properties")) {
200→ schema.putObject("properties");
201→ }
202→ processSchemaOptions(schemaOptions, schema);
203→ return schema.toPrettyString();
204→ }
第 199-201 行的 Void 特判:
Void.class 生成的 schema 自然是 {"$schema": ..., "type": "object"} 之类。第 200 行强行加一个空的 properties: {}。
为什么? 因为某些供应商(尤其 OpenAI)要求 function 的 parameters 必须有 properties 字段,即使是空对象。没有的话会返回 400。
这个特判在 FunctionToolCallback.Builder.build() 里配合 Supplier 使用(第 10 章 §10.5.3):Supplier<O> 被转成 Function<Void, O>,inputType = Void.class,于是 schema 是 {"type":"object","properties":{}}。
注意第 199 行是 type == Void.class(引用相等),不是 Void.TYPE。
Void.class:java.lang.Void的 Class 对象(包装类型)Void.TYPE:等价于void.class(原始类型)
FunctionToolCallback.Builder 第 158 行用的是 .inputType(Void.class),所以匹配。但如果你的方法返回 void(原始类型),getGenericReturnType() 返回的是 void.class = Void.TYPE ------ 这里不会命中。
不过 generateForType 只用于输入类型 ,不用于返回类型,所以这个不对称不会造成实际问题。第 10 章 §10.3 的 DefaultToolCallResultConverter 用的是 Void.TYPE(那里才是返回类型的场景)。
两个 Void 常量用在不同地方,各自正确。 但如果你在写自定义代码,要分清楚。
9.9 ⚠️ required 判定的两套独立实现
这是本章最重要的发现之一。
9.9.1 方法参数路径:isMethodParameterRequired
java
// JsonSchemaGenerator.java:232
232→ private static boolean isMethodParameterRequired(Method method, int index) {
233→ Parameter parameter = method.getParameters()[index];
234→
235→ var toolParamAnnotation = parameter.getAnnotation(ToolParam.class);
236→ if (toolParamAnnotation != null) {
237→ return toolParamAnnotation.required();
238→ }
239→
240→ var propertyAnnotation = parameter.getAnnotation(JsonProperty.class);
241→ if (propertyAnnotation != null) {
242→ return propertyAnnotation.required();
243→ }
244→
245→ var schemaAnnotation = parameter.getAnnotation(Schema.class);
246→ if (schemaAnnotation != null) {
247→ return schemaAnnotation.requiredMode() == Schema.RequiredMode.REQUIRED
248→ || schemaAnnotation.requiredMode() == Schema.RequiredMode.AUTO || schemaAnnotation.required();
249→ }
250→
251→ Nullness nullness = Nullness.forParameter(parameter);
252→ if (nullness == Nullness.NULLABLE) {
253→ return false;
254→ }
255→
256→ return PROPERTY_REQUIRED_BY_DEFAULT;
257→ }
优先级链:
@ToolParam.required()(第 235-238 行)@JsonProperty.required()(第 240-243 行)@Schema.requiredMode()(第 245-249 行)Nullness.forParameter() == NULLABLE→ false(第 251-254 行)- 默认
true(第 256 行)
用的是 parameter.getAnnotation(...)(只看参数上的直接注解,不看元注解)。
9.9.2 POJO 字段路径:AbstractSpringAiSchemaModule.checkRequired
java
// AbstractSpringAiSchemaModule.java:97
97→ private boolean checkRequired(MemberScope<?, ?> member) {
98→ Boolean toolParamRequired = resolveToolParamRequired(member);
99→ if (toolParamRequired != null) {
100→ return toolParamRequired;
101→ }
102→
103→ var propertyAnnotation = member.getAnnotationConsideringFieldAndGetter(JsonProperty.class);
104→ if (propertyAnnotation != null) {
105→ return propertyAnnotation.required();
106→ }
107→
108→ var schemaAnnotation = member.getAnnotationConsideringFieldAndGetter(Schema.class);
109→ if (schemaAnnotation != null) {
110→ return schemaAnnotation.requiredMode() == Schema.RequiredMode.REQUIRED
111→ || schemaAnnotation.requiredMode() == Schema.RequiredMode.AUTO || schemaAnnotation.required();
112→ }
113→
114→ Nullness nullness;
115→ if (member instanceof FieldScope fs) {
116→ nullness = Nullness.forField(fs.getRawMember());
117→ }
118→ else if (member instanceof MethodScope ms) {
119→ nullness = Nullness.forMethodReturnType(ms.getRawMember());
120→ }
121→ else {
122→ throw new IllegalStateException("Unsupported member type: " + member);
123→ }
124→ if (nullness == Nullness.NULLABLE) {
125→ return false;
126→ }
127→ if (KotlinDetector.isKotlinReflectPresent()
128→ && KotlinDetector.isKotlinType(member.getDeclaringType().getErasedType())) {
129→ // Defer to KotlinModule for additional checks like default values
130→ return false;
131→ }
132→
133→ return this.requiredByDefault;
134→ }
9.9.3 逐条对比
| 项 | 方法参数(§9.9.1) | POJO 字段(§9.9.2) |
|---|---|---|
@ToolParam 查找 |
parameter.getAnnotation() |
SpringAiSchemaModule.resolveToolParamRequired → member.getAnnotationConsideringFieldAndGetter() |
@JsonProperty 查找 |
parameter.getAnnotation() |
member.getAnnotationConsideringFieldAndGetter() |
@Schema 查找 |
parameter.getAnnotation() |
member.getAnnotationConsideringFieldAndGetter() |
| Nullness | Nullness.forParameter() |
Nullness.forField() / forMethodReturnType() |
| Kotlin 默认值处理 | 无 | 有(第 127-131 行,交给 KotlinModule) |
| 默认值 | PROPERTY_REQUIRED_BY_DEFAULT(常量 true) |
this.requiredByDefault(可通过 Option 改) |
| 元注解支持 | 不支持 (getAnnotation) |
支持(victools 的 getAnnotationConsideringFieldAndGetter) |
9.9.4 实际影响
影响一:Kotlin data class 的默认值
kotlin
data class OrderRequest(
val itemId: String,
val quantity: Int = 1, // 有默认值
val note: String = "" // 有默认值
)
作为方法参数的类型传入时:
kotlin
@Tool fun place(order: OrderRequest) { }
order本身:走 §9.9.1,无@ToolParam→required = trueOrderRequest内部的quantity/note:走 §9.9.2 的generateForType递归 → Kotlin 分支生效 → 有默认值的字段required = false
所以 Kotlin 默认值只在"作为字段"时生效,作为"直接参数"时不生效。
对比:
kotlin
// 写法 A:字段在 DTO 里 → quantity 有默认值 → 可选 ✅
@Tool fun place(order: OrderRequest)
// 写法 B:散开成参数 → quantity 在参数上 → 无 Kotlin 处理 → required=true ❌
@Tool fun place(itemId: String, quantity: Int = 1)
写法 B 会强制模型填 quantity,即使 Kotlin 里它有默认值。这就是两套实现不一致的直接后果。
影响二:元注解
java
@Target(ElementType.PARAMETER)
@Retention(RUNTIME)
@ToolParam(required = false)
public @interface Optional {}
// 用法
@Tool void f(@Optional String x) { }
§9.9.1 第 235 行 parameter.getAnnotation(ToolParam.class) 返回 null(因为参数上是 @Optional 而非 @ToolParam)→ 继续往下 → 最终 required = true。元注解在方法参数上不起作用。
而同样的组合用在 POJO 字段上(§9.9.2 通过 victools 的查找)有可能生效(取决于 victools 是否支持合并注解)。
影响三:无法全局关闭"默认 required"
PROPERTY_REQUIRED_BY_DEFAULT 是 private static final boolean = true(第 87 行),没有配置入口 。而 AbstractSpringAiSchemaModule 的 requiredByDefault 可通过 Option.PROPERTY_REQUIRED_FALSE_BY_DEFAULT 改(第 49-52 行):
java
49→ protected AbstractSpringAiSchemaModule(Option... options) {
50→ this.requiredByDefault = Stream.of(options)
51→ .noneMatch(option -> option == Option.PROPERTY_REQUIRED_FALSE_BY_DEFAULT);
52→ }
第 100-101 行甚至有根据常量选择 Module 变体的代码:
java
100→ Module springAiSchemaModule = PROPERTY_REQUIRED_BY_DEFAULT ? new SpringAiSchemaModule()
101→ : new SpringAiSchemaModule(SpringAiSchemaModule.Option.PROPERTY_REQUIRED_FALSE_BY_DEFAULT);
但因为 PROPERTY_REQUIRED_BY_DEFAULT 是编译期常量 true ,第 101 行的分支永远不执行(死代码)。作者留了扩展点,但没有暴露开关。
如果你确实需要"默认可选" :只能在每个参数上写 @ToolParam(required = false)。或者在 DTO 层面用 @Nullable。
9.9.5 一个正面的细节:Schema.RequiredMode.AUTO 的处理
两条路径都写了:
java
schemaAnnotation.requiredMode() == Schema.RequiredMode.REQUIRED
|| schemaAnnotation.requiredMode() == Schema.RequiredMode.AUTO
|| schemaAnnotation.required();
AUTO 被当作 required(第 247-248、110-111 行)。这符合 Swagger 的语义:AUTO 意味着"由工具推断",而 JSON Schema 里没有"推断"这个概念,只有 required/not-required。Spring AI 选择推断为 required,与 PROPERTY_REQUIRED_BY_DEFAULT = true 一致。
第 95-96 行有 @SuppressWarnings("deprecation") 并注明原因:
java
95→ @SuppressWarnings("deprecation") // Schema.required() kept for backwards compatibility
96→ // with pre-requiredMode usages
Swagger 的 Schema.required() 已废弃(被 requiredMode 取代),但为了兼容老代码仍然检查。
9.10 供应商适配:为什么 strict 默认是关的
JsonSchemaGenerator 生成的是一份"标准"JSON Schema(Draft 2020-12)。但各家供应商对这个标准的支持程度差异极大。Spring AI 的处理策略是:生成一份保守的公共子集,再让各供应商模块自行转换。
ToolDefinition.inputSchema() 是 String 而非对象(§9.4),正是为了这个------每家拿到字符串后可以做任意变换。
9.10.1 OpenAI strict 模式:一个被默认关闭的特性
upgrade-notes.adoc:212-218 记录了 2.x 的一个行为变更:
adoc
==== Changed: Tool Calling Strict Mode Now Defaults to `false`
`OpenAiChatModel` no longer defaults tool schemas to `strict(true)`. `JsonSchemaGenerator`
omits optional parameters from `required` instead of using the nullable-type pattern
OpenAI's strict mode requires, so any tool with an optional parameter was rejected by
OpenAI with a 400 error under the old default.
Tools that relied on the implicit `strict(true)` default are now sent as non-strict unless
requested explicitly. This only affects OpenAI's own schema-adherence check; tool execution
and results are unchanged.
这段话信息量很大,逐句拆:
(1)OpenAI 的 strict 模式是什么?
OpenAI 的 function 定义支持 strict: true,开启后 OpenAI 会:
- 校验你的 schema 是否符合它的子集规范
- 保证模型返回的参数严格符合 schema(不会多字段、不会类型错)
(2)strict 模式对 schema 的额外要求
关键点在于:strict 模式下 所有字段都必须在 required 里。想表达"可选",不能用"不在 required 里",而必须用 nullable 类型:
json
{
"type": "object",
"properties": {
"city": { "type": "string" },
"at": { "type": ["string", "null"] } // ← nullable 表达可选
},
"required": ["city", "at"] // ← 两个都 required
}
(3)Spring AI 生成的不是这个形状
回到 §9.6.6:required 数组只包含判定为 required 的参数。所以可选参数生成的 schema 是:
json
{
"type": "object",
"properties": {
"city": { "type": "string" },
"at": { "type": "string" }
},
"required": ["city"] // ← at 不在里面
}
(4)冲突的后果
在 strict 模式下,OpenAI 看到 at 不在 required 里 → 400 错误。
而 1.x 时代 OpenAiChatModel 默认 strict(true),所以任何带可选参数的工具在 1.x + OpenAI 下都会 400。
(5)2.x 的修复方式
不是改 schema 生成(那会影响所有供应商),而是把 OpenAI 的 strict 默认值改成 false。
这是一个"最小影响面"的修复:OpenAI 的非 strict 模式对 schema 宽容得多。代价是失去了 OpenAI 的参数保证。
(6)如何重新开启
upgrade-notes.adoc:218 说"unless requested explicitly"。具体方式在 OpenAiChatOptions 里(第 19-20 章会展开)。
实践建议:
- 如果你的工具全部参数都必填 ,且你想获得 OpenAI 的参数保证 → 显式开
strict(true) - 如果有任何可选参数 → 保持
false,或者把所有可选参数改成 nullable 类型表达 - 切换供应商时(如 OpenAI → Anthropic),
strict的概念完全不适用,代码里不要把它写进通用逻辑
9.10.2 format 为什么被移除
§9.6.5 第 172-173 行:
java
// Remove OpenAPI format as some LLMs (like Mistral) don't handle them.
parameterNode.remove("format");
而 §9.5.2 第 108 行却打开了 Option.EXTRA_OPEN_API_FORMAT_VALUES。这两个动作方向相反,净效果是"只有当层参数的 format 被移除"。
format 是 JSON Schema 的注解关键字,用于给 string 附加语义:
json
{ "type": "string", "format": "date-time" }
{ "type": "integer", "format": "int32" }
{ "type": "string", "format": "email" }
为什么不移除反而更好? 因为 format 对模型是有用的提示------"format": "date-time" 告诉模型"要填 ISO-8601 时间"。
为什么要移除? 因为某些供应商(注释点名 Mistral)的实现会校验 format 值,遇到不认识的就报错。
这就是"公共子集"策略的典型困境:一个对 A 有用、对 B 有害的特性,只能砍掉。
9.10.3 UPPER_CASE_TYPE_VALUES:给 Google 的后门
§9.5.5 的 SchemaOption.UPPER_CASE_TYPE_VALUES 把 "type": "string" 变成 "type": "STRING"。
这不是 JSON Schema 规范 (规范要求小写),而是 Google Gemini 的 API 方言------它的 Schema 对象用的是 Type.STRING、Type.NUMBER 这样的枚举,序列化后是大写。
所以 JsonSchemaGenerator 内置了一个"为 Google 变形"的开关。这说明:公共子集策略无法覆盖所有情况,必须留方言后门。
调用方式(各供应商模块内部):
java
JsonSchemaGenerator.generateForMethodInput(method, SchemaOption.UPPER_CASE_TYPE_VALUES);
9.10.4 三种策略的对比
| 策略 | 优点 | 缺点 | Spring AI 的选择 |
|---|---|---|---|
| 生成各家专属 schema | 每家都能用满特性 | N 个供应商 × M 个特性 = 维护爆炸 | ❌ |
| 生成最小公共子集 | 一次生成,到处能跑 | 用不了任何方言特性 | ✅ 主策略 |
| 公共子集 + 后门开关 | 兼顾 | 后门会让代码复杂化 | ✅ 补充(SchemaOption) |
Spring AI 选的是第三种。SchemaOption 目前只有 2 个值,说明这个后门机制还算克制。
对你写自定义工具的意义:
- 不要依赖 schema 的高级特性 (
$ref复杂引用、pattern、format、if/then/else)------它们可能在某个供应商那里被丢掉或报错 - 用
description而不是format表达约束 ------"description": "ISO-8601 格式的时间,如 2026-10-18T09:00:00Z"比"format": "date-time"兼容性好得多 - 枚举用
enum而不是pattern------enum是各家都支持的基础特性 - 测试要跨供应商------至少在你实际会用的两个供应商上都验证一遍 schema 是否被接受
9.11 陷阱总表(定义与 Schema 篇)
| # | 陷阱 | 严重度 | 位置 | 规避 |
|---|---|---|---|---|
| 1 | @Tool 有注解但 description 空 → 描述是原始 camelCase 方法名 |
高 | ToolUtils:82 |
永远显式写 description |
| 2 | 无 @Tool 注解 → 描述是切分后的(与陷阱 1 不同) |
中 | ToolUtils:80 |
同上 |
| 3 | 方法参数 vs POJO 字段的 required 判定逻辑不同(Kotlin 默认值只在字段生效) | 高 | JsonSchemaGenerator:232 vs AbstractSpringAiSchemaModule:97 |
Kotlin 场景用 FunctionToolCallback + data class |
| 4 | 中文/带空格工具名只 warn 不 fail,运行时才 400 | 中 | ToolUtils:130 |
启动期强制校验(§9.12) |
| 5 | Optional/Mono/CompletableFuture 返回类型无检查,静默产出垃圾 |
中 | MethodToolCallbackProvider:110 只查函数式 |
启动期扫描签名(§9.12) |
| 6 | void(原始)与 Void(包装)在两处用不同常量判断 |
低 | JsonSchemaGenerator:199 vs 结果转换器 |
输入用 Void.class,返回用 void |
| 7 | 未开 -parameters → schema 属性名是 arg0,且不报错 |
高 | JsonSchemaGenerator:146 |
确认 maven.compiler.parameters=true |
| 8 | format 只移除顶层,不递归进 $defs |
低 | JsonSchemaGenerator:173 |
自定义递归清除(Mistral 场景) |
| 9 | toPrettyString() 让 schema 膨胀 30--60%,token 成本显著 |
中 | JsonSchemaGenerator:190 |
生产环境紧凑化(§9.11.1) |
| 10 | schema 生成是 synchronized 全局锁,全进程串行 |
中 | JsonSchemaGenerator:128 |
缓存 ToolCallback[] |
| 11 | additionalProperties: false 递归不覆盖"数组里的数组" |
低 | JsonSchemaGenerator:306-312 |
罕见,可忽略或自行递归 |
| 12 | PLAIN_DEFINITION_KEYS 让同名不同包的类冲突成 Order2 |
低 | JsonSchemaGenerator:109 |
避免同名类 |
| 13 | AugmentedArgumentType.required 默认 false(与全库相反) |
中 | ToolInputSchemaAugmenter:68 |
增强字段总是写 @ToolParam(required=...) |
| 14 | AugmentedArgumentType.description 默认值是字面量 "no description" |
低 | ToolInputSchemaAugmenter:67 |
同上 |
| 15 | DefaultToolDefinition.Builder 用 Assert.state 检查 name(只查 null 不查空) |
低 | DefaultToolDefinition:70 |
无实际影响(record 构造会拦) |
| 16 | Tool 注解 Javadoc 说"method name will be used",实际两条分支不同 |
低 | Tool.java:57 |
以代码为准 |
| 17 | isToolAnnotatedMethod 用 AnnotationUtils,ToolUtils 用 AnnotatedElementUtils |
低 | MethodToolCallbackProvider:126 vs ToolUtils:59 |
行为基本一致,代码不一致 |
| 18 | OpenAI strict 模式与 Spring AI 的 required 语义冲突 | 高 | upgrade-notes.adoc:212-218 |
2.x 已默认关闭 strict |
9.11.1 陷阱 9 的缓解:生产环境紧凑化
toPrettyString() 的成本(§9.6.7)在生产环境可以通过包装 ToolCallback 消除:
java
/**
* 把 ToolDefinition 的 inputSchema 从美化格式压成紧凑格式。
* 典型节省 30-60% 的 schema token。
*/
public record CompactSchemaToolCallback(ToolCallback delegate) implements ToolCallback {
private static final ObjectMapper MAPPER = new ObjectMapper();
@Override
public ToolDefinition getToolDefinition() {
ToolDefinition origin = this.delegate.getToolDefinition();
return ToolDefinition.builder()
.name(origin.name())
.description(origin.description())
.inputSchema(compact(origin.inputSchema()))
.build();
}
@Override
public ToolMetadata getToolMetadata() {
return this.delegate.getToolMetadata();
}
@Override
public String call(String toolInput) {
return this.delegate.call(toolInput);
}
@Override
public String call(String toolInput, ToolContext ctx) {
return this.delegate.call(toolInput, ctx);
}
private static String compact(String prettyJson) {
try {
JsonNode tree = MAPPER.readTree(prettyJson);
return MAPPER.writeValueAsString(tree); // 无缩进
}
catch (JsonProcessingException e) {
return prettyJson; // 解析失败则原样返回,绝不因优化而破坏功能
}
}
}
注意两个设计点:
- 解析失败时原样返回 (
catch分支)。优化绝不能成为故障源。 - 用 record 而非 class------无状态装饰者用 record 最简洁。
包装方式:
java
@Bean
ToolCallbackProvider compactedProvider(ToolCallbackProvider raw) {
return () -> Arrays.stream(raw.getToolCallbacks())
.map(CompactSchemaToolCallback::new)
.toArray(ToolCallback[]::new);
}
何时不该用:开发环境。美化的 schema 在日志里可读性极高,调试时价值远大于 token 成本。建议用 profile 区分。
9.11.2 陷阱 10 的量化
synchronized (generator) 是全进程串行(只有两个 generator 实例)。
粗算一个场景:
- 25 个工具,平均 3 个参数
- 每次
generateForMethodInput会为每个参数调一次generateSchema(外加$defs递归) - 假设每个工具触发 5 次
generateSchema调用 - 单次
generateSchema耗时估 0.5--2 ms(victools 要反射遍历类型)
一次全量 getToolCallbacks():
ini
25 工具 × 5 次 = 125 次锁获取
125 × 1ms = 125 ms(串行,无并发)
如果 QPS = 200 且每次请求都重新生成:
ini
200 × 125 ms = 25000 ms / 秒
即需要 25 倍的 CPU 时间------显然不可能,请求会堆积。
这说明:如果框架在请求路径上调用 getToolCallbacks(),高 QPS 应用必然出问题。 实际上 Spring AI 的 ToolCallingAdvisor 有缓存(第 11 章会讲),但自定义 advisor 或直接用 ChatModel 时没有保障。
结论 :无论如何,把 ToolCallback[] 或 ToolCallbackProvider 的结果缓存起来。这是零成本高收益的优化。
9.12 启动期强制校验器(可投产)
针对陷阱 1、4、5、7,一个可以直接放进项目的校验器。它把"运行时才发现模型不调工具"变成"启动即失败"。
java
@Component
@Slf4j
public class ToolDefinitionValidator implements ApplicationRunner {
/** 供应商普遍接受的工具名格式(OpenAI 的要求最严)。 */
private static final Pattern SAFE_NAME = Pattern.compile("^[a-zA-Z0-9_-]{1,64}$");
/** 裸 camelCase 方法名(说明没写 description)。 */
private static final Pattern BARE_CAMEL = Pattern.compile("^[a-z]+([A-Z][a-z]*)*$");
/** Java 未提供 -parameters 时的默认参数名。 */
private static final Pattern SYNTHETIC_PARAM = Pattern.compile("^arg\\d+$");
private final ObjectProvider<ToolCallbackProvider> providers;
public ToolDefinitionValidator(ObjectProvider<ToolCallbackProvider> providers) {
this.providers = providers;
}
@Override
public void run(ApplicationArguments args) {
Set<String> seen = new HashSet<>();
int count = 0;
for (ToolCallbackProvider provider : this.providers.orderedStream().toList()) {
for (ToolCallback cb : provider.getToolCallbacks()) {
ToolDefinition def = cb.getToolDefinition();
count++;
// 陷阱 4:工具名兼容性(中文/空格会在 OpenAI 侧 400)
if (!SAFE_NAME.matcher(def.name()).matches()) {
throw new IllegalStateException("工具名 '" + def.name()
+ "' 不符合供应商要求:只允许字母、数字、下划线、连字符,长度 1-64");
}
// 陷阱 1/2:description 不能是裸方法名
String desc = def.description();
if (desc == null || desc.isBlank()
|| desc.equals(def.name())
|| BARE_CAMEL.matcher(desc).matches()) {
throw new IllegalStateException("工具 '" + def.name()
+ "' 缺少有意义的 description(当前值: " + desc + ")。"
+ "不写 description 时 Spring AI 会用方法名兜底,模型无法判断何时调用。");
}
// 跨 provider 重名
if (!seen.add(def.name())) {
throw new IllegalStateException("工具名重复: " + def.name());
}
checkSchema(def);
}
}
log.info("工具定义校验通过,共 {} 个工具", count);
}
private void checkSchema(ToolDefinition def) {
ObjectMapper mapper = new ObjectMapper();
JsonNode root;
try {
root = mapper.readTree(def.inputSchema());
}
catch (JsonProcessingException e) {
throw new IllegalStateException("工具 '" + def.name() + "' 的 inputSchema 不是合法 JSON", e);
}
JsonNode props = root.path("properties");
props.fieldNames().forEachRemaining(name -> {
// 陷阱 7:arg0 检测
if (SYNTHETIC_PARAM.matcher(name).matches()) {
throw new IllegalStateException("工具 '" + def.name() + "' 的参数名是 '" + name
+ "' ------ 编译时未加 -parameters 选项,模型无法理解参数含义");
}
JsonNode prop = props.get(name);
if (!prop.path("description").asText().isEmpty()) {
return;
}
// 有 $ref 的参数(复杂 DTO)不强制要求 description
if (prop.has("$ref") || prop.has("allOf") || prop.has("anyOf") || prop.has("oneOf")) {
return;
}
log.warn("工具 '{}' 的参数 '{}' 没有 description,模型可能不知道该填什么", def.name(), name);
});
}
}
9.12.1 为什么用 ApplicationRunner 而不是 @PostConstruct
@PostConstruct 在 bean 初始化时执行,此时其他 bean 可能还没就绪(尤其是 ToolCallbackProvider 可能依赖 ChatMemory、数据库等)。
ApplicationRunner 在容器完全启动后、开始接收请求前执行。这个时机保证:
- 所有
ToolCallbackProviderbean 已就绪 - 失败会阻止应用启动(Fast Fail),不会带着错误配置上线
9.12.2 为什么用 ObjectProvider
ObjectProvider<ToolCallbackProvider> 而不是 List<ToolCallbackProvider>:
- 如果没有任何 provider,
List注入也能工作(空列表) - 但
ObjectProvider.orderedStream()支持@Order排序,且对"可能不存在"的 bean 更宽容
List<ToolCallbackProvider> 在 Spring 里如果没有匹配 bean 会注入空 List(不是报错),所以两者都行。但 ObjectProvider 的语义更明确。
9.12.3 校验器的局限
它只能检查已经注册为 bean 的 provider 。如果工具是运行时动态构造的(如 ChatClient.prompt().tools(new Xxx())),校验器看不到。
对动态工具,建议在构造处单独调用校验逻辑(把 checkSchema/name 检查抽成静态工具方法)。
9.12.4 一个可选的增强:检查返回类型
针对陷阱 5(Optional/Mono/CompletableFuture),如果 ToolCallback 是 MethodToolCallback,可以反射拿到方法:
java
if (cb instanceof MethodToolCallback mtc) {
// toolMethod 是 private final,需要反射
Field f = MethodToolCallback.class.getDeclaredField("toolMethod");
f.setAccessible(true);
Method m = (Method) f.get(mtc);
Class<?> rt = m.getReturnType();
if (Optional.class.isAssignableFrom(rt)
|| java.util.concurrent.CompletionStage.class.isAssignableFrom(rt)
|| org.reactivestreams.Publisher.class.isAssignableFrom(rt)) {
throw new IllegalStateException("工具 '" + def.name() + "' 的返回类型 " + rt.getSimpleName()
+ " 无法序列化给模型,请改为具体类型");
}
}
这段代码依赖反射访问 private 字段 ,属于"侵入式"。在 Java 17+ 模块系统下可能被限制(同 §9.11 的 setAccessible 问题)。
更稳的方式 :直接扫描 @Tool 方法(不通过 ToolCallback):
java
// 用自己的 ClassPathScanningCandidateComponentProvider 扫 @Component
// 然后反射所有带 @Tool 的方法,检查签名
这个方案不依赖 Spring AI 内部结构,更健壮。
9.13 本章小结
工具调用的上半场------把一个 Java 方法变成模型能理解的东西------由五个模块协作完成:
less
@Tool / @ToolParam(注解,声明意图)
↓
ToolUtils(名字 / 描述 / converter 推导)
↓
ToolDefinitions.from(Method) ──→ ToolDefinition
↓ ↓
JsonSchemaGenerator ToolMetadata.from(Method)
(inputSchema 字符串) (returnDirect)
↓
MethodToolCallback(持有 Method + toolObject + converter)
结论一:描述与执行靠"参数名"这根细线连着
schema 用 parameter.getName()(JsonSchemaGenerator:146),执行也用 parameter.getName()(MethodToolCallback:153)。
这根线断了(没开 -parameters),两条路会"自洽地一起错" :schema 写 arg0,模型填 arg0,绑定取 arg0------一切正常,只是模型根本不知道参数是什么意思。
这是本章最危险的一类 bug:不报错,只是效果差。 而且你从日志里看 schema 会觉得"很正常"(因为确实是合法 JSON)。
结论二:默认值策略在全库至少四种
| 场景 | 默认 description | 位置 |
|---|---|---|
@Tool 有注解但 description 空 |
原始方法名 getWeather |
ToolUtils:82 |
无 @Tool 注解 |
切分后 get weather |
ToolUtils:80 |
FunctionToolCallback 无 description |
切分后 get weather |
FunctionToolCallback:230-231 |
| 场景 | 默认 required | 位置 |
|---|---|---|
| 方法参数 | true |
JsonSchemaGenerator:256 |
| POJO 字段(非 Kotlin) | true |
AbstractSpringAiSchemaModule:133 |
| POJO 字段(Kotlin) | false(有默认值时) |
AbstractSpringAiSchemaModule:127-131 |
AugmentedArgumentType |
false |
ToolInputSchemaAugmenter:68 |
三种 description 默认值互不相同,required 有一处相反。 这不是设计,是演化痕迹。
唯一正确的应对:全部显式写出来。
结论三:required 判定有两套独立实现,且能力不对等
- 方法参数:
JsonSchemaGenerator.isMethodParameterRequired(静态私有) - POJO 字段:
AbstractSpringAiSchemaModule.checkRequired(victools 回调)
只有后者处理 Kotlin 默认值 (AbstractSpringAiSchemaModule:127-131)。所以:
- Kotlin data class 的默认值 → 生效(走字段路径)
- Kotlin 顶层函数参数 的默认值 → 不生效(走参数路径)
同一个 Kotlin 特性,两种写法结果不同。
结论四:additionalProperties: false 是默认行为
processSchemaOptions(第 206-214 行)默认调用 forbidAdditionalProperties------除非显式传 ALLOW_ADDITIONAL_PROPERTIES_BY_DEFAULT。
这对减少幻觉极其有效:模型不能自作主张加字段。但:
- 递归有盲区(数组里的数组)
- 对
Map<K,V>有特判保护(additionalProperties是对象时不覆盖)
结论五:schema 是"公共子集 + 方言后门"
三个证据:
- 移除
format(Mistral 不认)但保留在$defs里(不彻底) UPPER_CASE_TYPE_VALUES给 Google 留后门- OpenAI strict 模式在 2.x 默认关闭,因为与 Spring AI 的 required 语义冲突
给你的实践建议 :用 description 表达约束,不要用 format;用 enum 不要用 pattern;跨供应商测试。
结论六:性能热点在 schema 生成
synchronized全局锁(第 128 行)→ 全进程串行toPrettyString()(第 190 行)→ 体积膨胀 30-60%getToolCallbacks()无缓存 → 每次全量重扫
三个都可通过"缓存 + 紧凑化"缓解,收益显著。
9.14 思考题
第 1 题 :ToolUtils.getToolDescription 的两条分支(第 80 行与第 82 行)返回不同格式的默认值。请构造一个场景,说明这会导致"加了 @Tool 注解反而让模型表现变差"。并设计一个不读源码的验证方法(只看运行时输出)。
第 2 题 :JsonSchemaGenerator 第 172-173 行移除顶层 format,但 $defs 里的 format 保留。请说明:
- 这会导致什么具体的兼容性问题?
- 如果要修复(递归移除),你会改在哪一层?为什么?
- 修复后对"用
format表达时间格式"的用户有什么影响?
第 3 题 :AbstractSpringAiSchemaModule.checkRequired 第 127-131 行对 Kotlin 类型返回 false,注释说"Defer to KotlinModule for additional checks like default values"。请推理:
- 为什么这里返回
false而不是直接判断"有没有默认值"? - 如果 KotlinModule 不在 classpath,会发生什么?
- 这个设计与"方法参数路径不支持 Kotlin"合起来,说明了什么架构问题?
第 4 题 :hoistDefsToRoot 第 130-136 行只重写"本次插入的 $defs"。请构造一个具体场景(两个参数用了同名但不同结构的类),说明如果改成"重写所有 $defs 的引用"会出什么错。
第 5 题:假设你的应用有 30 个工具,其中 10 个带可选参数,部署在 OpenAI 上。
- 1.x 时代会发生什么?2.x 呢?
- 如果你希望获得 OpenAI strict 模式的参数保证,需要怎么改工具定义?
- 这个改动对切换到 Anthropic 有什么影响?
第 6 题 :PROPERTY_REQUIRED_BY_DEFAULT 是 private static final boolean = true,且第 100-101 行的三元表达式有一个永远不执行的分支。请评价这个设计:
- 作者为什么这么写?
- 如果要真正支持"默认可选",最小改动是什么?
- 为什么"加一个全局配置"可能是坏主意?
第 7 题 :本章的启动期校验器(§9.12)用 ApplicationRunner。请分析:
- 如果改成
@PostConstruct会有什么风险? - 如果改成
@EventListener(ContextRefreshedEvent)呢? - 在 GraalVM native image 下,反射相关的检查(§9.12.4)会怎样?需要什么额外配置?
9.15 关键文件索引(定义与 Schema 篇)
scss
spring-ai-model/src/main/java/org/springframework/ai/
├── tool/
│ ├── ToolCallback.java (70 行) ★ 四方法接口,logger 是接口字段
│ ├── ToolCallbackProvider.java (39 行) getToolCallbacks + 2 个 from 工厂
│ ├── StaticToolCallbackProvider.java (90 行) 内部数组直接返回 ⚠
│ ├── annotation/
│ │ ├── Tool.java (71 行) name/description/returnDirect/resultConverter
│ │ └── ToolParam.java (46 行) required(默认 true)/description
│ ├── definition/
│ │ ├── ToolDefinition.java (49 行) name/description/inputSchema
│ │ └── DefaultToolDefinition.java (81 行) record + 三重 Assert + Builder 兜底
│ ├── metadata/
│ │ ├── ToolMetadata.java (54 行) 仅 returnDirect
│ │ └── DefaultToolMetadata.java (49 行) record(boolean)
│ └── support/
│ ├── ToolDefinitions.java (62 行) ★ builder/from(Method)
│ └── ToolUtils.java (136 行) ★★ 名字/描述/converter/重名
└── util/json/schema/
├── JsonSchemaGenerator.java (362 行) ★★★ 本章核心
│ ├── 87 PROPERTY_REQUIRED_BY_DEFAULT = true
│ ├── 96-122 静态初始化:3 个 Module + 4 个 Option + 2 个 Generator
│ ├── 127-131 synchronized 包装 ⚠
│ ├── 136-191 generateForMethodInput ★★★
│ ├── 196-204 generateForType(Void.class 特判)
│ ├── 206-214 processSchemaOptions
│ ├── 232-257 isMethodParameterRequired ★
│ ├── 270-289 getMethodParameterDescription
│ ├── 297-314 forbidAdditionalProperties(递归)
│ ├── 316-343 convertTypeValuesToUpperCase(遍历逻辑不一致)
│ └── 348-359 enum SchemaOption
├── JsonSchemaUtils.java hoistDefsToRoot / uniqueDefsKey / rewriteDefsRefs
├── AbstractSpringAiSchemaModule.java (149 行) ★ checkRequired(含 Kotlin 分支)
├── SpringAiSchemaModule.java (57 行) resolveToolParamDescription/Required
└── SchemaType.java (37 行) 仅 JSON_SCHEMA / OPEN_API_SCHEMA
spring-ai-commons/src/main/java/org/springframework/ai/util/ParsingUtils.java
└── 74-80 reConcatenateCamelCase(驼峰切分)
spring-ai-docs/src/main/antora/modules/ROOT/pages/
├── api/tools.adoc (940 行) 官方工具文档
└── upgrade-notes.adoc
├── 212-218 OpenAI strict 默认改为 false ★
├── 305-328 Tool Resolution Fallback 默认关闭
└── 388-470 1.x→2.x 工具迁移(internalToolExecutionEnabled 移除)
9.16 复核命令(定义与 Schema 篇)
所有结论都可以在仓库里自己验证一遍:
bash
# 1. 验证 description 默认值的两条分支(§9.3.3)------ 本章最重要的发现
sed -n '76,83p' spring-ai-model/src/main/java/org/springframework/ai/tool/support/ToolUtils.java
sed -n '69,77p' spring-ai-model/src/main/java/org/springframework/ai/tool/definition/DefaultToolDefinition.java
sed -n '224,236p' spring-ai-model/src/main/java/org/springframework/ai/tool/function/FunctionToolCallback.java
# 对比:ToolUtils:80 用 reConcatenateCamelCase,ToolUtils:82 用 method.getName()
# 2. 验证 required 判定的两套实现(§9.9)
sed -n '232,257p' spring-ai-model/src/main/java/org/springframework/ai/util/json/schema/JsonSchemaGenerator.java
sed -n '97,134p' spring-ai-model/src/main/java/org/springframework/ai/util/json/schema/AbstractSpringAiSchemaModule.java
# 关键差异:只有后者有 Kotlin 分支(127-131 行)
# 3. 验证工具名正则只 warn 不 fail(§9.2.1)
sed -n '52,52p' spring-ai-model/src/main/java/org/springframework/ai/tool/support/ToolUtils.java
sed -n '128,134p' spring-ai-model/src/main/java/org/springframework/ai/tool/support/ToolUtils.java
# 4. 验证 schema 生成的 synchronized 与 toPrettyString(§9.5.4 / §9.6.7)
sed -n '127,131p' spring-ai-model/src/main/java/org/springframework/ai/util/json/schema/JsonSchemaGenerator.java
sed -n '185,191p' spring-ai-model/src/main/java/org/springframework/ai/util/json/schema/JsonSchemaGenerator.java
# 5. 验证 ToolContext 排除与 Kotlin Continuation 排除(§9.6.2 / §9.6.3)
sed -n '145,161p' spring-ai-model/src/main/java/org/springframework/ai/util/json/schema/JsonSchemaGenerator.java
# 6. 验证 format 只移除顶层,不递归(§9.6.5)
sed -n '165,179p' spring-ai-model/src/main/java/org/springframework/ai/util/json/schema/JsonSchemaGenerator.java
# 第 173 行 parameterNode.remove("format"),前后没有递归调用
# 7. 验证 additionalProperties 递归的盲区(§9.7.2)
sed -n '297,314p' spring-ai-model/src/main/java/org/springframework/ai/util/json/schema/JsonSchemaGenerator.java
# 第 307 行 element.isObject(),对比 convertTypeValuesToUpperCase 第 325 行的 isObject() || isArray()
# 8. 验证两个 Void 常量用在不同地方(§9.8 输入 / 第 10 章 §10.3.1 返回)
grep -n "Void.class" spring-ai-model/src/main/java/org/springframework/ai/util/json/schema/JsonSchemaGenerator.java
grep -n "Void.TYPE" spring-ai-model/src/main/java/org/springframework/ai/tool/execution/DefaultToolCallResultConverter.java
# 9. 验证 PROPERTY_REQUIRED_BY_DEFAULT 是编译期常量(死代码检测,§9.9.4)
sed -n '87,87p;100,101p' spring-ai-model/src/main/java/org/springframework/ai/util/json/schema/JsonSchemaGenerator.java
# 10. 验证 AugmentedArgumentType 的 required 默认 false(§9.18.3 / 陷阱 13)
sed -n '51,75p' spring-ai-model/src/main/java/org/springframework/ai/tool/augment/ToolInputSchemaAugmenter.java
# 第 68 行:toolParam != null ? toolParam.required() : false
# 11. 验证 isFunctionalType 只检查返回类型(§9.10.3 / 陷阱 5)
sed -n '110,123p' spring-ai-model/src/main/java/org/springframework/ai/tool/method/MethodToolCallbackProvider.java
sed -n '774,785p' spring-ai-docs/src/main/antora/modules/ROOT/pages/api/tools.adoc
# 代码检查 1 类,官方文档列 4 类
# 12. 验证 OpenAI strict 默认变更(§9.10.1)
sed -n '212,218p' spring-ai-docs/src/main/antora/modules/ROOT/pages/upgrade-notes.adoc
# 13. 验证 ParsingUtils 的位置(在 commons 而非 model)
find . -name "ParsingUtils.java" -not -path "*/target/*"
sed -n '74,80p' spring-ai-commons/src/main/java/org/springframework/ai/util/ParsingUtils.java
# 14. 验证 ToolCallbacks 在 support 包而非 tool 包
find . -name "ToolCallbacks.java" -not -path "*/target/*"
# → spring-ai-model/src/main/java/org/springframework/ai/support/ToolCallbacks.java
# 15. 验证 internalToolExecutionEnabled / toolNames 已移除
grep -rn "internalToolExecutionEnabled\|toolNames" --include=*.java spring-ai-model/ | head
# 应无输出
# 16. 验证所有 @since 2.0.1 的工具类
grep -rln "@since 2.0.1" --include=*.java spring-ai-model/src/main/java/org/springframework/ai/tool/ \
spring-ai-model/src/main/java/org/springframework/ai/model/tool/ \
spring-ai-model/src/main/java/org/springframework/ai/util/json/schema/
# 17. 实测:查看一个真实工具生成的 schema(最直观的验证)
cat > /tmp/DumpSchema.java <<'EOF'
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.ai.util.json.schema.JsonSchemaGenerator;
import java.lang.reflect.Method;
public class DumpSchema {
record Addr(String city, String street) {}
@Tool(description = "查询天气")
static String getWeather(@ToolParam(description="城市") String city,
@ToolParam(description="单位", required=false) String unit,
Addr addr) { return ""; }
public static void main(String[] a) throws Exception {
Method m = DumpSchema.class.getDeclaredMethod("getWeather", String.class, String.class, Addr.class);
System.out.println(JsonSchemaGenerator.generateForMethodInput(m));
}
}
EOF
# 用 mvn exec 或 jshell 运行,观察:
# - required 数组是否只含 city/addr
# - $defs 里是否有 Addr
# - 顶层 format 是否已移除
下一章 :第 10 章进入执行链路------MethodToolCallback 的参数绑定、ToolContext 的强制校验、结果转换、void 与 Consumer 的差异,以及受检异常为何不会变成给模型的错误消息。