Spring AI 2.0.1 深度解析-第 11 章 工具调用(下):ToolCallingManager、ToolCallingAdvisor 与调用预算
本章回答三个问题:
- 工具循环在 2.x 里跑在哪里 ------为什么模型层只剩
resolveToolDefinitions,执行权全归 advisor。ToolCallingAdvisor的do { ... } while (isToolCall)凭什么能递归------callAdvisorChain.copy(this)到底复制了什么、不复制会怎样。- 40 / 150 这两个数字怎么算出来的------预算的统计口径是"当前 turn",而这个 turn 的边界由最后一条
UserMessage决定。第 09 章讲了工具如何被定义(
@Tool→ToolDefinition→ JSON Schema),第 10 章讲了单个工具如何被执行(参数绑定 → 结果转换 → 异常处理)。本章讲把这些执行串成一轮又一轮的那个循环:谁来驱动它、它什么时候停、停的时候付了多少钱、以及钱(token usage)是怎么被记到账单上的。
11.0 本章的三句总纲
在展开之前先把结论摆出来,后面每一节都是给这三句话找源码证据:
-
2.x 的"循环"不在模型里,在 advisor 里。
internalToolExecutionEnabled这个 1.x 的开关在 2.0.1 里一个字符都不存在 (全库 grep 零命中)。模型层(OpenAiChatModel/AnthropicChatModel/ ...)对ToolCallingManager只有一处调用:resolveToolDefinitions(requestOptions)------把ToolCallback翻译成发给 API 的 schema。真正的"调工具 → 拼回历史 → 再问一次"完全由ToolCallingAdvisor的do-while驱动。 -
循环能递归的前提是
copy(this),而copy之所以必须存在,是因为 advisor 链是栈(Deque)并且用pop()消费。 一次nextCall就把栈弹空了,下一轮必须重建。这是第 05 章讲的栈式链在工具场景下的直接后果------不是设计偏好,是被迫。 -
预算的统计口径是"当前 turn",而 turn 从最后一条
UserMessage开始。ToolCallLimits.countPriorToolCalls每轮都重新扫描整段历史来重建计数,DefaultToolCallingManager自身不持有任何 per-turn 状态 。这带来一个反直觉的性质:如果你用ChatMemory把十轮对话全量重放,前九轮的工具调用不会 被计入本轮预算------因为切片从最后一条UserMessage开始。
11.1 循环的位置:从"模型内循环"到"advisor 内循环"
11.1.1 1.x 的 internalToolExecutionEnabled 已经彻底消失
如果你是从 Spring AI 1.x 迁移过来的,第一件要忘掉的事就是这个属性。在 1.x 里,ChatModel 实现内部有一段"模型内工具执行循环":模型返回 tool call → 模型自己执行 → 自己把结果拼回消息列表 → 自己再调一次 API,直到没有 tool call 为止。开关是 ChatOptions 上的 internalToolExecutionEnabled,默认值 true。
2.0.1 里它不存在。验证命令(本章所有命令都在仓库根目录 spring-ai-2.0.1 执行):
shell
grep -rn "internalToolExecutionEnabled" --include=*.java . | grep -v "/target/"
# 输出:空
连 @Deprecated 的残留都没有,说明这不是"标记废弃等下个版本删",而是整条代码路径被搬走了。
搬到了哪里?ToolCallingAdvisor。
11.1.2 模型层只剩 resolveToolDefinitions
ToolCallingManager 在 2.0.1 里被两条完全不同的代码路径使用,这一点是理解全章的钥匙:
| 调用方 | 调用的方法 | 用途 |
|---|---|---|
模型层 (OpenAiChatModel 等 6 个) |
resolveToolDefinitions(chatOptions) |
把 ToolCallback 列表翻译成要发给供应商 API 的 ToolDefinition 列表 |
Advisor 层 (ToolCallingAdvisor) |
executeToolCalls(prompt, chatResponse) |
执行模型返回的 tool call,拼装新一轮历史 |
模型层的调用点全部是一行,且只有这一个方法:
shell
grep -rn "toolCallingManager\." --include=*.java models/ | grep -v "/target/" | grep -v "/src/test/"
输出(节选,行号为 2.0.1 实际值):
css
models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiChatModel.java:879
List<ToolDefinition> toolDefinitions = this.toolCallingManager.resolveToolDefinitions(requestOptions);
models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicChatModel.java:860
List<ToolDefinition> toolDefinitions = this.toolCallingManager.resolveToolDefinitions(requestOptions);
models/spring-ai-ollama/.../OllamaChatModel.java (同)
models/spring-ai-mistral-ai/.../MistralAiChatModel.java (同)
models/spring-ai-deepseek/.../DeepSeekChatModel.java (同)
models/spring-ai-bedrock-converse/.../BedrockProxyChatModel.java(同)
六个模型,六行一模一样的调用,且没有任何一行执行工具。 这就是 2.x 的架构声明:模型只负责"把工具描述翻译成 wire format 并发出去",以及"把返回的 tool call 解析成 AssistantMessage.ToolCall"。执行权 100% 在 advisor。
顺带一个容易被忽略的细节:OpenAiChatModel 的 Builder 里,toolCallingManager 是 @Nullable 的,且默认值是现场 new 一个:
java
// models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiChatModel.java:1628-1632
ToolCallingManager resolvedToolCallingManager = Objects.requireNonNullElse(this.toolCallingManager,
ToolCallingManager.builder().observationRegistry(resolvedObservationRegistry).build());
而 AnthropicChatModel 用的是一个 static 常量:
java
// models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicChatModel.java:169
private static final ToolCallingManager DEFAULT_TOOL_CALLING_MANAGER = ToolCallingManager.builder().build();
两者都不是 Spring 容器里的那个 ToolCallingManager Bean。也就是说:如果你自己手搓 new OpenAiChatModel(...) 而没传 toolCallingManager,模型层用的是一个没有任何工具解析器、没有任何异常处理器的裸 manager。 在 2.x 这不影响工具执行(执行在 advisor,advisor 的 manager 来自容器),但会影响 schema 生成(见 11.2.4)。
11.1.3 为什么必须搬到 advisor:四个不可兼得
把循环留在模型里,会同时丢掉四样东西:
- 可拦截。 Advisor 链上的任何 advisor------日志、安全护栏、审计、RAG------都看不见模型内部那几轮往返。搬出来之后,
ToolCallingAdvisor的每一次nextCall都会穿过它下游的全部 advisor,于是"每轮都做一次检索"(第 07 章讲的 RAG 在循环内)才成为可能。 - 可观测。 模型内的循环是黑盒,只能看到一个总的 usage。搬出来之后,每一轮都有自己的
ChatModelobservation span,每一次工具执行都有自己的TOOL_CALLspan(11.11 节)。 - 可替换。 循环策略(什么时候停、最多几轮、超预算怎么办)从"每个模型各写一遍"变成"一个 advisor 里的一处
while条件"。ToolExecutionEligibilityChecker就是为此设计的扩展点(11.8 节)。 - 流式可行。 模型内的阻塞循环在 reactive 链路里根本无从下手。advisor 内的循环可以用
concatWith(Flux.defer(...))表达递归(11.6 节)。
代价是什么?模型层无法再对"工具执行后要不要再问一次"做任何判断 ,于是所有供应商特有的终止语义(例如 Anthropic 的 stop_reason: tool_use、某些模型的"最后一轮不要再调工具"提示)都必须通过 advisor 侧扩展点补齐。这正是 ToolExecutionEligibilityChecker 存在的理由。
11.1.4 ToolAdvisor:一个只有 Javadoc 没有方法的标记接口
java
// spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/api/ToolAdvisor.java:33-35
public interface ToolAdvisor extends Advisor {
}
它是空的。 整个接口没有声明任何方法,全部语义都在 Javadoc 里:
Marker interface for advisors that own the tool-call lifecycle. An advisor implementing this interface takes responsibility for executing tools and driving the tool-call loop, replacing the model-internal tool execution path.
{@link org.springframework.ai.chat.client.DefaultChatClient}uses this marker to detect whether a tool-call handling advisor is already present in the chain, and to avoid auto-registering a duplicate.
它解决的是一个具体的装配问题:DefaultChatClient 会在每次 call() / stream() 时自动注册一个 ToolCallingAdvisor(除非你显式关掉),但它怎么知道你已经自己加了一个? 靠 instanceof ToolAdvisor。
java
// spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/DefaultChatClient.java:1217-1238
private void autoRegisterToolCallingAdvisor() {
boolean autoRegisterDisabled = Boolean.FALSE
.equals(this.advisorParams.get(ChatClientAttributes.TOOL_CALLING_ADVISOR_AUTO_REGISTER.getKey()));
if (autoRegisterDisabled) {
return;
}
boolean hasToolCallingAdvisor = this.advisors.stream().anyMatch(a -> a instanceof ToolAdvisor);
if (hasToolCallingAdvisor) {
return;
}
int configuredOrder = this.toolCallingAdvisorBuilder.getAdvisorOrder();
boolean hasDownstreamMemoryAdvisor = this.advisors.stream()
.anyMatch(a -> a instanceof MemoryAdvisor && a.getOrder() > configuredOrder);
this.advisors.add(this.toolCallingAdvisorBuilder.copy()
.conversationHistoryEnabled(!hasDownstreamMemoryAdvisor)
.build());
}
这段代码里的信息密度极高,逐条拆:
(a) 自动注册是默认行为,且无条件。 注意它不检查"这次请求有没有配工具"。1.x 时代常见的"没配工具就别加 tool advisor"的优化在 2.x 不存在。Javadoc 解释了原因:
The advisor is always registered so that tools injected dynamically at runtime (e.g. by another advisor) are handled correctly even when no static tools are configured on the call.
这是一个面向动态工具 的让步:ToolSearchToolCallingAdvisor(11.10 节)这类 advisor 会在循环过程中往 options 里注入 ToolCallback,如果因为"调用时没配静态工具"就不注册 tool advisor,这些动态工具永远执行不了。代价是:哪怕你一个工具都没配,每次 ChatClient 调用链上也多一个 advisor(多一个 observation span,多一层栈)。
(b) 关闭开关是一个 Consumer<ChatClient.AdvisorSpec>,不是一个 boolean 属性。
java
// spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/AdvisorParams.java:75-77
public static Consumer<ChatClient.AdvisorSpec> toolCallingAdvisorAutoRegister(boolean enabled) {
return a -> a.param(ChatClientAttributes.TOOL_CALLING_ADVISOR_AUTO_REGISTER.getKey(), enabled);
}
用法:
java
client.prompt()
.tools(myTool)
.advisors(AdvisorParams.toolCallingAdvisorAutoRegister(false))
.call();
注意判断用的是 Boolean.FALSE.equals(...)------也就是说,只有显式传 false 才关闭;传 null 或不传都算开启 。这是一个"三态布尔"陷阱:如果你用 Map<String,Object> 拼 advisor params 时不小心放了个 null,结果是开启。
(c) hasDownstreamMemoryAdvisor 决定要不要维护内部历史。 这是 2.x 里一个非常关键的自动化决策,详见 11.12 节。这里先记住判据:a instanceof MemoryAdvisor && a.getOrder() > configuredOrder。configuredOrder 默认 HIGHEST_PRECEDENCE + 300,MessageChatMemoryAdvisor 默认 order 是 HIGHEST_PRECEDENCE + 200......
等等,200 < 300,a.getOrder() > configuredOrder 应该是 false?那默认的 memory advisor 反而不算 downstream?
这就是第 05/06 章讲过的"order 越小越外层"在这里的第一次咬人。 需要先明确两件事:
ToolCallingAdvisor.DEFAULT_ORDER = Ordered.HIGHEST_PRECEDENCE + 300(ToolCallingAdvisor.java:75)。MessageChatMemoryAdvisor的默认 order 需要查证。第 06 章列过内置 advisor 的 order 表,这里直接给结论:MessageChatMemoryAdvisor的默认 order 是DEFAULT_CHAT_MEMORY_PRECEDENCE_ORDER,即HIGHEST_PRECEDENCE + 200(见ToolCallingAdvisor.java:70-74的 Javadoc 明确引用了这个常量名)。
那么 200 > 300 为 false,hasDownstreamMemoryAdvisor 默认 false,conversationHistoryEnabled 默认 true。
而集成测试里是怎么写的?看 AbstractToolCallingAdvisorIT:
java
// spring-ai-test/.../AbstractToolCallingAdvisorIT.java:102-112
var response = ChatClient.create(getChatModel())
.prompt()
.advisors(ToolCallingAdvisor.builder().disableInternalConversationHistory().build(),
MessageChatMemoryAdvisor.builder(MessageWindowChatMemory.builder().maxMessages(500).build())
.order(1000)
.build())
...
注意 .order(1000)。测试显式把 memory advisor 的 order 调到 1000 ,远大于 300,才能让它成为"downstream"。这印证了上面的推理:Spring AI 自己的测试也承认,默认 order 下 memory advisor 不在工具循环的下游。
换句话说,autoRegisterToolCallingAdvisor 里那个 hasDownstreamMemoryAdvisor 分支是为"手动把 memory advisor 调到 1000"这种用法准备的;在默认配置下它永远走 false 分支,于是自动注册的 ToolCallingAdvisor 保持 conversationHistoryEnabled=true,自己维护完整历史。
这个话题在 11.12 节展开,那里会给出完整的 order 布局图和"为什么官方推荐 memory 在工具循环外面"。
(d) validateSingleToolAdvisor:链上最多一个。
java
// spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/DefaultChatClient.java:1240-1248
private void validateSingleToolAdvisor() {
List<Advisor> toolAdvisors = this.advisors.stream().filter(a -> a instanceof ToolAdvisor).toList();
if (toolAdvisors.size() > 1) {
String names = String.join(", ",
toolAdvisors.stream().map(a -> a.getName() + " (order=" + a.getOrder() + ")").toList());
throw new IllegalStateException("At most one ToolAdvisor is allowed in the advisor chain, but found "
+ toolAdvisors.size() + ": [" + names + "]");
}
}
buildAdvisorChain() 每次调用都先 autoRegisterToolCallingAdvisor() 再 validateSingleToolAdvisor()(DefaultChatClient.java:1189-1191)。所以:
- 你自己加了一个
ToolSearchToolCallingAdvisor→ 自动注册跳过 → 校验通过(1 个)。 - 你自己加了两个 → 抛
IllegalStateException。 - 你加了一个,同时把自动注册开关设为
true(默认),仍然只有一个,因为hasToolCallingAdvisor会短路。
陷阱 :validateSingleToolAdvisor 只在 buildAdvisorChain() 里被调用,而 buildAdvisorChain() 是在 call() / stream() 时才执行的(DefaultChatClient.java:1176-1187)。也就是说配置错误要等到第一次请求才炸 ,而且异常信息里带的 (order=...) 是 advisor 的 getOrder(),不是它在链里的实际位置------调试时别被误导。
11.1.5 更名对照表:ToolCallAdvisor → ToolCallingAdvisor
2.0.0 把 ToolCallAdvisor 改名为 ToolCallingAdvisor,旧类保留为废弃子类:
java
// spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/ToolCallAdvisor.java:22-34
/**
* @author Christian Tzolov
* @deprecated since 2.0.0 in favor of {@link ToolCallingAdvisor}. This is kept for
* backward compatibility and will be removed in a future release.
*/
@Deprecated(since = "2.0.0", forRemoval = true)
public class ToolCallAdvisor extends ToolCallingAdvisor {
注意它是 extends 而不是"别名类"。这有一个实际后果:new ToolCallAdvisor.Builder<>().build() 出来的实例,instanceof ToolAdvisor 为 true,所以能正常参与自动注册去重 。但 ToolCallAdvisor.Builder 的 build() 返回 ToolCallAdvisor,而父类 ToolCallingAdvisor.Builder.build() 返回 ToolCallingAdvisor------如果你把 builder 声明成 ToolCallingAdvisor.Builder<?> 却拿到 ToolCallAdvisor 实例,编译期类型是 ToolCallingAdvisor,运行时是子类,行为一致。
另一个坑是泛型自引用:
java
// ToolCallAdvisor.java:48
public static class Builder<T extends Builder<T>> extends ToolCallingAdvisor.Builder<T> {
T extends Builder<T> 里的 Builder<T> 指的是 ToolCallAdvisor.Builder<T> (因为在同一作用域内),而不是 ToolCallingAdvisor.Builder<T>。这意味着 ToolCallAdvisor.Builder<?> 的链式调用返回值类型是 ToolCallAdvisor.Builder 的子类型,self() 返回 (T) this 在子类未正确指定 T 时会 ClassCastException。实践建议:直接用 ToolCallingAdvisor.builder(),不要碰废弃类。
AdvisorParams 里的对应方法也一起废弃了:
java
// AdvisorParams.java:79-86
/**
* @deprecated since 2.0.0 in favor of {@link #toolCallingAdvisorAutoRegister(boolean)}.
*/
@Deprecated(since = "2.0.0", forRemoval = true)
public static Consumer<ChatClient.AdvisorSpec> toolCallAdvisorAutoRegister(boolean enabled) {
return toolCallingAdvisorAutoRegister(enabled);
}
11.1.6 小结:循环位置变更带来的连锁反应
| 维度 | 1.x(模型内循环) | 2.0.1(advisor 内循环) |
|---|---|---|
| 执行者 | 各 ChatModel 实现自己 |
ToolCallingAdvisor 的 do-while |
| 开关 | internalToolExecutionEnabled |
无开关;靠 AdvisorParams.toolCallingAdvisorAutoRegister(false) 不注册 advisor |
| 可拦截 | 否 | 是(下游 advisor 每轮都过) |
| usage | 模型内部聚合,外部只见总数 | UsageAccumulator 显式累加(11.7 节) |
| 流式 | 阻塞循环,流式下不可见 | concatWith(Flux.defer(...)) 递归(11.6 节) |
| 终止条件 | 硬编码"没有 tool call 就停" | ToolExecutionEligibilityChecker 可替换(11.8 节) |
| 预算 | 无 | ToolCallLimits 40/150(11.4 节) |
| 模型层残留 | 完整执行 | 仅 resolveToolDefinitions |
11.2 ToolCallingManager:两个方法,两个世界
11.2.1 接口本体
java
// spring-ai-model/src/main/java/org/springframework/ai/model/tool/ToolCallingManager.java:31-50
public interface ToolCallingManager {
/**
* Resolve the tool definitions from the model's tool calling options.
*/
List<ToolDefinition> resolveToolDefinitions(ToolCallingChatOptions chatOptions);
/**
* Execute the tool calls requested by the model.
*/
ToolExecutionResult executeToolCalls(Prompt prompt, ChatResponse chatResponse);
/**
* Create a default {@link ToolCallingManager} builder.
*/
static DefaultToolCallingManager.Builder builder() {
return DefaultToolCallingManager.builder();
}
}
两个方法,没有第三个。这个极简的接口掩盖了一个事实:这两个方法被调用的频率差了几个数量级,且调用方完全不相交。
resolveToolDefinitions:每一轮 模型调用之前,由模型层调用一次。一个五轮的工具循环会调它五次。executeToolCalls:每一轮 模型返回之后,由 advisor 调用一次(如果那一轮有 tool call)。同样可能五次。
所以一次"用户问天气 → 三次工具调用 → 回答"的完整交互里,ToolCallingManager 上的调用序列大致是:
ini
[轮1] resolveToolDefinitions → OpenAiChatModel:879
(模型返回 2 个 tool call)
executeToolCalls → ToolCallingAdvisor:181
[轮2] resolveToolDefinitions
(模型返回 1 个 tool call)
executeToolCalls
[轮3] resolveToolDefinitions
(模型返回普通文本,无 tool call)
------ 循环结束,不再调用 executeToolCalls
resolveToolDefinitions 三次,executeToolCalls 两次。
11.2.2 final class 的后果:Google 只能委托
java
// spring-ai-model/src/main/java/org/springframework/ai/model/tool/DefaultToolCallingManager.java:66
public final class DefaultToolCallingManager implements ToolCallingManager {
final。这意味着供应商无法继承它来改写 schema 生成 。于是 Google 的做法是实现接口 + 构造时委托:
java
// models/spring-ai-google-genai/src/main/java/org/springframework/ai/google/genai/schema/GoogleGenAiToolCallingManager.java:46-97
public class GoogleGenAiToolCallingManager implements ToolCallingManager {
private final ToolCallingManager delegateToolCallingManager;
public GoogleGenAiToolCallingManager(ToolCallingManager delegateToolCallingManager) {
Assert.notNull(delegateToolCallingManager, "Delegate tool calling manager must not be null");
this.delegateToolCallingManager = delegateToolCallingManager;
}
@Override
public List<ToolDefinition> resolveToolDefinitions(ToolCallingChatOptions chatOptions) {
List<ToolDefinition> toolDefinitions = this.delegateToolCallingManager.resolveToolDefinitions(chatOptions);
return toolDefinitions.stream().map(td -> {
ObjectNode jsonSchema = JsonSchemaConverter.fromJson(td.inputSchema());
ObjectNode openApiSchema = JsonSchemaConverter.convertToOpenApiSchema(jsonSchema);
JsonSchemaGenerator.convertTypeValuesToUpperCase(openApiSchema);
return DefaultToolDefinition.builder()
.name(td.name())
.description(td.description())
.inputSchema(openApiSchema.toPrettyString())
.build();
}).toList();
}
@Override
public ToolExecutionResult executeToolCalls(Prompt prompt, ChatResponse chatResponse) {
return this.delegateToolCallingManager.executeToolCalls(prompt, chatResponse);
}
}
三个观察点:
- 只改
resolveToolDefinitions,executeToolCalls纯透传。 说明"schema 方言"是供应商差异点,而"执行语义"是通用资产。这是接口拆分得干净的直接证据。 toPrettyString()又出现了。 第 09 章讲过DefaultToolDefinition的inputSchema生成时就调了toPrettyString(),导致 schema 体积膨胀 30--60%;这里再美化一次 。虽然这一次是转换后必须序列化(ObjectNode → String),但仍然是"每个工具、每一轮、都做一次 JSON 美化 + 字符串化"。在resolveToolDefinitions每轮都被调用的背景下(11.2.1),这是 O(轮数 × 工具数) 的重复劳动。convertTypeValuesToUpperCase------ Vertex AI 的 OpenAPI 方言要求 type 值大写("STRING"而非"string")。这是"方言转换"的最小必要改动,也说明ToolDefinition.inputSchema的契约是纯 JSON Schema 字符串,各供应商自己转。
11.2.3 五元构造参数与三个构造函数的默认值陷阱
DefaultToolCallingManager 有三个构造函数,其中两个是包级私有:
java
// DefaultToolCallingManager.java:127-165
public DefaultToolCallingManager(ObservationRegistry observationRegistry, ToolCallbackResolver toolCallbackResolver,
ToolExecutionExceptionProcessor toolExecutionExceptionProcessor) {
this(observationRegistry, toolCallbackResolver, toolExecutionExceptionProcessor,
new ToolCallLimits(DEFAULT_MAX_CALLS_PER_TOOL, Map.of(), Set.of(), DEFAULT_MAX_TOTAL_TOOL_CALLS,
ToolCallLimitBehavior.THROW),
DEFAULT_RESOLUTION_FALLBACK_ENABLED);
}
DefaultToolCallingManager(ObservationRegistry observationRegistry, ToolCallbackResolver toolCallbackResolver,
ToolExecutionExceptionProcessor toolExecutionExceptionProcessor, ToolCallLimits toolCallLimits) {
this(observationRegistry, toolCallbackResolver, toolExecutionExceptionProcessor, toolCallLimits,
DEFAULT_RESOLUTION_FALLBACK_ENABLED);
}
/**
* ...
* @since 2.0.1
*/
DefaultToolCallingManager(ObservationRegistry observationRegistry, ToolCallbackResolver toolCallbackResolver,
ToolExecutionExceptionProcessor toolExecutionExceptionProcessor, ToolCallLimits toolCallLimits,
boolean resolutionFallbackEnabled) {
// ... Assert.notNull × 4
}
关键点:
- 只有三参构造函数是
public,也是唯一不能 设置resolutionFallbackEnabled的入口。也就是说:如果你用new DefaultToolCallingManager(a, b, c),resolutionFallbackEnabled强制为默认false。 想开启只能用builder()。 - 四参和五参构造函数都是包级私有 (无修饰符),外部包无法调用。所以从应用代码看,
DefaultToolCallingManager只有两个可用入口:三参构造和builder()。 - 五参版本标注
@since 2.0.1,说明resolutionFallbackEnabled是 2.0.1 新增的。
那 4 个静态默认值是什么:
java
// DefaultToolCallingManager.java:72-84
private static final ObservationRegistry DEFAULT_OBSERVATION_REGISTRY
= ObservationRegistry.NOOP;
private static final ToolCallingObservationConvention DEFAULT_OBSERVATION_CONVENTION
= new DefaultToolCallingObservationConvention();
private static final ToolCallbackResolver DEFAULT_TOOL_CALLBACK_RESOLVER
= new DelegatingToolCallbackResolver(List.of());
private static final ToolExecutionExceptionProcessor DEFAULT_TOOL_EXECUTION_EXCEPTION_PROCESSOR
= DefaultToolExecutionExceptionProcessor.builder().build();
private static final boolean DEFAULT_RESOLUTION_FALLBACK_ENABLED = false;
DEFAULT_TOOL_CALLBACK_RESOLVER = new DelegatingToolCallbackResolver(List.of()) ------ 一个委托给空列表 的 resolver。它的 resolve(toolName) 永远返回 null。
这不是 bug,而是刻意的"安全默认":如果你用 ToolCallingManager.builder().build() 而不传 resolver,那么任何"按名字解析"的尝试都会失败。结合 resolutionFallbackEnabled 默认 false,两条防线一起确保:默认情况下,只有显式挂在本次请求 options 上的 ToolCallback 能被执行。
DefaultToolExecutionExceptionProcessor.builder().build() 的默认行为在第 10 章讲过:alwaysThrow=false,且对非 RuntimeException 无条件重抛。这里不重复。
11.2.4 resolveToolDefinitions:八行实现与三个隐含契约
java
// DefaultToolCallingManager.java:167-175
@Override
public List<ToolDefinition> resolveToolDefinitions(ToolCallingChatOptions chatOptions) {
Assert.notNull(chatOptions, "chatOptions cannot be null");
List<ToolCallback> toolCallbacks = new ArrayList<>(
!CollectionUtils.isEmpty(chatOptions.getToolCallbacks()) ? chatOptions.getToolCallbacks() : List.of());
return toolCallbacks.stream().map(ToolCallback::getToolDefinition).toList();
}
八行。但它承载了三个隐含契约:
契约一:它只认 getToolCallbacks(),不认"工具名"。 1.x 时代 ToolCallingChatOptions 有 getToolNames()(配一串名字,运行时用 ToolCallbackResolver 解析)。2.0.1 里这个方法不存在:
shell
grep -rn "getToolNames\|toolNames(" --include=*.java spring-ai-model/src/main/java/
# 输出:空
所以"只给工具名不给回调"的用法在 2.x 已经彻底消失。剩下的唯一"按名字解析"入口是 resolutionFallbackEnabled(11.3.5),而它默认关闭,且触发条件是"模型返回了请求里没有的工具名"------不是"请求里配了名字"。
契约二:new ArrayList<>(...) 是为了可写,但这里根本没写。 第二行把 List.of() 或 options 的不可变列表包进 ArrayList,然后只做了 stream().map().toList()。这个 ArrayList 是纯粹的浪费------除非是为了防御 getToolCallbacks() 返回的列表在遍历中被并发修改(它确实是 List.copyOf 过的不可变列表,见 ToolCallingChatOptions.mergeToolCallbacks)。属于防御性冗余,不是 bug。
契约三:ToolCallback::getToolDefinition 每次都重新生成 schema。 第 09 章讲过 MethodToolCallback.getToolDefinition() 内部有 synchronized 全局锁 + toPrettyString()。在"每轮都调 resolveToolDefinitions"的背景下(11.2.1),一个 5 轮循环 × 10 个工具 = 50 次 schema 生成(如果 MethodToolCallback 没有缓存的话)。第 09 章的结论是 MethodToolCallback 确实缓存了 ToolDefinition 实例,所以这里是 5 × 10 次 map 调用 + 首次的 10 次生成------可以接受。但如果你自己实现 ToolCallback 且 getToolDefinition() 每次现算(例如每次都重新反射一次方法签名),这个成本会被循环轮数放大。
11.2.5 谁给 advisor 注入 manager:容器还是默认值
ToolCallingAdvisor.Builder 的默认值:
java
// ToolCallingAdvisor.java:494
private ToolCallingManager toolCallingManager = ToolCallingManager.builder().build();
注意:ToolCallingManager.builder().build() 不带 observationRegistry ,于是内部用 ObservationRegistry.NOOP。也就是说,如果你直接 ToolCallingAdvisor.builder().build()(集成测试就是这么干的),所有工具执行的 observation 都被丢弃。
而 Spring Boot 场景下,容器里的 ToolCallingManager Bean 由 ToolCallingAutoConfiguration 提供,带真实的 ObservationRegistry:
java
// auto-configurations/models/tool/.../ToolCallingAutoConfiguration.java:135-187
@Bean
@ConditionalOnMissingBean
ToolCallingManager toolCallingManager(ToolCallbackResolver toolCallbackResolver,
ToolExecutionExceptionProcessor toolExecutionExceptionProcessor, ToolCallingProperties properties,
ObjectProvider<ObservationRegistry> observationRegistry,
ObjectProvider<ToolCallingObservationConvention> observationConvention) {
var builder = ToolCallingManager.builder()
.observationRegistry(observationRegistry.getIfUnique(() -> ObservationRegistry.NOOP))
.toolCallbackResolver(toolCallbackResolver)
.toolExecutionExceptionProcessor(toolExecutionExceptionProcessor)
.resolutionFallbackEnabled(properties.getResolution().getFallback().isEnabled());
// ... limits 映射
builder.onLimitExceeded(limits.getOnLimitExceeded());
var toolCallingManager = builder.build();
observationConvention.ifAvailable(toolCallingManager::setObservationConvention);
return toolCallingManager;
}
observationRegistry.getIfUnique(() -> ObservationRegistry.NOOP) ------ 如果容器里有多个 ObservationRegistry(不唯一),静默降级为 NOOP。 这是一个静默失败点:你的工具 span 消失了,但没有任何日志或警告。
这个 Bean 怎么进到 advisor 里?看 ChatClientAutoConfiguration:
shell
grep -n "ToolCallingManager\|ToolExecutionEligibilityChecker\|toolCallingAdvisor" \
auto-configurations/models/chat/client/spring-ai-autoconfigure-model-chat-client/src/main/java/org/springframework/ai/model/chat/client/autoconfigure/ChatClientAutoConfiguration.java
关键行(:102、:107):
java
public ChatClientBuilderConfigurer chatClientBuilderConfigurer(..., ObjectProvider<ToolExecutionEligibilityChecker> toolExecutionEligibilityChecker) {
// ...
toolExecutionEligibilityChecker.ifAvailable(builder::toolExecutionEligibilityChecker);
}
结合 DefaultChatClientBuilder 的构造:
java
// spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/DefaultChatClientBuilder.java:105-106
toolCallingAdvisorBuilder = Objects.requireNonNullElse(toolCallingAdvisorBuilder, ToolCallingAdvisor.builder()
.toolCallingManager(ToolCallingManager.builder().observationRegistry(observationRegistry).build()));
注意这里又是 ToolCallingManager.builder().observationRegistry(...),没有 toolCallbackResolver! 所以 ChatClient.Builder 自己创建的那个默认 toolCallingAdvisorBuilder 里的 manager,其 resolver 是空列表的 DelegatingToolCallbackResolver。
那 Spring Boot 场景下的 manager 从哪来?ChatClientAutoConfiguration 会用容器里的 ToolCallingManager Bean 覆盖这个 builder(通过 ChatClientBuilderConfigurer)。所以:
| 场景 | advisor 里的 manager | resolver | observationRegistry |
|---|---|---|---|
ChatClient.create(model) 手搓 |
ToolCallingManager.builder().observationRegistry(registry) |
空(永远解析失败) | 传入的 |
ChatClient.builder(model) 手搓 |
同上 | 空 | 传入的 |
Spring Boot + ChatClient.Builder Bean |
容器 ToolCallingManager Bean |
StaticToolCallbackResolver(含所有 ToolCallback Bean) |
容器唯一的那一个 |
ToolCallingAdvisor.builder().build() 手搓 |
ToolCallingManager.builder().build() |
空 | NOOP |
第二行和第四行的区别是实践中最大的坑 :同样的代码,在 Boot 里跑能解析工具,在单测里跑就报 No ToolCallback found for tool name: xxx。而 resolutionFallbackEnabled 默认 false 又让 resolver 根本没机会被调用------所以实际报错来自 11.3.5 的 toolCallback == null 分支。
11.2.6 setObservationConvention 是 setter 而不是 builder 项
java
// DefaultToolCallingManager.java:353-355
public void setObservationConvention(ToolCallingObservationConvention observationConvention) {
this.observationConvention = observationConvention;
}
注意 private ToolCallingObservationConvention observationConvention = DEFAULT_OBSERVATION_CONVENTION;(:125)不是 final,且 builder 里没有对应项。所以自定义 convention 只能:
java
var manager = ToolCallingManager.builder().build();
manager.setObservationConvention(new MyConvention());
两步。而自动配置里用的是 observationConvention.ifAvailable(toolCallingManager::setObservationConvention)(ToolCallingAutoConfiguration.java:184)------ifAvailable 在有多个候选时静默跳过,又一处静默失败。
11.3 executeToolCalls:一次工具批处理的完整解剖
这是本章的核心方法。先把完整源码摆出来(去掉 license 头),再逐段剖析。
java
// spring-ai-model/src/main/java/org/springframework/ai/model/tool/DefaultToolCallingManager.java:177-205
@Override
public ToolExecutionResult executeToolCalls(Prompt prompt, ChatResponse chatResponse) {
Assert.notNull(prompt, "prompt cannot be null");
Assert.notNull(chatResponse, "chatResponse cannot be null");
Optional<Generation> toolCallGeneration = chatResponse.getResults()
.stream()
.filter(g -> !CollectionUtils.isEmpty(g.getOutput().getToolCalls()))
.findFirst();
if (toolCallGeneration.isEmpty()) {
throw new IllegalStateException("No tool call requested by the chat model");
}
AssistantMessage assistantMessage = toolCallGeneration.get().getOutput();
ToolContext toolContext = buildToolContext(prompt, assistantMessage);
InternalToolExecutionResult internalToolExecutionResult = executeToolCall(prompt, assistantMessage,
toolContext);
List<Message> conversationHistory = buildConversationHistoryAfterToolExecution(prompt.getInstructions(),
assistantMessage, internalToolExecutionResult.toolResponseMessage());
return ToolExecutionResult.builder()
.conversationHistory(conversationHistory)
.returnDirect(internalToolExecutionResult.returnDirect())
.build();
}
11.3.1 第 182--189 行:findFirst() 与 n>1 的静默丢弃
java
Optional<Generation> toolCallGeneration = chatResponse.getResults()
.stream()
.filter(g -> !CollectionUtils.isEmpty(g.getOutput().getToolCalls()))
.findFirst();
if (toolCallGeneration.isEmpty()) {
throw new IllegalStateException("No tool call requested by the chat model");
}
AssistantMessage assistantMessage = toolCallGeneration.get().getOutput();
这里有一个必须点破的行为:如果模型返回了多个候选(OpenAI 的 n > 1,或者 Bedrock 的多个 Generation),其中超过一个候选携带 tool call,那么只有第一个会被执行,其余候选的 tool call 被静默丢弃。
为什么?findFirst() 取第一个非空 tool call 的 generation,然后用它的 getOutput()(一个 AssistantMessage)里的 getToolCalls() 执行。没有任何一行代码去合并其他 generation 的 tool call。
后果推演:假设 n=3,三个候选分别是:
- 候选 A:
toolCalls = [getWeather(Tokyo)] - 候选 B:
toolCalls = [getWeather(Paris)] - 候选 C:
text = "我不确定"
那么只有 getWeather(Tokyo) 被执行,getWeather(Paris) 消失,候选 C 的文本也消失。最终返回给模型的历史里只有一条 assistant 消息(候选 A)+ 一条 tool response。
而 ChatResponse 里其余两个 generation 呢? 它们仍然存在于 chatResponse 对象里------但 ToolCallingAdvisor 在拿到 ToolExecutionResult 后,用的是 toolExecutionResult.conversationHistory()(11.5 节),即 previousMessages + assistantMessage + toolResponseMessage,这个 assistantMessage 就是候选 A 一个。所以多候选的信息在工具循环里被单候选化。
这个行为在 2.x 是"事实标准"而非 bug:因为工具循环的前提是"这一轮模型想要调工具",而多候选本身是"给你几个备选答案"的语义,两者不兼容。Spring AI 选择了"取第一个"。
陷阱 :IllegalStateException("No tool call requested by the chat model")。什么时候会触发?ToolCallingAdvisor 只在 toolExecutionEligibilityChecker.isToolCallResponse(chatResponse) 返回 true 时才调 executeToolCalls(ToolCallingAdvisor.java:174-182)。默认 checker 是 chatResponse != null && chatResponse.hasToolCalls()。如果你自定义了 checker(11.8 节),让它返回 true 的条件比 hasToolCalls() 更宽 (例如"stop_reason == tool_use 就算"),那么就可能出现"checker 说要执行,但 manager 找不到任何 tool call"的矛盾,直接抛 IllegalStateException。
这是一个自定义 checker 的头号崩溃来源。配方见 11.8.5。
11.3.2 第 193 行 + buildToolContext:只从 options 取,且是新 HashMap
java
// DefaultToolCallingManager.java:207-216
private static ToolContext buildToolContext(Prompt prompt, AssistantMessage assistantMessage) {
Map<String, Object> toolContextMap = Map.of();
if (prompt.getOptions() instanceof ToolCallingChatOptions toolCallingChatOptions
&& !CollectionUtils.isEmpty(toolCallingChatOptions.getToolContext())) {
toolContextMap = new HashMap<>(toolCallingChatOptions.getToolContext());
}
return new ToolContext(toolContextMap);
}
三个细节:
- 参数是
assistantMessage,但方法体里完全没用它。 签名保留了这个参数(可能是历史遗留,或为子类预留),实际只用prompt。这是"未使用参数"的代码异味。 - 默认
Map.of(),即空 Map,而不是null。 第 10 章讲过:MethodToolCallback.call在toolContext为空 Map 时的行为(声明了ToolContext参数但 map 为空 → 抛IllegalArgumentException)。这里保证了传给工具的一定是非 null 的ToolContext。 new HashMap<>(...)是防御性拷贝。ToolCallingChatOptions.getToolContext()返回的是Map.copyOf的不可变 map(见mergeToolContext),这里拷贝成HashMap是为了让ToolContext内部可变?看ToolContext的定义------它在spring-ai-model/src/main/java/org/springframework/ai/chat/model/ToolContext.java,是Map<String,Object>的包装。拷贝的意义在于:工具方法拿到的是一个可修改的副本,修改不会污染 options。 但注意------这个副本是每次executeToolCalls都新建的 ,所以工具在 A 轮往 context 里放的东西,B 轮看不到。跨轮传递必须靠ChatMemory或 advisor context。
一个重要的不对称 :buildToolContext 只看 prompt.getOptions() 是不是 ToolCallingChatOptions。如果 prompt.getOptions() 是一个普通 ChatOptions(例如你用 ChatOptions.builder() 而不是 ToolCallingChatOptions.builder()),tool context 恒为空 。而 ToolCallingAdvisor.adviseCall 的第一件事就是检查 options instanceof ToolCallingChatOptions,不是就直接跳过整个工具逻辑(ToolCallingAdvisor.java:127-131)。所以能走到 executeToolCalls 的,options 一定是 ToolCallingChatOptions------这个 instanceof 判断在 advisor 层已经过了一遍,这里是第二遍。
11.3.3 executeToolCall 主循环:十二步
java
// DefaultToolCallingManager.java:221-343(结构节选,完整见文件)
private InternalToolExecutionResult executeToolCall(Prompt prompt, AssistantMessage assistantMessage,
ToolContext toolContext) {
List<ToolCallback> toolCallbacks = List.of();
if (prompt.getOptions() instanceof ToolCallingChatOptions toolCallingChatOptions) {
if (!CollectionUtils.isEmpty(toolCallingChatOptions.getToolCallbacks())) {
toolCallbacks = toolCallingChatOptions.getToolCallbacks();
}
}
List<ToolResponseMessage.ToolResponse> toolResponses = new ArrayList<>();
Boolean returnDirect = null;
Map<String, Integer> toolCallCounts = ToolCallLimits.countPriorToolCalls(prompt.getInstructions());
int totalToolCallCount = toolCallCounts.values().stream().mapToInt(Integer::intValue).sum();
for (AssistantMessage.ToolCall toolCall : assistantMessage.getToolCalls()) {
// ... 12 步
}
return new InternalToolExecutionResult(ToolResponseMessage.builder().responses(toolResponses).build(),
Objects.requireNonNullElse(returnDirect, false));
}
第一步(:223-228) :从 options 取 ToolCallback 列表。注意这里没有 像 resolveToolDefinitions 那样包 ArrayList,因为后续只读。
第二步(:230) :toolResponses 是 ArrayList,可变,逐个 add。
第三步(:232) :Boolean returnDirect = null; ------ 注意是包装类型 Boolean 且初值 null,不是 boolean false。 这是三态设计,见 11.3.6。
第四步(:234-235):预算基线。
java
Map<String, Integer> toolCallCounts = ToolCallLimits.countPriorToolCalls(prompt.getInstructions());
int totalToolCallCount = toolCallCounts.values().stream().mapToInt(Integer::intValue).sum();
countPriorToolCalls 扫描 prompt.getInstructions()(即完整历史 )来重建"到目前为止每个工具被调用了几次"。这是本方法唯一的"状态来源"------DefaultToolCallingManager 是无状态的。 11.4 节详述。
第五步(:237-241) :遍历本轮 assistant 消息里的每一个 tool call(并行工具调用 = 一个 assistant 消息带多个 ToolCall)。
第六步(:243-246):预算自增。
java
String toolName = toolCall.name();
totalToolCallCount++;
int toolCallCount = toolCallCounts.merge(toolName, 1, Integer::sum);
merge 返回更新后 的值。所以 toolCallCount 是"含本次在内的该工具累计调用次数"。
第七步(:248-269):预算检查与分岔(THROW / RETURN_ERROR_RESPONSE)。11.4 节详述。
第八步(:271-284):空 arguments 兜底。
java
String toolInputArguments = toolCall.arguments();
// Handle the possible null parameter situation in streaming mode.
final String finalToolInputArguments;
if (!StringUtils.hasText(toolInputArguments)) {
if (logger.isWarnEnabled()) {
logger.warn("Tool call arguments are null or empty for tool: " + toolName
+ ". Using empty JSON object as default.");
}
finalToolInputArguments = "{}";
}
else {
finalToolInputArguments = toolInputArguments;
}
注释明确写了原因:"possible null parameter situation in streaming mode" 。流式聚合时,如果模型只吐了工具名没吐参数(或者参数分片丢失),arguments 可能是 null 或空串。此时兜底成 {} 而不是报错。
陷阱 :兜底成 {} 意味着无参工具能正常执行,但有必填参数的工具会走到参数校验失败 。第 10 章讲过 MethodToolCallback 的参数绑定流程------{} 反序列化成 POJO 时所有字段为 null/默认值,如果字段标了 required 就抛异常。这个异常会被 DefaultToolExecutionExceptionProcessor 处理(alwaysThrow=false 时转成错误消息回喂模型)。所以"流式下参数丢失"表现为"工具报错,模型重试"而不是崩溃------这其实是一条合理的降级路径 ,但如果你开了 alwaysThrow=true,就会变成异常冒泡。
另外注意:final String finalToolInputArguments 加了 final 是为了能在第 313-319 行的 lambda(observationContext 构建与 observe(() -> {...}))里捕获。
第九步(:286-297) :查找 ToolCallback。11.3.5 详述。
第十步(:299-304) :returnDirect 归约。11.3.6 详述。
第十一步(:306-335):构造 observation 并执行。11.3.7 详述。
第十二步(:337-338):收集结果。
java
toolResponses.add(new ToolResponseMessage.ToolResponse(toolCall.id(), toolName,
toolCallResult != null ? toolCallResult : ""));
null 结果被转成空串。 第 10 章讲过:Consumer 返回类型的工具返回 null(因为 Consumer 没有返回值)。这里把它变成 "",模型收到一个空的 tool response。
这是一个危险的静默 :模型看到 "responseData": "",不知道是"工具成功但没输出"还是"工具返回 null"。如果你的工具是 Consumer<T> 类型(例如"发送邮件"),模型会收到一个空字符串,可能据此认为"发送失败"而重试。
配方 :不要用 Consumer,改成返回 String(哪怕返回 "OK")或 void(第 10 章讲过 void 会被转成 "Done")。三种返回类型的对照:
| 方法返回类型 | 工具执行结果 | 模型看到 |
|---|---|---|
String |
实际返回值 | 实际返回值 |
void |
"Done" |
"Done" |
Consumer<T> |
null → 这里转 "" |
"" |
11.3.4 buildConversationHistoryAfterToolExecution:三行拼装
java
// DefaultToolCallingManager.java:345-351
private List<Message> buildConversationHistoryAfterToolExecution(List<Message> previousMessages,
AssistantMessage assistantMessage, ToolResponseMessage toolResponseMessage) {
List<Message> messages = new ArrayList<>(previousMessages);
messages.add(assistantMessage);
messages.add(toolResponseMessage);
return messages;
}
这是整个工具循环里最重要的一次"历史增长"。 每轮它把两条消息(assistant 的 tool call + tool response)追加到上一轮历史末尾。
于是历史的增长曲线是:
sql
初始: [system, user] (2)
轮1后: [system, user, assistant(toolcall), toolResponse] (4)
轮2后: [system, user, assistant, toolResp, assistant, toolResp] (6)
轮3后: ... (8)
每轮 +2 条。 这就是循环的成本模型:第 N 轮发送给模型的消息数是 2 + 2*(N-1)。配合 11.7 节的 usage 累加,你可以精确预估一次工具循环的总 token 消耗。
陷阱 :new ArrayList<>(previousMessages) 每轮复制一次整个历史。10 轮循环 = 10 次 O(n) 复制,n 从 2 增长到 20。总成本 O(n²),n 很小所以无所谓。但如果你有超长历史(100+ 条),这是 10000 次引用拷贝------仍然微不足道。真正的问题不是拷贝,是这 100 条消息每轮都要发给模型。
11.3.5 回调查找:toolCallbacks 优先,resolver 兜底(默认关)
java
// DefaultToolCallingManager.java:286-297
ToolCallback toolCallback = toolCallbacks.stream()
.filter(tool -> toolName.equals(tool.getToolDefinition().name()))
.findFirst()
.orElseGet(() -> this.resolutionFallbackEnabled ? this.toolCallbackResolver.resolve(toolName) : null);
if (toolCallback == null) {
if (logger.isWarnEnabled()) {
logger.warn(POSSIBLE_LLM_TOOL_NAME_CHANGE_WARNING_START + toolName
+ POSSIBLE_LLM_TOOL_NAME_CHANGE_WARNING_END);
}
throw new IllegalStateException("No ToolCallback found for tool name: " + toolName);
}
查找顺序:
- 在本次请求的
toolCallbacks里按名字精确匹配(equals,大小写敏感),取第一个。 - 没找到且
resolutionFallbackEnabled == true→ 问toolCallbackResolver。 - 都没有 → 打 warn + 抛
IllegalStateException。
warn 文案很有意思 (DefaultToolCallingManager.java:86-88):
java
private static final String POSSIBLE_LLM_TOOL_NAME_CHANGE_WARNING_START = "LLM may have adapted the tool name '";
private static final String POSSIBLE_LLM_TOOL_NAME_CHANGE_WARNING_END
= "', especially if the name was truncated due to length limits. If this is the case, you can customize the prefixing and processing logic using McpToolNamePrefixGenerator";
拼接后的完整文案:
LLM may have adapted the tool name 'xxx', especially if the name was truncated due to length limits. If this is the case, you can customize the prefixing and processing logic using McpToolNamePrefixGenerator
它把 MCP 工具名前缀生成直接写进了核心模块的告警里。 这暴露了一个真实的高频故障:MCP server 的工具名被加上前缀后超过模型侧的长度上限(例如 OpenAI 的 64 字符),模型在回传时把名字截断了,导致匹配失败。第 13 章会详述 McpToolNamePrefixGenerator,这里先记住:工具名匹配失败的第一嫌疑人是长度截断,不是你拼错了。
resolutionFallbackEnabled 的语义 (:117-123 Javadoc):
Whether a call to a tool that is not part of the request's tool callbacks may still be executed by resolving it through the
ToolCallbackResolver. Whenfalse(the default), the tools attached to the request are the only tools that can be executed.
配置属性(ToolCallingProperties.java:170-204):
java
public static class Resolution {
private final Fallback fallback = new Fallback();
public Fallback getFallback() {
return this.fallback;
}
public static class Fallback {
/**
* Whether a requested tool that is absent from the request's tool callbacks
* may be resolved by name through the {@code ToolCallbackResolver} (backed by
* every {@code ToolCallback} bean in the application context) and executed.
* <p>
* Disabled by default: the tools attached to a request are the only tools
* that can be executed for that request.
* <p>
* <strong>Enable with care.</strong> When enabled, any tool reachable through
* the resolver becomes executable whenever the model names it, regardless of
* what the request attached --- including risk-tier and destructive tools.
*/
private boolean enabled = false;
// ...
}
}
注意 Javadoc 里的 "including risk-tier and destructive tools" ------ 官方自己标注了这是一个权限边界而非便利开关。
开启方式:
yaml
spring:
ai:
tools:
resolution:
fallback:
enabled: true
开启后会发生什么? toolCallbackResolver 是容器里的那个 Bean,由 ToolCallingAutoConfiguration.toolCallbackResolver 提供:
java
// ToolCallingAutoConfiguration.java:77-104
@Bean
@ConditionalOnMissingBean
ToolCallbackResolver toolCallbackResolver(
GenericApplicationContext applicationContext, // @formatter:off
List<ToolCallback> toolCallbacks,
// Deprecated in favor of the tcbProviders. Kept for backward compatibility.
ObjectProvider<List<ToolCallbackProvider>> tcbProviderList,
ObjectProvider<ToolCallbackProvider> tcbProviders) { // @formatter:on
List<ToolCallback> allFunctionAndToolCallbacks = new ArrayList<>(toolCallbacks);
// Merge ToolCallbackProviders from both ObjectProviders.
List<ToolCallbackProvider> totalToolCallbackProviders = new ArrayList<>(
tcbProviderList.stream().flatMap(List::stream).toList());
totalToolCallbackProviders.addAll(tcbProviders.stream().toList());
// De-duplicate ToolCallbackProviders
totalToolCallbackProviders = totalToolCallbackProviders.stream().distinct().toList();
totalToolCallbackProviders.stream()
.filter(pr -> !isMcpToolCallbackProvider(ResolvableType.forInstance(pr)))
.map(pr -> List.of(pr.getToolCallbacks()))
.forEach(allFunctionAndToolCallbacks::addAll);
var staticToolCallbackResolver = new StaticToolCallbackResolver(allFunctionAndToolCallbacks);
return new DelegatingToolCallbackResolver(List.of(staticToolCallbackResolver));
}
三层结构:DelegatingToolCallbackResolver → StaticToolCallbackResolver → 一个扁平的 List<ToolCallback>。
关键排除逻辑 :isMcpToolCallbackProvider 过滤掉了 MCP 的 provider:
java
// ToolCallingAutoConfiguration.java:106-113
private static boolean isMcpToolCallbackProvider(ResolvableType type) {
if (type.getType().getTypeName().equals("org.springframework.ai.mcp.SyncMcpToolCallbackProvider")
|| type.getType().getTypeName().equals("org.springframework.ai.mcp.AsyncMcpToolCallbackProvider")) {
return true;
}
var superType = type.getSuperType();
return superType != ResolvableType.NONE && isMcpToolCallbackProvider(superType);
}
注释说明理由:
MCP providers are excluded, to avoid initializing them early with #listTools().
这是一个启动性能与懒加载的权衡 :MCP 的 getToolCallbacks() 会触发 listTools() 网络调用,如果在应用启动时就调(因为 resolver Bean 要收集所有回调),会让每个 MCP server 在启动阶段被同步拉取一次。所以排除掉。
后果 :即使你开启了 resolutionFallbackEnabled,MCP 工具也永远无法通过 resolver 兜底解析。 它们只能走"显式挂在请求上"这条路。这在第 13 章讲 MCP 工具聚合时会再次提到。
另一个细节:ResolvableType 递归向上(getSuperType())检查类名,意味着你自己写一个 extends SyncMcpToolCallbackProvider 的类也会被识别并排除 。但类名是硬编码字符串,如果你自己写了个同名不同包的类......不会被排除(包名不匹配 org.springframework.ai.mcp)。硬编码类名是脆弱点。
11.3.6 returnDirect 的三态 Boolean 与 AND 归约
java
// DefaultToolCallingManager.java:299-304
if (returnDirect == null) {
returnDirect = toolCallback.getToolMetadata().returnDirect();
}
else {
returnDirect = returnDirect && toolCallback.getToolMetadata().returnDirect();
}
Boolean(包装类)+ 初值 null,这是三态:
| 状态 | 含义 |
|---|---|
null |
还没遇到任何工具(批处理的第一个) |
TRUE |
到目前为止所有工具都要求 returnDirect |
FALSE |
至少有一个工具不要求 returnDirect |
归约算子是 AND :一批并行工具调用中,只要有一个工具的 returnDirect=false,整批就为 false。
为什么必须是 AND?因为 returnDirect=true 的语义是"工具结果直接返回给用户,不要再喂给模型"(ToolExecutionResult.FINISH_REASON = "returnDirect")。如果一批里有三个工具,其中两个要求直接返回、一个不要求,你无法"部分直接返回"------要么全直接返回(丢失第三个工具的结果处理),要么全部继续(违背前两个的意图)。Spring AI 选了继续(AND 中 false 传染)。
这是一个保守但可能反直觉的选择 :假设你有 getWeather(returnDirect=false)和 sendEmail(returnDirect=true),模型在同一轮并行调用了两者。结果是 returnDirect=false,循环继续,sendEmail 的结果又被喂回模型。如果你的本意是"发完邮件就结束",这个语义做不到------必须靠 11.8 节的自定义 checker 或 11.14 节的配方。
returnDirect 的处理在 advisor 里 (ToolCallingAdvisor.java:198-209):
java
if (toolExecutionResult.returnDirect()) {
// Return tool execution result directly to the application client
// instead of returning it to the LLM.
chatClientResponse = chatClientResponse.mutate()
.chatResponse(ChatResponse.builder()
.from(chatResponse)
.generations(ToolExecutionResult.buildGenerations(toolExecutionResult))
.build())
.build();
break;
}
break 跳出 do-while,循环结束。buildGenerations 把 tool response 转成一个或多个 Generation:
java
// ToolExecutionResult.java:67-84
static List<Generation> buildGenerations(ToolExecutionResult toolExecutionResult) {
List<Message> conversationHistory = toolExecutionResult.conversationHistory();
List<Generation> generations = new ArrayList<>();
if (conversationHistory
.get(conversationHistory.size() - 1) instanceof ToolResponseMessage toolResponseMessage) {
toolResponseMessage.getResponses().forEach(response -> {
AssistantMessage assistantMessage = new AssistantMessage(response.responseData());
Generation generation = new Generation(assistantMessage,
ChatGenerationMetadata.builder()
.metadata(METADATA_TOOL_ID, response.id())
.metadata(METADATA_TOOL_NAME, response.name())
.finishReason(FINISH_REASON)
.build());
generations.add(generation);
});
}
return generations;
}
注意:
- 每个
ToolResponse变成一个Generation(所以并行工具调用 → 多个 generation)。 finishReason是"returnDirect"(常量ToolExecutionResult.FINISH_REASON)。- metadata 里塞了
toolId和toolName。
陷阱 :conversationHistory.get(conversationHistory.size() - 1) ------ 如果历史为空会 IndexOutOfBoundsException。但 buildConversationHistoryAfterToolExecution 保证至少加了 2 条,所以不会。
集成测试验证了这个行为(AbstractToolCallingAdvisorIT.java:151-164):
java
@Test
void callWithReturnDirect() {
String response = ChatClient.create(getChatModel())
.prompt()
.advisors(ToolCallingAdvisor.builder().build())
.user("What's the weather like in Tokyo?")
.tools(createReturnDirectWeatherToolCallback())
.call()
.content();
// With returnDirect=true, the raw tool result is returned without LLM
// processing
assertThat(response).contains("temp");
}
MockWeatherService 返回的原始 JSON 里含 "temp" 字段,断言 content 含 "temp" 说明确实是原始工具输出,没经过模型润色。
11.3.7 观测父上下文:两个来源的优先顺序
java
// DefaultToolCallingManager.java:306-335
// In streaming/reactive mode the parent observation is propagated through the
// Reactor context captured in ToolCallReactiveContextHolder. In blocking mode
// that holder is never populated, so fall back to the observation currently
// in scope on the calling thread to keep the observation hierarchy intact.
Observation parent = ToolCallReactiveContextHolder.getContext()
.getOrDefault(ObservationThreadLocalAccessor.KEY, this.observationRegistry.getCurrentObservation());
ToolCallingObservationContext observationContext = ToolCallingObservationContext.builder()
.toolDefinition(toolCallback.getToolDefinition())
.toolMetadata(toolCallback.getToolMetadata())
.toolCallId(toolCall.id())
.toolType(toolCall.type())
.toolCallArguments(finalToolInputArguments)
.build();
String toolCallResult = ToolCallingObservationDocumentation.TOOL_CALL
.observation(this.observationConvention, DEFAULT_OBSERVATION_CONVENTION, () -> observationContext,
this.observationRegistry)
.parentObservation(parent)
.observe(() -> {
String toolResult;
try {
toolResult = toolCallback.call(finalToolInputArguments, toolContext);
}
catch (ToolExecutionException ex) {
toolResult = this.toolExecutionExceptionProcessor.process(ex);
}
observationContext.setToolCallResult(toolResult);
return toolResult;
});
父 observation 的解析顺序:
ToolCallReactiveContextHolder.getContext().getOrDefault(KEY, ...)------ 从 ThreadLocal 里拿之前存下的 Reactor ContextView,再从中取ObservationThreadLocalAccessor.KEY。- 如果 1 拿不到(
Context.empty()时getOrDefault走默认值),用this.observationRegistry.getCurrentObservation()------ 当前线程上正在进行的 observation。
ToolCallReactiveContextHolder 是一个极简的 ThreadLocal 桥:
java
// spring-ai-model/src/main/java/org/springframework/ai/model/tool/internal/ToolCallReactiveContextHolder.java:30-50
public final class ToolCallReactiveContextHolder {
private static final ThreadLocal<ContextView> context = ThreadLocal.withInitial(Context::empty);
private ToolCallReactiveContextHolder() {
// prevent instantiation
}
public static void setContext(ContextView contextView) {
context.set(contextView);
}
public static ContextView getContext() {
return context.get();
}
public static void clearContext() {
context.remove();
}
}
谁 set 它? 只有一处------ToolCallingAdvisor 的流式分支:
java
// ToolCallingAdvisor.java:360-383
Flux<ChatClientResponse> toolCallFlux = Flux.deferContextual(ctx -> {
ToolExecutionResult toolExecutionResult;
try {
ToolCallReactiveContextHolder.setContext(ctx);
toolExecutionResult = this.toolCallingManager.executeToolCalls(new Prompt(fullTurnHistory, optionsCopy),
chatResponse);
}
catch (ToolCallLimitExceededException ex) {
// ...
}
finally {
ToolCallReactiveContextHolder.clearContext();
}
// ...
});
return toolCallFlux.subscribeOn(Schedulers.boundedElastic());
注意三件事:
(a) setContext 在 try 里,clearContext 在 finally 里。 但 ToolCallLimitExceededException 的 catch 分支在 try 之后、finally 之前 return------finally 仍会执行 clearContext。正确。
(b) subscribeOn(Schedulers.boundedElastic()) ------ 工具执行是阻塞的,所以整个 toolCallFlux 被调度到 boundedElastic 线程池。这意味着:
setContext/clearContext发生在 boundedElastic 的某个线程上。- ThreadLocal 的 set 和 clear 在同一段同步代码内,不跨线程,所以安全。
- 但如果
executeToolCalls内部又切换到别的线程执行工具(例如你的工具方法里用了CompletableFuture),ThreadLocal 不会传播。
(c) 阻塞链路下 ToolCallReactiveContextHolder 永远是 Context.empty()。 这是注释里明说的("In blocking mode that holder is never populated")。所以阻塞链路走的是 observationRegistry.getCurrentObservation()。
这意味着什么? 阻塞链路下,工具 span 的父节点是"调用线程上当前打开的 observation"。在正常 Spring MVC 请求里,这可能是 HTTP 请求的 observation(如果用了 Micrometer 的 servlet 过滤),也可能是 ChatClient 的 observation(如果 advisor 链开了 scope)。父子关系依赖线程局部状态,而不是显式传递------这是阻塞模式下 trace 树容易断的原因之一。11.11 节会给出完整的 span 结构图。
(d) catch (ToolExecutionException ex) 只捕获 ToolExecutionException。 第 10 章讲过:MethodToolCallback 把反射调用的任何异常包成 ToolExecutionException。所以这里是"所有工具执行异常的统一入口",交给 toolExecutionExceptionProcessor.process(ex) 转成字符串。
但注意:ToolCallLimitExceededException 不是在这里抛的 ------它在第七步(预算检查)就抛了,在 observe() 外面。所以预算超限没有 工具执行的 observation span(工具根本没执行)。这是合理的,但也意味着:你无法从 trace 里看到"某次工具调用因预算被拒" ,除非开 RETURN_ERROR_RESPONSE(那时工具没执行但有一条合成的 response)。
(e) observationContext.setToolCallResult(toolResult) 在 lambda 内部。 这是为了让 convention 在 observation stop 时能读到结果。但如果 toolCallback.call 抛出的是非 ToolExecutionException 的 RuntimeException(第 10 章讲过:DefaultToolExecutionExceptionProcessor 对非 RuntimeException 无条件重抛;对 RuntimeException 则取决于 alwaysThrow),那么:
- 异常传播出
observe(),setToolCallResult不会执行。 - observation 被标记为 error 结束(Micrometer 的
observe(Supplier)会捕获异常并observation.error(ex))。 toolCallResult上下文里没有结果。
配方 :想让"工具执行失败"也出现在 trace 的属性里,需要自定义 convention 从 observation 的 error 里提取,而不是依赖 getToolCallResult()。
11.3.8 结果 null → 空串:与流式参数兜底的对照
第 337-338 行:
java
toolResponses.add(new ToolResponseMessage.ToolResponse(toolCall.id(), toolName,
toolCallResult != null ? toolCallResult : ""));
这里有一个容易混淆的点:toolCallResult 什么时候是 null?
toolCallback.call(...)返回null------ 第 10 章讲过Consumer类型工具。toolExecutionExceptionProcessor.process(ex)返回null------ 默认实现返回错误消息字符串,不会 null。但自定义 processor 可能返回 null。
两种情况都被静默转成 ""。 结合 11.3.3 的"空 arguments → {}",可以看到 DefaultToolCallingManager 的整体风格是宁可降级也不抛:
| 异常情况 | 处理 | 是否告警 |
|---|---|---|
| arguments 为 null/空 | 用 {} |
WARN |
| 工具返回 null | 用 "" |
无 |
| 找不到 ToolCallback | 抛 IllegalStateException |
WARN |
| 模型没请求工具 | 抛 IllegalStateException |
无 |
| 超预算 | 抛 ToolCallLimitExceededException / 合成错误 response |
无 |
"找不到工具"和"模型没请求工具"是仅有的两个硬失败------因为它们无法降级(没有工具可执行、没有 tool call 可执行)。
11.3.9 InternalToolExecutionResult:一个私有 record
java
// DefaultToolCallingManager.java:361-362
private record InternalToolExecutionResult(ToolResponseMessage toolResponseMessage, boolean returnDirect) {
}
私有 record,两个字段,用于把 executeToolCall 的两个返回值打包。returnDirect 从 Boolean 收敛成 boolean(Objects.requireNonNullElse(returnDirect, false)),即"一个工具都没执行时,returnDirect=false"。
什么时候一个工具都没执行? 一批 tool call 全部因超预算被跳过(RETURN_ERROR_RESPONSE 模式下 continue)。此时 toolResponses 里全是合成的错误 response,returnDirect 为 false。
11.3.10 本节小结与一张时序图
一次 executeToolCalls 的完整流程:
scss
executeToolCalls(prompt, chatResponse)
│
├─ 找第一个带 tool call 的 Generation → findFirst() 【多候选静默丢弃】
│
├─ buildToolContext(prompt) → 从 options 取 toolContext,空则 Map.of()
│
└─ executeToolCall(prompt, assistantMessage, toolContext)
│
├─ 取 options.getToolCallbacks()
├─ countPriorToolCalls(prompt.getInstructions()) ← 【无状态:扫历史重建计数】
├─ totalToolCallCount = Σ counts
│
└─ for each ToolCall:
├─ totalToolCallCount++ ; counts.merge(name, 1, sum)
├─ ToolCallLimits.check(name, toolCallCount, totalToolCallCount)
│ ├─ 未超限 → 继续
│ ├─ 超限 + THROW → 组装 partial result → throw ToolCallLimitExceededException
│ └─ 超限 + RETURN_ERROR_RESPONSE → 加合成 response → continue
├─ arguments 空 → "{}" 【流式兜底 + WARN】
├─ 查找 ToolCallback:
│ ├─ toolCallbacks 精确匹配(equals)
│ ├─ resolutionFallbackEnabled ? resolver.resolve(name) : null
│ └─ null → WARN(提示 MCP 前缀截断)+ throw IllegalStateException
├─ returnDirect = null ? md.returnDirect() : (returnDirect && md.returnDirect()) 【AND 归约】
├─ parent = ReactiveContextHolder.getContext().getOrDefault(KEY, registry.getCurrentObservation())
├─ TOOL_CALL.observation(...).parentObservation(parent).observe(() -> {
│ toolResult = toolCallback.call(args, ctx);
│ catch (ToolExecutionException) → processor.process(ex);
│ observationContext.setToolCallResult(toolResult);
│ })
└─ toolResponses.add(new ToolResponse(id, name, result != null ? result : ""))
│
└─ return new InternalToolExecutionResult(ToolResponseMessage(toolResponses), returnDirect ?? false)
│
└─ buildConversationHistoryAfterToolExecution(previous, assistant, toolResponse)
→ [previous..., assistant, toolResponse] 【每轮 +2 条】
11.4 工具调用预算:ToolCallLimits 与 40 / 150
这一节回答标题里那个最具体的问题:40 和 150 是怎么来的,以及"一次调用"到底怎么数。
11.4.1 两个常量的定义与官方解释
java
// spring-ai-model/src/main/java/org/springframework/ai/model/tool/DefaultToolCallingManager.java:92-107
/**
* Default cap applied to every tool name unless overridden via
* {@link Builder#maxCallsPerTool(String, int)}, exempted via
* {@link Builder#excludeToolFromLimit(String)}, or disabled entirely via
* {@link Builder#unlimitedCallsPerTool()}. Generous enough for legitimate iterative
* tool use (pagination, retries) while still catching a runaway loop long before it
* becomes expensive.
*/
public static final int DEFAULT_MAX_CALLS_PER_TOOL = 40;
/**
* Default cap on the total number of tool calls, across all tools combined, allowed
* within a turn, unless disabled entirely via
* {@link Builder#unlimitedTotalToolCalls()}.
*/
public static final int DEFAULT_MAX_TOTAL_TOOL_CALLS = 150;
官方给出的设计理由是这句:
Generous enough for legitimate iterative tool use (pagination, retries) while still catching a runaway loop long before it becomes expensive.
翻译:"宽到能容纳正常的分页/重试,窄到能在失控循环变得昂贵之前抓住它。" 这是纯粹的经验值,没有公式。反推它的量级:
- 单工具 40:一个分页查询工具,页大小 20,翻 40 页 = 800 条记录。对于"让模型自己翻页直到找完"这种用法,40 次是一个合理的上限。再往上通常说明模型陷入了"反复调同一个工具却得不到所需信息"的死循环。
- 总 150 :如果一轮里平均每个分支调 3--5 个工具,150 允许 30--50 个分支轮次。考虑到每次工具调用都伴随着一次完整的模型往返(历史 +2 条消息),150 次工具调用意味着至少 150 次模型调用,按 GPT-4o 级别的价格和 2 万 token 的历史长度算,这是一笔几百元人民币级别的账单------"long before it becomes expensive" 的分界线大约在这里。
关键:这两个数字都不是"轮数"上限,而是"工具调用次数"上限。 一次模型往返可能携带 1 个或 10 个 tool call。所以:
| 场景 | 轮数 | 每轮工具数 | 总调用数 | 是否触发 150 |
|---|---|---|---|---|
| 顺序调 3 个城市天气 | 3 | 1 | 3 | 否 |
| 并行调 3 个城市天气 | 1 | 3 | 3 | 否 |
| 分页翻 30 页 | 30 | 1 | 30 | 否(<40 且 <150) |
| 死循环调同一工具 | ∞ | 1 | 41 时触发 | 是(单工具 40) |
| 每轮并行 5 个,跑 31 轮 | 31 | 5 | 155 | 是(总 150) |
注意最后一行 :单工具只调了 31 次(<40),但总数 155 > 150,所以先触发总量限制 。两个阈值独立检查,check() 里先查单工具再查总量(11.4.3),但**"先触发"指的是在同一批内谁先命中**------实际上每检查一个 tool call 都会先过单工具再过总量。
11.4.2 "turn" 的定义:从最后一条 UserMessage 切片
这是整个预算机制里最容易被误解的部分。
java
// spring-ai-model/src/main/java/org/springframework/ai/model/tool/ToolCallLimits.java:67-105
/**
* Tally tool calls already executed earlier in this turn by scanning the
* {@link ToolResponseMessage.ToolResponse} entries present in the current turn's
* portion of the conversation history. The
* {@link org.springframework.ai.chat.prompt.Prompt} passed into
* {@link DefaultToolCallingManager#executeToolCalls} accumulates every prior round's
* assistant and tool response messages (see {@code ToolCallingAdvisor}'s tool-calling
* loop), so this reconstructs the running count without the manager itself holding
* any per-turn state. Only messages from the current turn - the last
* {@link UserMessage} onward, matching the Turn definition used by the
* {@code spring-ai-session} project - are counted, so a long conversation replayed in
* full by an external {@code ChatMemory} advisor does not inflate the count with tool
* calls from earlier turns.
*/
static Map<String, Integer> countPriorToolCalls(List<Message> instructions) {
Map<String, Integer> counts = new HashMap<>();
for (Message message : currentTurnMessages(instructions)) {
if (message instanceof ToolResponseMessage toolResponseMessage) {
for (ToolResponseMessage.ToolResponse response : toolResponseMessage.getResponses()) {
counts.merge(response.name(), 1, Integer::sum);
}
}
}
return counts;
}
/**
* The current, still-open turn: the last {@link UserMessage} in {@code instructions}
* plus everything after it. Falls back to the full list if no {@link UserMessage} is
* present.
*/
private static List<Message> currentTurnMessages(List<Message> instructions) {
for (int i = instructions.size() - 1; i >= 0; i--) {
if (instructions.get(i) instanceof UserMessage) {
return instructions.subList(i, instructions.size());
}
}
return instructions;
}
Javadoc 里的一句话价值千金:
Only messages from the current turn - the last
UserMessageonward, matching the Turn definition used by thespring-ai-sessionproject - are counted, so a long conversation replayed in full by an externalChatMemoryadvisor does not inflate the count with tool calls from earlier turns.
这解决了一个真实的两难 :如果你的 ChatMemory 把前 9 轮对话(每轮都用了工具)全量重放进 prompt,那么"扫全历史"会把前 9 轮的工具调用数也累加进来,导致第 10 轮一开始就"已经用了 27 次工具"。这显然不对------预算应该是每轮用户提问重置一次。
解决方案是从后往前找最后一条 UserMessage,从那里开始切片。
这个定义的三个后果:
(a) 预算的生命周期 = 一次用户提问。 用户问"帮我查 X" → 预算从 0 开始 → 模型调工具 → 累加 → 循环结束 → 用户再问"再查 Y" → 预算归零重新开始。
(b) 如果整个 prompt 里没有 UserMessage,currentTurnMessages 返回完整列表。 什么时候会没有 UserMessage?纯 system + assistant 的构造(例如某些 agent 场景下的"自驱循环")。此时退化为"扫全历史",预算会跨轮累加不重置------这可能导致意外触发上限。
(c) 工具调用产生的消息里没有 UserMessage。 回顾 11.3.4 的历史增长:assistant(toolcall) + toolResponse,两条都不是 UserMessage。所以切片始终包含从用户提问开始的所有轮次------正是我们要的语义。
一个反例陷阱 :某些 ChatMemory 实现或自定义 advisor 会在工具循环内往历史里插入 UserMessage(例如把工具结果包装成"用户说:工具返回了 XXX")。这种写法会把 turn 边界往下推,导致之前的工具调用被"挤出"切片,预算被人为重置。
更隐蔽的是:ToolResponseMessage 之后如果插入 UserMessage,下一轮的 currentTurnMessages 就从那条 UserMessage 开始,本轮已经执行的工具调用全部不计入。这会让 40/150 形同虚设。
验证这一点的方法 :在自定义 advisor 的 doAfterCall 里打印 prompt.getInstructions() 的 getMessageType() 序列,确认工具循环期间除了 ASSISTANT 和 TOOL 没有别的类型。
11.4.3 check():两个阈值的判据与优先级
java
// spring-ai-model/src/main/java/org/springframework/ai/model/tool/ToolCallLimits.java:107-133
/**
* Checks the given call counts (inclusive of the call about to be made) against the
* configured limits.
* @return a {@link Breach} describing the limit that was hit, or {@code null} if the
* call is within all configured limits.
*/
@Nullable Breach check(String toolName, int toolCallCount, int totalToolCallCount) {
if (!this.toolsExcludedFromLimit.contains(toolName)) {
int maxCallsForTool = this.maxCallsPerTool.getOrDefault(toolName, this.defaultMaxCallsPerTool);
if (maxCallsForTool != NO_LIMIT && toolCallCount > maxCallsForTool) {
return new Breach(toolName, maxCallsForTool,
"Tool call limit (%d) exceeded for tool '%s'. No further calls to this tool are allowed in this turn."
.formatted(maxCallsForTool, toolName));
}
}
if (this.maxTotalToolCalls != NO_LIMIT && totalToolCallCount > this.maxTotalToolCalls) {
return new Breach(null, this.maxTotalToolCalls,
"Total tool call limit (%d) exceeded for this turn. No further tool calls are allowed."
.formatted(this.maxTotalToolCalls));
}
return null;
}
record Breach(@Nullable String toolName, int limit, String message) {
}
判据细节:
- 比较是
>而不是>=。toolCallCount > maxCallsForTool------ 所以maxCallsPerTool=1意味着"允许调用 1 次,第 2 次被拒"。集成测试正是这么用的(11.4.6)。 toolsExcludedFromLimit只对单工具限制生效。 被excludeToolFromLimit(name)排除的工具,仍然计入totalToolCallCount(Builder 的 Javadoc 明确写了:"Calls to this tool still count towardmaxTotalToolCalls")。maxCallsPerTool的 per-tool map 优先于defaultMaxCallsPerTool,用getOrDefault。NO_LIMIT = -1(ToolCallLimits.java:42),且maxCallsForTool == NO_LIMIT时跳过检查。- 单工具检查在前,总量检查在后,且两者互不短路。 一个 tool call 可能同时触发两者------返回的是先命中的那个(单工具)。
Breach 的 toolName 字段是 @Nullable :单工具超限时是工具名;总量超限时是 null。这个 null 一路传到 ToolCallLimitExceededException.toolName,再传到异常消息:
java
// ToolCallLimitExceededException.java:81-84
private static String buildMessage(@Nullable String toolName, int limit) {
return toolName != null ? "Tool call limit (%d) exceeded for tool '%s'".formatted(limit, toolName)
: "Total tool call limit (%d) exceeded for this turn".formatted(limit);
}
两种消息:
java
Tool call limit (40) exceeded for tool 'getCurrentWeather'
Total tool call limit (150) exceeded for this turn
注意这两条消息会被喂给模型或用户,它们是英文硬编码,没有国际化。如果你的产品面向中文用户,需要在 advisor 层拦截并替换。
11.4.4 两种超限行为:THROW 与 RETURN_ERROR_RESPONSE
java
// spring-ai-model/src/main/java/org/springframework/ai/model/tool/ToolCallLimitBehavior.java:26-42
public enum ToolCallLimitBehavior {
/**
* Throw a {@link ToolCallLimitExceededException} carrying the tool calls executed so
* far, aborting the current batch of tool calls immediately.
*/
THROW,
/**
* Skip invoking the tool callback and instead return a
* {@link org.springframework.ai.chat.messages.ToolResponseMessage.ToolResponse}
* explaining that the limit was reached, letting the model see the rejection and
* decide how to proceed.
*/
RETURN_ERROR_RESPONSE
}
默认是 THROW (DefaultToolCallingManager.java:130-131、ToolCallingProperties.java:126)。
THROW 路径 (DefaultToolCallingManager.java:249-264):
java
ToolCallLimits.Breach limitBreach = this.toolCallLimits.check(toolName, toolCallCount, totalToolCallCount);
if (limitBreach != null) {
toolResponses.add(new ToolResponseMessage.ToolResponse(toolCall.id(), toolName, limitBreach.message()));
if (this.toolCallLimits.onLimitExceeded() == ToolCallLimitBehavior.THROW) {
ToolResponseMessage partialToolResponseMessage = ToolResponseMessage.builder()
.responses(toolResponses)
.build();
List<Message> partialConversationHistory = buildConversationHistoryAfterToolExecution(
prompt.getInstructions(), assistantMessage, partialToolResponseMessage);
ToolExecutionResult partialToolExecutionResult = ToolExecutionResult.builder()
.conversationHistory(partialConversationHistory)
.returnDirect(Objects.requireNonNullElse(returnDirect, false))
.build();
throw new ToolCallLimitExceededException(limitBreach.toolName(), limitBreach.limit(),
partialToolExecutionResult);
}
// ToolCallLimitBehavior.RETURN_ERROR_RESPONSE: skip invoking this tool
// call but keep processing the rest of the batch.
continue;
}
注意顺序:先把 breach 消息作为一个 ToolResponse 加进 toolResponses ,然后再决定是否抛。所以 partialConversationHistory 里最后一条 ToolResponseMessage 的最后一个 response 就是那条错误消息。这个结构在 11.4.5 会被 buildGeneration() 利用。
THROW 的关键性质:立即中断整批。 假设一轮有 5 个并行 tool call,第 3 个超限 → 前 2 个已执行、第 3 个被拒并抛异常 → 第 4、5 个根本不执行。
这是一个"快速失败"设计,代价是丢弃了本可完成的工作(第 4、5 个工具没跑)。但异常里携带了前 2 个的结果(11.4.5)。
RETURN_ERROR_RESPONSE 路径 :不抛,continue 跳过当前 tool call,继续处理批里的其他 tool call。模型会收到一条"这个工具被限额了"的 tool response,可以据此调整策略(例如改用别的工具、或者直接回答)。
两种模式的对比:
| 维度 | THROW(默认) |
RETURN_ERROR_RESPONSE |
|---|---|---|
| 批内剩余 tool call | 不执行 | 继续执行 |
| 是否回喂模型 | 否,直接返回给用户 | 是,模型看到拒答消息 |
| 循环是否继续 | 否(ToolCallingAdvisor catch 后 break) |
是 |
| 用户看到什么 | breach 消息(作为唯一 generation) | 模型的后续回答 |
| 适用场景 | 成本硬止损 | 让模型优雅降级 |
关键差异在 advisor 里:
java
// ToolCallingAdvisor.java:180-194
ToolExecutionResult toolExecutionResult;
try {
toolExecutionResult = this.toolCallingManager
.executeToolCalls(new Prompt(fullTurnHistory, toolCallingChatOptions), chatResponse);
}
catch (ToolCallLimitExceededException ex) {
// Return the breach as a single generation - directly to the
// application client instead of looping back to the LLM.
chatClientResponse = chatClientResponse.mutate()
.chatResponse(ChatResponse.builder()
.from(chatResponse)
.generations(List.of(ex.buildGeneration()))
.build())
.build();
break;
}
break ------ 捕获后直接跳出循环,把 breach 作为唯一 generation 返回给用户。不再给模型一次"解释为什么停了"的机会。 用户看到的最终内容就是那句英文:
Tool call limit (1) exceeded for tool 'getCurrentWeather'. No further calls to this tool are allowed in this turn.
集成测试验证(11.4.6 给出)。
11.4.5 ToolCallLimitExceededException:如何保住已完成的工作
java
// spring-ai-model/src/main/java/org/springframework/ai/model/tool/ToolCallLimitExceededException.java:109-138
/**
* Builds a single {@link Generation} representing this exception, so that a limit
* breach is never mistaken for one of several equally valid answers.
* <p>
* The {@link Generation}'s output is the breach message synthesized by
* {@link DefaultToolCallingManager} for the call that exceeded the limit. Any tool
* responses that completed successfully earlier in the same batch are attached under
* {@value #METADATA_PARTIAL_TOOL_RESPONSES} instead of being emitted as separate,
* sibling generations, so completed work isn't discarded but also isn't confused with
* the error itself.
*/
public Generation buildGeneration() {
String breachMessage = getMessage();
List<ToolResponseMessage.ToolResponse> partialResponses = List.of();
List<Message> history = this.partialToolExecutionResult.conversationHistory();
if (!history.isEmpty() && history.get(history.size() - 1) instanceof ToolResponseMessage toolResponseMessage) {
List<ToolResponseMessage.ToolResponse> responses = toolResponseMessage.getResponses();
if (!responses.isEmpty()) {
breachMessage = responses.get(responses.size() - 1).responseData();
partialResponses = responses.subList(0, responses.size() - 1);
}
}
ChatGenerationMetadata metadata = ChatGenerationMetadata.builder()
.finishReason(FINISH_REASON)
.metadata(METADATA_PARTIAL_TOOL_RESPONSES, partialResponses)
.build();
return new Generation(new AssistantMessage(breachMessage), metadata);
}
这段代码的精妙之处在于它解决了一个真实的语义难题。
问题:一轮里前 2 个工具成功、第 3 个超限。如果你把 3 条结果都作为 sibling generation 返回,那么"成功的工具输出"和"限额错误"会并列出现,看起来像是"模型的三个备选答案"。这是语义污染。
解法:
- 正常输出 = 最后一条 response 的
responseData()(即 breach 消息)。 - 已完成的工作 = 前面的 response 们,塞进 metadata 的
partialToolResponseskey。
java
public static final String FINISH_REASON = "toolCallLimitExceeded";
public static final String METADATA_PARTIAL_TOOL_RESPONSES = "partialToolResponses";
finishReason = "toolCallLimitExceeded" 是一个可观测的信号:你可以用它做监控告警("今天有 3 次对话因工具预算被熔断")。
陷阱 :breachMessage = responses.get(responses.size() - 1).responseData() 取的是最后一条 response。这依赖于 11.4.4 里"先加 breach response 再抛异常"的顺序------breach response 一定是最后一条。如果将来代码调整了顺序(先抛后加),这里就会取到一个成功工具的输出作为错误信息。 这是一个隐式的顺序耦合,值得在升级时回归验证。
另一个陷阱 :buildGeneration() 用的 getMessage() 作为 fallback(当 history 为空时)。而 getMessage() 是 buildMessage(toolName, limit) 的短版本:
bash
Tool call limit (1) exceeded for tool 'getCurrentWeather'
而 11.4.3 里 check() 生成的 message 是长版本:
kotlin
Tool call limit (1) exceeded for tool 'getCurrentWeather'. No further calls to this tool are allowed in this turn.
两处文案不一致。 正常路径下用户看到的是长版本 (因为它是作为 ToolResponse.responseData() 加进去的,buildGeneration 从那里取);只有异常路径 fallback 时才用短版本。
11.4.6 集成测试里限流是怎么被验证的
java
// spring-ai-test/.../AbstractToolCallingAdvisorIT.java:272-295
@Test
void callToolCallLimitIsEnforced() {
ToolCallingManager limitedManager = ToolCallingManager.builder()
.maxCallsPerTool("getCurrentWeather", 1)
.build();
ChatResponse response = Objects.requireNonNull(ChatClient.create(getChatModel())
.prompt()
.advisors(ToolCallingAdvisor.builder().toolCallingManager(limitedManager).build())
.user("What's the weather like in San Francisco, Tokyo, and Paris in Celsius?")
.tools(createWeatherToolCallback())
.call()
.chatResponse());
// Only the first call to getCurrentWeather is allowed; the rest are
// rejected with the message DefaultToolCallingManager synthesizes when a
// configured limit is exceeded. Regardless of whether the model calls the
// tool sequentially or batches all three into one parallel round, the
// breach is always returned as the sole generation - never mixed in with a
// successful call from the same batch as if they were equally valid
// alternative answers.
assertThat(response.getResults()).hasSize(1);
assertThat(response.getResults().get(0).getOutput().getText()).contains("limit");
}
三个断言点值得注意:
hasSize(1)------ 验证buildGeneration()只返回一个 generation,不多不少。这是"语义不污染"的契约测试。contains("limit")------ 验证消息内容来自 breach。- 注释里的这句 :"Regardless of whether the model calls the tool sequentially or batches all three into one parallel round, the breach is always returned as the sole generation"。这说明测试作者很清楚并发与顺序两种调用形态都必须被覆盖,且都收敛成单 generation。
对应的流式测试(:297-315)只断言 content.contains("limit"),没有 hasSize------因为流式下是 chunk 流,没法直接断言 generation 数量。
11.4.7 配置映射表:-1 的三处不同语义
ToolCallingAutoConfiguration 把 YAML 属性映射到 builder:
java
// ToolCallingAutoConfiguration.java:147-180
ToolCallingProperties.Limits limits = properties.getLimits();
Integer maxCallsPerToolDefault = limits.getMaxCallsPerToolDefault();
if (maxCallsPerToolDefault != null) {
if (maxCallsPerToolDefault == UNLIMITED) {
builder.unlimitedCallsPerTool();
}
else {
builder.maxCallsPerTool(maxCallsPerToolDefault);
}
}
limits.getMaxCallsPerTool().forEach((toolName, maxCalls) -> {
if (maxCalls == UNLIMITED) {
builder.excludeToolFromLimit(toolName);
}
else {
builder.maxCallsPerTool(toolName, maxCalls);
}
});
limits.getExcludedTools().forEach(builder::excludeToolFromLimit);
Integer maxTotalToolCalls = limits.getMaxTotalToolCalls();
if (maxTotalToolCalls != null) {
if (maxTotalToolCalls == UNLIMITED) {
builder.unlimitedTotalToolCalls();
}
else {
builder.maxTotalToolCalls(maxTotalToolCalls);
}
}
builder.onLimitExceeded(limits.getOnLimitExceeded());
UNLIMITED = -1(:69)。同一个 -1,在三处表达了三种不同的语义:
| 配置项 | -1 的含义 |
|---|---|
max-calls-per-tool-default: -1 |
所有未单独配置的工具 都不限次(unlimitedCallsPerTool() → defaultMaxCallsPerTool = NO_LIMIT) |
max-calls-per-tool.<name>: -1 |
这一个工具 被豁免单工具限制(excludeToolFromLimit(name)),但仍计入总量 |
max-total-tool-calls: -1 |
总量不限(unlimitedTotalToolCalls()) |
第一和第二的区别很容易搞混 :前者是"把默认值改成无限",后者是"把这个工具加进豁免名单"。前者影响所有工具,后者只影响一个且不影响总量计数。
一个隐蔽的坑 :maxCallsPerToolDefault == UNLIMITED 用的是 == 比较两个 Integer......不对,是 maxCallsPerToolDefault(Integer)与 UNLIMITED(int 常量)比较,自动拆箱 ,所以是值比较,没问题。但如果 maxCallsPerToolDefault 是 null,null == -1 会 NPE------不会,因为外层有 if (maxCallsPerToolDefault != null)。
完整配置示例:
yaml
spring:
ai:
tools:
throw-exception-on-error: false # 工具报错时不抛,转成消息回喂模型
limits:
max-calls-per-tool-default: 40 # 默认单工具上限
max-calls-per-tool:
getCurrentWeather: 5 # 天气工具最多 5 次
searchDatabase: -1 # 数据库查询豁免单工具限制(但仍计入总量)
excluded-tools:
- healthCheck # 健康检查工具豁免单工具限制
- ping
max-total-tool-calls: 150 # 一轮总量上限
on-limit-exceeded: THROW # 或 RETURN_ERROR_RESPONSE
resolution:
fallback:
enabled: false # 是否允许按名字解析请求里没挂的工具
observations:
include-content: false # 是否把工具参数/结果放进 observation
注意 excluded-tools 和 max-calls-per-tool: -1 效果完全相同 (都走 excludeToolFromLimit),只是书写形式不同。
11.4.8 Builder 的互斥逻辑
java
// DefaultToolCallingManager.java:432-451
public Builder maxCallsPerTool(String toolName, int maxCallsPerTool) {
Assert.hasText(toolName, "toolName cannot be null or empty");
Assert.isTrue(maxCallsPerTool > 0, "maxCallsPerTool must be greater than 0");
this.maxCallsPerTool.put(toolName, maxCallsPerTool);
this.toolsExcludedFromLimit.remove(toolName);
return this;
}
public Builder excludeToolFromLimit(String toolName) {
Assert.hasText(toolName, "toolName cannot be null or empty");
this.toolsExcludedFromLimit.add(toolName);
this.maxCallsPerTool.remove(toolName);
return this;
}
两个方法互相清理对方的记录,保证一个工具不会同时"有上限"和"被豁免"。这意味着顺序敏感:
java
builder.maxCallsPerTool("a", 5).excludeToolFromLimit("a"); // 结果:a 被豁免
builder.excludeToolFromLimit("a").maxCallsPerTool("a", 5); // 结果:a 上限 5
后调用者胜。 虽然这符合直觉,但它是一个隐式的"最后写入生效"语义,没有任何日志提示。如果你在多个 @Bean 方法里配置同一个 builder(例如通过 BeanPostProcessor),可能产生意料之外的结果。
另一个 Assert :Assert.isTrue(maxCallsPerTool > 0, "maxCallsPerTool must be greater than 0") ------ 不能传 0 或负数 。想表达"无限"只能用 unlimitedCallsPerTool() 或 -1(走属性映射层)。直接调 builder().maxCallsPerTool(0) 会抛 IllegalArgumentException。
11.4.9 预算机制的五个陷阱汇总
| # | 陷阱 | 触发条件 | 后果 | 规避 |
|---|---|---|---|---|
| 1 | 预算是"调用次数"不是"轮数" | 并行工具调用 | 总量 150 可能比单工具 40 先到 | 按业务估算时乘上并行度 |
| 2 | turn 边界依赖最后一条 UserMessage |
工具循环内插入 UserMessage |
预算被静默重置 | 循环内不要插入 UserMessage |
| 3 | 无 UserMessage 时扫全历史 |
纯 system/assistant 构造 | 预算跨轮累加不重置 | 确保每轮有 UserMessage |
| 4 | THROW 丢弃批内剩余工具 |
并行 5 个,第 3 个超限 | 第 4、5 个不执行 | 需要"尽力完成"时用 RETURN_ERROR_RESPONSE |
| 5 | 超限消息是英文硬编码 | THROW 模式 |
用户看到英文 | advisor 层拦截 finishReason == "toolCallLimitExceeded" 并翻译 |
11.5 ToolCallingAdvisor 的阻塞循环
现在进入循环的本体。
11.5.1 类声明与 order
java
// spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/ToolCallingAdvisor.java:65
public class ToolCallingAdvisor implements CallAdvisor, StreamAdvisor, ToolAdvisor {
三个接口:CallAdvisor(阻塞)、StreamAdvisor(流式)、ToolAdvisor(标记)。注意它不是 BaseAdvisor ------第 05 章讲过 BaseAdvisor 是同时提供 before/after 钩子的"环绕"抽象,而 ToolCallingAdvisor 直接实现两个具体接口,自己管理循环。
java
// ToolCallingAdvisor.java:69-75
/**
* Default advisor order. Higher than
* {@link org.springframework.ai.chat.client.advisor.api.Advisor#DEFAULT_CHAT_MEMORY_PRECEDENCE_ORDER}
* (+200) so that memory advisors at their default order are placed outside this
* advisor and do not participate in each tool-call iteration.
*/
public static final int DEFAULT_ORDER = Ordered.HIGHEST_PRECEDENCE + 300;
Javadoc 解释了为什么是 +300 而不是别的:要高于 memory advisor 的 +200,目的是"让 memory advisor 位于本 advisor 之外,不参与每一次工具迭代"。
注意这里 Javadoc 的说法和 11.1.4 里 hasDownstreamMemoryAdvisor 的判断方向是相反的 :Javadoc 说"+300 高于 +200,所以 memory 在外层",而代码说"a.getOrder() > configuredOrder 才算 downstream(内层)"。
这两个说法的统一需要澄清第 05 章的结论:
- advisor 链是栈 ,
OrderComparator.sort()升序 +addLast+pop()消费。 - order 越小 → 越先入栈 → 越晚被 pop → 越"外层"(包裹别人)。
- 所以
memory(200) < tool(300)→ memory 外层,tool 内层。
那 Javadoc 说的 "memory advisors at their default order are placed outside this advisor" 是对的。
而 hasDownstreamMemoryAdvisor 的判断 a.getOrder() > configuredOrder(即 memory order > 300)意味着"memory 在 tool 内层"------这和"默认情况"相反。
结论 :默认情况下(memory=200,tool=300),memory 在外层 ,不参与每轮迭代,所以 conversationHistoryEnabled 保持 true(工具 advisor 自己维护历史)。只有当你把 memory 的 order 调到 > 300(像集成测试里的 .order(1000)),memory 才进入循环内层、每轮都跑,此时才需要 disableInternalConversationHistory()。
11.12 节会给出完整的位置图和推演。
11.5.2 adviseCall 的完整源码与逐段解析
java
// ToolCallingAdvisor.java:122-220
@Override
public ChatClientResponse adviseCall(ChatClientRequest chatClientRequest, CallAdvisorChain callAdvisorChain) {
Assert.notNull(callAdvisorChain, "callAdvisorChain must not be null");
Assert.notNull(chatClientRequest, "chatClientRequest must not be null");
ChatOptions options = chatClientRequest.prompt().getOptions();
if (!(options instanceof ToolCallingChatOptions toolCallingChatOptions)) {
// No tool calling options - skip the tool calling advisor
return callAdvisorChain.nextCall(chatClientRequest);
}
chatClientRequest = this.doInitializeLoop(chatClientRequest, callAdvisorChain);
var instructions = chatClientRequest.prompt().getInstructions();
// Always the complete history for this turn, independent of what
// doGetNextInstructionsForToolCall trims `instructions` down to for forwarding
// to the rest of the chain (e.g. for a memory advisor nested inside the loop
// that doesn't itself de-duplicate). This is what the ToolCallingManager sees,
// so trimming the forwarded prompt never hides earlier rounds from its tool
// call limit counting.
var fullTurnHistory = instructions;
ChatClientResponse chatClientResponse = null;
UsageAccumulator usageAccumulator = new UsageAccumulator();
boolean isToolCall = false;
do {
// Before Call
var processedChatClientRequest = ChatClientRequest.builder()
.prompt(new Prompt(instructions, toolCallingChatOptions))
.context(chatClientRequest.context())
.build();
// Next Call
processedChatClientRequest = this.doBeforeCall(processedChatClientRequest, callAdvisorChain);
// Re-read the options doBeforeCall() may have mutated (e.g. a subclass
// injecting per-iteration tool callbacks) so executeToolCalls() below sees
// what was actually sent to the model, not the pre-doBeforeCall snapshot.
toolCallingChatOptions = (ToolCallingChatOptions) processedChatClientRequest.prompt().getOptions();
Assert.notNull(toolCallingChatOptions,
"redundant check that should never fail (doBeforeCall must not change the options type away from ToolCallingChatOptions), but here to help NullAway");
chatClientResponse = callAdvisorChain.copy(this).nextCall(processedChatClientRequest);
chatClientResponse = this.doAfterCall(chatClientResponse, callAdvisorChain);
// After Call
ChatResponse chatResponse = chatClientResponse.chatResponse();
usageAccumulator.addRoundResponse(chatResponse);
isToolCall = this.toolExecutionEligibilityChecker.isToolCallResponse(chatResponse);
if (isToolCall) {
Assert.notNull(chatResponse, "redundant check that should never fail, but here to help NullAway");
ToolExecutionResult toolExecutionResult;
try {
toolExecutionResult = this.toolCallingManager
.executeToolCalls(new Prompt(fullTurnHistory, toolCallingChatOptions), chatResponse);
}
catch (ToolCallLimitExceededException ex) {
// Return the breach as a single generation - directly to the
// application client instead of looping back to the LLM.
chatClientResponse = chatClientResponse.mutate()
.chatResponse(ChatResponse.builder()
.from(chatResponse)
.generations(List.of(ex.buildGeneration()))
.build())
.build();
break;
}
fullTurnHistory = toolExecutionResult.conversationHistory();
if (toolExecutionResult.returnDirect()) {
// Return tool execution result directly to the application client
// instead of returning it to the LLM.
chatClientResponse = chatClientResponse.mutate()
.chatResponse(ChatResponse.builder()
.from(chatResponse)
.generations(ToolExecutionResult.buildGenerations(toolExecutionResult))
.build())
.build();
break;
}
instructions = this.doGetNextInstructionsForToolCall(processedChatClientRequest, chatClientResponse,
toolExecutionResult);
}
}
while (isToolCall); // loop until no tool calls are present
chatClientResponse = usageAccumulator.applyAccumulatedUsage(chatClientResponse);
return this.doFinalizeLoop(chatClientResponse, callAdvisorChain);
}
11.5.3 第 127-131 行:类型闸门
java
ChatOptions options = chatClientRequest.prompt().getOptions();
if (!(options instanceof ToolCallingChatOptions toolCallingChatOptions)) {
// No tool calling options - skip the tool calling advisor
return callAdvisorChain.nextCall(chatClientRequest);
}
如果 options 不是 ToolCallingChatOptions,整个工具 advisor 变成一个透传。 这是"模型不支持工具调用"的优雅降级路径------Javadoc 明说(:56-59):
When the prompt options do not implement
ToolCallingChatOptions(e.g. for models that don't support tool calling), this advisor passes the request through to the next advisor without modification.
注意这里用的是 nextCall 而不是 copy(this).nextCall ------ 因为不进入循环,直接走一次即可。
陷阱 :什么情况下 options 会不是 ToolCallingChatOptions?DefaultChatClientUtils 会把 ChatModel.getDefaultOptions() 与运行时 options 合并(第 03 章讲过 ChatOptions 的合并规则)。如果模型的默认 options 是一个普通 ChatOptions (例如某些 ChatModel 实现返回 ChatOptions.builder().build()),而你运行时没传任何工具相关配置,那么合并结果可能不是 ToolCallingChatOptions,于是工具 advisor 被跳过。
而自动注册保证了 advisor 一定在链上 (11.1.4),所以这个跳过是"静默的"------你以为配了工具,实际上 advisor 直接透传,模型收不到 tool definitions(resolveToolDefinitions 也拿不到 callbacks),工具永远不会被调用。没有任何日志。
排查方法 :在 advisor 链里加一个打印 prompt.getOptions().getClass().getName() 的 advisor,确认它是 DefaultToolCallingChatOptions(或供应商的子类)。
11.5.4 第 135-142 行:instructions 与 fullTurnHistory 的双变量分裂
java
var instructions = chatClientRequest.prompt().getInstructions();
// Always the complete history for this turn, independent of what
// doGetNextInstructionsForToolCall trims `instructions` down to for forwarding
// to the rest of the chain (e.g. for a memory advisor nested inside the loop
// that doesn't itself de-duplicate). This is what the ToolCallingManager sees,
// so trimming the forwarded prompt never hides earlier rounds from its tool
// call limit counting.
var fullTurnHistory = instructions;
这是整个循环里最微妙的设计。 两个变量初始值相同,但走的路完全不同:
| 变量 | 用途 | 更新时机 | 谁在用 |
|---|---|---|---|
instructions |
发给链下游(模型)的消息 | 每轮末尾由 doGetNextInstructionsForToolCall 重算 |
new Prompt(instructions, ...) → nextCall |
fullTurnHistory |
完整历史 | 每轮由 toolExecutionResult.conversationHistory() 覆盖 |
executeToolCalls(new Prompt(fullTurnHistory, ...)) |
为什么要分裂? Javadoc 给了答案:
This is what the
ToolCallingManagersees, so trimming the forwarded prompt never hides earlier rounds from its tool call limit counting.
问题场景 :假设你在循环内层放了一个 memory advisor(order > 300),它每轮都会往历史里塞完整对话。如果你只有一个变量,doGetNextInstructionsForToolCall 为了减少 token 把历史裁剪成 [system, lastToolResponse](这正是 conversationHistoryEnabled=false 时的行为,见 11.5.7),那么下一轮 executeToolCalls 拿到的 prompt 就只有 2 条消息------countPriorToolCalls 扫不到之前轮次的工具调用,预算计数被重置。
分裂之后 :fullTurnHistory 永远是 toolExecutionResult.conversationHistory()(完整累积,每轮 +2),预算计数正确;instructions 可以被裁剪以省 token,只影响发给模型的内容。
代价 :fullTurnHistory 每轮增长 2 条,而 instructions 可能只有 2 条。这造成了一个不对称------发给模型的是裁剪版,但工具管理器看到的是完整版。 如果工具本身依赖历史内容(例如工具从 ToolContext 里读历史),它看到的是 buildToolContext(prompt) 里 options 的 toolContext,不是历史------不受影响。
这个分裂是 2.0.1 的修复 (从 @since 和注释的口吻可以看出是后来加的)。如果你在 2.0.0 上遇到过"工具预算莫名其妙不生效",这就是原因。
11.5.5 第 167 行:copy(this) ------ 循环的存在前提
java
chatClientResponse = callAdvisorChain.copy(this).nextCall(processedChatClientRequest);
这行代码是本章标题问题的答案所在。 为什么不能直接用 callAdvisorChain.nextCall(...)?
回顾第 05 章的栈式链:
java
// DefaultAroundAdvisorChain.java:97-121
@Override
public ChatClientResponse nextCall(ChatClientRequest chatClientRequest) {
Assert.notNull(chatClientRequest, "the chatClientRequest cannot be null");
if (this.callAdvisors.isEmpty()) {
throw new IllegalStateException("No CallAdvisors available to execute");
}
var advisor = this.callAdvisors.pop();
// ... observation
return ... advisor.adviseCall(chatClientRequest, this) ...;
}
pop() 会移除元素。 而 callAdvisors 是 Deque<CallAdvisor>,构造时用 List.copyOf(callAdvisors) 存了一份快照到 originalCallAdvisors(:87)。
推演一次不带 copy 的循环:
arduino
初始 deque: [ToolCallingAdvisor(300), ChatModelCallAdvisor(LOWEST)]
← 栈顶(pop 先出)
轮1: nextCall → pop() 取出 ToolCallingAdvisor → deque 变成 [ChatModelCallAdvisor]
ToolCallingAdvisor.adviseCall 内部:
nextCall → pop() 取出 ChatModelCallAdvisor → deque 变成 []
→ 模型调用完成,返回
ToolCallingAdvisor 决定再循环一次:
轮2: nextCall → deque 为空 → throw IllegalStateException("No CallAdvisors available to execute")
第二轮直接崩。 因为栈已经被第一轮的两个 pop 耗尽了。
copy(this) 做了什么?
java
// DefaultAroundAdvisorChain.java:168-195
@Override
public CallAdvisorChain copy(CallAdvisor after) {
return this.copyAdvisorsAfter(this.getCallAdvisors(), after);
}
@Override
public StreamAdvisorChain copy(StreamAdvisor after) {
return this.copyAdvisorsAfter(this.getStreamAdvisors(), after);
}
private DefaultAroundAdvisorChain copyAdvisorsAfter(List<? extends Advisor> advisors, Advisor after) {
Assert.notNull(after, "The after advisor must not be null");
Assert.notNull(advisors, "The advisors must not be null");
int afterAdvisorIndex = advisors.indexOf(after);
if (afterAdvisorIndex < 0) {
throw new IllegalArgumentException("The specified advisor is not part of the chain: " + after.getName());
}
var remainingStreamAdvisors = advisors.subList(afterAdvisorIndex + 1, advisors.size());
return DefaultAroundAdvisorChain.builder(this.getObservationRegistry())
.observationConvention(this.observationConvention)
.pushAll(remainingStreamAdvisors)
.build();
}
关键三步:
this.getCallAdvisors()返回originalCallAdvisors(:198-200),即构造时的完整快照 ,不受pop()影响。advisors.indexOf(after)找到this(当前 advisor)在列表中的位置。advisors.subList(afterAdvisorIndex + 1, size)取它后面 的所有 advisor,用它们新建一个DefaultAroundAdvisorChain。
所以 copy(this) 的语义是:"给我一条新的链,里面只有排在我之后的那些 advisor。"
为什么必须排除自己? 因为如果包含自己,下一轮 nextCall 又会 pop 出 ToolCallingAdvisor,它的 adviseCall 又调 copy(this).nextCall → 无限递归(栈溢出)。
推演带 copy 的循环:
ini
originalCallAdvisors: [MemoryAdvisor?, ToolCallingAdvisor(300), ...下游..., ChatModelCallAdvisor]
index: ↑ 假设 index=1
轮1: ToolCallingAdvisor.adviseCall 被 nextCall 触发(外层链的 pop)
copy(this) → new chain([下游..., ChatModelCallAdvisor])
newChain.nextCall → pop 下游1 → ... → pop ChatModelCallAdvisor → 模型调用
返回,isToolCall=true
轮2: copy(this) → **又新建**一条 new chain([下游..., ChatModelCallAdvisor]) ← 关键:全新的 deque
newChain.nextCall → 同上
轮3: ...
每一轮都新建一条链。 这保证了:
- 每轮的 deque 都是完整的(下游 advisor 全部参与)。
- 每轮结束后 deque 被耗尽,但下一轮用的是新 deque,互不影响。
- 不会产生无限递归(自己被排除)。
代价 :每轮 new 一个 DefaultAroundAdvisorChain + 一次 OrderComparator.sort()(pushAll → reOrder,DefaultAroundAdvisorChain.java:269、:277-287)。对于一个 10 轮的工具循环,这是 10 次排序 + 10 个对象分配。成本可忽略。
一个重要的副作用 :copyAdvisorsAfter 用 advisors.indexOf(after) ------ 依赖 equals()。而 Advisor 接口没有规定 equals 默认实现,所以用的是 Object.equals(引用相等)。由于 this 就是链里的那个实例(同一个对象引用),indexOf 能找到。但如果你在链里放了同一个 advisor 的两个不同实例 (例如两个 ToolCallingAdvisor,虽然会被 validateSingleToolAdvisor 拦下),indexOf 返回第一个匹配。
如果 indexOf 返回 -1 (advisor 不在链里),抛 IllegalArgumentException("The specified advisor is not part of the chain: " + after.getName())。什么时候会这样?如果你手动构造了一个 advisor 链并直接调 adviseCall(不走 DefaultChatClient),而传入的 chain 不包含这个 advisor。
11.5.6 第 158-165 行:options 的"重新读取"
java
processedChatClientRequest = this.doBeforeCall(processedChatClientRequest, callAdvisorChain);
// Re-read the options doBeforeCall() may have mutated (e.g. a subclass
// injecting per-iteration tool callbacks) so executeToolCalls() below sees
// what was actually sent to the model, not the pre-doBeforeCall snapshot.
toolCallingChatOptions = (ToolCallingChatOptions) processedChatClientRequest.prompt().getOptions();
Assert.notNull(toolCallingChatOptions,
"redundant check that should never fail (doBeforeCall must not change the options type away from ToolCallingChatOptions), but here to help NullAway");
这段代码是为了支持"每轮动态注入工具"的场景。
doBeforeCall 是一个 protected 钩子(11.9 节),子类可以修改 request。ToolSearchToolCallingAdvisor 就是用它把"本轮搜索到的工具"注入到 options 的 toolCallbacks 里(11.10 节)。
如果没有这段重新读取 ,executeToolCalls 拿到的 toolCallingChatOptions 是 doBeforeCall 之前的快照------模型看到了新工具(因为发给模型的 prompt 用的是 processedChatClientRequest),但执行时按旧工具列表查找 → No ToolCallback found。
这是一个"读改写顺序"的经典陷阱,注释里明确点出了 "not the pre-doBeforeCall snapshot"。
同时注意 toolCallingChatOptions 是一个被循环复用的变量 (第 128 行的 pattern matching 绑定,然后在 163 行被重新赋值)。Java 的 pattern matching 变量在 if 块外可用(因为 if 不成立时 return),所以这里能重新赋值。但这也意味着:如果你在第 1 轮把 options 换成了别的实例,第 2 轮开头的 new Prompt(instructions, toolCallingChatOptions) 用的是第 1 轮末尾的版本。 这是有意的(保持连续性)。
11.5.7 doGetNextInstructionsForToolCall:历史裁剪策略
java
// ToolCallingAdvisor.java:222-234
protected List<Message> doGetNextInstructionsForToolCall(ChatClientRequest chatClientRequest,
ChatClientResponse chatClientResponse, ToolExecutionResult toolExecutionResult) {
if (!this.conversationHistoryEnabled) {
List<Message> history = toolExecutionResult.conversationHistory();
if (history.isEmpty()) {
return history;
}
return List.of(chatClientRequest.prompt().getSystemMessage(), history.get(history.size() - 1));
}
return toolExecutionResult.conversationHistory();
}
两个分支:
(a) conversationHistoryEnabled == true(默认) :返回完整历史(previous + assistant + toolResponse,每轮 +2)。
(b) conversationHistoryEnabled == false :只返回 [systemMessage, 最后一条消息]。
分支 (b) 的语义非常激进:它把历史砍到只剩 system + 最后一条 tool response。这意味着下一轮发给模型的消息是:
csharp
[system, toolResponse(最新)]
中间的 user 消息、之前轮次的 assistant/tool 消息全部消失。 这看起来会丢信息------但它是为"外层有 memory advisor 负责历史"的场景设计的:既然 memory advisor 会从 ChatMemory 里重新加载完整历史,那么这里只需要把"本轮新增的内容"传下去,让 memory advisor 存进去。
理解这一点的关键是 memory advisor 的工作方式 (第 06 章讲过):MessageChatMemoryAdvisor 在 before 阶段把 ChatMemory 里的历史加到 request 的 instructions 前面,在 after 阶段把本轮的 user + assistant 消息存进 memory。所以如果 memory advisor 在循环内层(order > 300),它每轮都会:
before:从 memory 读历史 + 拼接当前 instructions。after:把本轮内容存回 memory。
如果工具 advisor 每轮都传完整历史(分支 a),memory advisor 再拼一次 → 历史重复。分支 (b) 就是为了避免这个重复:只传"本轮新增",让 memory advisor 负责完整历史。
陷阱 :分支 (b) 用 chatClientRequest.prompt().getSystemMessage()。如果 prompt 没有 system message,这个方法返回什么?需要看 Prompt.getSystemMessage() 的实现------它通常返回一个默认的空 SystemMessage 或 null。如果返回 null,List.of(null, msg) 会抛 NullPointerException。
这是一个需要验证的边界。复现命令:
shell
grep -n "getSystemMessage" spring-ai-model/src/main/java/org/springframework/ai/chat/prompt/Prompt.java
(本章不展开,留给读者验证;实践建议是在使用 disableInternalConversationHistory() 时始终配置 system message。)
11.5.8 循环的三个出口
do-while 有四个退出路径:
| 出口 | 位置 | 条件 | 最终响应 |
|---|---|---|---|
| ① 自然结束 | while (isToolCall) |
模型本轮没返回 tool call | 模型的最终回答 |
| ② 预算超限 | :184-194 |
ToolCallLimitExceededException |
breach 单 generation |
| ③ returnDirect | :198-209 |
toolExecutionResult.returnDirect() |
工具原始输出(buildGenerations) |
| ④ 异常 | 任意 | 其他异常(如 No ToolCallback found) |
异常冒泡,无 catch |
路径 ④ 是唯一的"不优雅"出口 :IllegalStateException("No ToolCallback found for tool name: xxx") 会直接冒到调用方,不会被转换成一个 generation。这也意味着前面几轮已经消耗的 token 全部白费。
配方 (11.14 节展开):在生产环境应当用自定义 ToolCallingManager 或包装 advisor 捕获这类异常并转成友好响应。
11.5.9 收尾:usage 累加与 finalize
java
chatClientResponse = usageAccumulator.applyAccumulatedUsage(chatClientResponse);
return this.doFinalizeLoop(chatClientResponse, callAdvisorChain);
两行,顺序重要:先累加 usage,再调 doFinalizeLoop 。所以子类在 doFinalizeLoop 里看到的是已经累加过的 usage。11.7 节详述累加算法。
11.6 本章陷阱总表
| # | 陷阱 | 位置 | 后果 | 规避 |
|---|---|---|---|---|
| 1 | 模型层默认 ToolCallingManager 无 resolver、无真实 registry |
OpenAiChatModel:1628、AnthropicChatModel:169 |
手搓模型时 schema 解析能力退化 | 用容器 Bean 或显式传 manager |
| 2 | ToolCallingAdvisor.builder().build() 的 manager 用 NOOP registry |
ToolCallingAdvisor:494 |
工具 span 全部丢失 | Boot 场景用容器 manager |
| 3 | 多个 ObservationRegistry → 静默降级 NOOP |
ToolCallingAutoConfiguration:142 |
无提示地失去观测 | 保证 registry 唯一 |
| 4 | 多候选时只有第一个带 tool call 的 generation 被执行 | DefaultToolCallingManager:182-185 |
其余候选的 tool call 静默丢弃 | 不要用 n>1 配工具 |
| 5 | 自定义 checker 比 hasToolCalls() 宽 → IllegalStateException |
DefaultToolCallingManager:187-189 |
崩溃 | checker 必须蕴含"有 tool call" |
| 6 | arguments 为空 → {} |
:275-284 |
必填参数校验失败(降级为工具报错) | 工具参数尽量设默认值 |
| 7 | Consumer 返回 null → "" |
:337-338 |
模型误判为失败并重试 | 用 String 或 void |
| 8 | 找不到工具时抛 IllegalStateException,不会被转成 generation |
:291-297 |
前面几轮的 token 白费 | 自定义 manager 或包装 advisor 兜底 |
| 9 | MCP provider 被排除出 resolver | ToolCallingAutoConfiguration:96-99 |
resolutionFallbackEnabled 对 MCP 工具无效 |
MCP 工具必须显式挂在请求上 |
| 10 | 预算按"调用次数"而非"轮数" | ToolCallLimits:113-129 |
并行调用下总量先到 | 估算成本时乘并行度 |
| 11 | turn 边界 = 最后一条 UserMessage |
ToolCallLimits:98-105 |
循环内插入 UserMessage 会重置预算 | 循环内不插 UserMessage |
| 12 | 无 UserMessage 时扫全历史 |
ToolCallLimits:104 |
预算跨轮不重置 | 保证每轮有 UserMessage |
| 13 | THROW 丢弃批内剩余工具 |
DefaultToolCallingManager:252-264 |
已完成工作被中断 | 需要尽力完成时用 RETURN_ERROR_RESPONSE |
| 14 | 超限消息英文硬编码 | ToolCallLimits:118、:125 |
中文产品体验割裂 | 拦截 finishReason=toolCallLimitExceeded 翻译 |
| 15 | -1 在三处配置里语义不同 |
ToolCallingAutoConfiguration:147-178 |
配置错语义 | 见 11.4.7 对照表 |
| 16 | options 不是 ToolCallingChatOptions → advisor 静默透传 |
ToolCallingAdvisor:127-131 |
工具完全不生效且无日志 | 检查 options 实际类型 |
| 17 | 不用 copy(this) → 第二轮 IllegalStateException |
DefaultAroundAdvisorChain:101-105 |
循环崩溃 | 递归 advisor 必须 copy(this) |
| 18 | disableInternalConversationHistory() 后历史只剩 system + 最后一条 |
ToolCallingAdvisor:225-231 |
缺 system message 可能 NPE | 必配 system message |
| 19 | returnDirect 是 AND 归约 |
DefaultToolCallingManager:299-304 |
批内一个 false → 全批 false | 需要"发完就停"时用自定义 checker |
11.7 本章小结
-
循环的位置变了。
internalToolExecutionEnabled在 2.0.1 里零命中;六个ChatModel对ToolCallingManager只有一行调用:resolveToolDefinitions。执行权 100% 在ToolCallingAdvisor。 -
ToolCallingManager被两个世界使用。 模型层只用resolveToolDefinitions(每轮一次),advisor 只用executeToolCalls(有 tool call 时每轮一次)。DefaultToolCallingManager是final,所以供应商只能委托(Google 的做法)。 -
executeToolCalls是无状态的。 它的预算计数靠扫描prompt.getInstructions()重建(countPriorToolCalls),turn 边界是最后一条UserMessage。这既解决了"ChatMemory 重放虚增计数"的问题,也引入了"循环内插 UserMessage 会重置预算"的陷阱。 -
预算是 40(单工具)/ 150(单 turn 总量),按调用次数计。 默认
THROW:批内剩余工具不执行,breach 作为唯一 generation 直接返回用户,finishReason = "toolCallLimitExceeded"。已完成的工作被塞进 metadata 的partialToolResponses,不污染 generation 列表。 -
copy(this)是循环的物理前提。 advisor 链是栈、nextCall用pop()消费,一次调用就把栈弹空;copy(this)从originalCallAdvisors快照里取"排在我之后的 advisor"重建一条新链,既保证每轮下游 advisor 全部参与,又排除自己避免无限递归。 -
阻塞循环里有
instructions与fullTurnHistory的分裂。 前者发给模型(可被裁剪省 token),后者给ToolCallingManager(完整累积,保证预算计数正确)。这个分裂是 2.0.1 的修复点。 -
四个退出口 :自然结束、预算超限(
break)、returnDirect(break)、异常冒泡。前三个都产生"优雅"的响应,第四个是唯一会浪费前面所有 token 的路径。
11.8 思考题
-
如果把
spring.ai.tools.limits.on-limit-exceeded改成RETURN_ERROR_RESPONSE,单工具上限设为 1,模型在一轮里并行请求了 3 次getCurrentWeather,最终会发生什么?模型会看到几条 tool response?循环会继续吗? -
ToolCallLimits.countPriorToolCalls每轮都扫一遍完整历史。如果你的工具循环跑了 50 轮(历史 100 条消息),这个扫描的总复杂度是多少?在什么规模下它会成为瓶颈? -
为什么
copy(this)必须排除自己(subList(afterAdvisorIndex + 1, size))?如果改成subList(afterAdvisorIndex, size)(包含自己),会发生什么?请在本地构造一个最小复现。 -
你的团队要求"工具调用超过 10 轮就必须停下来问用户"。用本章学到的哪个扩展点实现最合适?
ToolCallLimits能做吗?为什么? -
DefaultToolCallingManager是final的,但ToolCallingAdvisor不是。如果你想在每次工具执行前后加审计日志,有哪三条路?各自的代价是什么? -
假设你在工具循环内层放了一个
MessageChatMemoryAdvisor(order = 1000),并调用了disableInternalConversationHistory()。请推演第 3 轮时ToolCallingManager.executeToolCalls收到的 prompt 里有哪些消息、ChatModel收到的 prompt 里有哪些消息。
11.9 本章文件索引
| 文件 | 关键行 | 内容 |
|---|---|---|
spring-ai-model/src/main/java/org/springframework/ai/model/tool/ToolCallingManager.java |
31-50 | 接口:两个方法 |
spring-ai-model/.../model/tool/DefaultToolCallingManager.java |
66 / 92-107 / 127-165 / 167-175 / 177-205 / 207-216 / 221-343 / 345-351 / 364-505 | 实现全貌、常量、构造函数、resolve、execute、context、主循环、历史拼装、Builder |
spring-ai-model/.../model/tool/ToolCallLimits.java |
37-135 | 预算:turn 定义、计数重建、check、Breach |
spring-ai-model/.../model/tool/ToolCallLimitBehavior.java |
26-42 | THROW / RETURN_ERROR_RESPONSE |
spring-ai-model/.../model/tool/ToolCallLimitExceededException.java |
40-140 | 异常与 buildGeneration() |
spring-ai-model/.../model/tool/ToolExecutionResult.java |
34-86 | returnDirect 常量与 buildGenerations |
spring-ai-model/.../model/tool/ToolExecutionEligibilityChecker.java |
31-42 | 终止判定接口(第 12 章展开) |
spring-ai-model/.../model/tool/internal/ToolCallReactiveContextHolder.java |
30-50 | 流式观测父上下文桥 |
spring-ai-model/.../model/tool/ToolCallingChatOptions.java |
39-164 | options:只有 getToolCallbacks(),getToolNames() 不存在 |
spring-ai-model/.../tool/observation/ToolCallingObservationContext.java |
36-158 | 工具 span 上下文 |
spring-ai-model/.../tool/observation/ToolCallingObservationDocumentation.java |
32-160 | span 名与低/高基数 key |
spring-ai-client-chat/.../advisor/ToolCallingAdvisor.java |
65 / 69-75 / 122-220 / 222-234 / 236-252 / 492-643 | 类声明、order、adviseCall、历史裁剪、钩子、Builder |
spring-ai-client-chat/.../advisor/ToolCallAdvisor.java |
22-63 | 废弃子类 |
spring-ai-client-chat/.../advisor/api/ToolAdvisor.java |
19-35 | 空标记接口 |
spring-ai-client-chat/.../advisor/DefaultAroundAdvisorChain.java |
97-121 / 168-195 / 277-287 | pop()、copy()、reOrder() |
spring-ai-client-chat/.../advisor/UsageAccumulator.java |
62-177 | usage 累加(第 12 章展开) |
spring-ai-client-chat/.../DefaultChatClient.java |
1176-1203 / 1217-1248 | 链的构建、自动注册、唯一性校验 |
spring-ai-client-chat/.../DefaultChatClientBuilder.java |
74-112 | 默认 tool advisor builder |
spring-ai-client-chat/.../AdvisorParams.java |
59-87 | 自动注册开关 |
models/spring-ai-openai/.../OpenAiChatModel.java |
154 / 879 / 1628-1632 | 模型层只用 resolve |
models/spring-ai-anthropic/.../AnthropicChatModel.java |
169 / 860 | 同上 |
models/spring-ai-google-genai/.../GoogleGenAiToolCallingManager.java |
46-99 | 委托式自定义 manager |
auto-configurations/models/tool/.../ToolCallingAutoConfiguration.java |
57-224 | resolver / processor / manager Bean |
auto-configurations/models/tool/.../ToolCallingProperties.java |
37-206 | spring.ai.tools.* 属性 |
spring-ai-support(UsageCalculator) |
32-150+ | usage 算术(第 12 章展开) |
spring-ai-test/.../AbstractToolCallingAdvisorIT.java |
82-319 | 行为契约测试 |
11.10 复核命令
shell
cd D:/CODE/spring-ai-2.0.1
# 1) 证明 internalToolExecutionEnabled 已彻底消失
grep -rn "internalToolExecutionEnabled" --include=*.java . | grep -v "/target/"
# 2) 模型层只调 resolveToolDefinitions
grep -rn "toolCallingManager\." --include=*.java models/ | grep -v "/target/" | grep -v "/src/test/"
# 3) 证明 getToolNames 已不存在
grep -rn "getToolNames\|toolNames(" --include=*.java spring-ai-model/src/main/java/
# 4) 预算常量
sed -n '92,107p' spring-ai-model/src/main/java/org/springframework/ai/model/tool/DefaultToolCallingManager.java
# 5) turn 切片逻辑
sed -n '67,105p' spring-ai-model/src/main/java/org/springframework/ai/model/tool/ToolCallLimits.java
# 6) copy() 的实现
sed -n '168,195p' spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/DefaultAroundAdvisorChain.java
# 7) 自动注册与唯一性校验
sed -n '1189,1248p' spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/DefaultChatClient.java
# 8) 工具 advisor 的 order 常量
sed -n '69,75p' spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/ToolCallingAdvisor.java
# 9) 所有 ToolExecutionEligibilityChecker 的使用点
grep -rn "ToolExecutionEligibilityChecker" --include=*.java . | grep -v "/target/" | grep -v "/src/test/"
# 10) MCP provider 被排除出 resolver
sed -n '96,113p' auto-configurations/models/tool/spring-ai-autoconfigure-model-tool/src/main/java/org/springframework/ai/model/tool/autoconfigure/ToolCallingAutoConfiguration.java
11.11 下一章预告
本章覆盖的是阻塞链路 的工具循环。流式循环(concatWith(Flux.defer(...)) 递归与中间轮 chunk 的过滤)、UsageAccumulator / UsageCalculator 的跨轮 usage 累加、ToolExecutionEligibilityChecker 自定义终止条件、六个 protected 钩子与 ToolSearchToolCallingAdvisor 子类化范例、工具 span 在 trace 树中的位置、循环与 memory / RAG advisor 的 order 布局,以及八个可投产配方,全部在第 12 章:工具循环的流式形态、Usage 累加与扩展体系。