Koordynacja wywołań narzędzi LLM w Node.js za pomocą Promise.withResolvers()
Zobacz, jak Promise.withResolvers() rozwiązuje problem koordynacji wywołań narzędzi w Node.js Lambda przy korzystaniu z Claude na Bedrock, a także jakie problemy z czasem reakcji, ponownymi próbami i ograniczeniami on nie rozwiązuje.
Gdy model językowy będzie mógł wywoływać narzędzia, twoja aplikacja musi wstrzymać rozmowę podczas wykonywania zapytania do bazy danych lub wezwania API, a następnie wznowić ją z wynikiem. Ten przewodnik pokazuje, w jaki sposób Promise.withResolvers() wyraża to wstrzymywanie i wznowienie jaśniej niż ręcznie tworzone konstruktorzy obietnic, omawia uproszczony cykl narzędzi Claude w AWS Lambda i Amazon Bedrock oraz wymienia środki ochronne, których API nie zapewnia.
Dlaczego wywoływanie narzędzi staje się problemem orkiestracji
Zapytanie wykorzystujące narzędzie przechodzi przez kilka asynchronicznych etapów, zanim użytkownik zobaczy odpowiedź:
User
↓
Claude
↓
Tool call
↓
External API / Database
↓
Tool result
↓
Claude
↓
Final response
Jedna część programu czeka, podczas gdy druga wykonywa pracę, a następnie oryginalny bieg programu kontynuuje się z uzyskanym wynikiem. Tradycyjnie oznacza to użycie nawarstwionych konstruktorów Promise oraz funkcji resolve/reject, które muszą być ręcznie przechwytywane i przekazywane dalej. Współczesne środowiska wykonawcze, w tym obecny Node.js, oferują prostsze rozwiązanie:
Promise.withResolvers()
Co zwraca Promise.withResolvers()
Klasyczny konstruktor dostarcza jedynie funkcje rozstrzygania wewnątrz callbacka executora:
const promise = new Promise((resolve, reject) => {
// asynchronous work
});
Rozstrzyganie sprawy z innego miejsca oznacza przemycenie funkcji resolve i reject poza obszar executora. Promise.withResolvers() dostarcza wszystkie trzy elementy jednocześnie:
const {
promise,
resolve,
reject
} = Promise.withResolvers();
Każda wartość ma jeden konkretny zadanie. Pierwsza z nich jest tym, na co czekają wywołujący:
promise → the promise you await
Dwa pozostałe rozwiązują tę sytuację – albo poprzez wartość, albo przez błąd:
resolve → completes the promise successfullyreject → completes the promise with an error
Wynika to wtedy, gdy kod generujący wynik jest oddzielony od kodu, który na niego czeka, np. gdy obsługa zdarzeń uruchamia się w nieprzewidywalnym momencie.
Gdzie klasyczny konstruktor staje się niewygodny
Zwykłe wywołanie narzędzia w ramach konstruktora wygląda nieszkodliwie:
function callTool(request) {
return new Promise((resolve, reject) => {
executeTool(request)
.then(resolve)
.catch(reject);
});
}
Nie ma z tym żadnego problemu; jest to nawet zbędne, ponieważ executeTool już zwraca obietnicę. Jednak prawdziwe pętle agenta zajmują się o wiele większą liczbą zadań:
- wyświetlanie wyników modelu w formie strumienia
- wykrywanie momentu, gdy model prosi o użycie narzędzia
- uruchamianie narzędzia
- zwroty do bazy danych i API
- próby ponowne
- timeouty
- obsługa błędów
- wielokrotne niezależne callbacki
Niedługo funkcje resolve i reject będą przechodzić przez kilka warstw, jak w tej zagnieżdżonej wersji:
function runAgent(request) {
return new Promise((resolve, reject) => {
invokeModel(request)
.then(response => {
executeTool(response)
.then(result => {
resolve(result);
})
.catch(reject);
})
.catch(reject);
});
}
To działa, ale śledzenie sukcesów i niepowodzeń wymaga przeczytania każdego poziomu. Dzięki withResolvers() obietnica oraz funkcje jej realizacji pochodzą z jednego wyrażenia i mogą być używane niezależnie:
const {
promise,
resolve,
reject
} = Promise.withResolvers();
Oto mały przykład, w którym funkcja pobiera użytkownika i realizuje stworzoną zewnętrznie obietnicę, podczas gdy wywołujący po prostu na nią czeka:
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;
W tak prostym przypadku zwrócenie użytkownika z fetchUser() byłoby równie jasne; chodzi tu o strukturę rozwiązania. withResolvers() nie sprawia, że cokolwiek działa szybciej. Daje jedynie czystszy sposób na wyrażenie koordynacji, gdy miejsce tworzenia obietnicy i miejsce jej realizacji nie są to samo.
Jak to odpowiada pętli agenta
Załóżmy, że użytkownik pyta o nowości w jednym z wewnętrznych produktów firmy. Aby odpowiedzieć, Claude może najpierw poprosić o narzędzie do wyszukiwania:
Claude
↓
Function call
↓
searchKnowledgeBase()
↓
Database/API
↓
Tool result
↓
Claude
↓
Final response
Kod musi poczekać na ten wynik przed kontynuowaniem, a obietnica ustalona z zewnątrz naturalnie pasuje do tego momentu oczekiwania.
Spośród uproszczonego pętli narzędzi Claude w Lambda
Poniższy przykład wykorzystuje Node.js 22, TypeScript, AWS Lambda, Amazon Bedrock oraz Claude, przy czym w centrum znajduje się Promise.withResolvers(). Przepływ zapytań jest następujący:
HTTP Request
↓
AWS Lambda
↓
Claude via Bedrock
↓
Claude requests tool
↓
Lambda executes tool
↓
Tool result
↓
Claude
↓
Final response
Traktuj kod jako szkic przepływu sterowania, a nie gotową integrację z Bedrockiem; notatki wskazują, gdzie kod produkcyjny musi się różnić.
Krok 1: zainstaluj klienta środowiska Bedrock Runtime
Pakiet AWS SDK dla Bedrock Runtime dostarcza klienta oraz klasy do wykonywania poleceń:
npm install @aws-sdk/client-bedrock-runtime
Krok 2: imporcie klienta i jego utworzenie
Importuj klienta, polecenie invoke oraz typ wyjątku usługi używany do obsługi błędów:
import {
BedrockRuntimeClient,
InvokeModelCommand,
BedrockRuntimeServiceException,
} from "@aws-sdk/client-bedrock-runtime";
Następnie utwórz instancję klienta w obszarze, w którym masz dostęp do modelu:
const client = new BedrockRuntimeClient({
region: "us-east-1",
});
Krok 3: utwórz resolver wewnątrz obsługi
Wewnątrz obsługi Lambda utwórz obietnicę przeznaczoną do przechowywania wyniku narzędzia:
const {
promise: toolPromise,
resolve,
reject
} = Promise.withResolvers();
To daje obsłudze trzy elementy sterujące o wyraźnie rozdzielonych rolach:
toolPromise → waits for the tool result
resolve() → supplies the tool result
reject() → reports a tool failure
Jakiś inny callback ostatecznie rozstrzygnie toolPromise. Utwórz go wewnątrz obsługi, a nie na poziomie modułu: Lambda ponownie wykorzystuje już przygotowane środowiska wykonawcze, a obietnica na poziomie modułu, która została już rozstrzygnięta, mogłaby spowodować wyciek wyniku jednej prośby do następnej.
Krok 4: opisz żądanie i narzędzie
Zapytanie zawiera wiadomość użytkownika oraz deklarację narzędzia searchKnowledgeBase, w tym JSON Schema dla jego jedynego argumentu 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
});
Definicja narzędzia informuje Claude, że może ono żądać tej funkcji, gdy potrzebuje informacji z zewnątrz:
searchKnowledgeBase
Zanim użyjesz tego formatu, sprawdź go wobec aktualnej dokumentacji Bedrock. Przy użyciu InvokeModel modele Anthropic oczekują formatu Anthropic Messages, który zawiera pole anthropic_version i max_tokens, a także deklaruje narzędzia w tablicy tools wraz z input_schema. Struktura toolConfig pokazana tutaj należy do odrębnej API Converse Bedrock, więc wybierz jedną z tych API i postępuj zgodnie z jej schematem.
Krok 5: wywołanie modelu
Otocz treść zapytania poleceniem zawierającym identyfikator modelu oraz typ danych JSON:
const command = new InvokeModelCommand({
modelId: "your-model-id",
contentType: "application/json",
accept: "application/json",
body: Buffer.from(prompt),
});
Wyślij to i przekształć nieudaną próbę wywołania w odpowiedź 502, używając komunikatu z wyjątku Bedrock, jeśli jest dostępny:
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}`
})
};
}
Dla wyjścia strumieniowego Bedrock oferuje dedykowane operacje (InvokeModelWithResponseStreamCommand lub ConverseStream dla API Converse); zwykły InvokeModelCommand zwraca całe ciało odpowiedzi od razu. Kolejny krok zakłada wariant strumieniowy.
Krok 6: wykrycie żądania narzędzia
Obsługa sprawdza przychodzące fragmenty tekstu, aby stwierdzić, czy Claude poprosił o użycie narzędzia. W tej uproszczonej wersji szuka nazwy narzędzia w surowym tekście, wyodrębnia argument za pomocą regularnych wyrażeń i uruchamia narzędzie:
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);
}
});
Linia, na którą należy zwrócić uwagę, łączy sam obietnicę narzędzia bezpośrednio z rozwiązywaczem utworzonym w kroku 3:
mockSearchKnowledgeBase(query)
.then(resolve)
.catch(reject);
Nie jest potrzebna żadna dodatkowa obietnica otaczająca, aby uzyskać wynik, ponieważ funkcje rozliczeniowe już istnieją. Jednak porównywanie ciągów znaków w surowych fragmentach jest nietrwałe: wywołanie narzędzia może zostać rozdzielone między fragmenty, a format argumentu nie będzie w sposób pewny pasował do takiej regularnej wyrażenia. Prawdziwy kod powinien analizować strukturalne zdarzenia strumienia i gromadzić dane wejściowe narzędzia, aż blok zostanie ukończony. Musisz również obsłużyć sytuację, gdy model zakończy pracę bez żadnego wywołania narzędzia; w przeciwnym razie toolPromise nigdy się nie rozstrzygnie.
Krok 7: oczekiwanie na narzędzie
Gdy narzędzie jest w trakcie działania, obsługa czeka na obietnicę i zwraca błąd 500, jeśli narzędzie zawiedzie:
let toolResult;
try {
toolResult =
await toolPromise;
} catch (error) {
return {
statusCode: 500,
body: JSON.stringify({
error: `Tool failed: ${error}`
})
};
}
To jest istota tego wzorca. Kod oczekujący nie ma pojęcia, skąd pochodzi wynik; interesuje go tylko to, że ktoś w końcu wywoła jedno z tych:
resolve(toolResult)
reject(error)
Krok 8: zwrócenie wyniku narzędzia do Claude
Gdy narzędzie zakończy pracę, wynik jest przekazywany z powrotem do modelu w kolejnej prośbie. Koncepcyjnie zawiera on runda asystenta oraz wynik działania narzędzia:
const followUp = JSON.stringify({
messages: [
{
role: "assistant",
content: "Calling tool..."
},
{
role: "tool",
name: "searchKnowledgeBase",
content: JSON.stringify(toolResult)
}
],
stream: true
});
Następnie Bedrock jest ponownie wywoływany z tym dodatkowym ciężarem danych:
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 może teraz napisać swoją ostateczną odpowiedź. Kształt wiadomości jest tu jedynie ilustracyjny: w formacie Anthropic Messages runda asystenta zawiera blok treści tool_use, a wynik jest wysyłany w wiadomości user jako blok tool_result odnoszący się do ID tego bloku, zamiast jako oddzielna rola tool.
Cały pętla
W całości architektura wygląda w ten sposób:
┌─────────────┐
│ User │
└──────┬──────┘
│
▼
┌─────────────┐
│ Lambda │
└──────┬──────┘
│
▼
┌─────────────┐
│ Claude │
│ Bedrock │
└──────┬──────┘
│
Tool request
│
▼
┌─────────────┐
│ Tool │
└──────┬──────┘
│
Tool result
│
▼
┌─────────────┐
│ Claude │
└──────┬──────┘
│
▼
┌─────────────┐
│ User │
└─────────────┘
Promise.withResolvers() stanowi punkt przeniesienia między wykonywaniem narzędzia a kontynuacją pętli:
Tool starts
│
▼
resolve(result)
│
▼
await toolPromise
│
▼
Continue agent loop
Sztuczne narzędzie do testowania
Aby przetestować proces bez rzeczywistego backendu, wyszukiwanie w bazie wiedzy można symulować za pomocą krótkiego opóźnienia:
function mockSearchKnowledgeBase(
query: string
): Promise<{ answer: string }> {
return new Promise((resolve) => {
setTimeout(() => {
resolve({
answer:
`Results for "${query}" (mocked).`
});
}, 300);
});
}
W produkcji ta sama funkcja może wywołać dowolne z tych rozwiązań:
DynamoDB
OpenSearch
RDS
S3
REST API
Internal service
Vector database
Knowledge base
Jedynym istotnym warunkiem jest to, aby narzędzie zwracało obietnicę.
Ochrona przed narzędziami, które nigdy się nie zakończą
Narzędzia zewnętrzne mogą utknąć lub zniknąć. Jeśli narzędzie nigdy nie osiągnie stanu ukończenia, ta linia czeka, aż sama Lambda wygaśnie:
await toolPromise;
Obwój z ograniczeniem czasowym ustala górny limit poprzez porównywanie obietnicy z timerem i wyłączanie timera niezależnie od tego, w jaki sposób obietnica zostanie zrealizowana:
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);
}
);
}
);
}
Następnie wynik narzędzia jest oczekiwany z ograniczeniem dwóch sekund:
const toolResult =
await withTimeout(
toolPromise,
2000
);
To wrapper zapobiega czekaniu kodu, a nie sam narzędzie: zapytanie nadal jest wykonywane, chyba że przekażesz mu również AbortSignal i je anulujesz.
Celowane radzenie sobie z błędami Bedrock
Rozróżnij różne rodzaje niepowodzeń. Przykład przyporządkowuje ograniczenia wydajności do kodu 429, inne błędy usług Bedrock do kodu 502, a wszystko nieoczekiwane ponownie rzuca jako błąd:
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;
}
Ponawianie prób po ograniczeniach wydajności z opóźnieniem
Ograniczenia wydajności są często tymczasowe, więc stanowią sensowny kandydat do ponownej próby. Ten pomocnik próbuje maksymalnie trzy razy, czekając nieco dłużej po każdej nieudanej próbie, a pozostałe błędy natychmiast ponownie rzuca jako błąd:
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."
);
}
Nie próbuj ponownie obsłużyć błędów, których nie można bezpiecznie powtarzać; ponawianie prób rozwiązania problemu uprawnień prowadzi jedynie do trzech identycznych niepowodzeń. Opóźnienie rośnie tu liniowo, a dodanie losowych odchyleń pomaga w sytuacjach, gdy wiele wywołań jest ograniczanych jednocześnie.
Obsługa środowisk bez withResolvers()
W środowisku, w którym brakuje tej metody, mały pomocnik zapewnia taką samą strukturę. Zaczyna się od deklaracji funkcji generycznej:
function createDeferred<T>() {
W jej wnętrzu deklaruje się funkcje rozstrzygania z użyciem założeń typu definite-assignment, pobiera je z standardowego konstruktora i zwraca wszystkie trzy razem:
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
};
}
Sposób użycia jest identyczny jak w przypadku natywnej API:
const {
promise,
resolve,
reject
} = createDeferred<Result>();
Gdy środowisko obsługuje Promise.withResolvers() natywnie, należy go preferować i pominąć pomocnik.
Czego withResolvers() nie rozwiązuje
Metoda upraszcza proces tworzenia obietnic i umożliwia dostęp do funkcji ich realizacji poza executorem. Nie rozwiązuje jednak następujących problemów:
- sytuaacji konkurencji
- wielokrotnych jednoczesnych wywołań narzędzi
- anulowania operacji
- timeout’ów
- zapobiegania dwukrotnej realizacji obietnicy
- oczyszczaniu zasobów
- właściwego obsługiwania danych w formie strumienia
- upoważniania do korzystania z narzędzi
- polityki ponawiania prób
Każdy z tych aspektów musi być nadal projektowany w sposób wyraźny. Łańcuch takie jak ten poniżej, bez ograniczeń co do liczby narzędzi, które model może kolejno wywołać, stanowi słabą architekturę, niezależnie od tego, jak dobrze są napisane obietnice:
Claude
↓
Tool A
↓
Tool B
↓
Tool C
↓
Unbounded execution
Pętla agenta wymaga sztywnych ograniczeń. Przewodnik bloga na temat ograniczonych pętli agentowych w TypeScript omawia te ograniczenia bardziej szczegółowo.
Dlaczego ten wzorzec nadal ma swoje zastosowanie
Orkiestracja agentów przechodzi przez wiele asynchronicznych granic pomiędzy wynikiem modelu a jego kontynuacją:
Model response
↓
Stream event
↓
Tool detection
↓
Tool execution
↓
Database
↓
Tool result
↓
Model continuation
Z użyciem zagnieżdżonych konstruktorów trudno jest prześledzić ten tok. Funkcja withResolvers() zapewnia czytelną sekwencję działań:
Create promise
↓
Expose resolver
↓
Start asynchronous operation
↓
Resolve when result arrives
↓
Await result
↓
Continue agent loop
Lista kontrolna w produkcji
Walidacja argumentów narzędzia
Traktuj argumenty wygenerowane przez model jako niezaufane dane wejściowe. Sprawdź przynajmniej:
Types
Required fields
String lengths
Allowed values
Authorization
Business rules
Ograniczenie wykonywania narzędzia
Ustal wyraźne granice dla:
Maximum tool calls
Maximum execution time
Maximum model iterations
Maximum response size
Zapewnienie widoczności pętli
Zapisuj metryki i ślady dla:
Lambda duration
Bedrock latency
Tool latency
Tool failures
Throttling
Token usage
Agent iterations
Timeouts
Zastosowanie zasady najmniejszych uprawnień i ograniczenie funkcjonalności narzędzi
Nadaj roli wykonywania Lambda tylko uprawnienia niezbędne do działania jego narzędzi i nigdy nie dawaj modelowi nieograniczonego dostępu do twojego konta AWS czy systemów wewnętrznych; zamiast tego udostępniaj małe, dobrze zdefiniowane operacje.
Wybór między withResolvers() a new Promise()
Konstruktor przechowuje elementy rozwiązywania wewnątrz wykonawcy, działa we wszystkich środowiskach działania i nadaje się do zwykłych operacji asynchronicznych, ale może wymuszać dodatkowe nawiasy w kodzie orkiestracji. withResolvers() zwraca obietnicę wraz z elementami rozwiązywania i nadaje się do przypadków, gdy rozstrzygnięcie następuje gdzie indziej, przy czym wymaga środowiska działania, które je obsługuje. To nie oznacza jednak, że każda implementacja powinna wykorzystywać new Promise(). Gdy operacja naturalnie pasuje do tej formy, zachowaj ją:
return new Promise(...)
Użyj withResolvers(), gdy proces tworzenia i rozstrzygania jest oddzielony.
Główne wnioski
Zamiast chować logikę wewnątrz konstruktora w ten sposób:
new Promise((resolve, reject) => {
// deeply nested asynchronous logic
});
można najpierw stworzyć poszczególne elementy:
const {
promise,
resolve,
reject
} = Promise.withResolvers();
i ułożyć przepływ w postaci jasnej sekwencji:
Promise creation
↓
Asynchronous tool execution
↓
resolve / reject
↓
Continue agent loop
withResolvers()pasuje do punktów pauzy i wznowienia w pętli agenta, gdzie jeden callback generuje wynik, a inny kod na niego czeka.- Należy tworzyć rozwiązania dla każdej prośby wewnątrz obsługi i upewnić się, że każda ścieżka realizuje obietnicę, włączając tę, w której nie wywołano żadnego narzędzia.
- Należy przestrzegać dokładnych formatów danych wejściowych wybranej API Bedrock; te pokazane tutaj są uproszczone.
- Czas wygaśnięcia, selektywne próby ponowne, walidacja, zasada najmniejszych uprawnień, możliwość obserwacji oraz limity iteracji nadal muszą być dodane wyraźnie.
Agent jest tak niezawodny, jak synchroniczna infrastruktura wspierająca model, co ma większe znaczenie niż sprytnie sformułowane polecenia. Użyj withResolvers(), gdy ułatwia to czytelność tej infrastruktury, oraz dodaj mechanizmy zabezpieczeń, których on nie może zapewnić.
Literatura pokrewna
- Ustawianie Prisma 7 z PostgreSQL w projekcie TypeScript Node.js — Naprawianie częstych błędów podczas konfiguracji Prisma 7 w TypeScript, od problemów z adresami URL typu string lub undefined po problemy z rootDir, oraz łączenie PostgreSQL za pomocą adaptera sterownika pg.