Головна / Статті / Обмежені агентні цикли: надійні шаблони 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): їх неможливо виправити під час цього конкретного виклику інструменту. Оскільки у моделі немає можливості надати собі додатковий доступ, правильною поведінкою є чисте зупинення виконання, а не продовжувати спроби.
  • Пов’язана література