1. 先想清楚:报错和延迟从来不是两件事
线上跑着一条内容加工流水线,白天一切正常,晚上八点开始报警:OpenAI API 的报错率从 0.3% 涨到 7%,同时 ATL(Average Turnaround Latency,平均周转延迟,指的是从业务代码发起请求到拿到完整、可解析结果的平均耗时)从 1.6 秒涨到 4.9 秒。值班的同学第一反应是"接口挂了",第二反应是"加机器"。但真正翻日志会发现,这两件事根本是同一件事的两面:延迟变长导致客户端读超时,读超时触发重试,重试把并发打高三倍,并发一高就撞上 429 限流,429 又被重试逻辑当成可重试错误继续放大,最后整个调用链陷入自我拥堵。
这就是我这些年做 API 接入时反复验证过的一个结论:报错率和延迟是一组耦合指标,只盯一头永远调不好。你只优化延迟,不做错误的分类治理,一次 429 风暴就能把 P99 拉爆;你只做报错重试,不看延迟结构,重试预算会吞掉全部超时余量,故障时把上游打得更惨。
这篇文章想讲透的就是这套东西:OpenAI API 常见报错的真实成因与分类处置方式、ATL 这个指标该怎么拆怎么量、以及在真实工程里怎么把延迟从"玄学"变成可以按预算分配的数字。内容偏实战,适合正在把大模型能力往生产系统里接的后端、平台和算法同学看,也适合刚接手调用层代码、被一堆 429 和 timeout 折磨到怀疑人生的同学。基础概念我会顺手带一句,不会假设你已经很熟。
需要先说清一个前提:下文所有数字都来自模拟压测和虚构的线上案例(某公司的内容加工流水线、模拟项目 X 等),具体到你的业务,量级会不一样,但拆解方法和取舍逻辑是通用的。
1.1 一个必须先统一的口径问题
"延迟"这个词在团队里最容易吵架。产品说延迟 2 秒,指的是用户点击到看到第一个字;后端说延迟 2 秒,指的是 HTTP 请求耗时;算法说延迟 2 秒,指的是模型 decode 阶段。三个人说的其实是三个指标,谁也说服不了谁。
我的做法是在项目一开始就把指标拆成四个,写进监控,后面所有讨论都对齐这四个:
| 指标 | 全称 | 口径 | 典型用途 |
|---|---|---|---|
| TTFT | Time To First Token | 请求发出到收到第一个有效 token(或第一个流式 chunk) | 决定用户"感觉快不快" |
| TTFT_full | Time To First Token(含排队) | 业务入队到首个 chunk,包含客户端并发等待 | 决定 SLO 达标率 |
| ATL | Average Turnaround Latency | 请求发出到结果完整可解析 | 决定整体吞吐与成本 |
| e2e | End-to-End | 用户点击到界面渲染完成 | 决定体验指标 |
四个指标里,ATL 是最容易被误用的。很多人直接拿 SDK 的elapsed当 ATL,但那个值不包含排队,也不包含你后面那段 JSON 反序列化和业务校验。我见过一次真实事故:调用层监控显示 P95 只有 1.2 秒,特别漂亮,但用户侧投诉严重。后来查出来,监控打点在 SDK 返回之后、业务解析之前,而那段解析因为模型偶尔返回带 markdown 代码块包裹的 JSON,走了异常分支再兜底正则,单次多花 800 毫秒。指标好看,体验很糟。
所以口径要定死:ATL 的起点是业务发起调用那一刻,终点是业务拿到结构化结果那一刻,中间所有耗时段都要埋点。这件事在一开始不做,后面补埋点的成本会高十倍。
1.2 为什么我更愿意先治理报错,再谈优化延迟
有个反直觉的经验:在很多项目里,把报错治理做扎实,延迟指标会自己好一截。原因是长尾延迟的很大一部分不是模型慢,而是"失败 + 重试 + 再失败"这条路径贡献的。一次成功调用 1.5 秒,一次失败重试成功可能 8 秒,如果失败率 5%,它对 P99 的贡献远超那 95% 的成功请求。
我做过一次对比,在同一条流水线上只做两件事:把不可重试错误(401、400 参数错、配额耗尽)从重试队列里摘出去,以及给重试加上抖动。结果是 P99 从 11.2 秒降到 4.8 秒,报错率也从 6.8% 降到 1.1%,模型参数一个字没改。这个投入产出比,比任何 prompt 层面的优化都高。
所以下面的结构是:先把报错按"该不该重试"重新分类,再拆延迟结构,最后落到具体优化手段。顺序不能反。
2. 报错分类:先判断"该不该重试",再判断"怎么修"
新手看报错容易陷入一个误区:把所有非 200 都当成"接口不稳定",统一丢进重试。这是最危险的做法,因为它会把确定性错误变成流量放大器。真正有用的分类维度只有一个——这个错误重试之后结果会不会变。
2.1 客户端错误:401、403、404、400 的排查手法
401 Unauthorized是最常见的"人祸"。我踩过的几种形态:
- 密钥轮换后,新密钥写进了配置中心,但某个容器因为没重启还在用旧密钥,于是只有部分实例报 401。这种"部分失败"最迷惑人,因为监控看板整体错误率可能只有 2%。
- 环境变量注入时带了尾随换行,
Authorization: Bearer sk-xxx\n,服务端直接判非法。肉眼看不出来,repr()一下立刻现形。 - 多环境混用密钥,测试环境的 key 打到了生产端点。
排查顺序我固定为三步:先用最小 curl 复现(剥离 SDK 和业务封装),再打印密钥的长度和首尾字符(不要打印完整密钥),最后核对注入链路。第一步能排掉 80% 的问题,因为大量 401 其实是 SDK 版本升级后 header 拼装逻辑变化导致的。
403 Forbidden和 401 的区别在于"身份认得,但没权限"。常见于组织级权限、项目级配额、模型访问白名单。这类错误不该重试,应该立刻告警到人,因为它是配置问题,不会自愈。
404 model_not_found有两个高频成因:一是模型名称写错(多一个空格、版本号用了已经下线的别名),二是账户还没开通该模型的访问权限。我建议在服务启动时做一次"预热自检":拿配置里的模型名发一个max_tokens=1的最小请求,失败就拒绝启动。这个自检能让一类上线事故彻底消失。
400 参数错误里头最值得单独讲的是context_length_exceeded。它的坑在于很多人以为"我 prompt 只有 3000 token,模型上下文 128k,怎么会超"。因为上下文包含输入 + 输出,而且你的max_tokens是从总窗口里预留的。正确算法是:
可用输入上限 = 模型上下文窗口 - max_tokens - 安全余量安全余量我一般留 200 token,用于抵消 tokenizer 版本差异和消息格式包装开销(每轮对话都有 role、分隔符这些固定开销,几条消息下来几十 token 很常见)。如果你的业务是多轮对话,历史累积很容易在某个用户的长会话里突然爆掉,而这类错误的表现是"偶发 400",非常难复现。我的处理方式是在拼装请求前先本地估 token,超了就做滑动窗口裁剪或者摘要压缩,绝不把超限请求发出去。
2.2 429 的两张面孔:限流 vs 配额耗尽
429 是整个调用链里被误解最深的错误。它至少有两种完全不同的含义:
| 类型 | 触发原因 | 响应特征 | 正确处理 |
|---|---|---|---|
| 速率限流 | RPM / TPM 超阈值 | 通常带 retry-after 或限流相关 header | 退避重试 + 削峰 |
| 配额耗尽 | 账户余额/额度用尽 | 错误体提示 quota / billing 相关 | 重试无意义,告警 + 降级 |
把这两类混在一起重试,是很多"重试风暴"的直接原因。配额耗尽型的 429 你重试一万次也不会成功,只会把延迟从秒级拉到分钟级,并且把日志写爆。
判断方式很朴素:读错误响应体里的 code / type 字段,别只看 HTTP 状态码。工程上我会在封装层定义一个错误枚举,把 429 拆成RATE_LIMITED和QUOTA_EXHAUSTED两个值,前者进重试队列,后者直接进入降级分支并触发一条高优先级告警。
速率限流还有个隐蔽点:TPM(每分钟 token 数)比 RPM(每分钟请求数)更容易先撞到。因为你的请求大小不均匀,平均每请求 800 token,偶尔来一个 20k token 的长文档,瞬时 TPM 就冲上去了。只按 RPM 做客户端限流,照样会撞墙。我的做法是按 token 做令牌桶,桶的容量按 TPM 的 70% 设置,给自己留 30% 缓冲给突发。
2.3 服务端 5xx 与流式中断:最难查的一类
500 / 502 / 503 / 504 从语义上都是"服务端那边出问题了",但处置差别很大:
- 500:服务端内部异常,通常是偶发。可以重试,但要有次数上限。
- 502 / 503:过载或上游不可用,这类错误往往成片出现,重试必须带抖动,否则同一秒内所有客户端一起重试,直接把恢复中的服务再打趴一次。
- 504:网关层超时。它的特点是"请求可能已经到达服务端并产生了 token 消耗,但客户端没收到结果"。也就是说你这次重试是要重新付费的。这一点在成本敏感的业务里要特别留意。
流式中断(SSE 连接中途断开)是最难查的。它的表现是:HTTP 状态 200,前半段正常返回 chunk,中途连接断掉,SDK 抛出一个读超时或者"流意外结束"的异常。成因我在实际项目里遇到过三种:
- 客户端读超时设得太短。流式请求的总耗时本来就长,如果你沿用非流式的 30 秒读超时,长输出必然被切断。
- 中间网络设备空闲超时。流式请求在 decode 阶段是"持续有数据但间隔可能几秒"的模式,某些设备对空闲连接的判定比较激进。
- 上游确实抖了。
对应处理:读超时改成"分两级"——总超时给足(比如 300 秒),同时加一个空闲看门狗:连续 N 秒(我一般设 15 秒)没有收到任何 chunk 就主动断开重试。这比单纯调大总超时靠谱得多,因为它能区分"慢但在动"和"已经死了"。
另外流式重试天然不幂等:你重试的时候,前半段已经输出给用户的内容怎么办?我的处理是业务侧维护一个"已输出字符游标",重试时把已输出的部分作为前缀校验,如果新流的内容和旧内容对不上就整段替换并打点。体验上会有一次内容跳变,但比页面卡死强。
2.4 一份可以直接抄的错误处置表
把上面的逻辑收敛成一张表,我每个项目都会带一份类似的,贴在封装层的 README 里:
| 错误 | 可重试 | 重试策略 | 附加动作 |
|---|---|---|---|
| 401 / 403 | 否 | 不重试 | 立即告警,停止该实例流量 |
| 404 model_not_found | 否 | 不重试 | 启动自检失败,拒绝上线 |
| 400 参数错误 | 否 | 不重试 | 记录请求特征,修代码 |
| 429 RATE_LIMITED | 是 | 指数退避 + 全抖动,最多 2 次 | 令牌桶动态降速 |
| 429 QUOTA_EXHAUSTED | 否 | 不重试 | 降级 + 高优告警 |
| 500 | 是 | 退避重试,最多 2 次 | 计数,超阈值熔断 |
| 502 / 503 | 是 | 退避 + 抖动,最多 3 次 | 触发熔断,切备用路径 |
| 504 | 是 | 谨慎重试,最多 1 次 | 记录可能重复计费 |
| 读超时(非流式) | 是 | 1 次 | 检查超时预算 |
| 流式中断 | 是 | 1 次,带游标校验 | 空闲看门狗 |
| finish_reason=length | 否 | 不重试 | 视为业务异常,调整 max_tokens |
这张表的价值不在于多全,而在于它强迫你在写代码之前就把"这条路径失败了该怎么办"想清楚。我见过太多封装层只有一句except Exception: retry(),那等于把分类工作全部推给了运气。
3. ATL 的结构化拆解:把一段耗时切成五块
延迟优化最常见的失败模式是"凭感觉调参":把max_tokens调小、把 prompt 砍短、把并发调高,然后发现指标没动或者动了之后别的地方炸了。根本原因是没有先做归因——你不知道那 3 秒里每一块占多少,任何优化都是赌博。
3.1 五段式拆解模型
我把一次完整的调用切成五段,每一段都能独立埋点:
ATL = T1 客户端排队 + T2 建连 + T3 上行 + T4 服务端处理 + T5 下行与解析T1 客户端排队:被信号量、线程池、连接池阻塞的时间。这一段在低峰期接近 0,一旦有并发控制,故障期会突然变大。它的意义在于:很多"上游变慢了"的报警,其实是自己这边的排队变长了。
T2 建连:DNS 解析 + TCP 握手 + TLS 握手。连接复用好之后这一段是 0,但冷启动的第一枪会非常难看。我实测过一个场景:容器刚启动,第一枪因为要建连,比后续请求多花 700~900 毫秒。如果你的服务是弹性扩缩容的,新实例持续加入,这段开销会稳定出现在 P99 里。
T3 上行:请求体传输时间。它和 prompt 长度、压缩方式、带宽有关。一般不大,但如果你把整篇文档塞进 prompt,上行几十 KB 到几百 KB,在网络条件一般的情况下也能占到几百毫秒。
T4 服务端处理:这是大头,还要再拆成两半——排队 + prefill(与输入 token 数近似线性相关,但如果服务端有 prompt 缓存,命中后 prefill 会大幅下降)和decode(与输出 token 数近似线性相关,吞吐大致在每秒几十 token 的量级)。
T5 下行与解析:接收响应 + JSON 反序列化 + 业务校验。这一段最容易被忽略,也是我见过最多"藏起来的时间"。
3.2 一个具体的预算分配算例
假设业务目标是 ATL ≤ 2.5 秒(P95),模型方面:待测模型 decode 吞吐按 60 token/s 估算,首 token 大约 400 毫秒(含排队)。那么算一下:
T4 ≈ 0.4s(首 token) + output_tokens / 60 若 output_tokens = 300,则 T4 ≈ 0.4 + 5.0 = 5.4s一算就发现 2.5 秒根本不可能。这时候你只有三个选择,而且必须选一个:
- 压输出:把 output 压到 80 token,T4 ≈ 0.4 + 1.33 = 1.73s,剩下 0.77 秒给 T1+T2+T3+T5。可行,但要求 prompt 里必须明确约束输出结构(比如要求只返回 JSON 且字段固定)。
- 换更快的模型:decode 吞吐提到 150 token/s,300 token 的输出就只要 2 秒。
- 改交付形态:从"等完整结果"改成"流式边出边显示",把用户感知指标从 ATL 换成 TTFT。TTFT 只需要 0.4~0.8 秒,体验立刻达标,但 ATL 本身没变。
这三条路的成本差异极大,而选哪条完全取决于你先把账算清楚。我在项目里见过最常见的错误决策是:明明产品要求的是"首字秒出",团队却花大力气优化 ATL,把输出从 500 token 压到 200 token,牺牲了答案质量,而用户感知根本没变——因为用户看的是第一个字什么时候出现。
3.3 埋点怎么写才不糊
埋点有个原则:每一段都要能单独回答"这段时间是谁花的"。我的做法是在客户端用单调时钟打四五个时间戳,然后统一上报,服务端只信自己算出来的值。
import time t0 = time.perf_counter() # 业务发起 # ... 获取信号量、取连接 ... t1 = time.perf_counter() # 出队 first_chunk = None chunks = [] for ch in stream: if first_chunk is None: first_chunk = time.perf_counter() chunks.append(ch) t_end = time.perf_counter() # ... 解析 ... t_parse = time.perf_counter() metrics.observe("atl", t_parse - t0) metrics.observe("queue", t1 - t0) metrics.observe("ttft", (first_chunk or t_end) - t1) metrics.observe("decode", t_end - (first_chunk or t1)) metrics.observe("parse", t_parse - t_end)几个细节值得强调:
- 用
perf_counter而不是time.time(),后者受系统时钟调整影响,算出来的差值可能是负的。 - 上报不要每秒同步写一次,攒批后异步发送,否则监控本身成为延迟来源。
- 一定要带
trace_id,把一次调用在日志、指标、链路追踪里串起来。故障复盘时没有 trace_id,等于重新破案。 - 分维度打标签:模型名、入口(哪个业务调用的)、是否命中缓存、是否重试。"是否重试"这个标签极其重要,它能把成功请求和重试请求的延迟分开看,否则你的 P99 永远是混在一起的。
4. 延迟优化的具体手法:从传输层到架构层
有了归因,优化才有靶子。我按投入产出比从高到低排,实际上大部分项目做到前三层就够了。
4.1 传输层:把该省的时间省掉
连接复用是第一优先级,没有之一。每次请求都新建连接,等于每次都付一次 TCP + TLS 的钱。做法很简单:全局复用一个 HTTP 客户端实例,配置合理的连接池上限,开启 keep-alive。这里有个容易搞错的点——连接池大小不是越大越好,它应该和你的并发数匹配:
所需连接数 ≈ QPS × 平均耗时(秒)举例:20 QPS,平均耗时 1.8 秒,那并发大约是 36,连接池上限设 40~50 比较合适(留点缓冲)。设成 200 的结果是一堆空闲连接被中间设备回收,反而增加建连次数。
如果你用的是 HTTP/2,单连接可以多路复用,连接数需求会低很多,但要注意并发流上限的配置,超过上限的请求会在客户端排队,表现为 T1 变大。这种情况下调连接数没用,要调并发流限制。
import httpx # 复用一个 client,别在函数里 new client = httpx.Client( timeout=httpx.Timeout(connect=2.0, read=300.0, write=10.0, pool=5.0), limits=httpx.Limits( max_connections=50, max_keepalive_connections=30, keepalive_expiry=60.0, ), http2=True, )超时要分层设,不能一个数字打天下。上面这段里,connect 只给 2 秒(建连超过 2 秒基本可以判定网络有问题,快速失败比干等强),read 给 300 秒是为了长输出流式,pool 给 5 秒是防止排队无限增长。非流式请求的 read 超时要单独按 P99 估,一般取 P99 的 1.5 倍。
DNS 和 TLS 的冷启动成本要提前处理。我会在服务启动完成后主动发一个max_tokens=1的预热请求,把一个连接建好放进池子。这一步能让上线初期的 P99 好看很多,代价几乎为零。
4.2 请求层:prompt、输出长度与流式
输出长度对 ATL 的影响是线性的,输入长度对 TTFT 的影响也是近似线性的。这两句话值很多钱。
先说输出。max_tokens不是"限制",是"预留"。设太大有三个坏处:占用上下文窗口、让服务端预留更多资源、在你忘记约束输出时导致长尾。我通常按业务的真实需求设,比如分类任务只需要返回一个标签,max_tokens设 20 就够。
但要注意finish_reason=length这个坑:很多人把它当成"报错"来处理,其实是输出被截断了。如果业务在解析 JSON,截断会导致解析失败,异常日志报的是"JSONDecodeError",你顺着错误找半天,发现根因是 max_tokens 差了几个。所以解析失败时第一件事是看finish_reason。
再说输入。压缩 prompt 的手法我常用的几个:
- 去掉重复的系统提示,把多轮历史做摘要而不是全量拼接。
- 结构化数据用紧凑格式(去掉缩进和多余空格),大 JSON 能省 20%~40% token。
- 把稳定不变的前缀放在最前面,有利于命中服务端的 prompt 缓存。这一点是纯收益,因为缓存命中后 prefill 阶段的耗时和计费都会降。我在一个长系统提示的场景里实测过,命中缓存后 TTFT 从 900 毫秒降到 300 毫秒左右。
流式是体验优化里性价比最高的开关。它不改变 ATL,但把用户感知延迟从"整段耗时"变成"首 token 耗时",通常能砍掉 70% 以上的等待感。代价是你要处理 SSE 解析、断流重连、以及前端渲染的增量更新。我的建议是:只要是人看的场景,一律开流式;纯机器消费的场景(比如批量打标)不开,因为流式会增加客户端连接管理的复杂度。
4.3 架构层:缓存、批处理、降级
精确缓存:对 prompt 做哈希,命中直接返回。适合模板化、重复率高的场景(比如固定分类体系下的内容打标)。命中率能到 30% 以上时,延迟和成本都会明显下降。
语义缓存:用 embedding 做近邻检索,相似度超过阈值就复用答案。这个要谨慎,阈值设低了会返回不准确的答案,我一般只在 FAQ 类场景用,阈值设 0.95 以上,并且灰度放量。
批处理:离线任务不要走实时接口,用批量提交的方式,用时间换成本。同步任务里的多条独立请求,如果能合并成一个请求(比如一次分类 20 条,用列表输出),ATL 会显著下降——因为省掉了 19 次网络往返和 19 次排队。
降级:当 ATL 超过预算或者错误率超过阈值时,自动切到小模型或者模板答案。降级不是"认输",是一种保护:宁可返回一个质量略低但及时的结果,也不要让用户等 10 秒然后看到错误页。
4.4 重试设计:最容易写错的一段代码
重试写不对,前面的优化全白做。三条铁律:
第一,重试必须有总预算。不是"最多重试 3 次",而是"整条链路总耗时不能超过 N 秒"。因为 3 次重试如果每次都等 30 秒超时,总耗时就是 120 秒,业务早就该超时了。
第二,必须加抖动。固定间隔的退避会在同一时刻产生共振,把恢复中的上游二次打垮。全抖动(full jitter)最简单也最有效:
import random def backoff_delay(attempt: int, base: float = 0.5, cap: float = 8.0) -> float: """全抖动退避:sleep 在 [0, min(cap, base * 2^n)] 内均匀分布""" ceiling = min(cap, base * (2 ** attempt)) return random.uniform(0, ceiling)第三,别让多层重试叠加。这是最隐蔽的一个坑:SDK 自带重试(默认通常 2 次),你的封装层再写 3 次,业务调用方再兜底 2 次,一次请求最坏会产生3 × 4 × 3 = 36次实际调用。故障时这不是"重试",是"自毁"。我的约定是只在一层做重试,通常是业务封装层,SDK 的重试显式关掉,上游调用方只做超时不做重试。
# 关掉 SDK 内置重试,统一在封装层控制 client = OpenAI(max_retries=0, timeout=60.0)5. 排查实录:那些文档里不会写的问题
下面这些是我在实际项目里真金白银踩出来的,按"症状 → 排查路径 → 根因 → 处置"整理。
5.1 典型问题速查表
| 症状 | 排查路径 | 常见根因 | 处置 |
|---|---|---|---|
| 错误率只有 2%,但集中在少数实例 | 按实例维度看错误率 | 部分实例用了旧密钥/旧配置 | 强制滚动重启,配置热更新的生效校验 |
| P99 突增但平均值正常 | 分离"成功请求"和"重试请求"的延迟 | 长尾是重试贡献的 | 摘掉不可重试错误,加抖动 |
| 冷启动第一分钟慢 | 看新实例的前 N 个请求耗时 | 建连 + TLS 握手 | 启动预热请求 |
| 流式请求偶发"意外结束" | 看断开时间点是否固定 | 空闲超时 / 读超时太短 | 空闲看门狗 15 秒 |
| 解析报 JSONDecodeError | 看 finish_reason | 输出被 max_tokens 截断 | 调大 max_tokens 或约束输出 |
| 429 重试后错误率更高 | 看 429 的错误体类型 | 配额耗尽被当成限流重试 | 拆成两个错误枚举 |
| 高峰期 T1 排队暴涨 | 看信号量等待时间 | 并发上限设死,没有弹性 | 动态并发 + 快速失败 |
| 成本突然翻倍 | 统计重试次数与 token 消耗 | 504 重试导致重复计费 | 504 只重试 1 次,加计费监控 |
5.2 几个具体的坑
坑一:把超时当成万能药。有个项目为了提高"成功率",把读超时从 30 秒调到 120 秒。结果错误率确实降了,但 P99 从 8 秒涨到 40 秒,用户大量流失。超时调的其实是"你愿意等多久",这个值应该由业务体验决定,而不是由"想少报点错"决定。
坑二:日志里打全量 prompt。出问题时想复盘,把完整 prompt 打进日志,一天下来磁盘告警。更麻烦的是,prompt 里可能包含业务数据,日志系统往往权限管理更松。我的做法是只记录 prompt 的哈希、token 数、前 50 字符,需要复现时用哈希去对象存储里取。
坑三:并发控制用的是固定值。上线时压测出"50 并发最优",就写死 50。但模型端的容量是会变的,你的流量结构也会变。更稳的做法是自适应:维护一个滑动窗口的 P95 延迟,延迟上升就主动降并发,延迟回落再慢慢升。这就是常说的"梯度限流"思路。
坑四:忽略 tokenizer 的版本差异。同一个字符串,不同版本的 tokenizer 算出来的 token 数可能不一样。你本地按旧版本估的 token 没超限,服务端按新版本算就超了,于是偶发 400。所以本地估算要留余量,并且把 tokenizer 版本也纳入配置管理。
坑五:没有独立的小流量通道。所有业务共用一个调用通道,一个批量任务跑起来就把实时业务挤死。我的做法是给实时业务和离线任务分不同的并发池、不同的限流桶,物理隔离,互不影响。
5.3 我个人的排查顺序
真出事的时候,我基本按这个顺序走,能在十分钟内定位到方向:
- 看错误类型分布,不看总量。429 占比多少、5xx 占比多少、超时占比多少。占比决定方向。
- 看延迟分位数,P50 / P95 / P99 一起看。P50 稳定而 P99 暴涨,说明是长尾问题(大概率重试或个别大请求);三个一起涨,说明是系统性变慢(上游或自己排队)。
- 看是不是"自己造成的"。查 T1 排队时间、查重试比例、查并发数是否被打满。这一步能排掉一半以上的"上游问题"。
- 最小复现。用 curl 直接打端点,绕开所有封装。如果 curl 正常,问题在你的代码;如果 curl 也慢,问题在链路或上游。
- 看变化点。最近上线的配置、SDK 版本、prompt 模板、并发参数,逐个回滚验证。
这套流程的价值在于它是有序的,不依赖于灵感。值班的同学照着走,就算不熟也能给出有意义的结论。
6. 上线前我会检查的几件事
写到这里,把一些零散但重要的经验收一下,这部分是我每个项目上线前都会过一遍的清单。
关于配置:模型名、超时、并发、重试次数、退避参数全部走配置中心,不要硬编码。硬编码的超时值在故障时改不动,只能发版,而发版本身要时间。配置变更要有生效校验,改完之后打一条日志确认真的生效了——我就遇到过配置推下去了但进程没重载的情况。
关于观测:至少要有四个面板——错误类型分布、延迟四分位、重试率、token 消耗。四个缺一个,你就得靠猜。另外建议加一个"降级触发次数"的计数器,降级是无声的,不计数你根本不知道自己在降级。
关于容量:按 Little's Law 算清楚并发需求,再乘以 1.5 的安全系数。同时准备好"流量翻三倍"时的应急手段:调低 max_tokens、关掉非核心功能、把部分流量切到小模型。这些开关要提前做好,故障现场现写代码是来不及的。
关于成本:延迟优化往往顺带省钱,但重试会顺带烧钱。给每次调用打上"是否重试"的标签,按标签统计花费,你会发现重试花的钱比想象中多。有一段时间我们的重试请求只占 4% 的调用量,却贡献了 18% 的 token 消耗。
关于测试:压测不要只压成功路径。我一般会构造三种故障注入:随机 429、随机 2 秒延迟、随机流中断,看系统在故障下的表现。这一步做过的系统,真出故障时基本不会失控。没做过的,第一次故障就是灾难。
最后说个我自己的体会。做 API 接入这件事,头一年我总觉得核心是"调通",把文档看一遍、把 SDK 接上、跑通 demo 就算完。后来才明白,真正的工程量全在"调通之后":怎么让它在流量涨十倍时不崩、在上游抖的时候不放大、在出错的时候能十分钟内说清楚发生了什么。报错分类和延迟拆解这两件事,本质上都是在把"不确定性"变成"可以量化、可以预算、可以回滚的东西"。做完这一步,你面对的不再是一个黑盒接口,而是一条每一段都能被看见的链路。