Головна / Статті / Координація викликів інструментів LLM у Node.js за допомогою Promise.withResolvers()

Координація викликів інструментів LLM у Node.js за допомогою Promise.withResolvers()

Подивіться, як Promise.withResolvers() розв’язує проблеми оркестрації викликів інструментів у Node.js Lambda під час виклику Claude на Bedrock, а також які таймаути, повторні спроби та обмеження він не охоплює.

3047 слів

Як тільки модель мови може викликати інструменти, ваш додаток повинен призупинити розмову під час виконання запиту до бази даних або виклику API, а потім продовжити її з результатом. У цьому посібнику показано, як Promise.withResolvers() чіткіше виражає цей процес призупинення та продовження, ніж власноруч створені конструктори обіцянок, наведено спрощений цикл використання інструментів 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

Код має чекати на цей результат перед тим, як продовжувати роботу, і обіцянка з зовнішнім вирішенням ідеально підходить для цього моменту очікування.

Спрощений цикл інструменту 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 Converse від Bedrock, тож оберіть один 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>() {

Усередині він оголошує функції обробки з використанням асертацій типу definite-assignment, захоплює їх зі звичайного конструктора та повертає всі три разом:

  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(), щоб ця система була зрозумілішою, та додайте захисні механізми, які вона не може забезпечити.

Пов’язана література

  • Створення GraphQL API з типовою безпекою за допомогою Prisma та Nexus у Node.js — Дізнайтеся, як пройти семиекратний пошуковий процес для створення GraphQL API у Node.js, який об’єднує модель даних Prisma з типами та резолверами, створеними Nexus.
  • Інтеграція інструментів MCP у UI чату React з вбудованою людською затвердженням — Дізнайтеся, як протокол Model Context Protocol підходить для React-додатку: чому бекенд має хостувати MCP, як працює сервер інструментів та як стрімувати та затверджувати виклики інструментів у UI.
  • Перенесення проекту Node.js на Bun: внутрішні механізми роботи, Lambda та процес міграції — Дізнайтеся, що замінює Bun у інструментальному комплексі Node.js, чому він запускається швидко, як він обробляє TypeScript та працює на AWS Lambda, а також як поетапно мігрувати проект.