ByteNoteByteNote
重试别只加 sleep:指数退避与 equal jitter
字

字节笔记本

2026年10月24日 · 约 15 分钟读完

重试别只加 sleep:指数退避与 equal jitter

API中转
¥120

模型配置:模型选择与上下文窗口

本文是《从 0 写 Agent》系列第 22 章。这个系列基于开源项目 agents-go(OpenAI Agents SDK 的 Go 移植版),用 Go 从零实现一个生产级 Agent 运行时;本章讲「Retry:指数退避与 equal jitter」。

模型 API 不是 100% 可靠,会超时、会 429 限流、会 500。没有重试机制,一次抖动就让 Agent 失败。

agents-go 的 NewRetryModel 装饰器用几何退避 + equal jitter 算法重试。这一章拆这个算法,以及一个关键约束,流式重试只在"首 event 前"能做。


引子:模型 API 的不可靠

调 OpenAI/Anthropic API,你会遇到:

  • 429 Too Many Requests,限流,稍后再试
  • 500/502/503,服务端临时故障
  • 超时,网络抖动或服务端慢
  • 连接重置,网络问题

这些都是"临时性错误",等一下再试就好了。 但如果你不重试,用户看到的就是"Agent 挂了"。生产环境必须有重试。


成本管理:token 用量与预算

原理:为什么不能"立即重试"

最朴素的重试,失败就立即重试。但这有个大问题,惊群。

想象 OpenAI 刚限流了 1000 个用户。这 1000 个用户的 Agent 都立即重试 → 1000 个请求同时打过去 → OpenAI 又限流 → 又同时重试 → 永远恢复不了。

解法是"退避",每次失败,等更久再试。 让负载逐渐恢复,而不是持续冲击。

几何退避

最常用的退避,几何增长。每次失败,等待时间翻倍:

text
第1次失败 → 等 500ms → 重试
第2次失败 → 等 1000ms → 重试
第3次失败 → 等 2000ms → 重试
第4次失败 → 等 4000ms → 重试
...

公式:delay = base * multiplier^(attempt-1),其中 base=500ms,multiplier=2。

这让负载"逐渐"恢复,第一次冲击后等 500ms,给服务端喘息;还不行就等 1s,更多喘息。

Equal Jitter,防止同步

但几何退避还有个问题,所有客户端的退避节奏一样。1000 个用户都在第 1 次失败后等 500ms,500ms 后又同时重试。

Equal Jitter 的解法,在退避时间上加随机抖动:

text
agents-go 的做法(model_retry.go:128):
half := d / 2
delay = half + rand.Float64() * half   // 落在 [d/2, d]

延迟落在 [d/2, d] 区间,每个客户端等不同的时间,避免同步冲击。这叫"equal jitter"(一半固定 + 一半随机)。


源码对照:RetryPolicy 和 backoff

RetryPolicy(model_retry.go:15)配置重试行为:

go
// agents/model_retry.go:15
type RetryPolicy struct {
    MaxAttempts int           // 总尝试次数(含首次),默认 3
    BaseDelay   time.Duration // 第二次尝试前的等待,默认 500ms
    MaxDelay    time.Duration // 等待上限,默认 30s
    Multiplier  float64       // 几何增长因子,默认 2
    RetryIf     func(error) bool  // 哪些错误该重试
    RetryAfter  func(error) (time.Duration, bool)  // 从错误里提取服务端建议的延迟
    Sleep       func(ctx, d) error  // 测试可注入假实现
}

backoff(model_retry.go:111)是核心算法:

go
// agents/model_retry.go:111(骨架)
func (p RetryPolicy) backoff(attempt int) time.Duration {
    base := 500 * time.Millisecond   // 默认
    limit := 30 * time.Second        // 默认上限
    mult := 2.0                      // 默认乘数

    d := float64(base) * math.Pow(mult, float64(attempt-1))  // 几何增长
    if d > float64(limit) {
        d = float64(limit)  // 封顶
    }
    half := d / 2
    return time.Duration(half + rand.Float64()*half)  // equal jitter
}

四步,几何增长、封顶、分半、随机抖动。 注意 math.Pow(mult, attempt-1),第 1 次重试是 mult^0=1(base delay),第 2 次是 mult^1(翻倍),依此类推。

MaxDelay 封顶的重要性

没有 MaxDelay,几何增长会失控,第 10 次重试等 500ms * 2^9 = 256 秒。用户等不了这么久。MaxDelay(默认 30s)保证最坏情况等待可控。


设计权衡:哪些错误该重试

DefaultRetryIf(model_retry.go:84)的默认策略:

go
// agents/model_retry.go:84
func DefaultRetryIf(err error) bool {
    if err == nil { return false }
    // context 取消/超时不重试(重试没意义,调用方已经放弃了)
    if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
        return false
    }
    return true  // 其他都重试
}

默认重试一切,除了 context 取消。 为什么?

  • context 取消意味着调用方主动放弃了,重试没意义(结果没人要了)
  • 其他错误(429/500/超时/网络)都可能是临时的,值得重试

但这个默认可能太激进,比如 400 Bad Request(参数错误)重试 100 次还是 400。所以 agents-go 允许自定义 RetryIf:

go
// 用 OpenAI 专用的错误分类:只重试 429/5xx,不重试 4xx
policy := agents.RetryPolicy{
    RetryIf: openai.RetryableError,  // 429/5xx 才重试
}

好的重试策略要区分"临时错误"(重试有意义)和"永久错误"(重试浪费)。 默认宽松(全重试),生产环境建议配精确的 RetryIf。


RetryAfter:尊重服务端的建议

有些错误带 Retry-After 头,服务端告诉你"多久后再试"。RetryAfter(model_retry.go:39)提取这个值:

go
policy := agents.RetryPolicy{
    RetryAfter: openai.RetryAfter,  // 从 429 的 Retry-After 头提取
}

如果服务端建议等 10 秒,就等 10 秒,不用自己算的几何退避。 服务端最清楚自己什么时候能恢复。wait 函数(model_retry.go:134)优先用 RetryAfter,没有才用 backoff。


设计权衡:流式重试的"首 event 前"约束

这是 retry 最微妙的设计。回忆第 2 章,流式调用一旦吐过 token,就不能重试。原因:重试会让模型从头生成,已经吐给用户的 token 和新生成的不一致。

agents-go 的流式重试(model_retry.go:194)用 producedAny 标志实现这个约束:

go
// agents/model_retry.go:194(骨架)
producedAny := false
for event, err := range inner.StreamResponse(ctx, req) {
    if err != nil && !producedAny {
        // 还没吐过 event,可以重试
        if shouldRetry {
            continue  // 重新开始流式
        }
    }
    producedAny = true  // 吐过 event 了
    yield(event, err)   // 后续错误直通,不重试
}

producedAny 标志,一旦为 true(吐过 event),后续错误直通不重试。

这是工程纪律,"宁可让用户看到一个失败,也不要给他看一个错乱的重试结果"。 流式场景下,部分输出比"看起来成功但内容变了"更诚实。


自定义 JSON 编解码:配置友好

注意 RetryPolicy 有自定义的 MarshalJSON/UnmarshalJSON(model_retry.go:59-80),把 time.Duration 转成毫秒整数:

go
type retryPolicyJSON struct {
    MaxAttempts int     `json:"max_attempts"`
    BaseDelayMs int     `json:"base_delay_ms"`  // 毫秒整数
    MaxDelayMs  int     `json:"max_delay_ms"`
    Multiplier  float64 `json:"multiplier"`
}

为什么? 因为 time.Duration 的默认 JSON 序列化是纳秒整数(500000000 表示 500ms),对人不可读。转成毫秒(500)后,配置文件里写 "base_delay_ms": 500 一目了然。

这让 RetryPolicy 可以直接从配置数据库 json.Unmarshal,agents-server(Part VI)就是这么存重试配置的。工程细节,但很重要。


RetryModel 是装饰器

NewRetryModel(model_retry.go:170)返回的还是 Model 接口,它包装另一个 Model,给它加重试能力:

go
// 用法
baseModel := openai.NewProvider().GetModel("gpt-5")
retryModel := agents.NewRetryModel(baseModel, agents.RetryPolicy{
    MaxAttempts: 5,
})

// retryModel 还是 Model 接口,Agent 循环无感知
agents.Run(ctx, agent, input, agents.RunOptions{
    Model: retryModel,
})

装饰器模式,接口不变,能力增加。 这让 retry 可以和 Fallback(第 23 章)、Router(第 21 章)任意组合。第 24 章会讲怎么组合。


小结

读完这一章,你应该理解:

  1. 为什么要退避,防惊群,让服务端有机会恢复
  2. 几何退避,delay = base * mult^(attempt-1),翻倍增长
  3. Equal Jitter,half + rand*half,落在 [d/2, d],防同步
  4. MaxDelay 封顶,防失控,默认 30s
  5. DefaultRetryIf,重试一切除了 context 取消
  6. 流式"首 event 前"约束,吐过 token 不重试,避免内容错乱
  7. 装饰器模式,RetryModel 实现同一接口,可任意组合

Retry 让模型调用"容忍临时故障"。 但如果某个模型彻底挂了(不是临时抖动),retry 也没用,这时需要 Fallback(降级到备用模型)。下一章讲。


下一篇

第 23 章,Fallback:模型降级链。当主模型彻底不可用,自动切到备用模型。agents-go 的推荐组合,"每个模型先包 Retry,再组 Fallback",让每个 provider 先耗尽自己的重试,再降级。


本文是《从 0 写 Agent》系列第 22 章,案例代码基于开源项目 agents-go(OpenAI Agents SDK 的 Go 移植版)。全系列目录见总览。

相关文章

分享: