NicoGIS Tech Weekly · Engineering Deep Dive
Distributed AI Agents in .NET: Fan-Out/Fan-In, Durable Orchestration, State Management e Verifiable Results
Dal parallelismo delle chiamate LLM a un sistema distribuito capace di recuperare i task, governare gli effetti collaterali e produrre risultati verificabili.
Abstract
Un sistema multi-agente non diventa distribuito soltanto perché avvia molte chiamate LLM in parallelo. Per essere utilizzabile in produzione deve garantire durabilità dell’orchestrazione, controllo della concorrenza, idempotenza, provenienza dei risultati e validazione indipendente dal modello. Questo laboratorio costruisce una pipeline fan-out/fan-in in .NET 10, distinguendo un prototipo eseguibile localmente da un deployment durevole su Azure.
Contratto del laboratorio: un coordinatore divide un dataset in partizioni; worker indipendenti producono osservazioni strutturate; un verificatore deterministico accetta solo risultati che rispettano schema, origine e vincoli; il reducer aggrega in modo ripetibile. Non si presume che un consenso fra LLM equivalga a una prova.
1. Il modello di esecuzione: fan-out/fan-in
Dato un insieme di unità di lavoro W = {w₁, …, wₙ}, il fan-out crea attività indipendenti Tᵢ = Analyze(wᵢ). Il fan-in attende le attività richieste e calcola R = Reduce(Validate(T₁), …, Validate(Tₙ)). Il parallelismo riduce il tempo totale soltanto se le attività sono sufficientemente indipendenti e le risorse esterne non sono già saturate.
La capacità effettiva dipende dal limite più stretto fra quote del provider LLM, connessioni, CPU, memoria, I/O e throughput del sistema di persistenza. Il numero di agenti attivi non deve essere usato come unica metrica di scalabilità.
2. Architettura di riferimento e confini di responsabilità
Separiamo orchestrator (decide quali attività attendere), activities (eseguono I/O e chiamate LLM), validator (controlli deterministici), result store (persistenza idempotente) e reducer (aggregazione).
↓
DURABLE ORCHESTRATOR (workflow ID, retry policy, fan-out/fan-in)
↓
ACTIVITIES → LLM / TOOLS → IMMUTABLE RESULT STORE
↓
SCHEMA + SOURCE + POLICY VALIDATORS
↓
DETERMINISTIC REDUCER → REPORT + PROVENANCE
Un agent framework gestisce conversazioni, strumenti e sessioni; un durable workflow engine gestisce il ciclo di vita delle esecuzioni. Le due responsabilità sono complementari, non intercambiabili.
3. Stato durevole, checkpoint e deterministic replay
Nei motori di orchestrazione durevole, lo stato di avanzamento viene ricostruito da eventi persistiti o checkpoint. Per questo l’orchestratore deve evitare operazioni non deterministiche: chiamate HTTP dirette, generazione casuale, letture dell’orologio di sistema e mutazioni esterne vanno spostate nelle attività. In caso di replay, il coordinatore deve poter ricostruire la stessa sequenza logica di decisioni.
La durabilità non implica exactly-once execution degli effetti esterni. Un’attività può completare una scrittura e interrompersi prima che il workflow registri il successo: il retry può rieseguire la stessa operazione. Il contratto corretto è at-least-once delivery + idempotent side effects.
Identificatore raccomandato: SHA256(workflowId | partitionId | analysisVersion | inputHash). Un vincolo univoco sul result store impedisce di inserire due volte lo stesso risultato logico.
4. Modello dati: risultati verificabili
public sealed record WorkItem(
string WorkflowId,
string PartitionId,
string InputSha256,
string Payload);
public sealed record Finding(
string SourceId,
string Claim,
string Evidence,
double Confidence);
public sealed record AgentResult(
string WorkflowId,
string PartitionId,
string InputSha256,
string ModelId,
string PromptVersion,
IReadOnlyList<Finding> Findings);
public sealed record VerifiedFinding(
string PartitionId,
string SourceId,
string Claim,
string Evidence);Confidence è un'autovalutazione del modello, non una probabilità calibrata né una prova. La provenienza deve includere almeno versione del prompt, modello, hash dell'input, identificativo del task e riferimenti alla fonte.
5. Laboratorio riproducibile: progetto .NET 10 senza dipendenze esterne
Il primo laboratorio è intenzionalmente eseguibile senza credenziali cloud né endpoint LLM. Un worker simulato restituisce osservazioni su record di input; la stessa interfaccia potrà poi essere sostituita con un client Microsoft Agent Framework. Il coordinatore locale implementa concorrenza limitata, cancellation e fan-in, ma non è un durable orchestrator.
dotnet new console -n NicoGIS.DistributedAgents -f net10.0
cd NicoGIS.DistributedAgents
# Sostituire Program.cs con il codice seguente
dotnet runusing System.Collections.Concurrent;
using System.Diagnostics;
using System.Security.Cryptography;
using System.Text;
var inputs = Enumerable.Range(1, 24)
.Select(i => new WorkItem("demo-2026-10-28", $"part-{i:D3}",
Hash($"record-{i}"), $"record-{i}"))
.ToArray();
var results = new ConcurrentDictionary<string, AgentResult>();
using var gate = new SemaphoreSlim(initialCount: 4);
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30));
var watch = Stopwatch.StartNew();
await Task.WhenAll(inputs.Select(async item =>
{
await gate.WaitAsync(timeout.Token);
try
{
var result = await AnalyzeAsync(item, timeout.Token);
// Demo: idempotenza in memoria, NON durevole.
results.TryAdd($"{item.WorkflowId}:{item.PartitionId}:{item.InputSha256}", result);
}
finally { gate.Release(); }
}));
var accepted = results.Values
.SelectMany(Validate)
.GroupBy(x => (x.PartitionId, x.SourceId, x.Claim))
.Select(g => g.First())
.OrderBy(x => x.PartitionId, StringComparer.Ordinal)
.ThenBy(x => x.SourceId, StringComparer.Ordinal)
.ToArray();
Console.WriteLine($"Partitions: {inputs.Length}; accepted: {accepted.Length}; elapsed: {watch.Elapsed}");
foreach (var f in accepted.Take(5))
Console.WriteLine($"{f.PartitionId} | {f.SourceId} | {f.Claim}");
static async Task<AgentResult> AnalyzeAsync(WorkItem item, CancellationToken ct)
{
await Task.Delay(80, ct); // Sostituire con una chiamata agent/LLM.
return new AgentResult(item.WorkflowId, item.PartitionId, item.InputSha256,
"deterministic-demo", "prompt-v1",
[new Finding(item.PartitionId, "Record elaborato", item.Payload, 0.9)]);
}
static IEnumerable<VerifiedFinding> Validate(AgentResult result)
{
foreach (var f in result.Findings)
{
if (f.SourceId != result.PartitionId ||
string.IsNullOrWhiteSpace(f.Claim) ||
string.IsNullOrWhiteSpace(f.Evidence)) continue;
yield return new(result.PartitionId, f.SourceId, f.Claim, f.Evidence);
}
}
static string Hash(string value) => Convert.ToHexString(
SHA256.HashData(Encoding.UTF8.GetBytes(value)));
public sealed record WorkItem(string WorkflowId, string PartitionId,
string InputSha256, string Payload);
public sealed record Finding(string SourceId, string Claim,
string Evidence, double Confidence);
public sealed record AgentResult(string WorkflowId, string PartitionId,
string InputSha256, string ModelId, string PromptVersion,
IReadOnlyList<Finding> Findings);
public sealed record VerifiedFinding(string PartitionId, string SourceId,
string Claim, string Evidence);Output atteso: 24 partizioni elaborate, 24 osservazioni accettate, cinque righe di anteprima. Il tempo varia in base alla macchina. Il demo non valida ancora la corrispondenza crittografica fra InputSha256 e contenuto originale: nel servizio reale la verifica deve avvenire recuperando l'input autorevole dal data store.
6. Da worker simulato ad agente LLM
Il punto di integrazione è AnalyzeAsync. In produzione il worker dovrebbe invocare un agente configurato con istruzioni versionate, un output JSON vincolato da schema e tool con permessi minimi. L'implementazione concreta dipende dalla versione dei pacchetti Microsoft Agent Framework: evitare di copiare API prerelease senza verificare la documentazione corrispondente.
Un contratto consigliato è IAgentAnalyzer.AnalyzeAsync(WorkItem, CancellationToken). Il client LLM deve gestire timeout, retry per errori transitori, rate limit 429, circuit breaker e telemetria per ogni tentativo. Il retry della chiamata LLM non va confuso con il retry dell'attività durevole.
public interface IAgentAnalyzer
{
Task<AgentResult> AnalyzeAsync(WorkItem item, CancellationToken cancellationToken);
}
// Contratto stabile dell'applicazione: l'adapter concreto può utilizzare
// Microsoft Agent Framework, Microsoft.Extensions.AI o un endpoint REST.
// L'output deve essere deserializzato e validato PRIMA di essere persistito.7. Durable orchestration: pattern e pseudocodice
Con Azure Durable Functions / Durable Task, il coordinatore programma attività persistenti e ne attende il completamento. Il seguente frammento è pseudocodice architetturale, non un esempio compilabile: nomi e firme delle API cambiano fra SDK e release.
// PSEUDOCODICE — non copiare come API reale
workflow = await LoadWorkflowInput();
partitions = DeterministicPartition(workflow.InputManifest);
tasks = partitions.Select(p => ScheduleDurableActivity("Analyze", p));
rawResults = await WhenAll(tasks);
verified = await ScheduleDurableActivity("ValidateAndPersist", rawResults);
report = await ScheduleDurableActivity("ReduceAndPublish", verified);
return report;Per migliaia di partizioni, evitare di caricare grandi payload nella history dell'orchestratore. Persistire i dati in Blob Storage o database e passare soltanto URI, hash e identificativi. Quando il fan-out è enorme, introdurre batch gerarchici e sub-orchestrations per limitare dimensione della history e pressione sullo scheduler.
Il Durable Task Scheduler è il backend di scheduling/gestione durevole: il modello di programmazione scelto (Durable Functions o SDK Durable Task) deve essere verificato con la release installata.
8. Idempotenza, race condition e failure injection
La combinazione critica è: side effect completato + acknowledgment perso. Un retry può ripetere la pubblicazione. Per questo una scrittura deve essere atomica rispetto alla chiave logica del task. SQL Server può usare un vincolo UNIQUE e una transazione; Blob Storage può utilizzare precondizioni di creazione; l'outbox pattern separa la persistenza del risultato dall'invio di eventi esterni.
CREATE TABLE dbo.AgentResults (
Id bigint IDENTITY(1,1) PRIMARY KEY,
WorkflowId nvarchar(100) NOT NULL,
PartitionId nvarchar(100) NOT NULL,
InputSha256 char(64) NOT NULL,
ResultJson nvarchar(max) NOT NULL,
CreatedAtUtc datetime2 NOT NULL DEFAULT SYSUTCDATETIME(),
CONSTRAINT UQ_AgentResults UNIQUE (WorkflowId, PartitionId, InputSha256)
);Test di resilienza: terminare un worker dopo il commit ma prima dell'ack; introdurre HTTP 429; rendere un task più lento degli altri; duplicare un messaggio; interrompere il coordinatore; cambiare la versione del prompt. Ogni test deve produrre un esito osservabile e verificabile.
9. Verifiable results: che cosa possiamo davvero dimostrare?
La validazione ha più livelli. Strutturale: JSON/schema e campi obbligatori. Referenziale: SourceId appartiene alla partizione e l'evidenza esiste nel dataset. Semantica deterministica: unità, intervalli, vincoli di dominio e calcoli ripetibili. Revisione assistita: per affermazioni non verificabili automaticamente, il risultato viene marcato come ipotesi e inviato a revisione umana.
Il consenso di cinque agenti sullo stesso claim non è una prova indipendente se condividono modello, prompt, dataset o errore sistematico. Un secondo LLM può aiutare nel triage, ma non sostituisce una fonte autorevole.
Per ogni finding conservare: workflowId, partitionId, hash input, versione prompt, modello, versione tool, riferimento esatto all'evidenza, risultato dei controlli, timestamp e identificativo di esecuzione.
10. Performance engineering: misure e modello di capacità
Per N task indipendenti con durata media t e concorrenza effettiva C, un limite inferiore ideale è T ≳ ceil(N/C) × t, a cui sommare overhead di scheduling, code, retry e riduzione. Se il provider limita a Q richieste al secondo, il throughput non può superare Q senza cambiare quota o workload.
| Metrica | Perché serve |
|---|---|
| End-to-end latency P50/P95/P99 | Misura l'esperienza completa, non solo la risposta del modello |
| Task throughput e queue age | Individuano saturazione e accumulo |
| Retry rate / dead-letter count | Quantificano instabilità e fallimenti permanenti |
| Token per task e costo per risultato verificato | Collegano spesa a qualità effettiva |
| Validation reject rate | Rileva degrado di modello, prompt o fonti |
| Replay / recovery duration | Misura la ripresa dopo crash |
Il benchmark deve confrontare almeno concorrenza 1, 4, 8 e 16 con dataset fisso, warm-up, stesso modello, stessi limiti di quota e ripetizioni sufficienti a stimare la variabilità. Il prototipo locale usa un delay artificiale: serve a verificare il pattern, non a misurare un LLM reale.
11. Sicurezza, tenant isolation e governance
Ogni worker deve ricevere un contesto di autorizzazione minimo e non poter ampliare i propri privilegi tramite il prompt. Gli strumenti applicano le policy sul server. Separare storage, cache e log per tenant; non inserire segreti nei prompt; utilizzare Managed Identity quando disponibile; limitare e controllare gli endpoint di rete. Le azioni distruttive richiedono un approval gate indipendente dal modello.
La telemetria deve permettere la correlazione workflow → activity → model request → tool call → stored result, evitando di registrare indiscriminatamente dati sensibili o interi prompt in log accessibili a più utenti.
12. Checklist per il passaggio in produzione
- Contratto JSON versionato, validazione di schema e provenienza.
- Durable orchestration con activity idempotenti e chiavi univoche.
- Limiti di concorrenza per modello, tenant e risorsa esterna.
- Timeout, retry con backoff/jitter, dead-letter e intervento umano.
- Result store transazionale, outbox per pubblicazioni esterne.
- Test di crash e replay, duplicazione messaggi e fault injection.
- Metriche di qualità oltre a token, latenza e throughput.
- Controlli di autorizzazione server-side e audit end-to-end.
13. Riferimenti tecnici
Per verificare le API e la compatibilità delle release, consultare la documentazione ufficiale prima di implementare gli adapter:
- Microsoft Learn — Durable Functions overview
- Microsoft Learn — Orchestrator code constraints
- Microsoft Learn — Performance and scale
- Microsoft Learn — Durable Functions best practices
- Microsoft Learn — Microsoft Agent Framework
- Microsoft Learn — AI for .NET developers
- Azure Architecture Center — Competing Consumers pattern
- Azure Architecture Center — Queue-Based Load Leveling
Nota editoriale: la parte di codice locale è autosufficiente; la sezione di durable orchestration descrive il pattern senza attribuire firme non verificate a SDK in evoluzione. Versioni, pacchetti e supporto del backend devono essere verificati al momento del deployment.
Conclusioni
La parallelizzazione è soltanto il primo passo. Il valore di una piattaforma multi-agente distribuita emerge quando ogni attività può essere recuperata, ogni effetto esterno può essere deduplicato e ogni risultato può essere ricondotto a una fonte verificabile. Un sistema che genera molte risposte rapidamente non è necessariamente affidabile: l'unità di misura più utile è il costo per risultato verificato e riproducibile.