Главная / Статьи / Координация вызовов инструментов 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

Пакет 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, который включает поле 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, а результат отправляется в сообщении от пользователя в виде блока 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 до ошибок, связанных с параметром rootDir, а также подключение PostgreSQL с использованием адаптера драйвера pg.
  • Создание типобезопасного GraphQL API с Prisma и Nexus в Node.js — Последуйте пошаговой инструкции из семи шагов для создания GraphQL API в Node.js, которое объединяет модель данных Prisma с типами и резолверами, генерируемыми Nexus.
  • Интеграция инструментов MCP в интерфейс чата на React с встроенной проверкой человеком — Узнайте, как протокол Model Context Protocol встраивается в приложение на React: почему бэкенд должен хостить MCP, как работает сервер инструментов и как транслировать и проверять вызовы инструментов в интерфейсе.
  • Перенос проекта Node.js на Bun: внутренности среды выполнения, Lambda и процесс миграции — Узнайте, что заменяет Bun в инструментальной сборке Node.js, почему он запускается быстро, как он обрабатывает TypeScript и работает на AWS Lambda, а также как пошагово мигрировать проект.