从零搭建 Agent Harness 系列(二十一)Provider 重试:错误分类、吐字后不重放与 SDK 映射
系列二十收口了阶段八。阶段九要解决的不是权限,而是模型调用太脆:一次 429 或 5xx,整次 Run 就停。工具已经执行过的 write_file / bash 不能跟着再打一遍。
本文对应 go-tiny-claw 两次提交:
1 | fa1085b feat: 为模型调用增加可重试错误分类与重试包装 |
一、只重试模型,不重试工具
重试必须包在 Provider 外面,不能写进 Engine 的工具循环。
1 | Engine.generate |
Generate / GenerateStream 失败且可重试,再打一次模型。这一轮已经跑过的工具 Observation 还在 Session 里,不会因为 HTTP 抖动再执行一次。
默认 3 次,间隔 200ms。间隔用 Timer + select 听 ctx,不用 Sleep:Ctrl-C 不必等满退避,定时器用 Stop 拆掉。
取消和截止不重试。context.Canceled / DeadlineExceeded 是 Run 要停,不是瞬时故障。
二、先分类,再决定重不重试
ClassifyError 不认任何一家 SDK。它只看三件事:
1 | Canceled / DeadlineExceeded → canceled,不重试 |
HTTP 状态收在自己的 HTTPError 里,errors.As 能穿过外层的 %w。这样 fmt.Errorf("OpenAI/Zhipu API 请求失败: %w", httpErr) 仍然能分出 429。
不要把 *openai.Error 写进 ClassifyError。Claude 以后也会失败,分类器不该依赖某一家客户端。
三、流式:没吐字才能重来
RetryingProvider 实现了 StreamingProvider。只实现 Generate 的话,Engine 会走同步路径,终端看不到打字。底层不会流式时,先走带重试的 Generate,再补一条 completed。
流式重试多一条规则:
1 | 还没转发 text_delta / completed |
已经吐出的「你好」不能再打一遍「你好」。半截流标成失败,让 Engine 停,而不是当新请求重放。
generate 在 ctx.Done() 时会立刻返回,不等生产者 goroutine。生产者自己听同一个 ctx:wait、sendStreamEvent、底层 GenerateStream 都会停,然后 defer close。这不是泄漏,是取消后自己收尾。
四、SDK 错误要在出厂时翻译
分类器认 HTTPError,OpenAI SDK 给的是 *openai.Error(带 StatusCode)。翻译放在 openai.go:
1 | func WrapOpenAIError(prefix string, err error) error { |
同步 Completions.New 和流式 stream.Err() 都走它。buildParams 失败、工具参数非法 JSON 不走:那是本地问题,再打一次请求没有意义。
不是 API 错误的超时、断连不要改成 Fatal。前缀加上之后,里面的 net.Error 还在,分类器仍能判成可重试。Canceled 包完仍是 canceled。
五、接线包在 main,不包进工厂
1 | llmProvider := provider.NewRetryingProvider( |
NewRuntimeFactory 继续收任意 LLMProvider。测试里的 fake 不该被强制套上 Retry。也不要把 Retry 写进 NewOpenAICompatibleProvider:客户端和重试策略要分开。
ClaudeProvider 这次没映射、没接线,两个入口都没用它。
六、测试要证明什么
分类:429 / 500 / 包装后的 429 / 网络超时可重试;401 / 400 / 普通错误不可重试;取消和截止是 canceled。
Retry:
Generate连着两次 429,第三次成功,一共 3 次- 401 只打 1 次
- 第一次 500 之后取消,不再打下一次
- 流式先
StreamError(429)再成功,外层只看到成功的 delta 和 completed - 先 delta 再 429,不重试,错误原样转发
映射:构造带 StatusCode 的 *openai.Error,走 WrapOpenAIError 再 ClassifyError。不打真 API。失败断言不要 %v 打印 SDK 错误:Request / Response 为空时,SDK 的 Error() 会 panic。
七、阶段九还没做完的
1 | CostTracker 能记账,但 main 没包,超了也不会停 Run |
路线图里的工具时长、Subagent 次数可以后做。重试已经保证「只打模型」,预算停 Run 时也不该回头重放工具。
总结
阶段九第一刀是让失败可分类、可重试,并且不碰工具:
1 | ClassifyError 只认 HTTPError / net.Error / context |
下一刀是预算:超了用中文错误结束当前 Run,记账还要能跟上流式。