从零搭建 Agent Harness 系列(二十二)预算停 Run、指数退避、首 Token 与半响应
系列二十一让失败可分类、可重试,并且不碰工具。当时留下的清单里,有几条已经可以收口:
1 | Token 超了不会停 Run |
本文对应 go-tiny-claw 这几次提交:
1 | 3b430d6 feat: 在主循环与 RunSub 的 trace 中记录 Token 消耗 |
一、预算放在 Engine,不放在 Provider 外套
二十一提过:不要再做一个只实现 Generate 的 CostTracker 挡在最外层。流式会被它吃掉。记账和停 Run 都应该读 Session 里已经有的 Usage。
最后删掉了那个装饰器。数字进 Session,闸门在 AgentEngine:
1 | checkBudget 每次 generate 之前 |
Session.TotalTokens() 加锁读 TotalPromptTokens + TotalCompletionTokens。MaxTokens <= 0 视为不限,单元测试保持这个默认。工厂里给生产 Engine 设 200000。
语义是停 下一次 模型调用,不是把已经打完的最后一句作废。第一次 Action 用满 10 个 Token、没有工具要跑,Run 应成功。第一次带了工具、累计已经顶到上限,下一轮 Action 前再拦。RunSub 没有 Session,这轮不接预算。
中文错误:已超过本次任务的 Token 预算(已用 %d,上限 %d)。
Trace 只是把同一份 Usage 写到 Thinking / Action / RunSub 的 span 上,方便以后看,不参与停 Run。
二、退避按失败次数加倍,封顶 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(),不要写成半响应。
五、测试要证明什么
预算:
- 第一次 generate 用满上限且还要再打,第二次不准发出去
- 最后一轮答完、后面没有 generate,用满上限也成功
退避: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,费用还是空账。
阶段十是 Session 落盘和任务恢复。它依赖「一次 Run 能干净失败」。Token 闸门已经能停,费用闸门不是前置条件。
总结
二十一解决「失败之后怎么办」。二十二补上「什么时候不准再打」和「流式失败分别是什么」:
1 | Session 累计 Token,generate 前检查 |
下一刀如果继续留在阶段九,就是把 Usage 换成人民币写入 TotalCostCNY。要进阶段十,也可以先让 Session 能落盘。