智能体的一次请求通常会经过模型、工具、重试和流式输出多个阶段,任何一层单独设置超时都可能让总耗时失控。本文从请求级 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 毫秒,而是继续使用原请求的剩余时间。重试前还需要检查剩余时间,并让退避等待本身也接受取消信号。

不同失败原因的处理方式应该区分开:

原因含义常见处理
modeloperation_timeout当前模型调用超过阶段上限换更快模型、缩短上下文或降级
tooloperation_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 把取消信号向下传播,并让重试退避、流式读取和排队等待都消耗同一份预算。

实现时可以记住四点:

  1. 在请求入口创建绝对 deadline,不在每一层重新计算完整 timeout。
  2. 为每个阶段设置必要的局部上限,但局部上限不能超过请求剩余时间。
  3. 对模型慢、工具慢、请求总预算耗尽和重试耗尽使用不同原因码。
  4. 流式响应必须覆盖首字节、连续读取和最终完成,不能把“已经开始输出”当成“不会超时”。

有了这些基础数据,后续才能可靠地实现更快模型降级、工具缓存、跳过非关键步骤,以及按阶段定位系统拥塞。