Startseite / Artikel / Gebundene agenteische Schleifen: Zuverlässige TypeScript-Muster für die Nutzung von LLM-Tools

Gebundene agenteische Schleifen: Zuverlässige TypeScript-Muster für die Nutzung von LLM-Tools

Erfahren Sie, wie Sie TypeScript LLM-Agenten mit begrenzten agierenden Schleifen unter Verwendung von Zod-Validierung, deterministischer Tool-Ausführung sowie Fehlerbehebung für eine produktionstaugliche Zuverlässigkeit entwerfen können.

2150 Wörter

Die meisten Entwickler betrachten große Sprachmodelle immer noch als aufgewertete Suchfelder: Sie geben eine Anfrage ein, analysieren den zurückgegebenen Text und hoffen, dass die Arithmetik, die Berechtigungsprüfungen oder die Formatierungsregeln nicht irgendwo unterwegs fehlerhaft funktioniert haben.

Dieser Ansatz wird gefährlich, sobald generatives KI in Systeme eingeführt wird, in denen Genauigkeit tatsächlich entscheidend ist – wie beispielsweise bei der Rechnungsstellung, der Konformitätsprüfung oder dem Lagerverwaltung. Die Verwendung probabilistischer Token-Generierung als Quelle der Wahrheit stellt ein architektonisches Risiko dar und keine geringfügige Unbequemlichkeit. Auch wenn aktuelle Modelle in der Regel einfache Rechenoperationen korrekt ausführen können, sollte man niemals zulassen, dass probabilistische Textgenerierung als autoritatives Protokoll für geschäftskritische Logiken dient.

Das Muster, das tatsächlich skaliert, könnte man als begrenzten agierenden Kreislauf bezeichnen: Anstatt vom Modell direkt Antworten zu verlangen, lässt man es als Orchesterator fungieren. Es ruft deterministische Tools auf, prüft die stark typisierten Ergebnisse dieser Tools und kommt von Fehlern wieder auf die Beine – alles innerhalb strenger betrieblicher Grenzen.

Die Falle des Einzel-Schritts gegenüber begrenzten agierenden Kreisläufen

1. Ausführung von Logik und Mathematik

  • Einzel-Schritt-Anweisungen: Verlassen sich auf probabilistische Vorhersagen des nächsten Tokens, was ein echtes Risiko von Halluzinationen bei Dingen wie gesetzlichen Formeln, Währungsumrechnungen oder transaktionalen Regeln mit sich bringt.
  • Begrenzter agierender Kreislauf: Leitet alle Berechnungen an deterministische Backend-Dienste weiter, sodass numerische Ergebnisse präzise bleiben.

2. Datenvalidierung und Systemintegrität

  • Einzugangsprompting: Verlässt sich auf brüchige reguläre Ausdrücke oder manuelles String-Parsing, um strukturierte Werte aus freiem Text zu extrahieren.
  • Begrenzter Agentenzyklus: Überprüft jeden Tool-Parameter laufend anhand eines Zod-Schemas, bevor etwas Ihre Geschäftslogik oder Datenbankschicht erreicht.

3. Fehlertoleranz und Modellwiederherstellung

  • Einzugangsprompting:Fällt entweder ohne Erklärung aus oder stoppt vollständig, sobald das Modell einen ungültigen Parameter liefert oder ein erforderliches Feld weglässt.
  • Begrenzter Agentenzyklus:Gibt Validierungsfehler und Geschäftsregelfehler als Tool-Ausgabe zurück in das Gespräch, sodass das Modell beim nächsten Versuch mit korrigierten Parametern weitermachen kann.

4. Konkurrenz und parallele Ausführung

  • Einzeltakt-Prompting: Man ist auf sequenzielle, alles-in-einem-Verfahren angewiesen, die bei komplexeren Anfragen die Geschwindigkeit stark einschränken.
  • Begrenzter Agentenzyklus: Mehrere unabhängige Tool-Aufrufe werden gleichzeitig mithilfe von Promise.all gestartet.

Trennung der Verantwortlichkeiten: LLM gegen Backend

Um einen zuverlässigen Agenten zu erstellen, muss man trennen, was vom Modell entschieden wird und was tatsächlich ausgeführt wird:

  • Was das LLM übernimmt: Das Verstehen der Benutzerabsicht, die semantisch richtige Auswahl des Tools, das Erstellen von Argumenten für dieses Tool sowie die Zusammenstellung der endgültigen Antwort in natürlicher Sprache.
  • Was der Backend besitzt: Validierung von Schemata zur Laufzeit, Authentifizierung und Autorisierung des Aufrufers, Durchsetzung von Geschäftsregeln, Gewährleistung der Idempotenz, Ausführung von Berechnungen, Anwendung jeglicher Nebeneffekte sowie Protokollierung aller Vorgänge zur Prüfung.
  • Die 4-Stufen-Agentschleifen-Architektur

    Eine zuverlässige Agentschleife durchläuft vier getrennte Phasen:

    1. Planen: Das Modell liest die Absicht des Benutzers, prüft die verfügbaren Tools und sendet einen strukturierten Toolaufruf.
    2. Validieren: Der Backend fängt diesen Aufruf ab und überprüft ihn anhand eines Zod-Schemas sowie der Berechtigungen des Aufrufers, bevor irgendeine Logik ausgeführt wird.
    3. Ausführen: Der Backend führt die eigentliche, deterministische Arbeit aus – sei es ein Datenbankabruf, ein Aufruf einer externen API oder eine Rechenaufgabe – und speichert das Ergebnis unter einem Idempotenzschlüssel.
  • Bemerken: Das Ergebnis dieser Ausführung wird dem Modell als strukturierte tool-Nachricht zurückgegeben. Das Modell entscheidet anschließend, ob ein weiterer Tool-Aufruf erforderlich ist oder es bereits die endgültige Antwort liefern kann.
  • Produktionsimplementierung in TypeScript

    Die untenstehende Implementierung kann als agent.ts gespeichert werden. Sie umfasst typisierte Tool-Verträge, Zod-Validierung zur Laufzeit, Autorisierungsprüfungen, Idempotenzschutz sowie harte Grenzen für die Ausführung.

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

    Kritische Realitäten in der Produktion

    1. Lassen Sie niemals zu, dass das System stumm in einen Ersatzmodus wechselt. In Bereichen wie Finanzen oder regulierten Ingenieurwesen kann das stille Ersetzen eines nicht erkannten Landcodes durch einen Standardwert ernsthafte Konformitätsprobleme verursachen. In der zuvor gezeigten Implementierung muss jede Rechtsordnung, die das System nicht ausdrücklich unterstützt, einen typisierten BusinessRuleError auslösen. Dieser Fehler wird an den Orchestrierer zurückgesendet, der dem Benutzer anschließend genau mitteilt, welche Grenze überschritten wurde, anstatt eine plausibel aussehende Zahl zu erfinden.

    2. Schützen Sie mutierende Operationen mit Idempotenzschlüsseln. Operationen, die nur Daten lesen, können ohne Risiko erneut ausgeführt werden. Doch alles, was den Zustand verändert – sei es das Versenden einer Rechnung, die Anpassung eines Kontostands oder der Aufruf eines Webhooks – muss durch einen Idempotenzschlüssel geschützt werden. Indem Sie diesen Schlüssel aus ${agentRunId}:${toolCallId} ableiten, stellen Sie sicher, dass das Modell bei einem doppelten Aufruf derselben Funktion beim Versuch, einen Fehler zu beheben, vom Backend als Duplikat erkannt wird und die Verarbeitung übersprungen wird.

    3. Teilen Sie Fehler in Kategorien ein, damit das Modell weiß, wie es reagieren soll. Nicht jeder Fehler sollte dazu führen, dass das LLM erneut versucht:

    • Validierungsfehler (validation_error): Die Argumente waren fehlerhaft. Das Modell kann die auf Feldebene von Zod gemeldeten Details lesen und seine Datenmenge beim nächsten Versuch korrigieren.
  • Business-Fehler (business_error): Es wurde eine definierte Regel verletzt, beispielsweise ein nicht unterstütztes Rechtsgebiet. Das Modell kann entweder einen anderen Ansatz versuchen oder die Einschränkung dem Benutzer mitteilen.
  • Erlaubniss Fehler (permission_denied): Diese sind für diesen spezifischen Tool-Aufruf nicht behobar. Da das Modell keine Möglichkeit hat, sich selbst zusätzlichen Zugriff zu gewähren, ist das richtige Verhalten, die Ausführung sauber zu beenden, anstatt weiterhin zu versuchen.
  • Zusätzliche Literatur