本文是「从零理解 Claude Code:20 个 Agent Harness 机制」系列的第 11 篇。
对应源码:s11_error_recovery
设想 Agent 正在帮你处理一个测试失败。
Agent 读过报错日志后,顺着日志找到相关配置文件,然后模型就开始生成修改方案,回答却停在半个代码块里。你感到有点疑惑,点击了重新生成,随后可能得到完整结果,当然更多时候却是仍然卡住。另一种情况更干脆,接口直接返回 529 overloaded,这一轮连半句话都没有。
如果 Agent 对所有失败都做同一件事,比如无脑重试,后面很容易出问题。
回答被截断时,程序需要决定那半段内容是否应该保存。比如上下文太长时,原请求重发多少次都会超限。又或者服务过载时,连续点击重试请求也照样发不出去。
s11_error_recovery 做的事情,是让 Agent Loop 根据失败位置选择恢复动作。它不能保证模型永远不中断,但能让一部分可恢复错误不至于直接结束整轮任务。
失败发生的位置,决定了程序能做什么
这一章处理三种情况:
| 现象 | 程序拿到的信息 | 恢复动作 |
|---|---|---|
| 模型输出到达长度上限 | 响应中的停止原因 | 提高输出上限,必要时续写 |
| 请求上下文过长 | 调用接口时抛出异常 | 紧急缩短消息后重试 |
| 接口限流或服务过载 | 调用接口时抛出 429、529 类错误 | 按递增间隔等待后重试 |
输出截断和接口异常的区别很大。
模型输出被截断时,程序已经拥有一份响应,只是内容还没结束。上下文超限、429 和 529 发生在请求模型的过程中,程序得不到正常响应,只能进入异常处理分支。
把三条恢复路线放在一张图里看,会更容易分清它们改动的是输出空间、消息历史,还是请求节奏。

回答写到一半时,第一次先扩大输出空间
本章先默认允许模型输出 8000 token。
模型因为输出长度不够而停止时,程序第一次不会把这段回答写入消息历史。它先把上限提高到 64000,再用原来的任务和消息记录重新请求模型。
ini
if response.stop_reason == "max_tokens":
if not state.has_escalated:
max_tokens = ESCALATED_MAX_TOKENS
state.has_escalated = True
continue
这段逻辑出现在回复写入消息历史之前。
这样安排和一次普通重试还不太一样。此时程序保留的是同一份输入,只给模型更多输出空间。模型有机会完整回答当前任务,不会因为读到半截旧回复,又从头补一段背景说明。
如果 64000 token 仍然不够,程序才会保存当前输出,再加入一条续写要求:
perl
Output token limit hit. Resume directly --- no apology, no recap. Pick up mid-thought.
这条提示只要求继续写,不允许模型重新解释已经说过的内容。续写最多执行三次,超过次数后循环结束。
这里的限制很有必要。模型连续多次输出不完,往往说明任务粒度太大,或者回答方式不合适。继续追加续写请求,最后很可能得到一份长度惊人、阅读体验却没有改善的回答。
上下文太长时,最近五条消息只是应急方案
上下文超限时,模型接口会直接拒绝这次请求。
教学代码中的紧急压缩没有生成摘要,只保留消息历史最后五条,这里再补一条说明:
arduino
def reactive_compact(messages: list) -> list:
tail = messages[-5:]
return [{
"role": "user",
"content": "[Reactive compact] Earlier conversation trimmed. "
"Continue from where you left off.",
}, *tail]
压缩后的消息会替换原来的消息历史,循环随后重新调用模型。
这条路径和 s08 中的常规上下文压缩不在同一个层级。s08 会尽量保留任务目标、工具结果和已有结论,而 s11 的紧急压缩只负责让请求长度尽快降下来。
假设 Agent 前面读取了十几个文件,最近五条消息恰好只剩工具结果和模型的一句中间判断。更早的用户要求、工具调用来源和项目约束可能已经不在消息历史里。模型可以继续生成内容,却未必还知道自己为什么走到这里。
因此,本章只允许紧急压缩一次。压缩后还是超限,程序直接返回错误。继续删消息当然能让上下文变短,但任务也可能被删得只剩一个标题。
还有一个实现细节:这里直接取最后五条消息,没有像 s08 那样确认工具调用和工具结果是否保持配对。它适合说明恢复方向,不适合直接搬进需要严格维护消息格式的系统。
429 和 529 的处理重点是控制重试节奏
429 表示限流,529 表示服务暂时过载。任务内容没有变化,消息历史不需要裁剪,程序需要做的是隔一段时间再尝试。
本章使用指数退避。等待时间会逐步增加:
| 第几次失败 | 基础等待时间 |
|---|---|
| 第 1 次 | 0.5 秒 |
| 第 2 次 | 1 秒 |
| 第 3 次 | 2 秒 |
| 第 4 次 | 4 秒 |
| 后续 | 最多 32 秒 |
每次等待还会加入少量随机时间。
如果多个 Agent 在同一时刻收到 529,又严格在 0.5 秒后同时重试,服务刚恢复就会再次收到一批请求。随机抖动让重试时间错开一些,压力不会在某个时间点重新堆起来。
代码把 429 和 529 放在 with_retry() 里处理,其他异常会重新抛给外层循环。这种分工让恢复逻辑比较清楚:临时服务问题交给重试函数,长度问题交给消息压缩逻辑,无法判断的错误记录后结束任务。
教学代码里,备用模型实际上还没有接上
连续三次 529 后,如果配置了备用模型,代码会更新当前模型名称:
ini
state.current_model = FALLBACK_MODEL
单看这行代码,备用模型似乎已经切换成功。
问题出在调用接口时,请求函数把当前模型作为 lambda 的默认参数保存了下来:
ini
lambda mt=max_tokens, mdl=state.current_model:
client.messages.create(model=mdl, ...)
后续重试会继续调用同一个 lambda。即使恢复状态里的模型名称已经更新,mdl 仍然保持 lambda 创建时的旧值。
因此,教学代码当前会打印切换备用模型的日志,后续重试却还是使用原模型。这是 s11 里一个值得单独记下来的实现缺口。
如果希望切换立即生效,请求函数需要在每次调用时读取恢复状态:
ini
def request():
return client.messages.create(
model=state.current_model,
system=system,
messages=messages,
tools=TOOLS,
max_tokens=max_tokens,
)
恢复逻辑里有一个常见误区:状态字段已经修改,不代表下一次操作一定会读取这个字段。中间如果缓存了参数、闭包保存了旧值,状态变化就只停留在日志里。
这份教学代码还省略了什么
| 位置 | 当前做法 | 实际系统还需要补上的部分 |
|---|---|---|
| 紧急压缩 | 保留最近五条消息 | 保护工具调用与工具结果的关联 |
| 服务端建议等待时间 | 延迟函数支持该参数 | 当前调用处没有读取接口响应中的等待信息 |
| 重试次数 | 循环总共请求 10 次 | 明确区分总尝试次数和额外重试次数 |
| 备用模型 | 更新当前模型状态 | 保证下一次请求读取最新模型 |
| 输出续写 | 固定最多三次 | 根据新产生的内容判断是否还值得继续 |
这些简化并不影响理解本章的主线。s11 想展示的是,错误恢复需要调整不同的对象:有时调整输出空间,有时调整消息历史,有时只需要调整请求节奏。
本章小结
这一章给 Agent Loop 补上了三种恢复动作。
模型输出不够时,程序先扩大输出空间,再考虑续写。如果消息过长的话,程序压缩消息后重试一次,但是如果是接口暂时不可用,程序会按递增等待时间重试。每条路径都有自己的次数上限,避免循环在失败状态里一直打转。
这套教学实现也留下了几个需要继续完善的地方,尤其是备用模型切换和紧急压缩后的消息完整性。它们说明恢复逻辑本身同样需要测试,不能只看状态有没有更新。
下一章会从一次任务的结束,进入任务本身的管理。待办列表能记录当前几步,但跨会话恢复、依赖关系和长期执行,还需要更完整的任务系统。