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 内部: + *

+ */ public final class HarnessRetryExecutor { private final DiagnosisHarnessCore core; @@ -17,6 +31,15 @@ public final class HarnessRetryExecutor { this.core = Objects.requireNonNull(core, "core must not be null"); } + /** + * 执行带重试的操作。 + * + * @param context RunContext(每次 attempt 前检查 active 用) + * @param policy 重试策略:maxAttempts + 可重试失败类型 + * @param operation 一次操作,通常是「模型调用 + 严格解析」 + * @param classifier 异常 → RetryFailure 分类器 + * @param recorder 每次 attempt 的收据(写 Trace / 账本) + */ public T execute(RunContext context, RetryPolicy policy, RetryOperation operation, @@ -29,32 +52,39 @@ public final class HarnessRetryExecutor { Objects.requireNonNull(recorder, "recorder must not be null"); for (int attempt = 1; attempt <= policy.maxAttempts(); attempt++) { + // 每个 attempt 前先确认 Run 仍可执行;Run 已死则这里直接抛 RunAbortedException core.checkActive(context); try { T result = operation.execute(); recorder.accept(RetryAttempt.succeeded(attempt)); return result; } catch (RunAbortedException exception) { + // Run 已终止:从终态快照区分预算耗尽还是取消,立即透出,绝不重试 RetryFailure failure = exception.termination().state() == RunState.BUDGET_EXHAUSTED ? RetryFailure.BUDGET_EXHAUSTED : RetryFailure.CANCELLED; recorder.accept(RetryAttempt.failed(attempt, failure)); throw new RetryExecutionException(attempt, failure, exception); } catch (BudgetExceededException exception) { + // 预算超限:重试只会再烧预算,立即透出,绝不重试 recorder.accept(RetryAttempt.failed(attempt, RetryFailure.BUDGET_EXHAUSTED)); throw new RetryExecutionException( attempt, RetryFailure.BUDGET_EXHAUSTED, exception); } catch (Exception exception) { + // 其他异常:先分类(null → UNKNOWN),再由策略裁决是否允许下一轮 attempt RetryFailure failure = classifier.classify(exception); if (failure == null) { failure = RetryFailure.UNKNOWN; } recorder.accept(RetryAttempt.failed(attempt, failure)); if (!policy.allowsRetry(attempt, failure)) { + // 裁决失败:要么次数用尽,要么失败类型不可重试(如业务拒绝/无证据) throw new RetryExecutionException(attempt, failure, exception); } + // 允许 → 继续下一轮循环 } } + // 理论上不可达:policy.maxAttempts >= 1,且循环内要么 return 要么 throw throw new IllegalStateException("retry loop exited without a result"); } } diff --git a/src/main/java/com/superbiz/agent/harness/retry/HarnessRetryPolicies.java b/src/main/java/com/superbiz/agent/harness/retry/HarnessRetryPolicies.java index ca51105..6f71331 100644 --- a/src/main/java/com/superbiz/agent/harness/retry/HarnessRetryPolicies.java +++ b/src/main/java/com/superbiz/agent/harness/retry/HarnessRetryPolicies.java @@ -3,6 +3,16 @@ package com.superbiz.agent.harness.retry; import java.util.Objects; import java.util.Set; +/** + * 每个组件独立的重试策略集合(不可变)。 + * + *

五个组件各有自己的 {@code maxAttempts + retryableFailures}, + * 因为「是否允许重试」取决于调用者知道的信息: + *

+ */ public record HarnessRetryPolicies( RetryPolicy intentRouter, RetryPolicy diagnosisAgent, @@ -18,6 +28,17 @@ public record HarnessRetryPolicies( Objects.requireNonNull(evidenceRepair, "evidenceRepair must not be null"); } + /** + * 严格默认策略: + * + *
+     * 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})写入 Trace(routingAttempt / semanticAttempt / + * evidenceRepairAttempt 等事件),让「试了几次、每次什么失败」完全可回放。 + * + *

与 {@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 maxAttempts} 限定为 1 或 2,防止配置膨胀成不可控的隐式重试。 + */ public record RetryPolicy(int maxAttempts, Set retryableFailures) { public RetryPolicy { @@ -11,6 +22,13 @@ public record RetryPolicy(int maxAttempts, Set retryableFailures) retryableFailures = retryableFailures == null ? Set.of() : Set.copyOf(retryableFailures); } + /** + * 双条件裁决:还有剩余次数 且 失败类型可重试,才允许下一次 attempt。 + * + *

注意 {@code completedAttempts} 是「已完成(失败)的次数」: + * 例如 maxAttempts=1(EvidenceRepair)时,第一次失败后 + * {@code 1 < 1} 为 false,永不重试。 + */ public boolean allowsRetry(int completedAttempts, RetryFailure failure) { return completedAttempts < maxAttempts && retryableFailures.contains(failure); }