Коордынацыя вызоў інструментаў LLM у Node.js з аднойчынай Promise.withResolvers()
Пазірце, як Promise.withResolvers() разв’язвае прыблокаванне вызоў інструментаў у Node.js Lambda, які выклікае Claude на Bedrock, а таксама чыя таймауты, перапрыблокаванні і ліміты ён не пакрывае.
Калі модель языка можа вызваць інструменты, ваша прыкладна програма павінна на час выканання запиту да базы дадзеных або вызова API павернуць розмову, а потым продактуваць ёю з рэзультатам. У гіде показана, як Promise.withResolvers() дае болей чыстае выражэнне процесу павернення та продактування, чым ручныя канстрактары promise, прадставлены спрасцаваны цикл выкарыстоўвання інструмента Claude на AWS Lambda та Amazon Bedrock, а таксама пераказаны захаванні, якія API не забезпечвае для вас.
Чаму вызов інструмента стае проблемай оркестрацыі
Запрос, який выкарыстоўвае інструмент, праходзіць через калькі асінхронных крокаў, прычым ужо пасля гэтага корыстнік бачыць адпаведны адказ:
User
↓
Claude
↓
Tool call
↓
External API / Database
↓
Tool result
↓
Claude
↓
Final response
Адзін частка праграмы чакае, пакалі іншая выканае роботу, а потым пачатковы тэкст продовжваецца з рэзультатам. Традыцыйна гэта значыць викорыстоўванне вярнутых адносна ўсероўні Promise-канстрактараў і функцый resolve/reject, якія прыменяюцца вручную і перадаюцца далей. Сучасныя рантаймы, укладаючы нават чынны Node.js, праследжуюць болей чыстыя способы:
Promise.withResolvers()
Што вяртае Promise.withResolvers()
Класычны канстрактар дае вам толькі функцыі вырашэння ўнутры калбэка экзэкютара:
const promise = new Promise((resolve, reject) => {
// asynchronous work
});
Вырашэння з іншага месца значыць таямны перадачу resolve і reject за межы экзэкютара. Promise.withResolvers() дае вам усі тры элементы адразу:
const {
promise,
resolve,
reject
} = Promise.withResolvers();
Кожны значэнне мае адну задачу. Першы ёсць тое, на чым чакаюць вызывачы:
promise → the promise you await
Іншыя два варыянты рашуюць пытанне: з адпаведным значэнням або з адказам пра адказку:
resolve → completes the promise successfullyreject → completes the promise with an error
Гэта становіцца корыстным, калі код, які генеруе рэзультат, знаходзится околасці коду, які яго чакае, напрыклад, калі обрабоўчы запускаецца у неперакладзімы момент.
Дзе класычны канстракт стае некомфортным
Звычная вызов канцэльніка, які ўпакоўваецца ў канстракт, выглядае безнебяжна:
function callTool(request) {
return new Promise((resolve, reject) => {
executeTool(request)
.then(resolve)
.catch(reject);
});
}
У гэтым немаяць нічога пакладзенага; гэта нават зайвая дзеянне, адколі executeTool вэсьле вяртае прабаму. Аднак справжнія цыклы агентаў керуюць набагато большай колькасцю элементаў:
- выдача рэзультата моделі
- выяўленне моменту, калі модель запрашае канцэльнік
- запуск канцэльніка
- звяртання да базы дадзенаў і API
- перапрыбуткі
- таймауты
- обработка адказак пра адказку
- некалькі незалежных калебаў
Няўдзе resolve і reject праходзяць чераз калькі слоёў, як у гэтым варыянце з вложанасцю:
function runAgent(request) {
return new Promise((resolve, reject) => {
invokeModel(request)
.then(response => {
executeTool(response)
.then(result => {
resolve(result);
})
.catch(reject);
})
.catch(reject);
});
}
Гэта працуе, але для выявлення успеху чы розбіцьця трэба прачытаць кожны ранг. З withResolvers() праміс і функцыі яго выкарыстання выклікаюцца з адной запаведзі і можу быть выкарыстаны незалежна:
const {
promise,
resolve,
reject
} = Promise.withResolvers();
Ёсць маўкі прыклад, дзе функцыя запрашвае данні корыстніка і выкарыстоўвае створаны званэнне зза меж, а вызывач проста чакае на яго рэзультат:
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;
У такім простым случае вярнуць данні корыстніка з fetchUser() было б таксама зрозумела; сэрца прыбліжнае ў тым, як усё структуруюцца. withResolvers() нічога не прыяўлічвае ў швальнасці. Ён даёт чыстэйшы спосаб выражэння коордынаціі, калі месца стварэння праміса і месца яго выкарыстання не з’еднаны.
Як гэта адпавядае цыклу агента
Падзеймім, ўжоцьвярдзілы корыстунач спытаеся, што новага ў аднам з внутраніх продуктав компаніі. Чыракаць на гэты вопыт, Claude можа спачатку запрашаць інструмент для пошуку:
Claude
↓
Function call
↓
searchKnowledgeBase()
↓
Database/API
↓
Tool result
↓
Claude
↓
Final response
Код павінен чакаць гэты рэзультат прычымоўваючы да продажу, і promise, які быў створаны зза меж кода, ідеальна падходзіць для гэтага моменту чакання.
Спрасцаваны цыкл інструмента Claude на Lambda
У прыкладзе нижэй викорыстоўваюцца Node.js 22, TypeScript, AWS Lambda, Amazon Bedrock і Claude, прычымоўваючы Promise.withResolvers() як цэнтральны элемент. Прыем паказанаецца так:
HTTP Request
↓
AWS Lambda
↓
Claude via Bedrock
↓
Claude requests tool
↓
Lambda executes tool
↓
Tool result
↓
Claude
↓
Final response
Спрыяйце коду як эскізу прабегу кантролю, а не як готовай інтэграцыі з Bedrock; прымечанні паказваюць, дзе код для рэальных умов павінен адрозніцца.
Крок 1: інсталюйце кліента Bedrock runtime
Пакет AWS SDK для Bedrock Runtime з’яўляецца кліента і класы для выканання команд:
npm install @aws-sdk/client-bedrock-runtime
Крок 2: імпортуйце кліента і стварыце яго
Імпортуйце кліента, команду вызову і тип абяктва службы, які викорыстоўваюцца для обробкі абяктаў:
import {
BedrockRuntimeClient,
InvokeModelCommand,
BedrockRuntimeServiceException,
} from "@aws-sdk/client-bedrock-runtime";
Потым створыце экзанпляр кліента ў регіоне, дзе у вас є доступ да модэлю:
const client = new BedrockRuntimeClient({
region: "us-east-1",
});
Крок 3: створыце рэшар унутранік хендлера
Унутранік хендлера Lambda створыце праміс, прызначаны для рэзультата інструмента:
const {
promise: toolPromise,
resolve,
reject
} = Promise.withResolvers();
Это дае хендлеру тры кантроллеры з чыста разнымі ролямі:
toolPromise → waits for the tool result
resolve() → supplies the tool result
reject() → reports a tool failure
Іншы калбэк у канцы канцоў закінчыць toolPromise. Створыце яго унутранік хендлера, а не на рэвэле модуля: Lambda пераўтарытае выконвальныя сераўысы, і праміс на рэвэле модуля, які вялікі ўжо, могаў бы прасочыць рэзультат адной запыткі ў наступную.
Крок 4: апісаць запытку і інструмент
Запрос містыць паведамленне пользователя і адказ пра інструмент searchKnowledgeBase, укладзены ў формате JSON Schema для яго елемента 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
});
Узначэнне інструмента паведамляе Claude, што ён можа запрашваць гэты функцыя, калі йому трэба інфармацыя з зовнішніх джэрел:
searchKnowledgeBase
Перад викорыстоўванням пераканайцеся, што формат паданых дадзенняў супарадні з чыннымя інструкцыямі Bedrock. Калі викорыстоўваецца InvokeModel, моделі Anthropic чакаюць формату Anthropic Messages, які включае поле anthropic_version і max_tokens, а таксама адзначэнне інструментоў у масіве tools з параметрам input_schema. Формат toolConfig, паказаны тут, належыць да окранайчанага API Bedrock Converse, таму выберыце адны з API і следавайце яго схеме.
Шаг 5: запуск моделі
Закаменяйце паданыя дадзеныя ў команду з ідэнтыфікатарам моделі та типам кантэнта JSON:
const command = new InvokeModelCommand({
modelId: "your-model-id",
contentType: "application/json",
accept: "application/json",
body: Buffer.from(prompt),
});
Адрасуйце яго і ператворыце неудачны вызов у адказ 502, выкарыстоўваючы паведамленне аб асаблівасці Bedrock, калі яно ў наявнасці:
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}`
})
};
}
Для стрімавання выходных дадзеных Bedrock мае спецыяльныя функцыі (InvokeModelWithResponseStreamCommand або ConverseStream для API Converse); звычайны InvokeModelCommand вяртае всю масэвую частку заўтраўка. Наступны крок выконваецца за адлічэнням стрімаванага варыянта.
Крок 6: выкліканне запиту на інструмент
Обробнік пераглядае прыходзячыя часткі, каб выявіць, чы не запрашаў Claude інструмент. У гэтым спрасцаваным варыянте ён шукае назву інструмента ў необработаным тексте, выкарыстоўваючы рэгулярныя выразы, выдзеляе аргумент і запускае інструмент:
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);
}
});
Лінія, на якую трэба звернуць увагу, безпасяродна павязывае самае обяцанне інструмента з рашэйджерам, створаным у 3-му кроце:
mockSearchKnowledgeBase(query)
.then(resolve)
.catch(reject);
Няма патрэбы ў дадатковай обгорткавай задачы-прабаме для адкрыцья рэзультата, таму што функцыі вырахунку вже існуюць. Аднак пашук стрэлкі ў необработаных частках є нестабільным: вызов інструменту можа быць разбіты между часткамі, і формат аргументаў не будзе надзеямо падходзіць да такога рэгулярнага выраза. Справжні код павінен парсаваць структураваныя запускі падзеяў і накапліваць даны, якія вводзіцца ў інструмент, пакуль блок не будзе завершаны. Таксама трэба адмаўляцца ситуацыі, калі модель завершае роботу без якога-лібо запиту да інструмента; інакш toolPromise ніколі не будзе завершаны.
Шаг 7: чакаць на інструмент
Калі інструмент працюе, працоўнік чакае на задачу-прабаму і вяртае код 500, якщо інструмент зазнаў неудачы:
let toolResult;
try {
toolResult =
await toolPromise;
} catch (error) {
return {
statusCode: 500,
body: JSON.stringify({
error: `Tool failed: ${error}`
})
};
}
Гэта ўсё сэрца данага шаблону. Код, які чакае, не мае жаданняў пра тое, звядзе будзе рэзультат; яму важліва толькі тое, што хтось у канцы канцоў вызначыць адны з гэтых:
resolve(toolResult)
reject(error)
Шаг 8: вярнуць рэзультат адказвача да Claude
Калі адказвач завершыў свою роботу, рэзультат вяртаецца да моделі ў наступным запытэ. Канцэптуальна ён складаецца з часткі, прызначанай для адказвача, і выходных данных адказвача:
const followUp = JSON.stringify({
messages: [
{
role: "assistant",
content: "Calling tool..."
},
{
role: "tool",
name: "searchKnowledgeBase",
content: JSON.stringify(toolResult)
}
],
stream: true
});
Пасля чаго Bedrock зноў запускаецца з наступным пакетам дадзеных:
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 можа запісаць свой фінальны адказ. Формат паведамлення зноў є ілюстратыўным: у формате Anthropic Messages частка, прызначаная для адказвача, містіць блок з контентам tool_use, а рэзультат надаецца ў паведамленні user у вачынку tool_result, які павяязаны з ID таго блока, а не як окремая ролі tool.
Цэлы цикл
У сукупнасці архітектура выглядае так:
┌─────────────┐
│ User │
└──────┬──────┘
│
▼
┌─────────────┐
│ Lambda │
└──────┬──────┘
│
▼
┌─────────────┐
│ Claude │
│ Bedrock │
└──────┬──────┘
│
Tool request
│
▼
┌─────────────┐
│ Tool │
└──────┬──────┘
│
Tool result
│
▼
┌─────────────┐
│ Claude │
└──────┬──────┘
│
▼
┌─────────────┐
│ User │
└─────────────┘
Promise.withResolvers() выступае як точка перадачы між адкрыццем інструмента та продажчыцай ціклу:
Tool starts
│
▼
resolve(result)
│
▼
await toolPromise
│
▼
Continue agent loop
Мак-інструмент для тэставання
Ёжы працэўкі без рэальнага бэкенду, пошук у базе знаёмых можна імітаваць за дапамою короткага затрымлення:
function mockSearchKnowledgeBase(
query: string
): Promise<{ answer: string }> {
return new Promise((resolve) => {
setTimeout(() => {
resolve({
answer:
`Results for "${query}" (mocked).`
});
}, 300);
});
}
У працэйнай сітуацыі тая ж функцыя можа вызваць будзь-кан з гэтых:
DynamoDB
OpenSearch
RDS
S3
REST API
Internal service
Vector database
Knowledge base
Едынственныя умовы — гэта інструмент должен вернуць прабаму.
Захаванне ад інструментоў, якія ніколі не завершаюцца
Зовнішнія інструменты можу застряць або зникнуць. Якщо інструмент ніколі не вырашыцца, гэтыя рядкі чакаюць, пакуль сама Lambda не выйдзе за час:
await toolPromise;
Кэпсулка з таймаутам задае верхнюя межу, прабуючы пераканаць прабаму працаваць раней за таймер, і абрывае таймер незалежна ад таго, як вырашыцца прабама:
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);
}
);
}
);
}
Рэзультат інструмента пасля гэтага чакаецца з лімітом у два секунды:
const toolResult =
await withTimeout(
toolPromise,
2000
);
Рэнер не дазволяе вашам коду чакаць, а не самам інструменту: запыт продовжвае выконванне, якщо толькі вы не перадасте ў яго AbortSignal і не анулюеце його.
Свядомая обработка адзінакоў Bedrock
Разлічвайце разныя виды неудач. У прыкладзе обмежэнне частоты запытоў адносіцца да коду 429, іншыя адзінаки службы Bedrock — да коду 502, а ўсе нечаканыя проблемы падаюць знову:
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;
}
Павторныя спробы запытоў пасля обмежэння частоты з адкладаннем
Обмежэнне частоты запытоў часта є тымчасовым, таму яно ўзгоджваецца з ідеяю павторных спроб. Штатны калектар прыменяе спробы да трох разоў, чакаючы трохце дольшэ пасля кожной такой спробы, і негайна падае знову кожную іншую адзінаку:
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."
);
}
Павтараць можна толькі тыя памылкі, якія безпечны для павтарэння; павтарэнне проблемы з правамі прыводзіць толькі да трох аднаковых неудач. Час адлегківання тут расте лінейна, а дадзенне касуальных паўтарэнняў дапамагае, калі адразу блакуецца больш заўсёды вызоўваў.
Падтрымка рантаймаў без withResolvers()
У средовыце, якая не мае гэтага методу, невеликі памочны клас забезпечвае той жа формат. Ён пачынаецца з адзначэнняя генерычнай функцыі:
function createDeferred<T>() {
Усередзіне ён адзначае функцыі вырашэння з асерцыямі на пэўнае прызначэнне, захопляе іх з звычайнага канстрактара і вяртае ўсе тры разам:
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
};
}
Спосаб выкарыстоўвання ідэнтычны натыўнаму API:
const {
promise,
resolve,
reject
} = createDeferred<Result>();
Калі рантайм натыўна падтрымае Promise.withResolvers(), лепш выкарыстоўваць яго і адмовіцца ад памочнага класу.
Што не рашае withResolvers()
Гэты метод спростоўвае процес стварэння прапазы і робіць функцыі ўсунення наследкавае яе выпалення доступнымі за межамі экзэкутара. Ён нічага не робіць з:
- условымі супернечкамі
- багатакратнымі одночаснымі вызовамі інструментаў
- анулюванням
- таймаутамі
- захопам ад двойнага усунення наследкавае
- чыстэйшам вываленню рэсурсаў
- правільным адобразаваннем выходных дадзенняў модэлю
- автарызацыяй інструментаў
- палітыкай павторных спроб
Кожны з гэтых аспектаў все рава трэба яшчэ раз праектаваць адкрыта. Ланцоўка, падобная да той, што паказана нижэй, без ліміту на колькі інструментаў модэль можа вызваць па спрабах, є паслабай архітэктураю, незалежна ад таго, насколькі апрантна напісана прапазы:
Claude
↓
Tool A
↓
Tool B
↓
Tool C
↓
Unbounded execution
Цікл агента патрабуе жорсткіх лімітаў. Адна з статэй блога прасвятляе тэму лімітаваных цыклаў агентаў у TypeScript і дэтальней раскрывае гэтыя ліміты.
Чаму гэты патэрн яшчэ застае свае месца
Оркестрацыя агента перакрывае многія асінхронныя межы между выходама модэля і ўсуненнем яго роботы:
Model response
↓
Stream event
↓
Tool detection
↓
Tool execution
↓
Database
↓
Tool result
↓
Model continuation
З-за вкладзеных канстрактароў такі тэкст часта ёсць важкі для адстежэння. withResolvers() дае можлівасць паводліва адстэжыць гэты тэкст:
Create promise
↓
Expose resolver
↓
Start asynchronous operation
↓
Resolve when result arrives
↓
Await result
↓
Continue agent loop
Чакліст для працы ў прыметнай сэрвісе
Пераканацца ў правядзібнасці аргументаў інструмента
Спрыяць аргументам, створаным модэлем, як да інпуту, які не ўзлежыць на вас. Пераканацца прынеймна ў:
Types
Required fields
String lengths
Allowed values
Authorization
Business rules
Абмежэння выканання інструмента
Установіць чыстае абмежэння для:
Maximum tool calls
Maximum execution time
Maximum model iterations
Maximum response size
Зробіць цыкл працяздатным для адстэжэння
Запісваць паметры і стэйсы для:
Lambda duration
Bedrock latency
Tool latency
Tool failures
Throttling
Token usage
Agent iterations
Timeouts
Застосаваць прынцип мінімальных прав і заставіць інструменты ўзвужанымі
Даюць ролі выканання Lambda толькі тыя правыя, якія неабходны для ўсіх яе інструментаў, і ніколі не даюць модэлю безмежнага доступу да вашага акаунта AWS чыстае внутршняях системах; у замен выкладзіце маленькія, чыстае апісаныя операцыі.
Выбор между withResolvers() і new Promise()
Канстрактар захоўвае рэзалверы ўнутрь экзэкютара, працуе на кожным рантайме і падходзіць для звычных асінхронных операцый, але можа вымусіць дадатковаяе гнездаванне ў кодзе оркестрацыі. withResolvers() вяртае праміс і рэзалверы разам і падходзіць для случаў, калі вырашэнне вядзецца інфармацыя, але для гэтага трэбуе рантайм, які яго падтрымае. Гэта не значыць, што кожны new Promise() павінен быць выкарыстоўваны. Калі операцыя па свойствам падходзіць да гэтага формата, застаўце яе:
return new Promise(...)
Выбірайце withResolvers(), калі стварэнне і вырашэнне аддзеляюцца.
Ключовыя выводы
У замест на тое, кабям логіку хаваць унутрь канстрактара такім чынам:
new Promise((resolve, reject) => {
// deeply nested asynchronous logic
});
вы можете стварыць неабходныя элементы заздалегідь:
const {
promise,
resolve,
reject
} = Promise.withResolvers();
і структураваць алгорытм як чыстую последовасць:
Promise creation
↓
Asynchronous tool execution
↓
resolve / reject
↓
Continue agent loop
withResolvers()падходзіць для момантов паузы і продажвачэння роботы ціклу агента, калі адны калбэк выдае рэзультат, а іншы код чакае на яго.- Стварайце рэзалверы па кожны запит унутрь хандлера і пераканайцеся, што кожны маршрут выкарыстоўвае прапаз, укладаючы нават той, дзе не выклікаецца жадны інструмент.
- Дазвольваюцца толькі точныя форматы пэйлоадаў API Bedrock, які вы выбралі; тыя, што паказаны тут, ў спрощанай форме.
- Таймауты, выбраныя перапрыбуткі, верыфікацыя, прынцип мінімальных прав, можлівасць адзоравання і ліміты ітэрацый все рава трэба дадзіць явна.
Агент ўзимае на сябе ролю толькі столькі, сколькі дозволяе асінхронная система взаўмадзейнасця ў рамках модэлю, і гэта мае большое значэнне, чым хуткая нараджання запитаў. Іспользуйце withResolvers(), калі гэта дапамагае зробіць структуру взаўмадзейнасця более зрозумелай, і дадзіце тыя захаванні, якія агент не можа забезпечыць.
Супакойлівае чытанне
- Настройка Prisma 7 з PostgreSQL у проекте на TypeScript Node.js — Усуненне распашчытых падчас настройкі Prisma 7 аднойчын у TypeScript, ад проблем з URL-адрэсамі або значэнням undefined да скарг на rootDir, а таксама підключэнне PostgreSQL за дапамою адаптара pg driver.