首页 / 文章 / 在 Node.js 中使用 Promise.withResolvers() 协调 LLM 工具调用

在 Node.js 中使用 Promise.withResolvers() 协调 LLM 工具调用

了解 Promise.withResolvers() 如何简化在 Node.js Lambda 中调用 Bedrock 上的 Claude 的工具调用流程,以及它未涵盖的超时、重试和限制机制。

3047 词

一旦语言模型能够调用工具,您的应用程序就必须在执行数据库查询或API调用时暂停对话,待结果返回后再继续。本指南将展示如何使用Promise.withResolvers()比手动实现的承诺构造函数更清晰地表达这种暂停与恢复机制,同时介绍在AWS Lambda和Amazon Bedrock上简化的Claude工具调用流程,并列出API未能为您提供的防护措施。

为何工具调用会变成编排问题

在用户看到答案之前,使用工具的请求需要经过多个异步处理步骤:

User
 ↓
Claude
 ↓
Tool call
 ↓
External API / Database
 ↓
Tool result
 ↓
Claude
 ↓
Final response

程序中有一部分负责等待,另一部分则执行具体任务,之后再根据结果继续原有的流程。传统上这需要使用嵌套的 Promise 构造函数,以及手动捕获并传递的 resolve/reject 函数。而包括当前版本的 Node.js 在内的现代运行时则提供了更简洁的解决方案:

Promise.withResolvers()

Promise.withResolvers() 返回什么

传统的构造函数仅能在执行器回调中提供处理结果的函数:

const promise = new Promise((resolve, reject) => {
  // asynchronous work
});

从其他地方来处理结果意味着需要将 resolve 和 reject 函数“偷运”出执行器。而 Promise.withResolvers() 可以一次性将这三者都提供给你:

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

每个值都有其特定的职责。第一个值就是调用者所等待的结果:

promise → the promise you await

另外两种情况会分别得到结果值或错误信息:

resolve → completes the promise successfullyreject → completes the promise with an error

当生成结果的代码与等待该结果的代码分离时,这种方法尤为有用,比如在不可预测的时间触发的事件处理程序。

传统构造函数存在的弊端

将简单的工具调用封装在构造函数中看起来并无问题:

function callTool(request) {
  return new Promise((resolve, reject) => {
    executeTool(request)
      .then(resolve)
      .catch(reject);
  });
}

这并没有什么错误;实际上还有些多余,因为executeTool已经返回了承诺对象。然而,真正的智能体循环需要处理更多事务:

  • 流式输出模型结果
  • 检测模型何时请求使用工具
  • 运行相应工具
  • 数据库及API调用
  • 重试机制
  • 超时处理
  • 错误处理
  • 多个独立的回调函数

很快,resolve和reject就会贯穿多层结构,就像这个嵌套版本所示:

function runAgent(request) {
  return new Promise((resolve, reject) => {
invokeModel(request)
      .then(response => {
        executeTool(response)
          .then(result => {
            resolve(result);
          })
          .catch(reject);
      })
      .catch(reject);
  });
}

这种方法确实可行,但要追踪成功与失败的情况就需要遍历每一层。而使用withResolvers()后,承诺对象及其处理函数都来自同一条语句,且可以独立使用:

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

这里有一个简单的示例:一个函数负责获取用户并处理外部创建的承诺,而调用方只需等待其结果即可:

const {
  promise,
  resolve,
  reject
} = Promise.withResolvers();
async function fetchUser(id) {
  try {
    const user = await db.getUser(id);
    resolve(user);
  } catch (error) {
    reject(error);
  }
}
fetchUser("U123");
const user = await promise;

在如此简单的情况下,直接从fetchUser()返回用户信息同样清晰易懂;关键在于结构设计。withResolvers()并不会提升性能,它只是为那些创建承诺和处理承诺的代码位于不同位置时提供了一种更清晰的表达方式。

这与代理循环的关系

假设有用户询问公司某款内部产品的最新动态。为回答此问题,Claude 可能会首先请求一个搜索工具:

Claude
  ↓
Function call
  ↓
searchKnowledgeBase()
  ↓
Database/API
  ↓
Tool result
  ↓
Claude
  ↓
Final response

代码必须等待该结果后再继续执行,而外部定义的 Promise 正好适合用于这种等待场景。

Lambda 上简化的 Claude 工具循环

下面的示例使用了 Node.js 22、TypeScript、AWS Lambda、Amazon Bedrock 和 Claude,核心是 Promise.withResolvers()。请求流程如下:

HTTP Request
     ↓
AWS Lambda
     ↓
Claude via Bedrock
     ↓
Claude requests tool
     ↓
Lambda executes tool
     ↓
Tool result
     ↓
Claude
     ↓
Final response

请将此代码视为控制流的概要,而非可直接嵌入 Bedrock 的完整集成方案;注释中指出了生产环境代码需要做哪些修改。

步骤 1:安装 Bedrock 运行时客户端

用于 Bedrock Runtime 的 AWS SDK 包提供了相应的客户端及命令类:

npm install @aws-sdk/client-bedrock-runtime

步骤 2:导入客户端并创建实例

导入用于错误处理的客户端、调用命令以及服务异常类型:

import {
  BedrockRuntimeClient,
  InvokeModelCommand,
  BedrockRuntimeServiceException,
} from "@aws-sdk/client-bedrock-runtime";

然后在可以访问模型的区域中实例化该客户端:

const client = new BedrockRuntimeClient({
  region: "us-east-1",
});

步骤 3:在处理程序中创建解析器

在 Lambda 处理程序内部,创建一个专门用于处理工具结果的承诺对象:

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

这样处理程序就拥有了三个功能分工明确的处理机制:

toolPromise → waits for the tool result
resolve() → supplies the tool result
reject() → reports a tool failure

会有其他回调函数最终解决 toolPromise。应在处理程序内部创建它,而非在模块作用域中:Lambda 会重用已加热的执行环境,如果在模块级别创建且已被解决的承诺对象,会导致一个请求的结果泄露到下一个请求中。

步骤 4:描述请求与工具

该请求包含用户的消息以及searchKnowledgeBase工具的声明,其中还包含了该工具唯一query参数的JSON Schema:

const prompt = JSON.stringify({
  messages: [
    {
      role: "user",
      content: event.body ?? "Tell me a story."
    }
  ],
toolConfig: {
    tools: [
      {
        name: "searchKnowledgeBase",
        description:
          "Searches the company's knowledge base.",
        inputSchema: {
          type: "object",
          properties: {
            query: {
              type: "string"
            }
          },
          required: ["query"]
        }
      }
    ]
  },
  stream: true
});

工具定义告知Claude在需要外部信息时可以调用此函数:

searchKnowledgeBase

在使用之前,请根据当前的Bedrock文档检查有效载荷的格式。对于InvokeModel,Anthropic模型期望采用Anthropic Messages格式,该格式包含anthropic_version字段和max_tokens,并且会在带有input_schema的tools数组中声明工具。此处所示的toolConfig结构属于Bedrock独立的Converse API,因此请选择其中一个API并遵循其格式要求。

第5步:调用模型

需将有效载荷封装在带有模型ID和JSON内容类型的命令中:

const command = new InvokeModelCommand({
  modelId: "your-model-id",
contentType: "application/json",
  accept: "application/json",
  body: Buffer.from(prompt),
});

发送请求后,若调用失败则返回502响应;如有Bedrock异常信息,则使用该信息:

let modelStream;
try {
  const response = await client.send(command);
  modelStream =
    response.body as NodeJS.ReadableStream;
} catch (error) {
  const message =
    (error as BedrockRuntimeServiceException).message
    ?? "Unknown error";
  return {
    statusCode: 502,
    body: JSON.stringify({
      error: `Bedrock call failed: ${message}`
    })
  };
}

对于流式输出,Bedrock提供了专用操作(InvokeModelWithResponseStreamCommand或Converse API的ConverseStream);而普通的InvokeModelCommand会一次性返回整个响应内容。后续步骤以流式版本为前提。

第6步:检测工具请求

处理程序会检查传入的各部分内容,判断Claude是否调用了某个工具。在这个简化版本中,它会从原始文本中查找工具名称,用正则表达式提取参数,然后执行该工具:

modelStream.on("data", async (chunk) => {
const text = chunk.toString();
  if (
    text.includes(
      `"name":"searchKnowledgeBase"`
    )
  ) {
    const match =
      /"arguments":\s*"([^"]+)"/
        .exec(text);
    const query =
      match?.[1] ?? "default query";
    mockSearchKnowledgeBase(query)
      .then(resolve)
      .catch(reject);
  }
});

关键所在在于将工具自身的承诺直接与第3步中创建的解析器关联起来:

mockSearchKnowledgeBase(query)
  .then(resolve)
  .catch(reject);

由于已经存在结算函数,因此无需额外的包装 Promise 来获取结果。不过直接按原始数据块匹配字符串存在缺陷:工具调用可能会被拆分到不同的数据块中,且参数格式无法始终可靠地匹配此类正则表达式。实际代码应解析结构化的流事件,并持续收集工具输入直至整个处理块完成。同时还需处理模型在完全未调用工具的情况下就结束运行的情况,否则 toolPromise 永远不会得到解决。

第7步:等待工具响应

在工具运行期间,处理程序会等待该 Promise 的结果;如果工具执行失败,则返回500错误码:

let toolResult;
try {
  toolResult =
    await toolPromise;
} catch (error) {
  return {
    statusCode: 500,
    body: JSON.stringify({
      error: `Tool failed: ${error}`
    })
  };
}

这就是该模式的核心所在。等待代码并不知道结果会从何处传来,它只关心最终是否会有人调用这些函数之一:

resolve(toolResult)
reject(error)

第8步:将工具处理结果返回给Claude

工具处理完成后,结果会通过后续请求传回模型。从概念上讲,该请求包含助手的回复内容以及工具的输出结果:

const followUp = JSON.stringify({
  messages: [
    {
      role: "assistant",
      content: "Calling tool..."
    },
    {
      role: "tool",
      name: "searchKnowledgeBase",
      content: JSON.stringify(toolResult)
    }
  ],
  stream: true
});

随后会使用该后续数据再次调用Bedrock:

const followUpCommand =
  new InvokeModelCommand({
    modelId: "your-model-id",
    contentType: "application/json",
    accept: "application/json",
    body: Buffer.from(followUp)
  });
const response =
  await client.send(followUpCommand);

Claude此时可以撰写最终答案。消息格式如下:在Anthropic Messages格式中,助手的回复部分包含一个tool_use内容块,而结果则作为user消息中的tool_result块发送,该块会引用前述内容块的ID,而非以独立的tool角色形式呈现。

整个循环流程

综合来看,其架构结构如下:

                 ┌─────────────┐
                 │    User     │
                 └──────┬──────┘
                        │
                        ▼
                 ┌─────────────┐
                 │   Lambda    │
                 └──────┬──────┘
                        │
                        ▼
                 ┌─────────────┐
                 │   Claude    │
                 │  Bedrock    │
                 └──────┬──────┘
                        │
                  Tool request
                        │
                        ▼
                 ┌─────────────┐
                 │    Tool     │
                 └──────┬──────┘
                        │
                  Tool result
                        │
                        ▼
                 ┌─────────────┐
                 │   Claude    │
                 └──────┬──────┘
                        │
                        ▼
                 ┌─────────────┐
                 │    User     │
                 └─────────────┘

Promise.withResolvers()位于工具执行与循环继续之间的交接点:

Tool starts
    │
    ▼
resolve(result)
    │
    ▼
await toolPromise
    │
    ▼
Continue agent loop

用于测试的模拟工具

为在无需真实后端的情况下测试流程,可通过短暂延迟来模拟知识库搜索:

function mockSearchKnowledgeBase(
  query: string
): Promise<{ answer: string }> {
return new Promise((resolve) => {
    setTimeout(() => {
      resolve({
        answer:
          `Results for "${query}" (mocked).`
      });
    }, 300);
  });
}

在生产环境中,同一函数可能会调用这些中的任意一个:

DynamoDB
OpenSearch
RDS
S3
REST API
Internal service
Vector database
Knowledge base

唯一重要的约定是该工具必须返回一个承诺对象。

防止工具永远无法完成

外部工具可能会卡住或消失。如果某个工具始终无法完成,这条代码会一直等待直到Lambda本身超时:

await toolPromise;

超时封装机制通过让承诺与计时器竞争来设定时间上限,并根据承诺的最终状态清除计时器:

function withTimeout<T>(
  promise: Promise<T>,
  milliseconds: number
): Promise<T> {
return new Promise<T>(
    (resolve, reject) => {
      const timer =
        setTimeout(() => {
          reject(
            new Error(
              `Operation timed out after ${milliseconds}ms`
            )
          );
        }, milliseconds);
      promise.then(
        (value) => {
          clearTimeout(timer);
          resolve(value);
        },
        (error) => {
          clearTimeout(timer);
          reject(error);
        }
      );
    }
  );
}

随后会在两秒的时间限制内等待工具的返回结果:

const toolResult =
  await withTimeout(
    toolPromise,
    2000
  );

阻止代码等待的是包装器,而非工具本身:除非同时传入AbortSignal并取消请求,否则查询会持续运行。

有意识地处理 Bedrock 错误

需区分不同类型的故障。示例中将限流错误映射为 429 状态码,其他 Bedrock 服务错误映射为 502 状态码,而将所有意外错误直接重新抛出:

try {
  await client.send(command);
} catch (error) {
  if (
    error instanceof Error &&
    error.name === "ThrottlingException"
  ) {
    return {
      statusCode: 429,
      body: JSON.stringify({
        error:
          "Bedrock request was throttled."
      })
    };
  }
  if (
    error instanceof
    BedrockRuntimeServiceException
  ) {
    return {
      statusCode: 502,
      body: JSON.stringify({
        error:
          `Bedrock error: ${error.message}`
      })
    };
  }
  throw error;
}

带有退避机制的限流调用重试

限流现象通常是暂时的,因此很适合作为重试的对象。该辅助函数最多尝试三次,每次限流后等待更长的时间,并立即重新抛出其他所有错误:

async function invokeWithBackoff(
  command: InvokeModelCommand,
  attempts = 3
) {
for (
    let attempt = 0;
    attempt < attempts;
    attempt++
  ) {
    try {
      return await client.send(command);
    } catch (error) {
      if (
        error instanceof Error &&
        error.name === "ThrottlingException"
      ) {
        const delay =
          500 * (attempt + 1);
        await new Promise(
          resolve =>
            setTimeout(resolve, delay)
        );
        continue;
      }
      throw error;
    }
  }
  throw new Error(
    "Exceeded retry attempts."
  );
}

仅重试那些可以安全重试的错误;对权限问题进行重试只会产生三次相同的失败结果。这里的延迟呈线性增长,而在多次调用同时被限制时,加入随机抖动会有所帮助。

在没有withResolvers()的运行时环境中的支持

在缺乏该方法的环境中,一个小型辅助函数可以提供类似的功能。它首先声明通用函数:

function createDeferred<T>() {

在函数内部,它通过确定性赋值断言来声明结算函数,从普通构造函数中捕获这些函数,然后将三者一起返回:

  let resolve!: (value: T) => void;  let reject!: (reason?: unknown) => void;  const promise =
    new Promise<T>((res, rej) => {      resolve = res;
      reject = rej;    });  return {
    promise,
    resolve,
    reject
  };
}

其使用方式与原生API完全相同:

const {
  promise,
  resolve,
  reject
} = createDeferred<Result>();

当运行时环境原生支持Promise.withResolvers()时,应优先使用它,而无需再使用该辅助函数。

withResolvers()无法解决的问题

该方法简化了承诺的创建方式,并使其结算功能能够在执行器之外使用。但它无法解决以下问题:

  • 竞态条件
  • 多个并发工具调用
  • 取消操作
  • 超时问题
  • 防止重复结算
  • 资源清理
  • 正确处理流式模型输出
  • 工具授权
  • 重试策略

所有这些问题仍需单独设计解决方案。如下所示的链式结构,由于不对模型连续调用的工具数量设限,无论承诺的实现多么完善,都属于糟糕的架构:

Claude
 ↓
Tool A
 ↓
Tool B
 ↓
Tool C
 ↓
Unbounded execution

智能体循环需要明确的限制。博客中关于TypeScript 中的有界智能体循环的指南对这类限制进行了更深入的阐述。

为何该模式仍具有价值

代理协调需要处理模型输出与其后续操作之间的诸多异步边界:

Model response
      ↓
Stream event
      ↓
Tool detection
      ↓
Tool execution
      ↓
Database
      ↓
Tool result
      ↓
Model continuation

由于存在嵌套构造函数,这种流程很难追踪。withResolvers()能够使其呈现为清晰的顺序:

Create promise
      ↓
Expose resolver
      ↓
Start asynchronous operation
      ↓
Resolve when result arrives
      ↓
Await result
      ↓
Continue agent loop

生产环境检查清单

验证工具参数

将模型生成的参数视为不可信的输入,至少需检查:

Types
Required fields
String lengths
Allowed values
Authorization
Business rules

限制工具执行范围

为以下内容设定明确的上限:

Maximum tool calls
Maximum execution time
Maximum model iterations
Maximum response size

使循环过程可观测

需记录以下内容的指标与追踪信息:

Lambda duration
Bedrock latency
Tool latency
Tool failures
Throttling
Token usage
Agent iterations
Timeouts

实施最小权限原则并保持工具功能精简

仅授予 Lambda 执行角色其工具所需的权限,绝不可让模型无限制地访问您的 AWS 账户或内部系统;应仅暴露小型且定义明确的操作。

选择 withResolvers() 还是 new Promise()

构造函数将解析器保留在执行器内部,适用于所有运行时环境,也适合普通的异步操作,但可能会导致编排代码中出现额外的嵌套结构。withResolvers() 会同时返回承诺对象和解析器,适用于在其他地方进行状态结算的场景,但前提是运行时需支持该功能。这并不意味着所有情况都应使用 new Promise(),当某个操作天然适合这种形式时,就应继续使用它:

return new Promise(...)

当创建与状态结算分离时,应使用 withResolvers()。

关键要点

不必像这样将逻辑嵌在构造函数中:

new Promise((resolve, reject) => {
  // deeply nested asynchronous logic
});

你可以提前创建各个组件:

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

并将执行流程组织成清晰的顺序:

Promise creation
       ↓
Asynchronous tool execution
       ↓
resolve / reject
       ↓
Continue agent loop
  • withResolvers()非常适合处理代理循环中的暂停与继续点,即一个回调产生结果后其他代码会等待该结果。
  • 在处理程序中为每个请求创建相应的解析器,并确保所有路径都能解决承诺,包括那些没有调用任何工具的路径。
  • 必须遵循所选Bedrock API的确切数据格式;此处展示的仅为简化版本。
  • 超时、选择性重试、验证、最小权限原则、可观测性以及迭代次数限制仍需明确添加。

智能体的可靠性取决于模型周围的异步处理机制,这一点比巧妙的提示语更为重要。在需要提升代码可读性的地方使用 withResolvers(),并补充该机制无法提供的防护措施。

相关阅读

  • 使用 Prisma 和 Nexus 在 Node.js 中构建类型安全的 GraphQL API — 通过七步指南学习如何打造一个将 Prisma 的数据模型与 Nexus 生成的类型及解析器相结合的 Node.js GraphQL API。
  • 将 MCP 工具集成到具备内置人工审批功能的 React 聊天界面中 — 了解模型上下文协议如何适配 React 应用:为何后端需要托管 MCP、工具服务器的运作原理,以及如何在界面中实现工具调用的流式处理与人工审批。
  • 将 Node.js 项目迁移到 Bun:运行时内部机制、Lambda 以及迁移流程 — 了解 Bun 替换了 Node.js 工具链中的哪些部分,它为何能快速启动,如何运行 TypeScript 以及如何在 AWS Lambda 上运行,还有如何逐步完成项目迁移。