Inicio / Artículos / Bucles de agencia acotados: patrones fiables en TypeScript para el uso de herramientas de LLM

Bucles de agencia acotados: patrones fiables en TypeScript para el uso de herramientas de LLM

Aprenda a diseñar agentes LLM en TypeScript utilizando bucles de agencia limitados con validación Zod, ejecución determinista de herramientas y recuperación de errores para lograr una fiabilidad de nivel profesional.

2150 palabras

La mayoría de los desarrolladores siguen tratando a los modelos de lenguaje grande como cajas de búsqueda mejoradas: envían una solicitud, analizan la cadena de texto que reciben y esperan que las operaciones aritméticas, las verificaciones de permisos o las reglas de formato no hayan fallado silenciosamente en algún momento del proceso.

Ese enfoque se vuelve peligroso en el momento en que se integra la IA generativa en sistemas donde la exactitud es realmente importante, como la facturación, el cumplimiento normativo o la gestión de inventarios. Tratar la generación probabilística de tokens como fuente de verdad representa un riesgo arquitectónico, no una simple molestia. Incluso si los modelos actuales suelen acertar en las operaciones aritméticas simples, nunca se debe permitir que la generación de texto probabilística sirva como registro autoritativo para la lógica crítica del negocio.

El patrón que realmente permite escalar es lo que podríamos denominar un bucle agente limitado: en lugar de pedirle al modelo que genere respuestas directamente, se le permite actuar como orquestador. Él llama a herramientas deterministas, examina los resultados de tipo estricto obtenidos de esas herramientas y se recupera de los fallos, todo dentro de límites operativos estrictos.

La trampa del turno único vs. los bucles agente limitados

1. Ejecución de lógica y matemáticas

  • Estímulo de un solo turno: Depende de la predicción probabilística del siguiente token, lo que introduce un verdadero riesgo de alucinaciones en cosas como fórmulas legales, conversiones monetarias o reglas transaccionales.
  • Bucle agente limitado: Transfiere todo el cálculo a servicios backend deterministas, de modo que los resultados numéricos permanecen precisos.

2. Validación de datos e integridad del sistema

  • Inducción de una sola vuelta: Se basa en expresiones regulares frágiles o en el análisis manual de cadenas para extraer valores estructurados del texto libre.
  • Bucle de agente con límites: Verifica cada argumento de la herramienta contra un esquema Zod en tiempo de ejecución, antes de que nada llegue a la lógica empresarial o a la capa de base de datos.

3. Tolerancia a fallos y recuperación del modelo

  • Inducción de una sola vuelta: O falla sin explicación o se detiene por completo en el momento en que el modelo proporciona un argumento inválido o omite un campo requerido.
  • Bucle de agente con límites: Devuelve los fallos de validación y los errores en las reglas empresariales como salida de la herramienta, permitiendo que el modelo intente nuevamente con parámetros corregidos en su siguiente turno.

4. Concurrency y ejecución paralela

  • Solicitud de una sola etapa: Se queda atado a generaciones secuenciales y integrales que ralentizan cualquier tarea que vaya más allá de solicitudes triviales.
  • Bucle de agente limitado: Envía varias llamadas a herramientas independientes al mismo tiempo utilizando Promise.all.

Distribución de responsabilidades: LLM vs. backend

Para crear un agente fiable es necesario separar lo que decide el modelo de lo que se ejecuta realmente:

  • Responsabilidades del LLM: Comprender la intención del usuario, elegir semanticamente la herramienta adecuada, generar argumentos para dicha herramienta y compilar la respuesta final en lenguaje natural.
  • Qué posee el backend: Validación de esquemas en tiempo de ejecución, autenticación y autorización del solicitante, aplicación de reglas de negocio, garantía de idempotencia, realización de cálculos, aplicación de efectos secundarios y registro de todo para auditoría.
  • La arquitectura del bucle de agentes en 4 etapas

    Un bucle de agentes robusto pasa por cuatro etapas distintas:

    1. Planificar: El modelo lee la intención del usuario, examina las herramientas disponibles y emite una solicitud estructurada a dichas herramientas.
    2. Validar: El backend intercepta esa solicitud y la verifica contra un esquema Zod así como las permisos del solicitante antes de que se ejecute cualquier lógica.
    3. Ejecutar: El backend realiza el trabajo determinista real, ya sea una lectura de base de datos, una llamada a una API externa o un trabajo de procesamiento, y registra el resultado bajo una clave de idempotencia.
  • Observe: El resultado de esa ejecución se devuelve al modelo como un mensaje estructurado tool. Luego, el modelo decide si se necesita otra llamada a la herramienta o si puede generar la respuesta final.
  • Implementación en producción con TypeScript

    La implementación a continuación puede guardarse como agent.ts. Incluye contratos de herramientas tipados, validación con Zod en tiempo de ejecución, verificaciones de autorización, protección contra idempotencia y límites estrictos en la ejecución.

    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();
    

    Realidades críticas en la producción

    1. Nunca permita que el sistema pase silenciosamente a un modo de respaldo. En sectores como las finanzas o la ingeniería regulada, sustituir silenciosamente un código de país no reconocido por una tasa predeterminada puede generar graves problemas de cumplimiento. En la implementación mostrada anteriormente, cualquier jurisdicción que el sistema no soporte explícitamente debe generar un error de tipo BusinessRuleError. Ese error se envía de vuelta al orquestador, el cual luego informa al usuario con precisión qué límite se ha superado en lugar de fabricar un número que parezca plausible.

    2. Proteja las operaciones de mutación con claves de idempotencia. Las operaciones que solo leen datos pueden intentarse nuevamente sin riesgo. Pero cualquier acción que modifique el estado, como enviar una factura, ajustar un saldo contable o activar un webhook, debe estar protegida por una clave de idempotencia. Al derivar dicha clave a partir de ${agentRunId}:${toolCallId}, se garantiza que si el modelo llama a la misma función dos veces al intentar recuperarse de un error, el backend reconozca la duplicación y evite reprocesarla.

    3. Divida los errores en categorías para que el modelo sepa cómo reaccionar. No todos los fallos deben provocar un nuevo intento por parte del LLM:

    • Errores de validación (validation_error): los argumentos estaban mal formados. El modelo puede leer los detalles a nivel de campo que informa Zod y corregir su carga en el siguiente intento.
  • Errores de negocio (business_error): se violó una regla definida, como una jurisdicción no soportada. El modelo puede intentar un enfoque diferente o mostrar la limitación al usuario.
  • Errores de permisos (permission_denied): estos no se pueden resolver para esa llamada de herramienta en particular. Dado que el modelo no tiene forma de otorgarse acceso adicional, el comportamiento correcto es detener la ejecución de manera ordenada en lugar de seguir intentándolo.
  • Lecturas relacionadas