Главная / Статьи / Ограниченные агентные циклы: надежные шаблоны TypeScript для использования инструментов LLM

Ограниченные агентные циклы: надежные шаблоны TypeScript для использования инструментов LLM

Узнайте, как проектировать агентов на TypeScript для работы с LLM с использованием ограниченных циклов агентности, валидации с помощью Zod, детерминированной выполнения инструментов и механизмов восстановления от ошибок для обеспечения надежности уровня продакшена.

2150 слов

Большинство разработчиков по-прежнему рассматривают большие языковые модели как улучшенные поисковые поля: вводят запрос, анализируют полученную строку и надеются, что арифметические вычисления, проверки прав доступа или правила форматирования не сломались где-то по пути.

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

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

Ловушка однократного запроса против ограниченных агентских циклов

1. Выполнение логики и математических операций

  • Запросы с однократным ответом: основаны на вероятностном прогнозировании следующего токена, что создаёт риск галлюцинаций при работе с такими вещами, как формулы законодательства, конвертация валют или правила транзакций.
  • Ограниченный агентский цикл: переносит вси вычисления на детерминистичные бэкенд-сервисы, благодаря чему числовые результаты остаются точными.

2. Проверка данных и целостность системы

  • Ввод запроса в один этап: опирается на хрупкие регулярные выражения или ручной парсинг строк для извлечения структурированных значений из свободного текста.
  • Ограниченный цикл агента: проверяет каждый аргумент инструмента согласно схеме Zod во время выполнения, прежде чем что-либо дойдет до слоя бизнес-логики или базы данных.

3. Толерантность к сбоям и восстановление модели

  • Ввод запроса в один этап: либо терпит неудачу без объяснений, либо полностью останавливается в момент, когда модель предоставляет недопустимый аргумент или пропускает обязательное поле.
  • Ограниченный цикл агента: возвращает информацию о сбоях проверки и ошибках бизнес-правил в виде вывода инструмента в ход разговора, позволяя модели попробовать снова с исправленными параметрами в следующем этапе.

4. Конкурентность и параллельная обработка

  • Ввод данных за один раз: использование последовательных, комплексных генераций, что замедляет обработку любых запросов, выходящих за рамки простых задач.
  • Ограниченный цикл агента: одновременная отправка нескольких независимых запросов к инструментам с использованием Promise.all.

Разделение обязанностей: LLM против бэкенда

Чтобы создать надежного агента, необходимо разделить то, что решает модель, и то, что фактически выполняется:

  • Задачи LLM: понимание намерений пользователя, семантический выбор подходящего инструмента, формирование аргументов для этого инструмента и составление окончательного ответа на естественном языке.
  • Что находится в ведении бэкенда: проверка схем во время выполнения, аутентификация и авторизация вызывающего пользователя, соблюдение бизнес-правил, гарантия идемпотентности, выполнение вычислений, применение любых побочных эффектов и логирование всего для аудита.
  • Архитектура цикла агента с 4 этапами

    Надежный цикл агента проходит через четыре отдельных этапа:

    1. Планирование: модель анализирует намерение пользователя, рассматривает доступные инструменты и формирует структурированный запрос к инструменту.
    2. Проверка: бэкенд перехватывает этот запрос и проверяет его согласно схеме Zod и правам пользователя перед запуском любой логики.
    3. Выполнение: бэкенд выполняет фактическую работу, будь то чтение из базы данных, вызов внешнего API или выполнение вычислительной задачи, и записывает результат под ключом идемпотентности.
  • Обратите внимание: Результат такой обработки возвращается в модель в виде структурированного сообщения типа tool. Затем модель решает, требуется ли ещё один вызов инструмента или она может сразу выдать окончательный ответ.
  • Реализация в TypeScript для производственного использования

    Приведённую ниже реализацию можно сохранить как agent.ts. Она включает типизированные контракты инструментов, проверку данных с помощью Zod во время выполнения, проверки авторизации, защиту от дублирования операций и строгие ограничения на выполнение.

    import OpenAI from "openai";
    import { z } from "zod";
    import { randomUUID } from "node:crypto";
    
    const openai = new OpenAI({
      apiKey: process.env.OPENAI_API_KEY,
    });
    // ============================================================================
    // 1. Types & Operational Contracts
    // ============================================================================
    export interface SecurityContext {
      userId: string;
      tenantId: string;
      roles: string[];
    }
    export type ToolResultStatus =
      | "success"
      | "validation_error"
      | "permission_denied"
      | "business_error"
      | "fatal_error";
    export interface ToolResult<T = unknown> {
      status: ToolResultStatus;
      data?: T;
      error?: {
        code: string;
        message: string;
        details?: unknown;
      };
      metadata: {
        toolName: string;
        toolCallId: string;
        latencyMs: number;
        idempotencyKey?: string;
      };
    }
    export interface AgentTool<TInput = unknown, TOutput = unknown> {
      name: string;
      description: string;
      schema: z.ZodType<TInput>;
      openAiDefinition: OpenAI.Chat.Completions.ChatCompletionTool;
      isMutating: boolean;
      requiredPermission?: string;
      handler: (
        input: TInput,
        context: SecurityContext,
        idempotencyKey?: string
      ) => Promise<TOutput>;
    }
    // Custom Error Classes
    class BusinessRuleError extends Error {
      constructor(public readonly code: string, message: string) {
        super(message);
        this.name = "BusinessRuleError";
      }
    }
    // ============================================================================
    // 2. Demonstration Tax Tool (Explicit Jurisdictions, No Silent Fallbacks)
    // ============================================================================
    const CalculateTaxSchema = z.object({
      subtotal: z.number().positive("Subtotal must be greater than 0"),
      countryCode: z
        .string()
        .length(2, "Country code must be a 2-letter ISO code")
        .toUpperCase(),
    });
    type CalculateTaxInput = z.infer<typeof CalculateTaxSchema>;
    interface TaxResult {
      jurisdiction: string;
      rateApplied: number;
      taxAmount: number;
      grossTotal: number;
    }
    // Simplified demonstration rates. Production systems should query a versioned tax rules engine.
    const DEMO_STATUTORY_RATES: Record<string, number> = {
      UK: 0.20, // 20% Standard VAT
      BD: 0.15, // 15% Standard VAT
    };
    const calculateTaxTool: AgentTool<CalculateTaxInput, TaxResult> = {
      name: "calculateTax",
      description:
        "Computes statutory tax and gross total for verified jurisdictions (UK, BD). Rejects unsupported regions.",
      schema: CalculateTaxSchema,
      isMutating: false,
      openAiDefinition: {
        type: "function",
        function: {
          name: "calculateTax",
          description:
            "Computes statutory tax for an invoice line. Only supports UK and BD in this demonstration environment.",
          parameters: {
            type: "object",
            properties: {
              subtotal: { type: "number", description: "Net amount before tax" },
              countryCode: { type: "string", description: "2-letter ISO code (e.g. 'UK', 'BD')" },
            },
            required: ["subtotal", "countryCode"],
            additionalProperties: false,
          },
          strict: true,
        },
      },
      handler: async (input: CalculateTaxInput): Promise<TaxResult> => {
        const rate = DEMO_STATUTORY_RATES[input.countryCode];
        if (rate === undefined) {
          throw new BusinessRuleError(
            "UNSUPPORTED_JURISDICTION",
            `Jurisdiction '${input.countryCode}' is not supported. Only UK and BD are configured.`
          );
        }
        const taxAmount = Number((input.subtotal * rate).toFixed(2));
        const grossTotal = Number((input.subtotal + taxAmount).toFixed(2));
        return {
          jurisdiction: input.countryCode,
          rateApplied: rate,
          taxAmount,
          grossTotal,
        };
      },
    };
    // ============================================================================
    // 3. Mutating Side-Effect Tool (With Idempotency & Auth Boundary)
    // ============================================================================
    const RecordInvoiceSchema = z.object({
      clientName: z.string().min(1, "Client name is required"),
      amount: z.number().positive("Amount must be positive"),
      taxAmount: z.number().nonnegative(),
      currency: z.string().length(3).toUpperCase(),
    });
    type RecordInvoiceInput = z.infer<typeof RecordInvoiceSchema>;
    const processedIdempotencyKeys = new Set<string>();
    const recordInvoiceTool: AgentTool<RecordInvoiceInput, { invoiceId: string; status: string }> = {
      name: "recordInvoice",
      description: "Records an invoice in the ledger. Mutating operation requiring 'billing:write' permission.",
      schema: RecordInvoiceSchema,
      isMutating: true,
      requiredPermission: "billing:write",
      openAiDefinition: {
        type: "function",
        function: {
          name: "recordInvoice",
          description: "Persists an invoice into the financial accounting ledger.",
          parameters: {
            type: "object",
            properties: {
              clientName: { type: "string", description: "Customer or business entity name" },
              amount: { type: "number", description: "Gross invoice amount" },
              taxAmount: { type: "number", description: "Computed tax portion" },
              currency: { type: "string", description: "3-letter currency code (e.g. GBP, BDT)" },
            },
            required: ["clientName", "amount", "taxAmount", "currency"],
            additionalProperties: false,
          },
          strict: true,
        },
      },
      handler: async (input, context, idempotencyKey) => {
        if (!idempotencyKey) {
          throw new Error("Fatal: Mutating operations require an idempotency key.");
        }
        if (processedIdempotencyKeys.has(idempotencyKey)) {
          return {
            invoiceId: `inv_cached_${idempotencyKey.slice(0, 8)}`,
            status: "already_processed_idempotent",
          };
        }
        processedIdempotencyKeys.add(idempotencyKey);
        return {
          invoiceId: `inv_${randomUUID().slice(0, 8)}`,
          status: "recorded",
        };
      },
    };
    // ============================================================================
    // 4. Strongly Typed Registry & Dispatcher
    // ============================================================================
    const toolRegistry = new Map<string, AgentTool<any, any>>([
      [calculateTaxTool.name, calculateTaxTool],
      [recordInvoiceTool.name, recordInvoiceTool],
    ]);
    async function dispatchToolCall(
      toolCall: OpenAI.Chat.Completions.ChatCompletionMessageToolCall,
      agentRunId: string,
      securityContext: SecurityContext
    ): Promise<ToolResult> {
      const startTime = Date.now();
      const toolName = toolCall.function.name;
      const tool = toolRegistry.get(toolName);
      const idempotencyKey = tool?.isMutating ? `${agentRunId}:${toolCall.id}` : undefined;
      // 1. Tool Existence Check
      if (!tool) {
        return {
          status: "fatal_error",
          error: { code: "UNKNOWN_TOOL", message: `Tool '${toolName}' does not exist.` },
          metadata: { toolName, toolCallId: toolCall.id, latencyMs: Date.now() - startTime },
        };
      }
      // 2. Authorization Boundary Check
      if (tool.requiredPermission && !securityContext.roles.includes(tool.requiredPermission)) {
        return {
          status: "permission_denied",
          error: {
            code: "UNAUTHORIZED",
            message: `Execution rejected: Caller lacks required permission '${tool.requiredPermission}'.`,
          },
          metadata: { toolName, toolCallId: toolCall.id, latencyMs: Date.now() - startTime },
        };
      }
      // 3. Schema Boundary Check (Zod)
      let parsedArguments: unknown;
      try {
        parsedArguments = JSON.parse(toolCall.function.arguments);
      } catch {
        return {
          status: "validation_error",
          error: { code: "MALFORMED_JSON", message: "Arguments payload was not valid JSON." },
          metadata: { toolName, toolCallId: toolCall.id, latencyMs: Date.now() - startTime },
        };
      }
      const validationResult = tool.schema.safeParse(parsedArguments);
      if (!validationResult.success) {
        return {
          status: "validation_error",
          error: {
            code: "SCHEMA_VALIDATION_FAILED",
            message: "Tool arguments failed schema validation.",
            details: validationResult.error.flatten().fieldErrors,
          },
          metadata: { toolName, toolCallId: toolCall.id, latencyMs: Date.now() - startTime },
        };
      }
      // 4. Execution Boundary (Business logic & side effects)
      try {
        const data = await tool.handler(validationResult.data, securityContext, idempotencyKey);
        return {
          status: "success",
          data,
          metadata: {
            toolName,
            toolCallId: toolCall.id,
            latencyMs: Date.now() - startTime,
            idempotencyKey,
          },
        };
      } catch (error) {
        if (error instanceof BusinessRuleError) {
          return {
            status: "business_error",
            error: { code: error.code, message: error.message },
            metadata: { toolName, toolCallId: toolCall.id, latencyMs: Date.now() - startTime },
          };
        }
        return {
          status: "fatal_error",
          error: {
            code: "INTERNAL_EXECUTION_FAILURE",
            message: error instanceof Error ? error.message : "Unhandled execution crash.",
          },
          metadata: { toolName, toolCallId: toolCall.id, latencyMs: Date.now() - startTime },
        };
      }
    }
    // ============================================================================
    // 5. Bounded Orchestration Loop with Observability & Limits
    // ============================================================================
    interface AgentRunConfig {
      maxCycles?: number;
      timeoutMs?: number;
      model?: string;
    }
    async function runReliableAgent(
      userPrompt: string,
      securityContext: SecurityContext,
      config: AgentRunConfig = {}
    ): Promise<string> {
      const {
        maxCycles = 5,
        timeoutMs = 30_000,
        model = process.env.OPENAI_MODEL || "gpt-4o-mini",
      } = config;
      const agentRunId = `run_${randomUUID()}`;
      const startTime = Date.now();
      const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [
        {
          role: "system",
          content:
            "You are an enterprise accounting orchestrator. You do not calculate statutory taxes or book entries directly. You coordinate with deterministic tools. If a tool reports a validation error or business rule failure, analyze the issue and attempt to correct parameters or explain the limitation.",
        },
        { role: "user", content: userPrompt },
      ];
      const toolsPayload = Array.from(toolRegistry.values()).map((t) => t.openAiDefinition);
      let cycle = 0;
      while (cycle < maxCycles) {
        cycle += 1;
        // Guardrail: Wall-clock timeout
        if (Date.now() - startTime > timeoutMs) {
          throw new Error(`Agent run [${agentRunId}] aborted: Exceeded timeout of ${timeoutMs}ms.`);
        }
        const response = await openai.chat.completions.create({
          model,
          messages,
          tools: toolsPayload,
          tool_choice: "auto",
        });
        const choice = response.choices[0];
        const assistantMessage = choice.message;
        messages.push(assistantMessage);
        // Terminal condition: Orchestrator reached final text answer
        if (!assistantMessage.tool_calls || assistantMessage.tool_calls.length === 0) {
          return assistantMessage.content ?? "Agent completed without generating text.";
        }
        // Concurrently dispatch independent tool calls
        const toolPromises = assistantMessage.tool_calls.map(async (toolCall) => {
          const result = await dispatchToolCall(toolCall, agentRunId, securityContext);
          return {
            role: "tool" as const,
            tool_call_id: toolCall.id,
            content: JSON.stringify(result),
          };
        });
        const toolMessages = await Promise.all(toolPromises);
        messages.push(...toolMessages);
      }
      throw new Error(`Agent run [${agentRunId}] halted: Exceeded maximum iterations (${maxCycles}).`);
    }
    // ============================================================================
    // 6. Test Scenario: Concurrency, Unsupported Jurisdictions & Auth
    // ============================================================================
    async function main() {
      const securityContext: SecurityContext = {
        userId: "usr_9918",
        tenantId: "tenant_uk_01",
        roles: ["billing:write"],
      };
      const prompt =
        "Calculate tax for two invoices: 1500 GBP for a client in the UK, and 500 EUR for a client in Germany (DE). If tax calculation succeeds, record the invoice for the UK client.";
      console.log("Executing Agent Run...\n");
      try {
        const finalAnswer = await runReliableAgent(prompt, securityContext);
        console.log("=== Agent Response ===");
        console.log(finalAnswer);
      } catch (err) {
        console.error("Execution failed:", err);
      }
    }
    main();
    

    Критические аспекты производственной среды

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

    2. Защищайте операции с изменением состояния с помощью ключей идемпотентности. Операции, которые только читают данные, можно повторять без риска. Однако любые операции, изменяющие состояние — отправка счета-фактуры, корректировка баланса в учетной книге или запуск webhook-запроса — должны выполняться с использованием ключа идемпотентности. Получая этот ключ из ${agentRunId}:${toolCallId}, вы гарантируете, что если модель дважды вызовет ту же функцию в процессе восстановления после ошибки, бэкенд распознает дублирование и пропустит повторную обработку.

    3. Разделяйте ошибки на категории, чтобы модель знала, как реагировать. Не каждая ошибка должна стимулировать повторную попытку от LLM:

    • Ошибки валидации (validation_error): аргументы были некорректны. Модель может прочитать подробности на уровне полей, сообщаемые Zod, и скорректировать свой пакет данных при следующей попытке.
  • Ошибки бизнес-логики (business_error): нарушено определённое правило, например, используется неподдерживаемая юрисдикция. Модель может попытаться использовать другой подход или сообщить пользователю о существующем ограничении.
  • Ошибки разрешений (permission_denied): в данном случае невозможно восстановить работу инструмента. Поскольку у модели нет возможности предоставить себе дополнительный доступ, правильным поведением является прекращение выполнения без попыток повтора.
  • Связанные материалы