线上最可怕的不是一次失败,而是失败后被重试放大。
在 OpenAI Responses + Go 的工具调用链路里,如果没有幂等键、退避抖动和熔断阈值,10 个请求很快就能打成 1000 个下游调用,账单和延迟一起爆炸。
先给结论:三道闸门缺一不可
- 幂等键:同一业务动作只能“生效一次”。
- 退避 + 抖动:失败重试要“错峰”,避免同步风暴。
- 熔断阈值:错误率超线就快速失败,给系统喘息窗口。
典型事故链路(为什么会风暴)
常见错误配置:
- HTTP client timeout 过短(例如 3s)
- 网关重试 3 次 + 业务层再重试 3 次
- 工具执行无幂等控制(重复写库/重复扣费)
- 所有实例固定间隔重试(无抖动)
最终效果:
- 上游抖一下,下游被放大 9~27 倍
- P95 延迟升高,队列堆积
- 告警同时覆盖 API 错误、DB 锁冲突、缓存击穿
Go 实战:幂等键设计
推荐键结构:
idem:{tenant}:{workflow}:{biz_id}:{step}
要求:
- 由业务唯一字段构成(不要用随机 UUID)
- TTL 覆盖“最大重试窗口”(例如 15 分钟)
- 幂等记录至少包含:状态、响应摘要、首次时间、最后更新时间
Redis 示例(SETNX + TTL):
ok, err := rdb.SetNX(ctx, idemKey, "PENDING", 15*time.Minute).Result()
if err != nil {
return err
}
if !ok {
// 已存在:直接读取之前结果,避免重复执行
return ErrDuplicateSuppressed
}
工具成功后写入结果摘要:
_ = rdb.Set(ctx, idemKey, "DONE:tool_result_hash", 15*time.Minute).Err()
Go 实战:指数退避 + Full Jitter
错误做法:固定 500ms 重试。
正确做法:指数退避 + 抖动(Full Jitter):
func backoff(attempt int, base, cap time.Duration) time.Duration {
// base * 2^attempt, capped
max := base << attempt
if max > cap {
max = cap
}
// full jitter: [0, max)
return time.Duration(rand.Int63n(int64(max)))
}
建议参数(保守):
base = 200mscap = 5smaxAttempts = 4- 仅对可重试错误(429/5xx/网络瞬断)生效
Go 实战:熔断阈值(错误预算优先)
按 30 秒滑动窗口统计:
- 请求量 >= 50
- 错误率 >= 25%
- 连续触发 2 个窗口 → 熔断 20 秒
伪代码:
if window.Req >= 50 && window.ErrRate() >= 0.25 {
breaker.Trip(20 * time.Second)
}
if breaker.Open() {
return ErrFastFail
}
配合降级策略:
- 返回缓存摘要或上次成功结果
- 非关键工具调用直接跳过
- 向用户明确提示“结果可能不完整”
观测指标(必须落地)
至少打这 6 个指标:
tool_call_total{tool,status}retry_total{reason}idempotency_suppressed_totalbreaker_open_totalllm_latency_ms_p95cost_usd_total
告警建议:
- 5 分钟内
retry_total较基线上涨 > 3 倍 idempotency_suppressed_total突增(说明上游重复提交)breaker_open_total持续 > 0(说明系统在硬降级)
排障清单(可直接照着查)
- 查最近 15 分钟 429/5xx 比例。
- 核对是否出现“双层重试”(网关 + 代码)。
- 抽样 20 条失败请求,确认幂等键是否稳定。
- 检查重试是否带 jitter(不是固定 sleep)。
- 看熔断是否触发、是否自动半开恢复。
- 对账:重复调用是否造成重复写入/重复扣费。
总结
重试不是免费午餐。
在 Responses + Go 的生产链路里,先做幂等,再做抖动重试,最后加熔断阈值,这套组合能把“雪崩”变成“可控抖动”。
如果你现在只能做一件事:先把幂等键补上。它通常是 ROI 最高的一刀。