Spcnet.it Notizie geek e OSS/Linux. Su Telegram: InfoSec ITA Notizie
Home > Articolo > Un agente AI è un sistema distribuito, non un chatbot: pattern di durable orchestration in .NET
Un agente AI è un sistema distribuito, non un chatbot: pattern di durable orchestration in .NET

Negli ultimi mesi molti team hanno scoperto a proprie spese che il problema più grande di un agente AI in produzione non è la qualità del prompt, ma la sua affidabilità operativa. Un articolo recente su DZone, “Your AI Agent Is a Distributed System, Not a Chatbot”, mette a fuoco un punto che chi progetta sistemi backend conosce bene da anni: se un componente deve sopravvivere a crash, timeout, retry e attese umane di ore, non puoi trattarlo come una semplice chiamata sincrona a un modello. Devi trattarlo come un sistema distribuito, con tutto ciò che questo comporta: stato persistente, checkpoint, idempotenza e recovery.

In questo articolo riprendiamo quei concetti e li caliamo nella pratica con .NET, mostrando come Azure Durable Functions (o alternative come Temporal) permettano di costruire agenti AI multi-step che non perdono lo stato quando qualcosa va storto — cosa che, in produzione, prima o poi succede sempre.

Perché un chatbot stateless non basta

Un chatbot classico è un ciclo request/response: l’utente scrive, il modello risponde, fine. Un agente AI enterprise, invece, tipicamente deve:

  • orchestrare più chiamate a modelli e strumenti in sequenza o in parallelo;
  • interrogare sistemi esterni (CRM, ticketing, knowledge base, database);
  • attendere l’approvazione di un umano, che può richiedere minuti oppure ore;
  • gestire fallimenti parziali senza dover ripartire da zero;
  • garantire che un’azione con effetti collaterali (un rimborso, un invio email, una scrittura su un sistema esterno) non venga eseguita due volte per colpa di un retry.

Nessuno di questi requisiti è nuovo: sono gli stessi problemi che i sistemi distribuiti risolvono da decenni con pattern come saga, checkpointing e idempotency key. La differenza è che oggi il “servizio downstream” spesso è un LLM, e la latenza dominante non è più quella di rete, ma quella dell’attesa umana.

Durable orchestration: lo stato che sopravvive al crash

Il pattern architetturale proposto è un runtime di orchestrazione durevole, che mantiene lo stato del workflow indipendentemente dal ciclo di vita del processo che lo esegue. In .NET, l’implementazione più diretta è Azure Durable Functions, che introduce tre concetti chiave:

  • Orchestrator function: decide “cosa succede dopo”, applica le retry policy e coordina le chiamate, ma non esegue lavoro con effetti collaterali direttamente;
  • Activity function: esegue il lavoro reale (chiamata al modello, query, side effect) ed è il livello dove si applica l’idempotenza;
  • Checkpointing automatico: dopo ogni `await`, il runtime salva lo stato dell’orchestrazione, così un crash del processo non fa perdere il progresso già fatto.

Un’alternativa nota, citata anche nell’articolo originale, è Temporal, che applica lo stesso principio con un modello di programmazione simile ma un runtime a sé stante, spesso preferito in contesti multi-linguaggio o Kubernetes-native.

Il pattern fan-out/fan-in per agenti multipli

Quando un task richiede il contributo di più agenti specializzati (per esempio: un agente diagnostico, uno di ricerca sulla knowledge base, uno che consulta lo storico dei casi e uno che verifica le policy aziendali), il pattern corretto è il fan-out/fan-in: l’orchestratore lancia tutte le attività in parallelo e aggrega i risultati solo quando sono tutte terminate.

Ecco un esempio realistico in C# con il modello isolato di Azure Functions, ispirato agli esempi ufficiali Microsoft:

[Function("SupportInvestigationOrchestrator")]
public static async Task<InvestigationResult> Run(
    [OrchestrationTrigger] TaskOrchestrationContext context)
{
    var caseId = context.GetInput<string>();

    // Fan-out: 4 agenti specializzati lanciati in parallelo
    var diagnosticTask = context.CallActivityAsync<AgentFinding>(
        "RunDiagnosticAgent", caseId);
    var knowledgeTask = context.CallActivityAsync<AgentFinding>(
        "RunKnowledgeSearchAgent", caseId);
    var historyTask = context.CallActivityAsync<AgentFinding>(
        "RunHistoricalCaseAgent", caseId);
    var policyTask = context.CallActivityAsync<AgentFinding>(
        "RunPolicyAgent", caseId);

    // Fan-in: si attende che tutti i task completino
    AgentFinding[] findings = await Task.WhenAll(
        diagnosticTask, knowledgeTask, historyTask, policyTask);

    // Sintesi della raccomandazione finale
    var recommendation = await context.CallActivityAsync<Recommendation>(
        "SynthesizeRecommendation", findings);

    return new InvestigationResult(caseId, findings, recommendation);
}

Il vantaggio rispetto a un semplice `Task.WhenAll` in un servizio stateless è che, se il processo host crasha mentre due agenti su quattro hanno già risposto, l’orchestrazione riparte dal checkpoint e non richiama gli agenti già completati.

Human-in-the-loop senza tenere aperta la compute

Il collo di bottiglia più comune non è il modello, ma l’attesa di un’approvazione umana. Un workflow che tenesse una funzione serverless “in ascolto” per ore sarebbe insostenibile in termini di costo e di timeout. Il pattern corretto è sospendere l’orchestrazione in attesa di un evento esterno:

[Function("SupportInvestigationOrchestrator")]
public static async Task<InvestigationResult> Run(
    [OrchestrationTrigger] TaskOrchestrationContext context)
{
    // ... fan-out/fan-in come sopra ...

    await context.CallActivityAsync("NotifyHumanReviewer", recommendation);

    // Il workflow si sospende senza consumare risorse compute
    // fino a quando l'evento non arriva, anche dopo diverse ore
    var decision = await context.WaitForExternalEvent<HumanReviewDecision>(
        "HumanReviewCompleted");

    if (decision.Approved)
    {
        await context.CallActivityAsync("ApplyResolution", recommendation);
    }

    return new InvestigationResult(caseId, findings, recommendation, decision);
}

L’evento viene “consegnato” all’orchestrazione in pausa tramite una chiamata separata (per esempio da una Function HTTP-triggered collegata a un pulsante “Approva” nella UI), e il runtime si occupa di riprendere l’esecuzione esattamente dal punto in cui era stata sospesa.

Idempotenza: il vero rischio nascosto

Un runtime durevole garantisce tipicamente semantica at-least-once: dopo un crash, un’attività può essere rieseguita. Questo va benissimo per operazioni pure (una query di lettura), ma è pericoloso per operazioni con side effect (un rimborso, un invio email, una scrittura irreversibile). Il momento critico è il cosiddetto crash window: l’intervallo tra il completamento effettivo di un’azione e il salvataggio del suo checkpoint.

La soluzione è associare a ogni operazione critica una idempotency key deterministica, così che una riesecuzione accidentale venga riconosciuta e ignorata a livello applicativo:

[Function("ApplyGoodwillRefund")]
public static async Task ApplyGoodwillRefund(
    [ActivityTrigger] RefundRequest request)
{
    string idempotencyKey = $"{request.CaseId}:goodwill-refund";

    if (await _paymentService.WasAlreadyProcessedAsync(idempotencyKey))
    {
        return; // operazione già eseguita, nessun effetto collaterale duplicato
    }

    await _paymentService.ProcessRefundAsync(request, idempotencyKey);
}

Questo pattern, ben noto a chi lavora con gateway di pagamento, va applicato sistematicamente a ogni activity che tocchi sistemi esterni non idempotenti per natura.

Successo parziale, non tutto-o-niente

Se uno dei quattro agenti fallisce (per esempio, il servizio di knowledge search va in timeout), il sistema non dovrebbe far fallire l’intera indagine. Meglio trattare i risultati come esiti strutturati, distinguendo tra “successo”, “fallito” e “degradato”, e permettere che la sintesi finale proceda comunque, segnalando esplicitamente quali fonti mancano:

public record AgentFinding(
    string AgentName,
    AgentStatus Status,   // Success, Failed, Degraded
    string? Result,
    string? FailureReason);

Questo approccio “graceful degradation” è preferibile a un fallimento totale che obbliga a rieseguire da capo un’indagine costosa in termini di tempo e token consumati.

Osservabilità come requisito di prodotto

In un sistema dove un’indagine può durare ore e coinvolgere quattro o più agenti, la tracciabilità non è un dettaglio tecnico: è parte dell’esperienza utente e, in molti contesti regolamentati, un requisito di audit. Vale la pena tracciare sistematicamente:

  • workflow instance ID, correlation ID e case ID;
  • lo stage corrente e la durata di ogni singolo agente;
  • il numero di retry e il motivo di ogni fallimento;
  • la latenza di revisione umana (spesso il fattore dominante);
  • la tracciabilità delle evidenze usate per la raccomandazione finale, per scopi di audit.

Conclusione

Il messaggio di fondo è semplice ma spesso trascurato: il workflow conta più del prompt. Un agente AI ben progettato non è quello con il prompt più raffinato, ma quello costruito su un runtime capace di sopravvivere a crash, gestire attese umane di ore senza sprecare risorse, ed evitare effetti collaterali duplicati. Per chi lavora nello stack .NET, Azure Durable Functions offre oggi gli strumenti necessari per applicare questi pattern senza dover reinventare un motore di orchestrazione da zero; per contesti poliglotta o già containerizzati, Temporal resta un’alternativa solida con la stessa filosofia.

Fonte originale: “Your AI Agent Is a Distributed System, Not a Chatbot” su DZone.

Condividi: Twitter  |  Facebook  |  LinkedIn
Unisciti alla discussione

Questo è un blog del Fediverso: puoi trovare questo articolo ovunque con @blog@insicurezzadigitale.com e ogni commento/risposta apparirà qui sotto.

Se vuoi commentare su Un agente AI è un sistema distribuito, non un chatbot: pattern di durable orchestration in .NET, utilizza la discussione sul Forum.

>> forum community