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.
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
- Configuración de Prisma 7 con PostgreSQL en un proyecto TypeScript Node.js — Solucione los errores comunes al configurar Prisma 7 en TypeScript, desde URLs inválidas o indefinidas hasta problemas con rootDir, e integre PostgreSQL mediante el adaptador del controlador pg.