从零搭建 Agent Harness 系列(二十一)Provider 重试:错误分类、吐字后不重放与 SDK 映射

系列二十收口了阶段八。阶段九要解决的不是权限,而是模型调用太脆:一次 429 或 5xx,整次 Run 就停。工具已经执行过的 write_file / bash 不能跟着再打一遍。

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

1
2
fa1085b  feat: 为模型调用增加可重试错误分类与重试包装
b1557fa feat: 将 OpenAI 错误映射为可重试类型并接入 Retry

一、只重试模型,不重试工具

重试必须包在 Provider 外面,不能写进 Engine 的工具循环。

1
2
3
4
5
Engine.generate

RetryingProvider

OpenAI / Claude

Generate / GenerateStream 失败且可重试,再打一次模型。这一轮已经跑过的工具 Observation 还在 Session 里,不会因为 HTTP 抖动再执行一次。

默认 3 次,间隔 200ms。间隔用 Timer + selectctx,不用 Sleep:Ctrl-C 不必等满退避,定时器用 Stop 拆掉。

取消和截止不重试。context.Canceled / DeadlineExceeded 是 Run 要停,不是瞬时故障。

二、先分类,再决定重不重试

ClassifyError 不认任何一家 SDK。它只看三件事:

1
2
3
Canceled / DeadlineExceeded  →  canceled,不重试
HTTP 429、5xx、net 超时 → retryable
401、400、普通错误 → fatal

HTTP 状态收在自己的 HTTPError 里,errors.As 能穿过外层的 %w。这样 fmt.Errorf("OpenAI/Zhipu API 请求失败: %w", httpErr) 仍然能分出 429。

不要把 *openai.Error 写进 ClassifyError。Claude 以后也会失败,分类器不该依赖某一家客户端。

三、流式:没吐字才能重来

RetryingProvider 实现了 StreamingProvider。只实现 Generate 的话,Engine 会走同步路径,终端看不到打字。底层不会流式时,先走带重试的 Generate,再补一条 completed

流式重试多一条规则:

1
2
3
4
5
6
还没转发 text_delta / completed
且错误可重试
→ 丢掉这次失败,再开一条流

已经把字发给 Reporter
→ 不再重试,把 StreamError 原样往外传

已经吐出的「你好」不能再打一遍「你好」。半截流标成失败,让 Engine 停,而不是当新请求重放。

generatectx.Done() 时会立刻返回,不等生产者 goroutine。生产者自己听同一个 ctxwaitsendStreamEvent、底层 GenerateStream 都会停,然后 defer close。这不是泄漏,是取消后自己收尾。

四、SDK 错误要在出厂时翻译

分类器认 HTTPError,OpenAI SDK 给的是 *openai.Error(带 StatusCode)。翻译放在 openai.go

1
2
3
4
5
6
7
8
9
10
func WrapOpenAIError(prefix string, err error) error {
if err == nil {
return nil
}
var apiErr *openai.Error
if errors.As(err, &apiErr) {
err = &HTTPError{StatusCode: apiErr.StatusCode, Err: err}
}
return fmt.Errorf("%s: %w", prefix, err)
}

同步 Completions.New 和流式 stream.Err() 都走它。buildParams 失败、工具参数非法 JSON 不走:那是本地问题,再打一次请求没有意义。

不是 API 错误的超时、断连不要改成 Fatal。前缀加上之后,里面的 net.Error 还在,分类器仍能判成可重试。Canceled 包完仍是 canceled。

五、接线包在 main,不包进工厂

1
2
3
llmProvider := provider.NewRetryingProvider(
provider.NewOpenAICompatibleProvider("glm-5-2-260617"),
)

NewRuntimeFactory 继续收任意 LLMProvider。测试里的 fake 不该被强制套上 Retry。也不要把 Retry 写进 NewOpenAICompatibleProvider:客户端和重试策略要分开。

ClaudeProvider 这次没映射、没接线,两个入口都没用它。

六、测试要证明什么

分类:429 / 500 / 包装后的 429 / 网络超时可重试;401 / 400 / 普通错误不可重试;取消和截止是 canceled。

Retry:

  1. Generate 连着两次 429,第三次成功,一共 3 次
  2. 401 只打 1 次
  3. 第一次 500 之后取消,不再打下一次
  4. 流式先 StreamError(429) 再成功,外层只看到成功的 delta 和 completed
  5. 先 delta 再 429,不重试,错误原样转发

映射:构造带 StatusCode*openai.Error,走 WrapOpenAIErrorClassifyError。不打真 API。失败断言不要 %v 打印 SDK 错误:Request / Response 为空时,SDK 的 Error() 会 panic。

七、阶段九还没做完的

1
2
3
4
5
6
7
CostTracker 能记账,但 main 没包,超了也不会停 Run
它只实现了 Generate,包在最外层会丢掉流式
单次 Run / Session 的 Token 与费用上限
首 Token 超时
流式半响应单独标错
固定 200ms 还不是指数退避
Claude 的 HTTP 状态还没翻译

路线图里的工具时长、Subagent 次数可以后做。重试已经保证「只打模型」,预算停 Run 时也不该回头重放工具。

总结

阶段九第一刀是让失败可分类、可重试,并且不碰工具:

1
2
3
4
5
6
7
8
9
ClassifyError 只认 HTTPError / net.Error / context

Retry 只包 Generate / GenerateStream

流式吐字后不重放

openai.go 把 SDK 错误收成 HTTPError

两个入口在工厂外面包 NewRetryingProvider

下一刀是预算:超了用中文错误结束当前 Run,记账还要能跟上流式。