Files
SuperBizAgent-java/mvp/engineering/harness/Retry重试机制-显式可计量的attempt循环.md
T

12 KiB
Raw Blame History

Harness Retry 重试机制:显式、可计量、可审计的 attempt 循环

用途:面试讲解与复习 Harness 重试设计的完整文档。回答"为什么重试权归 Harness、重试如何被分类裁决、如何防重试失控"。 代码基线:com.superbiz.agent.harness.retry + guard/semantic/GuardModelCall 配套:Harness 执行控制笔记-终态检查与取消广播

1. 一句话核心

重试不是通用的容错开关,而是显式的、分类驱动的、可审计的 attempt 循环:HarnessRetryExecutor 统一执行,RetryFailure 分类决定"哪种失败能重试",RetryPolicy 决定"最多试几次",每次 attempt 都受 Run 门禁控制、真实消耗预算、并记录到 Trace。SDK 的隐式重试被关闭(spring.ai.retry.max-attempts: 1),因为重试必须是调用者的有意识决策,且必须可计量。

2. 设计动机:为什么重试权归 Harness

2.1 问题:SDK 在内部悄悄重试

Spring AI 默认 maxAttempts=10,RetryTemplate 包在 ChatModel.call() 内部。Harness 拦截器在外面,只看到一次调用入口,实际却发生了多次 Provider attempt:

Harness 视角:   "我调了一次模型,扣了一次预算"
实际发生:        Provider 内部悄悄试了 10 次(9 次失败 + 1 次成功)

后果:预算失真、Trace 失真、取消失效、成本失控——"重试"这个决定被藏在 SDK 内部,Harness 看不见、管不着、记不了账。

2.2 钩子只能观察,不能控制

框架确实提供观察钩子(Spring Retry 的 RetryListener:open/onError/onSuccess),但钩子只能"看见"重试,不能"控制"重试:

需要的能力 RetryListener 能吗
attempt 之间检查 Run 是否还 active(取消/预算/超时后立刻停) 不能(只是通知,不能中断循环)
每个 attempt 前扣预算 不能
根据 Run 状态决定放弃重试 不能(不知道 RunContext)

SDK 一旦开始重试,即使 Run 已取消或预算耗尽也会继续。所以取舍是"关闭 SDK 重试 + 重试外移到 Harness 自己控制",让每个 attempt 都成为完整可控点。

2.3 决策

① 关闭 SDK 隐式重试:spring.ai.retry.max-attempts: 1(测试锁定,防回归)
② 失败分类:RetryFailure——只有技术类失败才可能重试
③ 策略与执行分离:RetryPolicy(静态配置) + HarnessRetryExecutor(执行循环)
④ 每次 attempt 都 checkActive、扣预算、记录——完全可控可计量

3. 架构:Retry 在调用链中的位置

flowchart LR
    CALLER["IntentRouter / SemanticGuard / EvidenceRepair"]
    CALLER -->|"execute(context, policy, operation, classifier, recorder)"| R["HarnessRetryExecutor<br/>attempt 循环 + 双条件裁决"]
    R -->|"每次 attempt: operation.execute()"| G["GuardModelCall<br/>单次调用边界"]
    G -->|"beforeModelCall"| C["HarnessCore<br/>checkActive + 预算预扣"]
    G --> M["ChatModel(SDK retry=1)"]
    R -.->|"每次 attempt 收据"| T["Trace / Audit<br/>RetryAttempt"]

    style R fill:#e6f4ff,stroke:#0958d9
    style G fill:#f6ffed,stroke:#389e0d

4. 核心组件

类型 角色 内容
RetryFailure 失败分类枚举(10 种) 可重试组(技术类)vs 绝不重试组(业务/系统事实)
RetryPolicy 不可变策略 maxAttempts(1或2) + retryableFailures,不是布尔 retry=true
HarnessRetryExecutor 统一重试循环 每个 attempt 前 checkActive,4 条异常路径分支
RetryAttempt 单次 attempt 收据 序号 + 成败 + 失败类型;经 recorder 送 Trace
RetryExecutionException 重试终止异常 attempts + failure,保留最终失败

5. 时序图:一次带重试的调用(失败→重试→成功)

sequenceDiagram
    participant R as HarnessRetryExecutor
    participant G as GuardModelCall
    participant C as HarnessCore
    participant M as ChatModel(Provider)
    participant T as Trace/Audit

    R->>R: attempt=1
    R->>C: checkActive(Run 仍可执行)
    R->>G: operation.execute()(模型调用 + 严格解析)
    G->>C: beforeModelCall(扣 1 次模型预算)
    G->>M: chatModel.call(prompt, timeout=remaining)
    M--xG: 超时 / 传输失败
    G-->>R: GuardModelCallException(TIMEOUT)
    R->>R: classify → TIMEOUT
    R->>T: recorder.failed(1, TIMEOUT)
    R->>R: policy.allowsRetry(1, TIMEOUT)?→ 是

    R->>C: checkActive(第二次 attempt 前再查)
    R->>G: operation.execute()(attempt=2)
    G->>C: beforeModelCall(再扣 1 次预算)
    G->>M: chatModel.call(prompt, timeout=remaining 递减)
    M-->>G: 合法响应
    G-->>R: 解析成功
    R->>T: recorder.succeeded(2)
    R-->>调用方: 返回业务结果(T)

6. 失败分类与双条件裁决

6.1 RetryFailure:什么失败能重试

可重试组(技术类,重试可能成功):
  TIMEOUT / TRANSPORT / INVALID_OUTPUT / PARSE_ERROR / SCHEMA_INVALID

绝不重试组(业务/系统事实,重试不会改变结果):
  NO_EVIDENCE / BUSINESS_REJECTION / CANCELLED / BUDGET_EXHAUSTED / UNKNOWN

关键:"业务无证据"(NO_EVIDENCE)不是技术失败——重试不会让证据出现。取消、预算耗尽更不能被重试吞掉。

6.2 双条件裁决

flowchart TD
    A["attempt 开始"] --> B{"checkActive?"}
    B -->|"Run 已终止"| X1["抛 RunAbortedException<br/>不重试"]
    B -->|"可执行"| C["operation.execute()"]
    C -->|"成功"| S["return 业务结果"]
    C -->|"RunAborted"| X2["分类 BUDGET_EXHAUSTED / CANCELLED<br/>立即抛,不重试"]
    C -->|"BudgetExceeded"| X3["BUDGET_EXHAUSTED<br/>立即抛,不重试"]
    C -->|"其他异常"| CL["classifier.classify()<br/>null → UNKNOWN"]
    CL --> P{"allowsRetry?<br/>attempt < maxAttempts<br/>&& failure ∈ retryableFailures"}
    P -->|"是"| A
    P -->|"否"| X4["RetryExecutionException<br/>(attempts, failure)"]
public boolean allowsRetry(int completedAttempts, RetryFailure failure) {
    return completedAttempts < maxAttempts      // 条件1:还有剩余次数
            && retryableFailures.contains(failure);  // 条件2:失败类型可重试
}

7. 剩余超时递减:两层超时防撑爆总时间

每个可重试组件有两套超时:perAttemptTimeout(单次)+ totalTimeout(整体)。

flowchart LR
    T["totalTimeout(总封顶)<br/>Router:25s / Semantic:45s"] -->|"每次计算剩余"| R["remaining = total - elapsed<br/>elapsed = now - 固定起点"]
    R -->|"attempt 实际超时"| A["min(remaining, perAttemptTimeout)"]
    A -->|"剩余 <= 0"| E["直接抛 TIMEOUT<br/>不发起注定失败的调用"]
Router(perAttempt=10s, total=25s):
第一次 attempt:remaining = min(25, 10) = 10s → 用 9s 失败
第二次 attempt:remaining = 25 - 9 = 16 → min(16, 10) = 10s → 用 10s 失败
第三次 attempt:remaining = 25 - 19 = 6s → 6s 到点直接 TIMEOUT
总耗时 = 25s,被 totalTimeout 精确封顶

要点:

  • 起点固定:startedNanos 在 execute 前取一次,operation lambda 捕获它——每次 attempt 用同一起点算 elapsed,之前 attempt 的耗时自然累计
  • 单次超时管"一次别太久",剩余递减管"总共别太久"
  • 时间计算用 System.nanoTime()(单调时钟),不受系统时间调整影响

8. 三重止损:次数 / 时间 / 成本

flowchart TB
    B["次数封顶<br/>RetryPolicy.maxAttempts(1 或 2)"] --- S["重试不会失控"]
    T["时间封顶<br/>totalTimeout + 剩余递减"] --- S
    C["成本封顶<br/>每个 attempt 扣预算"] --- S
    S["三层独立、互相兜底"]
  • 即使未来把 maxAttempts 调大,总时间仍然被封死
  • 预算一耗尽(BudgetExceededException)重试立即停止——不重试是防止继续烧预算

9. 幂等性假设:为什么 Agent / Tool 不重试

重试的前提是副作用幂等(执行 N 次 = 执行 1 次的外部效果)。但幂等是必要条件,不是充分条件:

组件 副作用幂等 重试策略 原因
IntentRouter ✅(单轮无状态判定) 2 次 Harness 拥有调用控制权、成本低、重试结果都是合法判定
SemanticGuard ✅(单轮无状态判定) 2 次 同上
Diagnosis Agent ❌(多轮有状态 loop) 1 次 轮级重试需侵入框架;失败走受控停止 → Fallback
业务 Tool ✅(只读)但有成本 1 次 重试决策权在 Agent(入参可能不同);后端执行昂贵;Agent 自有重试语义
EvidenceRepair 状态相关 1 次 失败走 Fallback 更安全

关键区分:

副作用幂等 vs 结果幂等:
  Router 重试结果可能不同(模型非确定性),但每次都只是"一次独立判定"——无副作用、不破坏状态
  → "结果变了也没关系"的正确表述:重试只在第一次失败(无结果)时发生,重试结果是唯一判定,不存在覆盖

只读 ≠ 免费:
  业务 Tool 技术上幂等(只读),但每次执行消耗真实后端资源——重试是成本决策,不是安全决策

10. 与预算 / 取消的关系

预算:每个 attempt 都走 GuardModelCall → beforeModelCall → 扣 1 次模型调用额度
      2 次 attempt = 2 次配额;配额耗尽 → BUDGET_EXHAUSTED → 不重试

取消:每次 attempt 前 checkActive——取消发生在 attempt 之间时,第二次调用被拦下
      GuardModelCall 挂 onCancel → future.cancel(true) → 正在等待的 attempt 可被打断

11. 面试话术

为什么重试权归 Harness:

SDK 默认在 ChatModel 外包装 RetryTemplate 悄悄重试 10 次,Harness 只看到一次入口、计量却失真。我们通过 spring.ai.retry.max-attempts: 1 关掉它,并用专门的配置测试锁死防回归——这样一次 ChatModel 调用对应一次真实 Provider attempt,预算和 Trace 才可计量。框架的 RetryListener 钩子只能观察不能控制,所以重试外移到 Harness 自己的 RetryExecutor。

分类与裁决:

重试先分类再决策:RetryFailure 区分技术失败(超时、传输、解析、schema)和业务失败(无证据、业务拒绝、取消、预算耗尽),只有技术类才允许重试;RetryPolicy 限定每个组件最多 2 次。每次 attempt 前 checkActive、每个 attempt 真实扣预算并记录,所以"试了几次、为什么停"完全可审计。

为什么 Agent / Tool 不重试:

Router 和 SemanticGuard 是 Harness 自己发起的单轮无副作用判定,重试便宜且结果独立;Diagnosis Agent 是多轮有状态 loop,重试某一轮会破坏循环上下文,整个重试成本翻倍且破坏收敛——失败走受控停止到 Fallback 是设计好的结局;业务 Tool 虽只读但重试决策权在 Agent(下一轮入参可能不同),且后端执行昂贵。

12. 自测

  1. 为什么关闭 SDK 隐式重试?不关会发生什么计量失真?(一次入口 vs 10 次 Provider attempt,预算/Trace/取消/成本)
  2. RetryPolicy 的双条件裁决是哪两个?NO_EVIDENCE 为什么永不重试?
  3. 剩余超时递减怎么防止重试撑爆总时间?为什么起点必须固定?
  4. 为什么 Agent 不重试?"保留前 2 轮重试第 3 轮"技术上可行为什么系统不做?
  5. 业务 Tool 是只读的(幂等),为什么不重试?

13. 代码位置

内容 位置
重试循环(4 条路径) harness/retry/HarnessRetryExecutor.java
双条件裁决 harness/retry/RetryPolicy.java
失败分类 harness/retry/RetryFailure.java
组件策略 harness/retry/HarnessRetryPolicies.java
attempt 收据 harness/retry/RetryAttempt.java
单次调用边界 harness/guard/semantic/GuardModelCall.java
剩余超时计算 harness/application/routing/IntentRouter.java(remaining)
关闭 SDK 重试 src/main/resources/application.yml + SpringAiRetryConfigurationTest
行为契约测试 src/test/.../retry/HarnessRetryExecutorTest.java