Accueil / Articles / Coordonner les appels d’outils LLM dans Node.js à l’aide de Promise.withResolvers()

Coordonner les appels d’outils LLM dans Node.js à l’aide de Promise.withResolvers()

Voyez comment Promise.withResolvers() simplifie l’orchestration des appels d’outils dans une fonction Lambda Node.js qui interroge Claude sur Bedrock, ainsi que les délais d’attente, les tentatives de réessai et les limites qu’elle ne couvre pas.

3047 mots

Lorsqu’un modèle de langage peut appeler des outils, votre application doit suspendre la conversation pendant qu’une requête de base de données ou une appel API est en cours, puis reprendre avec le résultat. Ce guide montre comment Promise.withResolvers() exprime plus clairement cette pause et reprise que les constructeurs de promesses personnalisés, présente un cycle simplifié d’utilisation des outils Claude sur AWS Lambda et Amazon Bedrock, et énumère les mesures de protection que l’API ne met pas à votre disposition.

Pourquoi l’appel d’outils devient un problème d’orchestration

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

Une partie du programme attend tandis qu’une autre effectue le travail, puis le flux initial se poursuit avec le résultat. Traditionnellement, cela implique des constructeurs Promise imbriqués ainsi que des fonctions resolve/reject saisies manuellement et transmises d’un endroit à l’autre. Les environnements de exécution modernes, y compris le Node.js actuel, offrent une solution plus simple :

Promise.withResolvers()

Ce que renvoie Promise.withResolvers()

Le constructeur classique ne fournit que les fonctions de règlement à l’intérieur du callback d’exécution :

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

Régler la promesse depuis un autre endroit signifie introduire clandestinement les fonctions resolve et reject en dehors du mécanisme d’exécution. Promise.withResolvers() vous fournit les trois éléments en même temps :

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

Chaque valeur a une seule fonction. La première est celle sur laquelle attendent les appelsants :

promise → the promise you await

Les deux autres le résolvent, soit avec une valeur, soit avec une erreur :

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

Cela devient utile lorsque le code qui produit un résultat est séparé du code qui l’attend, comme par exemple un gestionnaire d’événements déclenché à un moment imprévisible.

Où le constructeur classique devient gênant

L’appel à une fonction de base encapsulé dans un constructeur semble inoffensif :

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

Il n’y a rien de mal à cela ; c’est même redondant, puisque executeTool retourne déjà une promesse. Cependant, les boucles réelles des agents gèrent bien plus de choses :

  • l’affichage en continu des résultats du modèle
  • la détection du moment où le modèle demande une fonction
  • l’exécution de cette fonction
  • les appels à la base de données et aux API
  • les tentatives de répétition
  • les délais d’expiration
  • la gestion des erreurs
  • plusieurs callbacks indépendants

Bientôt, resolve et reject sont intégrés à plusieurs niveaux, comme dans cette version imbriquée :

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

Cela fonctionne, mais pour suivre les succès et les échecs il faut lire chaque niveau. Avec withResolvers(), la promesse et ses fonctions de résolution proviennent d’une seule instruction et peuvent être utilisées indépendamment :

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

Voici un petit exemple où une fonction récupère un utilisateur et résout la promesse créée en externe, tandis que l’appelant n’a qu’à l’attendre :

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;

Dans un cas aussi simple, renvoyer l’utilisateur depuis fetchUser() serait tout aussi clair ; l’important est la structure. withResolvers() ne rend rien plus rapide. Il offre simplement un moyen plus propre d’exprimer la coordination lorsque l’endroit qui crée une promesse et celui qui la résout ne sont pas identiques.

Comment cela correspond à une boucle d’agent

Supposons qu’un utilisateur demande quelles sont les nouvelles fonctionnalités d’un des produits internes de l’entreprise. Pour répondre, Claude peut d’abord demander un outil de recherche :

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

Le code doit attendre ce résultat avant de continuer, et une promesse résolue externement convient naturellement à ce moment d’attente.

Boucle d’outil Claude simplifiée sur Lambda

L’exemple ci-dessous utilise Node.js 22, TypeScript, AWS Lambda, Amazon Bedrock et Claude, avec Promise.withResolvers() au cœur du processus. Le flux de demande est :

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

Considérez ce code comme un schéma du flux de contrôle, et non comme une intégration directe avec Bedrock ; les notes indiquent où le code de production doit différer.

Étape 1 : installer le client du runtime Bedrock

Le paquet SDK d’AWS pour Bedrock Runtime fournit le client ainsi que les classes de commande :

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

Étape 2 : importer le client et le créer

Importez le client, la commande d’appel ainsi que le type d’exception de service utilisé pour le traitement des erreurs :

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

Ensuite, instanciez le client dans la région où vous avez accès au modèle :

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

Étape 3 : créer le résolveur à l’intérieur du gestionnaire

Dans le gestionnaire Lambda, créez une promesse dédiée au résultat de l’outil :

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

Cela donne au gestionnaire trois objets de gestion avec des rôles clairement distincts :

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

Un autre callback résoudra finalement toolPromise. Créez-le à l’intérieur du gestionnaire, et non au niveau du module : Lambda réutilise des environnements d’exécution prêts à l’emploi, et une promesse au niveau du module déjà résolue ferait fuir le résultat d’une requête vers la suivante.

Étape 4 : décrire la requête et l’outil

La demande contient le message de l’utilisateur ainsi qu’une déclaration de l’outil searchKnowledgeBase, y compris un JSON Schema pour son unique argument 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 définition de l’outil indique à Claude qu’il peut demander cette fonction lorsqu’il a besoin d’informations externes :

searchKnowledgeBase

Vérifiez le format du payload par rapport à la documentation actuelle de Bedrock avant de l’utiliser. Avec InvokeModel, les modèles d’Anthropic attendent le format Anthropic Messages, qui inclut un champ anthropic_version et max_tokens, ainsi que la déclaration des outils dans un tableau tools accompagné de input_schema. La structure toolConfig présentée ici appartient à l’API Converse distincte de Bedrock ; choisissez donc une API et suivez son schéma.

Étape 5 : invoquer le modèle

Enveloppez le payload dans une commande contenant l’ID du modèle et le type de contenu JSON :

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

Envoyez-le et transformez une appel échoué en réponse 502, en utilisant le message de l’exception Bedrock si 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}`
    })
  };
}

Pour une sortie en flux, Bedrock dispose d’opérations dédiées (InvokeModelWithResponseStreamCommand ou ConverseStream pour l’API Converse) ; la commande simple InvokeModelCommand renvoie l’ensemble du corps en une seule fois. L’étape suivante suppose donc une version en flux.

Étape 6 : détecter la demande d’outil

Le gestionnaire examine les fragments reçus pour déterminer si Claude a demandé l’outil. Dans cette version simplifiée, il cherche le nom de l’outil dans le texte brut, extrait l’argument à l’aide d’une expression régulière et exécute l’outil :

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 ligne à examiner relie directement la promesse propre de l’outil au résolveur créé à l’étape 3 :

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

Aucune promesse d’encapsulation supplémentaire n’est nécessaire pour exposer le résultat, car les fonctions de règlement existent déjà. Cependant, la comparaison de chaînes dans des blocs bruts est fragile : une appel à outil peut être divisé en plusieurs blocs, et le format des arguments ne correspondra pas de manière fiable à une expression régulière de ce type. Le code réel doit parser les événements du flux structuré et accumuler les entrées de l’outil jusqu’à ce que le bloc soit complet. Il faut également gérer le cas où le modèle se termine sans jamais demander d’outil ; sinon, toolPromise ne sera jamais résolu.

Étape 7 : attendre l’outil

Lorsque l’outil est en cours d’exécution, le gestionnaire attend la promesse et renvoie un code 500 si l’outil échoue :

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

C’est le cœur du schéma. Le code d’attente ne sait pas d’où proviendra le résultat ; il se contente de s’assurer que quelqu’un appellera finalement l’une de ces fonctions :

resolve(toolResult)
reject(error)

Étape 8 : renvoyer le résultat de l’outil à Claude

Lorsque l’outil a terminé, le résultat est renvoyé au modèle dans une demande ultérieure. Conceptuellement, il contient le tour de l’assistant ainsi que la sortie de l’outil :

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

Ensuite, Bedrock est à nouveau invoqué avec ce payload supplémentaire :

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 peut maintenant écrire sa réponse finale. La structure du message est à titre illustratif : dans le format Anthropic Messages, le tour de l’assistant comprend un bloc de contenu tool_use, et le résultat est envoyé dans un message user sous forme de bloc tool_result faisant référence à l’ID de ce bloc, plutôt que sous la forme d’un rôle tool distinct.

Le cycle dans son ensemble

Dans leur globalité, les composants de l’architecture se présentent comme ceci :

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

Promise.withResolvers() constitue le point de transition entre l’exécution de l’outil et la poursuite du boucle :

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

Un outil de simulation pour les tests

Afin de tester le flux sans backend réel, la recherche dans la base de connaissances peut être simulée par un bref délai :

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

En production, la même fonction peut appeler n’importe lequel de ces éléments :

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

Le seul critère important est que l’outil retourne une promesse.

Protection contre les outils qui ne se terminent jamais

Les outils externes peuvent planter ou disparaître. Si un outil ne se résout jamais, cette ligne attend que Lambda lui-même expire :

await toolPromise;

Un enveloppeur de délai imparti fixe une limite en comparant la promesse avec un chronomètre et en annulant ce dernier selon le résultat obtenu par la promesse :

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

Le résultat de l’outil est ensuite attendu avec une limite de deux secondes :

const toolResult =
  await withTimeout(
    toolPromise,
    2000
  );

C’est l’encapsuleur qui empêche votre code d’attendre, et non l’outil lui-même : la requête continue de s’exécuter à moins que vous ne lui transmettiez également un AbortSignal pour l’annuler.

Gestion intentionnelle des erreurs Bedrock

Distinguez les différents types d’échecs. L’exemple associe le throttling à un code 429, les autres erreurs des services Bedrock à un code 502, et relance immédiatement tout élément inattendu :

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

Réessayer les appels soumis à un throttling avec backoff

Le throttling est souvent temporaire, ce qui en fait un candidat approprié pour une tentative de réessai. Ce outil d’aide tente jusqu’à trois fois, en attendant un peu plus de temps après chaque tentative bloquée, et relance immédiatement tous les autres types d’erreurs :

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

Ne tentez de relancer que les erreurs pour lesquelles c’est sécurisé ; relancer un problème de permissions ne produit que trois échecs identiques. Le délai augmente de manière linéaire dans ce cas, et l’ajout d’un léger jitter aide lorsque de nombreuses invocations sont limitées en même temps.

Soutien aux environnements sans withResolvers()

Dans un environnement ne disposant pas de cette méthode, un petit outil d’aide offre la même structure. Il commence par déclarer la fonction générique :

function createDeferred<T>() {

À l’intérieur, il déclare les fonctions de règlement à l’aide d’assertions d’affectation définie, les capture depuis un constructeur normal et renvoie les trois ensemble :

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

L’utilisation est identique à celle de l’API native :

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

Lorsque l’environnement prend en charge nativement Promise.withResolvers(), préférez-le et omettez l’outil d’aide.

Ce que withResolvers() ne résout pas

Cette méthode simplifie la création d’une promesse et rend ses fonctions de règlement accessibles en dehors de l’exécuteur. Elle ne fait rien concernant :

  • les conditions de concurrence
  • les appels multiples et simultanés aux outils
  • la cancellation
  • les délais d’expiration
  • la protection contre un règlement double
  • le nettoyage des ressources
  • la gestion correcte des résultats en flux du modèle
  • l’autorisation d’accès aux outils
  • la politique de tentative

Chacun de ces aspects doit encore être conçu explicitement. Une chaîne comme celle ci-dessous, sans limite sur le nombre d’outils que le modèle peut appeler successivement, constitue une architecture médiocre, quel que soit le soin apporté à l’écriture des promesses :

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

Un cycle d’agent nécessite des limites strictes. Le guide du blog sur les cycles d’agent limités en TypeScript aborde ces limites plus en détail.

Pourquoi ce modèle conserve sa pertinence

L’orchestration des agents traverse de nombreuses frontières asynchrones entre la sortie du modèle et sa poursuite :

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

Avec des constructeurs imbriqués, il est difficile de suivre ce flux. withResolvers() en offre une séquence lisible :

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

Liste de contrôle pour la production

Vérifier les arguments des outils

Considérer les arguments générés par le modèle comme des entrées non fiables. Vérifier au moins :

Types
Required fields
String lengths
Allowed values
Authorization
Business rules

Limiter l’exécution des outils

Imposer des plafonds explicites pour :

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

Rendre la boucle observable

Enregistrer des métriques et des traces pour :

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

Appliquer le principe du moindre privilège et restreindre les outils

Accordez au rôle d’exécution Lambda uniquement les permissions nécessaires à ses outils, et ne lui donnez jamais un accès illimité à votre compte AWS ou à vos systèmes internes ; exposez plutôt des opérations petites et bien définies.

Choix entre withResolvers() et new Promise()

Le constructeur conserve les résolveurs à l’intérieur de l’exécuteur, fonctionne sur tous les environnements d’exécution et convient aux opérations asynchrones ordinaires, mais peut imposer un enchaînement supplémentaire dans le code d’orchestration. withResolvers() renvoie à la fois la promesse et les résolveurs, ce qui convient aux cas où le règlement a lieu ailleurs, au prix de la nécessité d’un environnement d’exécution qui le prend en charge. Rien dans ce texte ne signifie pour autant que chaque new Promise() doive être utilisé. Lorsqu’une opération convient naturellement à cette forme, conservez-la :

return new Promise(...)

Préférez withResolvers() lorsque la création et le règlement sont séparés.

Points clés

Au lieu de cacher la logique à l’intérieur d’un constructeur comme ceci :

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

vous pouvez créer les éléments au préalable :

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

et structurer le flux en une séquence claire :

Promise creation
       ↓
Asynchronous tool execution
       ↓
resolve / reject
       ↓
Continue agent loop
  • withResolvers() convient aux points d’arrêt et de reprise d’un cycle d’agent, où une fonction de rappel produit un résultat et d’autres codes attendent ce dernier.
  • Créez des résolveurs par demande à l’intérieur du gestionnaire, et assurez-vous que chaque chemin résout la promesse, y compris celui où aucune fonction n’est appelée.
  • Respectez strictement les formats de charge utile de l’API Bedrock que vous avez choisie ; ceux présentés ici sont simplifiés.
  • Les délais d’expiration, les tentatives de réessai sélectives, la validation, le principe du moindre privilège, l’observabilité et les limites d’itération doivent encore être ajoutés explicitement.

Un agent n’est fiable que dans la mesure où le système d’interaction asynchrone associé au modèle est performant, ce qui compte plus que des instructions de type prompting sophistiquées. Utilisez withResolvers() lorsque cela rend ce système plus lisible, et ajoutez les mesures de sécurité qu’il ne peut pas fournir.

Lectures complémentaires

  • Construire une API GraphQL sécurisée par le type avec Prisma et Nexus dans Node.js — Suivez une démarche en sept étapes pour créer une API GraphQL Node.js qui unifie le modèle de données Prisma avec les types et résolveurs générés par Nexus.
  • Intégrer des outils MCP dans un UI de chat React avec validation humaine intégrée — Découvrez comment le Model Context Protocol s’intègre à une application React : pourquoi le backend doit héberger MCP, comment fonctionne un serveur d’outils, et comment diffuser et valider les appels aux outils dans l’UI.
  • Déplacer un projet Node.js vers Bun : Internes du runtime, Lambda et migration — Comprendre ce que Bun remplace dans une chaîne d’outils Node.js, pourquoi il démarre rapidement, comment il exécute TypeScript et sur AWS Lambda, ainsi que la manière de migrer un projet étape par étape.