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

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

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

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

1
2
3
4
5
3b430d6  feat: 在主循环与 RunSub 的 trace 中记录 Token 消耗
ce7e757 feat: 在模型调用前检查 Session Token 预算
93d3b76 feat: 为模型重试增加指数退避与单次调用超时
9aa7f78 feat: 为流式模型调用增加首 Token 超时
06682fe feat: 将流式半响应标成不可重试错误

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

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

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

1
2
checkBudget    每次 generate 之前
recordUsage 这次 generate 成功之后

Session.TotalTokens() 加锁读 TotalPromptTokens + TotalCompletionTokensMaxTokens <= 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
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(),不要写成半响应。

五、测试要证明什么

预算:

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

退避: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
3
4
5
TotalCostCNY 一直是 0,recordUsage 第三个参数没算钱
没有单 Turn Token 上限
没有 Run 级工具总时长
主任务派出 Subagent 的次数没限
Claude 的 HTTP 状态还没翻译(入口没用它)

可靠性这一侧可以停:失败可分类、只重试模型、指数退避、两层超时、吐字后既不重放也能被认出来。预算现在只有 Token,费用还是空账。

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

总结

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

1
2
3
4
5
6
7
8
9
Session 累计 Token,generate 前检查

工厂默认 20 万,测试默认不限

Backoff 加倍,封顶 2s

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

已吐字再断 → PartialResponseError,fatal

下一刀如果继续留在阶段九,就是把 Usage 换成人民币写入 TotalCostCNY。要进阶段十,也可以先让 Session 能落盘。