timeout-budget-tuning · ZH · 2026-10-27

在一把聚合 key 上给每个后端设超时:为什么同一个 30 秒设置在六个后端上坏法各不同

讲解如何用实测 p50/p99 延迟为聚合 API 中每个后端单独推导超时与重试预算,并说明为什么同一个固定超时值在不同后端上会以不同方式失效,最后给出在 OpenAI 兼容客户端中的落地设置方法。

为什么一个 30 秒超时在六个后端上表现不同

在聚合站里用一个 API key 调多个模型时,很多人会图省事,在客户端设一个全局 30 秒超时,然后假设所有后端都按同一节奏运行。实际上,超时是否"合理"取决于后端的响应时间分布,而不是你随手写下的数字。

同一个 30 秒,对某些后端可能是"永远撞不到"的上限,对另一些后端则落在 p99 附近,甚至落在 p50 之后不远处。后果也不同:

  • 后端整体很快、尾部略长:30 秒几乎不触发,你误以为超时设置没问题,直到某次长尾请求拖了整条流水线。
  • 后端本身推理较慢:30 秒频繁命中,客户端反复中断再重试,反而把整体吞吐拉低。
  • 后端支持长上下文:输入越长延迟越高,固定超时会让长请求系统性失败。
  • 后端有排队行为:超时命中的是排队时间而不是生成时间,重试只会加重排队。
  • 后端流式输出:首 token 很快但整体耗时长,用"整请求超时"去卡会误杀正常请求。
  • 后端偶发冷启动或限流:30 秒撞上的是偶发态,重试本来能救,但你的重试预算又不够。

所以正确做法不是找一个"万能秒数",而是让每个后端有自己的超时和重试预算。

先测量,再设超时:p50 与 p99

超时值应当由延迟分布推导,而不是凭经验猜。你需要至少两个统计量:

  • p50(中位延迟):代表典型请求耗时,用来判断基准水位。
  • p99(尾部延迟):代表绝大多数请求都能在此之内完成,用来设"正常请求不该被误杀"的上限。

测量要点:

  1. 按后端分别统计,不要把所有后端的延迟混在一起算。
  2. 区分"首 token 延迟"和"完整响应延迟",流式与非流式分别记录。
  3. 按输入长度分桶(例如短 prompt、长上下文),因为延迟与输入规模强相关。
  4. 样本量足够,并覆盖不同时段,避免把某次网络抖动当成常态。
  5. 记录失败请求的类型(超时、限流、服务端错误),它们决定要不要重试。

有了分布,超时就不再是"猜一个数",而是从 p99 出发做一次有依据的放大。

从 p99 推导单次请求超时

一个实用的起点是:

单次请求超时 ≈ max(p99 × 安全系数, 业务可接受下限)

几点考虑:

  • 安全系数要覆盖你尚未观测到的长尾。p99 是经验值,不是硬上限。
  • 保留业务下限:如果某个后端 p99 很低,但偶发冷启动明显更慢,下限能避免过紧。
  • 区分流式:对流式请求,更有意义的是"首 token 超时"和"两次数据块之间的间隔超时",而不是整请求超时。
  • 非流式长上下文请求需要单独一档更高的超时,或干脆改走流式。

重要的是让超时有据可查:每个后端一行配置,附上推导依据(p50、p99、样本时间范围)。这样后续调整时你知道为什么是这个值。

重试预算:与超时配套,而不是独立设置

超时只管"一次尝试多久放弃",重试预算管"一共愿意花多久、试几次"。两者必须一起算,否则会出现"每次 30 秒、重试 3 次"这种总时长失控的配置。

推导思路:

  • 总预算上限:业务能容忍的端到端时间(例如用户在前端等待的时长)。
  • 单次尝试超时:由上一节的 p99 推导。
  • 最大尝试次数:总预算 ÷ 单次超时,再留出退避间隔的空间。
  • 只对可重试错误重试:超时、连接错误、限流(需配合退避)、部分 5xx 适合重试;参数错误、鉴权失败、内容被拒不应重试。
  • 退避:重试之间加指数退避和小抖动,避免多个客户端同时重试打爆同一个后端。

还要注意重试放大效应:如果你在一个聚合 key 后面同时调度多个后端,一个后端的超时重试会占用客户端并发额度,可能挤占其他后端的正常请求。重试预算因此也是并发预算的一部分。

在 OpenAI 兼容客户端里落地

大多数 OpenAI 兼容客户端允许你为每个请求传超时,也允许在客户端实例级别设默认值。推荐的组织方式:

  1. 建立后端配置表:每个后端一条记录,包含 base URL、模型名、单次超时、最大尝试次数、退避参数、是否流式。
  2. 按后端创建客户端实例(或每次请求传该后端的超时),不要共用一个全局超时。
  3. 路由时带上预算:选后端的同时取出它对应的超时与重试配置,不要在下游临时改。
  4. 区分首 token 与整体:流式请求用读取超时/空闲超时表达"多久没数据就放弃",非流式用整请求超时。
  5. 记录实际耗时:把每次请求的实际耗时、是否重试、最终结果打日志,持续回填 p50/p99。

伪代码示意(概念性,不依赖具体 SDK):

for attempt in range(cfg.max_attempts):
    try:
        return client.chat(
            model=cfg.model,
            timeout=cfg.timeout,
            stream=cfg.stream,
            ...
        )
    except RetryableError:
        if attempt == cfg.max_attempts - 1:
            raise
        sleep(backoff(attempt, cfg))

关键是 cfg 来自后端配置表,而不是写死在调用点。

常见坑与检查清单

  • 用总超时卡流式请求:会误杀"首 token 快、生成长"的正常请求。
  • 重试不区分错误类型:对参数错误重试只会浪费时间。
  • 重试没有退避:限流场景下会雪上加霜。
  • 超时不随输入长度变化:长上下文请求需要单独一档。
  • 配置散落在代码里:超时和重试应与后端清单集中维护并可审计。
  • 只测一次就定值:延迟会漂移,需要定期重测并更新 p50/p99。

把超时当成"从分布推导出的参数"而不是"一个习惯数字",是让多后端流水线稳定的关键一步。每个后端有各自的延迟画像,也就该有各自的超时与重试预算。