从零搭建 Agent Harness 系列(二十二)预算停 Run、指数退避、首 Token 与半响应

系列二十一让失败可分类、可重试,并且不碰工具。当时留下的清单里,有几条已经可以收口:

1
2
3
4
5
6
Token 超了不会停 Run
CostTracker 只实现 Generate,包上去会丢掉流式
固定 200ms 还不是指数退避
没有单独的模型调用超时
没有首 Token 超时
流式半响应只是普通 error

后来又补上单次 Run Token、派出次数和工具墙钟,并让 RunSub 共用同一本账。

本文对应 go-tiny-claw 这几次提交:

1
2
3
4
5
6
7
8
9
3b430d6  feat: 在主循环与 RunSub 的 trace 中记录 Token 消耗
ce7e757 feat: 在模型调用前检查 Session Token 预算
93d3b76 feat: 为模型重试增加指数退避与单次调用超时
9aa7f78 feat: 为流式模型调用增加首 Token 超时
06682fe feat: 将流式半响应标成不可重试错误
e3a2701 feat: 增加单次 Run 的 Token 预算并与会话累计分开
24ddcff feat: 为生产 Engine 设置单次 Run Token 上限
6a7421b feat: 限制单次运行的工具墙钟时间和 Subagent 派出次数
7e230a6 refactor: 拆分 Engine 循环并让 RunSub 共用运行预算

一、预算放在 Engine,不放在 Provider 外套

二十一提过:不要再做一个只实现 GenerateCostTracker 挡在最外层。流式会被它吃掉。记账和停 Run 都应该读 Session 里已经有的 Usage。

最后删掉了那个装饰器。数字进 Session,闸门在 AgentEngine

1
2
3
checkBudget       每次 generate 之前,读 Session 累计
checkRunBudget 同样在 generate 之前,读本次 Run 增量
recordUsage 这次 generate 成功之后

Session.TotalTokens() 加锁读 TotalPromptTokens + TotalCompletionTokensMaxTokens <= 0 视为不限,单元测试保持这个默认。工厂里会话上限 200000

MaxTokens 计量的是 Session,不是单次 RunGetOrCreate 同一个 ID,账一直加,REPL 下一发不会从零算。报错是「已超过本会话的 Token 预算」。

语义是停 下一次 模型调用,不是把已经打完的最后一句作废。第一次 Action 用满上限、没有工具要跑,应成功。第一次带了工具、累计已经顶到上限,下一轮 Action 前再拦。

Trace 只是把同一份 Usage 写到 Thinking / Action / RunSub 的 span 上,不参与停 Run。

二、单次 Run、派出次数、工具墙钟

只靠会话 Token 挡不住「这一发工具循环把账烧光」。后来加了三道按 一次 Run 计的闸,<= 0 都不限:

工厂默认计量
MaxTokensPerRun100000TotalTokens() - runStart,停下一枪模型
MaxSubagents3RunSub 入口加锁计数,Run() 开头清零
MaxToolTime2 分钟各 Turn 工具波次的墙钟加总,不是每轮重置

单 Turn Token 没做:没开 Thinking 时一轮就是一枪,枪前增量永远是 0。卡一次 Run 比卡一轮更合理。

工具时长不要套在整个 Runctx 上,否则模型也会被掐。toolBatchContext 只包 Execute。并行同一波只算这一波墙钟,不加总每个 goroutine。审批等待不算。bash 的 30s 仍是单次命令。2 分钟是保险丝,构建/测试多的任务会先撞它。

RunSub 现在接同一套:主循环进行中就写进主 Session、累加主 toolElapsed;单独调用则用临时 Session 和自己的计时。子循环仍最多 10 轮。MaxTurns(主 20)管不着子循环。Retry / CallTimeout / 首 Token 在 Provider 上,子循环同样有效。

循环拆开之后,预算在 budget.go,工具波次在 tools_batch.goRun / RunSub 共用 runToolBatch

三、退避按失败次数加倍,封顶 2 秒

Backoff 仍是第一档,默认 200ms。第 n 次失败后等 Backoff * 2^(n-1),封顶 MaxBackoff(默认 2s)。Backoff == 0 立即重试,旧测试不用睡。

wait 继续用 Timer + select 听父 ctx。Ctrl-C 不必等满退避。

四、两层超时,不要共用一个 ctx

整次尝试用 CallTimeout(默认 60s),包在 attemptContext 里传给底层。这管「这一枪从开打到结束」。

首 Token 不能写进同一个 ctx。字已经出来之后,那个 timeout 一到会把还在流的 HTTP 掐死。所以首 Token(默认 15s)是消费循环上的定时器:

1
2
3
4
attemptCtx = CallTimeout          整次尝试
firstToken 定时器 通道已开、还没有任何事件
任意事件到达(delta / completed / error) 立刻停表
之后再慢只走 CallTimeout

select 三路:父 ctx、首 Token 表、inner。父 ctx 和定时器同时就绪时,Go 会随机选一条,所以走进 case <-firstToken 还要再看一次 ctx.Err()。否则 Ctrl-C 会被写成「模型首 Token 超时」,还可能按 DeadlineExceeded 再打一次。

同步 Generate 没有「第一个字」和「整段回复」的区别,只保留 CallTimeoutFirstTokenTimeout == 0 关掉首 Token 表,测试默认这么做。

文案分开:模型调用超时 对整次尝试,模型首 Token 超时 对还没出字。两者都包着 DeadlineExceededshouldRetry 认它,没出字可以重试。

五、吐字之后再断,是半响应,不是 429

二十一已经规定:转发过 text_delta / completed 不再重试。缺的是类型。吐字后的 429 如果原样往外扔,ClassifyError 仍会说可重试。出字后的 CallTimeout 会变成 canceled

所以加 PartialResponseError,里面留着原来的原因。ClassifyError 认半响应,再看取消和 HTTP:

1
2
3
4
PartialResponseError     →  fatal,不重试
Canceled / DeadlineExceeded → canceled
HTTP 429 / 5xx / net 超时 → retryable
其余 → fatal

wrapPartial 已经是半响应就不再包一层。

只在用户已经看到字时包装:

1
2
3
forwarded 且收到 StreamError     → wrapPartial(父 ctx 取消除外)
forwarded 且通道空关 → wrapPartial(未返回最终消息)
没出字就结束 → 普通错误,不是半响应

本仓库工具调用不单独推 delta,只出现在 completed 里,所以 completed 也算「已经转发」。父 ctx 取消仍返回 ctx.Err(),不要写成半响应。

六、测试要证明什么

会话 Token:

  1. 第一次 generate 用满上限且还要再打,第二次不准发出去
  2. 最后一轮答完、后面没有 generate,用满上限也成功

单次 Run Token:会话里已有 100,本次运行用满 10 会停下一枪;最后一枪答完不再打则成功。文案是「本次运行」,不是「本会话」。

派出次数:MaxSubagents = 1 时第二次 RunSub 失败且不再打模型;新的 Run() 会清零。

工具墙钟:卡住的工具听 toolCtx,30ms 后报「工具时间预算」,不再打下一枪;工具很快结束则跑完第二枪。

RunSub:自己也能顶满 Run Token / 工具时间;从主循环派出时,Token 记进主 Session。

退避:200ms → 400ms,封顶 500ms;Backoff = 0 等待为 0。

整次超时:堵在 ctx.Done() 上的 GenerateCallTimeout = 20ms、两次尝试,报「模型调用超时」。父 ctx 已取消,一次都不打。

首 Token:

  1. 开口 1 秒才出字,20ms 超时,重试 2 次,报「模型首 Token 超时」
  2. 10ms 出字、200ms 限额,只打一次
  3. 先吐 hello 再卡到 CallTimeout:只打一次,错误是「模型调用超时」,不是首 Token

半响应:

  1. 半响应包着 429 或 DeadlineExceeded,分类都是 fatal
  2. 先 delta 再 429:不重试,外层是 PartialResponseError,仍能 errors.As 出 429
  3. 吐字后通道直接关:半响应,不重试
  4. 上面第 3 条尾包超时,现在也是半响应,文案里还能看到「模型调用超时」

七、阶段九还没做完的

1
2
TotalCostCNY 一直是 0,recordUsage 第三个参数没算钱
Claude 的 HTTP 状态还没翻译(入口没用它)

可靠性可以停。Token、派出次数、工具墙钟都已能停 Run。费用仍是空账,先跳过计价。

阶段十是 Session 落盘和任务恢复。它依赖「一次 Run 能干净失败」。Token 闸门已经能停,费用闸门不是前置条件。

总结

二十一解决「失败之后怎么办」。二十二补上「什么时候不准再打」和「流式失败分别是什么」:

1
2
3
4
5
6
7
8
9
10
11
Session 累计 Token + 单次 Run 增量,generate 前检查

派出次数卡在 RunSub 入口;工具墙钟只套 Execute

RunSub 挂在主 Run 上时共用同一本账

Backoff 加倍,封顶 2s

CallTimeout 管整次;首 Token 只盯还没出字

已吐字再断 → PartialResponseError,fatal

下一阶段是 Session 落盘:先能存历史和 Token,先不做「卡在工具执行中」的崩溃恢复。