Claude 的工具调用循环在 Node.js 中以产生纠缠不清的 Promise 代码而闻名。

Node.js 22 的新特性 Promise.withResolvers() 让开发者可以用单行代码取代冗长的 new Promise 模式,直接获取 promise 及其 resolve/reject 函数。其结果是:减少了遗忘 resolve 调用的情况,消除了重复 reject 的警告,并实现了更扁平的控制流,使其在无服务器环境中更易于测试和保持运行。

为什么旧模式会破坏流程

当像 Claude 这样的 LLM 请求工具时,典型的 Node 实现如下:

return new Promise((resolve, reject) => {
  // launch the tool, attach callbacks, maybe fire another async call
});

三个常见的陷阱随之而来:

  • 遗忘 resolve —— 如果代码路径从未调用 resolve,Lambda 或其他无服务器处理程序将一直挂起直到超时,从而推高成本。
  • 重复 reject —— 如果错误路径调用了两次 reject,会触发“未处理的拒绝 (unhandled rejection)”警告,这在严格模式下可能会导致进程崩溃。
  • 深层嵌套 —— 每个异步步骤都会在构造函数内部嵌套另一个回调,导致逻辑分散,并使单元测试变得脆弱。

所有这些问题都源于一个事实:promise 的控制函数被锁定在构造函数的闭包中,迫使代码的其他部分必须回溯到闭包内部才能操作它。

一行代码实现 Promise.withResolvers()

Node 22 添加了一个静态辅助方法,返回一个包含 promise 以及两个用于改变其状态的函数的对象:

const { promise, resolve, reject } = Promise.withResolvers();

现在,promise 可以被传递给系统的任何部分——HTTP 处理程序、数据库监听器或后台工作线程——而原始调用者只需 await 该 promise。无需再将整个工具执行块包装在 new Promise 构造函数中。

应用于 Claude 的工具循环

Claude 的工作流是:

  1. LLM 发出工具请求。
  2. 你的代码运行工具(例如,API 调用、文件读取)。
  3. 工具的结果被发回给 Claude 以进行下一轮对话。

使用 withResolvers 后,循环简化为:

async function runTool(request) {
  const { promise, resolve, reject } = Promise.withResolvers();

  // Kick off the tool; it can call resolve/reject from anywhere
  executeTool(request, { resolve, reject });

  // Optional timeout wrapper
  const timeout = setTimeout(() => reject(new Error('Tool timed out')), 10_000);
  try {
    const result = await promise;
    clearTimeout(timeout);
    return result;               // feed back to Claude
  } finally {
    // clean-up if needed
  }
}

工具实现不再需要被包装在新的 promise 中;它只需接收 resolve 和 reject。这消除了上述三种失败模式。

生产环境中的关键配置

即使 promise 的结构变得更清晰,实际的 Agent 仍会遇到其他限制:

  • 超时 (Timeouts) —— 上面的代码片段展示了一个简单的计时器,如果工具执行超过阈值则会 reject。请根据 SLA 预期调整持续时间。
  • 节流 (Throttling) —— 当底层服务返回节流错误(例如 Bedrock 的 ThrottlingException)时,请捕获它,暂停,并使用指数退避 (exponential back-off) 进行重试。resolve/reject 对保持不变,改变的只是重试逻辑。
  • Lambda 成本 —— 在 AWS Lambda 中,设置 callbackWaitsForEmptyEventLoop = false。这会告诉运行时,只要处理程序返回,就立即终止函数,即使流或其他后台句柄仍处于打开状态。这可以防止函数在 promise 在其他地方完成时继续滞留。

这种新辅助方法并非万灵丹

Promise.withResolvers() 仅在 Node 22 及更高版本中可用。受限于旧版 LTS 版本的项目必须使用 polyfill 模式或坚持使用经典的构造函数。Polyfill 可以模拟该 API,但无法获得原生性能优势。此外,该辅助方法并不能神奇地解决逻辑错误:开发者仍需确保每个请求恰好调用一次 resolve 或 reject 中的一个,否则 promise 将无限期地处于 pending 状态。

下一步值得关注的方向

  • 框架采用 —— 抽象了 LLM Agent 循环的库(例如开源的 Claude 封装库)正开始将 withResolvers 作为一项可选功能开放。请留意那些将该模式设为默认选项的更新。
  • Node 生态 —— 随着更多服务迁移到 Node 22,该辅助方法将成为任何“触发并等待 (fire-and-wait)”异步模式的事实标准,而不仅仅是针对 LLM Agent。
  • 工具调用标准 —— 新兴的 LLM 工具调用规范可能会规定“单一 promise”契约,这与 withResolvers 的方法完美契合。

核心结论: 通过将冗长的 new Promise 包装替换为单行的 Promise.withResolvers(),基于 Claude 的 Agent 可以获得更清晰的流程、更少的运行时意外,并能更严密地控制无服务器成本——前提是运行时支持 Node 22。