In questo articolo
- 🧊 Perché un LLM non si testa con un assert
- 🧱 La libreria Microsoft.Extensions.AI.Evaluation
- 🏗️ L'architettura della suite
- ⬇️ Passo 1 · Ollama con Docker Compose
- 🧩 Passo 2 · Progetto e pacchetti NuGet
- 🤖 Passo 3 · L'assistente sotto test
- ⚙️ Passo 4 · Il wiring: giudice, evaluator e reporting
- 📏 Passo 5 · Un evaluator custom senza LLM
- 🧪 Passo 6 · Il test xunit con gli scenari
- 📊 Passo 7 · dotnet test, cache e report HTML
- 🛡️ Limiti, giudice locale e cosa ho imparato
- 📦 GitHub repo
- ✅ Checklist finale e prossimi passi
🧊 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.
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.
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.
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:🧩 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.
$ 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.ConsoleLe librerie di evaluation · Microsoft Learn ↗
🤖 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.
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);
}
}⚙️ 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.
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 ↗
📏 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.
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 ↗
🧪 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.
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}");
}
}
}📊 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.
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/.
$ 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.htmlllm-eval-dotnet-ollama · GitHub ↗
✅ 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.
- 01Ollama via Composeserver + pull di llama3.1
- 02Progetto + NuGetevaluation solo nei test
- 03Assistente sotto testcontesto, 80 parole, T=0
- 04EvalSetupSUT e giudice separati + reporting
- 05Evaluator customIEvaluator deterministico
- 06Test xunitscenari + gate a doppio regime
- 07Cache + 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.



