Главная/Блог/Гайд/Как построить надежный цикл агента с…
Гайд9 мин чтения · 19 сентября 2026 г.

Как построить надежный цикл агента с вызовом инструментов на OpenRouter

Полное руководство по созданию собственного цикла агента (agent loop) с использованием OpenRouter SDK: от базовой настройки до управления историей и MCP.

Как построить надежный цикл агента с вызовом инструментов на OpenRouter

В эпоху, когда большие языковые модели (LLM) стали неотъемлемой частью современных приложений, граница между простым чат-ботом и автономным агентом размывается. Многие разработчики ошибочно полагают, что для создания «умного» помощника им необходимо подключать тяжеловесные фреймворки вроде LangChain или AutoGen. Однако часто истинная мощь и гибкость кроются в понимании базового паттерна: цикла с вызовом инструментов (tool-calling agent loop). Этот подход позволяет модели не просто генерировать текст, а взаимодействовать с внешним миром, выполнять вычисления и принимать решения на основе реальных данных.

В этой статье мы подробно разберем, как построить надежный, эффективный и безопасный цикл агента, используя TypeScript SDK от OpenRouter. Мы не просто скопируем код — мы поймем логику каждого шага: от определения инструментов и управления историей диалога до обработки ошибок, контроля параллелизма и интеграции с протоколом Model Context Protocol (MCP). Этот материал предназначен для разработчиков, которые хотят взять полный контроль над поведением своего AI-агента, избегая «черных ящиков» проприетарных библиотек.

01Как работает цикл вызова инструментов: фундаментальная логика

Прежде чем писать код, важно понять архитектурный принцип. Цикл агента с вызовом инструментов — это итеративный процесс, в котором модель LLM выступает в роли «мозга», а ваше приложение — в роли «рука и ноги».

Процесс выглядит следующим образом:

  1. Запрос: Вы отправляете текущую историю сообщений и список доступных инструментов (функций) модели.
  2. Решение модели: Модель анализирует запрос и решает, нужен ли ей инструмент. Если да, она возвращает структуру с именем функции и аргументами. Если нет, она возвращает финальный текстовый ответ.
  3. Исполнение: Ваше приложение парсит аргументы, выполняет функцию (например, делает запрос к API или базе данных) и получает результат.
  4. Обратная связь: Результат выполнения отправляется обратно модели в виде сообщения от имени «инструмента».
  5. Повторение: Цикл повторяется, пока модель не вернет финальный ответ или не сработает условие остановки.

Ключевой момент здесь заключается в разделении ответственности. Модель решает что сделать, но ваше приложение решает как это сделать и когда остановиться. Без жестких ограничений на количество итераций или повторений модель может зациклиться, если столкнется с ошибкой или неоднозначной ситуацией. Поэтому внедрение «предохранителей» (stop conditions) — это не опция, а необходимость для production-решений.

💡
Важно понимать. Модель не исполняет код. Она только генерирует JSON-структуры, описывающие вызов функции. Вся безопасность и логика исполнения остаются на стороне вашего сервера или клиента.

02Шаг 1: Настройка клиента и определение инструментов

Первый этап — подготовка среды. Мы будем использовать TypeScript, так как он обеспечивает строгую типизацию, что критически важно при работе с динамическими структурами ответов LLM. Установите необходимые зависимости:

Как построить надежный цикл агента с вызовом инструментов на OpenRouter
terminalbash
mkdir openrouter-agent-loop
cd openrouter-agent-loop
npm init -y
npm pkg set type=module
npm install @openrouter/sdk
npm install --save-dev tsx

После установки создайте файл agent.ts. Здесь мы инициализируем клиент OpenRouter и определяем инструменты. Инструменты описываются в формате JSON Schema, который модель использует для валидации аргументов.

terminaltypescript
import { OpenRouter } from "@openrouter/sdk";
import type { ChatMessages, ChatToolCall } from "@openrouter/sdk/models";

if (!process.env.OPENROUTER_API_KEY) {
  throw new Error("Set OPENROUTER_API_KEY before running this example");
}

const openRouter = new OpenRouter({
  apiKey: process.env.OPENROUTER_API_KEY,
});

// Список моделей с приоритетом (fallback)
const models = [
  "google/gemini-3-flash-preview",
  "nvidia/nemotron-3.5-lightning",
];

// Определение инструментов
const tools = [
  {
    type: "function" as const,
    function: {
      name: "get_weather",
      description: "Get local sample weather data for Lagos or London",
      parameters: {
        type: "object",
        properties: {
          city: { type: "string", enum: ["Lagos", "London"] },
        },
        required: ["city"],
        additionalProperties: false,
      },
    },
  },
];

// Локальная «база данных» для примера
const weatherByCity: Record = {
  lagos: { temperatureC: 29, conditions: "partly cloudy" },
  london: { temperatureC: 18, conditions: "overcast" },
};

Обратите внимание на описание инструмента. Оно должно быть максимально конкретным. Указание enum: ["Lagos", "London"] в параметрах помогает модели избежать галлюцинаций с несуществующими городами. Также важно помнить, что определение инструментов должно отправляться с каждым запросом к модели, включая последующие итерации цикла. Модель не запоминает контекст между запросами в рамках одного API-вызова, поэтому контекст инструментов должен быть явным.

03Шаг 2: Вызов модели и анализ ответа

Следующий шаг — создание функции, которая отправляет запрос к модели. В OpenRouter SDK запросы оборачиваются в объект chatRequest, а поля используют camelCase (например, toolChoice), которые SDK автоматически преобразует в snake_case для передачи по сети.

terminaltypescript
async function sendTurn(
  messages: ChatMessages[],
  toolChoice: "required" | "auto" = "auto"
) {
  const result = await openRouter.chat.send({
    chatRequest: {
      models,
      messages,
      tools,
      toolChoice,
      maxCompletionTokens: 1024,
      stream: false,
    },
  });

  if (!("choices" in result)) {
    throw new Error("Expected a non-streaming response");
  }

  const message = result.choices[0]?.message;
  if (!message) throw new Error("The model returned no message");

  return {
    result,
    message,
    calls: message.toolCalls ?? [],
  };
}

Параметр toolChoice играет решающую роль. В первой итерации мы можем использовать "required", чтобы принудительно заставить модель использовать инструмент. В последующих итерациях, когда модель уже получила результаты, мы переключаемся на "auto", позволяя ей решить, нужен ли еще один вызов функции или она готова дать финальный ответ. Если оставить "required" навсегда, модель никогда не сможет завершить диалог текстом, так как будет вынуждена постоянно вызывать функции.

⚠️
Важно. По умолчанию, если инструменты переданы, параметр tool_choice в API равен "auto". Явное указание "required" полезно на старте, чтобы гарантировать, что агент начнет взаимодействие с внешним миром.

04Шаг 3: Исполнение инструментов и возврат результатов

Когда модель возвращает список вызовов инструментов (toolCalls), ваше приложение должно выполнить их. Это место, где происходит интеграция с вашим бизнес-логикой. В примере ниже мы парсим аргументы и вызываем локальную функцию.

terminaltypescript
async function executeToolCall(
  call: ChatToolCall
): Promise {
  let content: string;
  try {
    if (call.function.name !== "get_weather") {
      throw new Error(`Unknown tool: ${call.function.name}`);
    }

    const args = JSON.parse(call.function.arguments) as { city?: unknown };
    if (typeof args.city !== "string") {
      throw new Error("city must be a string");
    }

    const weather = weatherByCity[args.city.toLowerCase()];
    if (!weather) {
      throw new Error(`No weather data for ${args.city}`);
    }

    content = JSON.stringify({ city: args.city, ...weather });
  } catch (error) {
    content = JSON.stringify({
      error: error instanceof Error ? error.message : String(error),
    });
  }

  return {
    role: "tool",
    toolCallId: call.id,
    content,
  };
}

Здесь критически важна обработка ошибок. Аргументы модели приходят в виде строки JSON, поэтому JSON.parse должен быть внутри блока try-catch. Если модель сгенерировала невалидный JSON или передала неизвестное имя функции, мы не должны ломать весь цикл. Вместо этого мы возвращаем сообщение об ошибке в поле content с ролью "tool". Это позволяет модели увидеть ошибку, скорректировать свои аргументы или выбрать другой инструмент. Обратите внимание на поле toolCallId — оно обязательно для сопоставления результата с конкретным вызовом.

Как построить надежный цикл агента с вызовом инструментов на OpenRouter

05Шаг 4: Сборка цикла с ограничениями

Теперь объединим все части в единую функцию runAgent. Самая важная часть здесь — управление состоянием и предотвращение бесконечных циклов.

terminaltypescript
async function runAgent(task: string, maxIterations = 10) {
  if (
    !Number.isSafeInteger(maxIterations) ||
    maxIterations <= 0
  ) {
    throw new Error("maxIterations must be a positive safe integer");
  }

  const messages: ChatMessages[] = [
    { role: "user", content: task },
  ];
  const callCounts = new Map();

  for (let iteration = 0; ; iteration++) {
    const startedAt = performance.now();
    const { result, message, calls } = await sendTurn(
      messages,
      iteration === 0 ? "required" : "auto"
    );

    console.info({
      iteration,
      model: result.model,
      tools: calls.map((call) => call.function.name),
      latencyMs: Math.round(performance.now() - startedAt),
    });

    // Если нет вызовов инструментов — это финальный ответ
    if (calls.length === 0) {
      return typeof message.content === "string" ? message.content : null;
    }

    // Проверка лимита итераций
    if (iteration === maxIterations) {
      throw new Error(`Stopped after ${maxIterations} iterations`);
    }

    // Проверка на повторение одних и тех же вызовов
    for (const call of calls) {
      const fingerprint = `${call.function.name}:${call.function.arguments}`;
      const count = (callCounts.get(fingerprint) ?? 0) + 1;
      callCounts.set(fingerprint, count);

      if (count >= 3) {
        throw new Error(`Stopped after three identical calls to ${call.function.name}`);
      }
    }

    // Добавляем сообщение ассистента и результаты инструментов
    messages.push(message);
    messages.push(...(await Promise.all(calls.map(executeToolCall))));
  }
}

В этом коде реализованы три уровня защиты:

  1. Лимит итераций (maxIterations): Жесткое ограничение на количество циклов. Проверка происходит до выполнения инструментов на последней итерации, чтобы избежать побочных эффектов, которые модель все равно не увидит.
  2. Детекция зацикливания: Мы используем Map для отслеживания уникальных вызовов (имя функции + аргументы). Если один и тот же вызов повторяется 3 раза, цикл прерывается. Это защищает от ситуаций, когда модель застревает в попытке исправить ошибку.
  3. Валидация параметров: Проверка Number.isSafeInteger гарантирует, что лимит корректен и не приведет к бесконечному циклу из-за переполнения или некорректных значений.

06Тестирование и наблюдение за работой

Чтобы протестировать цикл, создадим простую функцию main:

terminaltypescript
async function main() {
  const answer = await runAgent(
    "Compare the weather in Lagos and London. Which city is warmer?"
  );
  console.log(answer);
}

main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

Запустите скрипт через npx tsx agent.ts. Вы увидите вывод, похожий на этот:

terminalyaml
iteration: 1,
model: 'google/gemini-3-flash-preview',
tools: [ 'get_weather', 'get_weather' ],
latencyMs: 3930
iteration: 2,
model: 'google/gemini-3-flash-preview',
tools: [],
latencyMs: 1417
Lagos is currently warmer than London.

Как видно, на первой итерации модель вызвала get_weather дважды параллельно. На второй итерации, получив данные, она вернула финальный текстовый ответ без вызовов инструментов. Это демонстрирует эффективность параллельного выполнения независимых задач.

Как построить надежный цикл агента с вызовом инструментов на OpenRouter
📌
Факт. В реальном приложении задержки (latency) могут варьироваться в зависимости от выбранной модели и провайдера. Использование списка моделей (models) позволяет автоматически переключаться на резервную модель при недоступности основной.

07Дополнительные улучшения: Отказоустойчивость и MCP

Для production-решений простого цикла может быть недостаточно. Рассмотрим несколько важных улучшений.

Управление историей и рост контекста

С каждым циклом история сообщений растет. Если инструменты возвращают большие объемы данных, это быстро приведет к превышению лимита контекста модели и увеличению затрат. Решение: обрезать или хешировать результаты инструментов. Если инструмент вернул большой JSON, передавайте модели только сводку или ссылку на источник, а не весь массив данных.

Интеграция с MCP (Model Context Protocol)

MCP позволяет подключать внешние сервисы (GitHub, Linear, базы данных) как инструменты. В отличие от локальных функций, MCP-серверы управляют обнаружением и выполнением инструментов. Однако логика цикла остается прежней: вы все равно должны отправлять определения инструментов, обрабатывать вызовы и контролировать итерации. MCP меняет только место исполнения, но не архитектуру взаимодействия.

Когда переходить на Agent SDK?

Если вам нужно управление состоянием, стриминг, сложная маршрутизация и автоматическое управление многооборотными диалогами, рассмотрите использование официального Agent SDK от OpenRouter. Однако для многих задач простой цикл, описанный выше, остается более прозрачным, легким и предсказуемым решением.

08Что это значит на практике

Построение собственного цикла агента дает вам полный контроль над поведением AI. Вы не зависите от абстракций фреймворков, которые могут скрывать ошибки или добавлять ненужные задержки. Вы четко понимаете, когда и почему модель принимает то или иное решение. Это особенно важно в сценариях, где критичны стоимость запросов, скорость отклика и безопасность данных. Освоив этот базовый паттерн, вы сможете строить сложные многоагентные системы, где каждый агент — это простой, но надежный цикл с вызовом инструментов, объединенный в единую оркестрируемую сеть.

Источник: OpenRouter ↗