AI Agent 单轮耗时受最慢工具制约,文章给出 Deadline 类型与 budgetFor 函数设计,实现单次运行级别的超时预算在子调用间分配,避免均匀超时导致的阻塞或提前终止。
一次 agent turn 的速度取决于它最慢的那个工具。只要上游服务有一个要四十秒才超时,整个运行就会卡在那里——用户盯着加载动画,请求握着连接不放,turn 预算不断消耗。
Node 在一个特定的地方很容易写错:await 没有截止时间,fetch 不带 signal 就会一直等到 socket 关闭为止。
截止时间不是超时
大多数实现会给每次工具调用设置一个超时时间。这有必要,但不够——五个工具各带十秒超时,加起来就是五十秒的 turn。
真正需要的是一个运行级别的截止时间,各调用都继承它:
export type Deadline = { at: number };
export const remaining = (d: Deadline) => Math.max(0, d.at - Date.now());
export function budgetFor(d: Deadline, toolMax: number) {
return Math.min(toolMax, remaining(d));
}
这样,一个本来能拿十秒的工具如果在只剩三秒的情况下就只拿三秒,整个运行不会超出它对调用方的整体承诺。
const deadline: Deadline = { at: Date.now() + 60_000 };
按工具设置预算,因为工具各不相同
const TIMEOUTS: Record<string, number> = {
search_docs: 5_000,
get_order: 3_000,
generate_report: 30_000,
send_email: 8_000,
};
const DEFAULT_TIMEOUT = 10_000;
统一超时时间要么对报告生成器太短,要么对主键查询太长。两种情况都不可接受,而这张表不花什么成本。
真正能取消的取消
这里人们常写错:用 Promise.race 让 promise 和计时器赛跑,你等不到了,但工作还在继续。
// 错误 — fetch 继续跑,socket 保持打开
const out = await Promise.race([
runTool(block),
new Promise((_, rej) => setTimeout(() => rej(new Timeout()), ms)),
]);
signal 必须传到 I/O 层:
export async function executeWithDeadline(
block: ToolUseBlock,
ctx: Ctx,
deadline: Deadline,
): Promise<ToolResultBlockParam> {
const ms = budgetFor(deadline, TIMEOUTS[block.name] ?? DEFAULT_TIMEOUT);
if (ms <= 0) {
return errorResult(block.id,
"Skipped: the overall time budget for this task is exhausted.");
}
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), ms);
try {
const out = await runTool(block, { ...ctx, signal: ac.signal });
return okResult(block.id, out);
} catch (err) {
if (ac.signal.aborted) {
metrics.increment(`tool.timeout.${block.name}`);
return errorResult(block.id, timeoutMessage(block.name, ms));
}
throw err;
} finally {
clearTimeout(timer);
}
}
三个在 Node 环境中很关键的细节。
finally 中 clearTimeout——没清理的计时器会让事件循环保持活跃,工作完成后进程却不退出,诊断起来非常痛苦。
检查 ac.signal.aborted 而不是错误类型——被中止的 fetch 会抛出 AbortError,但下游库可能把它包装成别的东西。signal 才是可靠的见证者。
把 signal 传递给工具内部每一次 fetch 和数据库调用。一个停在工具边界上的 signal 什么都没取消。
async run({ query }, ctx) {
const r = await fetch(url, { signal: ctx.signal });
return r.json();
}

应该告诉模型什么
这是决定运行能否恢复的部分。超时不是通用故障——它携带了模型可以处理的信息。
function timeoutMessage(tool: string, ms: number): string {
return [
`${tool} did not respond within ${Math.round(ms / 1000)}s.`,
"This is a timeout, not an empty result — the data may exist.",
"Do not retry with the same arguments.",
"Either narrow the request, use a different tool, or continue without it",
"and tell the user which part is missing.",
].join(" ");
}
每一行都有其作用。区分超时和空结果,防止模型得出记录不存在的结论。禁止用相同参数重试,防止由此引发的死循环。提供三个明确选项,把死胡同变成下一步。
对比大多数实现发出的:
return errorResult(block.id, "Error: ETIMEDOUT");
模型不知道该重试、放弃还是道歉,所以通常会重试。
并行调用共享截止时间
当一个 turn 发出多个工具调用时,它们并发运行,都继承同一个运行截止时间:
const outs = await Promise.all(
blocks.map((b) => executeWithDeadline(b, ctx, deadline)),
);
这里 Promise.all 是安全的,因为 executeWithDeadline 不会抛出 reject——所有路径都返回结果块。这正是让部分失败能够工作的特性:三个工具成功,一个超时,模型收到全部四个结果。
降级而不是失败
对于只读工具,超时不一定意味着什么都没有。
async run({ query }, ctx) {
try {
return await liveSearch(query, ctx.signal);
} catch (err) {
if (!ctx.signal.aborted) throw err;
const cached = await cache.get(query);
if (cached) {
return { ...cached, stale: true, note: "Cached result; live search timed out." };
}
throw err;
}
}
stale 标记和 note 都是给模型看的。把过期的答案当最新的给出去比超时要差;但标明了过期的过期答案通常比什么都没有好,模型会适当加上说明。
关注 p99,不要看均值
metrics.histogram("tool.duration_ms", ms, { tool: block.name });
metrics.increment(`tool.outcome.${block.name}.${outcome}`);
超时预算应该从健康日的 p99 设定,而不是从某个某人喜欢的整数取整。一个 p99 是 4.2 秒的工具不需要三秒超时,这个只有看直方图才能发现。
值得报警的比例是每个工具超时次数占调用次数的百分比。一个每二十次调用就超时一次的工具正在悄无声息地让你的 agent 降级——运行仍然完成,只是少了四分之一的信息。

每个工具都从运行级截止时间获得预算。每个 abort signal 都传到实际 I/O。每个计时器都在 finally 中清理。每个超时都返回一个结果块,绝不抛出异常。每个超时消息都说明是超时,禁止同参数重试,并提供下一步。
把这五点做对,一次上游服务挂掉只损失一个降级的答案,而不是一个挂起的请求和烧光的预算。
AI That Acts 覆盖了 agent 的执行层——截止时间、取消、部分失败,以及让模型能够从中恢复的错误结果。
