系列 《模型作为组件:契约、失效模式与选型》 第 6 / 7 篇 入门
系列总览 — 为什么这样组织、读它需要什么、读完能做什么 · 幻灯片 — 整个系列的精华,一份可分享的 deck
学习地图 — AI 应用工程师学习地图 第 1 / 7 个系列
前五篇讲的是组件本身:它怎么坏、接口是什么、花多少钱、怎么选。这一篇讲你这一侧——调用它的客户端。调用模型 API 与调用任何不稳定的第三方服务有大半是相同的:会超时、会 429、会 5xx,要重试、要退避、要熔断。不同的是失败的种类更多——上下文超限、内容过滤、工具调用格式错、推理超预算、流式中途断开——而且每一次重试都要再花一次钱,一次重试可能是几美分也可能是几美元(一个 300K 输入的请求)。
后端工程师写过的每一个 HTTP 客户端的经验在这里都有效,但要加三条模型 API 特有的纪律:发请求之前数 token(超限的 400 是可以避免的失败);流式响应的失败只能重发不能续传(并接受已生成 token 的费用);重试策略要区分”再试一次可能成功”与”再试一次只是再花一次钱”(幻觉不会因为重试消失,格式错误可能会)。
本篇要回答的核心问题是:
模型 API 的错误有哪几类,哪些该重试、哪些不该?1 超时为什么要分四层,流式中断怎么处理?2 限流的维度与使用层级对容量规划意味着什么?3 为什么要在发请求前数 token,token 计数会怎样变?4
一、总览
1. 一次调用在客户端经过的检查点
%% 图:一次调用在客户端经过的检查点:数 token、取并发槽、四层超时发送、错误分类后退避重试或 fallback、解析并记 trace
flowchart TB
S(["业务层发起调用"]) --> CNT["数 token<br/>超过上下文或预算 → 先压缩 / 拒绝,不发"]
CNT --> SLOT["取并发槽 / 令牌<br/>按模型 · 按租户的限流器"]
SLOT --> SEND["发送(流式或非流式)<br/>四层超时:连接 · 首事件 · 事件间 · 总"]
SEND --> OK{"成功?"}
OK -->|"是"| PARSE["解析:拼接增量 · 工具参数等 done · 读 usage · 读 stop_reason"]
PARSE --> DONE(["返回业务层 + 记 trace"])
OK -->|"否"| CLS{"错误分类"}
CLS -->|"可重试:429 / 5xx / 529 / 超时 / 连接断"| BACK["退避(指数 + 抖动,遵守 Retry-After)<br/>重试预算未耗尽?"]
BACK -->|"是"| SLOT
BACK -->|"否"| FB
CLS -->|"不可重试:400 · 401 · 配额不足 · 内容过滤"| FB{"有 fallback?"}
FB -->|"换模型 / 换供应商 / 降级"| SLOT
FB -->|"无"| FAIL(["失败返回业务层 + 记 trace"])
classDef step fill:#fff7e0,stroke:#c98a00,stroke-width:2px,color:#222
classDef dec fill:#eef6ff,stroke:#5b8fd6,color:#222
classDef stop fill:#f0f0f0,stroke:#888,color:#222
class CNT,SLOT,SEND,PARSE,BACK step
class OK,CLS,FB dec
class S,DONE,FAIL stop
图里每个方框对应本文一章。多数 SDK 替你做了”发送”与部分”重试”(默认重试 2 次);数 token、限流、错误分类的细节、fallback、trace 都是你的。
2. 本文的章节安排
第二章给错误分类表;第三章讲重试与退避;第四章讲四层超时与流式中断;第五章讲幂等与副作用;第六章讲限流的维度、使用层级与容量规划;第七章讲流式解析的工程细节;第八章讲 token 计数;第九章讲降级与 fallback;第十章实践建议。
二、错误分类
1. 一张表
| 类别 | 信号 | 含义 | 重试? | 该做什么 |
|---|---|---|---|---|
| 限流 |
|
超过 RPM / TPM | 是,遵守 Retry-After,退避 |
检查限流器配置;长期看容量(第六章) |
| 配额 / 余额 | 429 但 insufficient_quota(OpenAI);402 / 余额不足 |
钱用完了 | 否 | 告警;这是账务不是流控 |
| 过载 |
|
供应商侧容量 | 是,退避更长 | 看状态页;触发 fallback 到另一模型 / 供应商 |
| 网络 | 连接超时、连接重置、DNS | 到达前失败 | 是 | 幂等无忧(请求没到) |
| 读超时 | 首事件 / 事件间 / 总超时 | 请求到了,可能已在生成 | 是(但已生成的 token 可能计费) | 缩短输入、调低 effort、改流式 |
| 请求非法 | 400 invalid_request_error:参数错、消息结构错(工具结果缺配对)、thinking block 校验失败 |
你的 bug 或契约变更 | 否 | 修代码;第二、三篇的清单 |
| 上下文超限 | 400,OpenAI context_length_exceeded;Anthropic “prompt is too long” |
输入 + max_tokens 超过窗口 |
否(重发同样超) | 发前数 token(第八章);压缩历史 |
| 输出截断 | 200 但 stop_reason / finish_reason = max_tokens / length |
回答不完整 | 视情况:提高 max_tokens 或调低 effort 后重试 |
监控比例 |
| 内容过滤 / 拒答 |
|
模型或供应商侧拒绝 | 否(同样的输入同样拒) |
|
| 工具调用格式错 | 200 但 arguments 不是合法 JSON、参数不符 schema、调用了不存在的工具 |
模型输出错误 | 可,一次:把错误作为 tool result 送回让模型改 | 开 strict 工具 schema |
| 结构化输出校验失败 | 200 但业务校验不过 | 语义错误 | 可,一次,带错误信息 | 第二篇第四章 |
| 认证 / 权限 | 401 / 403 | key 错、无权用该模型 | 否 | 配置问题 |
| 模型不存在 | 404 model_not_found |
模型名错或已下线 | 否 | 第五篇的弃用流程;fallback |
三个原则。只重试”再试可能不同”的错误:限流、过载、网络、超时——它们的原因在外部且会变;400 类的原因在你的请求里,重发只是再花一次钱(超限的 400 不计费,但 200 的截断与格式错是计费的)。内容过滤不要用重试绕:它是供应商的政策,反复重试会触发更严的限制,且在合规上说不清。200 也可能是失败:截断、格式错、拒答都是 200,错误分类要读响应体,不只读状态码。
2. 请求 id
每次响应都带请求 id(OpenAI x-request-id,Anthropic request-id)。把它记进 trace——向供应商报问题时这是唯一的凭据,也是核对账单争议的依据。
三、重试与退避
1. 指数退避加抖动
第 \(n\) 次重试前等待:
\[t_n = \min\left(t_{\max},\ \text{random}\left(0,\ t_0 \cdot 2^{n}\right)\right)\]“full jitter”——在 0 到指数上限之间均匀取随机值。为什么要抖动:一次 429 或 529 通常是很多客户端同时撞上限流或过载,无抖动的退避让它们在同一时刻重试,形成第二波。\(t_0\) 取 0.5–1 s,\(t_{\max}\) 取 30–60 s,重试次数 3–5。
Retry-After 优先:供应商在 429 里给了等待秒数就用它,不要自己算——它知道你的窗口何时重置。Anthropic 与 OpenAI 的 429 都带。
%% 图:为什么退避要加抖动——一次过载让 1,000 个客户端同时收到 529;无抖动的指数退避让它们在 1 s、2 s、4 s 整点同时重试,形成一波波的第二次过载;full jitter 把每一轮的重试均匀撒在 0 到上限之间,供应商看到的是平滑的流量
flowchart TB
subgraph NJ["无抖动:t = t₀ · 2ⁿ"]
direction LR
N0["1,000 个客户端<br/>同一秒收到 529"] --> N1["1 s 后:1,000 个同时重试<br/>→ 又一次过载"] --> N2["2 s 后:再来一波"] --> N3["4 s 后:再来一波…"]
end
subgraph FJ["full jitter:t = random(0, t₀ · 2ⁿ)"]
direction LR
F0["1,000 个客户端<br/>同一秒收到 529"] --> F1["0–1 s 内均匀散开重试"] --> F2["失败的在 0–2 s 内散开"] --> F3["供应商看到的是平滑的流量<br/>加上 Retry-After 就更准"]
end
NJ ~~~ FJ
classDef bad fill:#fdecea,stroke:#c0392b,color:#222
classDef ok fill:#eefaf0,stroke:#4d9a5c,color:#222
classDef n fill:#fff7e0,stroke:#c98a00,color:#222
class N0,F0 n
class N1,N2,N3 bad
class F1,F2,F3 ok
2. 重试预算
单个请求的重试次数上限是必要的,但不够:一个过载事件里,如果每个请求都重试 5 次,供应商收到的流量是平时的 5 倍,过载更久。用重试预算——单位时间内重试次数不超过总请求的某个比例(比如 10%)——超过预算就直接失败或走 fallback。这是 SRE 的老做法,在按 token 计费的场景里多了一个理由:重试预算也是钱的预算。
3. 重试的层次
- 请求级:同一请求体重发。适用于限流、过载、网络。
- 修正级:改一下再发——工具调用格式错时把错误信息作为结果送回、结构化输出校验失败时附上错误让模型重试一次、截断时提高
max_tokens。这是模型 API 特有的重试形态,每次都是一次新的付费调用,限一次。 - 任务级:整个 agent 任务失败后从头或从 checkpoint 重来。属于 L4 运行时;客户端不做。
SDK 的默认重试(OpenAI 与 Anthropic 的官方 SDK 默认 2 次,指数退避)只覆盖请求级、只针对 429 / 5xx / 连接错误。修正级要自己写。
%% 图:重试的三个层次各归谁管——请求级(原样重发,SDK 默认做)、修正级(把错误信息送回让模型改一次,模型 API 特有,要自己写)、任务级(agent 任务从 checkpoint 重来,属于 L4 运行时)
flowchart TB
E["一次调用出了问题"] --> C{"错在哪?"}
C -->|"429 · 5xx · 529 · 网络 · 超时<br/>原因在外部,会变"| R1["请求级:同一请求体重发<br/>指数退避 + 抖动 · 3–5 次 · 重试预算<br/>SDK 默认覆盖(2 次)"]
C -->|"200 但工具参数不是合法 JSON · 校验不过 · 截断<br/>原因在模型的输出"| R2["修正级:改一下再发<br/>错误信息作为 tool result 送回 / 提高 max_tokens<br/>限一次;每次都是新的付费调用;要自己写"]
C -->|"整个 agent 任务失败<br/>循环跑飞、预算耗尽"| R3["任务级:从头或从 checkpoint 重来<br/>属于 L4 运行时,客户端不做"]
C -->|"400 · 401 · 配额不足 · 内容过滤<br/>重发只是再花一次钱"| R0["不重试:修代码 / 告警 / 按产品逻辑处理"]
classDef dec fill:#eef6ff,stroke:#5b8fd6,color:#222
classDef ok fill:#eefaf0,stroke:#4d9a5c,color:#222
classDef n fill:#fff7e0,stroke:#c98a00,color:#222
classDef bad fill:#fdecea,stroke:#c0392b,color:#222
class C dec
class R1 ok
class R2,R3 n
class R0 bad
4. 流式中的重试
流式请求在收到第一个事件之前失败,与非流式一样重试。在生成中途断开——供应商已经生成了一部分并会为此计费——重试意味着从头再生成一遍,双倍成本。策略:中途断开只对短回答自动重试;长回答(agent 的长输出、大段代码)要么在 UI 上提示用户重试,要么接受部分结果。没有”从第 137 个 token 续上”的机制。
四、超时
1. 四层
| 层 | 含义 | 典型值 | 超过意味着 |
|---|---|---|---|
| 连接 | TCP / TLS 建立 | 5–10 s | 网络或供应商入口问题;可重试 |
| 首事件(TTFT) | 从发送到第一个字节 / 第一个 SSE 事件 | 按输入长度与 effort:短请求 10–30 s,长 prefill 或 xhigh 思考 60–120 s |
排队、长 prefill、长思考;第四篇的三段排查 |
| 事件间 | 两个 SSE 事件之间 | 15–30 s | 生成卡住或连接半死 |
| 总 | 整个请求 | 按预期输出长度:\(\text{TTFT} + n_{\text{out}} \times \text{TPOT}\) 再乘 2–3 | 兜底 |
只设一个总超时的客户端有两个毛病:设短了会误杀正常的长回答(800 token 在 30 ms/token 下要 24 秒),设长了对一个卡死的连接要等很久才发现。四层各管一段。
2. SDK 的默认值
官方 SDK 的默认总超时通常是 10 分钟。Anthropic 的 SDK 对非流式请求有一个额外的规则:如果按 max_tokens 估算生成时间可能超过 10 分钟,它会拒绝以非流式发送,要求改用流式——因为长连接空等 10 分钟在网络上不可靠。这条规则说明了一件事:超过一分钟的生成应该流式,不是为了 UI,是为了连接的健康。
3. 更长的任务
深度研究、长文档处理、几十步的 agent 任务,几分钟到几小时。它们不该占着一个 HTTP 连接:OpenAI 的 Responses 有 background: true(提交后轮询或 webhook 通知),Anthropic 与 Gemini 有 Batch,Agents API 一类的托管运行时把任务变成异步 job。客户端的对应设计是”提交 → 轮询 / 回调”,超时策略变成 job 级的 SLA。L4 讲运行时,这里只说:HTTP 请求的超时不该超过几分钟,更长的任务换形态。
五、幂等与副作用
1. 模型调用本身
一次纯生成调用的重复执行没有业务副作用——只有两个后果:再花一次钱,以及在服务端状态开启时(Responses 默认 store: true)多存一条。所以模型调用的”幂等”主要是成本问题,而不是正确性问题;四家的生成接口都没有通用的幂等键(Batch 接口有 custom_id 用于配对)。控制手段是重试预算与”中途断开只重试短回答”。
2. 工具的副作用
真正的幂等问题在工具上。agent 循环里模型返回 tool_call: create_order(...),你执行了,然后送回结果的那次请求超时了——你重试,模型没收到结果又返回同一个 tool_call,你再执行,订单重复。防止的办法与任何分布式系统相同:
- 工具本身幂等(用
tool_call_id或业务键做幂等键,第二次调用返回第一次的结果); - 在应用侧记录”哪些
tool_call_id已执行”,重复的直接返回缓存结果而不再执行; - 有副作用且不幂等的工具(发邮件、付款)走确认门(L4)。
这是第一篇”越界”一条的客户端版本:重试是无害的,直到它触发了一个不幂等的动作。
%% 图:重试怎么让订单重复——模型返回 create_order 的 tool_call,应用执行了,送回结果的那次请求超时;重试时模型没见过结果,又返回同一个 tool_call,应用再执行一次;解法是用 tool_call_id 做幂等键,第二次直接返回第一次的结果
sequenceDiagram
participant App as 应用
participant API as 模型 API
participant Tool as create_order
App->>API: 请求 #1
API-->>App: tool_call(id = call_7,create_order)
App->>Tool: 执行 → 订单 A 创建
App->>API: 请求 #2:历史 + call_7 的结果
Note over App,API: ✗ 超时(请求可能没到,也可能到了)
App->>API: 重试请求 #2 … 或者重发 #1
API-->>App: 又一个 tool_call(call_7 或新 id,create_order)
Note over App: 没有幂等键:再执行 → 订单 B,重复了
Note over App,Tool: 有幂等键:查到 call_7 已执行 → 直接返回订单 A 的结果,不再调用
3. 服务端状态下的重复
用 previous_response_id 链式调用时,一次超时后重试会在服务端产生两条分叉的响应。要么用返回的 id 去重(以第一条成功的为准),要么把状态放在客户端(第二篇第六章)。
六、限流与容量
1. 维度
限流至少两个维度:每分钟请求数(RPM)与每分钟 token 数(TPM);Anthropic 进一步分输入 TPM 与输出 TPM。每一维都可能先触顶:一个每请求 50K token 的应用,RPM 远没用完 TPM 就满了;一个高频短请求的应用反过来。响应头告诉你剩余:OpenAI 的 x-ratelimit-limit-requests / x-ratelimit-remaining-requests / x-ratelimit-reset-requests 与对应的 -tokens 三个;Anthropic 的 anthropic-ratelimit-requests-*、anthropic-ratelimit-input-tokens-*、anthropic-ratelimit-output-tokens-*。DeepSeek 不限 RPM / TPM,限并发(V4.1 Flash 2,500、V4-Pro 500),超过时请求排队或返回忙。
2. 使用层级
额度按账户的历史消费分层:OpenAI 的 Tier 1–5,Anthropic 在 Sonnet 5 发布时把层级简化为 Start / Build / Scale 三级。新账户的额度低,一个上线即有流量的产品会在第一天撞墙。容量规划要提前:估算峰值 TPM(峰值 QPS × 每请求平均 token,输入输出分开算),对照当前层级,提前升级或申请提额;多供应商分摊(L6 网关)也是一种扩容。
3. 客户端限流器
不要等供应商 429 再退避——那已经浪费了一次往返,而且多个实例同时撞限会放大。在客户端放一个按模型的令牌桶(容量 = 你的 TPM 额度打九折,按 token 而不只按请求扣),请求前扣 token(用第八章的计数),响应后按 usage 校正。多租户的应用再加一层按租户的公平队列,防止一个租户吃光额度。这是 L6 网关的核心功能之一,在没有网关的时候客户端要自己做。
%% 图:客户端限流器的两层——按租户的公平队列防止一个租户吃光额度,按模型的令牌桶把容量定在供应商 TPM 额度的九折、发送前按估算的 token 扣、响应后按 usage 校正、并用限流头动态调整
flowchart LR
T1["租户 A 的请求"] & T2["租户 B 的请求"] & T3["租户 C 的请求"] --> FQ["按租户的公平队列<br/>轮转取用,单租户有上限"]
FQ --> TB["按模型的令牌桶<br/>容量 = TPM 额度 × 0.9<br/>发送前扣估算的 token"]
TB -->|"有令牌"| SEND["发送"]
TB -->|"没令牌"| WAIT["本地等待<br/>(不去撞 429)"]
SEND --> RESP["响应"]
RESP -->|"usage 校正扣除量"| TB
RESP -->|"x-ratelimit-remaining / reset<br/>喂回桶的速率"| TB
classDef step fill:#fff7e0,stroke:#c98a00,color:#222
classDef ok fill:#eefaf0,stroke:#4d9a5c,color:#222
classDef n fill:#eef6ff,stroke:#5b8fd6,color:#222
class T1,T2,T3 n
class FQ,TB,SEND,RESP step
class WAIT ok
4. 从限流头调整
限流头是动态信息:接近 remaining = 0 时主动降速,reset 告诉你何时恢复。一个好的客户端把这两个数喂给自己的令牌桶。
七、流式解析的工程
第二篇第五章讲了事件模型;这里是实现层的坑:
- SSE 的行协议。事件以
data:行开头、空行分隔;有的供应商发注释行(: keep-alive)或空事件作为心跳——DeepSeek 在排队时会发空行保持连接。解析器要忽略它们而不是报错,事件间超时的计时器要在收到心跳时重置。 - 增量拼接。文本增量直接追加;工具调用参数按
index/id分桶拼接字符串,等该调用的done事件(或 Anthropic 的content_block_stop)再解析 JSON。并行工具调用的增量会交错到达。 usage在末尾。预算控制与成本记账在结束事件里做;流式过程中的”已用 token”只能估算。- 客户端断开。用户关了页面,你的服务器与供应商之间的流还在——供应商继续生成并计费直到你主动关闭连接。前端断开要传播到后端并取消上游请求。
- 背压。模型每秒几十个 token,UI 每个 token 重绘一次会卡;按时间(每 50–100 ms)或按块合并后再推给前端。
- 中断后的状态。中断时已收到的增量可以展示为部分结果并标注”已中断”,但不要把它当作完整回答存入历史——下一轮模型会以为自己说完了。
八、token 计数
1. 为什么在发之前数
上下文超限的 400 是唯一一类可以在发送前完全避免的失败:窗口大小已知、max_tokens 已知、输入长度可以算。发送前算:\(n_{\text{in}} + \text{max\_tokens} \le \text{窗口}\),不满足就先压缩历史(L2)或拒绝。此外,第六章的令牌桶要按 token 扣、第四篇的成本预估要 token 数——都要在发送前知道。
2. 怎么数
| 供应商 | 本地 | 远程 |
|---|---|---|
| OpenAI | tiktoken(按模型选编码) |
— |
| Anthropic | 无官方本地 tokenizer | count_tokens 端点(免费,接受完整请求体含 tools 与 system) |
| — | countTokens |
|
| DeepSeek | 官方提供离线 tokenizer 包 | — |
本地计数快但要跟模型的 tokenizer 版本;远程计数准但多一次往返(Anthropic 的端点接受与真实请求相同的结构,所以工具定义、system、图片都算进去了)。折中做法:本地估算用于限流与预算(允许 5–10% 误差),远程精确计数只在接近窗口上限时做。
3. 计数会变
Claude Sonnet 5 换了 tokenizer,同样的文本比 Sonnet 4.6 多产出约 30% 的 token(Anthropic 的发布说明原话,”具体增幅取决于内容与负载形态”)。含义:(1)账单上涨 30%,即使价格不变;(2)原来按 4.6 算好的”历史保留 N 轮”在 5 上会超窗口;(3)基于字符数的粗估(”1 token ≈ 4 字符”)失效。换模型时 token 计数要重新校准。
另外,中文比英文贵:多数 tokenizer 下一个汉字 1–2 个 token,英文约 0.75 个 token 一个词;同样信息量的中文 prompt 比英文长 30–50%。这是中文应用做成本估算时的一个常数。
九、降级与 fallback
1. 熔断
对一个模型 / 供应商的错误率或超时率在滑动窗口内超过阈值(比如 30 秒内 20%),熔断器打开:后续请求不再发给它,直接走 fallback;隔一段时间放一个探测请求,成功则半开、逐步恢复。这防止了过载事件里你的重试雪崩到已经不健康的端点。
%% 图:熔断器的三个状态——关闭时正常放行并统计错误率,30 秒窗口内错误率超过 20% 就打开、请求直接走 fallback,等待期过后半开放一个探测请求,成功则关闭、失败则重新打开
stateDiagram-v2
direction LR
[*] --> Closed
Closed: 关闭 — 正常放行
统计滑动窗口内的错误率 / 超时率
Open: 打开 — 不再发给它
请求直接走 fallback
HalfOpen: 半开 — 放一个探测请求
Closed --> Open: 30 s 内错误率 > 20%
Open --> HalfOpen: 等待期到(如 30 s)
HalfOpen --> Closed: 探测成功 → 逐步恢复流量
HalfOpen --> Open: 探测失败
2. fallback 的层次
| 层 | 做法 | 代价 |
|---|---|---|
| 同模型换区域 / 云 | Claude 在 Anthropic API、Bedrock、Vertex、Foundry 都有 | 最小;价格略有差别(第四篇的区域加价) |
| 同供应商换模型 | Fable 5.1 → Opus 5;Sol → Terra | 质量可能下降;推理状态的方向性(第三篇:新模型的 thinking block 旧模型读不了) |
| 换供应商 | Sonnet 5 → GPT-5.6 Terra |
|
| 降级到非模型 | 规则、缓存的答案、”稍后再试” | 功能降级,但服务不挂 |
fallback 的选择要提前在评测集上验证(第五篇:替代模型每月热身),不能在故障当天第一次试。
3. 对用户说什么
降级发生时告诉用户(”当前使用备用模型,回答可能略有差异”)比静默好——L7 讲不确定性的呈现。静默降级的问题是用户把质量下降归因于产品本身。
十、实践建议
一份客户端检查清单,按本文顺序:
- 错误分类器:按第二章的表把响应映射到”可重试 / 修正后重试一次 / 不可重试 / 需人工”,读响应体不只读状态码;记请求 id。
- 重试:指数退避 + full jitter,遵守
Retry-After,单请求 3–5 次,全局重试预算 10%;修正级重试限一次。 - 超时四层:连接 5–10 s、首事件按输入与 effort、事件间 15–30 s、总按预期输出;超过一分钟的生成一律流式;更长的任务换后台形态。
- 工具幂等:按
tool_call_id去重,不幂等的工具走确认门。 - 限流器:按模型的令牌桶按 token 扣,多租户加公平队列,用限流头校正;提前估峰值 TPM 对照层级。
- 流式解析:忽略心跳、按 id 分桶拼工具参数、等 done 再解析、前端断开传播到上游、合并增量减少重绘。
- 发前数 token:本地估算 + 接近上限时远程精确计数;换模型重新校准;中文按 1–2 token / 字估。
- 熔断与 fallback:错误率阈值熔断,fallback 链提前在评测集上验证,降级时告知用户。
十一、本文小结
- 错误分四类:可重试(429、5xx、529、网络、超时)、修正后重试一次(工具格式错、结构化输出校验失败、截断)、不可重试(400、401、配额不足、内容过滤、模型不存在)、需人工。200 也可能是失败——读
stop_reason。 - 重试用指数退避 + full jitter,
Retry-After优先,加全局重试预算;每次重试都是一次付费调用,流式中途断开只能重发。 - 超时分连接 / 首事件 / 事件间 / 总四层;超过一分钟的生成用流式(Anthropic SDK 会强制),更长的任务用后台或批处理形态。
- 模型调用本身的重复只多花钱;幂等问题在工具上——按
tool_call_id去重,不幂等的走确认门。 - 限流按 RPM 与 TPM(Anthropic 分输入 / 输出 TPM,DeepSeek 限并发),额度按层级(OpenAI Tier 1–5,Anthropic Start / Build / Scale);客户端放令牌桶按 token 扣,用限流头校正,提前规划容量。
- 流式解析要忽略心跳、分桶拼接工具参数、等 done 再解析、传播客户端断开(否则供应商继续计费)、合并增量。
- 发前数 token 能完全避免超限的 400;计数会随模型变(Sonnet 5 多 30%),中文比英文贵 30–50%。
- 熔断 + 分层 fallback(换区域 → 换模型 → 换供应商 → 降级到非模型),fallback 链提前验证,降级时告知用户。
十二、自测
-
一个请求返回 HTTP 200,
stop_reason是max_tokens,输出的 JSON 不完整。该重试吗?怎么重试?与stop_reason: refusal的处理有何不同?答案
这是”修正后重试一次”:提高
max_tokens(推理模型上它含思考 token,第三篇)或调低 effort 后重发一次;原样重发大概率同样截断。refusal是不可重试的:模型 / 供应商按政策拒绝,同样输入同样拒,反复重试可能触发更严的限制,应按产品逻辑处理并记录。两者都是 200,错误分类必须读响应体。详见第二章。 -
供应商过载,1,000 个客户端实例同时收到 529。对比”固定 2 秒后重试”、”指数退避无抖动”、”指数退避 + full jitter”三种策略在接下来 10 秒内对供应商的流量形状。
答案
固定 2 秒:1,000 个请求在第 2 秒同时到达,第二波过载,之后每 2 秒一波。指数退避无抖动:第 1、2、4、8 秒各一波,波峰仍是 1,000。full jitter:第一次重试均匀散在 0–1 s,第二次散在 0–2 s,第三次 0–4 s……流量被摊平,峰值降到原来的几分之一,供应商更快恢复。再加全局重试预算(重试不超过总量 10%)可进一步限制总流量。详见第三章。
-
agent 循环里模型返回
tool_call: send_invoice(order=123),你执行成功,但送回结果的请求超时。SDK 自动重试了那次请求。可能发生什么?怎么防?答案
重试的请求带的是同样的历史(含工具结果),通常没问题;但如果超时发生在你尚未送回结果、模型再次返回同一个
tool_call时,你会再执行一次,发票重复发送。防:工具按tool_call_id或业务键幂等(第二次返回第一次的结果);应用侧记录已执行的tool_call_id;发票、付款这类不幂等且对外的动作走确认门。模型调用本身重复只多花钱,副作用在工具上。详见第五章。 -
一个应用峰值 20 QPS,每请求平均输入 6,000 token、输出 800 token。它需要多少输入 TPM 与输出 TPM?如果账户当前层级的 TPM 是 400K,会怎样?
答案
输入 TPM = 20 × 60 × 6,000 = 7,200,000;输出 TPM = 20 × 60 × 800 = 960,000。400K 的 TPM 只够峰值的 5.5%(输入),上线即大面积 429。要提前升级层级 / 申请提额、用 prompt caching(部分供应商对缓存命中的 token 不计入或减计 TPM,需查文档)、或多供应商分摊;客户端令牌桶应按 400K × 0.9 扣,超出的请求排队而不是打出去挨 429。详见第六章。
-
从 Claude Sonnet 4.6 迁到 Sonnet 5 后,一个”保留最近 30 轮历史”的对话应用开始偶发上下文超限的 400,而窗口大小没变。原因是什么?该怎么改?
答案
Sonnet 5 的新 tokenizer 对同样文本多产出约 30% 的 token,30 轮历史在 4.6 上够放、在 5 上超了;且 Sonnet 5 默认开启 thinking,
max_tokens通常也被调高,占用了更多窗口。改法:不按轮数而按 token 数保留历史,发送前用count_tokens端点(或校准过的本地估算)检查 \(n_{\text{in}} + \text{max\_tokens} \le \text{窗口}\),超过则压缩;换模型时重新校准计数。详见第八章。
-
四类。可重试(原因在外部且会变):429 限流(遵守
Retry-After)、5xx / Anthropic 529 过载、网络错误、读超时。修正后重试一次(每次都是新的付费调用):工具调用参数不是合法 JSON 或不符 schema(把错误作为 tool result 送回)、结构化输出业务校验失败(附错误信息)、stop_reason: max_tokens截断(提高max_tokens或调低 effort)。不可重试(原因在你的请求或供应商政策里):400 参数 / 结构错、上下文超限、401 / 403、insufficient_quota、内容过滤 /refusal、404 模型不存在。需人工:配额与账务。注意 200 也可能是失败,要读响应体的stop_reason/finish_reason。详见第二章。 ↩ -
单一总超时设短会误杀正常的长回答(800 token × 30 ms = 24 s),设长则对卡死的连接要等很久。四层各管一段:连接(5–10 s,网络)、首事件(按输入长度与 effort,10–120 s,排队 / prefill / 思考)、事件间(15–30 s,生成卡住;收到心跳要重置)、总(TTFT + 输出 × TPOT 的 2–3 倍)。超过一分钟的生成一律流式(Anthropic SDK 对可能超 10 分钟的非流式请求会拒绝),更长的任务换后台 / Batch 形态。流式中断:首事件前失败按普通重试;中途断开没有续传机制,只能重发且已生成的 token 已计费——短回答自动重试、长回答提示用户;已收到的部分可展示但不能当完整回答存入历史;客户端断开要传播到上游否则供应商继续生成计费。详见第三章、第四章、第七章。 ↩
-
维度:RPM 与 TPM(Anthropic 分输入 / 输出 TPM;DeepSeek 不限速率限并发 2,500 / 500),每一维都可能先触顶——长输入应用先满 TPM,高频短请求先满 RPM。响应头给出限额 / 剩余 / 重置(OpenAI
x-ratelimit-*,Anthropicanthropic-ratelimit-*)。额度按账户历史消费分层(OpenAI Tier 1–5,Anthropic Start / Build / Scale),新账户额度低。容量规划:峰值 QPS × 每请求 token 分别算输入 / 输出 TPM,对照层级提前升级或多供应商分摊;客户端放按模型的令牌桶按 token 扣(额度打九折)、多租户加公平队列、用限流头动态校正,而不是等 429 再退避。详见第六章。 ↩ -
上下文超限的 400 是唯一可以在发送前完全避免的失败:窗口、
max_tokens、输入长度都已知,发前检查 \(n_{\text{in}} + \text{max\_tokens} \le \text{窗口}\),超了先压缩;限流器按 token 扣与成本预估也需要发前的计数。工具:OpenAI 本地tiktoken,Anthropic 远程count_tokens(免费,接受完整请求体),GooglecountTokens,DeepSeek 离线 tokenizer;本地估算用于限流,接近上限时远程精确计数。计数会变:Claude Sonnet 5 的新 tokenizer 让同样文本多约 30% token——账单涨、按轮数保留的历史超窗、字符数粗估失效,换模型要重新校准;中文每字 1–2 token,比同等信息量的英文长 30–50%。详见第八章。 ↩
系列 《模型作为组件:契约、失效模式与选型》 第 6 / 7 篇 入门
系列总览 — 为什么这样组织、读它需要什么、读完能做什么 · 幻灯片 — 整个系列的精华,一份可分享的 deck
学习地图 — AI 应用工程师学习地图 第 1 / 7 个系列
本文由 arganzheng 创作,采用 CC BY 4.0 许可协议。在保留原文作者、署名以及完整原文链接(https://arganzheng.life/llm-client-engineering-retries-timeouts-streaming-and-rate-limits.html)的前提下,欢迎各种形式的转载、翻译或商业引用。
COMMENTS
评论存放在 GitHub Discussions, 用 GitHub 账号登录即可发表,支持 Markdown。 想针对正文某句话说?选中那段文字,点浮出的「评论」即可划线评论;觉得哪里写错了,发表时勾上「同时提交 Issue」。 有人回复你时 GitHub 会按你的通知设置发邮件,不用守在这里。