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 的工作流是:
- LLM 发出工具请求。
- 你的代码运行工具(例如,API 调用、文件读取)。
- 工具的结果被发回给 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。
