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 结果之前,中间发生了什么。
这条链路比大多数人想象的长:
模型返回 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:扫描与校验
// 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 的错误消息
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:
"...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 处理值得注意:
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 的过滤器不一致 ⚠️
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)。
泛型会导致桥接方法:
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:返回值是函数式类型就跳过
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(),没检查参数类型。所以:
@Tool String process(Function<String,String> mapper) { } // 参数是不支持的,但不会被过滤
这个方法会通过所有过滤器,生成 schema 时 victools 对 Function 类型……大概率生成一个奇怪的 schema。然后执行时 buildTypedArgument 反序列化 Function 会失败。
官方文档 tools.adoc:777-782 列出了不支持类型:
Optional— use@Nullableor@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:重名即抛
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 行没有任何缓存。每次调用都:
- 反射扫描所有方法(第 89-90 行)
- 为每个方法生成 JSON Schema(第 95 行
ToolDefinitions.from→JsonSchemaGenerator.generateForMethodInput) - 抢
synchronized锁(第 09 章 §9.5.4) - new 一个 converter(第 99 行)
- new 一个
MethodToolCallback - 重名校验(第 105 行)
这是一个重量级操作。 如果框架在每次请求时调用它,20 个工具的应用会:
- 20 次 schema 生成(每次可能 5-10 个子 schema 生成)
- 200 次
synchronized竞争 - 20 个 converter + 20 个 callback 对象
实践建议(重要):
@Bean
ToolCallbackProvider weatherTools() {
return MethodToolCallbackProvider.builder().toolObjects(new WeatherTools()).build();
}
注册为 singleton bean,让 Spring 只创建一次。但注意——getToolCallbacks() 本身仍然每次调用都重新扫描,bean 单例不缓存方法返回值。
真正要缓存得自己包一层:
@Bean
ToolCallback[] cachedToolCallbacks() {
return MethodToolCallbackProvider.builder()
.toolObjects(new WeatherTools())
.build()
.getToolCallbacks(); // 只在这里调用一次,结果作为 bean
}
或者用 StaticToolCallbackProvider 包一层(第 09 章 §9.1.4)。
第 11 章会看到 ToolCallingAdvisor 是如何使用 provider 的——那里决定了这个开销的实际频率。
10.2 MethodToolCallback:执行的完整链路
265 行,是工具执行的核心。
// 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 主流程
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:一个反直觉的强制
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。 否则工具直接不可用。
实践中这会造成一类诡异故障:
@Tool(description = "查询订单")
List<Order> listOrders(@ToolParam("用户ID") String userId, ToolContext ctx) {
String tenant = (String) ctx.getContext().get("tenantId");
...
}
- 通过
ChatClient调用时,如果没设.toolContext(...)→ 抛异常 - 单元测试直接调
callback.call("{}")→ 抛异常
建议写法:把 ToolContext 声明为可选,在方法内部判空:
@Tool(description = "查询订单")
List<Order> listOrders(@ToolParam("用户ID") String userId) {
// 不声明 ToolContext,改为让框架通过其他方式注入
}
或者,如果你确实需要 context,就得保证调用链上始终设置。§10.6.3 会给一个"强制注入 toolContext 的 advisor"。
10.2.3 ⚠️ 参数绑定依赖 parameter.getName()
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 选项。
如果没加:
// 方法签名
@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 插件默认会加:
<!-- 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 会给命令):
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
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→ }
两条路径:
- 非泛型类型(第 163-165 行):
convertToTypedObject(value, Class)—— 直接转换 - 泛型类型(第 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
所以:原始类型参数 + 可选 = 运行时崩溃。
@Tool void f(@ToolParam(required = false) int count) { }
// 模型不传 count → buildTypedArgument 返回 null → invoke(int, null) → IllegalArgumentException
规避:可选参数用包装类型 Integer,并在方法内判 null:
@Tool void f(@ToolParam(required = false) Integer count) {
int actual = (count != null) ? count : 10;
}
10.2.5 extractToolArguments:JSON 解析的容错
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 与异常包装
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。
异常链梳理:
业务方法抛 RuntimeException("库存不足")
→ InvocationTargetException(反射包装)
→ ToolExecutionException(cause = RuntimeException("库存不足")) [第192行]
→ ToolExecutionExceptionProcessor.process() [§10.4]
→ 要么返回错误消息给模型,要么重抛
10.2.7 isObjectNotPublic / isMethodNotPublic
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
// 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 字符串:
"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:
@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)时:
- 第 53 行:
ByteArrayOutputStream(1024*4)—— 初始 4KB 缓冲 - 第 55 行:
ImageIO.write(..., "PNG", buf) - 第 60 行:base64 编码
- 第 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 序列化 → 加引号、转义@Tool String greet() { return "Hello"; } // 模型收到: "Hello" (含引号) - 返回
List<Order>→ JSON 数组 - 返回
null(非 void 方法)→"null"
"返回 String 会被加引号"这个点常被忽略。 如果你在 prompt 里指望模型拿到裸文本,实际拿到的是带引号的 JSON 字符串。对大多数模型无影响(它们能理解),但对精确匹配场景有影响。
10.4 异常处理:ToolExecutionException 与处理器
10.4.1 ToolExecutionException
// 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
// 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→ }
一个函数式接口。两个选择:
- 返回错误消息 → 框架把它作为 tool response 发给模型,模型可以据此恢复或道歉
- 抛异常 → 调用方处理(第 11 章会看到
ToolCallingAdvisor怎么处理)
10.4.3 ⚠️ DefaultToolExecutionExceptionProcessor 的非对称行为
// 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 行是本章最容易被忽略的硬结论:
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……
所以:
@Tool(description = "读取文件")
String readFile(@ToolParam("路径") String path) throws IOException {
return Files.readString(Path.of(path)); // 可能抛 IOException
}
如果文件不存在:
MethodToolCallback.callMethod第 191-192 行:InvocationTargetException→ToolExecutionException(cause = NoSuchFileException)process()第 61 行:NoSuchFileException instanceof RuntimeException?NoSuchFileException extends IOException extends Exception→ false- 第 66-70 行:
throw exception - 异常冒出工具调用循环,一路抛到
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:
@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 的异常都转成消息:
@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 行的消息兜底
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 行合成一个:
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 变成工具。
// 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:
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 行):
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 行):
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:
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 逐参数
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:
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 行:
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 行):
.inputType(new ParameterizedTypeReference<List<Order>>() {})
10.5.4 异常包装
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:不可变的上下文传递
// 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 的不可修改视图。
这意味着:
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 视图不提供任何并发保护。
正确用法:
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 里设
@Bean
ChatClient chatClient(ChatModel model) {
return ChatClient.builder(model)
.defaultOptions(ToolCallingChatOptions.builder()
.toolContext(Map.of("tenantId", "default")) // 保底非空
.build())
.build();
}
方案 B:自定义 advisor 强制注入(推荐,可动态)
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 的变更
// 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 行:
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<?>,于是:
ToolCallingChatOptions opts = ...;
opts.mutate()
.toolCallbacks(cb) // 无需 cast
.temperature(0.5)
.build();
如果没有这个收窄,.toolCallbacks() 就不可见。
10.7.3 两个静态合并方法:语义完全不同
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。
两个同名前缀的方法,语义完全不同。 这是极易踩的坑:
// default 里有 10 个工具
chatClient.defaultOptions(...toolCallbacks(defaultTenTools))
// runtime 传 1 个工具
chatClient.prompt()...options(...toolCallbacks(oneTool))
// 结果:只有 1 个工具!default 的 10 个全丢了
而 toolContext 则是合并:
// default: {tenant: A, user: X}
// runtime: {tenant: B}
// 结果: {tenant: B, user: X} ← 合并了
为什么会这样设计? 工具列表的"合并"语义不明确(同名工具怎么办?顺序怎么办?),所以作者选择"运行时全量覆盖"。而 context 是 Map,合并语义清晰。
实践影响:如果你在 defaultOptions 配了全局工具(如"获取当前时间"),然后在某次调用传了特定工具,全局工具会消失。要保留必须显式合并:
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:按名解析(默认已禁用)
// 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):
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,
DefaultToolCallingManagerfell back to the application-wideToolCallbackResolver(backed by everyToolCallbackbean in the application context) and executed the resolved tool. Starting with 2.0.1, the tools attached to a request — via.tools(...),.defaultTools(...), orToolCallingChatOptions.toolCallbacks(...)— are the only tools that can be executed for that request.
这是一个安全加固。 之前的行为有风险:模型可以让它"猜"一个工具名,框架会从容器里所有 ToolCallback bean 里找出来执行。这等于:模型可以调用你注册了但没打算在这条请求里暴露的工具。
开启方式(upgrade-notes.adoc:315):
spring.ai.tools.resolution.fallback.enabled=true
或编程式(第 322-324 行):
ToolCallingManager toolCallingManager = ToolCallingManager.builder()
.toolCallbackResolver(toolCallbackResolver)
.toolResolutionFallbackEnabled(true) // 大概
.build();
建议保持默认关闭,除非你明确需要"全局工具池"语义。
10.8 2.0.1 新增:ToolCallLimits(40 / 150)
这是 2.0.1 的全新特性(@since 2.0.1),也是补丁版本里少见的功能新增。
// 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
// 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:
DefaultToolCallingManagernow 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:无状态的计数重建
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
UserMessageonward ... are counted, so a long conversation replayed in full by an externalChatMemoryadvisor does not inflate the count with tool calls from earlier turns.
这是关键设计。如果没有这个限制:
第 1 轮对话:用户问 → 模型调 30 次工具 → 回答
第 2 轮对话:ChatMemory 把完整历史(含 30 次工具调用)塞进 prompt
→ 计数从 30 开始 → 第 2 轮只能用 10 次工具
多轮对话会越来越早地触发上限——这是错误的。
currentTurnMessages(第 98-105 行):
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:超限判定
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:两种超限行为
// 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 的精妙设计
// 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)模式要求模型在调用工具时先说明推理过程:
{
"reasoning": "用户想知道北京天气,我需要调用 getWeather",
"confidence": "high",
"city": "北京"
}
但 reasoning/confidence 不是工具的真实参数——业务方法不该接收它们。
AugmentedToolCallback 的做法:
- 把额外字段加入 schema(模型会填)
- 执行前提取出来,交给一个
Consumer - (可选)从 toolInput 里移除这些字段
- 用净化后的 input 调用真实工具
10.9.2 实现
// 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。
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
// 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 行有个有价值的注释:
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 一个完整示例
// 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):
82→ private boolean removeExtraArgumentsAfterProcessing = true;
如果不移除,真实方法会收到多余字段——对 MethodToolCallback 来说多余字段被忽略(§10.2.3 按名字取),对 FunctionToolCallback(整体反序列化)可能导致 UnrecognizedPropertyException(取决于 Jackson 配置)。
10.10 工具调用的观测
// 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 的"受检异常穿透"问题彻底解决的处理器:
/**
* 让绝大多数工具异常都能以"模型可理解的消息"形式回喂,而不是穿透到调用方。
*/
@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() 逻辑 |
最小可编译骨架:
@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);
}
}
三个要点:
- override 两参
call(第 09 章 §9.1.2)。不 override 的话ToolContext会被静默丢弃。 - 表名白名单。这是自定义工具最常见的价值——内置路径无法表达"这个字符串必须是枚举值之一"。
- schema 手写。因为表结构是固定的,手写比生成更可控(也省掉了第 09 章 §9.5.4 的
synchronized开销)。
10.12 一个完整的生产级工具定义示例
综合本章所有结论,一个"正确"的工具类长这样:
@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 方法调用,再把结果送回去——由五个环节构成:
@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)的设计判断,值得在自己的代码里借鉴:区分"答案"和"附带信息",不要用同一个容器。
结论五:性能热点在三个地方
- schema 生成:
synchronized全局锁(第 09 章 §9.5.4)+toPrettyString()(第 09 章 §9.6.7) - provider 重复扫描:
getToolCallbacks()无缓存(§10.1.5) - 泛型参数绑定: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 关键文件索引
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 复核命令
所有结论都可以在仓库里自己验证一遍:
# 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