Error Recovery
健壮的 Harness 对失败分类,决定哪种重试值得做。
错误分类 + 退避重试 + 反馈给模型。
问题
真实世界里一切都会失败:API 限流、网络抖动、工具超时、命令退出码非零、JSON 解析失败。
s01 的循环对失败毫无防御:fetch 抛异常 → 进程崩溃;工具输出乱码 → 模型得到垃圾输入;模型卡在死循环 → 烧 token。
失败是常态,不是异常。 健壮的 Harness 需要:先分类失败(可重试?可恢复?致命?),再决定动作(重试?反馈给模型让它改?终止?)。
解决方案
错误分类表 + 动作决策:
| 错误类型 | 例子 | 动作 |
|---|---|---|
rate_limit |
429 | 退避重试(等 2s、4s、8s…) |
transient |
网络超时、5xx | 有限重试 |
tool_failure |
命令退出非零 | 不重试,把错误文本喂回模型,让它自己修正 |
fatal |
认证失败 401 | 终止并报告 |
function classify(err) {
if (err.status === 429) return "rate_limit";
if (err.status === 401) return "fatal";
if (err.status >= 500 || err.type === "network") return "transient";
return "tool_failure";
}
async function withRetry(fn, { max = 3 } = {}) {
let delay = 1000;
for (let attempt = 1; ; attempt++) {
try { return await fn(); }
catch (err) {
const kind = classify(err);
if (kind === "fatal" || attempt >= max) throw err;
if (kind === "tool_failure") throw err; // 工具失败不自动重试
await sleep(delay * Math.pow(2, attempt - 1)); // 指数退避
}
}
}
工作原理
第 1 步:模型请求包上退避重试(rate_limit / transient 值得自动重试——服务器可能刚好恢复)。
const data = await withRetry(() =>
fetch(API, { method: "POST", /* ... */ }).then((r) => { if (!r.ok) throw Object.assign(new Error(r.statusText), { status: r.status }); return r.json(); })
);
第 2 步:工具执行失败不自动重试——喂回模型。模型是唯一能"换个做法"的实体:
try {
output = await execute(name, args);
} catch (e) {
output = `TOOL_ERROR: ${e.message}`; // 作为 tool 结果回传,循环继续
}
第 3 步:步骤上限——模型陷入循环时,harness 必须能喊停。这属于"防失控",与重试正交但同属错误恢复:
let steps = 0;
while (true) {
if (++steps > MAX_STEPS) return "(达到最大步数,停止)";
// ...
}
重试哲学:自动重试只用于"环境暂时不好"(限流、网络);"模型做错了"永远不自动重试,而是把错误反馈给它——模型从错误中学习下一步。把这两类混为一谈,就会在错误的命令上重试 3 次浪费 token。
试一下
运行 s11,制造各种失败:
- 给一个会失败的命令(
cat 不存在的文件)——观察错误如何喂回模型,模型如何修正 - 把 API 地址改成错误端口——观察重试与退避
- 给一个永远执行不完的循环任务——观察步数上限
观察重点:模型收到 TOOL_ERROR 后是修正还是重蹈覆辙?rate_limit 的退避节奏是否合理?
以下内容基于 packages/guard 与 packages/llm 的核查。DSH 的错误恢复分散在几个正交的系统里:
一、守卫插件(guard)
packages/guard 是"循环卫生"插件族:
timeout-policy:作为部署策略给每个工具调用武装截止时间(注册tools/execute监听者)。工具超时不是工具自己的事——是策略在管道上施加的预算。repeat-tool-reminder:监测重复工具调用,以additionalContexts附加提醒("你刚跑过这个命令")——不是硬拦截,是顾问式的。这对应你的"防失控",但用提醒而非硬性中止。
二、LLM 请求重试
packages/llm 提供按路由的重试策略(dsh-llm-retry):不同模型/路由可以配置不同重试次数与退避——429/超时/5xx 的分类和退避是产品策略,不是写死在循环里。
三、你的分类表 vs DSH
| 你的最小实现 | DSH 的真实实现 |
|---|---|
classify() 在循环里 |
分类与策略是配置/插件:超时在 timeout-policy,重试在 llm-retry,提醒在 repeat-tool-reminder |
| 步数上限写死 | turn 上限、token 预算等多条退出路径(s01 已见) |
| 失败反馈给模型靠手写 | 工具结果管道保证错误文本结构化回传 |
一句话:错误恢复不是循环的一个分支,而是一系列环绕循环的策略插件——这正是 s04 钩子思想的延续:横切行为住在循环外面。