跳到主要内容

隐藏的 SDK 重试机制:为什么你付了两倍的钱却浑然不知

· 阅读需 12 分钟
Tian Pan
Software Engineer

打开 OpenAI Python SDK 的源代码,你会发现一行安静的代码:DEFAULT_MAX_RETRIES = 2。Anthropic SDK 也采用了同样的默认设置。大多数 TypeScript SDK 也是如此。两次重试,指数级退避(exponential backoff),在连接错误、408、409、429 以及任何 5xx 错误时自动触发——这些都在你的代码看到失败之前就执行了。你没有配置它。你没有选择加入。你通常甚至不知道它的发生,因为你的应用记录的指标是 request_count(请求数),而不是 attempt_count(尝试数),并且你的追踪器(tracer)唯一能看到的 span 是 SDK 在最后一次尝试后关闭的最外层 span。

这在大多数情况下都没问题,直到出问题为止。如果在该 SDK 调用之上再添加一个应用级的重试装饰器——那种每个团队在遇到第一个 429 错误后都会写的代码——你就构建了一个 3x3 的风暴:SDK 尝试三次,你的包装层围绕 SDK 又尝试三次,在服务商降级期间,一个单一的用户请求会扇出为九次推理调用。服务商的账单会计算每一次尝试。而你的仪表盘只记录了一次。当最终有人进行账实对账时,那将是一场谁都不会喜欢的季度末谈话。

你未曾配置的默认设置

孤立来看,SDK 的默认设置是合理的。由于瞬时节流导致的 429 错误、负载均衡器重新洗牌导致的 503 错误,或者由于在传输过程中 keep-alive 过期导致的连接重置——模型在下一次尝试时很可能就能正常回答。带有退避机制的两次重试可以无感地处理最常见的瞬时错误,并让你的代码保持整洁。

问题在于,这些 SDK 是为调用者是单一小型服务的场景而设计的。在真实的生产技术栈中,那个单一的 SDK 调用会被多层包装:

  • 平台团队为了让“所有请求在 5xx 时重试”而添加的 Tenacity 装饰器。
  • 队列工作线程(queue worker),它在发生任何异常(包括其中的 LLM 调用)时都会重试整个任务。
  • 编排器(如 LangGraph、Step Function 或 Agent 循环),它将失败的步骤作为节点层级的关注点进行重试。
  • 具有自身上游超时重试策略的人口网关(ingress gateway)。

每一层的作者都在进行局部思考。单独来看,他们都没有错。然而,这些重试预算的乘积才是你的推理工作负载实际运行下的重试预算,而且团队中没有人算过这个乘法。一种“重试很廉价”的微服务直觉在 50ms 级别的 RPC 规模下校准良好,因为三次尝试仅消耗 150ms 和少量的 CPU 周期。但当这种直觉被转移到耗时 4 秒、包含 8000 个 token 的补全(completion)任务时,其运行成本系数比选择 retries=3 的工程师所建模的要高出几个数量级。

为什么你的链路追踪看不到它

糟糕的地方在于,这些重试对于你通常用来发现它们的工具来说是不可见的。

大多数 APM 和链路追踪(tracing)设置是在应用边界进行插桩的。你将 client.chat.completions.create(...) 调用包装在一个 span 中;该 span 在你进入时开始,在 SDK 返回时结束。SDK 内部的重试循环完全在这个 span 内部运行。从链路追踪的角度来看,你发起了一次耗时 9 秒的调用。从服务商的角度来看,你发起了三次调用,每次耗时 3 秒。从你的账单角度来看,你购买了三次补全服务。

针对 gen_ai.* 属性的标准 OpenTelemetry 语义约定在每次外部调用中只触发一次。它们记录提示词、响应以及最后一次成功尝试的 token 计数。在那之前的两次失败尝试消失在了虚无中,除非你在 SDK 之下的 HTTP 层显式进行了插桩——但大多数团队并不会这样做,因为 SDK 本身就被视为网络边界。

第一个迹象是服务商端指标与你自身指标之间的差距。提取服务商仪表盘在一小时窗口内的请求计数;再提取你的应用指标在同一窗口内的请求计数。它们本应在几个百分点内保持一致。但通常并非如此。差值就是不可见的重试流量,且每当服务商遇到状况时,这个差值就会变大。

一个更可靠的审计方法是在受控实验中完全禁用 SDK 重试,并在其上层添加显式的应用级重试。这样,每一次尝试都会变成一个 span。重新运行具有代表性的生产负载。总尝试次数除以请求总数,就是你一直在支付却看不见的乘数。在大多数尚未对此进行优化的团队中,这个乘数通常在 1.05 到 1.3 之间,而在服务商发生故障期间,它会飙升至 3 或 4。

服务商波动中的 3x3 风暴

这种风暴并非理论推演。其算术逻辑简单且残酷。

想象一个普通的下午。由于某个“吵闹的邻居”在同一区域疯狂消耗配额,服务商的速率限制收紧了。你的错误率从 0.1% 上升到 8%。SDK 的默认策略开始生效:每个失败的调用都会自动重试两次。其中大多数重试在第二次尝试时就成功了——因为节流是短暂的——你的应用指标显示 99% 的成功率、略微升高的 p95 延迟,以及绿色的状态页。

在底层,每一个遇到 429 错误的用户请求实际上都触达了 API 三次。你在那 30 分钟窗口内的 token 账单大约是仪表盘显示的三倍。服务商在该端点承载的负载大约也是在没有 SDK 重试情况下的三倍。其他客户的 SDK 也在重试。原本旨在缓解压力的节流措施,现在每拒绝一个请求,就会迎来三次尝试。这就是教科书式的重试风暴,且这种放大效应在协同工作的集群中是乘性增长的。

加载中…
References:Let's stay in touch and Follow me for more thoughts and updates