# 15 · 错误恢复与模型回退：把“出错”当成常态

> 生产环境里模型调用会以各种方式失败——限流、过载、超时、上下文超长、输出被截断。讲清可恢复与致命错误的分野、指数退避重试、模型降级回退、输出截断的两种恢复、以及重试时那些必须清理的“孤儿消息”，还有一条贯穿始终的红线：别把错误恢复写成死亡螺旋。

- 作者：David（道雾轩）
- 专栏：从 LLM 到 Coding Agent（https://daiw.org/manual/llm-to-agent.md）
- 最后更新：2026-09-13
- 原文：https://daiw.org/manual/llm-to-agent/error-recovery
- 转载与引用：请注明出处并附原文链接（https://daiw.org/about/copyright）

# 15 · 错误恢复与模型回退：把“出错”当成常态

前五个阶段，我们默认“调用模型”这一步总会成功。**在生产环境里，这个假设每天都被打脸几十次。** 这一篇进入第六阶段，正视一个真相：对一个真实 Agent 来说，**出错不是意外，是常态**。

## 一、背景：那行 `await` 会以十种方式失败

[03 篇](https://daiw.org/manual/llm-to-agent/agent-loop)循环里那行朴素的“调用模型”，在真实世界里是最脆弱的一环。它会失败，而且花样繁多：

- **限流**(429)：你请求太频繁，被限速了。
- **过载**(529)：模型服务此刻扛不住，让你稍后再来。
- **超时 / 网络抖动 / 偶发 500**：基础设施层面的临时故障。
- **上下文超长**(413)：历史撑爆了窗口([第 13 篇](https://daiw.org/manual/llm-to-agent/auto-compaction)的场景)。
- **输出被截断**(`stop_reason: max_tokens`)：模型话没说完就撞到了输出上限。

一个让这些错误**直接抛出**的循环，是个玩具——它会在第一次网络抖动时当场暴毙。一个生产级 Agent，**一大半的代码在处理这些失败路径**。

## 二、核心分野：可恢复 vs 致命

处理错误的第一原则，是把它们分成两类，区别对待：

<Callout type="info">
  **可恢复错误**(限流、过载、超时、上下文超长、输出截断)→ **调整状态，重试或继续**，用户最好无感。
  **致命错误**(认证失败、请求格式错误、余额耗尽)→ **干净地、明确地告诉用户**，别假装还能继续。

  而且——**错误应该被“产出”成一条合成消息，而不是被“抛出”。** 让循环把错误作为一个正常的事件 `yield` 出去(UI 优雅显示、循环自己决定下一步)，而不是让异常冒泡炸穿整个调用栈。这样，无论哪种错误，系统都是“受控地应对”，而不是“失控地崩溃”。
</Callout>

## 三、四种可恢复错误的恢复姿势

### ① 临时故障：指数退避重试

限流、过载、偶发 5xx、网络抖动——这些是**临时**的，过一会儿再试往往就好了。标准解法是**指数退避 + 抖动**：

```ts title="指数退避重试"
async function withRetry<T>(fn: () => Promise<T>, max = 4): Promise<T> {
  for (let attempt = 0; ; attempt++) {
    try {
      return await fn()
    } catch (err) {
      if (!isTransient(err) || attempt >= max) throw err
      // 退避:1s, 2s, 4s, 8s …… 再加随机抖动,避免所有客户端同时重试造成"惊群"
      const delay = 2 ** attempt * 1000 + Math.random() * 1000
      // 若响应带了 Retry-After 头,优先听它的
      await sleep(retryAfter(err) ?? delay)
    }
  }
}
```

两个细节别漏：**抖动**(jitter)防止大量客户端在同一时刻齐刷刷重试(惊群)；**`Retry-After`** 头(服务器明确告诉你“X 秒后再来”)要优先于你自己算的退避。

### ② 模型过载：降级回退

如果重试几次主模型仍然过载，还有一招：**换一个模型**。降级到一个次选模型(可能便宜些、能力稍弱，但此刻可用)，把请求重发：

```ts title="模型回退:主模型不行,换备用的"
try {
  return await callModel(primaryModel, req)
} catch (err) {
  if (isOverloaded(err) && fallbackModel) {
    yield systemNote(`主模型繁忙,已切换到 ${fallbackModel}`)
    return await callModel(fallbackModel, req)   // 换个模型重试
  }
  throw err
}
```

“能用一个稍弱的模型把活干完”，几乎总比“卡在这儿什么都干不了”强。

### ③ 输出被截断：续写或提额

`stop_reason: max_tokens` 意味着模型话说到一半被硬切了。两条恢复路：

- **提额重试**：如果这次用的是一个较小的 `max_tokens`，直接把上限调大、原样重试一次。
- **续写**：注入一条“你被输出上限截断了，请**直接接着刚才**说，别道歉、别重述”的消息，让模型从断点续上。

<Callout type="info">
  续写那条消息的措辞很讲究：要明确告诉模型“**从断的地方接着说**，不要重新开头、不要为中断道歉”。否则模型往往会礼貌地“抱歉刚才没说完，让我重新说……”然后把已经生成的部分又来一遍，既浪费又可能前后矛盾。**恢复消息的措辞，本身就是 prompt 工程。**
</Callout>

### ④ 上下文超长：压缩再重试

`413` 就是[第 13 篇](https://daiw.org/manual/llm-to-agent/auto-compaction)说的 reactive 压缩场景：捕获它 → 压缩历史 → 重试刚才的请求。**“上下文超长”是一种可恢复错误**，这正是把压缩和错误恢复串起来的那根线。

## 四、重试的暗礁：孤儿消息

现在讲一个重试时极其隐蔽、又极其重要的坑。

<Callout type="warn">
  **当你在流式途中失败、要重试时，那些已经流出来一半的“半成品”消息，必须先清理掉，否则它们会毒化重试。** 想象：模型流到一半、已经吐了一个带签名的思考块或半个 `tool_use`，然后连接断了。如果你带着这半条消息去重试，会撞上两类问题：一是[历史合法性](https://daiw.org/manual/llm-to-agent/interrupt)被破坏(半个 `tool_use` 没有配对结果)；二是那些**半成品的思考块签名是无效的**，重发会被 API 直接拒绝。

  所以重试/回退前，必须把这一轮产生的、还没“落定”的半成品消息**作废清理**(有的实现会给它们打上“墓碑”标记，从历史和 UI 里一并移除)。**重试的前提，是先把上一次失败尝试的残骸打扫干净。**
</Callout>

这也是为什么[前面](https://daiw.org/manual/llm-to-agent/agent-loop)反复强调：只在拿到**完整**的模型响应后，才把它“提交”进历史。半成品在落定之前，永远是可丢弃的。

## 五、贯穿始终的红线：别写成死亡螺旋

所有恢复机制，都共享一个致命风险：

<Callout type="warn">
  **恢复本身可能触发新一轮同样的失败，形成死亡螺旋。** 几个真实的螺旋：
  - 压缩后上下文**还是**太长 → 再压 → 还是长 → …… 无限压缩，烧光预算。
  - 对一个 API 错误消息去跑[Stop 钩子](https://daiw.org/manual/llm-to-agent/hooks) → 钩子注入更多内容 → 更长 → 又错 → ……
  - 续写截断的输出，结果每次续写又立刻被截断 → 无限续写。

  所以**每一条恢复路径都必须自带硬闸**：最大重试次数、最大恢复次数、“这种错误已经试过一次就别再试”的标记。恢复逻辑里，“什么时候**放弃**恢复、干净地报错退出”和“怎么恢复”同等重要——甚至更重要。**能优雅地失败，是比能恢复更高的要求。**
</Callout>

## 六、小结

把“出错”当常态，是玩具 Agent 和生产 Agent 最扎眼的分界线之一。核心就几条：

1. **分清可恢复与致命**；错误**产出**为事件，而非抛出为异常。
2. 临时故障 → **指数退避 + 抖动 + 听 Retry-After**；过载 → **降级回退**。
3. 截断 → **续写/提额**；超长 → **压缩重试**。
4. 重试前**清理孤儿消息**，只提交完整响应。
5. 每条恢复路径**都要有硬闸**，防死亡螺旋——**优雅地放弃，也是一种能力**。

我们的 Agent 现在能扛住真实世界的各种抖动了。但它每一次调用、每一次重试、每一次压缩，都在**花钱**。下一篇[成本与用量追踪](https://daiw.org/manual/llm-to-agent/cost-tracking)，我们把这些 token 换算成真金白银，并实时盯住。

---

**本篇对应代码**：配套仓库给 client 开启指数退避重试、加入模型回退，并把错误优雅地产出为事件而非抛出(`src/agent.ts`、`src/llm.ts`)。`git checkout article-15`。
