Spring AI 2.0.2 修了 StructuredOutputValidationAdvisor:模型返回带 tool call 的回复时,校验顾问会立刻把该回复交还给后续的 tool calling 链路,同一请求不会再按 maxRepeatAttempts + 1 次空打给模型。本地用 Spring Boot 4.1.2、spring-ai-bom:2.0.2 与终端 CallAdvisor 桩(未调外部模型)对照测了两例:maxRepeatAttempts=3 时,tool call 回复的 terminal 命中为 1;缺必填字段的 JSON 仍会重试到上限 4 次。
同窗口里 2.1.0-RC1 的 Tool Call Confirmation 与 Session Memory 已分别写在混工具回复整段暂停与压缩窗口不拆工具轮两篇,本文只覆盖 2.0.2 这条结构化输出校验与 tool call 的交叉行为。
发布依据
GitHub Release v2.0.2 于 2026-10-23T14:56:46Z 发布(Asia/Shanghai 2026-10-23 22:56)。Bug Fixes 写明 Fix StructuredOutputValidationAdvisor repeating tool call requests #7100。合并的 PR 是 #7100(merged 2026-10-06T09:27:31Z),落地提交 b4cb5ac204。提交说明原文:带 tool call 的响应跳过了校验,却没退出重试循环,同一请求会被送到模型最多 maxRepeatAttempts + 1 次;只有最后一次的 tool call 会执行,前面几次仍计入 token。修复做法是遇到 tool call 立即返回。
父 POM 用 Spring Boot 4.1.2,BOM 为 org.springframework.ai:spring-ai-bom:2.0.2。运行环境是 JDK 27、Maven 3.10.0。演示只依赖 spring-ai-client-chat 与 spring-ai-model,用 DefaultAroundAdvisorChain 把顾问接到终端桩上,未配置任何 ChatModel。
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.2</version>
</parent>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>2.0.2</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-client-chat</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-model</artifactId>
</dependency>
顾问怎么接
官方测试 adviseCallReturnsToolCallResponseWithoutRepeatingTheRequest(StructuredOutputValidationAdvisorTests,标签 v2.0.2)与本次演示同一套路:StructuredOutputValidationAdvisor 按 Person 的 JSON Schema 建顾问,maxRepeatAttempts(3);终端顾问 order 取 Ordered.LOWEST_PRECEDENCE,保证校验顾问先跑。链用 DefaultAroundAdvisorChain 的 pushAll。
StructuredOutputValidationAdvisor advisor = StructuredOutputValidationAdvisor.builder()
.outputType(new TypeReference<Person>() { })
.maxRepeatAttempts(3)
.build();
ChatClientRequest request = ChatClientRequest.builder()
.prompt(new Prompt(List.of(new UserMessage("Who is Alice?"))))
.build();
CallAdvisor toolTerminal = terminal((req, chain) -> {
toolCallHits.incrementAndGet();
AssistantMessage am = AssistantMessage.builder()
.content("")
.toolCalls(List.of(new AssistantMessage.ToolCall(
"call-1", "function", "getPerson", "{\"name\":\"Alice\"}")))
.build();
return ChatClientResponse.builder()
.chatResponse(new ChatResponse(List.of(new Generation(am))))
.build();
});
CallAdvisorChain toolChain = DefaultAroundAdvisorChain.builder(ObservationRegistry.NOOP)
.pushAll(List.of(advisor, toolTerminal))
.build();
ChatClientResponse toolResult = toolChain.nextCall(request);
对照例把终端改成始终返回缺 age 的 {"name":"John Doe"},与官方 testAdviseCallWithInvalidJsonRetries 同款非法载荷。源码里(提交 b4cb5ac204)循环上界是 1 + maxRepeatAttempts;若 chatResponse.hasToolCalls() 为真则 break,注释写明把该轮交回给 tool calling advisor,避免重复同一请求。
实测对照
启动日志确认运行在 Boot 4.1.2 上,并打印了本次未调用远程模型 API。
:: Spring Boot :: (v4.1.2)
=== Spring AI 2.0.2 StructuredOutputValidationAdvisor tool-call demo ===
NOTE: no remote model API called; terminal CallAdvisor stubs the model
Case A:终端返回带 getPerson tool call 的助手消息。terminal_hits=1,请求只进桩一次,响应仍带 tool calls。
--- A: tool-call response (maxRepeatAttempts=3) ---
terminal_hits=1
request_count=1
has_tool_calls=true
tool_names=[getPerson]
measured_tool_call_hits_equals_1=true
Case B:同一顾问、同一 maxRepeatAttempts=3,终端始终返回缺 age 的 JSON。顾问连续打出四次 WARN(required property 'age' not found),terminal 命中到上限 4(即 1 + 3)。
JSON validation failed: SchemaValidation[success=false, errorMessage=required property 'age' not found]
JSON validation failed: SchemaValidation[success=false, errorMessage=required property 'age' not found]
JSON validation failed: SchemaValidation[success=false, errorMessage=required property 'age' not found]
JSON validation failed: SchemaValidation[success=false, errorMessage=required property 'age' not found]
--- B: invalid JSON contrast (maxRepeatAttempts=3) ---
terminal_hits=4
expected_hits_1_plus_maxRepeatAttempts=4
final_text={"name":"John Doe"}
measured_invalid_retries_to_ceiling=true
--- MEASURED ---
A_tool_call_single_hit=true
B_invalid_json_hits_4=true
按 PR #7100 说明,修前 Case A 会在跳过校验后仍把循环跑满,同一 tool call 请求最多空打 maxRepeatAttempts + 1 次;2.0.2 在 hasToolCalls() 分支直接 break,与 Case B 的重试天花板形成对照。生产里若把该顾问挂在会发起真实 tool call 的 ChatClient 链上,省下的是那些本不该发生的重复模型调用与对应 token。
一起交流
分享你的思考,让讨论更进一步。