42 已经拿到了,为什么还会失败?
还是检查 main.py 与 expected.txt。程序获准运行,退出码为 0,实际输出 42,工具结果也已经保存。Harness 正把这些证据交给模型,请它报告核对结论,服务却返回了 429。
这时直接从头再来,看起来省事:读取、运行、报告再走一遍。可如果程序会写文件、发通知,第二遍就可能多做一次事情。需要恢复的是报告的模型请求,已经执行过的工具应保留原来的证据。
Error Recovery(错误恢复)首先要回答失败发生在哪里。第十课整理了模型请求中的说明、工具定义与消息,现在把恢复范围放在这一份请求周围:完整响应才交回原循环,失败候选先在这里处理。
同样是“失败”,为什么下一步不一样?
先看一个区别:程序返回除零错误,和模型服务暂时过载,都可能让界面出现红字,但前者已经是工具结果,后者连这次模型响应都没取得。工具结果应带回模型供它判断;模型请求失败则由宿主决定是否重发。
请求这一层还要继续分。等待可能有助于临时过载,却不会修好一个写错的参数。输出被截断时,已经收到一部分内容,也不能据此认定新工具调用完整可执行。
| 观察到的情况 | 本课采取的动作 | 为什么 |
|---|---|---|
| 临时 HTTP 故障、连接或超时 | 在额度内等待并重发 | 同一请求可能在稍后成功 |
| stop_reason=max_tokens | 原消息不变,输出预算扩大一次 | 内容还不完整,不把半截工具调用交出去 |
| 认证、权限、参数或请求大小错误 | 带原因停下 | 需要修改配置或请求,反复原样发送无助于修正 |
| 工具本身执行失败 | 按原循环保存工具结果 | 模型请求恢复层不负责重跑工具 |
429 也不保证稍后就好,账户限额等原因可能需要人处理。因此“可重试”只是有界尝试的判断,不是必然恢复的承诺。未知响应同样停下,不让一个宽泛的 except 把代码缺陷也变成重试风暴。
把重试圈缩到刚好包住请求
下面沿“程序已运行、准备报告”走一遍。两种情形共用同一份已存证据,只改变模型请求失败后的恢复动作。
用户要求检查main.py与expected.txt。本次获准运行得到42,调用ID与工具结果已经成对保存在历史里。现在只差模型依据证据报告结论。
已存:exit_code=0,输出42- 程序已经执行,结果也已经保存:用户要求检查main.py与expected.txt。本次获准运行得到42,调用ID与工具结果已经成对保存在历史里。现在只差模型依据证据报告结论。
- 带着同一份证据请求报告:Harness沿用原来的system与tools,把当前问题和完整工具交接交给模型。已有程序不需要为这次报告再执行一次。
- 服务暂时拒绝了这次请求:本例服务返回429与等待秒数。工具没有报错,程序也没有重跑;失败发生在这一次模型请求。
- 先判断这一次恢复是否还有意义:有效服务器秒数优先。下一次发送前检查本轮尝试数、父会话请求余量与累计计划等待,过长就停下。
- 恢复只回到模型请求:等候以后重新发送相同请求。已存工具结果保持,执行器不会因为报告重试而再运行一次main.py。
- 完整响应回来以后,原循环才继续:本例取得完整报告,原循环保存回答后结束。若没有恢复成功,则带原因停下并保留已有工具证据;请求失败和工具执行失败分别处理。
图里的回路返回模型请求。程序工具不在这条回路内;无论恢复成功还是带原因结束,已取得的调用与结果都继续留在原历史中。
可以用一段独立短代码观察这个位置差异。这里的工具结果与响应都是固定教学数据,没有真实程序或网络调用:
history = [{"tool_use_id": "run_1", "content": "exit_code=0,输出42"}]
tool_runs = 1
attempts = 0
responses = ["529", "complete"]
for response in responses:
attempts += 1
if response == "complete":
break
print("模型请求尝试:", attempts)
print("程序执行次数:", tool_runs)
print("仍保存的结果:", history[0]["content"])点击行号设置断点,再点“调试”;也可以直接运行。
输出分别是 2 次请求尝试、1 次程序执行和原来的 42。短代码只展示恢复范围;实践入口会用真实文件计数验证程序恰好运行一次,并让固定故障发生在已经保存结果以后。
等一会儿,具体要等多久?
如果服务给出有效的 Retry-After 秒数,优先遵守它。没有有效秒数时,本例从 0.25 秒开始,按尝试序号增长,并加入 0 到 25% 的随机抖动。抖动让一批请求不必在同一时刻整齐地再撞回服务。
但“服务器让等 9 秒”和“本任务还愿意等 3 秒”是两件事。本课选择停下,报告等待额度不足;不能把 9 秒偷偷改成 3 秒,然后声称已经遵守服务器要求。等待计算函数只给计划秒数,是否等、还有多少额度,由请求恢复函数判断。
还有一层很容易漏:SDK 自身也可能自动重试。若宿主再套四次,实际出站请求就可能超过自己看到的计数。本实践关闭 SDK 自动重试,由一层恢复入口统一扣除请求额度。默认演示只记录等待,不真的让读者等;显式在线入口才会等待。
内容只差一点,要不要接着写?
max_tokens 是收到的响应结束原因,不是 HTTP 失败。本课第一次遇到它,丢弃本次截断候选,用同一份 messages、同一模型,把 max_tokens 从 1024 改成 2048 再发一次。这两个数是教学选择,实际设置必须适配所用模型。
这里“丢弃”针对尚未提交的候选响应。原历史中早已存在的工具结果没有删除。即使截断候选包含一个看似完整的 run_main,也不先运行它;等取得完整、可接收的响应,才交回原循环判断与分发。
第二次仍然截断,本例停下。自动续写需要额外处理文本拼接、工具内容完整性和跨响应状态;简单加一句“继续”并不能保证这些问题已经解决。上下文太大也类似:第八课的整理保留完整工具交接,不能因为一次 400 就随手只留下最后几条消息。参数错误与上下文限制可能共享状态码,应先定位原因,再准备更小且合法的请求。
什么条件下,必须停止恢复?
本例给每次逻辑调用最多 4 次请求尝试,父会话共享 20 次尝试与 3 秒计划等待额度。发送前扣请求数,等待前检查并预留秒数;末次失败不再等待。输出预算只扩大一次,认证与未知响应直接停下。
这些限制分别约束不同的东西。原循环的 max_calls 限逻辑调用;恢复层的次数限实际发送尝试;等待额度只限本层计划等待,不是整个任务墙钟时长,也不包含网络等待。子任务和摘要仍有自己的调用边界,本课恢复只接在父请求上。
恢复失败以后,原循环以 model_error 返回,已有消息和工具证据仍在。这样读者能分清“程序没跑成”和“程序已经跑了,但模型报告没拿到”。实践篇把分类、等待计算和请求恢复拆成三个真实函数,可沿断点检查候选何时才被交回。
资料核对于2026-10-19:课程 Error Recovery提供按故障选择恢复策略的学习路线;Claude API 错误文档支持状态码、类型化异常与 SDK 重试边界;停止原因文档用于区分响应截断和请求异常。本册独立实现有界子集,不把社区不可核验的内部源码分析当作官方事实。