当依赖的 LLM API 响应延迟升高,生产环境里最典型的故障不是直接返回 5xx,而是请求长时间挂起、直到客户端超时被掐断。如果超时参数设置不合理,一个慢接口就能占满线程池;如果重试策略设置不合理,一次故障就会演变成重试风暴。这篇文章把超时、重试、退避和熔断拆成独立决策,给出可落地的调优框架,并说明批量处理、实时交互和 Agent 工具调用链应该如何差异化配置。

把超时拆成三段,而不是只设一个数值

多数 HTTP 客户端允许设置三类超时,含义完全不同。以 Python httpx 为例:

timeout = httpx.Timeout(
    connect=5.0,   # 建立连接阶段
    read=30.0,     # 两次数据读取之间的间隔
    total=60.0     # 整个请求总时长
)

这三段对应不同失败模式:

  • 连接超时:网络不可达、DNS 异常、服务端不接受连接。
  • 读取超时:请求已发出,但服务端迟迟没有返回数据。LLM API 场景下,这可能是服务端排队、推理慢或网络拥塞。
  • 总超时:整个调用超出预算,必须本地快速失败。

对流式响应,尤其需要注意读取超时的语义。流式生成过程中,模型有时会停顿几秒,这不代表连接已经失效。如果读取超时设得太短,正常的长文本生成会被误杀。一个可行做法是设置“首字节超时”和“后续读超时”两个值:首字节超时用于等待服务端开始响应,可设置得较长;后续读超时用于判断单次 read() 调用是否僵死,可以基于实际数据到达间隔的 p99 来确定。但很多 SDK 只暴露一个 read timeout,此时你需要确认它到底覆盖首字节还是覆盖整个读过程,否则会配置错。

超时值不是越大越好。建议先采集线上的延迟分布,以 p99 加上网络往返和一定缓冲作为初始值,然后持续观察超时占比。如果超时率超过 1%,优先排查服务端延迟和队列堆积,而不是盲目把超时调大一倍。超时是保护机制,不是降低失败率的手段。

重试之前先回答:这次请求是否安全重放

重试设计里最容易被忽略的是幂等性。客户端把请求提交到服务端,服务端执行了,但响应在网络中丢失,此时客户端重试同一个请求,应用层可能产生重复效应。对于 LLM API,重复调用可能意味着重复计费,或在业务侧写入重复结果。

处理方式是在每次请求时生成唯一请求 ID,放在 X-Request-IDIdempotency-Key 请求头中,由服务端做去重。但前提是服务端真的实现了幂等逻辑。如果 API 供应商不保证这一点,重试策略就必须更保守,并且只在业务的“幂等窗口”内重试。

另一个问题是哪些失败允许重试。一个通用规则是:

  • 网络层异常(连接重置、请求未发出)可以重试。
  • 429 限流和 5xx 服务端错误可能可以重试,但要尊重响应头里的 Retry-After
  • 4xx 客户端错误(除 429)通常不值得重试,因为参数错误不会因为重试而变正确。
  • 超时的情况要谨慎:总超时发生时,请求可能已经在服务端执行;读超时发生时,连接已经断裂,相对安全但仍存在不确定性。

不要用 catch Exception 统一重试。有些团队把参数错误重复了三次,最终只能靠日志找原因。重试必须基于明确的可重试条件,并且把重试原因记入日志。

用指数退避和抖动打散重试流量

固定间隔重试会让所有客户端在相同时间点再次请求,容易形成同步峰。指数退避让每次重试的等待时间按 2 的幂增长,但如果所有客户端使用同一个退避序列,依然会在同一时刻发出请求。业界常用的 full jitter 算法,是在 [0, base * 2^attempt] 范围内随机取等待时间:

def wait_time(attempt, base=0.5):
    upper = base * (2 ** attempt)
    return random.uniform(0, upper)

这种随机化能显著降低重试流量的峰值。重试次数必须设置上限。5 次重试、退避上限 8 秒,最坏情况会让单次调用阻塞数十秒。这个时间要纳入客户端总预算。如果业务流程要求 3 秒内完成,那么最多重试 1 次,且退避上限不能超过 200 毫秒;如果是离线批量任务,可以重试 3 次以上,但仍然需要明确最大等待时间,避免长时间占用连接池。

建议在编写重试逻辑时显式声明两个常量:max_attemptsmax_wait_seconds,并通过日志暴露实际遇到的退避时长,方便后续调整。

熔断:把单次调用的局部决策升级为全局保护

超时和重试只保护单个请求,但当下游持续过载时,每个新请求都会经历同样的“尝试、超时、重试、失败”过程,线程池和连接池很快被占满。熔断器在错误率超过阈值后快速失败,给服务端恢复的时间。

以 Resilience4j 的配置为例:

resilience4j.circuitbreaker:
  instances:
    llmApi:
      slidingWindowSize: 20
      failureRateThreshold: 50
      slowCallRateThreshold: 80
      slowCallDurationThreshold: 5s
      waitDurationInOpenState: 30s
      permittedNumberOfCallsInHalfOpenState: 5

它的含义是:在最近 20 次调用中,如果失败率超过 50%,熔断器打开,后续请求直接拒绝,不再进入 HTTP 调用。30 秒后进入半开状态,允许 5 个探测请求,观察成功与否再决定关闭或继续打开。

这里有一个工程判断:熔断器粒度要按业务场景隔离,不能所有请求共用一个熔断器。批处理任务通常可以承受更长耗时,如果它触发熔断,不应该导致实时交互接口也被瞬间拒绝。更好的设计是基础调用层按 API 方法或域名配置一个熔断器,业务编排层再按场景配置上层熔断器。

在 Agent 工具调用链中,这一点尤其重要。Agent 主流程会反复调用工具,如果工具 API 已经故障,Agent 不应该让每次工具调用都超时重试后返回超时错误,而应该在熔断打开后快速失败,把“工具暂不可用”的结构化结果交给模型,让模型调整计划。否则,一次 API 故障会被 Agent 的多次规划放大成严重的响应延迟。

不同场景需要不同的超时与重试预算

超时与重试不是一套配置走天下,而是针对请求特征设置的一组预算。

批量数据处理:单个请求可以给较长的读取超时,比如 60 秒以上;重试 3 次,使用指数退避。这里关注的是吞吐,不是首字节时间。但要注意批处理的重试不应该和实时交互共享同一个连接池,否则批量重试可能耗尽实时请求的连接。

实时交互应用:用户等待时间有限,总预算通常不超过 3 秒。超时设得再长,整体延迟也超出了交互可接受范围。此时重试的意义很小,更合理的做法是快速失败并返回兜底内容。如果确实要重试,只重试一次,且退避不应超过 200 毫秒。

Agent 工具调用链:工具调用的时间由 LLM 主循环控制,单个工具请求的读取超时必须考虑多轮调用的叠加。建议为每个工具调用设置独立超时,并在编排层设置整体 deadline。当整体超时到达时,主动取消未完成的子请求,避免多个工具请求各自空转到最后。

可观测性:没有指标的超时策略等于盲调

无论超时、重试还是熔断,都需要持续用线上数据验证。至少监控以下指标:

  • 超时类型占比:连接超时、读取超时、总超时分别有多少。
  • 重试触发率,以及重试后成功的比例。
  • 熔断器从打开到半开再到关闭的恢复情况。
  • 单次请求的 p50、p99 延迟变化。

每次重试的日志应该包含:请求 ID、重试次数、触发原因(连接失败、429、5xx、读取超时)、计算出的退避时长、收到的 Retry-After 值。这样在延迟波动时,才能区分是正常的尾部延迟,还是服务端已经过载、重试反而加剧问题。

结语

当 API 响应变慢,单纯增加超时或取消重试都不能解决问题。真正需要的是一个有边界、有预算、可观测的策略:超时分段设置,重试前确认幂等,退避加抖动,熔断按场景隔离。这套框架不依赖特定供应商,适用于大多数 LLM API 调用。接入具体产品时,以官方文档为准,重点确认三个问题:API 是否支持幂等键?错误响应是否提供 Retry-After?流式 SDK 如何区分“生成缓慢”和“连接已死”?这三个答案决定你的参数应该保守还是激进。