本地调一次接口 1.2 秒返回,感觉挺好。上线第一周,监控里 P99 跳到 40 秒,超时告警一天几十条;月底账单出来,比估算多了一倍。
这两件事其实是同一个问题的两面:你把一次模型调用当成了普通的 HTTP 请求,但它可能慢三倍,也可能被限流,还可能因为你把整篇文档塞进 prompt 而悄悄变贵。
超时要分层,不能一个数管到底#
只设一个总超时是最常见的错误。三十秒的总超时意味着:一个卡在连接阶段的请求会占着线程三十秒,最后仍然失败。
至少要分开三段时间:
- 连接超时:建连阶段,一般三到五秒,短一点没坏处;
- 首 token 超时:从发出请求到收到第一个 token,这是用户感知最强的指标,流式接口必须单独设;
- 总时长:整段回答的上限,由产品体验决定,而不是由模型决定;
- 流式空闲超时:两个 token 之间超过多久算卡死,通常十到十五秒。
对交互式产品,我倾向「首 token 三秒、总时长二十秒、空闲十秒」。超过总时长的请求不要静默等待,直接中断并返回部分内容加一句「回答可能不完整」。用户看到半截答案,比看到转圈三十秒好得多。
重试只对可恢复的错误有意义#
把 400、401、403 这类错误重试,纯属浪费配额,还会把真正的问题掩盖掉。只重试这几类:连接失败、超时、429 限流、500 与 502 到 504。
退避要带抖动。固定间隔的重试,在限流场景下会让一批请求同时回来,形成第二次尖峰。指数退避加随机因子,能把这批请求摊平。
更关键的是总预算,而不是单次重试次数。用户能接受的等待是十八秒,那就让所有尝试共享这十八秒:主模型两次、降级模型两次,时间用完就返回兜底结果。按次数限流的写法,一次慢请求加三次重试可能拖到一分钟。
还有一个前提:重试必须幂等。生成类请求天然幂等,但「生成后写库」不是,重试前要确认有没有已经落库。
降级要有阶梯,不能只有「失败」#
降级不是「出错就返回 500」,而是准备几个可接受的替代方案,按代价排序:
- 换小模型:同一供应商的小一号模型,或另一家供应商。质量降一点,可用性保住。
- 读缓存:相似问题命中历史答案。要设相似度阈值和时间窗口,别把过期答案当新答案。
- 规则兜底:模板化回复、检索到的原文片段直接返回。丑但正确。
- 异步化:告诉用户「正在处理,稍后通知」,把同步等待变成任务队列。
关键是降级要打标。响应里带上 degraded: true 和原因,日志里记录触发了哪一级。否则你永远不会知道系统有一半时间在跑降级路径,也不会知道降级答案的质量如何。
成本要能归因,才谈得上控制#
只看月账单没有意义,因为它不告诉你是哪个功能、哪个租户、哪段 prompt 花掉的。
先把归因做好:每次调用记录功能名、租户、输入 token、输出 token、模型名。然后看两个指标——单次请求成本,以及每个成功任务的成本。后者更真实,因为失败的调用同样计费。
| 手段 | 省的是什么 | 副作用 |
|---|---|---|
| 裁剪上下文,只留相关片段 | 输入 token | 召回不足会掉准确率 |
| 限制输出长度 | 输出 token | 长答案被截断 |
| 按任务选模型 | 单价差 | 小模型在难任务上错误率上升 |
| 结果缓存 | 重复请求全部成本 | 时效性内容必须带过期策略 |
| 批量合并请求 | 调用次数 | 延迟变高,失败影响面变大 |
| 结构化输出替代长解释 | 输出 token | 需要下游能解析 |
不要靠「把 max_tokens 调到很小」来省钱。被截断的答案会引发重问,总成本反而更高。省钱的顺序应该是:先裁上下文,再换模型档位,最后才动输出长度。
下面这个封装把总预算、带抖动的重试和降级串在一起,可以直接当模板改。
"""llm_call.py —— 共享总预算的重试 + 降级(依赖 pip install openai)"""
import os, random, time
from openai import OpenAI
client = OpenAI(base_url=os.getenv("OPENAI_BASE_URL"), api_key=os.getenv("OPENAI_API_KEY"))
PRIMARY, FALLBACK = "your-primary-model", "your-fallback-model"
RETRYABLE = {408, 429, 500, 502, 503, 504} # 其余错误重试无意义
def call_llm(messages, total_budget=18.0, per_try=8.0):
"""所有尝试共享 total_budget,时间用完就走兜底,而不是无限重试"""
start, attempts, last_err = time.monotonic(), 0, None
for model in (PRIMARY, FALLBACK):
for _ in range(2):
left = total_budget - (time.monotonic() - start)
if left <= 1.0:
return {"ok": False, "reason": "budget_exhausted", "degraded": True}
attempts += 1
try:
resp = client.with_options(timeout=min(per_try, left)).chat.completions.create(
model=model, messages=messages, max_tokens=800, temperature=0.2)
text = resp.choices[0].message.content or ""
return {"ok": True, "text": text, "model": model, "attempts": attempts,
"degraded": model != PRIMARY, "tokens": resp.usage.total_tokens}
except Exception as exc:
last_err = exc
code = getattr(exc, "status_code", 0) or 0
if code not in RETRYABLE and code != 0:
break # 400/401 之类,重试也不会变好
# 指数退避 + 抖动,避免限流时二次尖峰
time.sleep(min(0.4 * 2 ** attempts, 2.0) * (0.5 + random.random()))
return {"ok": False, "reason": str(last_err), "attempts": attempts, "degraded": True}
把它接进业务时,别把 degraded 丢掉。它决定了要不要给用户加一句「本次回答质量可能受限」,也决定了告警要不要响。
上线前先定四个指标#
成功率要拆成「主链路成功」和「降级成功」,混在一起看会掩盖质量下滑。首 token 的 P95 比均值更能反映体感,均值会被大量快请求拉平。降级触发率长期超过百分之五,说明容量或配额有问题,而不是偶发抖动。每成功任务成本要按功能拆分,否则一个昂贵功能会盖住其他功能的优化空间。
这四个数每周看一次,比出事后翻日志有效。尤其是首 token P95 和降级率,它们通常在用户投诉之前就已经开始变差。
结论#
- 超时按连接、首 token、总时长、空闲分开设,交互产品优先保首 token。
- 只重试连接失败、超时、429 与 5xx,退避必须带抖动。
- 用总时间预算而不是重试次数做上限,慢请求不该拖住后面的重试。
- 降级准备四级:小模型、缓存、规则兜底、异步化,并且一定要打标。
- 成本先做归因,再看每成功任务成本;省钱的顺序是裁上下文、换档位、压输出。
这些机制不会让模型变强,但它们决定了模型出问题时,用户是看到一个慢一点的答案,还是看到一个 500。
完