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。

— 感谢阅读 —
广告

一起交流

分享你的思考,让讨论更进一步。