Spring AI 2.0.1 深度解析-第 12 章 工具循环的流式形态、Usage 累加与扩展体系

Spring AI 2.0.1 深度解析-第 12 章 工具循环的流式形态、Usage 累加与扩展体系

第 11 章把阻塞链路 的工具循环剖到了源码级:循环为什么从模型里搬出来、ToolCallingManager 的两个方法各自被谁调用、40/150 的预算怎么数、copy(this) 为什么不可省略。

本章补齐另外一半:

  1. 流式循环 如何用 concatWith(Flux.defer(...)) 表达递归,以及为什么中间轮次的 chunk 会被 filter 全部吞掉。
  2. 跨轮 usage 累加 :UsageAccumulator + UsageCalculator 的算术、流式的"每 chunk 补一次"策略,以及最后一轮可能额外发出的"只带 usage 的空 chunk"。
  3. 怎么控制循环停不停 :ToolExecutionEligibilityChecker 是唯一的官方扩展点,它同时也是流式 filter 的判据------两者必须自洽。
  4. 怎么扩展循环 :六个 protected 钩子 + ToolSearchToolCallingAdvisor 这个教科书级子类。
  5. 循环在 trace 树和 advisor order 图里的位置:工具 span 挂在哪、memory / RAG 该放循环内还是循环外。

12.0 本章的三句总纲

  1. 流式下,中间轮次对用户完全不可见。 streamWithToolCallResponses 最后一行 filter(ccr -> !checker.isToolCallResponse(...)) 把所有"被判定为工具调用轮"的 chunk 全部丢弃。用户只看到最后一轮的文本。想做"正在调用工具..."的 UI 提示,必须另找通道。
  2. 跨轮 usage 是"事后补账"。 阻塞路径在循环结束后一次性 applyAccumulatedUsage;流式路径没法回头改已发出的 chunk,所以每个 chunk 在发出时就带上"之前所有轮次"的累计量,最后一轮再视情况补一个 usage-only 的空 chunk。
  3. ToolExecutionEligibilityChecker 同时决定两件事 :要不要执行工具(阻塞/流式的循环继续条件)、以及哪些 chunk 被过滤(流式)。这两个语义必须一致,否则会出现"chunk 被吞但工具没执行"或"工具执行了但 chunk 流出"的错乱。

12.1 流式循环:聚合 → 递归 → 过滤

流式路径比阻塞路径复杂得多,但结构是清晰的"三段式"。

12.1.1 入口:为什么用 Flux.defer

java 复制代码
// ToolCallingAdvisor.java:257-277
@Override
public Flux<ChatClientResponse> adviseStream(ChatClientRequest chatClientRequest,
		StreamAdvisorChain streamAdvisorChain) {
	Assert.notNull(streamAdvisorChain, "streamAdvisorChain 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 streamAdvisorChain.nextStream(chatClientRequest);
	}

	return Flux.defer(() -> {
		ChatClientRequest initializedRequest = this.doInitializeLoopStream(chatClientRequest, streamAdvisorChain);
		// Subscription-local accumulator so usage is not shared across subscriptions.
		UsageAccumulator usageAccumulator = new UsageAccumulator();
		List<Message> initialInstructions = initializedRequest.prompt().getInstructions();
		return this.internalStream(streamAdvisorChain, initializedRequest, toolCallingChatOptions,
				initialInstructions, initialInstructions, usageAccumulator);
	});
}

Flux.defer 的作用 :把 usageAccumulator 的创建推迟到每次订阅。Javadoc 注释写明:

Subscription-local accumulator so usage is not shared across subscriptions.

为什么重要? UsageAccumulator 是有状态的(持有一个 accumulated 字段),且 Javadoc 明确说 "An instance is stateful and not thread-safe" (UsageAccumulator.java:36-38)。如果把它作为 advisor 的字段,两次并发订阅会互相污染 usage 计数。

Flux.defer 保证每个订阅者拿到自己的 accumulator。

12.1.2 internalStream:递归的载体

java 复制代码
// ToolCallingAdvisor.java:279-309
private Flux<ChatClientResponse> internalStream(StreamAdvisorChain streamAdvisorChain,
		ChatClientRequest originalRequest, ToolCallingChatOptions toolCallingChatOptions,
		List<Message> instructions, List<Message> fullTurnHistory, UsageAccumulator usageAccumulator) {

	return Flux.deferContextual(contextView -> {
		// Build request with current instructions
		var processedRequest = ChatClientRequest.builder()
			.prompt(new Prompt(instructions, toolCallingChatOptions))
			.context(originalRequest.context())
			.build();

		processedRequest = this.doBeforeStream(processedRequest, streamAdvisorChain);

		// Get a copy of the chain excluding this advisor
		StreamAdvisorChain chainCopy = streamAdvisorChain.copy(this);

		final ChatClientRequest finalRequest = processedRequest;

		Flux<ChatClientResponse> responseFlux = chainCopy.nextStream(processedRequest);

		// Re-read the options doBeforeStream() 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-doBeforeStream snapshot.
		ToolCallingChatOptions processedOptions = (ToolCallingChatOptions) finalRequest.prompt().getOptions();
		Assert.notNull(processedOptions,
				"redundant check that should never fail (doBeforeStream must not change the options type away from ToolCallingChatOptions), but here to help NullAway");

		return streamWithToolCallResponses(responseFlux, finalRequest, fullTurnHistory, streamAdvisorChain,
				originalRequest, processedOptions, usageAccumulator);
	});
}

与阻塞路径的对应关系:

阻塞 流式
doBeforeCall doBeforeStream
callAdvisorChain.copy(this).nextCall(...) streamAdvisorChain.copy(this).nextStream(...)
重新读 options 重新读 options(processedOptions)
do { ... } while concatWith(Flux.defer(...)) 递归
局部变量 instructions / fullTurnHistory 方法参数 instructions / fullTurnHistory

方法参数替代了循环变量 ------这是把迭代改写成递归的标准手法。internalStream 会自己调自己 (:399-400):

java 复制代码
return this.internalStream(streamAdvisorChain, originalRequest, optionsCopy, nextInstructions,
		toolExecutionResult.conversationHistory(), usageAccumulator);

12.1.3 streamWithToolCallResponses:三段式

java 复制代码
// ToolCallingAdvisor.java:311-325
private Flux<ChatClientResponse> streamWithToolCallResponses(Flux<ChatClientResponse> responseFlux,
		ChatClientRequest finalRequest, List<Message> fullTurnHistory, StreamAdvisorChain streamAdvisorChain,
		ChatClientRequest originalRequest, ToolCallingChatOptions optionsCopy, UsageAccumulator usageAccumulator) {

	AtomicReference<ChatClientResponse> aggregatedResponseRef = new AtomicReference<>();
	// Snapshot of the usage accumulated from previous rounds (before this round).
	ChatResponse previousAccumulatedResponse = usageAccumulator.accumulatedResponse();

	return CHAT_CLIENT_MESSAGE_AGGREGATOR.aggregateChatClientResponse(responseFlux, aggregatedResponseRef::set)
		.map(chatClientResponse -> UsageAccumulator.applyPreviousAccumulatedUsageToChunk(chatClientResponse,
				previousAccumulatedResponse))
		.concatWith(Flux.defer(() -> this.handleToolCallRecursion(aggregatedResponseRef.get(), finalRequest,
				fullTurnHistory, streamAdvisorChain, originalRequest, optionsCopy, usageAccumulator)))
		.filter(ccr -> !this.toolExecutionEligibilityChecker.isToolCallResponse(ccr.chatResponse()));
}

三段:

第一段:聚合。 CHAT_CLIENT_MESSAGE_AGGREGATOR.aggregateChatClientResponse(responseFlux, aggregatedResponseRef::set) ------ 把整个 chunk 流聚合,同时把聚合结果存进 AtomicReference。

注意 CHAT_CLIENT_MESSAGE_AGGREGATOR 是 static final 字段 (:67):

java 复制代码
private static final ChatClientMessageAggregator CHAT_CLIENT_MESSAGE_AGGREGATOR = new ChatClientMessageAggregator();

一个 advisor 类的静态单例,被所有实例共享。 第 06 章讲过 ChatClientMessageAggregator 让所有 chunk 共享同一个 context Map(:43、:54)。这里它是否线程安全取决于其内部实现是否也是无状态的------aggregateChatClientResponse 应该是纯函数式(输入 Flux,输出 Flux),静态共享没问题。

关键副作用 :aggregateChatClientResponse 的第二个参数是 Consumer<ChatClientResponse>,用于"顺便"把聚合结果存起来。这意味着聚合发生在订阅时 ,而 concatWith(Flux.defer(...)) 里的 aggregatedResponseRef.get() 要等到第一段完成后才能读到值------Flux.defer 保证了延迟求值。

第二段:usage 修正。 applyPreviousAccumulatedUsageToChunk(chunk, previousAccumulatedResponse) ------ 把之前轮次累计的 usage 加到当前 chunk 上。注意 previousAccumulatedResponse 是在本轮开始前 的快照(:317),所以这里加的是"之前所有轮次"的量,不含本轮。§12.2 详述。

第三段:递归。 concatWith(Flux.defer(() -> handleToolCallRecursion(...))) ------ 第一段结束后,判断是否需要再来一轮。

第四段(容易被忽略):过滤。

java 复制代码
.filter(ccr -> !this.toolExecutionEligibilityChecker.isToolCallResponse(ccr.chatResponse()));

这行是流式工具循环最反直觉的地方。 它把所有被判定为 tool call 的 chunk 全部过滤掉。

为什么? 因为流式下,模型返回 tool call 的那一轮,它的 chunk 内容是工具调用的增量 JSON ({"name": "getCu..., "arguments": "{\"loc...)。这些 JSON 片段对用户毫无意义,而且如果被 ChatClient.stream().content() 直接吐给前端,用户会看到一堆乱码。

所以:中间轮次(tool call 轮)的内容对用户完全不可见,只有最后一轮(普通文本轮)才会流出去。

这个行为的三个后果:

  1. 用户看不到"模型正在调工具"的任何迹象。 如果你想做"正在查询天气..."的 UI 提示,必须在别处(例如自定义 advisor 或前端状态机)实现。
  2. filter 用的是 toolExecutionEligibilityChecker,不是 hasToolCalls()。 所以自定义 checker 的语义直接影响哪些 chunk 被吞掉。如果 checker 返回 true 的范围比"真有 tool call"更宽(例如"stop_reason == tool_use"),那么一些实际上有内容的 chunk 也会被过滤。
  3. 过滤发生在 concatWith 之后 ,所以它会同时作用于第一段的 chunk 和 递归产生的后续 chunk。也就是说:递归里每一轮产出的 chunk 都会被这个 filter 检查一次。 最后一轮不是 tool call → 保留;中间轮是 tool call → 丢弃。

验证: 集成测试的流式用例断言 content.contains("30", "10", "15")(AbstractToolCallingAdvisorIT.java:185),即最终文本里包含三个城市的温度------这是最后一轮模型汇总的输出。测试没有断言中间轮的内容,说明它们确实不可见。

12.1.4 handleToolCallRecursion:递归的决策点

java 复制代码
// ToolCallingAdvisor.java:331-404
private Flux<ChatClientResponse> handleToolCallRecursion(ChatClientResponse aggregatedResponse,
		ChatClientRequest finalRequest, List<Message> fullTurnHistory, StreamAdvisorChain streamAdvisorChain,
		ChatClientRequest originalRequest, ToolCallingChatOptions optionsCopy, UsageAccumulator usageAccumulator) {

	if (aggregatedResponse == null) {
		return Flux.empty();
	}

	aggregatedResponse = this.doAfterStream(aggregatedResponse, streamAdvisorChain);

	ChatResponse chatResponse = aggregatedResponse.chatResponse();
	ChatResponse accumulatedChatResponse = usageAccumulator.addRoundResponse(chatResponse);
	boolean isToolCall = this.toolExecutionEligibilityChecker.isToolCallResponse(chatResponse);

	if (!isToolCall) {
		// No tool call: the final round's chunks have already been streamed. If that
		// round carried no usage of its own, the usage accumulated from previous
		// rounds was never stamped onto any emitted chunk, so emit a trailing
		// usage-only response to keep the aggregated stream usage correct. Otherwise
		// nothing more needs to be emitted.
		Flux<ChatClientResponse> finalEmissions = UsageAccumulator
			.emitFinalUsageCorrectionIfNecessary(aggregatedResponse, chatResponse, accumulatedChatResponse);
		return this.doFinalizeLoopStream(finalEmissions, streamAdvisorChain);
	}

	Assert.notNull(chatResponse, "redundant check that should never fail, but here to help NullAway");
	final ChatClientResponse finalAggregatedResponse = aggregatedResponse;

	// Execute tool calls on bounded elastic scheduler (tool execution is blocking)
	Flux<ChatClientResponse> toolCallFlux = Flux.deferContextual(ctx -> {
		ToolExecutionResult toolExecutionResult;
		try {
			ToolCallReactiveContextHolder.setContext(ctx);
			toolExecutionResult = this.toolCallingManager.executeToolCalls(new Prompt(fullTurnHistory, optionsCopy),
					chatResponse);
		}
		catch (ToolCallLimitExceededException ex) {
			// A configured tool call limit was hit. Return the breach as a single
			// generation - rather than mixing it with any successful calls from the
			// same batch as if they were equally valid alternative answers -
			// directly to the application client instead of looping back to the
			// LLM.
			ChatClientResponse limitResponse = finalAggregatedResponse.mutate()
				.chatResponse(ChatResponse.builder()
					.from(chatResponse)
					.generations(List.of(ex.buildGeneration()))
					.build())
				.build();
			return Flux.just(usageAccumulator.applyAccumulatedUsage(limitResponse));
		}
		finally {
			ToolCallReactiveContextHolder.clearContext();
		}

		if (toolExecutionResult.returnDirect()) {
			// Return tool execution result directly to the application client
			ChatClientResponse directResponse = finalAggregatedResponse.mutate()
				.chatResponse(ChatResponse.builder()
					.from(chatResponse)
					.generations(ToolExecutionResult.buildGenerations(toolExecutionResult))
					.build())
				.build();
			return Flux.just(usageAccumulator.applyAccumulatedUsage(directResponse));
		}
		else {
			// Recursive call with updated conversation history
			List<Message> nextInstructions = this.doGetNextInstructionsForToolCallStream(finalRequest,
					finalAggregatedResponse, toolExecutionResult);
			return this.internalStream(streamAdvisorChain, originalRequest, optionsCopy, nextInstructions,
					toolExecutionResult.conversationHistory(), usageAccumulator);
		}
	});
	return toolCallFlux.subscribeOn(Schedulers.boundedElastic());
}

四个分支:

分支 条件 返回
① aggregatedResponse == null 聚合出空(上游没发任何 chunk) Flux.empty()
② !isToolCall 模型给了最终答案 usage 修正后结束(可能发一个 usage-only chunk)
③ 预算超限 ToolCallLimitExceededException 单 generation(breach)+ 累加 usage
④ returnDirect 工具要求直接返回 buildGenerations + 累加 usage
⑤ 正常递归 都不是 internalStream(...) 再来一轮

注意分支 ③ 和 ④ 里都调了 usageAccumulator.applyAccumulatedUsage(...) ------ 显式补上 usage,因为这两条路径不会走到最后的 emitFinalUsageCorrectionIfNecessary。

分支 ⑤ 的递归 :internalStream(streamAdvisorChain, originalRequest, optionsCopy, nextInstructions, toolExecutionResult.conversationHistory(), usageAccumulator) ------ 注意 usageAccumulator 是同一个实例传下去的,所以在整条递归链上共享(这正是订阅隔离的意义)。

subscribeOn(Schedulers.boundedElastic()) 只对分支 ③④⑤ 的 toolCallFlux 生效。它把工具执行 和递归调用都调度到 boundedElastic。

这里有一个需要注意的点 :internalStream 是在 toolCallFlux 内部被调用的,而 toolCallFlux 被 subscribeOn(boundedElastic) 包裹------所以下一轮的模型调用也在 boundedElastic 线程上。

对于非阻塞的 WebClient 调用,这没坏处(调度到 boundedElastic 后仍然是异步的)。但如果你用的是阻塞的 HTTP 客户端(例如某些供应商 SDK 的同步模式),那么每一轮模型调用都会占一个 boundedElastic 线程直到完成。10 轮循环 = 长时间占用。

boundedElastic 的默认上限是 10 * CPU 核数 ,队列无界。高并发下可能出现线程饥饿。配方 :如果工具循环轮数可能很大,考虑自定义 Schedulers 或在自定义 advisor 里改调度策略。

12.1.5 阻塞式 do-while 与流式递归的等价性证明

把两条路径放在一起对比,可以验证它们在语义上等价:

scss 复制代码
阻塞:                                   流式:
instructions = init                     internalStream(..., instructions, fullTurnHistory, ...)
fullTurnHistory = init                    ├─ aggregate(round)
usage = new Accumulator()                 ├─ map(applyPreviousUsage)
do {                                      ├─ concatWith(defer(() -> {
  req = new Prompt(instructions, opts)    │     agg = doAfterStream(agg)
  req = doBeforeCall(req)                 │     usage.addRoundResponse(agg.chatResponse())
  resp = copy(this).nextCall(req)         │     if (!isToolCall) return finalUsageCorrection
  resp = doAfterCall(resp)                │     result = executeToolCalls(fullTurnHistory, agg)
  usage.addRoundResponse(resp)            │     if (returnDirect) return buildGenerations
  isToolCall = checker(resp)              │     return internalStream(..., next, result.history(), usage)
  if (isToolCall) {                       │   }))
    result = exec(fullTurnHistory, resp)  └─ filter(!isToolCallResponse)
    fullTurnHistory = result.history()
    if (returnDirect) break
    instructions = next(req, resp, result)
  }
} while (isToolCall)
usage.applyAccumulatedUsage(resp)
return doFinalizeLoop(resp)

结构完全对应。 唯一的非对称是:

  • 阻塞路径在所有轮次结束后统一 applyAccumulatedUsage。
  • 流式路径在每个 chunk 上 就应用"之前轮次"的 usage(applyPreviousAccumulatedUsageToChunk),最后一轮再补一次修正。

原因是流式的 chunk 是逐个发出的,没法"事后回头改"。§12.2 详述。

修正一处容易误读的地方 :ChatClientMessageAggregator.aggregateChatClientResponse 返回的 Flux 仍然是原始 chunk 流 ,不是"累积快照流"。看它的实现(spring-ai-client-chat/.../ChatClientMessageAggregator.java:40-55):

java 复制代码
public Flux<ChatClientResponse> aggregateChatClientResponse(Flux<ChatClientResponse> chatClientResponses,
		Consumer<ChatClientResponse> aggregationHandler) {

	AtomicReference<Map<String, Object>> context = new AtomicReference<>(new HashMap<>());

	return new MessageAggregator().aggregate(chatClientResponses.mapNotNull(chatClientResponse -> {
		context.get().putAll(chatClientResponse.context());
		return chatClientResponse.chatResponse();
	}), aggregatedChatResponse -> {
		ChatClientResponse aggregatedChatClientResponse = ChatClientResponse.builder()
			.chatResponse(aggregatedChatResponse)
			.context(context.get())
			.build();
		aggregationHandler.accept(aggregatedChatClientResponse);
	}).map(chatResponse -> ChatClientResponse.builder().chatResponse(chatResponse).context(context.get()).build());
}

而 MessageAggregator.aggregate 内部是 fluxChatResponse.doOnSubscribe(...).doOnNext(...).doOnComplete(...)------没有 map 变换,纯透传 ,只在 doOnComplete 时把完整聚合交给 onAggregationComplete(spring-ai-model/.../chat/model/MessageAggregator.java:80、:155-190)。

这个细节有两个直接后果:

  1. filter 判定的是单个 chunk,不是整轮。 所以"模型在发出 tool call 之前先说了一段话"的那些 chunk,hasToolCalls() 为 false → 会通过 filter 流向用户。也就是说流式下中间轮并非 100% 不可见,而是"带 tool call 的 chunk 不可见,不带的可见"。
  2. context 是累积共享的同一个 HashMap (context.get()),所有 chunk 拿到同一个引用。这是第 06 章讲过的那个特性,在工具循环里它意味着:任意 advisor 往 context 里写的东西,本轮所有 chunk 都能看到。

12.2 跨轮 Usage 累加:UsageAccumulator 与 UsageCalculator

一次五轮的工具循环会打五次模型 API。如果只看最后一轮的 usage,你会少算 80% 的 token。这一节讲 Spring AI 2.x 怎么把五次的账合成一笔。

12.2.1 为什么需要累加:一个具体的数字推演

假设一个"查三个城市天气"的请求:

轮次 发给模型的消息数 prompt tokens completion tokens 本轮 usage
1(用户问 + 3 个工具定义) 2 320 45 365
2(+ assistant(toolcall) + toolResponse) 4 480 20 500
3(+ assistant(toolcall) + toolResponse) 6 640 20 660
4(+ assistant(toolcall) + toolResponse) 8 800 20 820
5(最终回答) 10 960 120 1080

总消耗 = 365 + 500 + 660 + 820 + 1080 = 3425 tokens。

如果不累加,ChatResponse.getMetadata().getUsage() 只报最后一轮的 1080 ------ 少算 68%。

这就是为什么 ToolCallingAdvisor 里每一行 nextCall 后面都跟着 usageAccumulator.addRoundResponse(chatResponse)。

12.2.2 UsageAccumulator 的完整 API

java 复制代码
// spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/UsageAccumulator.java:62-177
public final class UsageAccumulator {

	private @Nullable ChatResponse accumulated;

	/**
	 * Returns the cumulative usage folded in so far, or {@code null} if none. In a
	 * streaming loop this is the total of the <em>previous</em> rounds, since it is read
	 * before the in-flight round is {@link #addRoundResponse(ChatResponse) added}.
	 */
	public @Nullable ChatResponse accumulatedResponse() {
		return this.accumulated;
	}

	public @Nullable ChatResponse addRoundResponse(@Nullable ChatResponse roundChatResponse) {
		this.accumulated = UsageCalculator.accumulateResponseUsage(roundChatResponse, this.accumulated);
		return this.accumulated;
	}

	public ChatClientResponse applyAccumulatedUsage(ChatClientResponse chatClientResponse) {
		return stampTotalUsage(chatClientResponse, this.accumulated);
	}

	private static ChatClientResponse stampTotalUsage(ChatClientResponse chatClientResponse,
			@Nullable ChatResponse accumulatedChatResponse) {
		ChatResponse currentChatResponse = chatClientResponse.chatResponse();
		if (currentChatResponse == null || accumulatedChatResponse == null) {
			return chatClientResponse;
		}
		Usage accumulatedUsage = accumulatedChatResponse.getMetadata().getUsage();
		if (UsageCalculator.isEmpty(accumulatedUsage)
				|| Objects.equals(currentChatResponse.getMetadata().getUsage(), accumulatedUsage)) {
			return chatClientResponse;
		}
		return chatClientResponse.mutate()
			.chatResponse(UsageCalculator.withUsage(currentChatResponse, accumulatedUsage))
			.build();
	}

	static ChatClientResponse applyPreviousAccumulatedUsageToChunk(ChatClientResponse chunk,
			@Nullable ChatResponse previousAccumulatedResponse) {
		ChatResponse currentChatResponse = chunk.chatResponse();
		if (currentChatResponse == null || previousAccumulatedResponse == null) {
			return chunk;
		}
		Usage currentUsage = currentChatResponse.getMetadata().getUsage();
		if (UsageCalculator.isEmpty(currentUsage)
				|| UsageCalculator.isEmpty(previousAccumulatedResponse.getMetadata().getUsage())) {
			return chunk;
		}
		Usage cumulativeUsage = UsageCalculator.getCumulativeUsage(currentUsage, previousAccumulatedResponse);
		if (Objects.equals(currentUsage, cumulativeUsage)) {
			return chunk;
		}
		return chunk.mutate().chatResponse(UsageCalculator.withUsage(currentChatResponse, cumulativeUsage)).build();
	}

	static Flux<ChatClientResponse> emitFinalUsageCorrectionIfNecessary(ChatClientResponse aggregatedResponse,
			@Nullable ChatResponse roundChatResponse, @Nullable ChatResponse accumulatedChatResponse) {
		if (accumulatedChatResponse == null) {
			return Flux.empty();
		}
		Usage accumulatedUsage = accumulatedChatResponse.getMetadata().getUsage();
		if (!UsageCalculator.isEmpty(getUsage(roundChatResponse)) || UsageCalculator.isEmpty(accumulatedUsage)) {
			return Flux.empty();
		}
		ChatResponse usageOnlyChatResponse = ChatResponse.builder()
			.from(accumulatedChatResponse)
			.generations(List.of())
			.build();
		return Flux.just(aggregatedResponse.mutate().chatResponse(usageOnlyChatResponse).build());
	}

	private static @Nullable Usage getUsage(@Nullable ChatResponse chatResponse) {
		return chatResponse != null ? chatResponse.getMetadata().getUsage() : null;
	}

}

四个方法,各自的角色:

方法 调用时机 作用
addRoundResponse 每轮模型返回后 把本轮 usage 折进累计
accumulatedResponse 流式每轮开始前 取"之前所有轮次"的快照
applyAccumulatedUsage 阻塞循环结束 / 流式提前退出(limit / returnDirect) 把累计量盖到响应上
applyPreviousAccumulatedUsageToChunk 流式每个 chunk 把"之前轮次"的量加到该 chunk 自带的 usage 上
emitFinalUsageCorrectionIfNecessary 流式最后一轮 若最后一轮自带 usage,补发一个 usage-only chunk

关键设计:流式的"两段式补账"。

流式下 chunk 一旦发出就改不了,所以:

  • 第 N 轮的每个 chunk :把自己的 usage(如果有)+ 前 N-1 轮的累计 = 当前累计。由 applyPreviousAccumulatedUsageToChunk 完成。
  • 第 N 轮本身的量 :等第 N+1 轮开始时才会被折进 accumulated。所以最后一轮的量永远无法通过"下一轮"补上 ------这正是 emitFinalUsageCorrectionIfNecessary 存在的原因。

12.2.3 emitFinalUsageCorrectionIfNecessary 到底在补什么

java 复制代码
if (!UsageCalculator.isEmpty(getUsage(roundChatResponse)) || UsageCalculator.isEmpty(accumulatedUsage)) {
	return Flux.empty();
}
ChatResponse usageOnlyChatResponse = ChatResponse.builder()
	.from(accumulatedChatResponse)
	.generations(List.of())
	.build();
return Flux.just(aggregatedResponse.mutate().chatResponse(usageOnlyChatResponse).build());

触发条件(两个都满足才发):

  1. 最后一轮自己的 usage 为空 (!isEmpty(roundUsage) 为 false,即 isEmpty(roundUsage) 为真)。
  2. 累计量非空。

为什么会有"最后一轮没有 usage"的情况? 很常见:

  • 供应商在流式模式下不返回 usage(例如 Ollama 默认 stream_options 没开)。
  • 最后一轮因为某种原因(模型侧截断、连接提前关闭)没收到带 usage 的 chunk。
  • 供应商只在 finish_reason 那个 chunk 带 usage,而框架没把它放进 ChatResponse.getMetadata()。

此时如果不补,整个响应(聚合后)的 usage 就是前 N-1 轮的累计,少了最后一轮------虽然比完全不累加好,但仍然不准。

补的方式 :发一个 generations = List.of()(空 generation,不会产生任何文本内容)的 ChatResponse,里面带完整的累计 usage。Javadoc 明确说明目的:

In that case the accumulated total from previous rounds was never stamped onto an emitted chunk, so a usage-only ChatClientResponse (with empty generations, so no content is duplicated) carrying the accumulated total is emitted.

"with empty generations, so no content is duplicated" ------ 这个空 chunk 经过 stream().content() 时会产生一个空字符串,join 后无影响。

陷阱 :如果你的前端对"空 chunk"敏感(例如每个 chunk 都要渲染一个气泡),这个 usage-only chunk 会产生一个空白气泡。解决 :在 .content() 之前用 filter(StringUtils::hasText),或者干脆用 .chatResponse() 拿原始响应。

12.2.4 UsageCalculator 的算术与三个陷阱

java 复制代码
// spring-ai-model/src/main/java/org/springframework/ai/support/UsageCalculator.java:52-96
public static Usage getCumulativeUsage(final Usage currentUsage,
		final @Nullable ChatResponse previousChatResponse) {
	Usage usageFromPreviousChatResponse = null;
	if (previousChatResponse != null) {
		usageFromPreviousChatResponse = previousChatResponse.getMetadata().getUsage();
	}
	else {
		// Return the current usage when the previous chat response usage is empty or null.
		return currentUsage;
	}
	// For a valid usage from previous chat response, accumulate it to the current usage.
	if (!isEmpty(currentUsage)) {
		Integer promptTokens = currentUsage.getPromptTokens();
		Integer generationTokens = currentUsage.getCompletionTokens();
		Integer totalTokens = currentUsage.getTotalTokens();
		// Make sure to accumulate the usage from the previous chat response.
		promptTokens += usageFromPreviousChatResponse.getPromptTokens();
		generationTokens += usageFromPreviousChatResponse.getCompletionTokens();
		totalTokens += usageFromPreviousChatResponse.getTotalTokens();
		// Accumulate cache metrics, preserving null when neither side reports them.
		Long cacheRead = null;
		if (currentUsage.getCacheReadInputTokens() != null
				|| usageFromPreviousChatResponse.getCacheReadInputTokens() != null) {
			cacheRead = (currentUsage.getCacheReadInputTokens() != null ? currentUsage.getCacheReadInputTokens()
					: 0L)
					+ (usageFromPreviousChatResponse.getCacheReadInputTokens() != null
							? usageFromPreviousChatResponse.getCacheReadInputTokens() : 0L);
		}
		Long cacheWrite = null;
		if (currentUsage.getCacheWriteInputTokens() != null
				|| usageFromPreviousChatResponse.getCacheWriteInputTokens() != null) {
			cacheWrite = (currentUsage.getCacheWriteInputTokens() != null ? currentUsage.getCacheWriteInputTokens()
					: 0L)
					+ (usageFromPreviousChatResponse.getCacheWriteInputTokens() != null
							? usageFromPreviousChatResponse.getCacheWriteInputTokens() : 0L);
		}
		// Native usage is passed as null: provider-specific objects cannot be merged
		// across responses, so only token counts and cache metrics are accumulated.
		return new DefaultUsage(promptTokens, generationTokens, totalTokens, null, cacheRead, cacheWrite);
	}
	// When current usage is empty, return the usage from the previous chat response.
	return usageFromPreviousChatResponse;
}

陷阱一:isEmpty 的判据是 totalTokens == 0,不是"字段为 null"。

java 复制代码
// UsageCalculator.java:104-106
public static boolean isEmpty(@Nullable Usage usage) {
	return usage == null || usage.getTotalTokens() == 0L;
}

也就是说:一个 promptTokens=100, completionTokens=50, totalTokens=0 的 usage 会被判为"空",然后在 getCumulativeUsage 里走 return usageFromPreviousChatResponse 分支,把本轮的 100+50 完全丢弃。

什么时候 totalTokens 会是 0 而分项非 0?某些供应商的流式响应只报分项不报总计;或者 MessageAggregator 里那段(MessageAggregator.java:124-131):

java 复制代码
metadataUsagePromptTokensRef.set(usage.getPromptTokens() > 0 ? usage.getPromptTokens() : metadataUsagePromptTokensRef.get());
metadataUsageGenerationTokensRef.set(usage.getCompletionTokens() > 0 ? usage.getCompletionTokens() : metadataUsageGenerationTokensRef.get());
metadataUsageTotalTokensRef.set(usage.getTotalTokens() > 0 ? usage.getTotalTokens() : metadataUsageTotalTokensRef.get());

它逐项取"大于 0 的最后一个值",而不是累加! 这意味着流式聚合后的 usage 是"最后一次非零上报"的快照。如果供应商在每个 chunk 都报递增的 usage(OpenAI 不会,但某些供应商会),聚合结果就是最后一个 chunk 的值------碰巧正确。如果供应商只在某些 chunk 报,就可能错。

这是一个真实的精度问题:流式的 usage 依赖供应商的上报策略,Spring AI 没有做"取最大值"或"取总和"的归一。

陷阱二:Integer 拆箱的 NPE 风险。

java 复制代码
Integer promptTokens = currentUsage.getPromptTokens();
Integer generationTokens = currentUsage.getCompletionTokens();
Integer totalTokens = currentUsage.getTotalTokens();
promptTokens += usageFromPreviousChatResponse.getPromptTokens();   // ← 拆箱

promptTokens += x 等价于 promptTokens = Integer.valueOf(promptTokens.intValue() + x.intValue())。如果 currentUsage.getPromptTokens() 返回 null,这里抛 NullPointerException。

触发条件:!isEmpty(currentUsage)(即 totalTokens != 0)但 promptTokens == null。一个自定义 Usage 实现只填了 totalTokens 就会出现。

同样,usageFromPreviousChatResponse.getPromptTokens() 为 null 也会 NPE ,而它前面没有 isEmpty 保护------只有 previousChatResponse != null 的检查。

配方 :如果你实现了自定义 Usage,必须保证三个 token 字段同时非 null。

陷阱三:native usage 被丢弃。

java 复制代码
return new DefaultUsage(promptTokens, generationTokens, totalTokens, null, cacheRead, cacheWrite);

第四个参数是 nativeUsage,写死 null。Javadoc 解释了:

Native usage is passed as null: provider-specific objects cannot be merged across responses, so only token counts and cache metrics are accumulated.

后果 :一旦进入累加路径(多轮循环),你再也拿不到供应商原生的 usage 对象(例如 Anthropic 的 cache_creation_input_tokens 明细、OpenAI 的 completion_tokens_details)。最后一轮的响应被 stampTotalUsage 覆盖后,原本的 native usage 也没了。

配方 :需要原生明细时,在自定义 advisor 的 doAfterCall 里把 chatResponse.getMetadata().getUsage().getNativeUsage() 提前存进 advisor context 或 MDC。

12.2.5 accumulateResponseUsage:折叠函数

java 复制代码
// UsageCalculator.java:123-135
public static @Nullable ChatResponse accumulateResponseUsage(@Nullable ChatResponse currentChatResponse,
		@Nullable ChatResponse accumulatedChatResponse) {
	if (currentChatResponse == null) {
		return accumulatedChatResponse;
	}
	Usage currentUsage = currentChatResponse.getMetadata().getUsage();
	ChatResponse previousChatResponse = isEmpty(getUsage(accumulatedChatResponse)) ? null : accumulatedChatResponse;
	Usage cumulativeUsage = getCumulativeUsage(currentUsage, previousChatResponse);
	if (isEmpty(cumulativeUsage)) {
		return null;
	}
	return withUsage(currentChatResponse, cumulativeUsage);
}

它把"累计"表示为一个 ChatResponse 对象 (而不是一个 Usage)。这个设计是为了让 UsageAccumulator 只需持有一个字段。

注意 isEmpty(getUsage(accumulatedChatResponse)) ? null : accumulatedChatResponse ------ 如果之前累计的是"空 usage",就当作没有累计 ,走 getCumulativeUsage(currentUsage, null) 分支,直接返回 currentUsage。这是"空值传播"的处理。

withUsage 保留了哪些元数据?

java 复制代码
// UsageCalculator.java:152-161
private static ChatResponseMetadata metadataWithUsage(ChatResponseMetadata metadata, Usage usage) {
	ChatResponseMetadata.Builder builder = ChatResponseMetadata.builder()
		.id(metadata.getId())
		.model(metadata.getModel())
		.rateLimit(metadata.getRateLimit())
		.usage(usage)
		.promptMetadata(metadata.getPromptMetadata());
	metadata.entrySet().forEach(entry -> builder.keyValue(entry.getKey(), entry.getValue()));
	return builder.build();
}

保留 id、model、rateLimit、promptMetadata,并用 entrySet() 把所有自定义 key 逐个搬过去。

陷阱 :builder.keyValue(k, v) 逐条搬运,但 usage 是在这之前设置的------如果 metadata.entrySet() 里也有一个名为 usage 的 key,会覆盖 前面设的值。这取决于 ChatResponseMetadata.entrySet() 是否包含 usage。属于边缘情况,但值得知道。

12.2.6 usage 累加的五个陷阱汇总

# 陷阱 位置 后果 规避
1 isEmpty 只看 totalTokens == 0 UsageCalculator:104-106 分项非 0 但总计 0 的 usage 被整轮丢弃 保证 totalTokens 正确
2 Integer 拆箱 NPE UsageCalculator:66-72 自定义 Usage 字段为 null 时崩溃 三个 token 字段必须同时非 null
3 native usage 累加后丢失 UsageCalculator:92 供应商明细不可见 提前存进 context
4 流式聚合逐项"取最后非零值" MessageAggregator:124-131 依赖供应商上报策略 校准供应商行为
5 最后一轮可能多发一个空 chunk UsageAccumulator:157-171 前端出现空白气泡 过滤空文本

12.3 ToolExecutionEligibilityChecker:唯一的官方终止条件扩展点

12.3.1 接口本体:一个 Function 加一个 default 方法

java 复制代码
// spring-ai-model/src/main/java/org/springframework/ai/model/tool/ToolExecutionEligibilityChecker.java:31-42
public interface ToolExecutionEligibilityChecker extends Function<ChatResponse, Boolean> {

	/**
	 * Determines if the response is a tool call message response.
	 * @param chatResponse The response from the chat model call
	 * @return true if the response is a tool call message response, false otherwise
	 */
	default boolean isToolCallResponse(@Nullable ChatResponse chatResponse) {
		return chatResponse != null && apply(chatResponse);
	}

}

它 extends Function<ChatResponse, Boolean>。 这意味着你可以用 lambda 直接构造,也可以写实现类。而 isToolCallResponse 是 default 方法,做了 null 保护。

默认实现 (ToolCallingAdvisor.java:77-78):

java 复制代码
protected static final ToolExecutionEligibilityChecker DEFAULT_TOOL_EXECUTION_ELIGIBILITY_CHECKER = chatResponse -> chatResponse != null
		&& chatResponse.hasToolCalls();

注意默认实现自己又判了一次 null ------和 isToolCallResponse 里的判断重复。这是历史遗留(default 方法后加的),无害。

ChatResponse.hasToolCalls() 的真实判据 (spring-ai-model/.../chat/model/ChatResponse.java:111-115):

java 复制代码
public boolean hasToolCalls() {
	// ...
	return this.generations.stream().anyMatch(generation -> generation.getOutput().hasToolCalls());
}

即"任意一个 generation 的 AssistantMessage 有 tool call"。

12.3.2 它在三个地方被调用

# 位置 传入什么 作用
1 ToolCallingAdvisor:174 阻塞:本轮完整 ChatResponse 决定是否 executeToolCalls + 是否继续循环
2 ToolCallingAdvisor:324 流式:单个 chunk 的 ChatResponse 决定是否把该 chunk 过滤掉
3 ToolCallingAdvisor:343 流式:聚合后的完整响应 决定是否递归

这三个调用点共享同一个 checker 实例,但语义不完全相同。 这是设计上最大的约束:

  • 调用点 1 和 3 判定的是"整轮",语义一致。
  • 调用点 2 判定的是"单 chunk",语义是"这个 chunk 属于工具调用轮吗"。

所以自定义 checker 必须同时满足两个语义,否则会出乱子:

checker 过于宽松 checker 过于严格
调用点 1:checker 说 true 但没 tool call → executeToolCalls 抛 IllegalStateException("No tool call requested by the chat model") 调用点 1:有 tool call 但 checker 说 false → 循环终止,工具不执行,模型收到的是"工具调用 JSON"作为最终答案
调用点 2:正常文本 chunk 被误判 → 用户看不到内容 调用点 2:tool call chunk 未被过滤 → 用户看到 JSON 碎片

12.3.3 配方一:按 stop_reason 判定(供应商特有语义)

某些供应商(Anthropic)用 stop_reason: "tool_use" 表示"模型想调工具",而这个信息在 ChatGenerationMetadata 里而不是 tool call 列表里。默认 checker 只看 tool call 列表,在这些场景下可能超前或滞后。

java 复制代码
public class StopReasonToolExecutionChecker implements ToolExecutionEligibilityChecker {

	private static final Set<String> TOOL_USE_STOP_REASONS = Set.of("tool_use", "tool_calls");

	@Override
	public Boolean apply(ChatResponse chatResponse) {
		if (chatResponse == null) {
			return false;
		}
		// 必须先保证"真的有 tool call",否则 executeToolCalls 会抛 IllegalStateException
		if (!chatResponse.hasToolCalls()) {
			return false;
		}
		// 再用 stop_reason 收窄:只有明确表示要用工具时才执行
		return chatResponse.getResults().stream()
			.anyMatch(g -> {
				String finishReason = g.getMetadata() != null ? g.getMetadata().getFinishReason() : null;
				return finishReason != null && TOOL_USE_STOP_REASONS.contains(finishReason);
			}) || noFinishReasonReported(chatResponse);
	}

	private boolean noFinishReasonReported(ChatResponse r) {
		// 供应商没报 finish_reason 时退化为默认行为
		return r.getResults().stream().allMatch(g -> g.getMetadata() == null
				|| !StringUtils.hasText(g.getMetadata().getFinishReason()));
	}
}

关键约束(写进注释的第一行) :if (!chatResponse.hasToolCalls()) return false; ------ 自定义 checker 必须是默认 checker 的"收窄",不能是"放宽"。

为什么? 因为 executeToolCalls 的第一件事就是 findFirst() 找带 tool call 的 generation,找不到就抛 IllegalStateException(第 11 章 §11.3.1)。如果你的 checker 返回 true 而实际没有 tool call,循环会在这一轮崩溃。

(此代码为示意,字段名需按实际 ChatGenerationMetadata.getFinishReason() 校验。)

12.3.4 配方二:限制最大轮数

ToolCallLimits 限制的是工具调用次数 ,不是循环轮数。如果你的诉求是"最多问模型 5 次",需要一个有状态的 checker:

java 复制代码
public final class MaxRoundsToolExecutionChecker implements ToolExecutionEligibilityChecker {

	private final int maxRounds;
	private final AtomicInteger round = new AtomicInteger();

	public MaxRoundsToolExecutionChecker(int maxRounds) {
		this.maxRounds = maxRounds;
	}

	@Override
	public Boolean apply(ChatResponse chatResponse) {
		if (chatResponse == null || !chatResponse.hasToolCalls()) {
			return false;
		}
		// 第 1..maxRounds 轮允许执行工具;第 maxRounds+1 轮起拒绝
		return this.round.incrementAndGet() < this.maxRounds;
	}
}

这里有三个必须注意的点:

  1. 有状态 = 线程不安全。 AtomicInteger 解决了原子性,但没有解决"每次请求重新开始计数"。这个 checker 如果被注册为单例 Bean,计数器会跨请求累加,第 6 个请求就直接被拒。

    正确做法 :把它做成 per-request 实例 ,或者在 doInitializeLoop 钩子里重置。而 ToolCallingAdvisor 的 checker 是构造时注入的(private final ToolExecutionEligibilityChecker),无法 per-request 替换。

    可行方案 :用 ThreadLocal<Integer> 配合在链的最外层 advisor 里重置;或者干脆用 ToolCallLimits.maxTotalToolCalls 近似替代(它天然是 per-turn 的)。

  2. round.incrementAndGet() < maxRounds 的语义 :第 1 轮返回 1 < 5 = true(执行工具);第 5 轮返回 5 < 5 = false(不执行,循环终止)。所以实际是"最多 5 轮,第 5 轮不再执行工具"。

  3. 调用点 2(流式 filter)也会消耗计数。 流式下每个 chunk 都会调一次 apply!这会让计数器被 chunk 数量放大几十倍。 这是最致命的问题------同一个 checker 在流式下被调用 N×M 次(N 轮 × M chunk/轮)。

    结论:有状态的 checker 在流式下不可用。 这是本节最重要的实践结论。

12.3.5 配方三:工具黑名单 / 审批门

java 复制代码
public class ToolNameGateChecker implements ToolExecutionEligibilityChecker {

	private final Set<String> forbidden = Set.of("deleteUser", "transferMoney");

	@Override
	public Boolean apply(ChatResponse chatResponse) {
		if (chatResponse == null || !chatResponse.hasToolCalls()) {
			return false;
		}
		// 只要这批 tool call 里有任何一个被禁,整批都不执行
		return chatResponse.getResults().stream()
			.flatMap(g -> g.getOutput().getToolCalls().stream())
			.noneMatch(tc -> this.forbidden.contains(tc.name()));
	}
}

无状态,可在流式下安全使用。 但注意它的问题是"要么全批执行,要么全批不执行"------无法做到"只拦那一个工具"。

更精细的做法 :在 ToolCallback 层面拦截(第 10 章的 ToolCallback 包装),或在 DefaultToolExecutionExceptionProcessor 里处理。

12.3.6 checker 是怎么被注入的

Spring Boot 场景下由 ObjectProvider 提供:

java 复制代码
// auto-configurations/models/chat/client/.../ChatClientAutoConfiguration.java:102,107
public ChatClientBuilderConfigurer chatClientBuilderConfigurer(..., ObjectProvider<ToolExecutionEligibilityChecker> toolExecutionEligibilityChecker) {
	// ...
	toolExecutionEligibilityChecker.ifAvailable(builder::toolExecutionEligibilityChecker);
}

ifAvailable ------ 没有 Bean 就跳过(用默认),有多个 Bean 就静默跳过 (ifAvailable 在存在多个候选时取 null)。又一次静默失败。

所以要让你的自定义 checker 生效,必须保证容器里只有一个 ToolExecutionEligibilityChecker Bean。

ToolSearchAdvisorAutoConfiguration 里也有同样的模式(:130、:147),用于给 ToolSearchToolCallingAdvisor 注入。

12.3.7 checker 的四个约束汇总

# 约束 违反后果
1 必须是默认 checker 的收窄 (蕴含 hasToolCalls()) executeToolCalls 抛 IllegalStateException
2 必须无状态(流式下每 chunk 调用一次) 计数被 chunk 数放大,行为不可预测
3 容器内只能有一个 Bean ifAvailable 静默跳过,用回默认
4 阻塞/流式共用同一实例 两条链路的语义必须同时成立

12.4 六个 protected 钩子:子类化 ToolCallingAdvisor 的契约

ToolCallingAdvisor 不是 final,而且它主动为子类留了六个钩子。这六个钩子分成两组,每组三个,覆盖"循环开始 / 每轮前后 / 循环结束"。

12.4.1 钩子清单

钩子 阻塞版 流式版 调用时机 默认实现
初始化 doInitializeLoop doInitializeLoopStream 循环开始前一次 原样返回
每轮前 doBeforeCall doBeforeStream 每轮 nextCall/nextStream 之前 原样返回
每轮后 doAfterCall doAfterStream 每轮响应回来之后 原样返回
下一轮指令 doGetNextInstructionsForToolCall doGetNextInstructionsForToolCallStream 每轮工具执行后 见第 11 章 §11.5.7
结束 doFinalizeLoop doFinalizeLoopStream 循环结束后一次 原样返回

(严格说是"五个语义 × 两条链路 = 十个方法",其中"下一轮指令"两个版本逻辑相同。)

源码(ToolCallingAdvisor.java:236-252、:413-474):

java 复制代码
protected ChatClientResponse doFinalizeLoop(ChatClientResponse chatClientResponse,
		CallAdvisorChain callAdvisorChain) {
	return chatClientResponse;
}

protected ChatClientRequest doInitializeLoop(ChatClientRequest chatClientRequest,
		CallAdvisorChain callAdvisorChain) {
	return chatClientRequest;
}

protected ChatClientRequest doBeforeCall(ChatClientRequest chatClientRequest, CallAdvisorChain callAdvisorChain) {
	return chatClientRequest;
}

protected ChatClientResponse doAfterCall(ChatClientResponse chatClientResponse, CallAdvisorChain callAdvisorChain) {
	return chatClientResponse;
}

全部是"原样返回"的空实现。 这意味着子类化是纯粹的可选扩展,不覆盖就完全等价于父类。

12.4.2 钩子的三个关键性质

(a) 每轮都会调用的是 doBefore* / doAfter*,不是 doInitialize*。

java 复制代码
chatClientRequest = this.doInitializeLoop(chatClientRequest, callAdvisorChain);   // 循环外,一次
do {
    ...
    processedChatClientRequest = this.doBeforeCall(processedChatClientRequest, callAdvisorChain);   // 每轮
    chatClientResponse = callAdvisorChain.copy(this).nextCall(processedChatClientRequest);
    chatClientResponse = this.doAfterCall(chatClientResponse, callAdvisorChain);                     // 每轮
    ...
} while (isToolCall);

所以"只做一次的事"(建索引、初始化计数器、加 system prompt 后缀)放 doInitializeLoop;"每轮都要做的事"(注入本轮工具集、记录轮次)放 doBeforeCall。

doInitializeLoopStream 也是一次 ------它在 adviseStream 的 Flux.defer 里、internalStream 之外调用(ToolCallingAdvisor.java:270),而递归调的是 internalStream,不会再次触发 doInitializeLoopStream。这是一个容易搞错的点。

(b) doBeforeCall 的返回值会被重新读取 options。 第 11 章 §11.5.6 讲过:

java 复制代码
toolCallingChatOptions = (ToolCallingChatOptions) processedChatClientRequest.prompt().getOptions();

所以在 doBeforeCall 里改 options 是安全的且会被采纳。这是"动态工具注入"的唯一官方路径。

(c) doFinalizeLoop 在 usage 累加之后调用。

java 复制代码
chatClientResponse = usageAccumulator.applyAccumulatedUsage(chatClientResponse);
return this.doFinalizeLoop(chatClientResponse, callAdvisorChain);

所以子类在 doFinalizeLoop 里看到的 usage 已经是跨轮累计值。想做"本次请求花了多少 token"的统计,这里是最佳位置。

但流式的 doFinalizeLoopStream 拿的是一个 Flux,不是单个响应 ------它只在自然结束 (!isToolCall)路径上被调用(ToolCallingAdvisor.java:353)。预算超限和 returnDirect 这两条提前退出路径不会调用它!

这是一个不对称:

退出路径 阻塞 流式
自然结束 doFinalizeLoop ✅ doFinalizeLoopStream ✅
预算超限 doFinalizeLoop ✅(在 break 之后统一走) ❌ 不调用
returnDirect doFinalizeLoop ✅ ❌ 不调用

阻塞路径三个都调用 (因为 break 之后仍会执行循环外的两行);流式只有自然结束调用 (因为另外两条路径直接 return Flux.just(...))。

配方 :如果你的收尾逻辑(例如"记录本次工具调用统计")必须覆盖所有路径,流式下要另外在 doAfterStream 里做,或者包装整个 Flux。

12.4.3 子类化必须满足的三个契约

  1. 必须同时实现 CallAdvisor 和 StreamAdvisor 的钩子对。 ToolCallingAdvisor 同时实现两个接口,如果你只覆盖 doBeforeCall 而流式走 doBeforeStream(默认原样),两条链路行为不一致。
  2. 构造必须走 super(...) 的四参构造函数 (ToolCallingAdvisor.java:95-107),它会 Assert 三件事:toolCallingManager 非 null、checker 非 null、advisorOrder 严格在 HIGHEST_PRECEDENCE 和 LOWEST_PRECEDENCE 之间(开区间)。
java 复制代码
Assert.isTrue(advisorOrder > BaseAdvisor.HIGHEST_PRECEDENCE && advisorOrder < BaseAdvisor.LOWEST_PRECEDENCE,
		"advisorOrder must be between HIGHEST_PRECEDENCE and LOWEST_PRECEDENCE");

注意用的是 BaseAdvisor.HIGHEST_PRECEDENCE / LOWEST_PRECEDENCE(advisor 包里的常量),不是 Ordered 的。Ordered.HIGHEST_PRECEDENCE = Integer.MIN_VALUE,Ordered.LOWEST_PRECEDENCE = Integer.MAX_VALUE,所以这个开区间实际上允许除了两个极端值之外的所有 int。

  1. Builder 必须覆盖 newCopy()。 ToolCallingAdvisor.Builder.copy() 用 newCopy() 创建空白实例再填字段(:603-621)。如果子类不覆盖 newCopy(),copy() 会返回一个 ToolCallingAdvisor.Builder 而不是子类的 Builder------子类的配置在 copy 时全部丢失。

这一点在 DefaultChatClient.autoRegisterToolCallingAdvisor 里会直接咬人,因为那里调的正是 this.toolCallingAdvisorBuilder.copy()。


12.5 ToolSearchToolCallingAdvisor:教科书级的子类化范例

这是 2.0.0 新增的一个 advisor(模块 spring-ai-tool-search-advisor)。它解决的是一个非常实际的问题:当你有 200 个工具时,不可能把 200 个 schema 全塞进 prompt(OpenAI 的 tools 数组上限、以及 token 成本都不允许)。

方案是:只给模型一个"工具搜索工具",让模型自己描述需要什么能力,搜索出工具名,再把命中的工具注入下一轮。

12.5.1 它覆盖了哪些钩子

java 复制代码
// advisors/spring-ai-tool-search-advisor/.../ToolSearchToolCallingAdvisor.java:76
public class ToolSearchToolCallingAdvisor extends ToolCallingAdvisor {

覆盖了六个钩子中的四个(两组 × 初始化 + 每轮前):

java 复制代码
@Override
protected ChatClientRequest doInitializeLoop(ChatClientRequest chatClientRequest,
		CallAdvisorChain callAdvisorChain) {
	if (chatClientRequest.prompt().getOptions() instanceof ToolCallingChatOptions) {
		return initializeSession(chatClientRequest);
	}
	return super.doInitializeLoop(chatClientRequest, callAdvisorChain);
}

@Override
protected ChatClientRequest doBeforeCall(ChatClientRequest chatClientRequest, CallAdvisorChain callAdvisorChain) {
	if (chatClientRequest.prompt().getOptions() instanceof ToolCallingChatOptions) {
		return prepareIteration(chatClientRequest);
	}
	return super.doBeforeCall(chatClientRequest, callAdvisorChain);
}
// + doInitializeLoopStream / doBeforeStream,逻辑相同

模式值得学习 :先 instanceof 判断,是 ToolCallingChatOptions 才走自定义逻辑,否则 super.xxx(...)。这是"防御式覆盖"------即使 options 类型不对,行为仍与父类一致。

12.5.2 initializeSession:建索引 + 加 system 后缀

java 复制代码
// ToolSearchToolCallingAdvisor.java:194-240
private ChatClientRequest initializeSession(ChatClientRequest chatClientRequest) {
	ToolCallingChatOptions toolOptions = (ToolCallingChatOptions) chatClientRequest.prompt().getOptions();

	String sessionId = this.getSessionId(chatClientRequest.context());

	// Evict stale sessions before touching the current one.
	this.evictionStrategy.onAccess(sessionId).forEach(this::doEvict);

	// validation of tool options happens in the tool calling advisor, so we can
	// assume that if tool options are present, they are valid and contain either tool
	// callbacks or tool names to search for.
	List<ToolReference> toolReferences = this.toolCallingManager
		.resolveToolDefinitions(Objects.requireNonNull(toolOptions))
		.stream()
		.map(toolDef -> ToolReference.builder().toolName(toolDef.name()).summary(toolDef.description()).build())
		.toList();

	// Re-index only when the tool set has changed for this session.
	// compute() serializes concurrent requests for the same session so that only one
	// thread performs clear+reindex when the fingerprint changes.
	String fingerprint = computeFingerprint(toolReferences);
	this.indexedSessionFingerprints.compute(sessionId, (id, current) -> {
		if (!fingerprint.equals(current)) {
			this.toolIndex.clearIndex(id);
			this.toolIndex.indexTools(id, toolReferences);
		}
		return fingerprint;
	});

	ConcurrentHashMap<String, ToolCallback> cachedResolvedToolCallbacks = new ConcurrentHashMap<>();
	if (!CollectionUtils.isEmpty(toolOptions.getToolCallbacks())) {
		toolOptions.getToolCallbacks()
			.forEach(tc -> cachedResolvedToolCallbacks.putIfAbsent(tc.getToolDefinition().name(), tc));
	}

	chatClientRequest.context().put(CACHED_TOOL_CALLBACKS_KEY, cachedResolvedToolCallbacks);
	chatClientRequest.context().put(ToolSearchTool.TOOL_SEARCH_TOOL_SESSION_ID_KEY, sessionId);

	return chatClientRequest.mutate()
		.prompt(chatClientRequest.prompt()
			.copy()
			.augmentSystemMessage(systemMessage -> systemMessage.copy()
				.mutate()
				.text(systemMessage.getText() + this.systemMessageSuffix)
				.build()))
		.build();
}

五个值得学习的点:

  1. 指纹去重索引。 computeFingerprint 对"按名字排序后的 name+description"做 SHA-256(ToolSearchToolCallingAdvisor.java:338-352)。工具集没变就不重建索引。注释还特意说明了为什么不用字符串拼接:

    Hashing avoids false cache hits that string-concatenation with delimiters can produce when names or summaries contain those delimiter characters.

    这是一个真实的 bug 预防 :如果工具描述里含有你用的分隔符,"a|b" + "c" 和 "a" + "b|c" 会算出相同的拼接串。

  2. compute() 串行化并发重建。 注释:"compute() serializes concurrent requests for the same session so that only one thread performs clear+reindex when the fingerprint changes." ConcurrentHashMap.compute 对同一个 key 是原子独占的,所以同一 session 的并发请求不会重复建索引。

  3. sessionId 来自 advisor context,默认 key 是 ChatMemory.CONVERSATION_ID。

java 复制代码
private String sessionIdKeyName = ChatMemory.CONVERSATION_ID;   // :385

而 getSessionId 会 Assert.notNull(context.get(this.sessionIdKeyName), ...)(:326-328)------ 如果 context 里没有 conversationId,直接抛 IllegalArgumentException。

这是一个硬约束 :用 ToolSearchToolCallingAdvisor 必须在 advisor context 里放 conversationId。而 ChatClient 里放它的方式是 .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, "xxx"))。

  1. system 后缀只在初始化时追加一次。 因为 doInitializeLoop 只调一次,所以 systemMessage.getText() + suffix 不会每轮累积。这与"每轮都追加"的常见 bug 形成对照。

  2. 工具回调被缓存进 context。 CACHED_TOOL_CALLBACKS_KEY 存的是一个 ConcurrentHashMap<String, ToolCallback>,供 prepareIteration 按名字取。

12.5.3 prepareIteration:每轮注入本轮工具集

java 复制代码
// ToolSearchToolCallingAdvisor.java:245-272
private ChatClientRequest prepareIteration(ChatClientRequest chatClientRequest) {
	ToolCallingChatOptions toolOptions = Objects
		.requireNonNull((ToolCallingChatOptions) chatClientRequest.prompt().getOptions());

	Set<ToolCallback> selectedToolCallbacks = new HashSet<>(List.of(this.toolSearchToolCallback));

	var cachedToolCallbacks = (Map<String, ToolCallback>) chatClientRequest.context()
		.get(CACHED_TOOL_CALLBACKS_KEY);

	if (cachedToolCallbacks != null) {
		this.extractToolNameReferences(chatClientRequest.prompt().getInstructions()).forEach(toolName -> {
			if (cachedToolCallbacks.containsKey(toolName)) {
				selectedToolCallbacks.add(cachedToolCallbacks.get(toolName));
			}
		});
	}

	ToolCallingChatOptions toolOptionsCopy = ((ToolCallingChatOptions.Builder<?>) toolOptions.mutate())
		.toolCallbacks(new ArrayList<>(selectedToolCallbacks))
		.toolContext(ToolSearchTool.TOOL_SEARCH_TOOL_SESSION_ID_KEY,
				Objects.requireNonNull(
						chatClientRequest.context().get(ToolSearchTool.TOOL_SEARCH_TOOL_SESSION_ID_KEY)))
		.build();

	return chatClientRequest.mutate()
		.prompt(chatClientRequest.prompt().mutate().chatOptions(toolOptionsCopy).build())
		.build();
}

核心机制:

  1. 工具集 = {toolSearchTool} ∪ 从历史里提取出的、之前搜索命中的工具名。
  2. extractToolNameReferences 扫描历史中所有 MessageType.TOOL 的消息,过滤出 name == "toolSearchTool" 的 response,把它们的 responseData() 反序列化成 List<String>(工具名列表)。
  3. 把结果写成新的 ToolCallingChatOptions,替换 request 的 prompt options。

这就是"渐进式工具暴露" :第 1 轮只有 toolSearchTool;模型调用它搜索"天气"→ 返回 ["getCurrentWeather"] → 第 2 轮工具集变成 {toolSearchTool, getCurrentWeather} → 模型直接调 getCurrentWeather。

referenceToolNameAccumulation 开关 (:106、:312-313):

java 复制代码
List<List<ToolResponse>> selectedTurns = this.referenceToolNameAccumulation ? toolSearchResponsesPerTurn
		: List.of(toolSearchResponsesPerTurn.get(toolSearchResponsesPerTurn.size() - 1));
  • true(默认):累积所有轮次搜索到的工具。
  • false:只保留最近一次搜索的结果(但那一轮里的多个并行搜索都保留)。

注释解释了为什么 false 分支仍要保留一个 turn 内的多个结果:

When accumulation is disabled only the most recent search turn is honored, but that turn may contain multiple parallel toolSearchTool calls that must all be kept.

12.5.4 ToolSearchTool:一个被 @Tool 注解的普通类

java 复制代码
// spring-ai-tool-search-tool/.../ToolSearchTool.java:30-69
public class ToolSearchTool {

	public static final String TOOL_SEARCH_TOOL_SESSION_ID_KEY = "toolSearchToolSessionId";

	private final ToolIndex toolIndex;

	@Nullable private final Integer advisorMaxResults;

	@Tool(name = "toolSearchTool", description = """
			Search for tools in the tool registry to discover capabilities for completing the current task.
			Use this when you need functionality not provided by your currently available tools.
			The search queries against tool names, descriptions, and parameter information to find the most relevant tools.
			Returns references to matching tools which will be expanded into full definitions you can then invoke.
			""")
	public List<String> toolSearchTool(
		@ToolParam(description = "A natural language search query describing the tool capability you need. Be specific and include relevant keywords.") String query,
		@ToolParam(description = "Maximum number of tool references to return (1-10). Default is 5.", required = false) Integer maxResults,
		@ToolParam(description = "Optional filter to narrow search to a specific tool category.", required = false) String categoryFilter,
		ToolContext toolContext) {

		String sessionId = Objects.requireNonNull(toolContext.getContext().get(TOOL_SEARCH_TOOL_SESSION_ID_KEY))
			.toString();

		maxResults = (maxResults != null) ? maxResults : this.advisorMaxResults;

		ToolSearchResponse toolSearchResponse = this.toolIndex
			.search(new ToolSearchRequest(sessionId, query, maxResults, categoryFilter));

		return toolSearchResponse.toolReferences().stream().map(tr -> tr.toolName()).toList();
	}

}

三个观察:

  1. 它就是一个普通的 @Tool 类 ,通过 MethodToolCallbackProvider 注册(ToolSearchToolCallingAdvisor.java:130-133):
java 复制代码
this.toolSearchToolCallback = MethodToolCallbackProvider.builder()
	.toolObjects(new ToolSearchTool(toolIndex, maxResults))
	.build()
	.getToolCallbacks()[0];

getToolCallbacks()[0] ------ 取第一个。目前只有一个 @Tool 方法所以安全,但将来加了第二个方法就会取错。

  1. toolContext 用来传 sessionId。 这是一个"advisor → 工具"传参的范例:prepareIteration 把 sessionId 放进 toolContext,ToolSearchTool 从 toolContext.getContext() 取。结合第 11 章 §11.3.2(buildToolContext 从 options 取 toolContext 并拷贝成新 HashMap),这条链路是:
css 复制代码
prepareIteration: options.toolContext.put("toolSearchToolSessionId", sid)
   ↓
executeToolCalls → buildToolContext(prompt) → new HashMap<>(options.getToolContext())
   ↓
toolCallback.call(args, toolContext)
   ↓
ToolSearchTool.toolSearchTool(..., toolContext) → toolContext.getContext().get(key)
  1. Objects.requireNonNull ------ 如果 toolContext 里没这个 key 就抛 NPE。而它由 prepareIteration 保证放入。

12.5.5 驱逐策略:默认 LRU 1000

java 复制代码
// ToolSearchToolCallingAdvisor.java:387
private ToolIndexEvictionStrategy evictionStrategy = new LruEvictionStrategy(1000);

Javadoc 给了选型建议(:439-458):

Defaults to new LruEvictionStrategy(1000), which retains indexes for at most 1 000 concurrently active sessions and silently evicts the least-recently-used one when the cap is exceeded. Adjust the cap to match the expected peak concurrency of your deployment --- lower values reduce memory pressure, higher values reduce unnecessary re-indexing for services with many parallel conversations. Combine with TtlEvictionStrategy via CompositeEvictionStrategy to also release indexes for sessions that have been idle longer than a fixed duration.

模块里提供了五种策略:AlwaysEvictStrategy、NeverEvictStrategy、LruEvictionStrategy、TtlEvictionStrategy、CompositeEvictionStrategy。

陷阱 :evictSession(String) 是 public 的,可以手动驱逐(:281-289),但没有定时清理 ------onAccess 是在每次 initializeSession 时触发的(:200)。所以如果某个 session 再也不被访问,它的索引会一直留着(除非 LRU 被挤掉或 TTL 策略在某个 access 时被检查)。

这是一个"惰性清理"设计:清理只在有人访问时发生。长期不活跃的 session 索引会滞留到 LRU 满为止。

12.5.6 用它需要注意的四件事

# 事项 说明
1 必须提供 conversationId 否则 getSessionId 抛 IllegalArgumentException
2 它是 ToolAdvisor 会自动抑制 ToolCallingAdvisor 的自动注册(第 11 章 §11.1.4)
3 第一轮的 system 后缀来自 classpath 默认读 classpath:/DEFAULT_SYSTEM_PROMPT_SUFFIX.md(:476-478),可用 systemMessageSuffix() 覆盖
4 索引是 per-session 的 工具集相同但 sessionId 不同 → 各建一份索引

12.5.7 ToolIndex 的三种实现与选型

ToolIndex 接口本身只有四个方法(spring-ai-tool-search-tool/.../ToolIndex.java:32-77):

java 复制代码
public interface ToolIndex {

	void indexTool(String sessionId, ToolReference toolReference);

	@SuppressWarnings("null")
	default void indexTools(String sessionId, List<ToolReference> toolReferences) {
		for (ToolReference ref : toolReferences) {
			indexTool(sessionId, ref);
		}
	}

	ToolSearchResponse search(ToolSearchRequest toolSearchRequest);

	void clearIndex(String sessionId);

}

indexTools 是 default 方法 (逐个 indexTool),Javadoc 说明为什么留这个钩子:

Implementations should override this method when a batch operation is more efficient than repeated indexTool calls (e.g. a single embedding API call or a single write transaction).

VectorToolIndex 确实覆盖了它(一次批量 embed),而 RegexToolIndex 也覆盖了(:106)。

三种实现的对比:

实现 检索方式 依赖 适合工具规模 语义能力
RegexToolIndex 正则匹配(名字 + 描述) 无 小(< 100) 无,纯关键词
LuceneToolIndex 全文检索(Lucene) Apache Lucene 中(100--10000) 弱(词形还原、词序无关)
VectorToolIndex 向量语义检索 VectorStore + EmbeddingModel 任意 强

RegexToolIndex:查询怎么变成正则

java 复制代码
// spring-ai-tool-search-tool/.../index/regex/RegexToolIndex.java:153-212
protected String convertQueryToRegexPattern(String query) {
	if (query == null || query.isBlank()) {
		return ".*"; // Match everything if no query
	}

	// Common English stop words to filter out
	Set<String> stopWords = Set.of("a", "an", "the", "is", "are", ...);

	// Tokenize: split by whitespace and common delimiters
	String[] tokens = query.toLowerCase(Locale.ROOT).split("[\\s,;:.!?()\\[\\]{}\"']+");

	// Filter and process tokens
	List<String> processedTokens = Arrays.stream(tokens)
		.map(String::trim)
		.filter(token -> !token.isEmpty())
		.filter(token -> token.length() >= 2) // Filter out single characters
		.filter(token -> !stopWords.contains(token))
		.map(this::escapeRegexSpecialChars)
		.distinct()
		.collect(Collectors.toList());
	// ...
	String pattern = "(?i)(" + String.join("|", processedTokens) + ")";

	// Enforce max pattern length
	if (pattern.length() > MAX_PATTERN_LENGTH) {
		while (pattern.length() > MAX_PATTERN_LENGTH && processedTokens.size() > 1) {
			processedTokens.remove(processedTokens.size() - 1);
			pattern = "(?i)(" + String.join("|", processedTokens) + ")";
		}
	}
	return pattern;
}

五个设计点:

  1. 空查询返回 .*(匹配一切)------ 模型如果传空 query,会拿到全部工具。
  2. 停用词表是纯英文的 :a / an / the / is / are / ... 大约 80 个。中文查询一个都过滤不掉。
  3. 分词按空白和英文标点 :split("[\\s,;:.!?()\\[\\]{}\"']+")。中文没有空格,所以"查询天气的工具"会被切成......一个 token(因为分隔符里没有中文标点,整句连在一起)。
  4. 正则转义 :escapeRegexSpecialChars 防止用户输入破坏正则(ReDoS 与语法错误的双重防护)。
  5. MAX_PATTERN_LENGTH = 200 字符 (:148 的 Javadoc 提到),超了就从尾部丢 token。

由此得出一个对中文用户非常重要的结论:

RegexToolIndex 在中文场景下基本不可用。 模型用中文描述"我需要一个查询天气的工具",整句变成一个 token,然后去匹配英文的工具名/描述(getCurrentWeather / "Get the weather in location")------ 匹配率接近 0。

中文场景的正确选型 :VectorToolIndex(配一个支持中文的 EmbeddingModel)或 LuceneToolIndex(配一个中文 Analyzer------但 LuceneToolIndex 里写死 new StandardAnalyzer(),:103,不支持中文分词,需要子类覆盖)。

LuceneToolIndex:per-session 内存索引

java 复制代码
// spring-ai-tool-search-tool/.../index/lucene/LuceneToolIndex.java:380-394
private static class SessionIndex {
	final Directory directory;
	final IndexWriter writer;
	@Nullable DirectoryReader reader;

	SessionIndex(Analyzer analyzer) throws IOException {
		this.directory = new ByteBuffersDirectory();
		IndexWriterConfig config = new IndexWriterConfig(analyzer);
		this.writer = new IndexWriter(this.directory, config);
	}
	// ...
}

ByteBuffersDirectory 是纯内存的 Lucene Directory。 每个 session 一份,工具数量 × session 数 = 内存占用。配合 LRU 1000 的驱逐策略,1000 个 session × 200 个工具的索引,内存大约在几百 MB 量级------这是选型时必须估算的。

VectorToolIndex:把工具描述当文档存进向量库

java 复制代码
// spring-ai-tool-search-tool/.../index/vectorstore/VectorToolIndex.java:113-117
documents.add(new Document(id, ref.summary(), Map.of(METADATA_SESSION_ID, sessionId, METADATA_ID, id, ...)));

检索时按 sessionId 过滤:

java 复制代码
// :169-176
.filterExpression(sessionId != null ? b.eq(METADATA_SESSION_ID, sessionId).build() : null)

即"同一个 VectorStore 里存所有 session 的工具,用 metadata 过滤隔离"。 这意味着:

  • 工具索引会持久化 到你的向量库(如果用的是 PgVector 等持久化实现)------重启后仍在,但 indexedSessionFingerprints 是内存的,重启后会重建索引(先 clearIndex 再 indexTools)。
  • 跨 session 的数据隔离依赖 filter expression,第 15 章会讲过滤表达式的方言差异。

12.6 观测:工具 span 在 trace 树里的位置

12.6.1 span 名与关键属性

java 复制代码
// spring-ai-model/.../tool/observation/DefaultToolCallingObservationConvention.java:33-56
public static final String DEFAULT_NAME = "spring.ai.tool";

@Override
public String getName() {
	return this.name;
}

@Override
public String getContextualName(ToolCallingObservationContext context) {
	Assert.notNull(context, "context cannot be null");
	String toolName = context.getToolDefinition().name();
	return "%s %s".formatted(AiOperationType.EXECUTE_TOOL.value(), toolName);
}
  • span 名 :spring.ai.tool(用于指标聚合,低基数)。
  • contextual name :execute_tool getCurrentWeather(用于 trace 展示,含工具名)。

低基数 key(进指标):

key 值
ai.operation.type(AI_OPERATION_TYPE) execute_tool(AiOperationType.EXECUTE_TOOL.value())
ai.provider(AI_PROVIDER) spring.ai
spring.ai.kind(SPRING_AI_KIND) Spring AI kind
spring.ai.tool.type(TOOL_TYPE) function(默认,见 12.6.2)
spring.ai.tool.definition.name(TOOL_DEFINITION_NAME) 工具名

高基数 key(只进 trace,不进指标):

key 值
spring.ai.tool.definition.description 工具描述
spring.ai.tool.call.id(TOOL_CALL_ID) 工具调用 ID
spring.ai.tool.call.arguments(TOOL_CALL_ARGUMENTS) 参数 JSON(默认不含,见 12.6.3)
spring.ai.tool.call.result(TOOL_CALL_RESULT) 结果(默认不含)

12.6.2 ToolCallingObservationContext 的三处默认值

java 复制代码
// ToolCallingObservationContext.java:53-65
this.toolType = StringUtils.hasText(toolType) ? toolType : "function";
this.toolCallId = StringUtils.hasText(toolCallId) ? toolCallId : "";
this.toolCallArguments = StringUtils.hasText(toolCallArguments) ? toolCallArguments : "{}";
  • toolType 默认 "function"(兼容 OpenAI 的 type: "function")。
  • toolCallId 默认空串。
  • toolCallArguments 默认 "{}"。

注意 toolCallArguments 默认 {} 而不是 null ------ 与第 11 章 §11.3.3 里 finalToolInputArguments 兜底成 {} 是一致的:观测看到的和实际传给工具的是同一个值。

12.6.3 工具参数/结果默认不进 observation

java 复制代码
// auto-configurations/models/tool/.../ToolCallingAutoConfiguration.java:189-197
@Bean
@ConditionalOnMissingBean
@ConditionalOnProperty(prefix = ToolCallingProperties.CONFIG_PREFIX + ".observations", name = "include-content",
		havingValue = "true")
ToolCallingContentObservationFilter toolCallingContentObservationFilter() {
	logger.warn(
			"You have enabled the inclusion of the tool call arguments and result in the observations, with the risk of exposing sensitive or private information. Please, be careful!");
	return new ToolCallingContentObservationFilter();
}

默认关闭,开启时会打一条 WARN。 开启后由 ToolCallingContentObservationFilter 把 arguments 和 result 加进高基数 key:

java 复制代码
// spring-ai-model/.../tool/observation/ToolCallingContentObservationFilter.java:29-49
@Override
public Observation.Context map(Observation.Context context) {
	if (!(context instanceof ToolCallingObservationContext toolCallingObservationContext)) {
		return context;
	}

	String toolCallArguments = toolCallingObservationContext.getToolCallArguments();
	toolCallingObservationContext
		.addHighCardinalityKeyValue(ToolCallingObservationDocumentation.HighCardinalityKeyNames.TOOL_CALL_ARGUMENTS
			.withValue(toolCallArguments));

	String toolCallResult = toolCallingObservationContext.getToolCallResult();
	if (toolCallResult != null) {
		toolCallingObservationContext
			.addHighCardinalityKeyValue(ToolCallingObservationDocumentation.HighCardinalityKeyNames.TOOL_CALL_RESULT
				.withValue(toolCallResult));
	}

	return toolCallingObservationContext;
}

注意它是 ObservationFilter,不是 convention ------ 它在 observation stop 时对 context 做后处理。这意味着:

  • 它在所有 ToolCallingObservationContext 上生效(全局 filter)。
  • 它加的是高基数 key,所以只进 trace,不进指标。

生产建议:开启前确认 trace 后端(Jaeger / Zipkin / 日志)的访问控制。工具参数里常常有用户 ID、手机号、SQL 语句。

12.6.4 trace 树的结构

一次 3 轮工具循环的完整 span 结构(理想情况):

css 复制代码
HTTP span(servlet filter)
└── spring.ai.chat.client(ChatClient observation)
    └── spring.ai.advisor  [Tool Calling Advisor, order=-2147483348]
        ├── spring.ai.chat.model  ← 轮 1
        │   ├── spring.ai.tool  [getCurrentWeather]
        │   ├── spring.ai.tool  [getCurrentWeather]
        │   └── spring.ai.tool  [getCurrentWeather]
        ├── spring.ai.chat.model  ← 轮 2
        │   └── spring.ai.tool  [...]
        └── spring.ai.chat.model  ← 轮 3(最终回答)

每一轮的模型调用是一个独立的 spring.ai.chat.model span,每一次工具执行是一个独立的 spring.ai.tool span。

父子关系怎么建立的?

  • advisor → 模型 :DefaultAroundAdvisorChain.nextCall 为每个 advisor 创建 AI_ADVISOR observation,而 ChatModelCallAdvisor 内部调 chatModel.call() 会创建自己的 observation。Micrometer 用"当前 scope"推断父子(第 05 章讲过 DefaultAroundAdvisorChain.nextStream 里显式 openScope() 的技巧;但 nextCall 没有这段逻辑,靠线程局部的 current observation)。
  • 工具 → 谁 :第 11 章 §11.3.7 讲过,parent 来自 ToolCallReactiveContextHolder(流式)或 observationRegistry.getCurrentObservation()(阻塞)。

所以阻塞模式下,工具 span 的父节点是"调用线程上当前打开的 observation"------它可能是 advisor span,也可能是 HTTP span,取决于 scope 是否打开。这是阻塞模式 trace 树容易"扁平化"或"错挂"的根因。

流式模式更可靠 :ToolCallReactiveContextHolder 里存的是 Flux.deferContextual 拿到的完整 Reactor Context,其中含 ObservationThreadLocalAccessor.KEY------这是由 DefaultAroundAdvisorChain.nextStream 的 contextWrite(ctx -> ctx.put(ObservationThreadLocalAccessor.KEY, observation)) 写入的(DefaultAroundAdvisorChain.java:161)。

验证方法 :跑一次流式工具调用,看 trace 里 spring.ai.tool 是否挂在 spring.ai.advisor 下;再跑一次阻塞的,对比差异。

12.6.5 每轮都有 advisor span 吗

copy(this).nextCall(...) 每次都新建一条 DefaultAroundAdvisorChain,而 nextCall 会为每个 advisor 创建 AI_ADVISOR observation(:107-120)。所以 N 轮循环会产生 N × (下游 advisor 数) 个 advisor span。

这是一个观测放大问题:3 轮循环 × 5 个下游 advisor = 15 个 advisor span,加上 3 个模型 span + 若干工具 span。对于高频接口,这会显著增加 trace 存储成本。

配方 :给 advisor observation 配置采样,或者用 ObservationPredicate 排除 spring.ai.advisor。


12.7 循环在 advisor order 图里的位置:谁在循环内,谁在循环外

这一节把第 05 章的"order 越小越外层"结论落到工具循环上,给出完整的布局图。

12.7.1 四个关键 order 值

advisor order 源码位置
MessageChatMemoryAdvisor(默认) Advisor.DEFAULT_CHAT_MEMORY_PRECEDENCE_ORDER = Ordered.HIGHEST_PRECEDENCE + 200 spring-ai-client-chat/.../advisor/api/Advisor.java:39、MessageChatMemoryAdvisor:171
ToolCallingAdvisor Ordered.HIGHEST_PRECEDENCE + 300 ToolCallingAdvisor.java:75
RetrievalAugmentationAdvisor(默认) 0 spring-ai-rag/.../RetrievalAugmentationAdvisor.java:99(this.order = order != null ? order : 0;)
ChatModelCallAdvisor Ordered.LOWEST_PRECEDENCE ChatModelCallAdvisor.java:109-111

数值大小 :MIN+200 < MIN+300 < 0 < MAX。

12.7.2 布局图

按第 05 章的规则(OrderComparator.sort() 升序 → addLast → pop() 从头取),order 越小越先被 pop,也就越外层。所以默认布局是:

ini 复制代码
请求方向 →                                              响应方向 ←
┌─────────────────────────────────────────────────────────────────┐
│ MemoryAdvisor          order = MIN+200   ← 最外层,整个请求只跑一次 │
│ ┌───────────────────────────────────────────────────────────┐   │
│ │ ToolCallingAdvisor   order = MIN+300   ← 循环发生在这里      │   │
│ │ ┌─────────────────────────────────────────────────────┐   │   │
│ │ │ RetrievalAugmentationAdvisor  order = 0  ← 每轮都跑   │   │   │
│ │ │ ┌───────────────────────────────────────────────┐   │   │   │
│ │ │ │ ChatModelCallAdvisor  order = MAX   ← 栈底      │   │   │   │
│ │ │ └───────────────────────────────────────────────┘   │   │   │
│ │ └─────────────────────────────────────────────────────┘   │   │
│ └───────────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────────┘

ToolCallingAdvisor 的 copy(this) 取的是它"之后"的 advisor ,即 [RAG, ChatModel]------不含 MemoryAdvisor。

两个直接结论:

  1. memory advisor 默认在循环外。 它只在最外层跑一次:请求进来时从 ChatMemory 读历史、响应出去时把本轮写回。工具循环的每一轮都不经过它。这与 ToolCallingAdvisor:70-74 的 Javadoc 完全一致。
  2. RAG advisor 默认在循环内。 每一轮工具循环都会重新检索一次------这正是第 07 章讲的"copy(this) 每轮重跑检索"。

12.7.3 这个默认布局合理吗

memory 在外:合理。 因为工具循环里的 assistant(toolcall) + toolResponse 消息通常不该进长期记忆(它们是实现细节),而且如果 memory 在循环内,每轮都会被"读一次写一次",产生大量重复写入。

RAG 在内:有争议。 每轮检索意味着:

  • 优点:模型在拿到工具结果后可以基于新信息再检索一次(更聪明)。
  • 缺点:N 轮循环 = N 次向量检索 + N 次文档拼接,成本和延迟都 ×N。

如果你不想要这个行为 ,把 RAG advisor 的 order 调到 < 300(例如 MIN+100),它就会被排到 ToolCallingAdvisor 之前,从而不在 copy(this) 的结果里:

java 复制代码
RetrievalAugmentationAdvisor.builder()
    .order(Ordered.HIGHEST_PRECEDENCE + 100)   // < 300 → 在工具循环之外
    .documentRetriever(retriever)
    .build();

反之,如果你想让 memory 参与每轮(例如希望模型在每一轮都能看到完整历史),把它调到 > 300:

java 复制代码
MessageChatMemoryAdvisor.builder(chatMemory)
    .order(1000)   // > 300 → 在工具循环之内
    .build();

但此时必须配套 disableInternalConversationHistory() ,否则工具 advisor 自己维护历史 + memory advisor 再拼历史 = 历史重复 。这正是 DefaultChatClient.hasDownstreamMemoryAdvisor 那段逻辑想自动化的事------只是它依赖 order > 300 这个判据,而默认值 200 不满足,所以自动化在默认配置下永远不生效。

这是一个值得记住的结论:Spring AI 2.0.1 的"自动禁用内部历史"逻辑,在默认 order 下不会触发。你必须手动调 order 并手动 disable。

12.7.4 三种典型布局与推荐

场景 memory order RAG order 内部历史 说明
默认(推荐给大多数应用) 200(默认) 0(默认) true memory 跑一次、RAG 每轮跑、工具 advisor 自己维护历史
长对话 + 严格 token 控制 1000 0 false(手动 disable) memory 每轮跑并负责历史,工具 advisor 只传新增消息
RAG 只需检索一次 200 MIN+100 true RAG 移到循环外,省掉 N-1 次检索

注意第二种布局的风险 :conversationHistoryEnabled=false 时 doGetNextInstructionsForToolCall 只返回 [systemMessage, 最后一条],如果 prompt 没有 system message 会出问题(第 11 章 §11.5.7)。

12.7.5 MemoryAdvisor 与 ToolAdvisor:两个空标记接口

java 复制代码
// spring-ai-client-chat/.../advisor/api/MemoryAdvisor.java:33-35
public interface MemoryAdvisor extends Advisor {

}

和 ToolAdvisor 一样,空接口 ,只用于 instanceof 检测。DefaultChatClient 用它做两件事:

  1. autoRegisterToolCallingAdvisor 里判断"有没有下游 memory advisor"(a instanceof MemoryAdvisor && a.getOrder() > configuredOrder)。
  2. Javadoc 提到"avoid auto-registering a duplicate when memory management is configured"------即自动注册 memory advisor 时的去重。

两个标记接口的区别 :ToolAdvisor 有 validateSingleToolAdvisor 的硬性校验 (至多一个);MemoryAdvisor 没有对应校验------链上可以有多个 memory advisor,其行为由各自的 order 决定。


12.8 故障模式与诊断清单

症状 最可能的原因 定位方法 修复
工具从来不被调用 options 不是 ToolCallingChatOptions,advisor 静默透传 打印 prompt.getOptions().getClass() 用 ToolCallingChatOptions.builder()
No ToolCallback found for tool name: xxx 工具名被模型改写/截断(常见:MCP 前缀过长) 看 WARN 日志里的 LLM may have adapted the tool name 缩短工具名或自定义 McpToolNamePrefixGenerator
IllegalStateException: No tool call requested by the chat model 自定义 checker 比 hasToolCalls() 宽 检查 checker 是否蕴含 hasToolCalls() 收紧 checker
IllegalStateException: No CallAdvisors available to execute 自定义 advisor 递归时没用 copy(this) 检查递归路径 用 copy(this)
IllegalArgumentException: The specified advisor is not part of the chain 手动构造链后调 adviseCall,this 不在链里 检查链的构造 走 DefaultChatClient 或把 advisor 加入链
循环只跑一轮就停 checker 过严 / options 被 doBeforeCall 改坏 在 doAfterCall 打印 hasToolCalls() 修正 checker
死循环(成本失控) 模型反复请求同一工具且结果无变化 看 spring.ai.tool span 数量 设 maxCallsPerTool 或用 RETURN_ERROR_RESPONSE
流式下用户看到 JSON 碎片 checker 过严,tool call chunk 未被 filter 检查 checker 收紧为包含 hasToolCalls()
流式下用户看不到内容 checker 过宽,正常 chunk 被 filter 检查 checker 放宽为仅 tool call 轮
usage 明显偏小 没走 UsageAccumulator(自定义递归 advisor 忘了调用) 对比 span 数与 usage 每轮调 addRoundResponse
usage 突然变 0 totalTokens 为 0 被判为 empty 打印原始 usage 检查供应商上报
前端出现空白气泡 emitFinalUsageCorrectionIfNecessary 补了空 chunk 抓最后一个 chunk filter(StringUtils::hasText)
trace 里工具 span 挂在 HTTP span 下 阻塞模式靠线程局部推断父子 对比流式 trace 接受,或改流式
工具 span 完全不见 manager 用了 NOOP registry 检查 ToolCallingManager 来源 用容器 Bean
两个 ToolAdvisor 报错 手动加了一个 + 自动注册没被抑制(例如自己实现的 advisor 没实现 ToolAdvisor) 看异常里的 advisor 列表 让你的 advisor 实现 ToolAdvisor
IllegalArgumentException: context must contain a non-null value for 'conversationId' 用了 ToolSearchToolCallingAdvisor 但没传 conversationId 检查 advisor params .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, id))
工具参数总是 {} 流式下参数分片丢失 看 WARN Tool call arguments are null or empty 检查供应商流式 tool call 实现

12.9 八个可投产配方

配方 1:人工确认(Human-in-the-loop)

诉求:某些高危工具调用前必须人工批准。

实现 :包一层 ToolCallback,在 call() 里检查审批状态。

java 复制代码
public final class ApprovalRequiredToolCallback implements ToolCallback {

	private final ToolCallback delegate;
	private final ApprovalService approvalService;

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

	@Override
	public String call(String toolInput, @Nullable ToolContext toolContext) {
		String toolName = getToolDefinition().name();
		String argsHash = DigestUtils.sha256Hex(toolInput);
		if (!this.approvalService.isApproved(toolName, argsHash)) {
			// 返回给模型的不是异常,而是一条明确的拒答消息
			return "Execution of this tool requires human approval and was not approved. "
					+ "Do not retry; ask the user to approve first.";
		}
		return this.delegate.call(toolInput, toolContext);
	}
}

为什么返回字符串而不是抛异常? 第 10 章讲过:抛 ToolExecutionException 后由 DefaultToolExecutionExceptionProcessor 处理,alwaysThrow=false 时也会变成字符串回喂模型------但那条消息是通用的异常文本,模型看不懂"为什么"。自己返回结构化文案,模型才知道该去问用户而不是重试。

配方 2:按租户/用户设预算

诉求:免费用户 10 次,付费用户 100 次。

实现 :ToolCallLimits 是 per-manager 的,无法 per-request 改变。但可以为每个租户准备一个 manager,在 advisor 里按租户选:

java 复制代码
public class TenantAwareToolCallingAdvisor extends ToolCallingAdvisor {

	private final Map<String, ToolCallingManager> managersByTier;

	@Override
	protected ChatClientRequest doInitializeLoop(ChatClientRequest req, CallAdvisorChain chain) {
		// 注意:不能改 this.toolCallingManager(final),只能改 options 里的工具集
		// 或者:在 doBeforeCall 里根据租户裁剪 toolCallbacks
		return super.doInitializeLoop(req, chain);
	}
}

更实际的方案 是 per-request 构造 ChatClient:

java 复制代码
ToolCallingManager manager = ToolCallingManager.builder()
    .maxTotalToolCalls(tier == Tier.PAID ? 100 : 10)
    .build();

chatClient.prompt()
    .advisors(ToolCallingAdvisor.builder().toolCallingManager(manager).build())
    ...

代价 :ToolCallingAdvisor 的 toolCallingManager 是 protected final,构造后不能换。所以按租户只能是"每个租户一个 advisor 实例"或"每次请求构造一个"。如果租户数有限(例如 3 个档位),预建 3 个 advisor 缓存起来即可。

配方 3:死循环熔断(比 ToolCallLimits 更准)

诉求 :模型连续 3 轮用完全相同的参数调同一个工具 → 判定为死循环,立即停止。

实现 :ToolCallLimits 只看次数不看内容,所以要用自定义 manager 或包装 ToolCallback:

java 复制代码
public final class LoopBreakerToolCallback implements ToolCallback {

	private final ToolCallback delegate;
	private final Map<String, Integer> recentArgHashes = new LinkedHashMap<>(8);

	@Override
	public String call(String toolInput, @Nullable ToolContext toolContext) {
		String key = getToolDefinition().name() + ":" + DigestUtils.sha256Hex(toolInput);
		int n = this.recentArgHashes.merge(key, 1, Integer::sum);
		if (n >= 3) {
			return "This exact call has already been made " + n
					+ " times with identical arguments in this turn. Stop retrying and answer with what you have.";
		}
		return this.delegate.call(toolInput, toolContext);
	}
}

注意线程安全 :LinkedHashMap 不是线程安全的,而工具执行可能在 boundedElastic 的任意线程上。生产实现必须用 ConcurrentHashMap 或 per-request 实例。

配方 4:让"正在调用工具"对用户可见

诉求:流式下用户看不到中间轮,想要"正在查询天气..."的提示。

实现 :在 doAfterStream 里往 advisor context 写一个标记,再用一个外层 advisor 把它转成一条提示消息推给前端。

关键点:必须在工具 advisor 的更外层(order < 300)放一个 advisor ,因为它要在工具循环之外工作。而工具 advisor 的 doAfterStream 写入的 context 会被外层看到(context 是同一个 Map)。

java 复制代码
public class ToolProgressAdvisor implements StreamAdvisor {

	@Override
	public Flux<ChatClientResponse> adviseStream(ChatClientRequest req, StreamAdvisorChain chain) {
		return chain.nextStream(req)
			.concatWith(Flux.defer(() -> {
				Object last = req.context().get("lastToolName");
				return last != null ? Flux.just(progressChunk(last.toString())) : Flux.empty();
			}));
	}
	// order 必须 < ToolCallingAdvisor.DEFAULT_ORDER
}

为什么不能直接在 doAfterStream 里发 chunk? 因为 doAfterStream 返回的是单个 ChatClientResponse(聚合后的),它会被 handleToolCallRecursion 用于判定,而不是直接流给用户。而且工具 advisor 的 filter 会把它过滤掉。

配方 5:把工具调用写进审计日志

最佳位置 :doAfterCall / doAfterStream(每轮都调用,能拿到响应),或自定义 ToolCallingManager(每个工具调用都有)。

推荐用自定义 manager 的委托 :因为它天然覆盖所有工具调用,且能拿到 ToolDefinition、arguments、结果。

java 复制代码
public class AuditingToolCallingManager implements ToolCallingManager {

	private final ToolCallingManager delegate;
	private final AuditSink sink;

	@Override
	public ToolExecutionResult executeToolCalls(Prompt prompt, ChatResponse chatResponse) {
		long start = System.nanoTime();
		try {
			ToolExecutionResult result = this.delegate.executeToolCalls(prompt, chatResponse);
			this.sink.record(extractCallRecords(prompt, chatResponse, result), System.nanoTime() - start);
			return result;
		}
		catch (RuntimeException ex) {
			this.sink.recordFailure(ex, System.nanoTime() - start);
			throw ex;
		}
	}

	@Override
	public List<ToolDefinition> resolveToolDefinitions(ToolCallingChatOptions chatOptions) {
		return this.delegate.resolveToolDefinitions(chatOptions);
	}
}

注意 :DefaultToolCallingManager 是 final,所以只能委托(和 Google 的做法一致)。

配方 6:超限消息的本地化

诉求:把英文的 breach 消息换成中文。

实现 :利用 finishReason = "toolCallLimitExceeded" 做后置拦截。

java 复制代码
public class LocalizedLimitMessageAdvisor implements CallAdvisor {

	@Override
	public ChatClientResponse adviseCall(ChatClientRequest req, CallAdvisorChain chain) {
		ChatClientResponse resp = chain.nextCall(req);
		ChatResponse cr = resp.chatResponse();
		if (cr == null || cr.getResults().size() != 1) {
			return resp;
		}
		var g = cr.getResults().get(0);
		if (g.getMetadata() != null
				&& ToolCallLimitExceededException.FINISH_REASON.equals(g.getMetadata().getFinishReason())) {
			// 替换文本,保留 metadata(含 partialToolResponses)
			Generation translated = new Generation(
					new AssistantMessage("本轮工具调用已达上限,已停止。请缩小问题范围后重试。"), g.getMetadata());
			return resp.mutate()
				.chatResponse(ChatResponse.builder().from(cr).generations(List.of(translated)).build())
				.build();
		}
		return resp;
	}

	@Override
	public String getName() { return "LocalizedLimitMessage"; }

	@Override
	public int getOrder() { return Ordered.LOWEST_PRECEDENCE - 1000; }  // 紧贴栈底
}

order 要靠近栈底(值大),这样它在工具 advisor 之后执行,能看到最终响应。

配方 7:给流式链路补 doFinalizeLoopStream 的缺失

第 12.4.2 讲过:流式的预算超限 / returnDirect 路径不会 调用 doFinalizeLoopStream。如果你的收尾逻辑必须覆盖所有路径,用 doOnTerminate 包装整个 Flux:

java 复制代码
@Override
public Flux<ChatClientResponse> adviseStream(ChatClientRequest req, StreamAdvisorChain chain) {
	return super.adviseStream(req, chain)
		.doOnTerminate(this::flushStats)
		.doOnCancel(this::flushStats);
}

注意 :不能在 doFinalizeLoopStream 里做这件事(它覆盖不全),只能在外层包。

配方 8:把工具执行挪到专用线程池

诉求 :工具执行是阻塞 IO,不想占用 boundedElastic。

现状 :ToolCallingAdvisor 硬编码 subscribeOn(Schedulers.boundedElastic())(ToolCallingAdvisor.java:403),无法配置。

可行方案:

  1. 在工具内部切换线程 ------工具方法自己用 CompletableFuture + 自定义 executor,并在返回前 join()。缺点是仍然占一个 boundedElastic 线程(只是把阻塞部分挪走了)。
  2. 自定义 ToolCallback 包装 ,在 call() 内部用自定义 executor 执行并阻塞等待。同上。
  3. 调大 boundedElastic 上限 :Schedulers.onScheduleHook 或直接 System.setProperty("reactor.schedulers.defaultBoundedElasticSize", "200")。

注意方案 3 的副作用 :boundedElastic 是全局共享的,调大会影响所有 Reactor 用户(包括 WebClient)。

根本建议 :如果工具是纯 IO 密集(HTTP 调用),考虑改写成异步工具(返回 Mono/Flux 的方法,Spring AI 支持吗?------第 10 章讲过 MethodToolCallback 的执行路径,异步返回类型需要自行确认支持情况,不在本章展开)。


12.10 本章小结

  1. 流式循环是"聚合 → 递归 → 过滤"三段式。 aggregateChatClientResponse 返回的仍是原始 chunk 流(聚合结果只在 doOnComplete 时写进 AtomicReference);递归靠 concatWith(Flux.defer(...));最后一行 filter(!checker.isToolCallResponse(...)) 把带 tool call 的 chunk 全部丢弃------所以中间轮次对用户基本不可见,但"模型在 tool call 之前说的话"会漏出去。

  2. 跨轮 usage 是"事后补账"。 阻塞路径循环结束后一次性 applyAccumulatedUsage;流式路径每个 chunk 发时就带上"前 N-1 轮累计"(applyPreviousAccumulatedUsageToChunk),最后一轮若自身无 usage 则补发一个 usage-only 空 chunk(emitFinalUsageCorrectionIfNecessary)。

  3. UsageCalculator 有三个真实陷阱 :isEmpty 只看 totalTokens == 0(分项非 0 但总计 0 的 usage 会被整轮丢弃)、Integer 拆箱 NPE(自定义 Usage 字段为 null 时)、累加后 native usage 被丢弃。

  4. ToolExecutionEligibilityChecker 是唯一的官方终止扩展点,但它被三个地方共用 (阻塞判定、流式递归判定、流式 chunk 过滤),且流式下每个 chunk 调一次 ------所以有状态的 checker 在流式下不可用 。它必须是默认 checker 的"收窄"(蕴含 hasToolCalls()),否则 executeToolCalls 会抛 IllegalStateException。

  5. 六个 protected 钩子 分为"一次性的 doInitialize*"和"每轮的 doBefore*/doAfter*"。阻塞路径的三个退出口都会走 doFinalizeLoop;流式只有自然结束会走 doFinalizeLoopStream------这是一个真实的不对称。子类化必须覆盖 newCopy(),否则 copy() 会丢失子类配置。

  6. ToolSearchToolCallingAdvisor 是子类化的教科书 :doInitialize* 建索引加 system 后缀(指纹去重 + compute() 串行化),doBefore* 每轮注入本轮工具集,实现"渐进式工具暴露"。它强制要求 advisor context 里有 conversationId。

  7. 默认 order 布局 :memory(MIN+200) 在循环外 只跑一次;RAG(0) 在循环内 每轮都跑;工具 advisor 自己维护历史。而 autoRegisterToolCallingAdvisor 里的 hasDownstreamMemoryAdvisor 判据(memory.getOrder() > 300)在默认 order 下永远不生效 ------要 memory 参与每轮,必须手动调 order 到 >300 并手动 disableInternalConversationHistory()。

  8. 观测 :span 名 spring.ai.tool,contextual name execute_tool <工具名>;工具参数和结果默认不进 observation(开启会打 WARN);N 轮循环会产生 N × 下游 advisor 数 个 advisor span,注意观测放大。


12.11 思考题

  1. 流式下 filter 对每个 chunk 独立判定。如果模型先输出一句"我来查一下天气",紧接着输出一个 tool call,用户会看到什么?如果反过来呢?这对你设计 checker 有什么启示?

  2. 为什么 emitFinalUsageCorrectionIfNecessary 只在"最后一轮自身无 usage"时才发空 chunk?如果最后一轮有 usage,它是怎么被计入总量的?请顺着 applyPreviousAccumulatedUsageToChunk 的调用链推一遍。

  3. 你的自定义 checker 想在流式下实现"最多 5 轮"。根据 12.3.4 的分析这是不可行的。请给出两个替代方案,并说明各自的代价。

  4. ToolSearchToolCallingAdvisor.prepareIteration 每轮都 toolOptions.mutate().toolCallbacks(...) 重建 options。这会不会影响第 11 章讲的 ToolCallLimits 预算计数?为什么?

  5. 如果你把 RetrievalAugmentationAdvisor 的 order 设成 MIN+100(移到工具循环外),第 07 章讲的"RAG 在工具循环内,每轮重跑检索"这个行为会怎么变?这时 RetrievalAugmentationAdvisor 检索用的是第几轮的 query?

  6. DefaultToolCallingManager 是 final 的。请列出委托式自定义 manager 能拦截的全部 时机,并指出哪些时机拦不到 (提示:预算检查发生在 executeToolCalls 内部)。


12.12 本章文件索引

文件 关键行 内容
spring-ai-client-chat/.../advisor/ToolCallingAdvisor.java 257-277 / 279-309 / 311-325 / 331-404 / 406-474 / 603-621 流式入口、internalStream、三段式、递归决策、钩子、Builder copy
spring-ai-client-chat/.../advisor/UsageAccumulator.java 62-177 usage 累加四个方法
spring-ai-model/.../support/UsageCalculator.java 32-165 usage 算术(含 NPE 与 native 丢失点)
spring-ai-model/.../chat/model/MessageAggregator.java 55-200 流式聚合(透传 + doOnComplete、usage 逐项取最后非零)
spring-ai-client-chat/.../ChatClientMessageAggregator.java 37-57 chunk 透传 + 共享 context
spring-ai-model/.../model/tool/ToolExecutionEligibilityChecker.java 31-42 终止判定接口
spring-ai-model/.../model/tool/internal/ToolCallReactiveContextHolder.java 30-50 流式观测父上下文
spring-ai-client-chat/.../advisor/api/Advisor.java 39 DEFAULT_CHAT_MEMORY_PRECEDENCE_ORDER = MIN+200
spring-ai-client-chat/.../advisor/api/MemoryAdvisor.java 33-35 空标记接口
spring-ai-client-chat/.../advisor/MessageChatMemoryAdvisor.java 64 / 171 memory advisor 默认 order
spring-ai-rag/.../advisor/RetrievalAugmentationAdvisor.java 99 / 190-192 RAG 默认 order = 0
advisors/spring-ai-tool-search-advisor/.../ToolSearchToolCallingAdvisor.java 76 / 146-183 / 194-240 / 245-272 / 291-352 / 375-510 子类化范例
spring-ai-tool-search-tool/.../ToolSearchTool.java 30-69 @Tool 实现的搜索工具
spring-ai-tool-search-tool/.../eviction/*.java --- 五种驱逐策略
spring-ai-model/.../tool/observation/ToolCallingObservationContext.java 36-158 工具 span 上下文
spring-ai-model/.../tool/observation/DefaultToolCallingObservationConvention.java 33-56 span 名 spring.ai.tool
spring-ai-model/.../tool/observation/ToolCallingContentObservationFilter.java 29-49 内容(默认关闭)
spring-ai-model/.../tool/observation/ToolCallingObservationDocumentation.java 32-160 低/高基数 key
auto-configurations/models/tool/.../ToolCallingAutoConfiguration.java 189-197 include-content 开关与 WARN
auto-configurations/advisors/spring-ai-autoconfigure-tool-search-advisor/.../ToolSearchAdvisorAutoConfiguration.java 130 / 147 checker 注入
auto-configurations/models/chat/client/.../ChatClientAutoConfiguration.java 102 / 107 checker 注入(ifAvailable)

12.13 复核命令

shell 复制代码
cd D:/CODE/spring-ai-2.0.1

# 1) 流式三段式
sed -n '311,325p' spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/ToolCallingAdvisor.java

# 2) 证明 aggregateChatClientResponse 是透传
sed -n '40,55p' spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/ChatClientMessageAggregator.java
grep -n "doOnComplete\|map(" spring-ai-model/src/main/java/org/springframework/ai/chat/model/MessageAggregator.java

# 3) usage 算术与 NPE 点
sed -n '52,106p' spring-ai-model/src/main/java/org/springframework/ai/support/UsageCalculator.java

# 4) 补发空 chunk 的条件
sed -n '144,175p' spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/UsageAccumulator.java

# 5) 四个 order 值
grep -n "PRECEDENCE_ORDER" spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/api/Advisor.java
sed -n '69,75p' spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/ToolCallingAdvisor.java
sed -n '95,101p' spring-ai-rag/src/main/java/org/springframework/ai/rag/advisor/RetrievalAugmentationAdvisor.java
sed -n '108,111p' spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/ChatModelCallAdvisor.java

# 6) 六个钩子
sed -n '236,252p' spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/ToolCallingAdvisor.java
grep -n "protected .* do[A-Z]" spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/ToolCallingAdvisor.java

# 7) ToolSearch 的两个核心方法
sed -n '194,272p' advisors/spring-ai-tool-search-advisor/src/main/java/org/springframework/ai/chat/client/advisor/toolsearch/ToolSearchToolCallingAdvisor.java

# 8) span 名
sed -n '33,56p' spring-ai-model/src/main/java/org/springframework/ai/tool/observation/DefaultToolCallingObservationConvention.java

# 9) 驱逐策略全家福
ls spring-ai-tool-search-tool/src/main/java/org/springframework/ai/tool/toolsearch/eviction/

12.14 下一章预告

工具调用这条线到这里讲完:第 09 章"怎么定义"、第 10 章"怎么执行"、第 11 章"循环在哪、预算怎么算"、第 12 章"流式形态、usage 与扩展"。

第 13 章开始 MCP(Model Context Protocol):

  • MCP 客户端的三种传输(STDIO / SSE / Streamable HTTP)、工具聚合与命名冲突 ------McpToolNamePrefixGenerator 为什么从"总是加前缀"改成"冲突时才加",以及第 11 章那条 WARN 提到的"名字被截断"到底怎么修。
  • SyncMcpToolCallbackProvider / AsyncMcpToolCallbackProvider 的差异、McpToolFilter、McpToolsChangedEvent 的热更新。

第 13 章:MCP 客户端------传输、工具聚合与命名冲突。

相关推荐
鶴哥只手遮天1 小时前
Spring AI 2.0.1 深度解析-第 16 章 向量存储(上):`VectorStore` 接口体系、`BatchingStrategy`
java·人工智能·开源
鶴哥只手遮天1 小时前
Spring AI 2.0.1 深度解析-第 23 章 模型供应商实现:从 `OpenAiSetup` 到 Bedrock
java·人工智能·开源
鶴哥只手遮天1 小时前
Spring AI 2.0.1 深度解析-第 06 章 内置 Advisor 全景:13 个实现逐个剖到源码级
java·人工智能·开源
鶴哥只手遮天2 小时前
Spring AI 2.0.1 深度解析-第 03 章 消息体系与 Prompt:不可变模型、`ChatOptions` 与模板渲染
java·人工智能·开源
鶴哥只手遮天2 小时前
Spring AI 2.0.1 深度解析-第 24 章 生产实践与 2.x 迁移:陷阱清单、AOT 与调优
java·人工智能·开源
鶴哥只手遮天2 小时前
Spring AI 2.0.1 深度解析-第 10 章 工具调用(中):执行链路、结果转换与工具限制
java·人工智能·开源
鶴哥只手遮天2 小时前
Spring AI 2.0.1 深度解析-第 13 章 MCP 客户端:传输、工具聚合与命名冲突
java·人工智能·开源
鶴哥只手遮天2 小时前
Spring AI 2.0.1 深度解析-第 11 章 工具调用(下):ToolCallingManager、ToolCallingAdvisor与调用预算
java·人工智能·开源
鶴哥只手遮天2 小时前
Spring AI 2.0.1 深度解析-第 17 章 向量存储(下):实现剖析,从 PgVector 到 S3 Vectors
java·人工智能·开源