15 · 错误恢复与模型回退:把“出错”当成常态
生产环境里模型调用会以各种方式失败——限流、过载、超时、上下文超长、输出被截断。讲清可恢复与致命错误的分野、指数退避重试、模型降级回退、输出截断的两种恢复、以及重试时那些必须清理的“孤儿消息”,还有一条贯穿始终的红线:别把错误恢复写成死亡螺旋。
15 · 错误恢复与模型回退:把“出错”当成常态
前五个阶段,我们默认“调用模型”这一步总会成功。在生产环境里,这个假设每天都被打脸几十次。 这一篇进入第六阶段,正视一个真相:对一个真实 Agent 来说,出错不是意外,是常态。
一、背景:那行 await 会以十种方式失败
03 篇循环里那行朴素的“调用模型”,在真实世界里是最脆弱的一环。它会失败,而且花样繁多:
- 限流(429):你请求太频繁,被限速了。
- 过载(529):模型服务此刻扛不住,让你稍后再来。
- 超时 / 网络抖动 / 偶发 500:基础设施层面的临时故障。
- 上下文超长(413):历史撑爆了窗口(第 13 篇的场景)。
- 输出被截断(
stop_reason: max_tokens):模型话没说完就撞到了输出上限。
一个让这些错误直接抛出的循环,是个玩具——它会在第一次网络抖动时当场暴毙。一个生产级 Agent,一大半的代码在处理这些失败路径。
二、核心分野:可恢复 vs 致命
处理错误的第一原则,是把它们分成两类,区别对待:
可恢复错误(限流、过载、超时、上下文超长、输出截断)→ 调整状态,重试或继续,用户最好无感。 致命错误(认证失败、请求格式错误、余额耗尽)→ 干净地、明确地告诉用户,别假装还能继续。
而且——错误应该被“产出”成一条合成消息,而不是被“抛出”。 让循环把错误作为一个正常的事件 yield 出去(UI 优雅显示、循环自己决定下一步),而不是让异常冒泡炸穿整个调用栈。这样,无论哪种错误,系统都是“受控地应对”,而不是“失控地崩溃”。
三、四种可恢复错误的恢复姿势
① 临时故障:指数退避重试
限流、过载、偶发 5xx、网络抖动——这些是临时的,过一会儿再试往往就好了。标准解法是指数退避 + 抖动:
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 秒后再来”)要优先于你自己算的退避。
② 模型过载:降级回退
如果重试几次主模型仍然过载,还有一招:换一个模型。降级到一个次选模型(可能便宜些、能力稍弱,但此刻可用),把请求重发:
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,直接把上限调大、原样重试一次。 - 续写:注入一条“你被输出上限截断了,请直接接着刚才说,别道歉、别重述”的消息,让模型从断点续上。
续写那条消息的措辞很讲究:要明确告诉模型“从断的地方接着说,不要重新开头、不要为中断道歉”。否则模型往往会礼貌地“抱歉刚才没说完,让我重新说……”然后把已经生成的部分又来一遍,既浪费又可能前后矛盾。恢复消息的措辞,本身就是 prompt 工程。
④ 上下文超长:压缩再重试
413 就是第 13 篇说的 reactive 压缩场景:捕获它 → 压缩历史 → 重试刚才的请求。“上下文超长”是一种可恢复错误,这正是把压缩和错误恢复串起来的那根线。
四、重试的暗礁:孤儿消息
现在讲一个重试时极其隐蔽、又极其重要的坑。
当你在流式途中失败、要重试时,那些已经流出来一半的“半成品”消息,必须先清理掉,否则它们会毒化重试。 想象:模型流到一半、已经吐了一个带签名的思考块或半个 tool_use,然后连接断了。如果你带着这半条消息去重试,会撞上两类问题:一是历史合法性被破坏(半个 tool_use 没有配对结果);二是那些半成品的思考块签名是无效的,重发会被 API 直接拒绝。
所以重试/回退前,必须把这一轮产生的、还没“落定”的半成品消息作废清理(有的实现会给它们打上“墓碑”标记,从历史和 UI 里一并移除)。重试的前提,是先把上一次失败尝试的残骸打扫干净。
这也是为什么前面反复强调:只在拿到完整的模型响应后,才把它“提交”进历史。半成品在落定之前,永远是可丢弃的。
五、贯穿始终的红线:别写成死亡螺旋
所有恢复机制,都共享一个致命风险:
恢复本身可能触发新一轮同样的失败,形成死亡螺旋。 几个真实的螺旋:
- 压缩后上下文还是太长 → 再压 → 还是长 → …… 无限压缩,烧光预算。
- 对一个 API 错误消息去跑Stop 钩子 → 钩子注入更多内容 → 更长 → 又错 → ……
- 续写截断的输出,结果每次续写又立刻被截断 → 无限续写。
所以每一条恢复路径都必须自带硬闸:最大重试次数、最大恢复次数、“这种错误已经试过一次就别再试”的标记。恢复逻辑里,“什么时候放弃恢复、干净地报错退出”和“怎么恢复”同等重要——甚至更重要。能优雅地失败,是比能恢复更高的要求。
六、小结
把“出错”当常态,是玩具 Agent 和生产 Agent 最扎眼的分界线之一。核心就几条:
- 分清可恢复与致命;错误产出为事件,而非抛出为异常。
- 临时故障 → 指数退避 + 抖动 + 听 Retry-After;过载 → 降级回退。
- 截断 → 续写/提额;超长 → 压缩重试。
- 重试前清理孤儿消息,只提交完整响应。
- 每条恢复路径都要有硬闸,防死亡螺旋——优雅地放弃,也是一种能力。
我们的 Agent 现在能扛住真实世界的各种抖动了。但它每一次调用、每一次重试、每一次压缩,都在花钱。下一篇成本与用量追踪,我们把这些 token 换算成真金白银,并实时盯住。
本篇对应代码:配套仓库给 client 开启指数退避重试、加入模型回退,并把错误优雅地产出为事件而非抛出(src/agent.ts、src/llm.ts)。git checkout article-15。