Spring AI 2.0.1 深度解析-第 09 章 工具调用(上):`@Tool` 注解、ToolDefinition 与 JSON Schema 生成

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) 时:

  1. 第 60 行判断 context 非空 → 进入 if
  2. 第 62 行打一条 INFO 级别的日志,说"你的实现不支持 tool context,请 override"
  3. 第 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"(带空格)或者 "查询天气"(中文)。这两种都会:

  1. 通过 Assert.hasText(非空即可)
  2. 正则不匹配 → 打 warn
  3. 照常注册
  4. 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。它有三个调用点:

  1. MethodToolCallbackProvider.validateToolCallbacks(第 131 行)→ 抛 IllegalArgumentException
  2. ToolCallingChatOptions.validateToolCallbacks(第 96 行)→ 抛 IllegalStateException
  3. 用户代码

注意异常类型不一致 :同样是重名,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 。所以传空字符串 "" 会:

  1. 通过第 70 行(非 null)
  2. 第 71-73 行:description 为空 → reConcatenateCamelCase("", " ") → ""
  3. 第 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 生成在全进程内串行。

什么时候会触发?

  1. 每次 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 defsandthedefs and the defsandtheref pointers into them are rooted at the sub-schema. Inlining the sub-schema under properties. re-parents existing "#/ defs/<Name>"refstotheouterroot,leavingthemunresolvable.Hoistdefs/<Name>" refs to the outer root, leaving them unresolvable. Hoist 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→	}

逻辑分层:

  1. 第 113-117 行:key 不存在 → 直接放进去
  2. 第 118-120 行 :key 存在且结构相等 → 复用(不插入,不加 insertedKeys)
  3. 第 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%(取决于嵌套深度)。对于工具调用,这个字符串会:

  1. 被塞进每个请求的 payload
  2. 计入 input token
  3. 每个工具、每次请求都发一次

举例:一个 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算,约∗∗2.5/1M token 算,约 ** 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→	}

优先级链:

  1. @ToolParam.required()(第 235-238 行)
  2. @JsonProperty.required()(第 240-243 行)
  3. @Schema.requiredMode()(第 245-249 行)
  4. Nullness.forParameter() == NULLABLE → false(第 251-254 行)
  5. 默认 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 = true
  • OrderRequest 内部的 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 个值,说明这个后门机制还算克制。

对你写自定义工具的意义:

  1. 不要依赖 schema 的高级特性 ($ref 复杂引用、pattern、format、if/then/else)------它们可能在某个供应商那里被丢掉或报错
  2. 用 description 而不是 format 表达约束 ------"description": "ISO-8601 格式的时间,如 2026-10-18T09:00:00Z" 比 "format": "date-time" 兼容性好得多
  3. 枚举用 enum 而不是 pattern ------enum 是各家都支持的基础特性
  4. 测试要跨供应商------至少在你实际会用的两个供应商上都验证一遍 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;                            // 解析失败则原样返回,绝不因优化而破坏功能
        }
    }
}

注意两个设计点:

  1. 解析失败时原样返回 (catch 分支)。优化绝不能成为故障源。
  2. 用 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 在容器完全启动后、开始接收请求前执行。这个时机保证:

  • 所有 ToolCallbackProvider bean 已就绪
  • 失败会阻止应用启动(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 是"公共子集 + 方言后门"

三个证据:

  1. 移除 format(Mistral 不认)但保留在 $defs 里(不彻底)
  2. UPPER_CASE_TYPE_VALUES 给 Google 留后门
  3. 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 的差异,以及受检异常为何不会变成给模型的错误消息。

相关推荐
鶴哥只手遮天1 小时前
Spring AI 2.0.1 深度解析-第 02 章 核心抽象层:`Model` / `StreamingModel` 与 ChatModel 体系
java·人工智能·开源
鶴哥只手遮天1 小时前
Spring AI 2.0.1 深度解析-第 01 章 全景:模块拓扑、依赖层次与设计哲学
java·人工智能·开源
鶴哥只手遮天1 小时前
Spring AI 2.0.1 深度解析-第 05 章 Advisor 架构:接口体系、栈式链与 context 传播
java·人工智能·开源
鶴哥只手遮天2 小时前
Spring AI 2.0.1 深度解析-第 04 章 `ChatClient` 全流程:Builder、两条链路与结构化输出
java·人工智能·开源
鶴哥只手遮天2 小时前
Spring AI 2.0.1 深度解析-第 08 章 RAG 组件逐个拆解:实现细节与八个可投产组件
java·人工智能·开源
Ward的AI产品实战2 小时前
多个订阅接多个 Agent 怎么管?我从 CC-Switch 迁移到了开源 AI 网关 Magpie(上篇)
开源·ai编程
匠测AI说2 小时前
AI for Testing 提效实战 · 执行自动化(五):这次该跑哪些用例,让 AI 圈回归范围
人工智能·测试
SL_staff3 小时前
告别报表重复开发:JVS-BI 模板市场的参数化继承实践
java·数据分析·数据可视化