RSS

第 11 章 / 共 21 章

错误恢复:先分类,再决定重试什么

约 7 分钟 更新于

上一章留下的问题:到目前为止,我们的代码假设每次 API 调用都成功。真实情况不是这样:模型服务会返回 429(限流)、529(过载);输出会因为撞上 max_tokens 被截断;上下文会超长。任何一个都能让你这个跑了二十分钟的任务瞬间归零。

新手的第一反应是套一个 try: ... except: retry。这在两种情况下是错的:上下文超长时你重试一百次也还是超长;输出被截断时重试等于重来一遍,前面生成的内容白扔。

恢复的前提是分类。

11.1 三类故障,三条路径

故障识别方式恢复动作尝试次数
输出被截断stop_reason == "max_tokens"先把 max_tokens 从 8K 提到 64K 重试;如果还截断,把已生成内容追加进去,再发一条”接着写”的续写提示提额 1 次 + 续写至多 3 次
上下文超长抛出 prompt_too_long触发兜底压缩,重试1 次
瞬时故障429 / 529指数退避 + 抖动后重试;连续 3 次 529 就切到备用模型至多 10 次
第11章:故障类型与恢复动作
图 11.1:故障分类与对应恢复动作的对照。要看的是右下那一格——每种恢复动作都有自己的次数上限,因为它们的边际收益完全不同:退避重试可以试十次,压缩重试只值得试一次。

11.2 代码

先用一个状态对象把”已经试过什么”记下来——这是防死循环的关键:

from dataclasses import dataclass
import random, time

@dataclass
class RecoveryState:
    has_escalated: bool = False              # 是否已提过 max_tokens
    continuations: int = 0                   # 已续写次数
    has_compacted: bool = False              # 是否已做过兜底压缩
    consecutive_529: int = 0
    model: str = MODEL

def backoff_delay(attempt: int) -> float:
    base = min(500 * (2 ** attempt), 32000) / 1000        # 上限 32 秒
    return base + random.uniform(0, base * 0.25)          # 加最多 25% 抖动

那个抖动不是可有可无的装饰。没有抖动时,同时被限流的一百个客户端会在完全相同的时刻一起重试,把服务再打垮一次——这就是”惊群效应”。加一点随机偏移,重试就被摊开了。

主循环里的处理骨架:

def call_model(messages, state: RecoveryState, max_tokens: int = 8000):
    for attempt in range(10):
        try:
            return client.messages.create(
                model=state.model, system=get_system_prompt(ctx),
                messages=messages, tools=TOOLS, max_tokens=max_tokens,
            )
        except anthropic.RateLimitError:                       # 429
            time.sleep(backoff_delay(attempt))
        except anthropic.APIStatusError as e:
            if e.status_code == 529:                           # 过载
                state.consecutive_529 += 1
                if state.consecutive_529 >= 3:
                    state.model = FALLBACK_MODEL               # 切备用模型
                time.sleep(backoff_delay(attempt))
            elif e.status_code >= 500:
                time.sleep(backoff_delay(attempt))
            else:
                raise                                          # 4xx 不重试
    raise RuntimeError("重试次数耗尽")

截断和超长的处理放在循环里:

        if response.stop_reason == "max_tokens":
            if not state.has_escalated:
                state.has_escalated = True
                max_tokens = 64000                 # 消息不动,直接放大重试
                continue
            if state.continuations < 3:
                state.continuations += 1
                messages.append({"role": "assistant", "content": response.content})
                messages.append({"role": "user", "content":
                    "直接从中断处继续,不要道歉,不要复述前面已经写过的内容。"})
                continue

那句续写提示的措辞是刻意的。如果只写”请继续”,模型往往会先道个歉、再把前面写过的复述一遍,然后才接着写——你花钱买了一段重复内容。明确禁止道歉和复述,能省下可观的 token。

prompt_too_long 的处理:

        except anthropic.BadRequestError as e:
            if "prompt_too_long" in str(e) and not state.has_compacted:
                state.has_compacted = True
                messages[:] = reactive_compact(messages)
                continue
            raise           # 压过一次还超长,说明压不动了,只能退出

11.3 走一遍:一次不太顺的调用

时刻发生了什么恢复动作状态变化
T0调用返回 529等 0.5s 后重试consecutive_529 = 1
T1又是 529等 1.1s 后重试consecutive_529 = 2
T2还是 529切到备用模型,等 2.3s 重试consecutive_529 = 3,model 已切换
T3成功,但 stop_reason == max_tokens提额到 64K,消息不变重试has_escalated = True
T4又截断追加已生成内容 + 续写提示continuations = 1
T5正常结束—任务继续

如果没有 RecoveryState,第 T4 步会再次尝试”提额”(因为它不知道自己提过了),而 max_tokens 已经是上限了,于是无限循环。记账不是可选项。

11.4 工程现实

  • 哪些错误不该重试:4xx(除了 429)通常是你的请求写错了,重试一百次还是错。参数错误、认证失败、模型名写错——这些要立刻报出来。
  • 续写的收益递减:连着续写三次之后,每次新增的内容会越来越少。一个实用的停止信号是”这次续写新增的 token 数低于某个下限(比如 500)“,说明模型已经没什么可写的了。
  • 备用模型不是免费的:切换模型会让提示词缓存失效(缓存是按模型隔离的),所以这是应急手段,不是常规策略。
  • 别忘了 refusal:模型也可能出于安全原因拒绝。这不是故障,重试通常也没用——应该把它当成一个需要报告给用户的结果。

11.5 常见坑

11.6 制造故障并观察恢复路径

11.7 说全三类故障的识别、动作与上限