Spring AI 2.0.1 深度解析-第 10 章 工具调用(中):执行链路、结果转换与工具限制

Spring AI 2.0.1 深度解析-第 10 章 工具调用(中):执行链路、结果转换与工具限制

本系列所有结论均基于 Spring AI 2.0.1 源码逐行取证。文件路径相对仓库根 spring-ai-2.0.1,行号对应源码实际行号。关键代码原样摘录。


10.0 本章导读

第 09 章解决的是静态 问题:如何把一个 Java 方法变成 ToolDefinition(名字 + 描述 + JSON Schema)。本章解决动态 问题:模型返回 {"city":"北京"} 之后,到你拿到 String 结果之前,中间发生了什么。

这条链路比大多数人想象的长:

javascript 复制代码
模型返回 AssistantMessage.ToolCall{ id, name="getWeather", arguments="{\"city\":\"北京\"}" }
    ↓
ToolCallingManager.executeToolCalls(prompt, response)
    ↓  resolveToolDefinitions → 找到匹配的 ToolCallback
    ↓
ToolCallback.call(arguments, toolContext)
    ↓  MethodToolCallback 路径
    ├─ validateToolContextSupport      (§10.2.1)可能直接抛异常
    ├─ extractToolArguments            (§10.2.4)JSON → Map<String,Object>
    ├─ buildMethodArguments            (§10.2.2)按参数名取值 + 类型转换
    ├─ callMethod                      (§10.2.5)反射 invoke + setAccessible
    └─ toolCallResultConverter.convert (§10.3)结果 → String
    ↓
ToolResponseMessage(含 ToolResponse{ id, name, responseData })
    ↓
拼回消息列表 → 再发模型(第 11 章的循环)

10.0.1 本章的四条硬结论

其一:声明了 ToolContext 参数的方法,在没有非空 context 时会直接抛异常。 MethodToolCallback.validateToolContextSupport(第 129-131 行)是硬失败 ,而 ToolCallback 的默认方法(第 59-68 行)是软失败(只打日志)。同一个概念,两种相反的处理。(§10.2.1)

其二:受检异常不会变成给模型的错误消息。 DefaultToolExecutionExceptionProcessor.process() 第 66-70 行对非 RuntimeException 无条件重抛。工具方法天然会抛 IOException / SQLException,而这条路径恰恰不支持"错误回喂给模型"。(§10.4.3)

其三:void 与 Consumer 两种"无返回值"的写法,给模型的字符串不同。 @Tool void f() → "Done";FunctionToolCallback + Consumer → null 的 JSON 序列化。前者明确,后者可能被模型理解为失败。(§10.3.1、§10.5.1)

其四:2.0.1 给工具调用加了预算------单工具 40 次、单轮 150 次。 ToolCallLimits 是 @since 2.0.1 的全新特性,用"扫描消息历史"而非持有状态来计数,并且只统计"当前 turn"。(§10.8)

10.0.2 与前两章的分工

主题 章
@Tool / @ToolParam / JsonSchemaGenerator / required 语义 第 09 章
扫描、执行、转换、异常、ToolContext、options、limits 本章
ToolCallingManager 递归循环、ToolCallingAdvisor、usage 累加 第 11 章

10.1 MethodToolCallbackProvider:扫描与校验

java 复制代码
// spring-ai-model/src/main/java/org/springframework/ai/tool/method/MethodToolCallbackProvider.java:53
53→public final class MethodToolCallbackProvider implements ToolCallbackProvider {
55→	private static final Log logger = LogFactory.getLog(MethodToolCallbackProvider.class);
57→	private final List<Object> toolObjects;
59→	private MethodToolCallbackProvider(List<Object> toolObjects) {
60→		Assert.notNull(toolObjects, "toolObjects cannot be null");
61→		Assert.noNullElements(toolObjects, "toolObjects cannot contain null elements");
62→		assertToolAnnotatedMethodsPresent(toolObjects);
63→		this.toolObjects = toolObjects;
64→		validateToolCallbacks(getToolCallbacks());
65→	}

构造期就做了三件事 (第 60-64 行):校验非空 → 断言有 @Tool 方法 → 校验重名。

第 64 行 validateToolCallbacks(getToolCallbacks()) 意味着构造时就把所有 callback 生成了一遍(但结果被丢弃,只是用来校验重名)。这是一次浪费的 full scan。

10.1.1 assertToolAnnotatedMethodsPresent 的错误消息

java 复制代码
67→	private void assertToolAnnotatedMethodsPresent(List<Object> toolObjects) {
68→
69→		for (Object toolObject : toolObjects) {
70→			List<Method> toolMethods = Stream
71→				.of(ReflectionUtils.getDeclaredMethods(
72→						AopUtils.isAopProxy(toolObject) ? AopUtils.getTargetClass(toolObject) : toolObject.getClass()))
73→				.filter(this::isToolAnnotatedMethod)
74→				.filter(toolMethod -> !isFunctionalType(toolMethod))
75→				.toList();
76→
77→			if (toolMethods.isEmpty()) {
78→				throw new IllegalArgumentException("No @Tool annotated methods found in " + toolObject + ". "
79→						+ "Did you mean to pass a ToolCallback or ToolCallbackProvider? If so, use"
80→						+ " .tools(toolCallback) or .toolCallbacks(toolCallback) instead.");
81→			}
82→		}
83→	}

第 78-80 行的错误消息写得很好------它预判了用户最可能的错误(把 ToolCallback 传给了期望 @Tool 对象的 API)。

但第 79 行有个字符串拼接 bug:

java 复制代码
"...Did you mean to pass a ToolCallback or ToolCallbackProvider? If so, use"
+ " .tools(toolCallback) or .toolCallbacks(toolCallback) instead."

前半句结尾是 "use",后半句开头是 " .tools",拼起来是 "use .tools(...)"(正确)。看起来没问题。

但注意 toolObject 在第 78 行被直接拼进字符串("..." + toolObject + "..."),这会调用 toString()。如果你的工具类没 override toString(),输出是 com.example.MyTools@1a2b3c4d ------ 还行。如果 override 了且抛异常,构造就失败了。

第 71-72 行的 AOP 处理值得注意:

java 复制代码
71→				.of(ReflectionUtils.getDeclaredMethods(
72→						AopUtils.isAopProxy(toolObject) ? AopUtils.getTargetClass(toolObject) : toolObject.getClass()))

如果是 AOP 代理,扫描目标类而不是代理类。 这很关键,因为:

  • CGLIB 代理会生成子类,子类里的方法是桥接到父类的,注解在父类方法上
  • JDK 动态代理只有接口方法,没有 @Tool 注解

如果这里不做 getTargetClass(),所有被 @Transactional、@Cacheable 等代理的工具类都会扫描失败。

但 getTargetClass() 返回的是目标类,而 toolObject 字段存的还是代理对象 (第 63 行 this.toolObjects = toolObjects)。所以:

  • 扫描用目标类(能找到注解)✅
  • 执行用代理对象(能触发 AOP 增强)✅

这是正确的设计:注解在目标类,增强在代理对象。

10.1.2 getToolCallbacks 与 assert 的过滤器不一致 ⚠️

java 复制代码
85→	@Override
86→	public ToolCallback[] getToolCallbacks() {
87→		var toolCallbacks = this.toolObjects.stream()
88→			.map(toolObject -> Stream
89→				.of(ReflectionUtils.getDeclaredMethods(
90→						AopUtils.isAopProxy(toolObject) ? AopUtils.getTargetClass(toolObject) : toolObject.getClass()))
91→				.filter(this::isToolAnnotatedMethod)
92→				.filter(toolMethod -> !isFunctionalType(toolMethod))
93→				.filter(ReflectionUtils.USER_DECLARED_METHODS::matches)
94→				.map(toolMethod -> MethodToolCallback.builder()
95→					.toolDefinition(ToolDefinitions.from(toolMethod))
96→					.toolMetadata(ToolMetadata.from(toolMethod))
97→					.toolMethod(toolMethod)
98→					.toolObject(toolObject)
99→					.toolCallResultConverter(ToolUtils.getToolCallResultConverter(toolMethod))
100→					.build())
101→				.toArray(ToolCallback[]::new))
102→			.flatMap(Stream::of)
103→			.toArray(ToolCallback[]::new);
104→
105→		validateToolCallbacks(toolCallbacks);
106→
107→		return toolCallbacks;
108→	}

对比第 70-75 行(assert)与第 88-93 行(get):

过滤器 assertToolAnnotatedMethodsPresent getToolCallbacks
getDeclaredMethods + AOP ✅ ✅
isToolAnnotatedMethod ✅ ✅
!isFunctionalType ✅ ✅
USER_DECLARED_METHODS ❌ 缺失 ✅

ReflectionUtils.USER_DECLARED_METHODS 是个 MethodFilter,排除桥接方法(bridge)和合成方法(synthetic)。

泛型会导致桥接方法:

java 复制代码
interface Repo<T> {
    @Tool T find(String id);
}
class OrderRepo implements Repo<Order> {
    @Override
    public Order find(String id) { ... }
}

编译后 OrderRepo 有两个 find:

  • Order find(String) ------ 真实方法
  • Object find(String) ------ 桥接方法 (synthetic + bridge)

@Tool 注解只在真实方法上(注解不继承到桥接方法)。

因此:

  • assertToolAnnotatedMethodsPresent 找到 1 个(或 2 个,取决于注解是否被复制到桥接方法)
  • getToolCallbacks 过滤掉桥接方法后可能只剩 1 个

这个不一致的实际后果是什么?

乐观情况:两者都找到 ≥1,assert 通过,get 返回正确的 1 个。一切正常。

悲观情况 :如果 @Tool 注解只存在于桥接方法上(罕见但可能,取决于编译器),assert 阶段认为"有",get 阶段过滤掉桥接方法后返回空数组 → 注册了 0 个工具,无任何报错。

这是"扫描过滤器不一致"的典型危害:校验路径与实际路径看到的世界不同。

实践规避 :避免在泛型接口/父类上标注 @Tool,只在具体实现类的方法上标。

10.1.3 isFunctionalType:返回值是函数式类型就跳过

java 复制代码
110→	private boolean isFunctionalType(Method toolMethod) {
111→		var isFunction = ClassUtils.isAssignable(Function.class, toolMethod.getReturnType())
112→				|| ClassUtils.isAssignable(Supplier.class, toolMethod.getReturnType())
113→				|| ClassUtils.isAssignable(Consumer.class, toolMethod.getReturnType());
114→
115→		if (isFunction) {
116→			if (logger.isWarnEnabled()) {
117→				logger.warn("Method " + toolMethod.getName() + "is annotated with @Tool but returns a functional type. "
118→						+ "This is not supported and the method will be ignored.");
119→			}
120→		}
121→
122→		return isFunction;
123→	}

返回 Function/Supplier/Consumer 的 @Tool 方法被静默忽略(只 warn)。

第 117 行有个字符串拼接瑕疵:"Method " + toolMethod.getName() + "is annotated" ------ getName() 后面没有空格 ,拼出来是 "Method getWeatheris annotated"。

理由很好理解:函数式类型无法序列化给模型。但只检查了 getReturnType(),没检查参数类型。所以:

java 复制代码
@Tool String process(Function<String,String> mapper) { }   // 参数是不支持的,但不会被过滤

这个方法会通过所有过滤器,生成 schema 时 victools 对 Function 类型......大概率生成一个奇怪的 schema。然后执行时 buildTypedArgument 反序列化 Function 会失败。

官方文档 tools.adoc:777-782 列出了不支持类型:

  • Optional --- use @Nullable or @ToolParam(required = false) instead.
  • Asynchronous types (CompletableFuture, Future).
  • Reactive types (Flow, Mono, Flux).
  • Functional types (Function, Supplier, Consumer).

但代码只检查了其中一类(函数式返回值) 。其他三类(Optional、CompletableFuture、Mono/Flux)没有任何检查:

  • Optional<String> 返回 → victools 会生成什么?可能 {"type":"object"} 或空
  • Mono<String> 返回 → 反射调用返回的是 Mono 对象(未订阅),DefaultToolCallResultConverter 会把它序列化成 JSON(大概率是 {} 或抛异常)
  • CompletableFuture<String> 返回 → 同上

这些都会"成功"执行,然后给模型一个毫无意义的结果。

§10.11 陷阱 5 会给一个启动期检查所有工具方法签名的方案。

10.1.4 validateToolCallbacks:重名即抛

java 复制代码
130→	private void validateToolCallbacks(ToolCallback[] toolCallbacks) {
131→		List<String> duplicateToolNames = ToolUtils.getDuplicateToolNames(toolCallbacks);
132→		if (!duplicateToolNames.isEmpty()) {
133→			throw new IllegalArgumentException("Multiple tools with the same name (%s) found in sources: %s".formatted(
134→					String.join(", ", duplicateToolNames),
135→					this.toolObjects.stream().map(o -> o.getClass().getName()).collect(Collectors.joining(", "))));
136→		}
137→	}

注意它检查的是单个 provider 内部 的重名。如果你有两个 provider,各自内部无重名但之间有重名,这里检测不到------得靠 ToolCallingChatOptions.validateToolCallbacks(§10.7.3)。

10.1.5 getToolCallbacks() 每次都重新扫描

第 87-103 行没有任何缓存。每次调用都:

  1. 反射扫描所有方法(第 89-90 行)
  2. 为每个方法生成 JSON Schema(第 95 行 ToolDefinitions.from → JsonSchemaGenerator.generateForMethodInput)
  3. 抢 synchronized 锁(第 09 章 §9.5.4)
  4. new 一个 converter(第 99 行)
  5. new 一个 MethodToolCallback
  6. 重名校验(第 105 行)

这是一个重量级操作。 如果框架在每次请求时调用它,20 个工具的应用会:

  • 20 次 schema 生成(每次可能 5-10 个子 schema 生成)
  • 200 次 synchronized 竞争
  • 20 个 converter + 20 个 callback 对象

实践建议(重要):

java 复制代码
@Bean
ToolCallbackProvider weatherTools() {
    return MethodToolCallbackProvider.builder().toolObjects(new WeatherTools()).build();
}

注册为 singleton bean ,让 Spring 只创建一次。但注意------getToolCallbacks() 本身仍然每次调用都重新扫描,bean 单例不缓存方法返回值。

真正要缓存得自己包一层:

java 复制代码
@Bean
ToolCallback[] cachedToolCallbacks() {
    return MethodToolCallbackProvider.builder()
        .toolObjects(new WeatherTools())
        .build()
        .getToolCallbacks();     // 只在这里调用一次,结果作为 bean
}

或者用 StaticToolCallbackProvider 包一层(第 09 章 §9.1.4)。

第 11 章会看到 ToolCallingAdvisor 是如何使用 provider 的------那里决定了这个开销的实际频率。


10.2 MethodToolCallback:执行的完整链路

265 行,是工具执行的核心。

java 复制代码
// spring-ai-model/src/main/java/org/springframework/ai/tool/method/MethodToolCallback.java:50
50→public final class MethodToolCallback implements ToolCallback {
52→	private static final JsonHelper jsonHelper = new JsonHelper();
54→	private static final Log logger = LogFactory.getLog(MethodToolCallback.class);
56→	private static final ToolCallResultConverter DEFAULT_RESULT_CONVERTER = new DefaultToolCallResultConverter();
58→	private static final ToolMetadata DEFAULT_TOOL_METADATA = ToolMetadata.builder().build();
60→	private final ToolDefinition toolDefinition;
62→	private final ToolMetadata toolMetadata;
64→	private final Method toolMethod;
66→	private final @Nullable Object toolObject;
68→	private final ToolCallResultConverter toolCallResultConverter;
70→	public MethodToolCallback(ToolDefinition toolDefinition, @Nullable ToolMetadata toolMetadata, Method toolMethod,
71→			@Nullable Object toolObject, @Nullable ToolCallResultConverter toolCallResultConverter) {
72→		Assert.notNull(toolDefinition, "toolDefinition cannot be null");
73→		Assert.notNull(toolMethod, "toolMethod cannot be null");
74→		Assert.isTrue(Modifier.isStatic(toolMethod.getModifiers()) || toolObject != null,
75→				"toolObject cannot be null for non-static methods");

第 74-75 行:非静态方法必须有 toolObject。这是个构造期校验,早失败优于晚失败。

10.2.1 call 主流程

java 复制代码
99→	@Override
100→	public String call(String toolInput, @Nullable ToolContext toolContext) {
101→		Assert.hasText(toolInput, "toolInput cannot be null or empty");
102→
103→		if (logger.isDebugEnabled()) {
104→			logger.debug("Starting execution of tool: " + this.toolDefinition.name());
105→		}
106→
107→		this.validateToolContextSupport(toolContext);
108→
109→		Map<String, Object> toolArguments = this.extractToolArguments(toolInput);
110→		Assert.state(toolArguments != null, "toolArguments must not be null");
111→
112→		Object[] methodArguments = this.buildMethodArguments(toolArguments, toolContext);
113→
114→		Object result = this.callMethod(methodArguments);
115→
116→		if (logger.isDebugEnabled()) {
117→			logger.debug("Successful execution of tool: " + this.toolDefinition.name());
118→		}
119→
120→		Type returnType = this.toolMethod.getGenericReturnType();
121→
122→		return this.toolCallResultConverter.convert(result, returnType);
123→	}

七步:Assert → 日志 → 校验 context → 解析参数 → 绑定参数 → 反射调用 → 转换结果。

注意第 120 行 :returnType 用的是 getGenericReturnType()(泛型类型),不是 getReturnType()(擦除类型)。这传给 toolCallResultConverter,让 converter 能知道 List<Order> 而不是裸 List。

不过 DefaultToolCallResultConverter(§10.3)只用了 Void.TYPE 判断,完全没用泛型信息。所以这个精确类型目前只给自定义 converter 用。

10.2.2 ⚠️ validateToolContextSupport:一个反直觉的强制

java 复制代码
125→	private void validateToolContextSupport(@Nullable ToolContext toolContext) {
126→		var isNonEmptyToolContextProvided = toolContext != null && !CollectionUtils.isEmpty(toolContext.getContext());
127→		var isToolContextAcceptedByMethod = Stream.of(this.toolMethod.getParameterTypes())
128→			.anyMatch(type -> ClassUtils.isAssignable(ToolContext.class, type));
129→		if (isToolContextAcceptedByMethod && !isNonEmptyToolContextProvided) {
130→			throw new IllegalArgumentException("ToolContext is required by the method as an argument");
131→		}
132→	}

读第 129 行的条件:

  • 方法声明了 ToolContext 参数(isToolContextAcceptedByMethod == true)
  • 且 提供的 context 为空(isNonEmptyToolContextProvided == false)
  • → 抛 IllegalArgumentException

这意味着:如果你的工具方法声明了 ToolContext 参数,但调用时没带 context(或 context 是空 Map),直接抛异常。

对比 ToolCallback 的默认实现(第 09 章 §9.1.2):那里是打 INFO 日志然后继续。这里是抛异常。

两种截然不同的策略。 哪个对?

MethodToolCallback 的策略更安全:如果方法需要 context 却没拿到,继续执行大概率 NPE 或数据错误,早失败更好。

但有个陷阱 :什么情况下 ToolContext 会是空的?

ToolCallingChatOptions.getToolContext() 返回 @Nullable Map,如果是 null 或空 Map,框架在调用时可能传 null 或 new ToolContext(Map.of())。两种情况下第 126 行都判定为 "not provided"。

所以:只要你在方法签名里写了 ToolContext,就必须保证每次调用都传了非空的 toolContext。 否则工具直接不可用。

实践中这会造成一类诡异故障:

java 复制代码
@Tool(description = "查询订单")
List<Order> listOrders(@ToolParam("用户ID") String userId, ToolContext ctx) {
    String tenant = (String) ctx.getContext().get("tenantId");
    ...
}
  • 通过 ChatClient 调用时,如果没设 .toolContext(...) → 抛异常
  • 单元测试直接调 callback.call("{}") → 抛异常

建议写法 :把 ToolContext 声明为可选,在方法内部判空:

java 复制代码
@Tool(description = "查询订单")
List<Order> listOrders(@ToolParam("用户ID") String userId) {
    // 不声明 ToolContext,改为让框架通过其他方式注入
}

或者,如果你确实需要 context,就得保证调用链上始终设置。§10.6.3 会给一个"强制注入 toolContext 的 advisor"。

10.2.3 ⚠️ 参数绑定依赖 parameter.getName()

java 复制代码
146→	// Based on the implementation in MethodToolCallback.
147→	@SuppressWarnings("null")
148→	private Object[] buildMethodArguments(Map<String, Object> toolInputArguments, @Nullable ToolContext toolContext) {
149→		return Stream.of(this.toolMethod.getParameters()).map(parameter -> {
150→			if (ClassUtils.isAssignable(ToolContext.class, parameter.getType())) {
151→				return toolContext;
152→			}
153→			Object rawArgument = toolInputArguments.get(parameter.getName());
154→			return buildTypedArgument(rawArgument, parameter.getParameterizedType());
155→		}).toArray();
156→	}

第 153 行:toolInputArguments.get(parameter.getName())。

Parameter.getName() 默认返回 arg0、arg1...... 除非编译时加了 -parameters 选项。

如果没加:

java 复制代码
// 方法签名
@Tool String getWeather(String city)

// parameter.getName() == "arg0"
// 模型返回 {"city": "北京"}(因为 schema 里写的是 "arg0"?)

等等------schema 生成(第 09 章 §9.6.1 第 146 行)也用 method.getParameters()[i].getName()。所以:

  • schema 里属性名 = "arg0"
  • 模型看到 "arg0",返回 {"arg0": "北京"}
  • 绑定时 get("arg0") → 命中

是"自洽"的! 因为没有 -parameters 时,schema 和绑定用的是同一个错误的名字。

但这意味着模型拿到的是无语义的参数名。 {"arg0": "..."} 对模型理解"这个参数是什么意思"毫无帮助------它只能靠 description(第 09 章 §9.2.5)和类型。

这就是为什么 Spring Boot 项目必须开 -parameters。Spring Boot 的 Maven 插件默认会加:

xml 复制代码
<!-- spring-boot-starter-parent 的 pom 里 -->
<maven.compiler.parameters>true</maven.compiler.parameters>

Spring Framework 的 ParameterNameDiscoverer 在 Spring 6.1+ 也做了改进。但如果你:

  • 不是 Spring Boot 项目
  • 用了自定义构建配置
  • 用 GraalVM native image(需要额外配置)

就会掉进 arg0 陷阱。

验证方法(§10.15 会给命令):

bash 复制代码
javap -p -c YourTools.class | grep -A2 "MethodParameters"

或者直接打印 ToolDefinition.inputSchema() 看属性名。

补充 :第 150-152 行的 ToolContext 判断用的是 ClassUtils.isAssignable(ToolContext.class, parameter.getType())。

注意方向:isAssignable(lhsType, rhsType) 返回 lhsType.isAssignableFrom(rhsType)。所以这里是 ToolContext.class.isAssignableFrom(parameter.getType()),即"参数类型是 ToolContext 或其子类"。正确。

10.2.4 类型转换:buildTypedArgument

java 复制代码
158→	private @Nullable Object buildTypedArgument(@Nullable Object value, Type type) {
159→		if (value == null) {
160→			return null;
161→		}
162→		try {
163→			if (type instanceof Class<?>) {
164→				return jsonHelper.convertToTypedObject(value, (Class<?>) type);
165→			}
166→
167→			// For generic types, use the fromJson method that accepts Type
168→			String json = jsonHelper.toJson(value, true);
169→			return jsonHelper.fromJson(json, type);
170→		}
171→		catch (Exception ex) {
172→			logger.warn("Conversion from JSON failed", ex);
173→			Throwable cause = (ex.getCause() instanceof JacksonException) ? ex.getCause() : ex;
174→			throw new ToolExecutionException(this.getToolDefinition(), cause);
175→		}
176→	}

两条路径:

  1. 非泛型类型 (第 163-165 行):convertToTypedObject(value, Class) ------ 直接转换
  2. 泛型类型 (第 167-169 行):先序列化成 JSON 字符串,再反序列化 ------ 一次往返

第 168-169 行的往返是为了处理 List<Order>、Map<String, X> 这类泛型。Jackson 的 convertValue 对泛型支持有限,绕一圈 JSON 更可靠。

代价:每次泛型参数绑定都有一次序列化 + 反序列化。对于高频工具,这是可测量的开销。

第 159-161 行:value == null 直接返回 null。 这意味着:

  • 模型没填某个参数 → rawArgument == null → 方法收到 null
  • 如果方法参数是原始类型 int,null 传给 invoke 会抛 IllegalArgumentException

所以:原始类型参数 + 可选 = 运行时崩溃。

java 复制代码
@Tool void f(@ToolParam(required = false) int count) { }
// 模型不传 count → buildTypedArgument 返回 null → invoke(int, null) → IllegalArgumentException

规避 :可选参数用包装类型 Integer,并在方法内判 null:

java 复制代码
@Tool void f(@ToolParam(required = false) Integer count) {
    int actual = (count != null) ? count : 10;
}

10.2.5 extractToolArguments:JSON 解析的容错

java 复制代码
134→	private @Nullable Map<String, Object> extractToolArguments(String toolInput) {
135→		try {
136→			return jsonHelper.fromJson(toolInput, new ParameterizedTypeReference<>() {
137→			});
138→		}
139→		catch (Exception ex) {
140→			logger.warn("Conversion from JSON failed", ex);
141→			Throwable cause = (ex.getCause() instanceof JacksonException) ? ex.getCause() : ex;
142→			throw new ToolExecutionException(this.getToolDefinition(), cause);
143→		}
144→	}

第 136-137 行用了匿名 ParameterizedTypeReference<>() {} 配合钻石操作符------Jackson 会通过反射拿到 Map<String,Object> 的实际类型。这是个巧妙的 Java 技巧(利用匿名类的泛型签名)。

第 139-143 行捕获所有 Exception 并包装成 ToolExecutionException。

这意味着:模型返回了非法 JSON,也会变成 ToolExecutionException,然后走异常处理器(§10.4)。

这是合理的------模型确实可能返回畸形 JSON(尤其是小模型或长上下文时)。

但注意:它不会重试 。异常会一路向上,最终要么变成给模型的错误消息("你的 JSON 格式不对,请重新生成"),要么抛给调用方。前者的效果取决于 ToolExecutionExceptionProcessor 的配置。

10.2.6 callMethod:setAccessible 与异常包装

java 复制代码
178→	@SuppressWarnings("NullAway") // ex.getCause() is guaranteed to be non-null
179→	private @Nullable Object callMethod(Object[] methodArguments) {
180→		if (isObjectNotPublic() || isMethodNotPublic()) {
181→			this.toolMethod.setAccessible(true);
182→		}
183→
184→		Object result;
185→		try {
186→			result = this.toolMethod.invoke(this.toolObject, methodArguments);
187→		}
188→		catch (IllegalAccessException ex) {
189→			throw new IllegalStateException("Could not access method: " + ex.getMessage(), ex);
190→		}
191→		catch (InvocationTargetException ex) {
192→			throw new ToolExecutionException(this.toolDefinition, ex.getCause());
193→		}
194→		return result;
195→	}

第 180-182 行:setAccessible(true)。

这意味着 @Tool 方法可以是 private、protected、package-private。官方文档 tools.adoc:86 明确说:

Annotate any method --- public, package-private, private; static or instance --- with @Tool

但 setAccessible 在 Java 17+ 的模块系统下会受限 。如果工具类在另一个模块且不 opens,会抛 InaccessibleObjectException(注意:它是 RuntimeException,不是 IllegalAccessException)。

第 188 行只 catch 了 IllegalAccessException ------ InaccessibleObjectException 不会被捕获 ,会直接抛出去,且不会 被包装成 ToolExecutionException。

这是一个跨模块场景下的真实问题。Spring Boot 应用通常在 unnamed module,不受影响;但在 JPMS 严格模块化或某些容器环境下会中招。

第 191-193 行:InvocationTargetException → ToolExecutionException。

注意第 192 行取的是 ex.getCause()(方法真正抛出的异常),而不是 InvocationTargetException 本身。这是正确的------ToolExecutionException 的 cause 是你的业务异常。

第 178 行的 @SuppressWarnings("NullAway") 注释说明 ex.getCause() 在 NullAway 看来可能为空,但这里 InvocationTargetException 保证有 cause。

异常链梳理:

scss 复制代码
业务方法抛 RuntimeException("库存不足")
  → InvocationTargetException(反射包装)
  → ToolExecutionException(cause = RuntimeException("库存不足"))   [第192行]
  → ToolExecutionExceptionProcessor.process()                      [§10.4]
  → 要么返回错误消息给模型,要么重抛

10.2.7 isObjectNotPublic / isMethodNotPublic

java 复制代码
197→	private boolean isObjectNotPublic() {
198→		return this.toolObject != null && !Modifier.isPublic(this.toolObject.getClass().getModifiers());
199→	}
200→
201→	private boolean isMethodNotPublic() {
202→		return !Modifier.isPublic(this.toolMethod.getModifiers());
203→	}

第 198 行检查 toolObject.getClass().getModifiers()。

注意这里检查的是运行时类。 如果 toolObject 是 package-private 类的实例,.getClass().getModifiers() 非 public → 返回 true → setAccessible。

但如果是 CGLIB 代理(§10.1.1 提到扫描用目标类),代理类本身通常是 public,所以 isObjectNotPublic() 返回 false。而目标方法可能非 public → isMethodNotPublic() 返回 true → 仍然 setAccessible。

逻辑是"或"(第 180 行),所以只要任一为真就设置。 覆盖完整。


10.3 结果转换:DefaultToolCallResultConverter

java 复制代码
// spring-ai-model/src/main/java/org/springframework/ai/tool/execution/DefaultToolCallResultConverter.java:40
40→	public final class DefaultToolCallResultConverter implements ToolCallResultConverter {
42→		private static final JsonHelper jsonHelper = new JsonHelper();
44→		private static final Log logger = LogFactory.getLog(DefaultToolCallResultConverter.class);
46→		@Override
47→		public String convert(@Nullable Object result, @Nullable Type returnType) {
48→			if (returnType == Void.TYPE) {
49→				logger.debug("The tool has no return type. Converting to conventional response.");
50→				return jsonHelper.toJson("Done");
51→			}
52→			if (result instanceof RenderedImage) {
53→				final var buf = new ByteArrayOutputStream(1024 * 4);
54→				try {
55→					ImageIO.write((RenderedImage) result, "PNG", buf);
56→				}
57→				catch (IOException e) {
58→					return "Failed to convert tool result to a base64 image: " + e.getMessage();
59→				}
60→				final var imgB64 = Base64.getEncoder().encodeToString(buf.toByteArray());
61→				return jsonHelper.toJson(Map.of("mimeType", "image/png", "data", imgB64));
62→			}
63→			else {
64→				logger.debug("Converting tool result to JSON.");
65→				return jsonHelper.toJson(result, true);
66→			}
67→		}
68→
69→	}

10.3.1 void → "Done"(含引号)

第 48-51 行:返回类型是 Void.TYPE(即原始 void)时,返回 jsonHelper.toJson("Done")。

注意是 toJson("Done") 而不是直接返回 "Done"。 所以实际返回的是带引号的 JSON 字符串:

arduino 复制代码
"Done"

模型收到的 tool message 内容就是 "Done"(含双引号)。

这是刻意的:tool message 的内容应该是 JSON,统一格式。

与 第 09 章 §9.8 的 Void.class 对比:

场景 用哪个 位置
输入类型是 Void(Supplier) type == Void.class JsonSchemaGenerator:199
返回类型是 void returnType == Void.TYPE DefaultToolCallResultConverter:48

如果一个方法返回 Void(包装类型)而非 void:

java 复制代码
@Tool Void cleanup() { ...; return null; }

getGenericReturnType() 返回 Void.class ≠ Void.TYPE → 第 48 行不命中 → 走第 65 行 toJson(null, true)。

第 65 行 jsonHelper.toJson(null, true) 的结果取决于 JsonHelper 实现------大概率是 "null"(字符串)或 null。如果是 null,那么 tool message 的内容是 null,某些供应商会拒绝。

所以:工具方法请用 void 而不是 Void。

10.3.2 RenderedImage → base64 PNG

第 52-62 行:返回 java.awt.image.RenderedImage(如 BufferedImage)时:

  1. 第 53 行:ByteArrayOutputStream(1024*4) ------ 初始 4KB 缓冲
  2. 第 55 行:ImageIO.write(..., "PNG", buf)
  3. 第 60 行:base64 编码
  4. 第 61 行:包成 {"mimeType":"image/png","data":"..."}

这是一个隐藏的多模态能力------工具可以返回图片,Spring AI 自动转 base64。

限制:

  • 只支持 RenderedImage ,不支持 MultipartFile、Resource、byte\[\]、路径字符串
  • 硬编码 PNG (第 55 行 "PNG"),不支持 JPEG
  • 第 57-59 行:写失败时返回错误字符串(不抛异常)

第 58 行的错误信息会直接给模型看 :"Failed to convert tool result to a base64 image: ..."。这算是合理的降级。

性能提示 :第 53 行的 4KB 初始缓冲对大图会频繁扩容。一张 1920×1080 的 PNG 可能几 MB,ByteArrayOutputStream 会经历 ~10 次翻倍扩容(每次数组复制)。对高频图像工具,这是个可优化点。

10.3.3 其他一切 → JSON

第 63-66 行:兜底路径,jsonHelper.toJson(result, true)。

第二个参数 true 是什么?从上下文看应该是"pretty print"或"include nulls"。考虑到 第 09 章 §9.6.7 对 toPrettyString() 的成本分析,如果这里是 pretty print,那么工具返回值也会被美化,同样有 token 成本。

实际上第 50 行 jsonHelper.toJson("Done") 只有 1 个参数,第 65 行有 2 个。需要看 JsonHelper 的重载定义才能确定(§10.15 会给验证命令)。

关键影响:

  • 返回 String → 被 JSON 序列化 → 加引号、转义

    java 复制代码
    @Tool String greet() { return "Hello"; }
    // 模型收到: "Hello"  (含引号)
  • 返回 List<Order> → JSON 数组

  • 返回 null(非 void 方法)→ "null"

"返回 String 会被加引号"这个点常被忽略。 如果你在 prompt 里指望模型拿到裸文本,实际拿到的是带引号的 JSON 字符串。对大多数模型无影响(它们能理解),但对精确匹配场景有影响。


10.4 异常处理:ToolExecutionException 与处理器

10.4.1 ToolExecutionException

java 复制代码
// spring-ai-model/src/main/java/org/springframework/ai/tool/execution/ToolExecutionException.java:27
27→	public class ToolExecutionException extends RuntimeException {
29→		private final ToolDefinition toolDefinition;
31→		public ToolExecutionException(ToolDefinition toolDefinition, Throwable cause) {
32→			super(cause.getMessage(), cause);
33→			this.toolDefinition = toolDefinition;
34→		}
36→		public ToolDefinition getToolDefinition() {
37→			return this.toolDefinition;
38→		}
39→	}

注意第 32 行:super(cause.getMessage(), cause) ------ message 直接取自 cause。

这意味着如果 cause 的 getMessage() 返回 null,异常消息也是 null。§10.4.3 会看到处理器的兜底。

没有 (String message, Throwable cause, ToolDefinition) 构造器,也没有无参构造器。所以你无法自定义消息------只能接受 cause 的消息。

10.4.2 ToolExecutionExceptionProcessor

java 复制代码
// spring-ai-model/src/main/java/org/springframework/ai/tool/execution/ToolExecutionExceptionProcessor.java:27
27→	@FunctionalInterface
28→	public interface ToolExecutionExceptionProcessor {
34→		String process(ToolExecutionException exception);
36→	}

一个函数式接口。两个选择:

  1. 返回错误消息 → 框架把它作为 tool response 发给模型,模型可以据此恢复或道歉
  2. 抛异常 → 调用方处理(第 11 章会看到 ToolCallingAdvisor 怎么处理)

10.4.3 ⚠️ DefaultToolExecutionExceptionProcessor 的非对称行为

java 复制代码
// DefaultToolExecutionExceptionProcessor.java:57
57→	@Override
58→	public String process(ToolExecutionException exception) {
59→		Assert.notNull(exception, "exception cannot be null");
60→		Throwable cause = exception.getCause();
61→		if (cause instanceof RuntimeException runtimeException) {
62→			if (this.rethrownExceptions.stream().anyMatch(rethrown -> rethrown.isAssignableFrom(cause.getClass()))) {
63→				throw runtimeException;
64→			}
65→		}
66→		else {
67→			// If the cause is not a RuntimeException (e.g., IOException,
68→			// OutOfMemoryError), rethrow the tool exception.
69→			throw exception;
70→		}
71→
72→		if (this.alwaysThrow) {
73→			throw exception;
74→		}
75→		String message = exception.getMessage();
76→		if (message == null || message.isBlank()) {
77→			message = "Exception occurred in tool: " + exception.getToolDefinition().name() + " ("
78→					+ cause.getClass().getSimpleName() + ")";
79→		}
80→		if (logger.isDebugEnabled()) {
81→			logger.debug("Exception thrown by tool: " + exception.getToolDefinition().name() + ". Message: " + message,
82→					exception);
83→		}
84→		return message;
85→	}

第 66-70 行是本章最容易被忽略的硬结论:

java 复制代码
66→		else {
67→			// If the cause is not a RuntimeException (e.g., IOException,
68→			// OutOfMemoryError), rethrow the tool exception.
69→			throw exception;
70→		}

cause 不是 RuntimeException 时,无条件重抛。

哪些异常不是 RuntimeException?

  • 所有受检异常 :IOException、SQLException、InterruptedException、ParseException......
  • 所有 Error :OutOfMemoryError、StackOverflowError、NoClassDefFoundError......

所以:

java 复制代码
@Tool(description = "读取文件")
String readFile(@ToolParam("路径") String path) throws IOException {
    return Files.readString(Path.of(path));    // 可能抛 IOException
}

如果文件不存在:

  1. MethodToolCallback.callMethod 第 191-192 行:InvocationTargetException → ToolExecutionException(cause = NoSuchFileException)
  2. process() 第 61 行:NoSuchFileException instanceof RuntimeException? NoSuchFileException extends IOException extends Exception → false
  3. 第 66-70 行:throw exception
  4. 异常冒出工具调用循环,一路抛到 ChatClient.call() → 你的 controller → 500

模型收不到任何错误消息,请求直接失败。

而官方文档 tools.adoc:791-793 描述的默认行为是:

The processor has two choices: produce an error message that the framework feeds back to the model (so the model can recover or apologize), or re-throw the exception so the caller handles it.

看起来两者都可。但默认实现对受检异常只做了后者。

规避方案 :在工具方法内部把受检异常转成 RuntimeException:

java 复制代码
@Tool(description = "读取文件")
String readFile(@ToolParam("路径") String path) {
    try {
        return Files.readString(Path.of(path));
    } catch (IOException e) {
        throw new IllegalStateException("无法读取文件 " + path + ":" + e.getMessage(), e);
    }
}

或者配置 rethrowExceptions 允许列表(但那是反向的------它是"要重抛"的列表,不是"要转换"的列表)。

更根本的方案 :自定义 ToolExecutionExceptionProcessor,把所有非 Error 的异常都转成消息:

java 复制代码
@Bean
ToolExecutionExceptionProcessor lenientProcessor() {
    return ex -> {
        Throwable cause = ex.getCause();
        if (cause instanceof Error) {
            throw ex;                    // Error 必须冒泡,不能吞
        }
        String msg = ex.getMessage();
        return (msg != null && !msg.isBlank())
            ? msg
            : "工具 " + ex.getToolDefinition().name() + " 执行失败";
    };
}

注意第 72-74 行的 alwaysThrow :它是 RuntimeException 路径下的最后一道检查。所以:

  • alwaysThrow = true + cause 是 RuntimeException → 第 73 行抛
  • alwaysThrow = true + cause 是受检异常 → 第 69 行先抛(更早)

alwaysThrow 对受检异常没有影响(因为第 66-70 行已经抛了)。

10.4.4 第 76-79 行的消息兜底

java 复制代码
75→		String message = exception.getMessage();
76→		if (message == null || message.isBlank()) {
77→			message = "Exception occurred in tool: " + exception.getToolDefinition().name() + " ("
78→					+ cause.getClass().getSimpleName() + ")";
79→		}

因为 ToolExecutionException 的 message 直接取自 cause(§10.4.1),如果 cause 没消息,第 75 行拿到 null。第 77-78 行合成一个:

php 复制代码
Exception occurred in tool: getWeather (NullPointerException)

这个兜底消息是英文的。 中文应用如果想让模型用中文道歉,得自己处理。

另外:NullPointerException 的 getMessage() 在 Java 14+ 的 helpful NPE 下通常有值(如 "Cannot invoke \"String.length()\" because \"x\" is null"),但在老版本 JDK 上是 null。

把 cause 的类名暴露给模型 (第 78 行)算不算信息泄漏?对内部系统无所谓;对公开服务,暴露 SQLException 之类的类名可能给攻击者线索。§10.11 陷阱 15 会讨论。


10.5 FunctionToolCallback:四种 lambda 形态

243 行的泛型类,用于把 Function/Supplier/Consumer/BiFunction 变成工具。

java 复制代码
// spring-ai-model/src/main/java/org/springframework/ai/tool/function/FunctionToolCallback.java:51
51→	public class FunctionToolCallback<I, O> implements ToolCallback {
65→		private final Type toolInputType;
67→		private final BiFunction<@Nullable I, @Nullable ToolContext, O> toolFunction;

内部统一用 BiFunction<I, ToolContext, O> 存储(第 67 行),其他形态都适配成它。

10.5.1 四种 builder 重载

重载 行号 适配
builder(String, BiFunction<I,ToolContext,O>) 139-142 直接用
builder(String, Function<I,O>) 147-150 (request, context) -> function.apply(request)
builder(String, Supplier<O>) 155-159 input -> supplier.get(),且 inputType(Void.class)
builder(String, Consumer<I>) 164-172 input -> { consumer.accept(input); return null; }

Function 版本(第 147-150 行)丢弃了 ToolContext:

java 复制代码
147→	public static <I, O> Builder<I, O> builder(String name, Function<I, O> function) {
148→		Assert.notNull(function, "function cannot be null");
149→		return new Builder<>(name, (request, context) -> function.apply(request));
150→	}

第 149 行的 lambda 第二个参数 context 被忽略。所以用 Function 定义的工具无法访问 ToolContext 。要访问就得用 BiFunction 版本。

Supplier 版本(第 155-159 行):

java 复制代码
155→	public static <O> Builder<Void, O> builder(String name, Supplier<O> supplier) {
156→		Assert.notNull(supplier, "supplier cannot be null");
157→		Function<Void, O> function = input -> supplier.get();
158→		return builder(name, function).inputType(Void.class);
159→	}

第 158 行 .inputType(Void.class) ------ 配合 第 09 章 §9.8 的 Void.class 特判,生成 {"type":"object","properties":{}}。

Consumer 版本(第 164-172 行):

java 复制代码
164→	public static <I> Builder<I, Void> builder(String name, Consumer<I> consumer) {
165→		Assert.notNull(consumer, "consumer cannot be null");
166→		@SuppressWarnings("NullAway")
167→		Function<I, Void> function = (I input) -> {
168→			consumer.accept(input);
169→			return null;
170→		};
171→		return builder(name, function);
172→	}

返回 null。然后 call 第 115 行 converter.convert(null, null)。

注意第 115 行传的 returnType 是 null (硬编码),不是 Void.TYPE:

java 复制代码
115→		return this.toolCallResultConverter.convert(response, null);

所以 DefaultToolCallResultConverter 第 48 行 returnType == Void.TYPE 不命中 (传的是 null),走第 65 行 toJson(null, true)。

结果:Consumer 工具返回 "null",而 @Tool void 方法返回 "Done"。

又一个不一致。 两个都是"无返回值"的工具语义,给模型的字符串却不同:

定义方式 模型收到
@Tool void f() "Done"
FunctionToolCallback.builder("f", (Consumer) ...) null 序列化结果

对模型来说,"Done" 比 "null" 信息量大得多(后者可能被理解为"工具出错了")。

10.5.2 call 的差异:整体反序列化 vs 逐参数

java 复制代码
100→	@Override
101→	public String call(String toolInput, @Nullable ToolContext toolContext) {
102→		Assert.hasText(toolInput, "toolInput cannot be null or empty");
...
108→		I request = jsonHelper.fromJson(toolInput, this.toolInputType);
109→		O response = callMethod(request, toolContext);
...
115→		return this.toolCallResultConverter.convert(response, null);
116→	}

第 108 行把整个 toolInput 反序列化成一个对象 I。

对比 MethodToolCallback(§10.2.3)是反序列化成 Map<String,Object> 再逐参数绑定。

这意味着 FunctionToolCallback 的入参必须是一个可以整体映射的类型------通常是 record 或 POJO:

java 复制代码
record WeatherRequest(String city, String unit) {}

FunctionToolCallback
    .builder("getWeather", (WeatherRequest req) -> weatherService.fetch(req.city(), req.unit()))
    .inputType(WeatherRequest.class)
    .description("查询天气")
    .build();

schema 通过 generateForType(WeatherRequest.class) 生成 (Builder.build() 第 233 行),所以字段的 required/description 由字段上的注解 决定(走 第 09 章 §9.9.2 的 checkRequired,支持 Kotlin 默认值)。

这比 MethodToolCallback 路径更完善 (第 09 章 §9.9.4)。如果你用 Kotlin 且需要默认值语义,优先用 FunctionToolCallback + data class。

10.5.3 inputType 必须显式设置

Builder.build() 第 224 行:

java 复制代码
224→		public FunctionToolCallback<I, O> build() {
225→			Assert.notNull(this.inputType, "inputType cannot be null");

除了 Supplier 版本(第 158 行自动设 Void.class),其他三种都必须 手动 .inputType(...),否则 IllegalStateException。

为什么不能从 lambda 推断? Java 泛型擦除------Function<WeatherRequest, String> 的 WeatherRequest 在运行时拿不到。所以必须显式传。

可以用 ParameterizedTypeReference 传复杂泛型(第 211-214 行):

java 复制代码
.inputType(new ParameterizedTypeReference<List<Order>>() {})

10.5.4 异常包装

java 复制代码
118→	private O callMethod(@Nullable I request, @Nullable ToolContext toolContext) {
119→		try {
120→			return this.toolFunction.apply(request, toolContext);
121→		}
122→		catch (ToolExecutionException ex) {
123→			throw ex;
124→		}
125→		catch (Exception ex) {
126→			throw new ToolExecutionException(this.toolDefinition, ex);
127→		}
128→	}

第 122-124 行:已经是 ToolExecutionException 就不再包装 (避免双重包装)。 第 125-127 行:其他 Exception → 包装。

注意第 125 行只 catch Exception,不 catch Error。 所以 OutOfMemoryError 会直接冒泡(合理)。

对比 MethodToolCallback 第 191-193 行只 catch InvocationTargetException / IllegalAccessException ------ 因为反射调用会把业务异常包进 InvocationTargetException。两条路径殊途同归。


10.6 ToolContext:不可变的上下文传递

java 复制代码
// spring-ai-model/src/main/java/org/springframework/ai/chat/model/ToolContext.java:43
43→	public final class ToolContext {
45→		private final Map<String, Object> context;
52→		public ToolContext(Map<String, Object> context) {
53→			this.context = Collections.unmodifiableMap(context);
54→		}
60→		public Map<String, Object> getContext() {
61→			return this.context;
62→		}
63→	}

64 行,极简。

10.6.1 ⚠️ unmodifiableMap 是视图,不是拷贝

第 53 行:Collections.unmodifiableMap(context) 返回的是原 Map 的不可修改视图。

这意味着:

java 复制代码
Map<String,Object> mutable = new HashMap<>();
mutable.put("tenant", "A");
ToolContext ctx = new ToolContext(mutable);

mutable.put("tenant", "B");        // 修改原 Map
ctx.getContext().get("tenant");    // → "B" !

ToolContext 的内容会随外部 Map 变化。 Javadoc 第 26-28 行声称:

It provides an immutable view of the context to ensure thread-safety and prevent modification after creation.

"immutable view"(不可变视图)的说法是准确 的------通过 ctx.getContext() 拿到的确实改不了。但"prevent modification after creation"是错的------通过原 Map 引用能改。

线程安全也是假的 :如果原 Map 是 HashMap 且被其他线程修改,unmodifiableMap 视图不提供任何并发保护。

正确用法:

java 复制代码
ToolContext ctx = new ToolContext(Map.copyOf(sourceMap));   // 先拷贝,再包装

Map.copyOf 做真正的不可变拷贝。这样 ToolContext 才真的是快照。

实践影响 :Spring AI 内部在 ToolCallingChatOptions 存的是 @Nullable Map<String,Object>(第 50 行),到执行时才包成 ToolContext。如果 options 被复用(如作为 defaultOptions),且原始 Map 被改,所有后续调用都会看到新值。

10.6.2 ToolContext 的用途

官方 tools.adoc 第 700 行左右(=== Tool Context 章节)说明它的作用是传递不属于模型交互的数据:

  • 租户 ID(多租户隔离)
  • 用户身份(权限校验)
  • 请求追踪 ID(日志关联)
  • 会话级配置

这些数据不应该出现在 schema 里 (否则模型可能篡改),也不应该让模型知道。第 09 章 §9.6.2 的 continue 保证了这点。

10.6.3 如何确保 ToolContext 总是非空

结合 §10.2.2 的强制校验,如果你有工具方法声明了 ToolContext,需要保证调用链上一定会注入。方案:

方案 A:在 defaultOptions 里设

java 复制代码
@Bean
ChatClient chatClient(ChatModel model) {
    return ChatClient.builder(model)
        .defaultOptions(ToolCallingChatOptions.builder()
            .toolContext(Map.of("tenantId", "default"))     // 保底非空
            .build())
        .build();
}

方案 B:自定义 advisor 强制注入(推荐,可动态)

java 复制代码
public class TenantContextAdvisor implements CallAdvisor {
    @Override
    public ChatClientResponse adviseCall(ChatClientRequest req, CallAdvisorChain chain) {
        var options = req.getOptions();
        var merged = ToolCallingChatOptions.builder()
            .toolContext(mergeTenant(options.getToolContext()))
            .build();
        return chain.nextCall(req.mutate().options(merged).build());
    }
}

方案 C:不声明 ToolContext,改用 ThreadLocal/Reactor Context

对于 Web 应用,很多时候用 RequestContextHolder 或 Reactor 的 Context 更自然。但要注意:工具方法可能在另一个线程 执行(如果用了 TaskExecutor),ThreadLocal 会失效。§10.11 陷阱 11 会展开。


10.7 ToolCallingChatOptions:2.x 的变更

java 复制代码
// spring-ai-model/src/main/java/org/springframework/ai/model/tool/ToolCallingChatOptions.java:39
39→	public interface ToolCallingChatOptions extends ChatOptions {
44→		@Nullable List<ToolCallback> getToolCallbacks();
50→		@Nullable Map<String, Object> getToolContext();
59→		@Override
60→		ToolCallingChatOptions.Builder<?> mutate();
65→		static ToolCallingChatOptions.Builder<?> builder() {
66→			return new DefaultToolCallingChatOptions.Builder<>();
67→		}

10.7.1 ⚠️ 只有两个字段,没有 toolNames

ToolCallingChatOptions 只增加了 toolCallbacks 和 toolContext 两个方法。

1.x 时代有 toolNames()(按名字引用已注册的 ToolCallback bean)------在 2.x 已移除。

这意味着:

  • 不能再通过 "getWeather" 这样的字符串引用工具
  • 必须传 ToolCallback 对象或 ToolCallbackProvider

而 ToolCallbackResolver(§10.7.4)保留了按名解析的能力,但默认不再作为 fallback (upgrade-notes.adoc:305-328)。

10.7.2 mutate() 的返回类型收窄

第 59-60 行:

java 复制代码
59→		@Override
60→		ToolCallingChatOptions.Builder<?> mutate();

Javadoc 第 55-57 行:

Narrows the return type of {@link ChatOptions#mutate()} so generic tool calling code can chain methods without casting.

这是 Java 协变返回类型的典型应用。父类 ChatOptions.mutate() 返回 ChatOptions.Builder<?>,这里收窄成 ToolCallingChatOptions.Builder<?>,于是:

java 复制代码
ToolCallingChatOptions opts = ...;
opts.mutate()
    .toolCallbacks(cb)       // 无需 cast
    .temperature(0.5)
    .build();

如果没有这个收窄,.toolCallbacks() 就不可见。

10.7.3 两个静态合并方法:语义完全不同

java 复制代码
69→	static @Nullable List<ToolCallback> mergeToolCallbacks(@Nullable List<ToolCallback> runtimeToolCallbacks,
70→			@Nullable List<ToolCallback> defaultToolCallbacks) {
71→		if (CollectionUtils.isEmpty(runtimeToolCallbacks)) {
72→			return defaultToolCallbacks != null ? List.copyOf(defaultToolCallbacks) : null;
73→		}
74→		return List.copyOf(runtimeToolCallbacks);
75→	}
76→
77→	static @Nullable Map<String, Object> mergeToolContext(@Nullable Map<String, Object> runtimeToolContext,
78→			@Nullable Map<String, Object> defaultToolContext) {
79→		if (CollectionUtils.isEmpty(runtimeToolContext)) {
80→			return defaultToolContext != null ? Map.copyOf(defaultToolContext) : null;
81→		}
82→		Assert.noNullElements(runtimeToolContext.keySet(), "runtimeToolContext keys cannot be null");
83→		if (CollectionUtils.isEmpty(defaultToolContext)) {
84→			return Map.copyOf(runtimeToolContext);
85→		}
86→		Assert.noNullElements(defaultToolContext.keySet(), "defaultToolContext keys cannot be null");
87→		var mergedToolContext = new java.util.HashMap<>(defaultToolContext);
88→		mergedToolContext.putAll(runtimeToolContext);
89→		return Map.copyOf(mergedToolContext);
90→	}

mergeToolCallbacks(第 69-75 行):二选一,不是合并!

  • runtime 非空 → 用 runtime(完全丢弃 default)
  • runtime 空 → 用 default

mergeToolContext(第 77-90 行):真正合并,runtime 覆盖 default。

两个同名前缀的方法,语义完全不同。 这是极易踩的坑:

java 复制代码
// default 里有 10 个工具
chatClient.defaultOptions(...toolCallbacks(defaultTenTools))

// runtime 传 1 个工具
chatClient.prompt()...options(...toolCallbacks(oneTool))

// 结果:只有 1 个工具!default 的 10 个全丢了

而 toolContext 则是合并:

java 复制代码
// default: {tenant: A, user: X}
// runtime: {tenant: B}
// 结果: {tenant: B, user: X}   ← 合并了

为什么会这样设计? 工具列表的"合并"语义不明确(同名工具怎么办?顺序怎么办?),所以作者选择"运行时全量覆盖"。而 context 是 Map,合并语义清晰。

实践影响 :如果你在 defaultOptions 配了全局工具(如"获取当前时间"),然后在某次调用传了特定工具,全局工具会消失。要保留必须显式合并:

java 复制代码
var merged = new ArrayList<>(defaultCallbacks);
merged.addAll(runtimeCallbacks);

第 82、86 行的 Assert.noNullElements(...keySet()) 检查 key 不能为 null(value 可以)。

第 87 行用了全限定名 new java.util.HashMap<>(...)(而不是 import),因为类里已经 import 了 Map,这里需要具体实现。这种写法在 Spring 源码里常见(避免额外 import)。

10.7.4 ToolCallbackResolver:按名解析(默认已禁用)

java 复制代码
// spring-ai-model/src/main/java/org/springframework/ai/tool/resolution/ToolCallbackResolver.java:29
29→	public interface ToolCallbackResolver {
34→		@Nullable ToolCallback resolve(String toolName);
36→	}

两个实现:

  • StaticToolCallbackResolver:从 HashMap 查(第 40-47 行构造时建索引)
  • DelegatingToolCallbackResolver:依次委托,第一个非 null 就返回(第 47-53 行)

StaticToolCallbackResolver 的重名是静默覆盖 (第 47 行 put):

java 复制代码
46→			.forEach(toolCallback -> this.toolCallbacks.put(toolCallback.getToolDefinition().name(), toolCallback));

两个同名工具,后者覆盖前者,无警告 。而 MethodToolCallbackProvider 会抛异常(§10.1.4)。

又一个不一致。

upgrade-notes.adoc:305-328 说明了 2.0.1 的重大变更:

==== Tool Resolution Fallback Disabled by Default

The fallback that resolves and executes tools registered as beans, but not attached to a request has been disabled by default. Previously, when the model returned a tool call for a tool that was not attached to the request, DefaultToolCallingManager fell back to the application-wide ToolCallbackResolver (backed by every ToolCallback bean in the application context) and executed the resolved tool. Starting with 2.0.1, the tools attached to a request --- via .tools(...), .defaultTools(...), or ToolCallingChatOptions.toolCallbacks(...) --- are the only tools that can be executed for that request.

这是一个安全加固。 之前的行为有风险:模型可以让它"猜"一个工具名,框架会从容器里所有 ToolCallback bean 里找出来执行。这等于:模型可以调用你注册了但没打算在这条请求里暴露的工具。

开启方式(upgrade-notes.adoc:315):

properties 复制代码
spring.ai.tools.resolution.fallback.enabled=true

或编程式(第 322-324 行):

java 复制代码
ToolCallingManager toolCallingManager = ToolCallingManager.builder()
    .toolCallbackResolver(toolCallbackResolver)
    .toolResolutionFallbackEnabled(true)     // 大概
    .build();

建议保持默认关闭,除非你明确需要"全局工具池"语义。


10.8 2.0.1 新增:ToolCallLimits(40 / 150)

这是 2.0.1 的全新特性 (@since 2.0.1),也是补丁版本里少见的功能新增。

java 复制代码
// spring-ai-model/src/main/java/org/springframework/ai/model/tool/ToolCallLimits.java:37
37→	final class ToolCallLimits {
42→		static final int NO_LIMIT = -1;
44→		private final int defaultMaxCallsPerTool;
46→		private final Map<String, Integer> maxCallsPerTool;
48→		private final Set<String> toolsExcludedFromLimit;
50→		private final int maxTotalToolCalls;
52→		private final ToolCallLimitBehavior onLimitExceeded;

注意第 37 行:类是 package-private(final class 无 public)。 用户无法直接引用,只能通过 DefaultToolCallingManager.Builder 配置。

10.8.1 默认值:40 与 150

java 复制代码
// DefaultToolCallingManager.java:100
100→	public static final int DEFAULT_MAX_CALLS_PER_TOOL = 40;
107→	public static final int DEFAULT_MAX_TOTAL_TOOL_CALLS = 150;

upgrade-notes.adoc:202:

DefaultToolCallingManager now caps tool calls per turn --- DefaultToolCallingManager.DEFAULT_MAX_CALLS_PER_TOOL (40) per tool, DEFAULT_MAX_TOTAL_TOOL_CALLS (150) overall --- where previously there was no limit at all.

从"无限制"到"有限制",这是一个行为变更。 虽然 40/150 很宽松,但对某些 agentic 场景(如批量处理 200 条记录)会触发。

10.8.2 countPriorToolCalls:无状态的计数重建

java 复制代码
67→	/**
68→	 * Tally tool calls already executed earlier in this turn by scanning the
69→	 * {@link ToolResponseMessage.ToolResponse} entries present in the current turn's
70→	 * portion of the conversation history. The
71→	 * {@link org.springframework.ai.chat.prompt.Prompt} passed into
72→	 * {@link DefaultToolCallingManager#executeToolCalls} accumulates every prior round's
73→	 * assistant and tool response messages (see {@code ToolCallingAdvisor}'s tool-calling
74→	 * loop), so this reconstructs the running count without the manager itself holding
75→	 * any per-turn state. Only messages from the current turn - the last
76→	 * {@link UserMessage} onward, matching the Turn definition used by the
77→	 * {@code spring-ai-session} project - are counted, so a long conversation replayed in
78→	 * full by an external {@code ChatMemory} advisor does not inflate the count with tool
79→	 * calls from earlier turns.
80→	 */
81→	static Map<String, Integer> countPriorToolCalls(List<Message> instructions) {
82→		Map<String, Integer> counts = new HashMap<>();
83→		for (Message message : currentTurnMessages(instructions)) {
84→			if (message instanceof ToolResponseMessage toolResponseMessage) {
85→				for (ToolResponseMessage.ToolResponse response : toolResponseMessage.getResponses()) {
86→					counts.merge(response.name(), 1, Integer::sum);
87→				}
88→			}
89→		}
90→		return counts;
91→	}

设计要点 :ToolCallingManager 是无状态 的(可以单例共享),计数通过扫描消息历史重建。

第 67-79 行的 Javadoc 解释了"为什么只算当前 turn":

Only messages from the current turn - the last UserMessage onward ... are counted, so a long conversation replayed in full by an external ChatMemory advisor does not inflate the count with tool calls from earlier turns.

这是关键设计。如果没有这个限制:

markdown 复制代码
第 1 轮对话:用户问 → 模型调 30 次工具 → 回答
第 2 轮对话:ChatMemory 把完整历史(含 30 次工具调用)塞进 prompt
            → 计数从 30 开始 → 第 2 轮只能用 10 次工具

多轮对话会越来越早地触发上限------这是错误的。

currentTurnMessages(第 98-105 行):

java 复制代码
98→	private static List<Message> currentTurnMessages(List<Message> instructions) {
99→		for (int i = instructions.size() - 1; i >= 0; i--) {
100→			if (instructions.get(i) instanceof UserMessage) {
101→				return instructions.subList(i, instructions.size());
102→			}
103→		}
104→		return instructions;
105→	}

从后往前找最后一个 UserMessage,取它及其之后的所有消息。

第 104 行:如果没有 UserMessage(如纯 system + assistant 的场景),返回全部消息。

这个 fallback 有风险:第 104 行返回全量,意味着"没有用户消息时,计数包含所有历史工具调用"。对于程序化调用(无 UserMessage),多轮会累积。

不过 ToolCallingAdvisor 的循环里通常有 UserMessage,所以实际影响有限。

10.8.3 check:超限判定

java 复制代码
113→	@Nullable Breach check(String toolName, int toolCallCount, int totalToolCallCount) {
114→		if (!this.toolsExcludedFromLimit.contains(toolName)) {
115→			int maxCallsForTool = this.maxCallsPerTool.getOrDefault(toolName, this.defaultMaxCallsPerTool);
116→			if (maxCallsForTool != NO_LIMIT && toolCallCount > maxCallsForTool) {
117→				return new Breach(toolName, maxCallsForTool,
118→						"Tool call limit (%d) exceeded for tool '%s'. No further calls to this tool are allowed in this turn."
119→							.formatted(maxCallsForTool, toolName));
120→			}
121→		}
122→
123→		if (this.maxTotalToolCalls != NO_LIMIT && totalToolCallCount > this.maxTotalToolCalls) {
124→			return new Breach(null, this.maxTotalToolCalls,
125→					"Total tool call limit (%d) exceeded for this turn. No further tool calls are allowed."
126→						.formatted(this.maxTotalToolCalls));
127→		}
128→
129→		return null;
130→	}
131→
132→	record Breach(@Nullable String toolName, int limit, String message) {
133→	}

第 116 行是 > 不是 >=。 所以 maxCallsPerTool = 40 时,第 41 次调用才触发。toolCallCount 是包含本次调用的计数。

第 114 行:toolsExcludedFromLimit 里的工具完全不受单工具限制 ,但仍受总限制(第 123-127 行在 if 外面)。

10.8.4 ToolCallLimitBehavior:两种超限行为

java 复制代码
// ToolCallLimitBehavior.java:26
26→	public enum ToolCallLimitBehavior {
32→		THROW,
40→		RETURN_ERROR_RESPONSE
41→	}
  • THROW :抛 ToolCallLimitExceededException,中断当前批次
  • RETURN_ERROR_RESPONSE :跳过调用,返回一个说明"已达上限"的 ToolResponse,让模型看到并自行决定

RETURN_ERROR_RESPONSE 是更优雅的设计------模型收到"你调用 X 已达上限",可以改弦更张(比如换一种方式,或者直接告诉用户)。

但这取决于模型是否"听话"。THROW 更确定。

哪个是默认? 需要看 DefaultToolCallingManager 的 Builder 默认值(第 372-378 行附近)。从 ToolCallLimits 的构造调用(第 130 行)看不到默认值。§10.15 会给验证命令。

10.8.5 ToolCallLimitExceededException 的精妙设计

java 复制代码
// ToolCallLimitExceededException.java:47
47→	public static final String FINISH_REASON = "toolCallLimitExceeded";
57→	public static final String METADATA_PARTIAL_TOOL_RESPONSES = "partialToolResponses";

120→	public Generation buildGeneration() {
121→		String breachMessage = getMessage();
122→		List<ToolResponseMessage.ToolResponse> partialResponses = List.of();
123→
124→		List<Message> history = this.partialToolExecutionResult.conversationHistory();
125→		if (!history.isEmpty() && history.get(history.size() - 1) instanceof ToolResponseMessage toolResponseMessage) {
126→			List<ToolResponseMessage.ToolResponse> responses = toolResponseMessage.getResponses();
127→			if (!responses.isEmpty()) {
128→				breachMessage = responses.get(responses.size() - 1).responseData();
129→				partialResponses = responses.subList(0, responses.size() - 1);
130→			}
131→		}
132→
133→		ChatGenerationMetadata metadata = ChatGenerationMetadata.builder()
134→			.finishReason(FINISH_REASON)
135→			.metadata(METADATA_PARTIAL_TOOL_RESPONSES, partialResponses)
136→			.build();
137→		return new Generation(new AssistantMessage(breachMessage), metadata);
138→	}

Javadoc 第 50-56 行解释了为什么把"已完成的部分结果"放进 metadata 而不是作为兄弟 Generation:

Keeping them in metadata - rather than as separate, sibling Generations - avoids mixing successful tool output with the error message as if they were equally valid alternative answers.

这个设计判断很值得学习。 Generation 在 Spring AI 的语义里是"一个候选答案"(对应 ChatResponse.getResults() 列表)。如果把部分工具结果和错误都作为 Generation,调用方会以为"有多个答案可选"。放进 metadata 则明确了:主答案是错误,那些是附带的上下文。

第 128-129 行:取最后一个 response 作为主消息(那是合成的"超限"消息),前面的作为 partial。


10.9 AugmentedToolCallback:给工具注入"推理字段"

这是 2.x 一个鲜为人知但很有用的机制,包在 org.springframework.ai.tool.augment。

10.9.1 它解决什么问题

ReAct(Reason + Act)模式要求模型在调用工具时先说明推理过程:

json 复制代码
{
  "reasoning": "用户想知道北京天气,我需要调用 getWeather",
  "confidence": "high",
  "city": "北京"
}

但 reasoning/confidence 不是工具的真实参数------业务方法不该接收它们。

AugmentedToolCallback 的做法:

  1. 把额外字段加入 schema(模型会填)
  2. 执行前提取 出来,交给一个 Consumer
  3. (可选)从 toolInput 里移除这些字段
  4. 用净化后的 input 调用真实工具

10.9.2 实现

java 复制代码
// spring-ai-model/src/main/java/org/springframework/ai/tool/augment/AugmentedToolCallback.java:75
75→	public AugmentedToolCallback(ToolCallback delegate, Class<T> augmentedArgumentsClass,
76→			@Nullable Consumer<AugmentedArgumentEvent<T>> augmentedArgumentsConsumer,
77→			boolean removeExtraArgumentsAfterProcessing) {
78→		Assert.notNull(delegate, "Delegate ToolCallback must not be null");
79→		Assert.notNull(augmentedArgumentsClass, "Argument types must not be null");
80→		Assert.isTrue(augmentedArgumentsClass.isRecord(), "Argument types must be a Record type");
81→		Assert.isTrue(augmentedArgumentsClass.getRecordComponents().length > 0,
82→				"Argument types must have at least one field");
83→
84→		this.delegate = delegate;
85→		this.augmentedArgumentTypes = ToolInputSchemaAugmenter.toAugmentedArgumentTypes(augmentedArgumentsClass);
86→		String originalSchema = this.delegate.getToolDefinition().inputSchema();
87→		String augmentedSchema = ToolInputSchemaAugmenter.augmentToolInputSchema(originalSchema,
88→				this.augmentedArgumentTypes);
89→		this.augmentedToolDefinition = ToolDefinition.builder()
90→			.name(this.delegate.getToolDefinition().name())
91→			.description(this.delegate.getToolDefinition().description())
92→			.inputSchema(augmentedSchema)
93→			.build();

第 80-82 行:必须是 record,且至少有一个组件。

第 86-93 行:读原 schema → 增强 → 用相同的 name/description 造新 ToolDefinition。

java 复制代码
122→	private String handleAugmentedArguments(String toolInput) {
123→
124→		// Extract the augmented arguments from the toolInput and send them to the
125→		// consumer if provided.
126→		if (this.augmentedArgumentsConsumer != null) {
127→			T augmentedArguments = jsonHelper.fromJson(toolInput, this.augmentedArgumentsClass);
128→			this.augmentedArgumentsConsumer
129→				.accept(new AugmentedArgumentEvent<>(this.augmentedToolDefinition, toolInput, augmentedArguments));
130→		}
131→
132→		// Optionally remove the extra arguments from the toolInput
133→		if (this.removeAugmentedArgumentsAfterProcessing) {
134→			var args = jsonHelper.fromJsonToMap(toolInput);
135→
136→			for (AugmentedArgumentType newFieldType : this.augmentedArgumentTypes) {
137→				args.remove(newFieldType.name());
138→			}
139→			toolInput = jsonHelper.toJson(args);
140→		}
141→
142→		return toolInput;
143→	}

10.9.3 ⚠️ ToolInputSchemaAugmenter.toAugmentedArgumentTypes 的 required 默认值是 false

java 复制代码
// ToolInputSchemaAugmenter.java:51
51→	public static <T extends Record> List<AugmentedArgumentType> toAugmentedArgumentTypes(Class<T> recordClass) {
52→		try {
53→
54→			return Arrays.stream(recordClass.getRecordComponents()).map(c -> {
55→				// Get the annotation from the corresponding field, not the record
56→				// component
57→				ToolParam toolParam = null;
58→				try {
59→					var field = recordClass.getDeclaredField(c.getName());
60→					toolParam = field.getAnnotation(ToolParam.class);
61→				}
62→				catch (NoSuchFieldException e) {
63→					// Field not found, toolParam remains null
64→				}
65→
66→				return new AugmentedArgumentType(c.getName(), c.getGenericType(),
67→						toolParam != null ? toolParam.description() : "no description",
68→						toolParam != null ? toolParam.required() : false);
69→			}).toList();
70→
71→		}
72→		catch (Exception e) {
73→			throw new RuntimeException("Failed to extract record field types", e);
74→		}
75→	}

第 68 行:没有 @ToolParam 时 required = false。

这与全库其他地方"默认 required = true"的约定相反(第 09 章 §9.2.5、第 09 章 §9.9)。

后果:你加了 reasoning 字段但忘记标 @ToolParam(required = true),它就是可选的,模型可能不填 → consumer 收到 null。

另外第 67 行:没有 @ToolParam 时 description 是字面量 "no description" (不是空字符串)。这个字符串会出现在给模型的 schema 里 。模型看到 "description": "no description" 会困惑。

这两处都像是调试代码遗留。 生产使用前建议自己实现提取逻辑,或者至少总是写 @ToolParam。

第 55-64 行有个有价值的注释:

java 复制代码
55→				// Get the annotation from the corresponding field, not the record
56→				// component

Java record 的注解如果没指定 @Target,会同时出现在record component 、field 、accessor method 上。getDeclaredField(...).getAnnotation(...) 确保从字段取(与 第 09 章 §9.9.2 的 victools 行为一致)。

10.9.4 一个完整示例

java 复制代码
// 1. 定义"推理字段"
public record ToolReasoning(
    @ToolParam(description = "你为什么调用这个工具", required = true) String reasoning,
    @ToolParam(description = "置信度 low/medium/high", required = false) String confidence
) {}

// 2. 包装
var augmented = AugmentedToolCallbackProvider.<ToolReasoning>builder()
    .toolObject(new WeatherTools())
    .argumentType(ToolReasoning.class)
    .argumentConsumer(event -> {
        ToolReasoning r = event.arguments();
        log.info("[{}] reasoning={}, confidence={}",
            event.toolDefinition().name(), r.reasoning(), r.confidence());
    })
    .removeExtraArgumentsAfterProcessing(true)      // 关键:从 input 移除
    .build();

模型看到的 schema 会多出 reasoning 和 confidence(在原有参数基础上)。执行时它们被消费掉并从 input 移除,真实方法只收到业务参数。

removeExtraArgumentsAfterProcessing 默认为 true (AugmentedToolCallbackProvider.Builder:82):

java 复制代码
82→		private boolean removeExtraArgumentsAfterProcessing = true;

如果不移除,真实方法会收到多余字段------对 MethodToolCallback 来说多余字段被忽略(§10.2.3 按名字取),对 FunctionToolCallback(整体反序列化)可能导致 UnrecognizedPropertyException(取决于 Jackson 配置)。


10.10 工具调用的观测

java 复制代码
// spring-ai-model/src/main/java/org/springframework/ai/tool/observation/ToolCallingObservationDocumentation.java:32
32→	public enum ToolCallingObservationDocumentation implements ObservationDocumentation {
37→		TOOL_CALL {
39→			@Override
40→			public Class<? extends ObservationConvention<? extends Observation.Context>> getDefaultConvention() {
41→				return DefaultToolCallingObservationConvention.class;
42→			}

低基数 key(进指标):

Key 名 值
ai.operation.type(AI_OPERATION_TYPE) 操作类型
ai.provider(AI_PROVIDER) 供应商
spring.ai.kind(SPRING_AI_KIND) Spring AI kind
spring.ai.tool.definition.name(TOOL_DEFINITION_NAME) 工具名
spring.ai.tool.type(TOOL_TYPE) 工具类型

高基数 key(只进 trace):

  • spring.ai.tool.definition.description(TOOL_DEFINITION_DESCRIPTION)
  • 可能还有 input/output

spring.ai.tool.definition.name 是低基数------这个判断基于"工具数量有限"。但如果你有动态工具(MCP 场景,第 11-12 章),工具名可能有几十上百个,且带前缀,基数会膨胀。

MCP 场景下要特别注意 :每个 MCP server 的工具都会带上前缀,工具基数 = 所有 server 工具数之和。如果这个值很大(如 500+),把它放进指标会导致指标基数爆炸(Prometheus 每个时间序列几百字节 × 500 = 可观的开销)。

第 20 章会详细讨论可观测性体系。


10.11 陷阱总表(全量 18 条)

# 陷阱 严重度 位置 规避
1 @Tool 无 description → 描述变成原始 camelCase 方法名 高 ToolUtils:82 永远显式写 description
2 方法参数 vs POJO 字段的 required 判定逻辑不同(Kotlin 默认值只在字段生效) 高 JsonSchemaGenerator:232 vs AbstractSpringAiSchemaModule:97 Kotlin 场景用 FunctionToolCallback + data class
3 受检异常不会被转成给模型的错误消息,直接穿透 高 DefaultToolExecutionExceptionProcessor:66-70 工具内转 RuntimeException,或自定义处理器
4 声明了 ToolContext 参数但调用时为空 → 抛 IAE 中 MethodToolCallback:129-131 保证 defaultOptions 里有 toolContext,或不声明该参数
5 返回 Optional/Mono/CompletableFuture 无任何检查,静默产出垃圾 中 MethodToolCallbackProvider:110 只查函数式返回 启动期扫描签名(见下)
6 未开 -parameters → schema 属性名是 arg0 高 JsonSchemaGenerator:146 确认 maven.compiler.parameters=true
7 可选参数用原始类型(int)→ 模型不传时 invoke 崩溃 中 MethodToolCallback:159-161 可选参数一律用包装类型
8 format 只移除顶层,不递归进 $defs 低 JsonSchemaGenerator:173 自定义递归清除(Mistral 等场景)
9 getToolCallbacks() 每次全量重扫 + 抢全局锁 中 MethodToolCallbackProvider:87 缓存成 bean,或用 Static 包装
10 mergeToolCallbacks 是"二选一"不是"合并" 高 ToolCallingChatOptions:69-75 需要合并时手工合并
11 ToolContext 是 unmodifiableMap 视图,非拷贝 中 ToolContext:53 构造时 Map.copyOf
12 toPrettyString() 让 schema 膨胀 30-60%,token 成本显著 中 JsonSchemaGenerator:190 生产环境紧凑化
13 StaticToolCallbackProvider.getToolCallbacks() 返回内部数组引用 低 StaticToolCallbackProvider:87 不用于长期持有
14 桥接方法在 assert 与 get 两阶段过滤不一致 低 MethodToolCallbackProvider:70 vs :93 不在泛型父类型上标 @Tool
15 异常消息含 cause 类名,可能泄漏内部信息 低 DefaultToolExecutionExceptionProcessor:78 自定义处理器脱敏
16 中文/带空格工具名只 warn 不 fail,运行时才 400 中 ToolUtils:130 启动期强制校验(见下)
17 Consumer 工具返回 "null",@Tool void 返回 "Done" 低 FunctionToolCallback:115 vs DefaultToolCallResultConverter:48 统一用 @Tool void
18 Void(包装)返回类型不触发 "Done",可能产出 null 低 DefaultToolCallResultConverter:48 用原始 void

10.11.1 生产可用的异常处理器

第 09 章 §9.12 给了启动期的 schema/定义校验器。这里补运行期的那一半------一个把 §10.4.3 的"受检异常穿透"问题彻底解决的处理器:

java 复制代码
/**
 * 让绝大多数工具异常都能以"模型可理解的消息"形式回喂,而不是穿透到调用方。
 */
@Bean
ToolExecutionExceptionProcessor resilientProcessor() {
    return ex -> {
        Throwable cause = ex.getCause();

        // 1) Error 必须冒泡:OOM / StackOverflow 等不应被假装成"工具失败"
        if (cause instanceof Error) {
            throw ex;
        }

        // 2) 安全相关异常必须冒泡:交给上层做鉴权失败处理,不能喂给模型
        if (cause instanceof AccessDeniedException || cause instanceof AuthenticationException) {
            throw ex;
        }

        // 3) 其余一律转成消息,且做脱敏(§10.4.3 陷阱 15)
        String msg = ex.getMessage();
        if (msg == null || msg.isBlank()) {
            msg = "工具 " + ex.getToolDefinition().name() + " 执行失败";
        }
        return sanitize(msg);
    };
}

/** 去掉可能泄漏内部实现的信息:异常类名、堆栈、SQL、路径。 */
private static String sanitize(String msg) {
    return msg
        .replaceAll("(?i)(exception|error)\\$?", "")
        .replaceAll("\\b[a-z]+\\\.[a-zA-Z0-9.]*Exception\\b", "错误")
        .replaceAll("(?i)select .*?from", "数据库查询")
        .replaceAll("(/[\\w.-]+){2,}", "[路径]")
        .trim();
}

三个设计判断:

(1)Error 必须冒泡。 OutOfMemoryError 不是"工具失败了",是"JVM 要挂了"。把它变成一条给模型的消息,会让模型以为重试有用,制造更大灾难。

(2)安全异常必须冒泡。 AccessDeniedException 喂给模型,模型可能回复"我没有权限查看 X"------这本身就是信息泄漏(攻击者得知 X 存在)。应该由上层统一处理为 403。

(3)脱敏是必需的,不是可选的。 §10.4.3 第 77-78 行合成的消息会包含 cause.getClass().getSimpleName(),也就是 SQLException、NoSuchFileException 这类名字。sanitize 把它们剥掉。

如何注册 :DefaultToolCallingManager.Builder 上有对应的设置项(第 11 章会展开 ToolCallingManager 的构造)。Spring Boot 场景下直接声明 ToolExecutionExceptionProcessor bean 即可被自动装配。

10.11.2 自定义 ToolCallback:什么时候值得,怎么写

内置的 MethodToolCallback / FunctionToolCallback 覆盖了绝大多数场景。需要自定义的情况:

场景 为什么内置的不够 自定做法
需要 Spring 注入 @Tool(resultConverter=...) 是 newInstance(),无法注入(第 09 章 §9.2.4) 自定义 callback 作为 bean
需要动态 schema schema 在运行时才知道(如数据库驱动的表单) getToolDefinition() 每次返回新 schema
需要非 JSON 入参 模型返回的是纯文本或自定义格式 自己解析 toolInput
需要批处理 一次调用里合并多次工具请求 自定义 call() 逻辑

最小可编译骨架:

java 复制代码
@Component
public class DatabaseQueryTool implements ToolCallback {

    private final JdbcTemplate jdbc;
    private final ToolDefinition definition;

    public DatabaseQueryTool(JdbcTemplate jdbc) {
        this.jdbc = jdbc;
        this.definition = ToolDefinition.builder()
            .name("query_table")
            .description("按表名与条件查询业务数据表,最多返回 50 行")
            .inputSchema("""
                {
                  "$schema": "https://json-schema.org/draft/2020-12/schema",
                  "type": "object",
                  "properties": {
                    "table": { "type": "string", "description": "表名,只能是 orders 或 customers" },
                    "limit": { "type": "integer", "description": "返回行数,1-50,默认 20" }
                  },
                  "required": ["table"]
                }
                """)
            .build();
    }

    @Override
    public ToolDefinition getToolDefinition() {
        return this.definition;
    }

    /** 必须 override 两参版本 ------ 见第 09 章 §9.1.2 的静默丢弃陷阱。 */
    @Override
    public String call(String toolInput, @Nullable ToolContext ctx) {
        if (ctx != null && !ctx.getContext().isEmpty()) {
            throw new IllegalStateException("本工具不接受 ToolContext,但收到了 keys: " + ctx.getContext().keySet());
        }
        return call(toolInput);
    }

    @Override
    public String call(String toolInput) {
        Map<String, Object> args = parse(toolInput);
        String table = (String) args.get("table");

        // 白名单:绝不让模型决定 SQL 的表名
        if (!Set.of("orders", "customers").contains(table)) {
            throw new IllegalArgumentException("不允许查询表: " + table);
        }
        int limit = Math.min(50, ((Number) args.getOrDefault("limit", 20)).intValue());

        // 白名单同样保护列名与排序
        List<Map<String, Object>> rows = this.jdbc.queryForList(
            "SELECT * FROM " + table + " LIMIT " + limit);

        return toJson(rows);
    }
}

三个要点:

  1. override 两参 call (第 09 章 §9.1.2)。不 override 的话 ToolContext 会被静默丢弃。
  2. 表名白名单。这是自定义工具最常见的价值------内置路径无法表达"这个字符串必须是枚举值之一"。
  3. schema 手写 。因为表结构是固定的,手写比生成更可控(也省掉了第 09 章 §9.5.4 的 synchronized 开销)。

10.12 一个完整的生产级工具定义示例

综合本章所有结论,一个"正确"的工具类长这样:

java 复制代码
@Component
public class OrderTools {

    private final OrderService orderService;

    public OrderTools(OrderService orderService) {
        this.orderService = orderService;
    }

    /**
     * 工具描述必须写清楚:
     * 1. 做什么
     * 2. 什么时候该用
     * 3. 什么时候不该用
     */
    @Tool(
        name = "list_user_orders",                 // 显式命名,snake_case,兼容所有供应商
        description = "查询指定用户最近N笔订单的摘要信息(订单号、金额、状态)。"
                    + "仅在用户明确询问订单、购买记录、历史交易时使用。"
                    + "不要用于查询商品库存或物流详情。")
    public List<OrderSummary> listUserOrders(
            @ToolParam(description = "用户ID,形如 U123456。从对话或 ToolContext 获取,不要猜测",
                       required = false) String userId,
            @ToolParam(description = "返回条数,1-50,默认10",
                       required = false) Integer limit) {

        // 陷阱 7:包装类型 + 内部兜底
        int actualLimit = (limit != null && limit > 0 && limit <= 50) ? limit : 10;

        // userId 缺失时优先从上下文取(不走 ToolContext 参数,避免陷阱 4)
        String actualUserId = (userId != null) ? userId : CurrentUser.getId();

        // 陷阱 3:受检异常转 RuntimeException,让模型能看到错误
        try {
            return orderService.listRecent(actualUserId, actualLimit);
        }
        catch (ServiceException e) {          // 假定是受检异常
            throw new IllegalStateException("查询订单失败:" + e.getMessage(), e);
        }
    }

    /**
     * void 工具:返回 "Done" 给模型
     */
    @Tool(
        name = "cancel_order",
        description = "取消一笔尚未发货的订单。已发货或已完成的订单无法取消。"
                    + "调用前必须先向用户确认订单号。")
    public void cancelOrder(
            @ToolParam(description = "要取消的订单号", required = true) String orderNo) {
        try {
            orderService.cancel(orderNo);
        }
        catch (OrderNotCancellableException e) {
            // 业务异常:让模型知道原因并转告用户
            throw new IllegalArgumentException("订单 " + orderNo + " 无法取消:" + e.getMessage(), e);
        }
    }
}

逐条对应本章结论:

写法 对应
显式 name = "list_user_orders" 第 09 章 §9.2.1 避免中文/空格名
三段式 description 第 09 章 §9.2.2、第 09 章 §9.3.3 避免裸方法名
Integer limit 而非 int §10.2.4 避免 null → 原始类型崩溃
required = false + 内部兜底 第 09 章 §9.2.5 避免模型编造
不声明 ToolContext 参数 §10.2.2 避免空 context 抛异常
受检异常转 IllegalStateException/IllegalArgumentException §10.4.3 让错误能回喂模型
void 而非 Void §10.3.1 得到 "Done"
返回 List<OrderSummary> 而非 Optional §10.1.3 避免静默产出垃圾

10.13 本章小结

工具调用的下半场------模型请求到达后,如何把它变成一次真实的 Java 方法调用,再把结果送回去------由五个环节构成:

java 复制代码
@Tool / @ToolParam(注解,声明意图)
    ↓
ToolUtils(名字/描述/converter 推导)
    ↓
ToolDefinitions.from(Method) → ToolDefinition
    ↓                              ↓
JsonSchemaGenerator        ToolMetadata.from(Method)
(inputSchema)                (returnDirect)
    ↓
MethodToolCallback(持有 Method + toolObject + converter)
    ↓ 运行时
extractToolArguments → buildMethodArguments → callMethod → convert

结论一:描述(schema)与执行(invoke)是两条独立的路,靠"参数名"这根细线连着

schema 用 parameter.getName()(第 09 章 §9.6.1 第 146 行),执行也用 parameter.getName()(§10.2.3 第 153 行)。这根线断了(没开 -parameters),两条路会"自洽地一起错" ------schema 写 arg0,模型填 arg0,绑定取 arg0,一切正常,但模型根本不知道参数是什么意思。

这是两章里最危险的一类 bug:不报错,只是效果差。

结论二:默认值策略延续了第 09 章的不一致

第 09 章已经列出 description 的三种默认值。这里补上 required 的另一处相反约定,作为完整清单:

场景 默认 位置
@Tool 有注解但 description 空 原始方法名 getWeather ToolUtils:82
无 @Tool 注解 切分后 get weather ToolUtils:80
FunctionToolCallback 无 description 切分后 get weather FunctionToolCallback:230-231
方法参数 required true JsonSchemaGenerator:256
AugmentedArgumentType.required false ToolInputSchemaAugmenter:68

三种 description 默认值互不相同,两种 required 默认值相反。 这不是设计,是演化痕迹。

唯一的正确应对:全部显式写出来。

结论三:错误处理的语义比看起来窄得多

ToolExecutionExceptionProcessor 听起来能让"错误回喂给模型",但默认实现只对 RuntimeException 这么做(§10.4.3)。受检异常和 Error 直接穿透。

而工具方法天然会抛受检异常(IOException、SQLException)------这是最常见的组合,却是最不被支持的路径。

结论四:2.0.1 给工具调用加了"预算"

40(单工具)/ 150(单轮)的上限(§10.8)是 2.0.1 新增的。它用扫描消息历史而非持有状态来实现计数,并且特意只算"当前 turn"以避免多轮对话累积。

配套的 ToolCallLimitExceededException.buildGeneration() 把部分结果放进 metadata(而非兄弟 Generation)的设计判断,值得在自己的代码里借鉴:区分"答案"和"附带信息",不要用同一个容器。

结论五:性能热点在三个地方

  1. schema 生成 :synchronized 全局锁(第 09 章 §9.5.4)+ toPrettyString()(第 09 章 §9.6.7)
  2. provider 重复扫描 :getToolCallbacks() 无缓存(§10.1.5)
  3. 泛型参数绑定:JSON 往返序列化(§10.2.4)

三个都可以通过"缓存 + 紧凑化 + 避免深层泛型"缓解。对工具数量多、QPS 高的应用,这是可测量的成本。


10.14 思考题

第 1 题 :ToolUtils.getToolDescription 的两条分支(第 80 行与第 82 行)返回不同格式的默认值。请构造一个场景,说明这会导致"加了 @Tool 注解反而让模型表现变差",并给出验证方法(不看源码,只看运行时输出)。

第 2 题 :MethodToolCallback.validateToolContextSupport(第 129-131 行)在"方法需要 ToolContext 但没提供"时抛异常;而 ToolCallback 的默认方法(第 59-68 行)在"提供了 ToolContext 但实现不支持"时只打日志。这两个方向相反的处理,哪一个更危险?为什么?如果让你统一,你会选哪种?

第 3 题 :DefaultToolExecutionExceptionProcessor.process 第 66-70 行对非 RuntimeException 直接重抛。假设你的工具方法签名是 String readFile(String path) throws IOException。请分别说明:

  • 用 @Tool 注解这个方法,文件不存在时会发生什么?
  • 你有哪些改法?每种的代价是什么?
  • 为什么框架作者可能认为"直接重抛"是合理的?(提示:想想受检异常与 Error 的语义差别)

第 4 题 :ToolCallLimits.countPriorToolCalls 用"从后往前找最后一个 UserMessage"来界定"当前 turn"。请指出这个定义在以下场景下的失效情况:

  • 用户连续发两条消息,中间没有 assistant 回复
  • 工具调用过程中,框架自己插入了一条 UserMessage(某些实现会这么做)
  • 纯 system + assistant 的场景(第 104 行的 fallback)

第 5 题 :mergeToolCallbacks(第 69-75 行)是"二选一",mergeToolContext(第 77-90 行)是"合并"。请说明:如果把它们统一成"都合并",会引入什么问题?(提示:List<ToolCallback> 合并时的同名冲突怎么处理?顺序怎么定?)

第 6 题 :JsonSchemaGenerator 第 128 行用 synchronized (generator) 包住 schema 生成。全进程只有两个 generator 实例。请估算:一个 25 个工具、每工具平均 3 个参数、QPS 200 的应用,如果每次请求都重新生成 schema,每秒有多少次锁竞争?给出优化方案并说明为什么"用 ConcurrentHashMap 缓存 schema"需要小心(提示:schema 依赖方法签名,什么时候会变?)。

第 7 题 :AugmentedToolCallback 让你可以给工具加"推理字段"。请分析:

  • 这对 token 消耗有什么影响?
  • 如果 removeExtraArgumentsAfterProcessing = false,对 MethodToolCallback 和 FunctionToolCallback 分别会怎样?为什么行为不同?
  • 这个机制与"让模型在文本里先思考再调用"相比,优劣是什么?

10.15 关键文件索引

php 复制代码
spring-ai-model/src/main/java/org/springframework/ai/
├── tool/
│   ├── ToolCallback.java                                    (70 行) ★ 四方法接口
│   ├── ToolCallbackProvider.java                            (39 行) getToolCallbacks + 2 个 from
│   ├── StaticToolCallbackProvider.java                      (90 行) 内部数组直接返回
│   ├── annotation/
│   │   ├── Tool.java                                        (71 行) name/description/returnDirect/resultConverter
│   │   └── ToolParam.java                                   (46 行) required(default true)/description
│   ├── definition/
│   │   ├── ToolDefinition.java                              (49 行) name/description/inputSchema
│   │   └── DefaultToolDefinition.java                       (81 行) record + 三重 Assert
│   ├── metadata/
│   │   ├── ToolMetadata.java                                (54 行) 仅 returnDirect
│   │   └── DefaultToolMetadata.java                         (49 行) record(boolean)
│   ├── method/
│   │   ├── MethodToolCallback.java                          (265 行) ★★ 执行核心
│   │   └── MethodToolCallbackProvider.java                  (162 行) ★ 扫描 + 校验
│   ├── function/
│   │   └── FunctionToolCallback.java                        (243 行) 四种 lambda 形态
│   ├── execution/
│   │   ├── ToolCallResultConverter.java                     (39 行) @FunctionalInterface
│   │   ├── DefaultToolCallResultConverter.java               (69 行) ★ void→"Done"、Image→base64
│   │   ├── ToolExecutionException.java                      (40 行) message 取自 cause
│   │   ├── ToolExecutionExceptionProcessor.java             (36 行) @FunctionalInterface
│   │   └── DefaultToolExecutionExceptionProcessor.java      (124 行) ★★ 非 RuntimeException 直接重抛
│   ├── resolution/
│   │   ├── ToolCallbackResolver.java                        (36 行) resolve(String)
│   │   ├── StaticToolCallbackResolver.java                  (57 行) HashMap,重名静默覆盖
│   │   └── DelegatingToolCallbackResolver.java              (56 行) 依次委托
│   ├── augment/
│   │   ├── ToolInputSchemaAugmenter.java                    (141 行) required 默认 false ⚠
│   │   ├── AugmentedToolCallback.java                       (145 行) 装饰者
│   │   ├── AugmentedToolCallbackProvider.java               (176 行) Builder
│   │   └── AugmentedArgumentEvent.java                      (34 行) record
│   ├── observation/
│   │   ├── ToolCallingObservationDocumentation.java         ★ 5 个低基数 key
│   │   ├── ToolCallingObservationContext.java
│   │   ├── DefaultToolCallingObservationConvention.java
│   │   └── ToolCallingContentObservationFilter.java
│   └── support/
│       ├── ToolDefinitions.java                             (62 行) builder/from(Method)
│       └── ToolUtils.java                                   (136 行) ★★ 名字/描述/converter/重名
├── model/tool/
│   ├── ToolCallingChatOptions.java                          (164 行) ★ merge 语义不对称
│   ├── DefaultToolCallingChatOptions.java                   (191 行)
│   ├── ToolCallingManager.java                              (50 行) resolveToolDefinitions/executeToolCalls
│   ├── DefaultToolCallingManager.java                       ★★ DEFAULT_MAX_CALLS_PER_TOOL=40 / TOTAL=150
│   ├── ToolCallLimits.java                                  (135 行) @since 2.0.1 ★
│   ├── ToolCallLimitBehavior.java                           (42 行) THROW / RETURN_ERROR_RESPONSE
│   ├── ToolCallLimitExceededException.java                  (140 行) ★ FINISH_REASON + metadata 设计
│   ├── ToolExecutionEligibilityChecker.java                  @since 2.0.0
│   └── ToolExecutionResult.java
├── chat/model/ToolContext.java                              (64 行) unmodifiableMap 视图 ⚠
├── support/ToolCallbacks.java                               (37 行) from(Object...)
└── util/json/schema/
    ├── JsonSchemaGenerator.java                             (362 行) ★★★ 本章核心
    ├── JsonSchemaUtils.java                                 hoistDefsToRoot
    ├── AbstractSpringAiSchemaModule.java                    (149 行) ★ checkRequired
    ├── SpringAiSchemaModule.java                            (57 行)
    └── SchemaType.java                                      (37 行) 仅 2 个值

10.16 复核命令

所有结论都可以在仓库里自己验证一遍:

bash 复制代码
# 1. 验证 description 默认值的两条分支(第 09 章 §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 用 reConcatenate,ToolUtils:82 用 method.getName()

# 2. 验证 required 判定的两套实现(第 09 章 §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. 验证受检异常直接重抛(§10.4.3)
sed -n '57,85p' spring-ai-model/src/main/java/org/springframework/ai/tool/execution/DefaultToolExecutionExceptionProcessor.java
# 重点看第 66-70 行的 else 分支

# 4. 验证 void → "Done" 与 Consumer → null(§10.3.1 / §10.5.1)
sed -n '46,67p' spring-ai-model/src/main/java/org/springframework/ai/tool/execution/DefaultToolCallResultConverter.java
sed -n '100,116p' spring-ai-model/src/main/java/org/springframework/ai/tool/function/FunctionToolCallback.java
# 注意 FunctionToolCallback:115 传的 returnType 是 null

# 5. 验证 ToolContext 强制校验(§10.2.2)
sed -n '125,132p' spring-ai-model/src/main/java/org/springframework/ai/tool/method/MethodToolCallback.java
sed -n '59,68p' spring-ai-model/src/main/java/org/springframework/ai/tool/ToolCallback.java
# 对比两种相反策略

# 6. 验证 merge 语义不对称(§10.7.3)
sed -n '69,90p' spring-ai-model/src/main/java/org/springframework/ai/model/tool/ToolCallingChatOptions.java

# 7. 验证 40 / 150 上限与计数逻辑(§10.8)
grep -n "DEFAULT_MAX_CALLS_PER_TOOL\|DEFAULT_MAX_TOTAL_TOOL_CALLS" \
  spring-ai-model/src/main/java/org/springframework/ai/model/tool/DefaultToolCallingManager.java
sed -n '81,105p' spring-ai-model/src/main/java/org/springframework/ai/model/tool/ToolCallLimits.java

# 8. 验证 schema 生成的 synchronized 与 toPrettyString(第 09 章 §9.5.4 / 第 09 章 §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

# 9. 验证 assert 与 get 的过滤器不一致(§10.1.2)
sed -n '67,83p' spring-ai-model/src/main/java/org/springframework/ai/tool/method/MethodToolCallbackProvider.java
sed -n '85,108p' spring-ai-model/src/main/java/org/springframework/ai/tool/method/MethodToolCallbackProvider.java
# 数一下 filter 数量:前者 2 个,后者 3 个(多 USER_DECLARED_METHODS)

# 10. 验证 format 只移除顶层(第 09 章 §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"),无递归

# 11. 验证 AugmentedArgumentType 的 required 默认 false(§10.9.3)
sed -n '51,75p' spring-ai-model/src/main/java/org/springframework/ai/tool/augment/ToolInputSchemaAugmenter.java
# 第 68 行:toolParam != null ? toolParam.required() : false

# 12. 验证 ToolContext 是视图不是拷贝(§10.6.1)
sed -n '43,63p' spring-ai-model/src/main/java/org/springframework/ai/chat/model/ToolContext.java
# 第 53 行 Collections.unmodifiableMap(context)

# 13. 验证 internalToolExecutionEnabled 已移除
grep -rn "internalToolExecutionEnabled" --include=*.java spring-ai-model/ | head
# 应无输出(或只剩 javadoc 提及)

# 14. 验证 toolNames 已移除
grep -rn "toolNames" --include=*.java spring-ai-model/ | head
# 应无输出

# 15. 验证 ToolCallbacks 的位置(不是 org.springframework.ai.tool)
find . -name "ToolCallbacks.java" -not -path "*/target/*"
# → spring-ai-model/src/main/java/org/springframework/ai/support/ToolCallbacks.java

# 16. 验证 2.0.1 新增的工具限制特性
grep -rn "@since 2.0.1" --include=*.java spring-ai-model/src/main/java/org/springframework/ai/model/tool/
# 应命中 ToolCallLimits / ToolCallLimitBehavior / ToolCallLimitExceededException

# 17. 验证官方升级说明中的工具相关变更
sed -n '198,220p' spring-ai-docs/src/main/antora/modules/ROOT/pages/upgrade-notes.adoc   # Tool Call Limits + strict 默认
sed -n '305,330p' spring-ai-docs/src/main/antora/modules/ROOT/pages/upgrade-notes.adoc   # Resolution Fallback 默认关闭
sed -n '388,470p' spring-ai-docs/src/main/antora/modules/ROOT/pages/upgrade-notes.adoc   # 1.x→2.x 工具迁移

# 18. 验证不支持类型的官方清单 vs 代码检查
sed -n '774,785p' spring-ai-docs/src/main/antora/modules/ROOT/pages/api/tools.adoc
grep -n "isFunctionalType" -A 12 spring-ai-model/src/main/java/org/springframework/ai/tool/method/MethodToolCallbackProvider.java
# 代码只检查了函数式返回值,官方文档列了 4 类

# 19. 验证 AOP 代理的扫描处理
sed -n '67,83p' spring-ai-model/src/main/java/org/springframework/ai/tool/method/MethodToolCallbackProvider.java
# AopUtils.isAopProxy / getTargetClass

# 20. 验证 Kotlin suspend 尾参数排除(第 09 章 §9.6.3)
sed -n '156,161p' spring-ai-model/src/main/java/org/springframework/ai/util/json/schema/JsonSchemaGenerator.java

相关推荐
鶴哥只手遮天1 小时前
Spring AI 2.0.1 深度解析-第 13 章 MCP 客户端:传输、工具聚合与命名冲突
java·人工智能·开源
鶴哥只手遮天1 小时前
Spring AI 2.0.1 深度解析-第 11 章 工具调用(下):ToolCallingManager、ToolCallingAdvisor与调用预算
java·人工智能·开源
鶴哥只手遮天1 小时前
Spring AI 2.0.1 深度解析-第 17 章 向量存储(下):实现剖析,从 PgVector 到 S3 Vectors
java·人工智能·开源
鶴哥只手遮天1 小时前
Spring AI 2.0.1 深度解析-第 07 章 RAG 模块化架构:RetrievalAugmentationAdvisor流水线与 Query体系
java·人工智能·开源
鶴哥只手遮天1 小时前
Spring AI 2.0.1 深度解析-第 09 章 工具调用(上):`@Tool` 注解、ToolDefinition 与 JSON Schema 生成
java·人工智能·开源
鶴哥只手遮天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·人工智能·开源