从零搭建 Agent Harness 系列(二十二)预算停 Run、指数退避、首 Token 与半响应
系列二十一让失败可分类、可重试,并且不碰工具。当时留下的清单里,有几条已经可以收口:
1 | Token 超了不会停 Run |
后来又补上单次 Run Token、派出次数和工具墙钟,并让 RunSub 共用同一本账。
本文对应 go-tiny-claw 这几次提交:
1 | 3b430d6 feat: 在主循环与 RunSub 的 trace 中记录 Token 消耗 |
一、预算放在 Engine,不放在 Provider 外套
二十一提过:不要再做一个只实现 Generate 的 CostTracker 挡在最外层。流式会被它吃掉。记账和停 Run 都应该读 Session 里已经有的 Usage。
最后删掉了那个装饰器。数字进 Session,闸门在 AgentEngine:
1 | checkBudget 每次 generate 之前,读 Session 累计 |
Session.TotalTokens() 加锁读 TotalPromptTokens + TotalCompletionTokens。MaxTokens <= 0 视为不限,单元测试保持这个默认。工厂里会话上限 200000。
MaxTokens 计量的是 Session,不是单次 Run。GetOrCreate 同一个 ID,账一直加,REPL 下一发不会从零算。报错是「已超过本会话的 Token 预算」。
语义是停 下一次 模型调用,不是把已经打完的最后一句作废。第一次 Action 用满上限、没有工具要跑,应成功。第一次带了工具、累计已经顶到上限,下一轮 Action 前再拦。
Trace 只是把同一份 Usage 写到 Thinking / Action / RunSub 的 span 上,不参与停 Run。
二、单次 Run、派出次数、工具墙钟
只靠会话 Token 挡不住「这一发工具循环把账烧光」。后来加了三道按 一次 Run 计的闸,<= 0 都不限:
| 闸 | 工厂默认 | 计量 |
|---|---|---|
MaxTokensPerRun | 100000 | TotalTokens() - runStart,停下一枪模型 |
MaxSubagents | 3 | RunSub 入口加锁计数,Run() 开头清零 |
MaxToolTime | 2 分钟 | 各 Turn 工具波次的墙钟加总,不是每轮重置 |
单 Turn Token 没做:没开 Thinking 时一轮就是一枪,枪前增量永远是 0。卡一次 Run 比卡一轮更合理。
工具时长不要套在整个 Run 的 ctx 上,否则模型也会被掐。toolBatchContext 只包 Execute。并行同一波只算这一波墙钟,不加总每个 goroutine。审批等待不算。bash 的 30s 仍是单次命令。2 分钟是保险丝,构建/测试多的任务会先撞它。
RunSub 现在接同一套:主循环进行中就写进主 Session、累加主 toolElapsed;单独调用则用临时 Session 和自己的计时。子循环仍最多 10 轮。MaxTurns(主 20)管不着子循环。Retry / CallTimeout / 首 Token 在 Provider 上,子循环同样有效。
循环拆开之后,预算在 budget.go,工具波次在 tools_batch.go,Run / 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 | attemptCtx = CallTimeout 整次尝试 |
select 三路:父 ctx、首 Token 表、inner。父 ctx 和定时器同时就绪时,Go 会随机选一条,所以走进 case <-firstToken 还要再看一次 ctx.Err()。否则 Ctrl-C 会被写成「模型首 Token 超时」,还可能按 DeadlineExceeded 再打一次。
同步 Generate 没有「第一个字」和「整段回复」的区别,只保留 CallTimeout。FirstTokenTimeout == 0 关掉首 Token 表,测试默认这么做。
文案分开:模型调用超时 对整次尝试,模型首 Token 超时 对还没出字。两者都包着 DeadlineExceeded,shouldRetry 认它,没出字可以重试。
五、吐字之后再断,是半响应,不是 429
二十一已经规定:转发过 text_delta / completed 不再重试。缺的是类型。吐字后的 429 如果原样往外扔,ClassifyError 仍会说可重试。出字后的 CallTimeout 会变成 canceled。
所以加 PartialResponseError,里面留着原来的原因。ClassifyError 先认半响应,再看取消和 HTTP:
1 | PartialResponseError → fatal,不重试 |
wrapPartial 已经是半响应就不再包一层。
只在用户已经看到字时包装:
1 | forwarded 且收到 StreamError → wrapPartial(父 ctx 取消除外) |
本仓库工具调用不单独推 delta,只出现在 completed 里,所以 completed 也算「已经转发」。父 ctx 取消仍返回 ctx.Err(),不要写成半响应。
六、测试要证明什么
会话 Token:
- 第一次 generate 用满上限且还要再打,第二次不准发出去
- 最后一轮答完、后面没有 generate,用满上限也成功
单次 Run Token:会话里已有 100,本次运行用满 10 会停下一枪;最后一枪答完不再打则成功。文案是「本次运行」,不是「本会话」。
派出次数:MaxSubagents = 1 时第二次 RunSub 失败且不再打模型;新的 Run() 会清零。
工具墙钟:卡住的工具听 toolCtx,30ms 后报「工具时间预算」,不再打下一枪;工具很快结束则跑完第二枪。
RunSub:自己也能顶满 Run Token / 工具时间;从主循环派出时,Token 记进主 Session。
退避:200ms → 400ms,封顶 500ms;Backoff = 0 等待为 0。
整次超时:堵在 ctx.Done() 上的 Generate,CallTimeout = 20ms、两次尝试,报「模型调用超时」。父 ctx 已取消,一次都不打。
首 Token:
- 开口 1 秒才出字,20ms 超时,重试 2 次,报「模型首 Token 超时」
- 10ms 出字、200ms 限额,只打一次
- 先吐
hello再卡到CallTimeout:只打一次,错误是「模型调用超时」,不是首 Token
半响应:
- 半响应包着 429 或
DeadlineExceeded,分类都是 fatal - 先 delta 再 429:不重试,外层是
PartialResponseError,仍能errors.As出 429 - 吐字后通道直接关:半响应,不重试
- 上面第 3 条尾包超时,现在也是半响应,文案里还能看到「模型调用超时」
七、阶段九还没做完的
1 | TotalCostCNY 一直是 0,recordUsage 第三个参数没算钱 |
可靠性可以停。Token、派出次数、工具墙钟都已能停 Run。费用仍是空账,先跳过计价。
阶段十是 Session 落盘和任务恢复。它依赖「一次 Run 能干净失败」。Token 闸门已经能停,费用闸门不是前置条件。
总结
二十一解决「失败之后怎么办」。二十二补上「什么时候不准再打」和「流式失败分别是什么」:
1 | Session 累计 Token + 单次 Run 增量,generate 前检查 |
下一阶段是 Session 落盘:先能存历史和 Token,先不做「卡在工具执行中」的崩溃恢复。