Inicio / Artículos / Coordenar llamadas a herramientas de LLM en Node.js con Promise.withResolvers()

Coordenar llamadas a herramientas de LLM en Node.js con Promise.withResolvers()

Vea cómo Promise.withResolvers() simplifica la orquestación de llamadas a herramientas en una función Lambda de Node.js que llama a Claude en Bedrock, además de los tiempos de espera, reintentos y límites que no abarca.

3047 palabras

Una vez que un modelo de lenguaje puede llamar a herramientas, su aplicación debe pausar la conversación mientras se ejecuta una consulta a la base de datos o una llamada a API, y luego reanudarla con el resultado. Esta guía muestra cómo Promise.withResolvers() expresa ese proceso de pausa y reanudación de manera más clara que los constructores de promesas hechos a mano, explica en detalle un bucle simplificado para herramientas de Claude en AWS Lambda y Amazon Bedrock, y enumera las medidas de protección que la API no le proporciona.

Por qué llamar a herramientas se convierte en un problema de orquestación

Una solicitud que utiliza herramientas pasa por varios pasos asíncronos antes de que el usuario vea una respuesta:

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

Una parte del programa espera mientras otra realiza el trabajo, y luego el flujo original continúa con el resultado. Tradicionalmente esto implica constructores anidados de Promise y funciones resolve/reject que se capturan manualmente y se pasan de un lugar a otro. Los entornos de ejecución modernos, incluido el actual Node.js, ofrecen una solución más sencilla:

Promise.withResolvers()

Qué devuelve Promise.withResolvers()

El constructor clásico solo proporciona las funciones de resolución dentro del callback del ejecutor:

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

Resolverlo desde otro lugar significa introducir de forma encubierta las funciones resolve y reject fuera del ejecutor. Promise.withResolvers() te entrega las tres partes de una sola vez:

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

Cada valor tiene una función específica. El primero es el que esperan quienes llaman al método:

promise → the promise you await

Los otros dos lo resuelven, ya sea con un valor o con un error:

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

Brilla cuando el código que genera un resultado está separado del código que lo espera, como por ejemplo un manejador de eventos que se dispara en un momento impredecible.

Dónde resulta incómodo el constructor clásico

Una invocación básica de una herramienta envuelta en un constructor parece inofensiva:

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

No hay nada malo en ello; incluso es redundante, ya que executeTool ya devuelve una promesa. Sin embargo, los bucles reales de los agentes manejan mucho más:

  • emisión en flujo continuo de los resultados del modelo
  • detección de cuando el modelo solicita una herramienta
  • ejecución de la herramienta
  • llamadas a bases de datos y APIs
  • reintentos
  • tiempos de espera
  • gestión de errores
  • varios callbacks independientes

Pronto, resolve y reject pasan a través de varias capas, como en esta versión anidada:

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

Funciona, pero rastrear los éxitos y fracasos implica leer cada nivel. Con withResolvers(), la promesa y sus funciones de resolución provienen de una sola instrucción y pueden usarse de forma independiente:

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

Aquí hay un pequeño ejemplo en el que una función obtiene un usuario y resuelve la promesa creada externamente, mientras que quien llama simplemente la espera:

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;

En un caso tan sencillo, devolver al usuario desde fetchUser() sería igual de claro; lo importante es la estructura. withResolvers() no hace que nada sea más rápido. Ofrece una forma más limpia de expresar la coordinación cuando el lugar que crea la promesa y el lugar que la resuelve no son lo mismo.

Cómo se relaciona esto con un bucle de agentes

Supongamos que un usuario pregunta qué hay de nuevo en uno de los productos internos de la empresa. Para responder, Claude puede solicitar primero una herramienta de búsqueda:

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

El código debe esperar ese resultado antes de continuar, y una promesa resuelta externamente se adapta perfectamente a ese punto de espera.

Un bucle simplificado para herramientas de Claude en Lambda

El ejemplo a continuación utiliza Node.js 22, TypeScript, AWS Lambda, Amazon Bedrock y Claude, con Promise.withResolvers() como elemento central. El flujo de solicitud es:

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

Considere el código como un boceto del flujo de control, no como una integración directa con Bedrock; las notas indican dónde debe diferir el código de producción.

Paso 1: instalar el cliente del entorno de ejecución Bedrock

El paquete AWS SDK para Bedrock Runtime proporciona el cliente y las clases de comando:

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

Paso 2: importar el cliente y crearlo

Importe el cliente, la orden de invocación y el tipo de excepción del servicio utilizado para el manejo de errores:

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

Luego, instancie el cliente en la región donde tenga acceso al modelo:

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

Paso 3: crear el resolvedor dentro del manejador

Dentro del manejador de Lambda, cree una promesa dedicada al resultado de la herramienta:

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

Eso proporciona al manejador tres mecanismos de manejo con roles claramente separados:

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

Otro callback resolverá eventualmente a toolPromise. Créelo dentro del manejador, no en el ámbito del módulo: Lambda reutiliza entornos de ejecución ya calentados, y una promesa a nivel de módulo que ya esté resuelta haría que el resultado de una solicitud se filtrara a la siguiente.

Paso 4: describir la solicitud y la herramienta

La solicitud contiene el mensaje del usuario y una declaración de la herramienta searchKnowledgeBase, incluyendo un JSON Schema para su único argumento query:

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
});

La definición de la herramienta indica a Claude que puede solicitar esta función cuando necesita información externa:

searchKnowledgeBase

Verifique el formato del payload según la documentación actual de Bedrock antes de usarlo. Con InvokeModel, los modelos de Anthropic esperan el formato Anthropic Messages, que incluye un campo anthropic_version y max_tokens, además de declarar las herramientas en un array tools con input_schema. La estructura toolConfig mostrada aquí pertenece a la API separada Converse de Bedrock, así que elija una API y siga su esquema.

Paso 5: invocar el modelo

Rodee el payload en un comando con un ID de modelo y tipo de contenido JSON:

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

Envíelo y convierta una llamada fallida en una respuesta 502, utilizando el mensaje de la excepción de Bedrock cuando esté disponible:

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}`
    })
  };
}

Para la salida por streaming, Bedrock dispone de operaciones dedicadas (InvokeModelWithResponseStreamCommand o ConverseStream para la API Converse); el comando simple InvokeModelCommand devuelve todo el contenido de una sola vez. El siguiente paso asume una variante de streaming.

Paso 6: detectar la solicitud de herramienta

El manejador inspecciona los fragmentos de texto recibidos para determinar si Claude ha solicitado una herramienta. En esta versión simplificada busca el nombre de la herramienta en el texto sin procesar, extrae el argumento con una expresión regular y ejecuta la herramienta:

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);
  }
});

La línea a analizar conecta directamente la promesa propia de la herramienta con el resolvedor creado en el paso 3:

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

No se necesita una promesa de envoltorio adicional para exponer el resultado, ya que las funciones de resolución ya existen. Sin embargo, comparar cadenas en fragmentos sin procesar es frágil: una llamada a la herramienta puede dividirse entre varios fragmentos, y el formato de los argumentos no coincidirá de manera fiable con una expresión regular como esta. El código real debe analizar los eventos del flujo estructurado y acumular la entrada de la herramienta hasta que el bloque esté completo. También es necesario manejar el caso en el que el modelo finalice sin solicitar la herramienta en absoluto; de lo contrario, toolPromise nunca se resolverá.

Paso 7: esperar a la herramienta

Mientras la herramienta está en ejecución, el manejador espera a que se resuelva la promesa y devuelve un código 500 si la herramienta falla:

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

Este es el núcleo del patrón. El código de espera no tiene idea de dónde provendrá el resultado; solo le importa que alguien llame eventualmente a alguna de estas funciones:

resolve(toolResult)
reject(error)

Paso 8: devolver el resultado de la herramienta a Claude

Una vez que la herramienta finaliza, el resultado vuelve al modelo a través de una solicitud posterior. Conceptualmente, contiene el turno del asistente y la salida de la herramienta:

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

Luego se vuelve a invocar a Bedrock con la carga de trabajo posterior:

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 puede ahora escribir su respuesta final. De nuevo, la estructura del mensaje es ilustrativa: en el formato de mensajes de Anthropic, el turno del asistente contiene un bloque de contenido tool_use, y el resultado se envía en un mensaje user como un bloque tool_result que hace referencia al ID de dicho bloque, en lugar de como un rol tool separado.

El bucle en su conjunto

En conjunto, la arquitectura se ve así:

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

Promise.withResolvers() actúa como punto de transición entre la ejecución de la herramienta y la continuación del bucle:

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

Una herramienta de simulación para pruebas

Para probar el flujo sin un backend real, la búsqueda en la base de conocimientos puede simularse con un breve retraso:

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

En producción, la misma función podría llamar a cualquiera de estos:

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

El único requisito importante es que la herramienta devuelva una promesa.

Protección contra herramientas que nunca terminan

Las herramientas externas pueden quedar atascadas o desaparecer. Si una herramienta nunca se resuelve, esta línea espera hasta que Lambda mismo llegue al tiempo de espera:

await toolPromise;

Un envoltorio de tiempo de espera establece un límite superior compitiendo la promesa contra un temporizador y eliminando el temporizador independientemente de cómo se resuelva la promesa:

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);
        }
      );
    }
  );
}

Luego, se espera el resultado de la herramienta con un límite de dos segundos:

const toolResult =
  await withTimeout(
    toolPromise,
    2000
  );

El contenedor evita que tu código espere, no la herramienta en sí: la consulta sigue ejecutándose a menos que también le pases un AbortSignal para cancelarla.

Manejo intencional de errores en Bedrock

Distingue entre los diferentes tipos de fallos. El ejemplo asocia el control de tasa con un código 429, otros errores del servicio Bedrock con un código 502, y vuelve a lanzar cualquier error inesperado:

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;
}

Volver a intentar llamadas sometidas a control de tasa con retroceso

El control de tasa suele ser temporal, por lo que es un candidato razonable para intentarlo de nuevo. Este helper prueba hasta tres veces, esperando un poco más después de cada intento sometido a control de tasa, y vuelve a lanzar inmediatamente cualquier otro 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."
  );
}

Solo intente nuevamente los errores que sean seguros de intentar de nuevo; intentarlo con un problema de permisos solo genera tres fallos idénticos. La demora aumenta de forma lineal en este caso, y agregar variabilidad aleatoria ayuda cuando muchas invocaciones se limitan al mismo tiempo.

Soporte para entornos sin withResolvers()

En un entorno que carece de este método, un pequeño auxiliar proporciona la misma estructura. Comienza declarando la función genérica:

function createDeferred<T>() {

Dentro de ella, se declaran las funciones de resolución con afirmaciones de asignación definitiva, se capturan desde un constructor regular y se devuelven las tres juntas:

  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
  };
}

El uso es idéntico al de la API nativa:

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

Cuando el entorno soporta nativamente Promise.withResolvers(), prefiera utilizarlo y elimine el auxiliar.

Lo que withResolvers() no resuelve

El método simplifica la forma en que se crea una promesa y hace que sus funciones de liquidación estén disponibles fuera del ejecutor. No aborda lo siguiente:

  • condiciones de carrera
  • llamadas concurrentes múltiples a herramientas
  • cancelación
  • tiempos de espera
  • protección contra liquidaciones duplicadas
  • Limpieza de recursos
  • Manejo correcto de la salida del modelo en flujo
  • Autenticación de herramientas
  • Política de reintentos

Cada uno de estos aspectos aún debe diseñarse explícitamente. Una cadena como la que se muestra a continuación, sin límite en cuántas herramientas puede llamar el modelo secuencialmente, constituye una arquitectura deficiente, sin importar cuán bien estén escritas las promesas:

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

Un bucle de agente necesita límites estrictos. La guía del blog sobre bucles de agente limitados en TypeScript explica esos límites con más detalle.

Por qué este patrón sigue siendo útil

La orquestación de agentes atraviesa numerosas barreras asíncronas entre la salida del modelo y su continuación:

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

Con constructores anidados, es difícil rastrear ese flujo. withResolvers() proporciona una secuencia legible:

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

Lista de verificación para producción

Validar argumentos de la herramienta

Trate los argumentos generados por el modelo como entrada no confiable. Verifique al menos:

Types
Required fields
String lengths
Allowed values
Authorization
Business rules

Límites en la ejecución de la herramienta

Establezca límites explícitos para:

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

Hacer que el bucle sea observable

Registre métricas y trazas para:

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

Aplicar el principio de menor privilegio y mantener las herramientas especializadas

Asigne al rol de ejecución de Lambda solo los permisos que requieren sus herramientas, y nunca permita que el modelo tenga acceso ilimitado a su cuenta de AWS o a sus sistemas internos; en su lugar, exponga operaciones pequeñas y bien definidas.

Elegir entre withResolvers() y new Promise()

El constructor mantiene los resolvers dentro del ejecutor, funciona en cualquier entorno de ejecución y es adecuado para operaciones asíncronas comunes, pero puede obligar a un anidamiento adicional en el código de orquestación. withResolvers() devuelve la promesa y los resolvers juntos, y es adecuado para casos en los que la resolución ocurre en otro lugar, a costa de necesitar un entorno de ejecución que lo soporte. Nada de esto implica que todo new Promise() deba utilizarse. Cuando una operación encaja naturalmente en esta forma, manténgala:

return new Promise(...)

Utilice withResolvers() cuando la creación y la resolución están separadas.

Puntos clave

En lugar de enterrar la lógica dentro de un constructor de esta manera:

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

puedes crear las partes por adelantado:

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

y estructurar el flujo como una secuencia clara:

Promise creation
       ↓
Asynchronous tool execution
       ↓
resolve / reject
       ↓
Continue agent loop
  • withResolvers() se adapta a los puntos de pausa y reanudación de un bucle de agente, donde una función de callback genera un resultado y otro código espera por él.
  • Crea resolvers por solicitud dentro del manejador, y asegúrate de que cada ruta resuelva la promesa, incluyendo aquella en la que no se llama a ninguna herramienta.
  • Sigue los formatos exactos de carga útil de la API Bedrock que elijas; los mostrados aquí están simplificados.
  • Tiempos de espera, reintentos selectivos, validación, principio de menor privilegio, capacidad de observabilidad y límites de iteración aún deben añadirse explícitamente.

Un agente solo es tan fiable como la infraestructura asíncrona que rodea al modelo, lo cual es más importante que prompts ingeniosos. Utilice withResolvers() cuando esto haga que dicha infraestructura sea más fácil de entender, y añada las medidas de seguridad que ella no puede proporcionar.

Lecturas relacionadas

  • Construyendo una API GraphQL segura con tipos con Prisma y Nexus en Node.js — Sigue una guía de siete pasos para crear una API GraphQL en Node.js que unifique el modelo de datos de Prisma con los tipos y resolvers generados por Nexus.
  • Conectando herramientas MCP a una UI de chat en React con aprobación humana incorporada — Aprende cómo se integra el Model Context Protocol en una aplicación React: por qué el backend debe alojar MCP, cómo funciona un servidor de herramientas y cómo transmitir y aprobar las llamadas a herramientas en la UI.
  • Mover un proyecto Node.js a Bun: Internos del entorno de ejecución, Lambda y migración — Entienda qué reemplaza Bun en una cadena de herramientas Node.js, por qué arranca rápido, cómo ejecuta TypeScript y en AWS Lambda, y cómo migrar un proyecto paso a paso.