12 KiB
RunBudget 预算流程:一次 Run 的资源门禁时序图
用途:面试讲解 RunBudget 用的聚焦时序图,回答"一次 Run 的资源消耗是如何被门禁控制的"。
代码基线:RunContext → RunBudget + RunBudgetLimits + RunCapacityCounter
1. 在完整 Harness 中的位置(极简上下文)
RunBudget 是 RunContext 里的一个执行控制句柄,两个门禁经过它:
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 流程时序图(核心)
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. 五个流程节点
- 创建:
startRun里new RunBudget(RunBudgetLimits),限额不可变,消耗状态可变,句柄随 RunContext 显式传递。 - 模型调用前:
reserveModelCall()synchronized 预扣,超限抛异常。 - Tool 调用前:
reserveToolCall(toolName)双重限额——总次数 + 单 Tool 次数。 - Canonical 写入前:
reserveRunBytes(bytes)走 CAS 计数器。 - 调用后:
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. 自测:对着图能回答这四个问题吗
-
第 7 轮模型调用时
reserveModelCall超限——哪个组件抛异常、Run 变成什么终态、后续调用为什么全部被拒? (ModelInterceptor 调 beforeModelCall → Core reserveModelCall 抛 BudgetExceededException → exhaustBudget 写 BUDGET_EXHAUSTED + cancel → 之后 checkActive 见终态直接抛 RunAbortedException) -
为什么 Tool 需要"总次数 + 单 Tool 次数"双重限额? (总量防"调用太多",单 Tool 限额防"死磕同一个工具",比如反复查同一份日志)
-
为什么字节预算用 CAS 自旋,计数预算用 synchronized? (字节是高频原子累加,AtomicLong + compareAndSet 无锁乐观并发;计数是"读-判-写"复合操作,synchronized 保证原子性)
-
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 配置来源:两层字节控制
字节控制有两层,作用域不同:
单次 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 两阶段异常:预算超限后的完整路径
第一次(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 是"尝试后的总量",不是"超出的部分"
int attempted = modelCalls + 1; // 尝试让计数变成多少
if (attempted > limits.maxModelCalls()) {
throw new BudgetExceededException(BudgetKind.MODEL_CALLS,
limits.maxModelCalls(), attempted);
}
modelCalls = attempted;
已调用 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 设计-非确定性 Agent 的确定性控制边界 | 决策五:预算与信息增益双机制 |
| Harness 信息增益停止-让无证据诊断正常收敛 | 预算之外的第二套停止机制 |
| Harness 异常处理-Loop 内外与状态流 | 预算耗尽如何落终态 |