给 Tool Call 加 Exponential Backoff Retry
很多人写 retry,第一版都是这样:
for (let i = 0; i < 3; i++) {
try {
return await callTool();
} catch (error) {
await sleep(1000);
}
}
看上去挺负责。
坏也坏在这里。
在 Agent Runtime 里,retry 不是'失败了再来一次'。它要先回答三个问题:
这个错误是不是瞬时错误?
这个工具能不能安全重放?
这次重试会不会绕过权限、预算或用户拒绝?
这三个问题没回答清楚,exponential backoff 只是更有节奏地制造事故。
先区分模型请求和工具请求
Provider 调用和 Tool Call 不是一类东西。
模型请求通常没有外部业务副作用。网络抖动、429、5xx,短重试是合理的。OpenAI 的 rate limit 指南和 cookbook 里也会建议遇到限流时使用指数退避。Google Cloud、AWS 也都长期建议在分布式系统里使用 exponential backoff,AWS 还专门强调 jitter,避免大量客户端在同一时间一起重试。
Tool Call 不一样。
读文件可以重试。搜索代码可以重试。查订单状态可以重试。
但创建订单、发邮件、扣款、提交 PR、删除文件、执行数据库迁移,不能因为超时就自动再跑一次。
所以不要写全局 retry,然后所有工具套进去。
RetryContext 要先出现
一个 Agent Runtime 里的 retry,应该从上下文开始:
type RetryContext = {
operation: "model_generate" | "local_tool_call" | "mcp_tool_call";
toolName?: string;
errorCode?: string;
attempt: number;
maxAttempts: number;
idempotency: "idempotent" | "conditional" | "non_idempotent";
replayPolicy: "auto" | "ask" | "never";
userApproved: boolean;
remainingRunTokens: number;
remainingWallClockMs: number;
};
然后再判断:
function shouldRetry(ctx: RetryContext) {
if (ctx.attempt >= ctx.maxAttempts) return false;
if (ctx.replayPolicy === "never") return false;
if (ctx.idempotency === "non_idempotent") return false;
if (!ctx.userApproved && ctx.operation !== "model_generate") return false;
if (ctx.remainingRunTokens <= 0) return false;
if (ctx.remainingWallClockMs <= 0) return false;
return ["RATE_LIMIT", "TRANSIENT_NETWORK", "SERVER_ERROR", "TIMEOUT"].includes(
ctx.errorCode ?? ""
);
}
注意,TIMEOUT 是否可重试,要看工具。
同样是 timeout,read_file 超时和 create_order 超时不是一回事。前者大概率可以重试,后者可能已经产生副作用。
前面讲过重复下单,就是这个坑。
Backoff 要加 jitter
普通指数退避:
const delay = baseMs * 2 ** attempt;
问题是,如果很多 Agent 同时遇到限流,它们会在同一时间重试。服务端刚缓过来,又被一波整齐的重试打回去。
所以要加 jitter。
function backoffWithJitter(input: {
baseMs: number;
capMs: number;
attempt: number;
}) {
const exponential = Math.min(input.capMs, input.baseMs * 2 ** input.attempt);
return Math.floor(Math.random() * exponential);
}
这是 full jitter 的简化版。AWS 的文章里专门讲过 backoff 和 jitter 的组合,核心思路就是不要让所有客户端同时醒来。
如果上游返回 Retry-After,优先尊重它:
function computeRetryDelay(error: {
retryAfterMs?: number;
}, attempt: number) {
if (error.retryAfterMs) return error.retryAfterMs;
return backoffWithJitter({
baseMs: 500,
capMs: 10_000,
attempt,
});
}
Agent Runtime 不应该像没礼貌的爬虫一样撞接口。
Retry 要记录进 Tool Ledger
只在最后记录'成功/失败'不够。
每一次 retry 都应该进入 ledger 和 trace:
type ToolAttempt = {
attempt: number;
startedAt: string;
endedAt?: string;
errorCode?: string;
delayBeforeNextMs?: number;
};
type ToolLedgerEntry = {
callId: string;
stepId: string;
toolName: string;
inputHash: string;
status: "started" | "succeeded" | "failed" | "uncertain";
idempotency: "idempotent" | "conditional" | "non_idempotent";
replayPolicy: "auto" | "ask" | "never";
attempts: ToolAttempt[];
};
为什么要这么细?
因为事故复盘时,你会问:
这次工具到底调用了几次?
每次间隔多久?
最后一次失败是什么?
有没有尊重 Retry-After?
有没有在用户拒绝后继续重试?
有没有在预算耗尽后继续重试?
没有 attempts,你只能猜。
给模型看的 observation 要短
retry 细节应该进 trace,不应该全塞给模型。
模型需要知道的是下一步怎么做:
{
"ok": false,
"code": "RATE_LIMIT",
"content": "search_issues 连续 3 次遇到限流,Runtime 已按 Retry-After 等待后仍失败。本轮不再自动重试。请改用本地缓存结果,或请用户稍后再试。",
"metadata": {
"retryable": false,
"attempts": 3
}
}
不要把每次 HTTP 头、堆栈、长日志都塞进上下文。
模型上下文要短,trace 证据要完整。
权限拒绝不能被 retry 绕过
这个坑很隐蔽。
用户拒绝了工具调用,runtime 不应该换个 provider、换个工具名、换个参数继续尝试同样风险动作。
比如:
用户拒绝 run_command("npm install")
↓
Agent 换成 shell_readonly("npm install")
↓
Runtime 如果只看工具名,可能放过
Retry 策略必须看权限决策。
PERMISSION_DENIED 默认不可重试。
如果模型想找低风险替代方案,可以继续规划,但不能自动重放原动作。比如用户拒绝安装依赖,Agent 可以读 package.json 或解释缺少依赖;不能绕过用户意思偷偷安装。
这也是为什么重试策略要在 Runtime,而不是在工具函数里各写各的。
条件幂等怎么处理
很多工具不是简单幂等或非幂等,而是条件幂等。
写文件就是典型例子。
如果你基于相同 base hash 应用同一个 patch,重复执行可以被设计成安全的。可如果文件已经被用户手动改过,再重放 patch 就可能覆盖用户改动。
所以工具定义可以这样写:
type ToolDefinition = {
name: string;
idempotency: "idempotent" | "conditional" | "non_idempotent";
replayPolicy: "auto" | "ask" | "never";
retryPolicy: {
maxAttempts: number;
baseDelayMs: number;
maxDelayMs: number;
};
};
条件幂等必须有校验函数:
type ReplayCheck = {
ok: boolean;
reason: string;
};
async function canReplayWriteFile(input: {
path: string;
expectedBaseHash: string;
}) : Promise<ReplayCheck> {
const currentHash = await hashFile(input.path);
if (currentHash !== input.expectedBaseHash) {
return { ok: false, reason: "FILE_CHANGED_AFTER_PLAN" };
}
return { ok: true, reason: "BASE_HASH_MATCH" };
}
没有条件校验,就别叫 conditional idempotent。
AbortSignal 要传到底
重试循环必须支持取消。
用户取消后,不能睡眠结束再重试。
async function sleep(ms: number, signal: AbortSignal) {
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(resolve, ms);
signal.addEventListener(
"abort",
() => {
clearTimeout(timer);
reject(new Error("aborted"));
},
{ once: true }
);
});
}
真实实现还要处理 listener 清理、abort reason、子进程退出、HTTP stream 中断。
这不是洁癖。用户界面已经显示'取消',机器上却还有 retry 在睡觉,过几秒继续发请求,这是很糟糕的体验。
一个可用的 retry 包装器
综合起来,可以写成这样:
async function retryCall<T>(input: {
run: () => Promise<T>;
context: Omit<RetryContext, "attempt" | "errorCode">;
classifyError(error: unknown): string;
signal: AbortSignal;
}) {
let lastError: unknown;
for (let attempt = 0; attempt < input.context.maxAttempts; attempt++) {
try {
return await input.run();
} catch (error) {
lastError = error;
const errorCode = input.classifyError(error);
const ctx: RetryContext = {
...input.context,
attempt: attempt + 1,
errorCode,
};
if (!shouldRetry(ctx)) break;
const delayMs = backoffWithJitter({
baseMs: 500,
capMs: 10_000,
attempt,
});
await sleep(delayMs, input.signal);
}
}
throw lastError;
}
这段代码还不是完整产品实现。它没写 ledger、trace、Retry-After、条件幂等校验。
但它至少把判断放在了正确的位置:先看语义,再决定是否 retry。
Eval 要测什么
Retry 不能只靠线上出事后复盘。
Eval 里至少写这些 case:
模型请求 RATE_LIMIT,重试 2 次后成功。
read_file TIMEOUT,可自动重试。
create_order TIMEOUT,标记 SIDE_EFFECT_UNCERTAIN,不自动重试。
PERMISSION_DENIED,不重试。
用户取消 retry sleep,立即停止。
Retry-After 存在时,delay 使用服务端建议。
非幂等工具 maxAttempts 即使配置为 3,也只执行 1 次。
这些 case 最好用 fake model 和 mock tool 跑。不要每次都打真实外部服务。
runtime 逻辑要可复现。
最后
Exponential backoff 是好东西。
但在 Agent Runtime 里,它必须被三条线约束:
幂等性
权限
预算
没有幂等性,retry 可能重复副作用。
没有权限约束,retry 可能绕过用户拒绝。
没有预算,retry 会把一次小任务拖成昂贵长跑。
所以,不要急着写 sleep(2 ** attempt)。
先问:这一步能不能重放?
参考资料
- AWS Architecture Blog: Exponential Backoff And Jitter
- Google Cloud Docs: Retry strategy
- OpenAI Cookbook: How to handle rate limits
- OpenAI API Docs: Rate limits
- Stripe Docs: Idempotent requests


