大模型 API 故障复盘:一次超时与重试风暴的排查过程

故障现象:服务突然变慢,请求大量失败

那天下午,我们运营的智能问答应用突然炸了——用户大面积报障,对话框半天没反应,有的请求直接返回错误。监控面板上,调用大模型 API 的 P99 延迟从平时的 5 秒一下子窜到了 45 秒,错误率也从 0.5% 飙升到 35%。业务方急得不行,说用户已经开始流失了,值班同事赶紧拉群排查。

这服务的架构其实挺简单的:用户请求先经过网关,然后业务服务通过 HTTP 去调第三方大模型 API(用的是 ClaudeAPI 兼容接入服务)拿结果。上游虽然有多条线路,但业务代码只配了一条默认路由,而且连个熔断或者降级机制都没开。

快速定位:超时和重试搞出来的连锁反应

我们一开始怀疑是上游大模型 API 出了问题。和平台那边沟通后,对方说当时确实有瞬时流量高峰,导致部分请求处理变慢了。但奇怪的是,我们这边业务服务器发出去的请求量比正常高出一大截——本来每秒大概 100 次请求,故障期间飙到了 300 多次。

接着查代码发现,业务服务用的 Go 标准库net/http,默认超时是 30 秒,重试策略是固定间隔重试 3 次,每次等 1 秒,而且没加任何抖动。也就是说,一条请求在 30 秒内没返回,客户端立马重试;要是上游响应还是慢,重试就会叠加。结果就是:大量并发请求同时超时 → 同时发起重试 → 上游压力猛增 → 响应更慢 → 更多超时和重试。这就是典型的重试风暴。

深入排查:重试风暴到底怎么来的

超时设置和重试策略

我们仔细翻了业务代码里 HTTP 客户端的配置:

连接超时:默认没单独设,用的是net/http的Transport默认值,实际连接超时大概 30 秒

读取超时:通过timeout字段统一设成了 30 秒

重试次数:硬编码 3 次,没有退避策略

重试间隔:固定 1 秒,没有抖动

这种配置碰上大模型 API 这种响应时间波动大的服务,风险非常高。大模型推理本身就可能因为输入长度、模型负载、网络节点这些因素时不时延迟一下。一旦某个时间窗口里多个请求同时遇到延迟,固定重试策略就会让请求量成倍放大。故障期间我们看了日志统计数据:有一条原文请求在 2 秒内被重试了 3 次,而下游 API 实际上 35 秒后返回了正确结果,但客户端早就超时放弃了这个连接,后端结果白白浪费了。

日志分析:锁定重试风暴的关键证据

我们把业务日志和 API 网关日志对了一下,还原出了时间线:

14:02:30:上游大模型 API 响应时间从 5 秒升到 12 秒

14:02:32:第一批超时请求开始重试,客户端并发量翻倍

14:02:35:上游 API 负载突破阈值,响应时间升到 40 秒

14:02:40:大量重试请求涌到上游,部分服务器开始丢请求,客户端错误率飙升

14:03:00:业务服务器连接池耗尽,新请求只能排队,系统眼看就要崩溃

日志里还能看到大量资源竞争的现象:连接池的Get操作阻塞时间从 0 毫秒涨到了 20 秒以上,说明客户端自己的线程资源也快被重试请求占满了。

重试风暴的典型危害

这次故障让我们切身体会到了重试风暴对系统稳定性的毁灭性打击:

下游服务雪崩:本来只是一次正常的负载波动,被重试机制放大成了拒绝服务攻击。上游 API 在故障期间,实际有效请求和重试请求的比例接近 1:3,大量算力都花在处理没意义的重试上了。

客户端资源耗尽:业务服务部署了 8 个 pod,每个 pod 最大连接数 50,总并发上限 400。重试风暴时实际并发请求达到了 900 多,大量 goroutine 被阻塞在等响应里,导致健康检查失效,k8s 开始重启 pod。

响应延迟急剧增加:用户那边感受到的等待时间从正常的 5 秒变成了 60 秒甚至直接超时,体验直接崩了。

解决方案:从源头掐断重试风暴

复盘之后,我们对重试机制做了系统性重构。核心思路就一条:重试不是免费的,每一次重试都可能伤害下游。

第一,合理设置超时

必须把连接超时和读取超时区分开。对大模型 API 调用来说,读取超时要根据模型最大推理时间来设,不能一刀切地用短超时。我们参考了第三方 ClaudeAPI 兼容接入服务推荐的超时建议(具体可以看平台最新文档),把读取超时调到了 60 秒,连接超时设成 5 秒。这样既能容忍正常的推理延迟,又能快速识别网络异常。

第二,采用指数退避和抖动

固定间隔重试是重试风暴的温床。我们换成了指数退避算法:第一次重试延迟 1 秒,第二次 2 秒,第三次 4 秒,最大延迟上限 16 秒。同时加了 ±25% 的随机抖动,避免多个客户端在同一时间点同时发起重试。比如,第一次重试的等待时间会在 0.75 秒到 1.25 秒之间随机选一个。

第三,限制最大重试次数

把重试次数从 3 次降到了 2 次,还设了全局重试上限。更重要的是,严格限制了重试条件:只有5xx服务端错误和timeout才触发重试,4xx客户端错误(比如 401、403)绝对不重试。原来的代码对所有非 2xx 状态码都重试,这明显不合理。

第四,熔断和降级

引入了熔断器模式:当错误率在 10 秒窗口内超过 50% 时,熔断器打开,所有请求直接返回降级结果(比如给缓存答案或者提示用户稍后再试)。熔断后每 30 秒尝试半开状态,放少量请求过去探测上游是否恢复。这样能有效切断重试风暴的传播链条。

第五,客户端连接池管理

把连接池大小从固定的 50 改成了根据 CPU 和内存自动限制,同时配了空闲连接超时和最大空闲连接数。另外每个请求都加了context超时控制,确保 goroutine 不会因为重试而无限阻塞下去。

代码层面的优化实践

下面是用 Go 实现的推荐配置片段,只是示意逻辑,不是完整生产代码:

transport := &http.Transport{

    MaxIdleConns:        100,

    MaxIdleConnsPerHost: 20,

    IdleConnTimeout:    90 * time.Second,

    DialContext: (&net.Dialer{

        Timeout:  5 * time.Second,

        KeepAlive: 30 * time.Second,

    }).DialContext,

}

client := &http.Client{

    Timeout:  60 * time.Second,

    Transport: transport,

}

type RetryPolicy struct {

    MaxRetries      int

    BaseDelay      time.Duration

    MaxDelay        time.Duration

    JitterFactor    float64

}

func (p *RetryPolicy) ShouldRetry(statusCode int) bool {

    return statusCode >= 500 || statusCode == 0 // 0 表示连接超时

}

func (p *RetryPolicy) Backoff(attempt int) time.Duration {

    delay := float64(p.BaseDelay) * math.Pow(2, float64(attempt))

    if delay > float64(p.MaxDelay) {

        delay = float64(p.MaxDelay)

    }

    jitter := delay * p.JitterFactor * (rand.Float64()*2 - 1)

    return time.Duration(delay + jitter)

}

实际调用时,用context.WithTimeout包裹每次重试,确保总耗时不超过业务能接受的范围。

监控与告警:让故障不再重复

排查过程中我们发现,原有的监控体系只覆盖了服务端指标(CPU、内存、QPS),完全没有观察客户端的重试行为。我们补充了以下关键指标:

API 调用延迟分布:把首次请求和重试请求的 P50/P95/P99 分开统计

重试次数统计:按重试次数分桶,只要单次请求重试次数大于 0 就告警

连接池等待时间:超过 100 毫秒就触发低级别告警

熔断器状态:熔断器一打开,立刻发电话告警

另外,我们在日志里给每次重试加了X-Retry-Attempt和X-Retry-Reason头部,方便和上游平台一起排查。第三方 ClaudeAPI 兼容接入服务的技术团队也帮了忙,确认了当时上游负载波动的原因。

总结

这次大模型 API 故障的根源其实不复杂——就是一个小小的固定重试策略,在合适的条件下引爆了整个系统。重试风暴不是那种很少见的边缘情况,只要系统有一定波动,客户端重试策略又设计得不好,它就可能在任何规模的业务里上演。

关键教训有两点:

重试机制是系统稳定性的放大器:设计好了能容忍瞬时抖动,设计不好就会把小故障变成大事故。必须给每次重试加上指数退避、抖动、上限和熔断保护。

监控要覆盖客户端视角:服务端一切正常的时候,客户端可能已经在死循环重试了。重试次数、连接池状态、熔断器状态这些指标,比 CPU 占用率更能反映系统的真实健康状况。

后来我们在所有接入大模型 API 的服务里推广了这套重试策略模板,也写进了团队开发规范。如果你也在用第三方大模型 API 服务,建议重新审视一下自己的重试代码——也许一个微小的配置改动,就能避免一次灾难性的重试风暴。

©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

相关阅读更多精彩内容

友情链接更多精彩内容