docs(harness): add budget flow sequence and execution control notes

This commit is contained in:
wdm1802
2026-08-02 20:58:13 +08:00
parent 5b2fb985d9
commit d084202166
3 changed files with 418 additions and 0 deletions
@@ -0,0 +1,251 @@
# RunBudget 预算流程:一次 Run 的资源门禁时序图
**用途**:面试讲解 RunBudget 用的聚焦时序图,回答"一次 Run 的资源消耗是如何被门禁控制的"。
**代码基线**:`RunContext` → `RunBudget` + `RunBudgetLimits` + `RunCapacityCounter`
## 1. 在完整 Harness 中的位置(极简上下文)
RunBudget 是 `RunContext` 里的一个执行控制句柄,两个门禁经过它:
```mermaid
flowchart LR
MI["ModelInterceptor<br/>每轮模型调用前"] -->|"beforeModelCall"| CORE["DiagnosisHarnessCore"]
TI["ToolInterceptor / ToolBoundary<br/>每次 Tool 执行前"] -->|"beforeToolCall"| CORE
CORE --> B["RunBudget<br/>预扣 + 超限升级"]
T["Canonical 写入前"] -->|"reserveRunBytes"| CORE
B --> E["BudgetExceededException →<br/>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)<br/>句柄挂到 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<String,Integer>` | 单工具名次数(防死磕) |
| `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) | 预算耗尽如何落终态 |