From e564863c43a67970e49c93f20c8ea4ea4cc19ea7 Mon Sep 17 00:00:00 2001
From: wdm1802 <2429771355@qq.com>
Date: Mon, 3 Aug 2026 01:29:36 +0800
Subject: [PATCH] docs(harness): add retry guide and learning roadmap; annotate
retry core
---
.../harness/Harness组件学习路线-进度追踪.md | 146 +++++++++++
mvp/engineering/harness/README.md | 2 +
.../Retry重试机制-显式可计量的attempt循环.md | 242 ++++++++++++++++++
.../harness/retry/HarnessRetryExecutor.java | 30 +++
.../harness/retry/HarnessRetryPolicies.java | 21 ++
.../agent/harness/retry/RetryAttempt.java | 15 ++
.../agent/harness/retry/RetryFailure.java | 4 +
.../agent/harness/retry/RetryPolicy.java | 18 ++
8 files changed, 478 insertions(+)
create mode 100644 mvp/engineering/harness/Harness组件学习路线-进度追踪.md
create mode 100644 mvp/engineering/harness/Retry重试机制-显式可计量的attempt循环.md
diff --git a/mvp/engineering/harness/Harness组件学习路线-进度追踪.md b/mvp/engineering/harness/Harness组件学习路线-进度追踪.md
new file mode 100644
index 0000000..9f5ea4d
--- /dev/null
+++ b/mvp/engineering/harness/Harness组件学习路线-进度追踪.md
@@ -0,0 +1,146 @@
+# Harness 组件学习路线(进度追踪)
+
+**用途**:记录面试准备过程中已了解的 Harness 组件,标记进度,规划下一步。每次学完一个职责域后更新本表。
+**依据**:`mvp/engineering/harness/Harness组件全景-职责-设计原因与边界.md`(10 个职责域、189 个文件)
+
+## 0. 学习交流方式与衔接说明(新会话请先读本节)
+
+### 0.1 目标
+
+为**面试准备**深入理解 Harness:不只是知道有哪些组件,要能讲清「为什么这样设计」——每个设计点都有动机(问题)→ 决策 → 代价 → 面试话术。
+
+### 0.2 交流模式(用户与 AI 的协作方式)
+
+1. **逐域学习**:按[学习主线](#3-一次请求的完整学习主线)顺序,一次一个职责域;进度见[第 1 节](#1-进度总览)。
+2. **讲解顺序固定**:设计动机(为什么重试权归 Harness)→ 实现细节(真实代码)→ 面试话术。
+3. **用户会用自己的话复述理解**(「我理解下...」)——AI 需逐条核对:基本正确就确认 + 精修表述;有偏差要明确指出并给出修正后的说法。
+4. **用户会追问**(「为什么...」「如果...那...」)——AI 必须基于源码事实回答(`src/main/java/com/superbiz/agent/harness`),先读代码再答,不凭印象。
+5. **概念分不清时用户会要求回到底层概念**(如「副作用幂等是什么」)——用类比 + 具体例子讲透再回到主线。
+6. **每学完一个主题沉淀成 mermaid 文档**,放本目录 `mvp/engineering/harness/`(与已有笔记同风格:用途/图/表/面试话术/代码位置),并更新本路线图。
+7. **终端对话中不输出 mermaid**(用户终端显示不了,用 ASCII 树/表格);落地文档中用 mermaid。
+8. 回复用中文、不用 emoji、重要内容(代码/表/推理)不截断。
+
+### 0.3 新会话衔接步骤
+
+```text
+1. 读本路线图:第 0 节(交流方式)+ 第 1 节(进度)+ 第 4 节(下一步)
+2. 读「已产出笔记」里的文档,了解已学内容的深度(尤其是 core / retry)
+3. 从第 4 节「下一步规划」继续,保持 0.2 的交流模式
+```
+
+### 0.4 当前会话的起始上下文(供追溯)
+
+本次学习从 Harness 入口文档开始,已完整走过:入口导读 → 面试速查 → RunContext → 执行控制(budget/cancel/lifecycle/checkActive)→ 取消广播与打断机制 → RunBudget 深挖 → retry(设计+实现+超时+幂等性)→ 状态流预备(枚举归属)。当前停在「执行控制面已闭环,下一步 progress」的位置。
+
+### 0.5 面试准备策略(学习目标)
+
+**学每个域的达标标准**(不只是「看懂了」):
+
+```text
+1. 能 2 分钟讲清该域:为什么存在 → 核心机制 → 边界/代价
+2. 能接住 3 个追问:动机追问(为什么这样)→ 细节追问(怎么实现)→ 边界追问(什么不做)
+3. 有一句背得出的面试话术(每篇笔记都有「面试话术」章节)
+```
+
+**每个域的面试讲法模板(固定叙事结构)**:
+
+```text
+① 动机:不这么做会出什么问题(问题驱动,不要先报组件名)
+② 决策:选了什么方案、放弃了什么(对比)
+③ 实现:关键机制 + 代码事实(一句话带过实现细节)
+④ 边界:明确不做什么、代价是什么(诚实)
+⑤ 话术:一段 30 秒可背诵的回答
+```
+
+**高频追问地图**(面试被问到时先答哪篇):
+
+| 面试问题 | 答案指向 |
+|---|---|
+| 什么是 Harness?30 秒讲清 | [面试速查](Harness面试速查-一张图讲清设计.md) §1-2 |
+| 为什么不用多 Agent? | [设计演进](Harness设计演进-从多Agent编排到确定性控制边界.md) |
+| RunContext 为什么要显式传递? | [执行控制笔记](Harness执行控制笔记-终态检查与取消广播.md) §2 |
+| 取消是强杀吗? | [执行控制笔记](Harness执行控制笔记-终态检查与取消广播.md) §6-8 |
+| 预算和 Ledger 有什么区别? | [RunBudget 时序图](RunBudget预算流程-一次Run的资源门禁时序图.md) §5 |
+| FALLBACK 算成功还是失败? | 状态流(未沉淀,学完后补) |
+| 重试为什么归 Harness 管? | [Retry 重试机制](Retry重试机制-显式可计量的attempt循环.md) §2 |
+| 为什么 Agent/Tool 不重试? | [Retry 重试机制](Retry重试机制-显式可计量的attempt循环.md) §9 |
+| 如何防止 Agent 编造证据? | 证据安全链(tool/guard 学完后补) |
+
+**面试总复习路径**(面试前一天):
+
+```text
+1. 30 秒电梯陈述 + 一张图(面试速查 §1-2)
+2. 默画三张白板图:主链路、职责迁移、数据三层(面试速查 §2)
+3. 过一遍六个易错点(面试速查 §8)
+4. 2 分钟真实案例(支付超时)
+5. 每篇笔记的「面试话术」章节快速背诵
+```
+
+## 1. 进度总览
+
+| 职责域 | 作用摘要 | 状态 | 已深入了解 | 对应文档 |
+|---|---|---|---|---|
+| `core` | **执行控制**:身份 / deadline / 预算 / 取消 / 唯一终态,checkActive 三道闸 | ✅ 深入 | RunContext、budget、cancel、lifecycle、checkActive、termination | [执行控制笔记](Harness执行控制笔记-终态检查与取消广播.md)、[RunBudget 时序图](RunBudget预算流程-一次Run的资源门禁时序图.md) |
+| `retry` | **显式可计量重试**:分类裁决(技术/业务)、次数/时间/成本三重封顶、attempt 可审计 | ✅ 深入 | 设计动机、分类裁决、剩余超时、幂等性、SDK 关闭 | [Retry 重试机制](Retry重试机制-显式可计量的attempt循环.md) |
+| `contract` | **跨层类型化语言**:Draft / PublishedResult / SafeFallback / 状态枚举,防字符串漂移 | ⬜ 部分 | RunState / ReleaseOutcome / SseOutcome / PublishedResult(只摸过枚举) | 状态流(未系统学) |
+| `agent` | **框架 ReAct 接入**:拦截器把预算/审计/停止协议挂到框架循环上,不复制 loop | ⬜ 部分 | HarnessModelInterceptor(预算/Token 记账) | — |
+| `audit` | **可观测账本**:Trace 事件回放、Token 对账、metadata-only(不存敏感正文) | ⬜ 部分 | ModelCallLedger / ModelCallAuditor | — |
+| `application` | **Run 应用所有者**:创建 Run / 路由意图 / 执行分支 / 持久化 / SSE 输出 | ⬜ 部分 | ChatApplicationUseCase 入口(cancel 链路) | — |
+| `guard` | **验证分离**:EvidenceGuard 机械验引用真实性 + SemanticGuard 隔离判结论支持度 | ⬜ 部分 | GuardModelCall(预算/超时/取消订阅) | — |
+| `release` | **唯一发布点**:SUCCESS / FALLBACK 裁决,EvidenceRepair 只修引用,SafeFallback 确定性构造 | ⬜ 部分 | DiagnosisReleaseResult(结果类型) | — |
+| `tool` | **证据边界**:ToolBoundary 统一执行规则、canonical 保存真相、projector 有界投影、MySQL 只读沙箱 | ⬜ 空白 | JdbcMysqlReadOnlyExecutor(取消订阅) | — |
+| `progress` | **收敛控制**:信息增益(GAINED/NO_GAIN)、重复检测、饱和停止(预算之外的第二套停止机制) | ⬜ 空白 | — | — |
+
+图例:✅ 深入 = 已完整学透,能面试讲 2 分钟;⬜ 部分 = 接触过但没系统学;⬜ 空白 = 未开始
+
+## 2. 已产出笔记
+
+| 文档 | 内容 | 状态 |
+|---|---|---|
+| [Harness 执行控制笔记-终态检查与取消广播](Harness执行控制笔记-终态检查与取消广播.md) | RunContext / checkActive / 两个 CAS / 取消广播 / 打断机制 | ✅ 已沉淀 |
+| [RunBudget 预算流程-一次 Run 的资源门禁时序图](RunBudget预算流程-一次Run的资源门禁时序图.md) | RunBudget 时序图 / 字段组件 / 异常终态 / 三要素 | ✅ 已沉淀 |
+| [Retry 重试机制-显式可计量的 attempt 循环](Retry重试机制-显式可计量的attempt循环.md) | Retry 设计动机 / 分类裁决 / 剩余超时 / 幂等性 | ✅ 已沉淀 |
+
+## 3. 一次请求的完整学习主线
+
+```mermaid
+flowchart LR
+ A["core
执行控制 ✅"] --> B["retry
重试 ✅"]
+ B --> C["progress
信息增益 ⬜"]
+ C --> D["tool
事实边界 ⬜"]
+ D --> E["guard
验证 ⬜"]
+ E --> F["release
发布 ⬜"]
+ F --> G["application + audit
收尾 ⬜"]
+ G --> H["contract
类型化语言 ⬜"]
+```
+
+## 4. 下一步规划
+
+```text
+下一个:progress(信息增益停止)——14 个文件,小而独立
+ 和刚学完的预算组成"双停止机制":预算管"能不能花",信息增益管"继续查有没有价值"
+
+之后顺序:
+ tool(49 个文件,按四层理解:Boundary → Canonical → Projector → Adapter)
+ guard(15 个文件:EvidenceGuard + SemanticGuard)
+ release(6 个文件,小而关键:唯一发布点)
+ 补 application(路由/执行器/SSE 收尾)和 audit(Trace 回放)
+ 最后状态流(RunState ↔ ReleaseOutcome ↔ SseOutcome 正交全景)
+```
+
+## 5. 建议每次学完一个域后更新
+
+```text
+1. 把本表"状态"从 ⬜ 改为 ✅/⬜
+2. 在"已深入了解"列补充该域的关键类
+3. 如产出笔记,加入"已产出笔记"表
+```
+
+## 6. 参考资料索引
+
+| 文档 | 用途 |
+|---|---|
+| [Harness 面试速查-一张图讲清设计](Harness面试速查-一张图讲清设计.md) | 面试主叙事(30 秒回答、三大决策、易错点) |
+| [Harness 组件全景-职责-设计原因与边界](Harness组件全景-职责-设计原因与边界.md) | 全部组件的参考手册(需要查类时用) |
+| [components/README.md](components/README.md) | 组件渐进式导读入口(02-04 对应 progress/tool/guard+release) |
+| [Harness 设计-非确定性 Agent 的确定性控制边界](Harness设计-非确定性Agent的确定性控制边界.md) | 设计主文档(决策总表、不变量) |
diff --git a/mvp/engineering/harness/README.md b/mvp/engineering/harness/README.md
index 1241742..5620181 100644
--- a/mvp/engineering/harness/README.md
+++ b/mvp/engineering/harness/README.md
@@ -66,6 +66,8 @@ flowchart TB
| 准备面试,想用一张图快速复习完整设计 | [Harness 面试速查](Harness面试速查-一张图讲清设计.md) |
| 想看一次 Run 的资源预算如何被门禁控制 | [RunBudget 预算流程时序图](RunBudget预算流程-一次Run的资源门禁时序图.md) |
| 想复习终态检查与取消广播(checkActive / onCancel) | [Harness 执行控制笔记](Harness执行控制笔记-终态检查与取消广播.md) |
+| 想复习重试机制(分类裁决 / 剩余超时 / 幂等性) | [Retry 重试机制](Retry重试机制-显式可计量的attempt循环.md) |
+| 想看当前组件学习进度与规划 | [Harness 组件学习路线](Harness组件学习路线-进度追踪.md) |
| 想看 Harness 如何处理一次真实支付超时诊断 | [支付超时诊断案例](案例-从一次支付超时诊断看Harness如何控制Agent.md) |
| 想知道这套设计如何从多 Agent 和 StateGraph 演进而来 | [Harness 设计演进](Harness设计演进-从多Agent编排到确定性控制边界.md) |
| 想知道异常、停止、降级和最终状态如何对应 | [Harness 失败图谱](Harness失败图谱-异常-停止-降级与终态.md) |
diff --git a/mvp/engineering/harness/Retry重试机制-显式可计量的attempt循环.md b/mvp/engineering/harness/Retry重试机制-显式可计量的attempt循环.md
new file mode 100644
index 0000000..c8b7488
--- /dev/null
+++ b/mvp/engineering/harness/Retry重试机制-显式可计量的attempt循环.md
@@ -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
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` |
diff --git a/src/main/java/com/superbiz/agent/harness/retry/HarnessRetryExecutor.java b/src/main/java/com/superbiz/agent/harness/retry/HarnessRetryExecutor.java
index 9741c6e..d6ad18b 100644
--- a/src/main/java/com/superbiz/agent/harness/retry/HarnessRetryExecutor.java
+++ b/src/main/java/com/superbiz/agent/harness/retry/HarnessRetryExecutor.java
@@ -9,6 +9,20 @@ import com.superbiz.agent.harness.core.RunState;
import java.util.Objects;
import java.util.function.Consumer;
+/**
+ * 统一重试执行器:把「失败分类 + 策略裁决 + attempt 记录」集中到一个循环里。
+ *
+ *
为什么重试必须在这里,而不是 SDK 内部: + *
五个组件各有自己的 {@code maxAttempts + retryableFailures}, + * 因为「是否允许重试」取决于调用者知道的信息: + *
+ * intentRouter : 2 次(超时 / 传输 / 非法输出可重试)——路由判据单轮无副作用 + * semanticGuard : 2 次(超时 / 传输 / 解析 / schema 可重试)——语义审查单轮无副作用 + * diagnosisAgent : 1 次——多轮 ReAct,失败会破坏循环上下文,不重试 + * toolCall : 1 次——业务 Tool 可能有副作用,重试会重复副作用 + * evidenceRepair : 1 次——只修引用,失败直接走 Fallback,不重试 + *+ */ public static HarnessRetryPolicies strict() { RetryPolicy oneAttempt = new RetryPolicy(1, Set.of()); return new HarnessRetryPolicies( diff --git a/src/main/java/com/superbiz/agent/harness/retry/RetryAttempt.java b/src/main/java/com/superbiz/agent/harness/retry/RetryAttempt.java index f4b70a2..9c166c2 100644 --- a/src/main/java/com/superbiz/agent/harness/retry/RetryAttempt.java +++ b/src/main/java/com/superbiz/agent/harness/retry/RetryAttempt.java @@ -1,5 +1,18 @@ package com.superbiz.agent.harness.retry; +/** + * 单次重试 attempt 的不可变收据(记录):第几次、成败、失败类型。 + * + *
由 {@link HarnessRetryExecutor} 在每次尝试后产生,经调用方的 recorder
+ * ({@code Consumer 与 {@link RetryExecutionException} 互补:RetryAttempt 是每一步的脚印(过程),
+ * RetryExecutionException 是最终定格(attempts 总数 + 最后失败类型)。
+ *
+ * 构造校验保证记录必然自洽:成功不能带失败类型、失败必须带失败类型,
+ * 避免把自相矛盾的脏记录写进 Trace。
+ */
public record RetryAttempt(int attemptNumber, boolean success, RetryFailure failure) {
public RetryAttempt {
@@ -14,10 +27,12 @@ public record RetryAttempt(int attemptNumber, boolean success, RetryFailure fail
}
}
+ /** 成功收据:failure 固定为 null(构造校验保证)。 */
public static RetryAttempt succeeded(int attemptNumber) {
return new RetryAttempt(attemptNumber, true, null);
}
+ /** 失败收据:必须携带失败类型,供 Trace 和策略裁决参考。 */
public static RetryAttempt failed(int attemptNumber, RetryFailure failure) {
return new RetryAttempt(attemptNumber, false, failure);
}
diff --git a/src/main/java/com/superbiz/agent/harness/retry/RetryFailure.java b/src/main/java/com/superbiz/agent/harness/retry/RetryFailure.java
index 1cd7128..ac1e8bd 100644
--- a/src/main/java/com/superbiz/agent/harness/retry/RetryFailure.java
+++ b/src/main/java/com/superbiz/agent/harness/retry/RetryFailure.java
@@ -11,6 +11,8 @@ package com.superbiz.agent.harness.retry;
*/
public enum RetryFailure {
+ // ===== 可重试组:技术类失败,重试可能成功 =====
+
/** 单次 attempt 或总超时。 */
TIMEOUT,
@@ -26,6 +28,8 @@ public enum RetryFailure {
/** 结构符合 JSON 但 schema/字段约束失败。 */
SCHEMA_INVALID,
+ // ===== 绝不重试组:业务/系统事实,重试不会改变结果 =====
+
/** 业务上判定无可用证据(若某组件使用该分类)。 */
NO_EVIDENCE,
diff --git a/src/main/java/com/superbiz/agent/harness/retry/RetryPolicy.java b/src/main/java/com/superbiz/agent/harness/retry/RetryPolicy.java
index d2451b6..10fb097 100644
--- a/src/main/java/com/superbiz/agent/harness/retry/RetryPolicy.java
+++ b/src/main/java/com/superbiz/agent/harness/retry/RetryPolicy.java
@@ -2,6 +2,17 @@ package com.superbiz.agent.harness.retry;
import java.util.Set;
+/**
+ * 重试策略:不可变值对象,表达「最多试几次 + 哪些失败类型可重试」。
+ *
+ * 不用布尔 {@code retry=true},而用 {@code maxAttempts + retryableFailures} 组合,
+ * 因为重试必须同时回答两个问题:
+ * 注意 {@code completedAttempts} 是「已完成(失败)的次数」:
+ * 例如 maxAttempts=1(EvidenceRepair)时,第一次失败后
+ * {@code 1 < 1} 为 false,永不重试。
+ */
public boolean allowsRetry(int completedAttempts, RetryFailure failure) {
return completedAttempts < maxAttempts && retryableFailures.contains(failure);
}
+ *
+ * {@code maxAttempts} 限定为 1 或 2,防止配置膨胀成不可控的隐式重试。
+ */
public record RetryPolicy(int maxAttempts, Set