AI 笔记 · 2026 年 11 月 11 日

把 LLM 接入生产:超时、重试、降级与成本控制

Demo 里一次调用只要一点二秒,上线后长尾变成几十秒,账单还超了预期。超时要分层、重试要有总预算、降级要留退路、成本要能归因,四件事缺一件就得出事。

本地调一次接口 1.2 秒返回,感觉挺好。上线第一周,监控里 P99 跳到 40 秒,超时告警一天几十条;月底账单出来,比估算多了一倍。

这两件事其实是同一个问题的两面:你把一次模型调用当成了普通的 HTTP 请求,但它可能慢三倍,也可能被限流,还可能因为你把整篇文档塞进 prompt 而悄悄变贵。

超时要分层,不能一个数管到底#

只设一个总超时是最常见的错误。三十秒的总超时意味着:一个卡在连接阶段的请求会占着线程三十秒,最后仍然失败。

至少要分开三段时间:

  • 连接超时:建连阶段,一般三到五秒,短一点没坏处;
  • 首 token 超时:从发出请求到收到第一个 token,这是用户感知最强的指标,流式接口必须单独设;
  • 总时长:整段回答的上限,由产品体验决定,而不是由模型决定;
  • 流式空闲超时:两个 token 之间超过多久算卡死,通常十到十五秒。

对交互式产品,我倾向「首 token 三秒、总时长二十秒、空闲十秒」。超过总时长的请求不要静默等待,直接中断并返回部分内容加一句「回答可能不完整」。用户看到半截答案,比看到转圈三十秒好得多。

重试只对可恢复的错误有意义#

把 400、401、403 这类错误重试,纯属浪费配额,还会把真正的问题掩盖掉。只重试这几类:连接失败、超时、429 限流、500 与 502 到 504。

退避要带抖动。固定间隔的重试,在限流场景下会让一批请求同时回来,形成第二次尖峰。指数退避加随机因子,能把这批请求摊平。

更关键的是总预算,而不是单次重试次数。用户能接受的等待是十八秒,那就让所有尝试共享这十八秒:主模型两次、降级模型两次,时间用完就返回兜底结果。按次数限流的写法,一次慢请求加三次重试可能拖到一分钟。

还有一个前提:重试必须幂等。生成类请求天然幂等,但「生成后写库」不是,重试前要确认有没有已经落库。

降级要有阶梯,不能只有「失败」#

降级不是「出错就返回 500」,而是准备几个可接受的替代方案,按代价排序:

  1. 换小模型:同一供应商的小一号模型,或另一家供应商。质量降一点,可用性保住。
  2. 读缓存:相似问题命中历史答案。要设相似度阈值和时间窗口,别把过期答案当新答案。
  3. 规则兜底:模板化回复、检索到的原文片段直接返回。丑但正确。
  4. 异步化:告诉用户「正在处理,稍后通知」,把同步等待变成任务队列。

关键是降级要打标。响应里带上 degraded: true 和原因,日志里记录触发了哪一级。否则你永远不会知道系统有一半时间在跑降级路径,也不会知道降级答案的质量如何。

成本要能归因,才谈得上控制#

只看月账单没有意义,因为它不告诉你是哪个功能、哪个租户、哪段 prompt 花掉的。

先把归因做好:每次调用记录功能名、租户、输入 token、输出 token、模型名。然后看两个指标——单次请求成本,以及每个成功任务的成本。后者更真实,因为失败的调用同样计费。

手段 省的是什么 副作用
裁剪上下文,只留相关片段 输入 token 召回不足会掉准确率
限制输出长度 输出 token 长答案被截断
按任务选模型 单价差 小模型在难任务上错误率上升
结果缓存 重复请求全部成本 时效性内容必须带过期策略
批量合并请求 调用次数 延迟变高,失败影响面变大
结构化输出替代长解释 输出 token 需要下游能解析
注意

不要靠「把 max_tokens 调到很小」来省钱。被截断的答案会引发重问,总成本反而更高。省钱的顺序应该是:先裁上下文,再换模型档位,最后才动输出长度。

下面这个封装把总预算、带抖动的重试和降级串在一起,可以直接当模板改。

python
"""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。

完

#LLM#稳定性#降级#成本
回到顶部 ↑