Галоўная / Артыкулы / Абсарганаваныя ціклы агента: надзеяныя шаблоны TypeScript для выкарыстоўвання інструментаў LLM

Абсарганаваныя ціклы агента: надзеяныя шаблоны TypeScript для выкарыстоўвання інструментаў LLM

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

2150 слоў

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

Такі падход становіць абаранне ў той момент, калі вы включаеце генератыўныя AI-системы ў тыя, дзе важна точнасць, напрыклад у білінгу, са адпаведнасцю законам аб управлінні запасамі. Спрыяўстваванне верыгоднай генераціі токенаў як аднаго з джэрел правды ёсць архітектурным рызыкам, а не простай незгоднасцю. Хоця сучасныя моделі часта можу правільна выконваць простыя вычысленні, нельга дазволяць верыгодной генерацыі тексту служыць автантрыпным записам для логіки, важной для бізнесу.

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

Пастка адна-этаповага запрашання проты обмежаных агентных цикламі

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