智能体的一次请求通常会经过模型、工具、重试和流式输出多个阶段,任何一层单独设置超时都可能让总耗时失控。本文从请求级 deadline 出发,用 Node.js 实现可取消的调用链、剩余预算计算和超时原因分类。
为什么局部 timeout 不够
假设接口整体希望在 10 秒内返回,模型调用设置 8 秒,工具调用设置 5 秒,重试再设置 8 秒。每一层都看起来合理,但它们的时间是串行消耗的,实际最坏耗时可能远超 10 秒。
问题不在于是否设置了 timeout,而在于 timeout 是否属于同一个预算。请求进入系统时应该确定一个绝对截止时间:
deadline = 当前时间 + 请求允许的总时长
后续每一步不再拥有一个独立的完整时长,而是使用:
remaining = deadline - 当前时间
模型、工具和重试都必须服从这个 remaining。如果剩余时间已经不足以启动下一步,就应立即失败或走降级路径,而不是继续排队等待。
这也解释了为什么建议在调用链中传递 deadline,而不是只传递一个初始 timeout。初始 timeout 只能说明请求刚进入系统时的预算,deadline 才能表达跨阶段共享的截止约束。
用 AbortController 传播预算
Node.js 内置的 AbortController 可以取消 fetch,也可以作为自定义异步任务的取消信号。下面的代码使用本地 HTTP 服务模拟模型、工具和流式响应,因此不依赖外部 API,保存为 budget-demo.mjs 后即可用 Node.js 18 或更高版本运行。
调用包装器做三件事:计算剩余时间;把父级取消信号传给当前操作;在当前操作超过剩余预算或局部上限时主动取消。局部上限只能用于防止某个阶段占满预算,不能增加请求总预算。
import { createServer } from 'node:http';
import { setTimeout as sleep } from 'node:timers/promises';
class BudgetError extends Error {
constructor(stage, reason) {
super(`${stage}: ${reason}`);
this.name = 'BudgetError';
this.stage = stage;
this.reason = reason;
}
}
function remaining(ctx) {
return Math.max(0, ctx.deadline - Date.now());
}
async function withBudget(ctx, stage, operation, localCap = Infinity) {
const left = remaining(ctx);
if (left <= 0) throw new BudgetError(stage, 'deadline_exceeded');
const controller = new AbortController();
const onParentAbort = () => controller.abort(ctx.signal.reason);
ctx.signal.addEventListener('abort', onParentAbort, { once: true });
const allowed = Math.min(left, localCap);
const timer = setTimeout(() => controller.abort('operation_timeout'), allowed);
try {
return await operation(controller.signal);
} catch (error) {
if (controller.signal.aborted) {
const reason = ctx.signal.aborted
? 'deadline_exceeded'
: 'operation_timeout';
throw new BudgetError(stage, reason);
}
throw error;
} finally {
clearTimeout(timer);
ctx.signal.removeEventListener('abort', onParentAbort);
}
}
async function postJson(url, body, signal) {
const response = await fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
signal
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
}
async function callModel(ctx, baseUrl) {
let attempts = 0;
while (attempts < 2) {
attempts += 1;
try {
return await withBudget(ctx, 'model', signal =>
postJson(`${baseUrl}/model`, { prompt: '查天气并总结' }, signal),
700
);
} catch (error) {
if (!(error instanceof BudgetError) || remaining(ctx) <= 0) throw error;
await sleep(Math.min(80, remaining(ctx)), undefined, { signal: ctx.signal });
}
}
throw new BudgetError('model', 'retry_exhausted');
}
async function callTool(ctx, baseUrl, query) {
return withBudget(ctx, 'tool', signal =>
postJson(`${baseUrl}/tool`, { query }, signal),
600
);
}
async function readStream(ctx, baseUrl) {
return withBudget(ctx, 'stream', async signal => {
const response = await fetch(`${baseUrl}/stream`, { signal });
if (!response.ok || !response.body) throw new Error(`HTTP ${response.status}`);
const reader = response.body.getReader();
const chunks = [];
while (true) {
const item = await reader.read();
if (item.done) break;
chunks.push(Buffer.from(item.value).toString('utf8'));
}
return chunks.join('');
}, 500);
}
async function runAgent(baseUrl, totalMs = 1500) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort('request_deadline'), totalMs);
const ctx = { deadline: Date.now() + totalMs, signal: controller.signal };
try {
const model = await callModel(ctx, baseUrl);
const tool = await callTool(ctx, baseUrl, model.query);
const answer = await readStream(ctx, baseUrl);
return { model, tool, answer, remainingMs: remaining(ctx) };
} finally {
clearTimeout(timer);
}
}
const server = createServer(async (req, res) => {
if (req.url === '/model') {
await sleep(180);
res.setHeader('content-type', 'application/json');
res.end(JSON.stringify({ query: 'weather:shanghai' }));
return;
}
if (req.url === '/tool') {
await sleep(220);
res.setHeader('content-type', 'application/json');
res.end(JSON.stringify({ temperature: 24, unit: 'C' }));
return;
}
if (req.url === '/stream') {
res.setHeader('content-type', 'text/plain; charset=utf-8');
for (const text of ['上海今天', '晴,', '气温 24C。']) {
await sleep(60);
res.write(text);
}
res.end();
return;
}
res.statusCode = 404;
res.end();
});
server.listen(0, async () => {
const { port } = server.address();
try {
const result = await runAgent(`http://127.0.0.1:${port}`);
console.log(JSON.stringify(result, null, 2));
} catch (error) {
console.error(JSON.stringify({
stage: error.stage || 'unknown',
reason: error.reason || 'failed',
message: error.message
}, null, 2));
} finally {
server.close();
}
});
生产环境中,/model 可以替换成任意支持 fetch 的模型网关,工具也可以是数据库、HTTP 服务或本地任务。关键不在于具体 SDK,而在于每次调用都接收同一个请求上下文,并使用传入的 signal。
重试不能制造额外时间
重试是最容易破坏预算的环节。示例中模型最多尝试两次,但第二次并没有重新获得 700 毫秒,而是继续使用原请求的剩余时间。重试前还需要检查剩余时间,并让退避等待本身也接受取消信号。
不同失败原因的处理方式应该区分开:
| 原因 | 含义 | 常见处理 |
|---|---|---|
model 的 operation_timeout | 当前模型调用超过阶段上限 | 换更快模型、缩短上下文或降级 |
tool 的 operation_timeout | 工具服务响应过慢 | 使用缓存、返回不完整结果或跳过工具 |
deadline_exceeded | 整个请求预算耗尽 | 立即结束链路,返回可识别的超时结果 |
retry_exhausted | 重试次数用完 | 记录原始错误,避免继续重试 |
| 普通网络或业务错误 | 非超时失败 | 根据错误码决定是否重试 |
不要对所有异常都重试。参数错误、权限错误和明确的 4xx 响应通常不适合重试;连接重置、短暂的 5xx 或限流错误才可能适合重试。重试次数也不是唯一限制,应该同时受到剩余时间和最大尝试次数约束。
需要注意,服务端排队时间也会消耗预算。如果模型网关内部还有队列,客户端的 fetch timeout 只能取消客户端等待,不能自动取消服务端已经创建的任务。对于异步任务,应额外传递请求标识和 deadline,让服务端也具备取消或过期清理能力。
流式响应也要服从 deadline
流式输出并不意味着请求已经完成。首字节很快,但后续 token 长时间没有到达,仍然可能占用连接和工作线程。因此流式阶段同样需要使用剩余预算。
上面的 readStream 对整个流设置了阶段上限。若业务更关注“连续一段时间没有新数据”,可以在每次 reader.read() 外再设置一个较短的空闲计时器,但这个计时器必须取:
本次读取允许时间 = min(空闲上限, 请求剩余时间)
否则流式阶段可能因为不断收到少量数据而长期持续。实践中还应在响应中记录是否已经发送首字节、发送了多少内容、结束时是正常完成还是被取消。这样排查时可以区分模型生成慢、网络传输慢和客户端预算耗尽。
建议把错误事件统一记录为结构化字段,而不是只记录一行字符串:
{
requestId: 'req-123',
stage: 'tool',
reason: 'operation_timeout',
attempt: 1,
remainingMs: 312,
model: 'model-a',
tool: 'weather'
}
当 remainingMs 在进入工具前已经很小,问题可能是上游模型或重试消耗了预算;当工具自身耗时持续接近阶段上限,则更可能是工具服务拥塞。这个区分比简单统计“智能体超时次数”更有行动价值。
总结
请求级 deadline 是跨模型、工具和输出阶段共享时间的基础。每个调用只计算当前的 remaining,通过 AbortController 把取消信号向下传播,并让重试退避、流式读取和排队等待都消耗同一份预算。
实现时可以记住四点:
- 在请求入口创建绝对 deadline,不在每一层重新计算完整 timeout。
- 为每个阶段设置必要的局部上限,但局部上限不能超过请求剩余时间。
- 对模型慢、工具慢、请求总预算耗尽和重试耗尽使用不同原因码。
- 流式响应必须覆盖首字节、连续读取和最终完成,不能把“已经开始输出”当成“不会超时”。
有了这些基础数据,后续才能可靠地实现更快模型降级、工具缓存、跳过非关键步骤,以及按阶段定位系统拥塞。
评论