Koordination von LLM-Tool-Aufrufen in Node.js mit Promise.withResolvers()
Sehen Sie, wie Promise.withResolvers() die Orchestrierung von Tool-Aufrufen bei einem Node.js Lambda, das Claude auf Bedrock aufruft, entwirrt – sowie die Timeout-Einstellungen, Wiederholungsversuche und Grenzen, die es nicht abdeckt.
Sobald ein Sprachmodell Tools aufrufen kann, muss Ihre Anwendung das Gespräch pausieren, während eine Datenbankabfrage oder ein API-Aufruf läuft, und anschließend mit dem Ergebnis fortsetzen. Diese Anleitung zeigt, wie Promise.withResolvers() diese Pause und Wiederaufnahme klarer darstellt als selbst erstellte Promise-Konstrukte, erläutert einen vereinfachten Tool-Loop von Claude auf AWS Lambda und Amazon Bedrock sowie auflistet die Schutzmaßnahmen, die die API nicht für Sie bereitstellt.
Warum der Aufruf von Tools zu einem Orchestrierungsproblem wird
Eine Anfrage, die ein Tool verwendet, durchläuft mehrere asynchrone Schritte, bevor der Benutzer eine Antwort erhält:
User
↓
Claude
↓
Tool call
↓
External API / Database
↓
Tool result
↓
Claude
↓
Final response
Ein Teil des Programms wartet, während ein anderer die Arbeit erledigt, anschließend setzt sich der ursprüngliche Ablauf mit dem Ergebnis fort. Traditionell bedeutet das verschachtelte Promise-Konstruktoren sowie manuell erfasste und weitergegebene resolve/reject-Funktionen. Moderne Laufzeiten, einschließlich des aktuellen Node.js, bieten eine sauberere Lösung:
Promise.withResolvers()
Was Promise.withResolvers() zurückgibt
Der klassische Konstruktor liefert lediglich die Abwicklungsfunktionen innerhalb des Executor-Callbacks:
const promise = new Promise((resolve, reject) => {
// asynchronous work
});
Die Abwicklung von außen bedeutet, resolve und reject aus dem Executor herauszuschmuggeln. Promise.withResolvers() gibt Ihnen alle drei Komponenten auf einmal zur Verfügung:
const {
promise,
resolve,
reject
} = Promise.withResolvers();
Jeder Wert hat eine einzige Aufgabe. Der erste ist das, worauf die Aufrufer warten:
promise → the promise you await
Die anderen beiden klären die Situation – entweder mit einem Wert oder mit einem Fehler:
resolve → completes the promise successfullyreject → completes the promise with an error
Es kommt zum Tragen, wenn der Code, der ein Ergebnis erzeugt, vom Code getrennt ist, der auf dieses Ergebnis wartet – beispielsweise ein Ereignishandler, der zu einem unvorhersehbaren Zeitpunkt ausgelöst wird.
Wo der klassische Konstruktor unpraktisch wird
Die Aufrufung eines einfachen Tools innerhalb eines Konstruktors erscheint harmlos:
function callTool(request) {
return new Promise((resolve, reject) => {
executeTool(request)
.then(resolve)
.catch(reject);
});
}
Daran ist nichts falsch; es ist sogar überflüssig, da executeTool bereits eine Promise zurückgibt. Reale Agentenschleifen hingegen müssen jedoch viel mehr bewältigen:
- das Streamen der Modellausgabe
- die Erkennung, wenn das Modell ein Tool anfordert
- den Ausführung des Tools
- Datenbank- und API-Aufrufe
- Wiederholungsversuche
- Zeitlimits
- Fehlerbehandlung
- mehrere unabhängige Callbacks
Bald werden resolve und reject über mehrere Ebenen weitergeleitet, wie in dieser verschachtelten Version:
function runAgent(request) {
return new Promise((resolve, reject) => {
invokeModel(request)
.then(response => {
executeTool(response)
.then(result => {
resolve(result);
})
.catch(reject);
})
.catch(reject);
});
}
Es funktioniert, doch um Erfolge und Misserfolge nachzuvollziehen, muss man jede Ebene durchlesen. Mit withResolvers() stammen die Promise sowie deren Abwicklungsfunktionen aus einer einzigen Anweisung und können unabhängig voneinander verwendet werden:
const {
promise,
resolve,
reject
} = Promise.withResolvers();
Hier ist ein kleines Beispiel, in dem eine Funktion einen Benutzer abruft und die extern erstellte Promise abwickelt, während der Aufrufer einfach darauf wartet:
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;
In einem so einfachen Fall wäre es genauso klar, den Benutzer direkt aus fetchUser() zurückzugeben; es geht um die Struktur. withResolvers() macht nichts schneller. Es bietet lediglich eine sauberere Möglichkeit, Koordination auszudrücken, wenn der Ort, an dem die Promise erstellt wird, und der Ort, an dem sie abgewickelt wird, nicht derselbe sind.
Wie dies auf einen Agentenloop übertragen wird
Nehmen wir an, ein Benutzer fragt nach Neuigkeiten zu einem der internen Produkte des Unternehmens. Um zu antworten, kann Claude zunächst ein Suchwerkzeug anfordern:
Claude
↓
Function call
↓
searchKnowledgeBase()
↓
Database/API
↓
Tool result
↓
Claude
↓
Final response
Der Code muss auf dieses Ergebnis warten, bevor er fortfährt, und ein extern geregelter Promise eignet sich ideal für diesen Wartezeitpunkt.
Eine vereinfachte Claude-Tool-Schleife auf Lambda
Das untenstehende Beispiel verwendet Node.js 22, TypeScript, AWS Lambda, Amazon Bedrock und Claude, wobei Promise.withResolvers() im Mittelpunkt steht. Der Anfragenfluss ist wie folgt:
HTTP Request
↓
AWS Lambda
↓
Claude via Bedrock
↓
Claude requests tool
↓
Lambda executes tool
↓
Tool result
↓
Claude
↓
Final response
Betrachten Sie den Code als Skizze des Steuerflusses, nicht als direkte Bedrock-Integration; die Anmerkungen weisen darauf hin, wo sich der Produktivcode unterscheiden muss.
Schritt 1: Den Bedrock-Runtime-Client installieren
Das AWS SDK-Paket für Bedrock Runtime stellt den Client sowie die Befehlsklassen bereit:
npm install @aws-sdk/client-bedrock-runtime
Schritt 2: Den Client importieren und erstellen
Importieren Sie den Client, den Aufrufbefehl sowie den für die Fehlerbehandlung verwendeten Service-Exceptionstyp:
import {
BedrockRuntimeClient,
InvokeModelCommand,
BedrockRuntimeServiceException,
} from "@aws-sdk/client-bedrock-runtime";
Erstellen Sie anschließend eine Instanz des Clients in dem Bereich, in dem Sie auf das Modell zugreifen können:
const client = new BedrockRuntimeClient({
region: "us-east-1",
});
Schritt 3: Erstellen Sie den Resolver innerhalb des Handlers
Innerhalb des Lambda-Handlers erstellen Sie eine Promise, die speziell für das Ergebnis der Tool-Funktion bestimmt ist:
const {
promise: toolPromise,
resolve,
reject
} = Promise.withResolvers();
Dadurch erhält der Handler drei Handles mit klar getrennten Aufgaben:
toolPromise → waits for the tool result
resolve() → supplies the tool result
reject() → reports a tool failure
Ein anderer Callback wird letztendlich toolPromise abarbeiten. Erstellen Sie diesen innerhalb des Handlers und nicht im Modulbereich: Lambda wiederverwendet bereits vorbereitete Ausführungsumgebungen, und eine auf Modulebene erstellte Promise, die bereits abgearbeitet wurde, könnte das Ergebnis einer Anfrage in die nächste übertragen.
Schritt 4: Beschreiben Sie die Anfrage und das Tool
Die Anfrage enthält die Nachricht des Benutzers sowie eine Angabe zum Tool searchKnowledgeBase, einschließlich eines JSON Schemas für sein einziges query-Argument:
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
});
Die Tool-Definition teilt Claude mit, dass es diese Funktion anfordern darf, wenn externe Informationen benötigt werden:
searchKnowledgeBase
Überprüfen Sie vor der Verwendung das Format des Payloads gegen die aktuelle Bedrock-Dokumentation. Bei InvokeModel erwarten die Anthropic-Modelle das Anthropic Messages-Format, das ein anthropic_version-Feld sowie max_tokens enthält und die Tools in einem tools-Array mit input_schema definiert. Die hier gezeigte toolConfig-Struktur gehört zur separaten Converse API von Bedrock – wählen Sie daher eine API aus und befolgen Sie deren Schema.
Schritt 5: Das Modell aufrufen
Umhüllen Sie den Payload in einen Befehl mit einer Modell-ID und dem JSON-Inhaltstyp:
const command = new InvokeModelCommand({
modelId: "your-model-id",
contentType: "application/json",
accept: "application/json",
body: Buffer.from(prompt),
});
Senden Sie es und wandeln Sie einen fehlgeschlagenen Aufruf in eine 502-Antwort um, wobei die Nachricht der Bedrock-Exception verwendet wird, sofern verfügbar:
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}`
})
};
}
Für gestreamte Ausgaben bietet Bedrock spezielle Operationen (InvokeModelWithResponseStreamCommand oder ConverseStream für die Converse-API); die einfache InvokeModelCommand-Funktion gibt den gesamten Inhalt auf einmal zurück. Der nächste Schritt setzt voraus, dass es sich um eine gestreamte Variante handelt.
Schritt 6: Toolanfrage erkennen
Der Handler prüft die eingehenden Datenblöcke, um festzustellen, ob Claude nach einem Tool gefragt hat. In dieser vereinfachten Version sucht er nach dem Namen des Tools im Rohtext, extrahiert das Argument mit einer regulären Ausdrucks und ruft das Tool auf:
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);
}
});
Die wichtige Zeile verbindet die eigene Promise des Tools direkt mit dem in Schritt 3 erstellten Resolver:
mockSearchKnowledgeBase(query)
.then(resolve)
.catch(reject);
Es ist kein zusätzliches Wrapper-Promise erforderlich, um das Ergebnis bereitzustellen, da die Settlement-Funktionen bereits vorhanden sind. Das Abgleichen von Zeichenketten in rohen Blöcken ist jedoch anfällig: Ein Tool-Aufruf kann auf mehrere Blöcke verteilt werden, und das Argumentformat wird nicht zuverlässig mit einer solchen Regex übereinstimmen. Reiner Code sollte die strukturierten Stream-Ereignisse parsen und die Tool-Eingaben sammeln, bis der Block abgeschlossen ist. Zudem muss man den Fall handhaben, in dem das Modell ohne jeglichen Tool-Aufruf abgeschlossen wird; andernfalls wird toolPromise niemals abgeschlossen.
Schritt 7: Auf das Tool warten
Während das Tool läuft, wartet der Handler auf das Promise und gibt bei einem Fehler des Tools einen 500-Status zurück:
let toolResult;
try {
toolResult =
await toolPromise;
} catch (error) {
return {
statusCode: 500,
body: JSON.stringify({
error: `Tool failed: ${error}`
})
};
}
Das ist der Kern des Musters. Der Wartecod hat keine Ahnung, woher das Ergebnis stammen wird; es kümmert ihn nur darum, dass irgendwann einer dieser Aufrufe erfolgt:
resolve(toolResult)
reject(error)
Schritt 8: Ergebnis der Tool-Nutzung an Claude zurückgeben
Sobald das Tool abgeschlossen ist, wird das Ergebnis in einer nachfolgenden Anfrage an das Modell zurückgesendet. Konzeptionell enthält es den Teil des Assistenten sowie die Ausgabe des Tools:
const followUp = JSON.stringify({
messages: [
{
role: "assistant",
content: "Calling tool..."
},
{
role: "tool",
name: "searchKnowledgeBase",
content: JSON.stringify(toolResult)
}
],
stream: true
});
Dann wird Bedrock erneut mit der nachfolgenden Nutzlast aufgerufen:
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 kann nun seine endgültige Antwort verfassen. Auch hier dient die Nachrichtenstruktur nur zur Veranschaulichung: Im Anthropic Messages-Format enthält der Teil des Assistenten einen tool_use-Inhaltsblock, und das Ergebnis wird in einer user-Nachricht als tool_result-Block übermittelt, der auf die ID dieses Blocks verweist – anstatt als separater tool-Block.
Der gesamte Loop
Insgesamt sieht die Architektur so aus:
┌─────────────┐
│ User │
└──────┬──────┘
│
▼
┌─────────────┐
│ Lambda │
└──────┬──────┘
│
▼
┌─────────────┐
│ Claude │
│ Bedrock │
└──────┬──────┘
│
Tool request
│
▼
┌─────────────┐
│ Tool │
└──────┬──────┘
│
Tool result
│
▼
┌─────────────┐
│ Claude │
└──────┬──────┘
│
▼
┌─────────────┐
│ User │
└─────────────┘
Promise.withResolvers() dient als Übergabepunkt zwischen der Ausführung des Tools und der Fortsetzung der Schleife:
Tool starts
│
▼
resolve(result)
│
▼
await toolPromise
│
▼
Continue agent loop
Ein Mock-Tool zum Testen
Um den Ablauf ohne echten Backend zu testen, kann die Suche in der Wissensdatenbank durch eine kurze Verzögerung emuliert werden:
function mockSearchKnowledgeBase(
query: string
): Promise<{ answer: string }> {
return new Promise((resolve) => {
setTimeout(() => {
resolve({
answer:
`Results for "${query}" (mocked).`
});
}, 300);
});
}
In der Produktion könnte dieselbe Funktion beliebige dieser Optionen aufrufen:
DynamoDB
OpenSearch
RDS
S3
REST API
Internal service
Vector database
Knowledge base
Das einzige wichtige Kriterium ist, dass das Tool eine Promise zurückgibt.
Schutz vor Tools, die nie abgeschlossen werden
Externe Tools können hängen bleiben oder verschwinden. Wenn ein Tool nie abgeschlossen wird, wartet diese Zeile, bis Lambda selbst eine Zeitüberschreitung aufweist:
await toolPromise;
Ein Timeout-Wrapper setzt eine Obergrenze, indem er die Promise mit einem Timer vergleicht und den Timer unabhängig davon löscht, wie die Promise abgeschlossen wird:
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);
}
);
}
);
}
Anschließend wird das Ergebnis des Tools mit einer Frist von zwei Sekunden abgewartet:
const toolResult =
await withTimeout(
toolPromise,
2000
);
Der Wrapper verhindert, dass Ihr Code wartet – nicht das Tool selbst: Die Abfrage läuft weiter, es sei denn, Sie übergeben ihr auch einen AbortSignal und canceln sie.
Bewusste Handhabung von Bedrock-Fehlern
Unterscheiden Sie zwischen verschiedenen Arten von Fehlern. Das Beispiel ordnet Throttling einer 429-Kodierung zu, andere Bedrock-Service-Fehler einer 502-Kodierung und wirft alle unerwarteten Fehler erneut aus:
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;
}
Wiederholte Anrufe bei Throttling mit Backoff
Throttling ist oft vorübergehend, weshalb es ein geeigneter Kandidat für eine Wiederholung ist. Dieser Hilfsfunktion versucht es bis zu drei Mal, wartet nach jedem throttelten Versuch etwas länger und wirft alle anderen Fehler sofort erneut aus:
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."
);
}
Wiederholen Sie nur solche Fehler, bei denen ein erneuter Versuch sicher ist; der Wiederholungsversuch bei einem Berechtigungsproblem führt lediglich zu drei identischen Fehlern. Die Verzögerung steigt hier linear an, und das Hinzufügen zufälliger Schwankungen hilft, wenn viele Aufrufe gleichzeitig eingeschränkt werden.
Unterstützung von Laufzeiten ohne withResolvers()
In einer Umgebung, die diese Methode nicht bietet, stellt ein kleiner Hilfsfunktion dieselbe Struktur bereit. Zunächst wird die generische Funktion deklariert:
function createDeferred<T>() {
Darinnen werden die Abwicklungsfunktionen mithilfe von Assertionen zur definitiven Zuweisung deklariert, sie aus einem regulären Konstruktor abgerufen und alle drei zusammen zurückgegeben:
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
};
}
Die Verwendung ist identisch zur nativen API:
const {
promise,
resolve,
reject
} = createDeferred<Result>();
Wenn die Laufzeit Promise.withResolvers() nativ unterstützt, sollte diese verwendet werden und der Hilfsfunktion entfallen.
Was withResolvers() nicht löst
Diese Methode vereinfacht die Erstellung einer Promise und macht deren Abwicklungsfunktionen außerhalb des Executors verfügbar. Sie bewirkt jedoch nichts bezüglich:
- Rennbedingungen
- Mehrfacher, gleichzeitiger Aufrufe von Tools
- Absage von Aufgaben
- Zeitlimits
- Schutz vor doppeltem Abwickeln
- Räumung von Ressourcen
- Korrekter Umgang mit gestreamtem Modellausgabe
- Autorisierung von Tools
- Wiederholungspolitik
Jedes dieser Aspekte muss weiterhin explizit entworfen werden. Eine Kette wie die unten gezeigte, bei der es keine Begrenzung dafür gibt, wie viele Tools das Modell nacheinander aufrufen darf, stellt unabhängig davon eine schlechte Architektur dar, wie ordentlich die Promises auch formuliert sein mögen:
Claude
↓
Tool A
↓
Tool B
↓
Tool C
↓
Unbounded execution
Eine Agentenschleife benötigt feste Grenzen. Der Blog-Guide zu begrenzten Agentenschleifen in TypeScript behandelt diese Grenzen ausführlicher.
Warum dieses Muster weiterhin seine Berechtigung hat
Die Orchestrierung von Agenten überschreitet viele asynchrone Grenzen zwischen der Ausgabe des Modells und ihrer Fortsetzung:
Model response
↓
Stream event
↓
Tool detection
↓
Tool execution
↓
Database
↓
Tool result
↓
Model continuation
Mit verschachtelten Konstruktoren ist dieser Ablauf schwer nachvollziehbar. withResolvers() sorgt für eine verständliche Abfolge:
Create promise
↓
Expose resolver
↓
Start asynchronous operation
↓
Resolve when result arrives
↓
Await result
↓
Continue agent loop
Produktions-Checkliste
Argumente der Tools überprüfen
Betrachten Sie von dem Modell erzeugte Argumente als unzuverlässige Eingaben. Überprüfen Sie zumindest:
Types
Required fields
String lengths
Allowed values
Authorization
Business rules
Begrenzte Ausführung der Tools
Setzen Sie explizite Obergrenzen für:
Maximum tool calls
Maximum execution time
Maximum model iterations
Maximum response size
Die Schleife sichtbar machen
Erstellen Sie Metriken und Spuren für:
Lambda duration
Bedrock latency
Tool latency
Tool failures
Throttling
Token usage
Agent iterations
Timeouts
Mindestberechtigungen anwenden und Tools begrenzen
Geben Sie der Lambda-Ausführungsrolle nur die Berechtigungen, die ihre Tools benötigen, und gewähren Sie dem Modell niemals uneingeschränkten Zugriff auf Ihr AWS-Konto oder interne Systeme; stellen Sie stattdessen kleine, klar definierte Operationen zur Verfügung.
Auswahl zwischen withResolvers() und new Promise()
Der Konstruktor behält die Resolver im Executor, funktioniert in jedem Laufzeitumfeld und eignet sich für gewöhnliche asynchrone Operationen, kann aber in Orchestrierungskodern zu zusätzlicher Verkettung führen. withResolvers() gibt Promise und Resolver gemeinsam zurück und eignet sich für Fälle, in denen die Abwicklung an einem anderen Ort stattfindet – mit dem Nachteil, dass ein Laufzeitumfeld erforderlich ist, das dies unterstützt. Das bedeutet jedoch nicht, dass jeder new Promise()-Aufruf verwendet werden sollte. Wenn eine Operation von Natur aus in diese Form passt, behalten Sie sie bei:
return new Promise(...)
Wählen Sie withResolvers(), wenn Erstellung und Abwicklung getrennt sind.
Kernpunkte
Anstatt die Logik so in einen Konstruktor zu verbergen:
new Promise((resolve, reject) => {
// deeply nested asynchronous logic
});
kann man die Komponenten im Voraus erstellen:
const {
promise,
resolve,
reject
} = Promise.withResolvers();
und den Ablauf als klare Sequenz strukturieren:
Promise creation
↓
Asynchronous tool execution
↓
resolve / reject
↓
Continue agent loop
withResolvers()eignet sich für die Pausen- und Wiederaufnahmepunkte einer Agentenschleife, bei denen eine Callback-Funktion ein Ergebnis liefert und anderer Code darauf wartet.- Erstellen Sie Resolver pro Anfrage innerhalb des Handlers und stellen Sie sicher, dass jeder Pfad die Promise abarbeitet – auch derjenige, bei dem kein Tool aufgerufen wird.
- Befolgen Sie die genauen Payload-Formate der von Ihnen gewählten Bedrock-API; die hier gezeigten sind vereinfacht.
- Timeouts, selektive Wiederholungsversuche, Validierung, Prinzip der geringsten Berechtigungen, Überwachbarkeit sowie Iterationslimits müssen weiterhin explizit hinzugefügt werden.
Ein Agent ist nur so zuverlässig wie die asynchrone Infrastruktur rund um das Modell, was wichtiger ist als kluge Anweisungen. Verwenden Sie withResolvers(), wenn dadurch diese Infrastruktur leichter lesbar wird, und fügen Sie die Sicherheitsmaßnahmen hinzu, die das Modell nicht bieten kann.
Verwandte Artikel
- Prisma 7 in einem TypeScript Node.js-Projekt mit PostgreSQL einrichten — Beheben Sie häufige Einrichtungsfehler von Prisma 7 in TypeScript, von Zeichenketten- oder undefined-URLs bis hin zu Problemen mit rootDir, und verbinden Sie PostgreSQL über den pg-Adapter.