In questo articolo

🧊 Perché un LLM non si testa con un assert

Un test unitario verifica un contratto deterministico: stesso input, stesso output. Un LLM rompe l'assunzione alla radice: la stessa domanda produce formulazioni diverse a ogni chiamata, tutte potenzialmente corrette. Assert.Equal su una risposta generata è un test che mente: rosso su risposte buone, verde solo per coincidenza.

La LLM evaluation sostituisce il confronto con il giudizio: metriche che misurano proprietà della risposta — è pertinente? è coerente? si appoggia solo al contesto fornito? — assegnando un punteggio da 1 a 5. Le metriche di qualità le calcola un altro modello che fa da giudice (LLM-as-judge); quelle deterministiche, come la lunghezza, restano codice normale.

Test unitario classico vs. LLM evaluation

Assert su stringhe

  • Stesso input ⇒ output diversi a ogni run
  • Rosso anche su risposte corrette
  • Nessuna misura di qualità, solo uguaglianza
  • Impossibile tracciare i trend nel tempo

Evaluation con metriche

  • Punteggi 1-5 su relevance, coherence, groundedness
  • Il giudizio tollera le riformulazioni
  • Metriche custom deterministiche dove serve
  • Report e trend per scenario, run dopo run

Il confronto misura l'uguaglianza; la valutazione misura la qualità.

🧱 La libreria Microsoft.Extensions.AI.Evaluation

Microsoft.Extensions.AI.Evaluation è la famiglia di pacchetti NuGet che porta la valutazione dentro i test .NET: si appoggia alle astrazioni di Microsoft.Extensions.AI (IChatClient), quindi funziona con qualsiasi provider — Ollama in locale, Azure OpenAI, qualunque backend con un client compatibile.

Si integra con il test framework che già usi — io uso xunit — e con il normale dotnet test: niente runner speciali, niente piattaforme esterne. I pezzi sono quattro:

  • Evaluation — le astrazioni: IEvaluator, EvaluationResult, le metriche (NumericMetric, BooleanMetric) e le interpretazioni.
  • Evaluation.Quality — gli evaluator LLM-as-judge pronti: RelevanceEvaluator, CoherenceEvaluator, GroundednessEvaluator, CompletenessEvaluator e gli agent-focused come ToolCallAccuracyEvaluator.
  • Evaluation.Reporting — ScenarioRun, cache delle risposte e store dei risultati su disco (o Azure Storage).
  • Evaluation.Console — il tool aieval che dai risultati genera il report HTML.

🏗️ L'architettura della suite

Il sistema sotto test è un assistente di supporto minimale: riceve una domanda e un contesto (la voce di FAQ pertinente) e deve rispondere solo con ciò che il contesto contiene, in meno di 80 parole, nella lingua della domanda. Piccolo apposta: il protagonista è il banco di prova, non l'assistente.

Ogni caso di valutazione diventa uno scenario: il test xunit crea uno ScenarioRun, ottiene la risposta dell'assistente, la fa giudicare dagli evaluator e lascia che il framework persista punteggi e cache su disco. Il modello sotto test e il giudice sono due configurazioni separate: oggi sono entrambi llama3.1 locale, domani il giudice può diventare un modello più grande senza toccare codice.

Architettura: un test, uno scenario, un punteggio
Architettura della suite di valutazione: il test xunit crea uno ScenarioRun, l'assistente SupportAssistant risponde con il modello llama3.1 via IChatClient, scenario.EvaluateAsync esegue RelevanceEvaluator, CoherenceEvaluator, GroundednessEvaluator con contesto e AnswerLengthEvaluator custom; cache e risultati vanno nella cartella TestReports e aieval genera il report HTML.

SUT e giudice sono due IChatClient distinti; cache e risultati vivono in TestReports/.

⬇️ Passo 1 · Ollama con Docker Compose

L'unica dipendenza esterna è Ollama. Nel repo la accendo con Docker Compose: un servizio per il server sull'endpoint http://localhost:11434 e un servizio one-shot che scarica llama3.1 nel volume condiviso e termina. Nessuna installazione locale oltre a Docker.

Se Ollama ce l'hai già nativo, salti Docker del tutto: l'endpoint di default è lo stesso.

docker-compose.yml
services:
  ollama:
    image: ollama/ollama:latest
    ports:
      - "11434:11434"
    volumes:
      - ollama-models:/root/.ollama
    healthcheck:
      test: ["CMD", "ollama", "list"]
      interval: 10s
      timeout: 5s
      retries: 10

  # scarica llama3.1 nel volume condiviso, poi esce
  ollama-init:
    image: ollama/ollama:latest
    depends_on:
      ollama:
        condition: service_healthy
    environment:
      OLLAMA_HOST: http://ollama:11434
    entrypoint: ["/bin/sh", "-c", "ollama pull llama3.1"]
    restart: "no"

volumes:
  ollama-models:

Ollama · Docs

docker compose up -d e le dipendenze sono su. Codice completo nel repo: https://github.com/fscamuzzi/llm-eval-dotnet-ollama

🧩 Passo 2 · Progetto e pacchetti NuGet

La soluzione ha due progetti: una class library con l'assistente e un progetto xunit con la suite di valutazione. I pacchetti di evaluation vanno solo nel progetto di test: il codice di produzione non sa nemmeno che esiste un giudice.

Aggiungo anche il tool manifest con Microsoft.Extensions.AI.Evaluation.Console: è il tool aieval che a fine corsa trasforma i risultati in report HTML.

Setup della soluzione
$ dotnet new sln -n LlmEvalDemo
$ dotnet new classlib -n SupportAssistant -o src/SupportAssistant -f net8.0
$ dotnet new xunit -n SupportAssistant.Evaluation.Tests \
    -o tests/SupportAssistant.Evaluation.Tests -f net8.0
$ dotnet sln add src/SupportAssistant tests/SupportAssistant.Evaluation.Tests

# la class library vede solo le astrazioni
$ dotnet add src/SupportAssistant package Microsoft.Extensions.AI.Abstractions

# tutta l'evaluation sta nel progetto di test
$ cd tests/SupportAssistant.Evaluation.Tests
$ dotnet add reference ../../src/SupportAssistant
$ dotnet add package Microsoft.Extensions.AI
$ dotnet add package Microsoft.Extensions.AI.Evaluation
$ dotnet add package Microsoft.Extensions.AI.Evaluation.Quality
$ dotnet add package Microsoft.Extensions.AI.Evaluation.Reporting
$ dotnet add package OllamaSharp

# il tool aieval per il report
$ cd ../.. && dotnet new tool-manifest
$ dotnet tool install Microsoft.Extensions.AI.Evaluation.Console

Le librerie di evaluation · Microsoft Learn

Evaluation solo nei test: la produzione non dipende dal giudice. Codice completo nel repo: https://github.com/fscamuzzi/llm-eval-dotnet-ollama

🤖 Passo 3 · L'assistente sotto test

Il servizio è volutamente semplice: un IChatClient, un system prompt con le tre regole — solo il contesto, massimo 80 parole, lingua della domanda — e temperatura 0 per ridurre la varianza tra i run.

Un dettaglio che conta: il metodo restituisce sia i messaggi sia la risposta. Gli evaluator giudicano la risposta rispetto alla conversazione: senza system prompt e domanda originale, il giudice non può valutare la pertinenza.

SupportAssistantService.cs
using Microsoft.Extensions.AI;

namespace SupportAssistant;

// Il sistema sotto test: un assistente che risponde SOLO dal contesto
// fornito — esattamente il comportamento che il GroundednessEvaluator
// verifichera' nella suite.
public class SupportAssistantService(IChatClient chat)
{
    private const string SystemPrompt =
        "You are the support assistant of a software product. " +
        "Answer using ONLY the information in the provided context. " +
        "If the context does not contain the answer, say you don't know " +
        "and suggest contacting support. " +
        "Keep the answer under 80 words and reply in the same language " +
        "as the question.";

    // Restituisce messaggi + risposta: gli evaluator giudicano la
    // risposta rispetto all'intera conversazione, non da sola.
    public async Task<(IList<ChatMessage> Messages, ChatResponse Response)> AskAsync(
        string question,
        string context,
        CancellationToken ct = default)
    {
        IList<ChatMessage> messages =
        [
            new(ChatRole.System, SystemPrompt),
            new(ChatRole.User, $"Context:\n{context}\n\nQuestion: {question}")
        ];

        ChatOptions options = new() { Temperature = 0f };
        ChatResponse response = await chat.GetResponseAsync(messages, options, ct);
        return (messages, response);
    }
}
Temperatura 0 e regole nel system prompt: meno varianza da giudicare. Codice completo nel repo: https://github.com/fscamuzzi/llm-eval-dotnet-ollama

⚙️ Passo 4 · Il wiring: giudice, evaluator e reporting

Tutto il setup vive in una classe statica: la configurazione legge endpoint e modelli da appsettings.tests.json, con SUT e giudice come voci separate. La ReportingConfiguration è il cuore: dichiara quali evaluator girano su ogni scenario, quale IChatClient usa il giudice e dove finiscono cache e risultati.

enableResponseCaching: true è la riga che cambia la vita: le risposte del giudice vengono riusate dai run successivi finché la richiesta non cambia (default 14 giorni). La prima corsa paga il costo pieno; le successive sono quasi istantanee.

EvalSetup.cs
using Microsoft.Extensions.AI;
using Microsoft.Extensions.AI.Evaluation;
using Microsoft.Extensions.AI.Evaluation.Quality;
using Microsoft.Extensions.AI.Evaluation.Reporting;
using Microsoft.Extensions.AI.Evaluation.Reporting.Storage;
using Microsoft.Extensions.Configuration;
using OllamaSharp;

namespace SupportAssistant.Evaluation.Tests;

public static class EvalSetup
{
    private static readonly IConfigurationRoot Config =
        new ConfigurationBuilder()
            .AddJsonFile("appsettings.tests.json", optional: false)
            .AddEnvironmentVariables()
            .Build();

    // L'assistente sotto test usa il modello SUT...
    public static IChatClient CreateSutClient() =>
        new OllamaApiClient(new Uri(Config["Ollama:Endpoint"]!), Config["Ollama:SutModel"]!);

    // ...gli evaluator LLM-as-judge usano il modello giudice: due voci
    // di config, cosi' il giudice puo' diventare piu' grande senza toccare codice.
    private static ChatConfiguration CreateJudgeConfiguration() =>
        new(new OllamaApiClient(new Uri(Config["Ollama:Endpoint"]!), Config["Ollama:JudgeModel"]!));

    // Un ExecutionName per run: nel report i risultati sono raggruppati per corsa.
    private static string ExecutionName { get; } = $"{DateTime.Now:yyyyMMddTHHmmss}";

    public static ReportingConfiguration Reporting { get; } =
        DiskBasedReportingConfiguration.Create(
            storageRootPath: FindReportsPath(),   // <repo>/TestReports
            evaluators:
            [
                new RelevanceEvaluator(),
                new CoherenceEvaluator(),
                new GroundednessEvaluator(),
                new AnswerLengthEvaluator()
            ],
            chatConfiguration: CreateJudgeConfiguration(),
            enableResponseCaching: true,
            executionName: ExecutionName);
}

Evaluate with reporting · Microsoft Learn

La ReportingConfiguration dichiara evaluator, giudice, cache e storage in un punto solo. Codice completo nel repo: https://github.com/fscamuzzi/llm-eval-dotnet-ollama

📏 Passo 5 · Un evaluator custom senza LLM

Non tutte le metriche hanno bisogno di un giudice. Il budget di 80 parole del system prompt è perfettamente verificabile con una regex: implemento IEvaluator con un conteggio parole e un'interpretazione che boccia le risposte fuori budget.

Questo è il pattern da ricordare: le metriche deterministiche sono il gate duro ideale per la CI — veloci, gratuite, zero flakiness — mentre i punteggi del giudice fanno da telemetria di qualità. EvaluateAsync restituisce una NumericMetric con valore, motivazione e interpretazione: la stessa forma delle metriche LLM-based, quindi finisce nello stesso report.

AnswerLengthEvaluator.cs
using System.Text.RegularExpressions;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.AI.Evaluation;

namespace SupportAssistant.Evaluation.Tests;

// Metrica deterministica, zero LLM: conta le parole e boccia le risposte
// fuori budget. E' il gate duro ideale per la CI.
public class AnswerLengthEvaluator : IEvaluator
{
    public const string MetricName = "Answer Length";

    private const int MinWords = 3;
    private const int MaxWords = 100;

    public IReadOnlyCollection<string> EvaluationMetricNames => [MetricName];

    public ValueTask<EvaluationResult> EvaluateAsync(
        IEnumerable<ChatMessage> messages,
        ChatResponse modelResponse,
        ChatConfiguration? chatConfiguration = null,
        IEnumerable<EvaluationContext>? additionalContext = null,
        CancellationToken cancellationToken = default)
    {
        int words = Regex.Matches(modelResponse.Text ?? "", @"\b\w+\b").Count;

        NumericMetric metric = new(
            MetricName,
            value: words,
            reason: $"The response contains {words} words (budget: {MinWords}-{MaxWords}).");

        metric.Interpretation = words >= MinWords && words <= MaxWords
            ? new EvaluationMetricInterpretation(
                EvaluationRating.Good,
                reason: "The response respects the word budget.")
            : new EvaluationMetricInterpretation(
                EvaluationRating.Unacceptable,
                failed: true,
                reason: "The response is empty, too short or over the word budget.");

        return new ValueTask<EvaluationResult>(new EvaluationResult(metric));
    }
}

IEvaluator · Microsoft Learn

Stessa forma delle metriche LLM-based: valore, motivazione, interpretazione. Codice completo nel repo: https://github.com/fscamuzzi/llm-eval-dotnet-ollama

🧪 Passo 6 · Il test xunit con gli scenari

Ogni caso è un record — nome, domanda, contesto — e diventa una riga della [Theory]. Tre scenari: una domanda coperta dal contesto, una in italiano (il contesto è in inglese: il giudice vede anche il cambio lingua) e una fuori contesto, dove la risposta giusta è ammettere di non sapere — ed è proprio ciò che il GroundednessEvaluator premia.

Nota il doppio regime degli assert: il gate deterministico è sempre attivo, i punteggi del giudice bloccano la build solo con EVAL_STRICT=1. È la raccomandazione della documentazione: i punteggi si muovono con l'evoluzione dei modelli, meglio guardare i trend nel report che far fallire la CI a ogni oscillazione.

SupportAssistantEvalTests.cs
public record EvalCase(string Name, string Question, string Context)
{
    public override string ToString() => Name;
}

public class SupportAssistantEvalTests(ITestOutputHelper output)
{
    private static bool Strict =>
        Environment.GetEnvironmentVariable("EVAL_STRICT") == "1";

    public static TheoryData<EvalCase> Cases => new()
    {
        new EvalCase("PasswordReset", "How do I reset my password?", AccountFaq),
        new EvalCase("InvoiceDownload", "Dove scarico le fatture in PDF?", BillingFaq),
        new EvalCase("OutOfScope", "Can I pay my invoices with Bitcoin?", BillingFaq)
    };

    [Theory]
    [MemberData(nameof(Cases))]
    public async Task Answer_quality_is_evaluated_and_reported(EvalCase evalCase)
    {
        // await using: alla dispose i risultati vengono persistiti su disco.
        await using ScenarioRun scenario =
            await EvalSetup.Reporting.CreateScenarioRunAsync(
                $"SupportAssistant.{evalCase.Name}");

        SupportAssistantService assistant = new(EvalSetup.CreateSutClient());
        (IList<ChatMessage> messages, ChatResponse response) =
            await assistant.AskAsync(evalCase.Question, evalCase.Context);

        // Il contesto dell'assistente e' anche il grounding context del giudice.
        EvaluationResult result = await scenario.EvaluateAsync(
            messages,
            response,
            additionalContext: [new GroundednessEvaluatorContext(evalCase.Context)]);

        // Gate deterministico: sempre attivo.
        NumericMetric length = result.Get<NumericMetric>(AnswerLengthEvaluator.MetricName);
        Assert.False(length.Interpretation!.Failed, length.Interpretation.Reason);

        // Metriche del giudice: loggate sempre, bloccanti solo in strict mode.
        foreach (string name in new[]
        {
            RelevanceEvaluator.RelevanceMetricName,
            CoherenceEvaluator.CoherenceMetricName,
            GroundednessEvaluator.GroundednessMetricName
        })
        {
            // Un giudice piccolo puo' non produrre un punteggio parseabile:
            // TryGetValue, cosi' la metrica mancante e' un dato nel report,
            // non un'eccezione nella suite.
            if (!result.Metrics.TryGetValue(name, out EvaluationMetric? raw)
                || raw is not NumericMetric metric)
            {
                output.WriteLine($"  {name}: no score produced by the judge");
                continue;
            }

            output.WriteLine($"  {name}: {metric.Value}{metric.Reason}");

            if (Strict)
                Assert.True(
                    metric.Interpretation?.Failed != true,
                    $"{name} failed: {metric.Interpretation?.Reason}");
        }
    }
}
Gate duro sul deterministico, telemetria sui punteggi del giudice. Codice completo nel repo: https://github.com/fscamuzzi/llm-eval-dotnet-ollama

📊 Passo 7 · dotnet test, cache e report HTML

La suite gira con il comando che conosci: dotnet test. La prima corsa interroga davvero Ollama — assistente più giudice, qualche minuto su hardware consumer — e riempie la cache; le successive riusano le risposte e chiudono in secondi.

Poi il dividendo: aieval report legge TestReports/ e genera un report HTML con punteggi, motivazioni del giudice e conversazioni complete, scenario per scenario, run dopo run. In CI basta pubblicarlo come artifact della pipeline.

I punteggi in dotnet test, il report con aieval
Terminale con l'output di dotnet test: per lo scenario PasswordReset la risposta dell'assistente e i punteggi Relevance 5, Coherence 4, Groundedness 5 e Answer Length 18; per OutOfScope l'assistente ammette di non sapere e Groundedness premia con 5; in chiusura il comando dotnet tool run aieval report genera report.html.

Sul caso fuori contesto il giudice premia chi ammette di non sapere.

🛡️ Limiti, giudice locale e cosa ho imparato

Il giudice locale è il compromesso da capire: gratuito e privato, ma llama3.1 da 8B è un giudice severo quanto incostante. Nei miei run è successo davvero: sul caso fuori contesto il giudice non ha prodotto un punteggio di Coherence parseabile — da lì il TryGetValue nel test — e l'assistente ha risposto una volta in portoghese a una domanda in inglese. La suite l'ha reso visibile: è esattamente il suo lavoro.

Regole pratiche che mi porto a casa:

  • Giudice ≥ modello sotto test: un giudice più piccolo del SUT produce punteggi rumorosi; appena puoi, punta JudgeModel a un modello più grande — è una riga di config.
  • Gate duri solo sul deterministico: lunghezza, formato, pattern vietati. I punteggi LLM bloccano la build solo in strict mode, i trend si leggono nel report.
  • La metrica mancante è un dato: TryGetValue invece di Get — un giudice che non risponde è informazione da riportare, non un crash della suite.
  • Cache come moltiplicatore: con enableResponseCaching la suite costa una volta e si rilancia gratis; invalidazione automatica quando cambiano prompt o modello.
  • Scenario fuori contesto sempre: è il test più informativo — misura se il sistema preferisce il silenzio all'invenzione.

📦 GitHub repo

Tutto il codice dell'articolo è in un repository pubblico: la soluzione completa — assistente, suite di valutazione con i tre scenari, evaluator custom — più il docker-compose.yml per Ollama, il tool manifest per aieval e il README con le istruzioni passo passo.

Clonato il repo, servono solo Docker (o un'installazione Ollama nativa) e l'SDK .NET: docker compose up -d, dotnet test, e i primi punteggi sono in TestReports/.

Clona e prova il repo
$ git clone https://github.com/fscamuzzi/llm-eval-dotnet-ollama.git
$ cd llm-eval-dotnet-ollama

# dipendenze in Docker (Ollama + pull di llama3.1)
$ docker compose up -d
$ docker compose logs -f ollama-init   # attendi il pull

# la suite di valutazione
$ dotnet test

# il report HTML
$ dotnet tool restore
$ dotnet tool run aieval report --path TestReports --output report.html

llm-eval-dotnet-ollama · GitHub

Codice completo nel repo: https://github.com/fscamuzzi/llm-eval-dotnet-ollama

✅ Checklist finale e prossimi passi

Ricapitolo il percorso: se hai seguito gli step hai una suite xunit che misura la qualità delle risposte di un sistema LLM con metriche standard e custom, un giudice locale a costo zero, cache che rende i re-run istantanei e un report HTML pronto per la CI.

Da qui si sale: gli evaluator agent-focused (ToolCallAccuracyEvaluator, TaskAdherenceEvaluator) portano lo stesso approccio sugli agenti con tool, e lo stesso banco di prova valuta un sistema RAG completo — il retrieval fornisce il contesto, la groundedness misura quanto la risposta ci resta dentro.

Il tutorial in 7 mosse
  1. 01
    Ollama via Composeserver + pull di llama3.1
  2. 02
    Progetto + NuGetevaluation solo nei test
  3. 03
    Assistente sotto testcontesto, 80 parole, T=0
  4. 04
    EvalSetupSUT e giudice separati + reporting
  5. 05
    Evaluator customIEvaluator deterministico
  6. 06
    Test xunitscenari + gate a doppio regime
  7. 07
    Cache + reportdotnet test, poi aieval report

Da qui: evaluator agent-focused e valutazione di pipeline RAG.

Domande frequenti su LLM evaluation .NET

Che cos'è la LLM evaluation?

È la pratica di misurare la qualità delle risposte di un modello con metriche — pertinenza, coerenza, aderenza al contesto — invece che con confronti esatti. Le metriche di qualità le assegna un modello giudice (LLM-as-judge), quelle deterministiche restano codice normale.

Cosa contiene Microsoft.Extensions.AI.Evaluation?

Le astrazioni (IEvaluator, EvaluationResult, le metriche), gli evaluator di qualità pronti come RelevanceEvaluator, CoherenceEvaluator e GroundednessEvaluator, il reporting con cache delle risposte e store su disco, e il tool da riga di comando aieval che genera il report HTML.

Posso usare Ollama come giudice per la LLM evaluation?

Sì: gli evaluator usano IChatClient, quindi qualsiasi modello esposto da OllamaSharp funziona. Un modello piccolo come llama3.1 8B è però un giudice incostante: meglio configurare come giudice un modello più grande del sistema sotto test appena possibile.

Che cos'è il GroundednessEvaluator?

È l'evaluator che misura quanto la risposta si appoggia al contesto fornito, passato come GroundednessEvaluatorContext. Premia le risposte supportate dal contesto e chi ammette di non sapere quando il contesto non contiene la risposta: è la metrica chiave per i sistemi RAG.

I punteggi del giudice devono far fallire la CI?

Meglio di no: i punteggi oscillano con l'evoluzione dei modelli. La documentazione consiglia gate duri solo sulle metriche deterministiche e monitoraggio dei trend nel report per quelle LLM-based; nel tutorial gli assert sui punteggi si attivano solo con EVAL_STRICT=1.

Come funziona la cache delle risposte nella evaluation?

Con enableResponseCaching la ReportingConfiguration salva su disco le risposte di modello e giudice: i run successivi le riusano finché prompt, modello o parametri non cambiano (scadenza di default 14 giorni). La prima corsa paga il costo pieno, le successive sono quasi istantanee.

Parliamone

Se questo tema ti riguarda, scrivimi: confrontarsi su codice e AI è sempre tempo speso bene.