为代理工具定义可安全恢复的错误结构
工具调用超时并不代表操作未执行,模糊报错可能让代理重试并产生重复副作用。文章提出类型化错误封装,显式记录已知状态、安全后续动作、禁止自动重复的操作及应保留的证据。
AI 深度解读
Agent 调用 create_invoice 后超时,最危险的问题不是「请求报错了」,而是「发票到底创建没有」。直接重试可能重复扣款,直接结束又可能把已经成功的操作判成失败。作者要解决的,就是工具报错后,系统如何继续行动。
他的主张是:用结构化错误信封(error envelope)记录操作结果和允许的恢复动作,再由运行时代码执行。文章没有提供效果对照数据,但给出了 TypeScript 契约和恢复流程,适合正在给 Agent 接入邮件、支付、部署或数据库写入的开发者参考。
为什么 HTTP 状态码管不了业务恢复
HTTP 状态码描述的是传输层结果,Agent 还需要知道业务走到了哪一步。
400 可能是缺少字段,补齐就能继续,也可能是用户没有权限;500 可能来自临时故障,也可能发生在数据已经写入之后。有些旧接口始终返回 200,真正的失败藏在响应体里。代理返回 502 时,上游服务甚至可能还在执行。
如果工具只返回一句「请求失败,请重试」,模型就得自行决定:原样再发、修改参数,还是假定已经完成。这几种行为的风险完全不同。
作者把职责分成三层:工具适配器观察下游并分类,运行时决定允许的状态转换,模型接收简短说明,并在授权范围内提出下一步。
模型可以帮助修正输入,写操作是否允许重试应由确定性代码决定。
错误信封就是这几层之间的契约。它不追求描述所有异常细节,而是交代清楚:发生了什么、结果是否确定、接下来允许做什么,以及凭什么追查。
最重要的字段不是 retryable,而是 commit_state
retryable: true 信息太少。它没有说明上一次是否已经执行、何时再试、还剩几次,以及原请求能否安全重发。
作者更强调 commit_state:
not_committed:能够确认下游操作没有执行。committed:已经核实目标状态。unknown:无法证明成功,也无法证明未执行。
对于写操作,unknown 必须先进入核实流程。
下面的类型把稳定的机器字段与展示给模型、用户的说明分开,解决「只能解析报错文案来决定下一步」的问题:
type CommitState = "not_committed" | "committed" | "unknown";
type RetryDirective = "never" | "after_delay" | "after_input_fix";
type NextAction =
| "correct_input"
| "wait_and_retry"
| "verify_operation"
| "request_approval"
| "escalate";
type ToolError = {
ok: false;
code: string; // stable, documented identifier
category: "validation" | "authorization" | "conflict" | "dependency" | "internal";
commit_state: CommitState;
retry: {
directive: RetryDirective;
not_before?: string; // ISO time, not a vague duration
max_attempts_remaining: number;
};
next_action: NextAction;
operation_ref: string; // durable idempotency or operation key
safe_details: Record<string, string | number | boolean>;
agent_message: string; // short, redacted, factual
user_message: string; // safe status text
};
重点看 commit_state、next_action 和 operation_ref:它们分别回答结果是否确定、进入哪个分支、追查哪次操作。not_before 使用 ISO 格式的绝对时间,避免让模型理解「稍后」并负责计时。
成功响应也要有证据。原文的 ToolSuccess<T> 包含 ok: true、operation_ref、commit_state: "committed"、result,以及记录 checked_at 和可选 evidence_ref 的 verification。不能因为没有抛异常,就推断业务成功。
四类失败,对应四种恢复路径
第一类是可修正的输入错误。原文以 INVALID_RECIPIENT_ADDRESS 为例,指定错误字段 recipient_email,标记 not_committed,重试指令为 after_input_fix,剩余次数为 1。
运行时应拒绝原样重试。只有相关字段发生变化并通过校验,才允许沿用 run_81/step_4 这个操作引用提交修正请求。
第二类是临时依赖故障。前提是能够确认没有写入到达目标,例如连接前域名解析失败,或者只读接口遭遇限流。延迟、抖动和次数统计应交给调度器,不能藏在模型循环里。
第三类是冲突或权限阻塞。记录版本已变化,可以返回 VERSION_CONFLICT;缺少审批,可以返回 MISSING_APPROVAL。这时不能任由模型随意修改参数碰运气,应转入审批、人工处理,或者取得新事实后重新规划。
第四类是写入结果未知,也是全文最关键的情况。原文用 OUTCOME_UNKNOWN 表示它,将 commit_state 设为 unknown,retry.directive 设为 never,剩余次数设为 0,下一步指定为 verify_operation。示例通过 get_invoice_by_operation_ref 查询结果。
这里的 never 禁止的是未经核实就再次写入。查询确认成功,原步骤转为成功;经过下游文档规定的一致性窗口仍查不到,运行时才可能使用同一个幂等键重新尝试。查询本身不可用,就升级处理。
操作引用必须在第一次写入之前生成并持久化,否则进程重启后可能连查什么都不知道。
作者用下面的代码展示运行时如何接管恢复分支:
async function executeWrite(step: Step) {
const result = await tool.call({ ...step.input, operation_ref: step.operationRef });
if (result.ok) return markSucceeded(step, result.verification);
switch (result.next_action) {
case "correct_input":
return markNeedsInput(step, result.safe_details);
case "wait_and_retry":
return schedule(step, result.retry.not_before!);
case "verify_operation":
return scheduleVerification(step, result.operation_ref);
case "request_approval":
return requestApproval(step, result.code);
default:
return escalate(step, result.code);
}
}
重点是 verify_operation 分支:它调度核实,没有再次调用写接口。这段代码只是流程示意,跨重启的次数限制、核实前落盘、相同错误指纹防循环和终态展示,还需要运行时实现。
我的判断
我认可作者把错误处理提升为工作流契约。对于有副作用的工具,超时后直接重试确实缺少依据;只加一句「不要重复执行」的提示词,也没有建立可检查的恢复规则。
但错误信封只是表达能力,不能凭空提供核实能力。下游是否支持幂等键、能否按操作引用查询、多久才能可靠查到结果,决定了方案能走多远。原文没有给出各类服务的具体实现,也没有提供重复写入减少多少的对照数据,应把它视为工程设计建议。
相比统一重试中间件,这套方案能区分补参数、等待、核实和审批,代价是适配器与持久化状态更复杂。作者也允许只读工具使用较小的契约,没必要让一次普通查询承担全部写操作恢复流程。
还有一个容易漏掉的边界:上游错误文本是不可信输入。safe_details 应使用白名单,只放允许暴露的字段、版本等信息。令牌、连接串、原始响应和可能包含客户数据的堆栈不应直接交给模型;错误文本里的指令应归一化为 UPSTREAM_ERROR 之类的中性代码。
能拿来做什么
- 给 Node 或 MCP 写工具增加兼容包装,先落地
ok、code、commit_state、next_action、operation_ref和简短脱敏说明,从邮件、部署、工单、数据库写入开始。 - 在开放自动恢复前,确认高影响写入支持结果查询,并持久化操作引用、幂等键和重试次数。查询暂不可用时,让流程进入待处理状态。
- 在前端区分「等待补充输入」「等待重试」「正在核实结果」与终态,只有确认完成后才展示成功。
- 增加一次写入发出后工作进程崩溃的故障测试,验证重启后会加载原操作引用并先核实。同时统计核实解决的未知结果、阻止的重复写入、恢复等待时间和最终升级次数,别只看重试次数是否下降。