2742 字
约 9 分钟
14
S22-X12 · 模型调用失败的处理:超时、限流与降级

把模型调用当成普通 HTTP 接口来写,通常能跑通,但线上会以一种特殊方式坏掉:不是抛异常,而是慢、贵、且静默。客户端重试三次、网关重试两次、SDK 内部再重试一次——同一次请求被放大成六次计费,而前面的流式内容早已推给了用户。

这篇按"差异 → 超时 → 重试 → 限流 → 分类 → 降级 → 流式中断 → 可观测"的顺序,把失败处理拆成可配置的策略。


1. 与普通 HTTP 调用的四个差异

差异 普通接口 模型调用 工程含义
延迟分布 集中在均值附近 长尾显著,p99 常是 p50 的数倍 均值没用,必须看分位数
失败相关性 与负载弱相关 失败率随并发与输出长度上升 重试在高负载时最容易失败
计费方式 按请求数 按输入+输出 token 重试与降级都直接改变成本
完成语义 成功/失败二元 存在"半成功":流已输出后中断 无法用单一状态码表达

最后一条最关键:模型调用有三种终态——完整成功、完全失败、部分成功。只处理前两种的代码,在第三种情况下既收不到错误、也拿不到完整内容。

典型量级:首 token 延迟与总延迟是两个指标,长输出的总延迟取决于输出长度,按所选模型版本与服务方配额核对,不要用常数写死。


2. 超时怎么设

llm:
  client:
    connect-timeout: 3s          # 建连,包含 DNS 与 TLS
    read-timeout: 30s            # 非流式:整段响应
    stream-idle-timeout: 12s     # 流式:两个 chunk 之间的最大间隔
    total-deadline: 60s          # 含重试的总上限
超时项 语义 设错的后果
connect-timeout 建连握手 太长会在网络故障时占满连接池
read-timeout(非流式) 等完整响应 太短会杀掉长输出请求
stream-idle-timeout 相邻 chunk 间隔 设成总时长会在长回复中途断连
total-deadline 含所有重试 不设则重试叠加出超长等待

两个原则:连接超时与读超时分开(网络问题与生成慢是两件事);应用侧超时必须短于上游网关,否则网关先返回 504,重试又会触发一次真实计费,而客户端看到的仍是一次失败。

流式场景下 read-timeout 的含义要改:不是"等整段响应",而是"等下一个 chunk 的间隔";用非流式阈值卡长流会在中途砍断。


3. 重试的边界

条件 是否可重试 说明
幂等的读式调用(无副作用) 是 绝大多数生成调用属于此类
已产生副作用的调用(写库、下单) 否 重试会重复执行,需业务幂等键
4xx 参数错误 否 重试结果一样
429 限流 是,需退避 不读 Retry-After 会持续被限
5xx / 连接重置 是,需抖动 无抖动会造成同步重试风暴
超时 谨慎 原请求可能仍在计费
def retry_delay(attempt, base=0.5, cap=8.0):
    # 指数退避 + 全抖动:避免同批客户端同时重试
    return random.uniform(0, min(cap, base * 2 ** attempt))

def call_with_retry(fn, max_attempts=3, deadline=None):
    for i in range(max_attempts):
        try:
            return fn()
        except Retryable as e:
            if deadline and now() > deadline:
                raise Budget("deadline exceeded")
            sleep(e.retry_after or retry_delay(i))
    raise Exhausted(max_attempts)

三件事必须同时有:退避(别立刻重打)、抖动(别让所有客户端同一时刻重试)、总时长上限(否则重试把一次等待叠加成数倍)。反过来,重试会放大限流:上游已因过载返回 429,重试等于继续加压。限流场景的重试次数应少于故障场景,且优先遵循服务端给出的等待时间。


4. 限流:分清 429 与别的错误

def handle_429(resp, limiter):
    ra = resp.headers.get("Retry-After")          # 秒数或 HTTP 日期
    wait = parse_retry_after(ra) if ra else limiter.next_slot()
    limiter.penalize(wait)                        # 反馈到本地令牌桶
    return wait
信号 含义 动作
429 + Retry-After 明确限流,给了等待时间 按值等待,不抖动
429 无头 限流但未给时间 用本地桶估计,退避加倍
403 / 401 鉴权或配额问题 不重试,告警
400 参数类 请求本身不合法 不重试,检查构造逻辑
503 上游过载或不可用 短退避重试,随后降级

客户端自我限速用令牌桶最实用:按观测到的配额设速率,收到 429 时降速并逐步恢复。

class TokenBucket:
    def __init__(self, rate, burst):
        self.rate, self.burst, self.tokens, self.t = rate, burst, burst, now()
    def take(self):
        self.tokens = min(self.burst, self.tokens + (now() - self.t) * self.rate)
        self.t = now()
        need = (1 - self.tokens) / self.rate
        sleep(max(0, need)); self.tokens = max(0, self.tokens - 1)

配额通常按每分钟请求数与每分钟 token 数双维度限制,只按 QPS 限速遇到长输出仍会被打到。


5. 失败分类与对应动作

类型 典型信号 动作 不可做的事
瞬时故障 连接重置、5xx 退避重试(限次) 无限重试
过载限流 429 等待 + 降速 立刻重打
上下文超限 400 含 length/token 关键词 缩短上下文后重发 原样重试
内容拦截 策略或过滤拒绝 按产品策略处理 反复改写绕过
超时 读超时、空闲超时 有限重试 → 降级 直接重试超大请求
服务不可用 持续 5xx、DNS 失败 切备用模型 → 降级 死等
// 分类后再决策,别把所有异常塞进同一个 catch
Action decide(LlmError e) {
    if (e instanceof RateLimited)   return Action.WAIT_THEN_RETRY;
    if (e instanceof ContextTooLong) return Action.SHRINK_AND_RETRY;
    if (e instanceof Timeout)       return Action.DEGRADE_OR_RETRY_ONCE;
    if (e instanceof AuthError)     return Action.FAIL_FAST_ALERT;
    return Action.DEGRADE;
}

分类的价值在于:只有"可重试"这类才消耗重试预算。把超时和 400 混着手动重试,既烧钱又延迟用户。


6. 降级的层次

层级 做法 质量影响 何时用
L1 换小模型 同任务交给更快/更便宜的模型 推理深度下降 上游主模型持续不可用
L2 减检索片段 从 top-k 降到更少条 答案可能不全 上下文超限或延迟敏感
L3 模板化回答 用规则拼装的兜底答案 无生成能力 需要确定性回复的场景
L4 明确告知 返回"暂时不可用,稍后重试" 用户体验下降 全部手段失效
def answer(q, ctx):
    try:
        return llm.complete(prompt(q, ctx), tier="primary")
    except (Timeout, Unavailable):
        try:
            return llm.complete(prompt(q, ctx[:2]), tier="small")   # L1 + L2
        except Exception:
            log.degraded(q, reason="all_tiers_failed")
            return TEMPLATE_FALLBACK.format(q=q)                    # L3

降级必须记录事件,否则线上看起来"一切正常",实际大量请求走了兜底。降级率比成功率更能反映真实体验。


7. 流式中断:留还是丢

场景 建议 理由
已输出大半,尾部断开 保留 + 标注不完整 用户已读到主要内容,丢弃等于白花成本
刚输出开头就断 丢弃并重试 残留半句比空白更糟
重连重发整段 会重复计费 输入 token 再次全额计费
def stream_with_resume(prompt, session, max_resume=1):
    emitted = session.emitted_text            # 已推送给用户的内容
    try:
        for chunk in client.stream(prompt):
            session.push(chunk)
    except StreamInterrupted:
        if len(emitted) < MIN_KEEP:           # 开头就断:丢弃重来
            session.reset()
        # 续写:把已输出内容作为前缀告知模型,避免重复生成
        stream_with_resume(continuation_prompt(prompt, emitted), session, max_resume - 1)

要点:"半成功"要落在数据模型里,例如给消息加 complete: false 标记。前端据此提示不完整,后端据此决定是否计入评测——把半截回答当成功样本会持续污染指标。


8. 可观测:五个必须有的指标

指标 口径 用途
成功率 完整成功 / 总请求 基础健康度
p95 / p99 延迟 分模型、分流式与非流式 长尾才是用户感知
重试率 发生重试的请求占比 偏高说明上游或配额有问题
降级率 走了 L1–L3 的请求占比 最容易被忽略的真实体验指标
token 消耗 输入/输出分开统计 成本归因,识别重试浪费
metrics.observe("llm_call", {
    "model": model_id, "stream": True, "outcome": outcome,   # ok|partial|error
    "attempts": attempts, "degraded": tier != "primary",
    "in_tokens": usage.prompt_tokens, "out_tokens": usage.completion_tokens,
    "latency_ms": elapsed,
})

标签里必须带 outcome(三分态),否则"部分成功"会被算成成功,成功率长期虚高。


9. 排错

现象 成因 处理
成本突然翻倍 多层重试叠加 统计 attempts 分布,收敛到单层重试
429 越重试越多 未读 Retry-After 按服务端时间等待,降低令牌桶速率
网关报 504 但模型侧有计费 应用超时长于网关 应用超时收紧到网关以内
线上看着健康但用户抱怨 降级率高未监控 增加降级率指标与告警
流式断开后内容重复 重连未做前缀续写 传已输出内容作为续写前缀

10. 边界

问题 本层能解决吗 应去哪一层
输出质量差但不报错 否 提示词与评测
单次请求必然超上下文 部分 切分与预算
上游长期不可用 否 多供应商与容量规划

11. 自测

  1. 模型调用的三种终态分别是什么,缺了哪一种会让指标虚高?
  2. 流式场景下读超时的语义与非流式有何不同?
  3. 为什么应用侧超时要短于上游网关?
  4. 重试的三个必备要素是什么,缺了抖动会怎样?
  5. 降级四个层次里,哪一层的质量损失最不可控,为什么?
  6. 流式半成功后重连,怎样避免重复计费与重复内容?
S22-X12 · 模型调用失败的处理:超时、限流与降级
https://www.clxhxhhr.top/posts/5874/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。