Accueil / Articles / Boucles d’agentie bornées : modèles fiables en TypeScript pour l’utilisation d’outils de LLM

Boucles d’agentie bornées : modèles fiables en TypeScript pour l’utilisation d’outils de LLM

Apprenez à concevoir des agents LLM en TypeScript en utilisant des boucles d’agentisation bornées avec la validation Zod, une exécution de outils déterministe et une récupération d’erreurs pour assurer une fiabilité de niveau production.

2150 mots

La plupart des développeurs considèrent encore les grands modèles de langage comme de simples boîtes de recherche améliorées : ils envoient une requête, analysent la chaîne de caractères qui revient et espèrent que les calculs, les vérifications de permissions ou les règles de formatage ne se sont pas déréglés quelque part en cours de route.

Cette approche devient dangereuse dès que l’on intègre de l’IA générative dans des systèmes où la précision est essentielle, tels que la facturation, le respect des réglementations ou la gestion des stocks. Considérer la génération probabiliste de tokens comme source unique de vérité représente un risque architectural, et non une simple gêne. Même si les modèles actuels parviennent généralement à effectuer correctement des calculs simples, on ne doit jamais permettre à la génération de texte probabiliste de servir de registre fiable pour des logiques critiques pour l’entreprise.

Le schéma qui permet réellement une mise à l’échelle est ce que l’on pourrait appeler un cycle d’agentie borné : au lieu de demander directement au modèle de produire des réponses, on le laisse agir en tant qu’orchestrateur. Il appelle des outils déterministes, examine les résultats fortement typés générés par ces outils et se remet des échecs, tout cela dans le cadre de limites opérationnelles strictes.

Le piège du tour unique vs. les cycles d’agentie bornés

1. Exécution de la logique et des calculs mathématiques

  • Induction par prompt en un seul tour : Dépend de la prédiction probabiliste du token suivant, ce qui crée un véritable risque d’hallucinations pour des éléments tels que les formules légales, les conversions de devises ou les règles transactionnelles.
  • Cycle d’agentie borné : Transfère tous les calculs à des services backend déterministes, ce qui garantit la précision des résultats numériques.

2. Validation des données et intégrité du système

  • Prompting en une seule étape : S’appuie sur des expressions régulières fragiles ou sur un analyse manuelle de chaînes de caractères pour extraire des valeurs structurées à partir de texte libre.
  • Boucle d’agentie bornée :Vérifie chaque argument de l’outil contre un schéma Zod en temps réel, avant que quoi que ce soit n’atteigne la logique métier ou la couche de base de données.

3. Tolérance aux pannes et récupération du modèle

  • Prompting en une seule étape :Féchit sans explication ou s’arrête complètement dès que le modèle fournit un argument invalide ou omet un champ requis.
  • Boucle d’agentie bornée :Renvoie les échecs de validation et les erreurs de règles métier dans la conversation sous forme de sortie d’outil, permettant au modèle de tenter à nouveau avec des paramètres corrigés lors de sa prochaine étape.

4. Concurrency et exécution parallèle

  • Incitation en une seule étape : Dépendance à des génération séquentielles et intégrées qui ralentissent tout ce qui dépasse les demandes simples.
  • Boucle d’agentie bornée : Envoi simultané de plusieurs appels à des outils indépendants grâce à Promise.all.

Division des responsabilités : LLM vs. Backend

Créer un agent fiable signifie séparer ce que le modèle décide de ce qui est effectivement exécuté :

  • Rôle du LLM : Comprendre l’intention de l’utilisateur, choisir le bon outil sur le plan sémantique, générer les arguments nécessaires à cet outil, et composer la réponse finale en langue naturelle.
  • Ce que possède le backend : validation des schémas en temps de exécution, authentification et autorisation de l’appelant, application des règles métier, garantie d’idempotence, exécution de calculs, application de tout effet secondaire, ainsi que journalisation de tout pour des fins d’audit.
  • L’architecture du cycle de l’agent en 4 étapes

    Un cycle d’agent fiable passe par quatre étapes distinctes :

    1. Planification : Le modèle lit l’intention de l’utilisateur, examine les outils à sa disposition et émet une requête structurée vers un outil.
    2. Validation : Le backend intercepte cette requête et la vérifie contre un schéma Zod ainsi que les permissions de l’appelant avant toute exécution de logique.
    3. Exécution : Le backend effectue le travail déterministe réel, qu’il s’agisse d’une lecture dans une base de données, d’une appel à une API externe ou d’un job de calcul, et enregistre l’effet associé sous une clé d’idempotence.
  • Remarque : Le résultat de cette exécution est renvoyé au modèle sous forme de message tool structuré. Le modèle détermine ensuite s’il est nécessaire d’appeler une autre fonctionnalité ou s’il peut produire la réponse finale.
  • Mise en œuvre en production avec TypeScript

    L’implémentation ci-dessous peut être enregistrée sous le nom de agent.ts. Elle prend en charge des contrats de fonctionnalités typés, une validation Zod en temps de exécution, des vérifications d’autorisation, une protection contre l’idempotence, ainsi que des limites strictes concernant l’exécution.

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

    Réalités critiques en environnement de production

    1. Ne permettez jamais au système de basculer silencieusement en mode de secours. Dans des domaines tels que la finance ou l’ingénierie réglementée, remplacer silencieusement un code pays non reconnu par un taux par défaut peut engendrer de graves problèmes de conformité. Dans l’implémentation présentée précédemment, toute juridiction que le système ne prend pas explicitement en charge doit déclencher une erreur de type BusinessRuleError. Cette erreur est renvoyée à l’orchestrateur, qui indique alors à l’utilisateur précisément quelles limites ont été atteintes, au lieu de fournir un chiffre qui semble plausible.

    2. Protégez les opérations de modification avec des clés d’idempotence. Les opérations qui se contentent de lire des données peuvent être réessayées sans risque. Mais toute opération qui modifie l’état, qu’il s’agisse d’envoyer une facture, de corriger le solde d’un registre ou d’exécuter un webhook, doit être contrôlée à l’aide d’une clé d’idempotence. En dérivant cette clé à partir de ${agentRunId}:${toolCallId}, vous assurez que si le modèle appelle deux fois la même fonction en tentant de se remettre d’une erreur, le backend reconnaîtra ce doublon et évitera de la retraiter.

    3. Classez les erreurs en catégories afin que le modèle sache comment réagir. Toute panne ne doit pas déclencher un nouvel essai de la part du LLM :

    • Erreurs de validation (validation_error) : les arguments étaient mal formatés. Le modèle peut consulter les détails au niveau des champs rapportés par Zod et corriger son envoi lors de la prochaine tentative.
  • Erreurs métier (business_error) : une règle définie a été violée, comme l’utilisation d’une juridiction non prise en charge. Le modèle peut soit essayer une approche différente, soit signaler cette limitation à l’utilisateur.
  • Erreurs de permission (permission_denied) : ces erreurs ne peuvent pas être résolues pour cette requête spécifique. Comme le modèle ne peut pas s’accorder des droits supplémentaires, il convient d’arrêter l’exécution proprement plutôt que de continuer à essayer.
  • Lectures complémentaires