docs(harness): add retry guide and learning roadmap; annotate retry core

This commit is contained in:
wdm1802
2026-08-03 01:29:36 +08:00
parent d084202166
commit e564863c43
8 changed files with 478 additions and 0 deletions
@@ -0,0 +1,242 @@
# 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` |