# Harness Retry 重试机制:显式、可计量、可审计的 attempt 循环 **用途**:面试讲解与复习 Harness 重试设计的完整文档。回答"为什么重试权归 Harness、重试如何被分类裁决、如何防重试失控"。 **代码基线**:`com.superbiz.agent.harness.retry` + `guard/semantic/GuardModelCall` **配套**:[Harness 执行控制笔记-终态检查与取消广播](Harness执行控制笔记-终态检查与取消广播.md) ## 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: ```text 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 决策 ```text ① 关闭 SDK 隐式重试:spring.ai.retry.max-attempts: 1(测试锁定,防回归) ② 失败分类:RetryFailure——只有技术类失败才可能重试 ③ 策略与执行分离:RetryPolicy(静态配置) + HarnessRetryExecutor(执行循环) ④ 每次 attempt 都 checkActive、扣预算、记录——完全可控可计量 ``` ## 3. 架构:Retry 在调用链中的位置 ```mermaid flowchart LR CALLER["IntentRouter / SemanticGuard / EvidenceRepair"] CALLER -->|"execute(context, policy, operation, classifier, recorder)"| R["HarnessRetryExecutor
attempt 循环 + 双条件裁决"] R -->|"每次 attempt: operation.execute()"| G["GuardModelCall
单次调用边界"] G -->|"beforeModelCall"| C["HarnessCore
checkActive + 预算预扣"] G --> M["ChatModel(SDK retry=1)"] R -.->|"每次 attempt 收据"| T["Trace / Audit
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. 时序图:一次带重试的调用(失败→重试→成功) ```mermaid 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:什么失败能重试 ```text 可重试组(技术类,重试可能成功): TIMEOUT / TRANSPORT / INVALID_OUTPUT / PARSE_ERROR / SCHEMA_INVALID 绝不重试组(业务/系统事实,重试不会改变结果): NO_EVIDENCE / BUSINESS_REJECTION / CANCELLED / BUDGET_EXHAUSTED / UNKNOWN ``` 关键:**"业务无证据"(NO_EVIDENCE)不是技术失败**——重试不会让证据出现。取消、预算耗尽更不能被重试吞掉。 ### 6.2 双条件裁决 ```mermaid flowchart TD A["attempt 开始"] --> B{"checkActive?"} B -->|"Run 已终止"| X1["抛 RunAbortedException
不重试"] B -->|"可执行"| C["operation.execute()"] C -->|"成功"| S["return 业务结果"] C -->|"RunAborted"| X2["分类 BUDGET_EXHAUSTED / CANCELLED
立即抛,不重试"] C -->|"BudgetExceeded"| X3["BUDGET_EXHAUSTED
立即抛,不重试"] C -->|"其他异常"| CL["classifier.classify()
null → UNKNOWN"] CL --> P{"allowsRetry?
attempt < maxAttempts
&& failure ∈ retryableFailures"} P -->|"是"| A P -->|"否"| X4["RetryExecutionException
(attempts, failure)"] ``` ```java public boolean allowsRetry(int completedAttempts, RetryFailure failure) { return completedAttempts < maxAttempts // 条件1:还有剩余次数 && retryableFailures.contains(failure); // 条件2:失败类型可重试 } ``` ## 7. 剩余超时递减:两层超时防撑爆总时间 每个可重试组件有两套超时:`perAttemptTimeout`(单次)+ `totalTimeout`(整体)。 ```mermaid flowchart LR T["totalTimeout(总封顶)
Router:25s / Semantic:45s"] -->|"每次计算剩余"| R["remaining = total - elapsed
elapsed = now - 固定起点"] R -->|"attempt 实际超时"| A["min(remaining, perAttemptTimeout)"] A -->|"剩余 <= 0"| E["直接抛 TIMEOUT
不发起注定失败的调用"] ``` ```text 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. 三重止损:次数 / 时间 / 成本 ```mermaid flowchart TB B["次数封顶
RetryPolicy.maxAttempts(1 或 2)"] --- S["重试不会失控"] T["时间封顶
totalTimeout + 剩余递减"] --- S C["成本封顶
每个 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 更安全 | 关键区分: ```text 副作用幂等 vs 结果幂等: Router 重试结果可能不同(模型非确定性),但每次都只是"一次独立判定"——无副作用、不破坏状态 → "结果变了也没关系"的正确表述:重试只在第一次失败(无结果)时发生,重试结果是唯一判定,不存在覆盖 只读 ≠ 免费: 业务 Tool 技术上幂等(只读),但每次执行消耗真实后端资源——重试是成本决策,不是安全决策 ``` ## 10. 与预算 / 取消的关系 ```text 预算:每个 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` |