From d084202166ead6634e088e60d07b156d938dcf2a Mon Sep 17 00:00:00 2001 From: wdm1802 <2429771355@qq.com> Date: Sun, 2 Aug 2026 20:58:13 +0800 Subject: [PATCH] docs(harness): add budget flow sequence and execution control notes --- .../Harness执行控制笔记-终态检查与取消广播.md | 165 ++++++++++++ mvp/engineering/harness/README.md | 2 + ...nBudget预算流程-一次Run的资源门禁时序图.md | 251 ++++++++++++++++++ 3 files changed, 418 insertions(+) create mode 100644 mvp/engineering/harness/Harness执行控制笔记-终态检查与取消广播.md create mode 100644 mvp/engineering/harness/RunBudget预算流程-一次Run的资源门禁时序图.md diff --git a/mvp/engineering/harness/Harness执行控制笔记-终态检查与取消广播.md b/mvp/engineering/harness/Harness执行控制笔记-终态检查与取消广播.md new file mode 100644 index 0000000..ebb90fc --- /dev/null +++ b/mvp/engineering/harness/Harness执行控制笔记-终态检查与取消广播.md @@ -0,0 +1,165 @@ +# Harness 执行控制笔记:终态检查与取消广播 + +**用途**:面试复习用。回答"一次 Run 的执行如何被控制、取消如何生效、为什么是协作式"。 +**代码基线**:`com.superbiz.agent.harness.core` + `guard/semantic/GuardModelCall` + `tool/mysql/JdbcMysqlReadOnlyExecutor` +**配套**:[RunBudget 预算流程时序图](RunBudget预算流程-一次Run的资源门禁时序图.md) + +## 1. 一句话核心 + +> 执行控制由三个句柄组成:RunBudget 管"还能不能花"、RunCancellation 管"要不要停"、RunLifecycle 管"最终怎么定"。判断停止的机制有两套:**checkActive 轮询检查点**(读终态)和 **onCancel 订阅广播**(推送中断)——前者让"到了检查点的调用"被拒绝,后者让"正在阻塞的操作"被实时打断。 + +## 2. 执行控制三件套 + +| 句柄 | 回答的问题 | 关键机制 | 本质 | +|---|---|---|---| +| RunBudget | 还能不能继续消耗 | 调用前预扣,超限抛异常 | 门禁(止损) | +| RunCancellation | 是否要求停止 | first-reason-wins + 回调广播 | 事件(信号) | +| RunLifecycle | 最终哪个终态生效 | first-terminal-wins(CAS) | 事实(结果) | + +**预扣 vs 记账 vs 落定**:预算在调用前拦,取消在运行中广播,终态在结束时定死。 + +## 3. checkActive:三层闸门(轮询) + +`DiagnosisHarnessCore.checkActive` 在**每次模型/Tool 调用前**执行,顺序固定: + +```java +① termination 已存在 → 抛 RunAbortedException // 已终态,无论什么原因 +② deadline 已过 → finish(TIMED_OUT) + cancel(DEADLINE_EXCEEDED) + 抛异常 + // 超时是主动动作:自己写终态、自己广播,不是等别人来 +③ cancellation.isCancelled() → 抛 RunAbortedException +``` + +- 调用点:`beforeModelCall`(每轮模型)、`beforeToolCall`(每次 Tool)、`reserveRunBytes`(canonical 体积) +- 它是**轮询**:只在检查点生效。正在阻塞的操作(模型等待、SQL 查询)不会自己撞上它。 + +## 4. termination:终结事实快照 + +`RunLifecycle` 持有 `AtomicReference`: + +```java +record RunTermination(RunState state, String reason, Instant completedAt) +// 构造校验:state 必须 isTerminal(),reason 非空 +// null = 还在 RUNNING;非 null = 已终结,不可变 +``` + +- 终态只有 5 个:`SUCCESS / FAILED / CANCELLED / TIMED_OUT / BUDGET_EXHAUSTED`(`RUNNING` 非终态) +- 写入后永远定格,只能靠 CAS 换整个引用 → first-terminal-wins 的物理基础 +- checkActive 第一道闸就是读它 + +## 5. 两个正交的 CAS + +| 门 | 保护什么 | 语义 | +|---|---|---| +| `RunCancellation.reason`(AtomicReference) | **原因**:谁要求停、为什么停 | first-reason-wins | +| `RunLifecycle.termination`(AtomicReference) | **结果**:最终终态 | first-terminal-wins | + +```java +// cancel() 两件事:写原因(CAS)+ 遍历 callbacks 广播 +public boolean cancel(RunCancellationReason reason) { + if (!this.reason.compareAndSet(null, reason)) return false; // first-reason-wins + callbacks.forEach(...); // 广播给订阅者 + return true; +} + +// finish() 一件事:写终态(CAS) +public boolean finish(RunState state, String reason) { + return termination.compareAndSet(null, new RunTermination(state, reason, now)); +} +``` + +**为什么不能合并**: +- 取消是"意图/原因"(可被多线程同时请求、可被订阅),终态是"结果/事实"(只读、不可变) +- 原因→终态是**多对一**映射:`DEADLINE_EXCEEDED→TIMED_OUT`、`INTERNAL_FAILURE→FAILED`、`CLIENT_DISCONNECTED/USER_REQUESTED→CANCELLED` +- 完成路径(`completeSuccess`/`completeFailure`)根本不经过 cancel;run 已终态时取消请求被拒绝(不翻案) + +## 6. 取消广播:为什么必须有它(推送 vs 轮询) + +只设置终态,只能让**下一次 checkActive** 拒绝——正在阻塞的操作不会自己醒来。广播通过 **onCancel 回调**直接打断阻塞中的操作。 + +代码里真实的订阅者只有三处: + +| 订阅者 | 回调动作 | 打断机制 | +|---|---|---| +| Core `startRun` | `lifecycle.finish(terminalState(reason))` | 终态联动 | +| `GuardModelCall` | `future.cancel(true)` | 线程 interrupt | +| `JdbcMysqlReadOnlyExecutor` | `statement.cancel()` | JDBC 协议取消 | + +## 7. 打断机制:两种物理中断 + +**机制一:Java 线程中断(`Future.cancel(true)`)** +```java +Future future = executor.submit(() -> invoke(...)); // Guard 任务在独立线程池 +context.cancellation().onCancel(ignored -> future.cancel(true)); // 订阅 +return future.get(timeout, TimeUnit.NANOSECONDS); // 业务线程阻塞等待 +``` +取消线程执行回调 → `future.cancel(true)` → 向执行任务的线程发 `Thread.interrupt()` → 目标线程若阻塞在可中断等待则立刻抛 `InterruptedException` / `CancellationException` 醒来 → catch 后 `checkActive` → `RunAbortedException`。 + +**机制二:JDBC 协议取消(`Statement.cancel()`)** +```java +AtomicReference statementRef = new AtomicReference<>(statement); +context.cancellation().onCancel(ignored -> cancel(statementRef.get())); // 订阅 +... executeQuery() ... +finally { statementRef.set(null); } +``` +取消线程 → `statement.cancel()` → 向 MySQL 服务器发取消请求 → 服务器终止查询 → 客户端 `executeQuery` 抛 `SQLException` 醒来。不走线程中断,走数据库协议,更"物理"。 + +**细节**: +- `statementRef` 用 AtomicReference 包:回调可能在执行前/中/后触发,`finally` 里 `set(null)`,读到 null 说明已结束、跳过 cancel +- `future.cancel()` 幂等,对已完成 Future 调用无害,Guard 侧无需此保护 +- 被打断不是"裸死":Guard catch `CancellationException` → `checkActive`;MySQL 抛 `SQLException` → ToolBoundary 记 ERROR——**醒来后仍走统一状态机**,取消不会产生绕过 Harness 的野异常 + +## 8. 协作式取消的精确边界 + +能否推送中断,取决于 **Harness 是否持有该调用的执行句柄**: + +| 调用 | 谁发起 | Harness 有句柄吗 | 取消时 | +|---|---|---|---| +| Agent 模型调用 | 框架 ReAct 内部 | 无(拦截器只环绕) | 只能等下一次 checkActive 轮询 | +| Guard 模型调用 | Harness `executor.submit` | 有 Future | `future.cancel(true)` 推送中断 | +| MySQL 查询 | Harness 自己执行 | 有 Statement | `statement.cancel()` 推送中断 | + +> "协作式" = 愿意被打断的(订阅了 onCancel 且持有句柄)实时打断;框架持有的 Agent loop 物理上无法打断,只能等检查点。但无论哪种,最终结果都受 first-terminal-wins 保护。 + +## 9. 为什么"先 finish 再 cancel"(二次 finish 无害) + +`exhaustBudget` / 超时路径都执行"自己设终态 + 自己广播": + +```java +lifecycle.finish(BUDGET_EXHAUSTED, ...); // ① 先固化事实(主操作,不依赖回调) +cancellation.cancel(BUDGET_EXHAUSTED); // ② 再广播信号(副作用) +``` + +- cancel 触发回调 → 回调里再次 `finish` → **CAS 失败返回 false** → 无害、被静默吸收 +- 顺序意义:终态不依赖回调注册/执行;即使回调异常或重复触发,终态都已正确 +- 两个 CAS 各自 first-wins,最终状态永远一致(见下表,任何组合都无害): + +| finish CAS | cancel CAS | 结果 | +|---|---|---| +| 成功 | 成功 | 正常 | +| 成功 | 失败 | 终态正确,广播已由先前 cancel 触发过 | +| 失败 | 成功 | 已有更早终态,回调里 finish 失败无害 | +| 失败 | 失败 | 早已终结,本次调用本就不该发生 | + +## 10. 面试话术(三段式) + +**执行控制**: +> 执行控制由预算、取消、终态三个句柄组成,统一走 Core 的检查链:每轮模型或 Tool 调用前先 checkActive——终态存在就拒绝,超时就主动写 TIMED_OUT 并广播取消,已取消就拒绝;然后预算预扣,超限时把 BUDGET_EXHAUSTED 固化为终态并广播取消。预算管"能不能花",取消管"要不要停",终态管"最终怎么定"。 + +**取消广播**: +> 取消是协作式的。取消信号可能由容器线程注入(SseEmitter 断连回调调 core.cancel),CAS 写原因后同步遍历订阅者回调:Guard 模型调用中断自己的 Future、MySQL 中断自己的 Statement,让业务线程从阻塞中立刻醒来,再在下一个检查点被 RunAbortedException 拒绝。能否推送中断取决于 Harness 是否持有该调用的句柄——自己提交的调用(Guard/MySQL)能打断,框架持有的 Agent 模型调用只能等下一次 checkActive。 + +**为什么两个 CAS**: +> cancel 和 finish 是两条正交的通道:cancel 传原因和广播信号,finish 落最终事实。取消必须走 cancel 是因为要保留原因维度、要广播给运行中的组件;但终态又必须由 finish 直接保证,不能依赖回调。所以预算耗尽时两个都调——事实先定,信号随后,重复写终态被 CAS 吸收。 + +## 11. 代码位置索引 + +| 内容 | 位置 | +|---|---| +| RunContext 九成员 | `harness/core/RunContext.java` | +| checkActive / startRun / exhaustBudget | `harness/core/DiagnosisHarnessCore.java` | +| 终态 CAS | `harness/core/RunLifecycle.java` + `RunTermination.java` | +| 原因 CAS + 广播 | `harness/core/RunCancellation.java` | +| 预算预扣/记账 | `harness/core/RunBudget.java` + `RunCapacityCounter.java` | +| Guard 模型取消订阅 | `harness/guard/semantic/GuardModelCall.java:58` | +| MySQL 查询取消订阅 | `harness/tool/mysql/JdbcMysqlReadOnlyExecutor.java:52` | +| 断连取消入口 | `controller/sse/ChatSseSession.java:44` → `harness/application/ChatApplicationUseCase.java:340` | diff --git a/mvp/engineering/harness/README.md b/mvp/engineering/harness/README.md index a728878..1241742 100644 --- a/mvp/engineering/harness/README.md +++ b/mvp/engineering/harness/README.md @@ -64,6 +64,8 @@ flowchart TB | 当你想知道 | 再阅读 | |---|---| | 准备面试,想用一张图快速复习完整设计 | [Harness 面试速查](Harness面试速查-一张图讲清设计.md) | +| 想看一次 Run 的资源预算如何被门禁控制 | [RunBudget 预算流程时序图](RunBudget预算流程-一次Run的资源门禁时序图.md) | +| 想复习终态检查与取消广播(checkActive / onCancel) | [Harness 执行控制笔记](Harness执行控制笔记-终态检查与取消广播.md) | | 想看 Harness 如何处理一次真实支付超时诊断 | [支付超时诊断案例](案例-从一次支付超时诊断看Harness如何控制Agent.md) | | 想知道这套设计如何从多 Agent 和 StateGraph 演进而来 | [Harness 设计演进](Harness设计演进-从多Agent编排到确定性控制边界.md) | | 想知道异常、停止、降级和最终状态如何对应 | [Harness 失败图谱](Harness失败图谱-异常-停止-降级与终态.md) | diff --git a/mvp/engineering/harness/RunBudget预算流程-一次Run的资源门禁时序图.md b/mvp/engineering/harness/RunBudget预算流程-一次Run的资源门禁时序图.md new file mode 100644 index 0000000..e8c7a31 --- /dev/null +++ b/mvp/engineering/harness/RunBudget预算流程-一次Run的资源门禁时序图.md @@ -0,0 +1,251 @@ +# RunBudget 预算流程:一次 Run 的资源门禁时序图 + +**用途**:面试讲解 RunBudget 用的聚焦时序图,回答"一次 Run 的资源消耗是如何被门禁控制的"。 +**代码基线**:`RunContext` → `RunBudget` + `RunBudgetLimits` + `RunCapacityCounter` + +## 1. 在完整 Harness 中的位置(极简上下文) + +RunBudget 是 `RunContext` 里的一个执行控制句柄,两个门禁经过它: + +```mermaid +flowchart LR + MI["ModelInterceptor
每轮模型调用前"] -->|"beforeModelCall"| CORE["DiagnosisHarnessCore"] + TI["ToolInterceptor / ToolBoundary
每次 Tool 执行前"] -->|"beforeToolCall"| CORE + CORE --> B["RunBudget
预扣 + 超限升级"] + T["Canonical 写入前"] -->|"reserveRunBytes"| CORE + B --> E["BudgetExceededException →
finish(BUDGET_EXHAUSTED) + cancel"] +``` + +## 2. RunBudget 流程时序图(核心) + +```mermaid +sequenceDiagram + participant APP as ChatApplication + participant CORE as DiagnosisHarnessCore + participant MI as ModelInterceptor + participant TI as ToolInterceptor + participant T as ToolBoundary + participant B as RunBudget + participant C as RunCapacityCounter(CAS) + + Note over APP,C: 启动:startRun(sessionId) → new RunBudget(RunBudgetLimits)
句柄挂到 RunContext,随请求显式传递 + + loop 每一轮模型调用 + MI->>CORE: beforeModelCall(context) + CORE->>CORE: checkActive() 先确认 Run 还能跑 + CORE->>B: reserveModelCall() 预扣 1 轮 + alt 超限 + B-->>CORE: BudgetExceededException(MODEL_CALLS) + CORE->>CORE: exhaustBudget:finish(BUDGET_EXHAUSTED) + cancel() + CORE-->>MI: 抛异常,之后所有 checkActive 拒绝 + end + CORE->>B: recordTokens(input, output) 调用后记账 + B->>B: 三档检查 input / output / total + end + + loop 每一次 Tool 调用 + TI->>CORE: beforeToolCall(context, toolName) + CORE->>CORE: checkActive() + CORE->>B: reserveToolCall(toolName) + alt 超限(总量 或 单 Tool 独立限额) + B-->>CORE: BudgetExceededException(TOOL_CALLS / TOOL_CALLS_PER_TOOL) + CORE->>CORE: exhaustBudget(...) + end + T->>CORE: reserveRunBytes(bytes) canonical 体积预扣 + CORE->>C: capacity.reserve(bytes) AtomicLong CAS 自旋 + alt 超限 + C-->>CORE: BudgetExceededException(RUN_BYTES) + CORE->>CORE: exhaustBudget(...) + end + end + + APP->>B: snapshot() → RunBudgetUsage + Note over APP,B: Run 结束对账:各维度实际用量(模型轮数/工具次数/Token/字节) +``` + +## 3. 五个流程节点 + +1. **创建**:`startRun` 里 `new RunBudget(RunBudgetLimits)`,限额不可变,消耗状态可变,句柄随 RunContext 显式传递。 +2. **模型调用前**:`reserveModelCall()` synchronized 预扣,超限抛异常。 +3. **Tool 调用前**:`reserveToolCall(toolName)` 双重限额——总次数 + 单 Tool 次数。 +4. **Canonical 写入前**:`reserveRunBytes(bytes)` 走 CAS 计数器。 +5. **调用后**:`recordTokens` 三档 Token 上限记账。 + +**统一超限出口**:任何维度超限都抛带 `BudgetKind` 的 `BudgetExceededException` → Core `exhaustBudget`(固化终态 + 广播取消)→ 后续所有调用被 checkActive 拒绝。预算失败是 Run 级事实,不是局部异常。 + +## 4. 四个维度的时机对照表 + +| 维度 | 时机 | 预扣/记账 | 超限 BudgetKind | +|---|---|---|---| +| 模型轮数 | 模型调用前 | 预扣 | `MODEL_CALLS` | +| Tool 次数 | Tool 调用前 | 预扣 | `TOOL_CALLS` / `TOOL_CALLS_PER_TOOL` | +| Token | 模型调用后 | 记账(实际用量) | `INPUT_TOKENS` / `OUTPUT_TOKENS` / `TOTAL_TOKENS` | +| 字节 | canonical 写入前 | 预扣 | `RUN_BYTES` | + +## 5. 自测:对着图能回答这四个问题吗 + +1. 第 7 轮模型调用时 `reserveModelCall` 超限——哪个组件抛异常、Run 变成什么终态、后续调用为什么全部被拒? + (ModelInterceptor 调 beforeModelCall → Core reserveModelCall 抛 BudgetExceededException → exhaustBudget 写 BUDGET_EXHAUSTED + cancel → 之后 checkActive 见终态直接抛 RunAbortedException) + +2. 为什么 Tool 需要"总次数 + 单 Tool 次数"双重限额? + (总量防"调用太多",单 Tool 限额防"死磕同一个工具",比如反复查同一份日志) + +3. 为什么字节预算用 CAS 自旋,计数预算用 synchronized? + (字节是高频原子累加,AtomicLong + compareAndSet 无锁乐观并发;计数是"读-判-写"复合操作,synchronized 保证原子性) + +4. RunBudget 和 ModelCallLedger 都是记消耗,区别在哪? + (Budget 调用前预扣、管允不允许、超限会停止 Run;Ledger 调用后记账、管记了多少、幂等去重,只服务审计对账) + +## 6. RunBudget 字段与组件速查 + +### 6.1 RunBudget 状态字段 + +| 字段 | 类型 | 含义 | +|---|---|---| +| `limits` | `RunBudgetLimits` | 限额定义(不可变) | +| `capacity` | `RunCapacityCounter` | 字节 CAS 计数器 | +| `modelCalls` | int | 累计模型调用轮数 | +| `toolCalls` | int | 累计工具调用次数 | +| `toolCallsByName` | `Map` | 单工具名次数(防死磕) | +| `inputTokens` / `outputTokens` / `totalTokens` | long | 三档累计 token | + +### 6.2 RunBudgetLimits:限额定义(7 字段 + 默认值) + +| 字段 | 默认值 | 来源 | +|---|---|---| +| `maxModelCalls` | 24 | 配置 `harness.chat.*` | +| `maxToolCalls` | 24 | 配置 | +| `maxCallsPerTool` | 8 | 配置 | +| `maxInputTokens` | 100_000 | 配置 | +| `maxOutputTokens` | 100_000 | 配置 | +| `maxTotalTokens` | 200_000 | 配置 | +| `maxRunBytes` | 1_000_000(1MB) | 配置 | + +### 6.3 支撑类型 + +| 类型 | 字段/枚举 | 作用 | +|---|---|---| +| `RunCapacityCounter` | `maxBytes` + `usedBytes`(AtomicLong) | 字节 CAS 自旋计数 | +| `RunBudgetUsage` | modelCalls / toolCalls / toolCallsByName / 三档 token / runBytes | `snapshot()` 只读快照,Run 结束对账 | +| `BudgetExceededException` | `kind` / `limit` / `attempted` | 超限异常,携带具体维度 | +| `BudgetKind` | 7 个枚举 | `MODEL_CALLS` / `TOOL_CALLS` / `TOOL_CALLS_PER_TOOL` / `INPUT_TOKENS` / `OUTPUT_TOKENS` / `TOTAL_TOKENS` / `RUN_BYTES` | + +### 6.4 持有与消费组件 + +| 组件 | 与 budget 的关系 | +|---|---| +| `DiagnosisHarnessCore` | **门禁枢纽**:持有 `RunBudgetLimits`,创建 `RunBudget`;`beforeModelCall` / `beforeToolCall` / `recordTokens` / `reserveRunBytes` 统一入口;超限走 `exhaustBudget` 升级终态 | +| `RunContext` | 持有 `RunBudget` 句柄,随请求显式传递 | +| `HarnessModelInterceptor` | 每轮模型调用:`beforeModelCall`(预扣)+ 调用后 `ModelCallAuditor.recordUsage`(→ `core.recordTokens`) | +| `HarnessToolInterceptor` | 工具请求:`beforeToolCall` 预扣 | +| `ToolBoundary` | `beforeToolCall` + **3 处** `reserveRunBytes`:request 字节、raw response 字节、agent_result 字节 | +| `GuardModelCall` / `DiagnosisAgentUseCase` / `EvidenceRepair` | 各自 `beforeModelCall` + `reserveRunBytes`(输入/输出/Draft/Repair 输入) | + +Token 记账链路:`HarnessModelInterceptor.recordUsage` → `ModelCallAuditor.recordUsage` → `core.recordTokens` → `RunBudget.recordTokens`(三档检查)。 + +### 6.5 配置来源:两层字节控制 + +字节控制有两层,作用域不同: + +```text +单次 payload 上限(每类内容独立闸门,不在 RunBudgetLimits 内): + diagnosis-max-query-bytes: 16384 查询输入 + diagnosis-max-previous-turn-bytes: 16384 历史轮次 + diagnosis-max-input-bytes: 49152 诊断输入合计 + diagnosis-max-draft-bytes: 49152 Draft + semantic-max-input/output-bytes: 100000 / 10000 + repair-max-input/output-bytes: 100000 / 48000 + canonical-max-record-bytes: 1048576 单条 canonical 记录 + canonical-max-agent-result-bytes: 65536 agent_result + +Run 累计上限: + max-run-bytes: 1000000 整个 Run 累计预扣 +``` + +**注意**:`canonicalMaxRecordBytes(1MB)` 是"单条记录"上限,`maxRunBytes(1MB)` 是整个 Run 累计上限——两者都是 1MB 但作用域不同,一条记录就能占满 Run 预算的一半以上。 + +## 7. 预算异常与终态对应 + +### 7.1 异常 → 终态对应表 + +| 异常 | 抛出处 | 携带信息 | 写入/对应终态 | +|---|---|---|---| +| `BudgetExceededException` | `RunBudget`(reserve/record) | `kind` / `limit` / `attempted` | **BUDGET_EXHAUSTED**(写入) | +| `RunAbortedException`(deadline 超时) | `checkActive` 第二道闸 | `RunTermination(TIMED_OUT, ...)` | **TIMED_OUT**(主动写入后抛出) | +| `RunAbortedException`(已取消) | `checkActive` 第三道闸 | 已有终态(如 CANCELLED) | **读取**已有终态,不新写 | +| `RunAbortedException`(终态已存在) | `checkActive` 第一道闸 | 已有终态(可能是任何终态) | **读取**已有终态,不新写 | +| `IllegalArgumentException` | `RunBudgetLimits` 构造 / `RunBudget` 参数 | 校验信息 | **无终态**(Run 开始前 fail fast) | + +### 7.2 两阶段异常:预算超限后的完整路径 + +```text +第一次(reserve/record 超限): + RunBudget 抛 BudgetExceededException(kind/limit/attempted) + → Core 捕获 → exhaustBudget:finish(BUDGET_EXHAUSTED) + cancel + → 异常继续向上抛(可审计"哪个维度爆了") + +之后(任何 checkActive): + termination 已存在 → 抛 RunAbortedException(携带 BUDGET_EXHAUSTED 终态) +``` + +第一枪是预算异常(带维度),之后所有拦截是终止异常(带终态)——两者配合。 + +### 7.3 各消费组件的异常处理 + +| 组件 | 处理 | +|---|---| +| `DiagnosisHarnessCore.applyBudget` | catch `BudgetExceededException` → `exhaustBudget` → 再 throw | +| `GuardModelCall` | `ExecutionException` 的 cause 判断:`instanceof BudgetExceededException` → 原样 rethrow;`CancellationException` → `checkActive` → 可能抛 `RunAbortedException` | +| `HarnessRetryExecutor` | `BudgetExceededException` / `RunAbortedException` 属于**从不重试**类(取消、预算耗尽、协议错误不能被重试吞掉) | + +## 8. 异常三要素:kind / limit / attempted + +### 8.1 三个字段 + +| 字段 | 含义 | 例子 | +|---|---|---| +| `kind` | **哪个资源维度**超限(BudgetKind 枚举) | `MODEL_CALLS` | +| `limit` | 该维度的**限额**(来自 RunBudgetLimits) | `maxModelCalls=24` | +| `attempted` | 本次**试图达到的值**(尝试后的总量,不是超出差额) | `25` | + +### 8.2 attempted 是"尝试后的总量",不是"超出的部分" + +```java +int attempted = modelCalls + 1; // 尝试让计数变成多少 +if (attempted > limits.maxModelCalls()) { + throw new BudgetExceededException(BudgetKind.MODEL_CALLS, + limits.maxModelCalls(), attempted); +} +modelCalls = attempted; +``` + +```text +已调用 24 次(正好达到上限)→ 第 25 次尝试:attempted=25 > 24 +→ 抛异常:kind=MODEL_CALLS, limit=24, attempted=25 +``` + +### 8.3 各维度实际值举例 + +| kind | limit(配置) | attempted | 含义 | +|---|---|---|---| +| `MODEL_CALLS` | 24 | 25 | 第 25 轮模型调用被拒 | +| `TOOL_CALLS` | 24 | 25 | 第 25 次工具调用被拒 | +| `TOOL_CALLS_PER_TOOL` | 8 | 9 | 某个工具第 9 次调用被拒(死磕拦截) | +| `INPUT_TOKENS` | 100_000 | 100_003 | 累计输入 token 超出 3 个 | +| `TOTAL_TOKENS` | 200_000 | 200_500 | 累计总 token 超出 | +| `RUN_BYTES` | 1_000_000 | 1_000_001 | Run 累计字节超出 1 字节 | + +### 8.4 审计价值 + +- `kind` → 定位哪一类资源(token / 次数 / 字节) +- `limit` → 知道配置上限(是否配置太紧) +- `attempted` → 知道差多少爆的(贴线超限说明要调配置,暴涨说明有失控路径) + +## 9. 关联文档 + +| 文档 | 用途 | +|---|---| +| [Harness 面试速查-一张图讲清设计](Harness面试速查-一张图讲清设计.md) | 面试主叙事 + 常见追问 | +| [Harness 设计-非确定性 Agent 的确定性控制边界](Harness设计-非确定性Agent的确定性控制边界.md) | 决策五:预算与信息增益双机制 | +| [Harness 信息增益停止-让无证据诊断正常收敛](Harness信息增益停止-让无证据诊断正常收敛.md) | 预算之外的第二套停止机制 | +| [Harness 异常处理-Loop 内外与状态流](Harness异常处理-Loop内外与状态流.md) | 预算耗尽如何落终态 |