Ograniczone pętle agentowe: niezawodne wzorce TypeScript do korzystania z narzędzi opartych na LLM
Dowiedz się, jak projektować agenty typu TypeScript LLM przy użyciu ograniczonych pętli agentowych z walidacją Zod, deterministycznego wykonywania narzędzi oraz mechanizmów naprawy błędów, aby zapewnić niezawodność na poziomie produkcyjnym.
Większość programistów nadal traktuje modele językowe dużego rozmiaru jak udoskonalone pola wyszukiwania: wprowadza się prompt, analizuje otrzymaną treść i ma nadzieję, że obliczenia, sprawdzanie uprawnień czy zasady formatowania nie uległy potajemnemu błędowi w trakcie procesu.
Taki podejście staje się niebezpieczne w momencie, gdy wprowadza się sztuczną inteligencję generatywną do systemów, gdzie ważna jest dokładność, takich jak księgowość, przestrzeganie regulacji czy zarządzanie zapasami. Traktowanie probabilistycznej generacji tokenów jako źródła prawdy stanowi ryzyko architektoniczne, a nie drobną niedogodność. Nawet jeśli modele obecnej generacji zazwyczaj potrafią poprawnie wykonać proste obliczenia, nigdy nie należy pozwalać na to, by probabilistyczna generacja tekstu służyła jako autorytatywny zapis logiki kluczowej dla biznesu.
Wzorzec, który faktycznie się skaluje, to to, co można nazwać ograniczonym pętlą agentową: zamiast prosić model o bezpośrednie generowanie odpowiedzi, pozwala się mu pełnić rolę orkiestratora. Model wywołuje narzędzia deterministyczne, sprawdza silnie typowane wyniki uzyskane z tych narzędzi oraz radzi sobie z błędami, wszystko to w ramach ścisłych ograniczeń operacyjnych.
Pułapka jednego kroku vs. ograniczone pętle agentowe
1. Wykonywanie zadań logicznych i matematycznych
- Wspomaganie jednym krokiem: Opiera się na probabilistycznej prognozie następnego tokena, co stwarza realne ryzyko halucynacji w przypadku takich zadań jak formuły prawne, konwersje walut czy reguły transakcyjne.
- Ograniczona pętla agentowa: Przenosi całe obliczenia na deterministyczne usługi backendowe, dzięki czemu wyniki liczbowe pozostają dokładne.
2. Walidacja danych i integralność systemu
- Single-Turn Prompting: Opiera się na kruchych wyrażeniach regularnych lub ręcznym analizowaniu ciągów znaków, aby wydobyć ustrukturyzowane wartości z tekstu nieustrukturyzowanego.
- Bounded Agentic Loop: Sprawdza każdy argument narzędzia pod kątem schematu Zod w czasie wykonywania, zanim cokolwiek dotrze do logiki biznesowej lub warstwy bazy danych.
3. Tolerancja na awarie i odzyskiwanie modelu
- Single-Turn Prompting: Albo zawodzi bez wyjaśnienia, albo zatrzymuje się całkowicie w momencie, gdy model dostarcza nieważny argument lub pominie wymagane pole.
- Bounded Agentic Loop: Przekazuje błędy walidacji i problemy z regułami biznesowymi z powrotem do rozmowy jako wynik narzędzia, umożliwiając modelowi ponowną próbę z poprawionymi parametrami w następnej turze.
4. Konkurencja i równoległe wykonywanie
- Procedura jednorazowa: Używanie sekwencyjnych, kompleksowych generacji, które spowalniają przetwarzanie jakichkolwiek zadań wykraczających poza proste żądania.
- Zamknięty cykl agenta: Jednoczesne wysyłanie kilku niezależnych wywołań narzędzi za pomocą
Promise.all.
Rozdział obowiązków: LLM vs. Backend
Budowa niezawodnego agenta wymaga rozdzielenia tego, co decyduje model, od tego, co faktycznie jest wykonywane:
- To, co obejmuje LLM: Rozumienie intencji użytkownika, semantyczny wybór odpowiedniego narzędzia, generowanie argumentów dla tego narzędzia oraz tworzenie ostatecznej odpowiedzi w języku naturalnym.
Architektura pętli agenta w 4 etapach
Robusta pętla agenta przechodzi przez cztery odrębne etapy:
- Planowanie: model odczytuje intencję użytkownika, sprawdza dostępne narzędzia i wysyła strukturyzowane żądanie do tych narzędzi.
- Walidacja: backend przechwytuje to żądanie i sprawdza je pod kątem schematu Zod oraz uprawnień osoby wykonującej żądanie, zanim zostanie uruchomiona jakakolwiek logika.
- Egzekucja: backend wykonywa faktyczną, deterministyczną pracę – czy to odczyt z bazy danych, żądanie do zewnętrznego API czy zadanie obliczeniowe – i rejestruje wynik pod kluczem idempotencji.
tool. Model następnie decyduje, czy potrzebna jest kolejna wywołanie narzędzia, czy może już dostarczyć ostateczną odpowiedź.Wdrożenie w produkcji w TypeScript
Poniższe wdrożenie można zapisać jako agent.ts. Obejmuje ono typowane umowy narzędzi, walidację za pomocą Zod w czasie wykonywania, sprawdzanie uprawnień, ochronę przed idempotencją oraz sztywne ograniczenia dotyczące eksploatacji.
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();
Krytyczne realia produkcji
1. Nigdy nie pozwól, by system cicho przechodził na tryb awaryjny. W dziedzinach takich jak finanse czy inżynieria regulowana, ciche zastąpienie niezidentyfikowanego kodu kraju jakimś domyślnym stawkiem może spowodować poważne problemy z przestrzeganiem regulacji. W wcześniej pokazanej implementacji każda jurysdykcja, której system nie obsługuje wyraźnie, musi wywołać typowany błąd BusinessRuleError. Ten błąd trafia z powrotem do orkiestratora, który następnie informuje użytkownika dokładnie, jaka granica została przekroczona, zamiast podawać liczby wyglądające wiarygodnie.
2. Ochrona operacji mutujących za pomocą kluczy idempotencji. Operacje, które jedynie odczytują dane, mogą być ponownie wykonywane bez ryzyka. Jednak wszystko, co zmienia stan – np. wysyłanie faktury, korekta salda w księgach rachunkowych lub uruchamianie webhooka – musi być zabezpieczone kluczem idempotencji. Używając klucza wygenerowanego z ${agentRunId}:${toolCallId}, zapewniasz, że jeśli model wezwie tę samą funkcję dwukrotnie podczas próby naprawy błędu, serwer backend rozpozna duplikat i pominie ponowną obróbkę.
3. Podział błędów na kategorie, aby model wiedział, jak zareagować. Nie każda awaria powinna skutkować ponowną próbą ze strony LLM:
- Błędy walidacji (
validation_error): argumenty były nieprawidłowo sformatowane. Model może przeczytać szczegóły na poziomie pól zgłoszone przez Zod i skorygować swoją treść przy następnej próbie.
business_error): naruszono zdefiniowane reguły, np. dotyczące nieobsługiwanej jurysdykcji. Model może albo spróbować innego podejścia, albo poinformować o tym ograniczeniu użytkownika.permission_denied): w takich przypadkach nie da się odzyskać funkcjonalności dla danej operacji narzędzia. Ponieważ model nie może sam sobie przyznać dodatkowego dostępu, właściwym zachowaniem jest zatrzymanie wykonywania zadań w sposób uporządkowany, zamiast ciągle je próbować.Literatura pokrewna
- Dzielenie sobą jednym schema Zod między frontendem React a backendem Node — Dowiedz się, jak pojedyncze schema Zod może weryfikować formularze React, odpowiedzi API, treści żądań Express oraz zmienne środowiskowe, jednocześnie generując odpowiadające im typy TypeScript.
- MCP dla agentów AI: Standardyzacja integracji narzędzi w LangGraph — Ten artykuł wyjaśnia, co dokładnie standardyzuje MCP w systemach AI opartych na agentach, porównując ad hoc integracje narzędzi z tymi opartymi na MCP w ramach orkiestratora LangGraph.