# Step 20: 错误处理

一句话导读:Phase 2 收官。前面的循环在"一切顺利"时很漂亮,但真实世界里工具会卡死、API 会 429、密钥会失效。这一步的核心不是"加了几个 try/catch",而是一个判断力——哪些错误该重试、哪些该放弃、哪些该甩给 AI 自己解决。


# 一、这一步做了什么(What)

在 step19(权限系统)基础上,加三层保护,收官 Phase 2:

  1. API 层重试(utils/api.ts):callWithTools 内部对 429/5xx 做指数退避重试,最多 3 次;
  2. 工具超时(index.ts):withTimeout 用 Promise.race 给每个工具 30 秒上限,防命令悬而不死;
  3. 错误分类(catError):把错误分成"可恢复/不可恢复",决定重试还是停止;
  4. 错误恢复:工具失败时,把带指引的错误信息回传给 AI,让它自行调整重试。

# 二、面试官视角:为什么要做?(Why)

面试题:错误处理里最难的不是"捕获错误",而是"决定拿它怎么办"。请说说:401、429、工具超时,这三种错误的处理策略为什么必须不同?

考的是错误分类驱动策略这个核心思想。一刀切地"全部重试"或"全部报错退出"都是错的:

错误 本质 可恢复? 正确策略
401 密钥无效 配置错误,重试一万次还是错 否 立即停止,让用户去修 key
429 请求频繁 临时限流,等一会就好 是 退避后自动重试
5xx 服务不可用 服务端临时抖动 是 退避后自动重试
工具超时 单次命令卡住,但别的可能没事 是 把超时告诉 AI,让它换法

分类的意义在于:对可恢复错误做无声的自动重试(用户无感),对不可恢复错误快速失败(别浪费时间),对工具级错误交还给 AI(让它自愈)。如果不分类,401 也傻傻重试 3 次是浪费,429 直接报错退出是脆弱。"聪明的错误处理"= 先分类、再分策略。

而"甩给 AI"是这一步最有 Agent 特色的一招——传统程序遇到工具报错只能崩或退,Agent 却可以把错误当成新信息喂回模型,让模型自己想办法(换参数、换工具、问用户)。


# 三、原理:它是怎么工作的(How)

# 第一层:API 重试(指数退避)

for (let attempt = 1; attempt <= 3; attempt++) {
  try {
    const stream = getClient().messages.stream({ ... });
    // ...正常返回
    return { text, contentBlocks, toolCalls, inT, outT };
  } catch (err: any) {
    lastError = err;
    if (err.status === 429 || (err.status >= 500 && err.status < 600)) {  // ★ 只重试这两类
      if (attempt < 3) {
        const delay = Math.min(1000 * Math.pow(2, attempt - 1), 5000);    // ★ 1s → 2s → 4s,封顶 5s
        await setTimeout(delay);
        continue;
      }
    }
    throw err;   // 非 429/5xx(如 401)或已试满 → 直接抛
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

两个设计要点:

  • 只对 429/5xx 重试:401、400 这类"你自己有问题"的错误重试无意义,立即 throw。
  • 指数退避 1000 * 2^(attempt-1):1s、2s、4s 逐次拉长。若限流是因为大家一起冲,固定间隔重试会造成"重试风暴"再次同时撞上;指数退避 + 封顶让压力逐步释放。

# 第二层:工具超时(Promise.race)

async function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
  const timeout = setTimeout(ms).then(() => { throw new Error('tool timed out (' + ms + 'ms)'); });
  return Promise.race([promise, timeout]);   // ★ 谁先完成用谁
}
// 使用:const tr = await withTimeout(tool.execute(tc.input), 30000);
1
2
3
4
5

Promise.race 的语义是"两个 promise 谁先 settle 就用谁"。让工具执行和一个"30 秒后必抛错"的定时器赛跑:工具 30 秒内完成就正常返回,超了则定时器先抛,把一个卡死的 bash sleep 999 变成一个可捕获的超时错误。这是最省事的"保命线"——一行 race 就避免了单个工具无限挂起拖垮整个会话。

# 第三层:错误分类 + 甩给 AI

function catError(err: any): { msg: string; recoverable: boolean } {
  if (err.status === 401) return { msg: 'API Key 无效', recoverable: false };
  if (err.status === 429) return { msg: '请求频繁,已自动重试', recoverable: true };
  if (err.status >= 500) return { msg: 'API 服务暂时不可用', recoverable: true };
  if ((err.message || '').includes('timeout')) return { msg: '命令执行超时 (30s)', recoverable: true };
  return { msg: err.message || String(err), recoverable: true };
}
1
2
3
4
5
6
7

工具失败后,不是简单回一句报错,而是构造带指引的恢复消息给 AI:

const recoveryMsg = '工具 ' + r.tc.name + ' 执行失败: ' + r.error +
                    '。请根据错误信息调整后重试或告诉用户。';
resultBlocks.push({ type: 'tool_result', tool_use_id: r.tc.id, content: recoveryMsg, is_error: true });
1
2
3

而 API 级错误(重试耗尽后仍失败)则用 recoverable 决定循环是否继续:

} catch (e: any) {
  const cat = catError(e);
  showErr('API error: ' + cat.msg);
  if (!cat.recoverable) break;   // ★ 401 这种,直接跳出循环,不再纠缠
}
1
2
3
4
5

# 数据流:一次工具超时的完整旅程

AI 调 Bash(某个卡死命令)
   ↓ withTimeout(execute, 30000) —— Promise.race
   ↓ 30 秒到,定时器先抛 "tool timed out (30000ms)"
catch → catError → { msg:"命令执行超时(30s)", recoverable:true }
   ↓ 构造 recoveryMsg:"工具 Bash 执行失败: 命令执行超时(30s)。请调整后重试或告诉用户。"
   ↓ 作为 tool_result(is_error) 回传
AI 看到超时 → "这个命令太慢了,我换个更快的方式 / 或者告诉你它超时了"
1
2
3
4
5
6
7

三层各司其职:API 层重试处理网络抖动(用户无感);超时防单个工具挂死;分类 + 甩给 AI 处理业务级失败(让 Agent 自愈)。


# 四、深入追问(面试常见 follow-up)

Q:为什么用指数退避(1s/2s/4s)而不是固定间隔(每次 1s)? A:因为 429 往往是"多个请求同时撞限流"造成的。如果所有失败请求都固定 1 秒后重试,它们会再次同时打过去,形成重试风暴、二次拥塞。指数退避让每次等待越来越长、且不同请求错开,给服务端喘息时间,命中率更高。封顶 5s 是防止退避太久让用户干等。

Q:withTimeout 用 Promise.race 让工具"超时"了,但那个底层的 bash 进程真的被杀掉了吗? A:没有——这是本实现的重要局限。Promise.race 只是让我们不再等那个 promise,但 tool.execute 内部真正的子进程还在后台跑(成了"孤儿")。真实系统必须用 AbortController 把取消信号透传到底层,真正 kill 掉子进程,否则超时命令会持续占用资源。教学版的超时是"逻辑超时"不是"物理终止",面试时点破这点很关键。

Q:错误分类靠 err.status 和字符串 includes('timeout'),这种判断可靠吗? A:err.status 相对可靠(HTTP 状态码是标准的);但 message.includes('timeout') 是脆弱的字符串匹配——不同库的超时错误措辞不同,大小写、语言都可能变,容易漏判。健壮做法是用错误类型/错误码(如自定义 TimeoutError 类、err.code === 'ETIMEDOUT')而非匹配文案。教学版图简单用了 includes,生产应基于类型判断。

Q:把错误信息原样甩给 AI,会不会有风险?比如错误里带了敏感路径、或 AI 陷入"报错-重试-又报错"的死循环? A:两个真实风险。一是信息泄露:错误堆栈可能含绝对路径、内部结构,回传给模型(尤其云端模型)有泄露面,生产会脱敏。二是重试死循环:AI 可能反复用同样的错参数重试。挡它的是 step17 那个 loopCount < 10 轮次上限——无论 AI 怎么折腾,超过上限强制收束。所以"甩给 AI 自愈"必须和"轮次硬上限"配套,否则自愈会变成自嗨。

Q:API 重试放在 callWithTools 内部(对 index.ts 透明),工具超时和分类放在 index.ts。这个分层合理吗? A:合理,体现了关注点分离。API 重试是"传输层"的事——网络抖动是 API 客户端的固有职责,对上层应该透明(index.ts 根本不知道刚才偷偷重试了 3 次)。而超时、权限、错误甩给 AI 是"编排层"的事,属于 QueryEngine 的决策范畴。把重试下沉到 api.ts,让主循环只关心"业务级"错误,代码更干净。真实架构也是这么分的(services/api vs QueryEngine)。


# 五、踩坑 / 设计权衡

  • 超时不杀进程:Promise.race 只是"不等了",底层子进程仍在跑。生产必须 AbortController 真正取消。
  • 字符串匹配错误脆弱:includes('timeout') 依赖文案,换个库就失灵,应基于错误类型。
  • 自愈需配轮次上限:把错误甩给 AI 让它重试,一定要有 loopCount 兜底,否则可能无限重试烧钱。
  • 重试对幂等的隐含假设:API 重试假设"重发请求是安全的"。对读操作成立,对有副作用的操作(真实版的工具级重试)就得小心,否则可能重复执行。

# 六、与真实源码的对照

特性 真实 QueryEngine / services 我们的实现
API 重试 categorizeRetryableAPIError + 退避 429/5xx 指数退避 3 次
超时取消 AbortController 真正 kill 子进程 Promise.race(逻辑超时,不杀进程)
错误分类 按错误类型/码 + 更多类别 catError 按 status + 字符串匹配
错误回传 AI 脱敏后作为 tool_result 原样 + 恢复指引文案
重试上限 可配置 + 熔断 硬编码 3 次 + loopCount<10

# 七、一句话总结

错误处理 = 分类驱动策略的三层防线:API 层对 429/5xx 做指数退避重试(传输层透明自愈)、工具层用 Promise.race 加 30 秒超时(防挂死,但只逻辑超时不杀进程)、编排层用 catError 分"可恢复/不可恢复"并把工具错误甩回给 AI 自愈(配 loopCount 上限兜底)。核心不是捕获错误,而是对每类错误做出"重试/放弃/交还 AI"的正确判断。至此 Phase 2 收官——一个能自主、并行、受控、抗故障调用工具的 Agent 成型了。

# 下一节预告

Phase 2 完成,进入 Phase 3。至今 System Prompt 还是硬编码的一句话。下一步 step21 把它升级成一个可维护、可迭代的独立模块——buildSystemPrompt。