总结
第 11 课讲的是一个很工程的问题:
- 模型调用不稳定时,Agent 要从“失败就退出”,升级成“ 识别信号、有限恢复、明确停止”。
这节课没有新增工具,也没有改变 Agent Loop 的大方向。它只是在模型请求外面包了一层恢复控制。
这张图就是这一课的核心:错误是一条运行信号,Harness 读懂信号后做有限动作。能恢复就回到正常 Agent Loop,恢复不了就明确停下。
用调用下游 API 理解错误恢复 ---> 容错机制
把 HarnessX 调 DeepSeek 想成一个 Node.js 程序调用下游 API。
程序员都熟悉这种场景:本机代码没崩,业务逻辑也没走错,但下游 API 可能返回异常、限流、超时、结果不完整。
调用方不能只写 try/catch 后直接退出,它要根据返回信号决定下一步。
- 响应成功,但业务结果被截断:
- 这对应模型返回
finish_reason=length或max_tokens。 - 类似调用一个导出接口,HTTP 200 回来了,但服务端告诉你“这次只生成到一半”。
- 第一次先把本次响应上限调大,按原请求再调一次。
- 或者如果 仍然不够,就保存已经拿到的文本,再发一条“从中断处继续”的请求。
- 这对应模型返回
- 请求体太大,下游装不下:
- 这对应上下文超限。
- 类似把一个过大的 JSON payload 发给接口,服务端返回 413 或明确说请求太长。
- 这时候继续重试没有意义,要先把请求体变小。
- HarnessX 的做法是把旧消息压成摘要,只保留摘要和最近消息,再重新请求。
- 下游服务限流或过载:
- 这对应 HTTP 429 或 529。
- 类似第三方接口告诉你“请求太频繁”或“服务暂时忙”。
- 调用方应该退避等待,逐步拉开重试间隔。
- 连续过载时,如果配置了备用模型,就切到备用下游继续同一个任务。
这个例子要表达的重点很简单:错误恢复要比“失败后再试一次”更具体。调用方要先读下游返回的信号,再选择对应动作。
这一层放在系统哪里
第 11 课的改动位置很关键。错误恢复不能散落在工具里,也不应该让每个业务分支自己判断。
这张图里,恢复控制层只包住模型请求。
- Agent Loop 仍然负责主流程:
- 准备 system prompt。
- 维护 messages。
- 执行工具。
- 打印最终答案。
- 恢复控制层只负责模型请求:
- 这次失败能不能恢复。
- 需要等多久。
- 要不要压缩上下文。
- 要不要让模型接着写。
- 什么时候停止。
这样做的好处是边界清楚。模型请求可以多试几次,但工具执行仍然只发生在完整模型消息之后。
三类信号和三种动作
这一课真正要记的是这张表。函数细节可以回源码再看。
| DeepSeek 返回的信号 | 工程含义 | HarnessX 的动作 | 停止条件 |
|---|---|---|---|
finish_reason=length 或 max_tokens | 输出空间不够,回答被截断 | 第一次扩大输出上限;仍然截断就保存片段并要求续写 | 续写超过 3 次,或出现半截工具调用 |
| 413 或明确上下文超限 | 输入太长,模型装不下 | 做一次 reactive compact,把早期材料压成摘要 | 压缩后仍然超限 |
| HTTP 429 / 529 | 服务端限流或过载 | 等一会儿再发同一个请求;连续 529 可切备用模型 | 重试超过 10 次 |
这张表比代码细节更重要。它定义了错误恢复的工程合同:
- 哪些错误可以恢复。
- 每种错误怎么恢复。
- 恢复最多做几次。
- 哪些情况必须停。
再换成图,就是三层关系:信号、动作、边界。
三条恢复路径
如果只看恢复决策,第 11 课可以压成这张图。这里不画回到请求模型的回边,把“重新请求”写进动作节点里,避免线条交叉。
输出被截断:先换大纸,再接着写
模型正常返回,也可能只返回了一半。判断点是 finish_reason。
程序发请求时会给一个输出上限。回答撞到上限时,DeepSeek 会把 finish_reason 标成 length 或类似含义。
第一次截断:
保持原任务不变
把输出上限从默认值提高到更大值
重新请求
再次截断:
保存已经拿到的文本
追加“从中断处继续”的提示
最多续写三次这里有一个硬边界:如果截断的是工具调用,不能执行。
原因很现实。工具调用是给本机执行的指令,参数少一个括号、少一段路径,都可能造成错误行为。文本回答可以续写,半截工具调用只能停下。
上下文超限:把旧材料压成摘要
上下文超限表示输入太长。它和输出截断是两类问题。
第 8 课已经做过上下文整理。第 11 课只是补了一层兜底:如果请求发出去后,服务端仍然明确说“装不下”,就做一次 reactive compact。
旧消息太多
-> 写入完整 transcript
-> 生成连续性摘要
-> 保留摘要和最近消息
-> 重新请求 DeepSeek这条路径只处理明确的上下文超限。
普通 400 直接报错。这个判断很重要。400 可能是模型名错了、参数错了、请求体错了。把所有 400 都当成上下文太长,会把真正的工程 bug 藏起来。
HTTP 429 / 529:等待后重发同一个请求
429 和 529 属于临时故障。
- 429:
- 请求太频繁。
- 529:
- 服务端过载。
这类问题一般不需要改任务,也不需要改 messages。更合理的做法是等一会儿,把同一个请求再发一次。
等待策略也不复杂:
先看服务端有没有给 Retry-After
有,就按服务端建议等
没有,就按 0.5 秒、1 秒、2 秒、4 秒逐步等待
每次再加一点随机错峰连续三次遇到 529 时,如果配置了 DEEPSEEK_FALLBACK_MODEL,HarnessX 会切到备用模型继续当前请求。
这里要守住的是同一张任务状态。切模型只是一个可选动作,用户任务、messages、恢复次数都没有丢。
一次 HTTP 请求如何进入恢复流程
这张图要说明两个工程事实:
- 恢复发生在模型请求层。
- 工具调用发生在完整模型消息返回之后。
所以 429 / 529 的重试不会重复执行工具。输出截断也不会把半截工具调用交给本地系统。
怎么跑
沿用第 0 课的 npm link 和 DeepSeek 配置。
先跑正常工具循环:
hx agent "读取 README.md 前 40 行,只回答当前课程列表最后一项的编号和名称"关键输出:
# 第一轮让 DeepSeek 决定读取文件
> read_file path=README.md limit=40
# 第二轮根据工具结果回答
当前课程列表最后一项是 `11`:Error Recovery 错误恢复。再把本次进程的输出上限临时调小,真实触发输出截断:
DEEPSEEK_MAX_TOKENS=64 \
DEEPSEEK_ESCALATED_MAX_TOKENS=512 \
hx agent "用至少 500 字解释当前 HarnessX 的三条恢复路径:finish_reason=length、上下文超限、HTTP 429/529。只输出正文,不调用工具"这条命令仍然调用真实 DeepSeek。两个环境变量只缩小本次请求的输出空间。
本次实际观察到:
# 64 token 不够,保持原 messages,把上限扩大到 512
[RECOVERY] output truncated max_tokens=64 -> 512
# 512 token 仍然不够,保存已有片段,再请求模型接着写
[RECOVERY] continuation=1/3
# 最终正常结束;恢复请求没有占用新的 Agent 轮次
[HOOK] Stop: turns=1, tool_calls=0, blocked=0判断跑通的标准:
- 能看到
output truncated。 - 能看到一次
continuation。 - 最终能得到合并后的完整文本。
Stop仍然显示turns=1。
429 / 529 依赖真实服务状态,本课不造假错误。实际遇到时看恢复日志:
[RECOVERY] HTTP 429 retry=1/10 wait=0.6s model=deepseek-chat
[RECOVERY] HTTP 529 retry=3/10 wait=2.4s model=deepseek-chat
[RECOVERY] fallback model=备用模型名这一课真正要记住
这节课的重点是工程思维。
- 错误要分类。
- 输出不够、输入太长、服务临时繁忙,是三件不同的事。
- 恢复要有限。
- 没有上限的重试会把故障扩大。
- 状态要属于当前任务。
- 当前模型、输出上限、续写次数、连续 529 次数,都只服务这一次
hx agent。
- 当前模型、输出上限、续写次数、连续 529 次数,都只服务这一次
- 工具执行要保守。
- 只有完整模型消息才能进入工具分发。
- 普通错误要快速暴露。
- 参数错、模型名错、请求体错,不应该被压缩或重试掩盖。
一句话收束:
Error Recovery 是一层工程保险:读懂模型故障信号,做有限恢复,成功就回到 Agent Loop,失败就明确停下。源码
源码只看主链路。这里不追每个分支的具体写法,只看错误恢复怎样接入 Agent Loop。
源码大流程图
代码概览
src/deepseek.js 只做一件关键事:把 DeepSeek 的返回信号保留下来。
// HTTP 失败时,不只抛一句字符串。
// 上层要拿 status、body、Retry-After 判断能不能恢复。
if (!response.ok) {
throw new DeepSeekApiError(message, { status, body, retryAfterMs });
}
// HTTP 成功时,也要保留 finish_reason。
// 这决定回答是正常结束,还是因为输出上限被截断。
return { ...choice.message, finish_reason };src/agent-loop.js 把一次模型调用交给恢复控制层。
// 每个 hx agent 任务都有自己的恢复状态。
const recovery = createRecoveryState();
const message = await callMainModelWithRecovery(
messages,
stats,
recovery,
{
tools: TOOL_DEFINITIONS,
tool_choice: "auto",
},
);
// 恢复控制层返回后,才按原来的 Agent Loop 继续。
messages.push(normalizeAssistantMessage(message));代码细分
模型请求的外层逻辑可以缩成这段伪代码:
async function requestModelWithRecovery(messages, recovery) {
while (true) {
try {
// 这里面处理 429 / 529:
// 等待、错峰、必要时切换备用模型。
const message = await callWithTransientRetry(messages, recovery);
if (输出被截断(message)) {
// 第一次扩大输出上限。
// 后续保存文本片段并让模型接着写。
// 半截工具调用直接报错。
处理输出恢复();
continue;
}
// 到这里说明拿到了完整模型消息。
return 合并续写片段(message);
} catch (error) {
if (明确是上下文超限(error)) {
// 沿用第 8 课的 reactive compact,只做一次。
压缩上下文();
continue;
}
// 其他错误快速失败,避免掩盖真实 bug。
throw error;
}
}
}HTTP 请求和返回在这一课只需要看这几个字段:
// 发出去:任务、工具、当前输出上限、当前模型。
POST /chat/completions
{
model,
messages,
tools,
max_tokens
}
// 成功回来:看 message,也看 finish_reason。
choices[0].message
choices[0].finish_reason
// 失败回来:看 HTTP status,也看服务端有没有建议等待多久。
status
body
Retry-After这就是第 11 课的源码主线:传输层保留真实信号,Agent 层按有限状态恢复,恢复成功后再回到原来的工具循环。