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

243 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<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. 时序图:一次带重试的调用(失败→重试→成功)
```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<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)"]
```
```java
public boolean allowsRetry(int completedAttempts, RetryFailure failure) {
return completedAttempts < maxAttempts // 条件1:还有剩余次数
&& retryableFailures.contains(failure); // 条件2:失败类型可重试
}
```
## 7. 剩余超时递减:两层超时防撑爆总时间
每个可重试组件有两套超时:`perAttemptTimeout`(单次)+ `totalTimeout`(整体)。
```mermaid
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/>不发起注定失败的调用"]
```
```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["次数封顶<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 更安全 |
关键区分:
```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` |