In questo articolo

🧊 Cos'è lo structured output

Un LLM produce testo libero: perfetto per un umano, fragile per un programma. Lo structured output obbliga il modello a rispondere con un JSON che rispetta uno JSON Schema che decidi tu: campi, tipi, valori ammessi. Non è una preghiera nel prompt, è un vincolo applicato in fase di generazione.

La differenza pratica è enorme: con il vincolo attivo il runtime del modello scarta i token che violerebbero lo schema. Niente virgole penzolanti, niente campi inventati, niente «Ecco il JSON richiesto:» prima dell'oggetto. Il risultato si deserializza sempre nel tuo tipo C#.

JSON chiesto nel prompt vs. structured output

«Rispondimi in JSON» nel prompt

  • Il modello può ignorare la richiesta
  • Testo attorno all'oggetto, markdown fence
  • Campi rinominati o mancanti a sorpresa
  • Parsing difensivo e retry a mano

Structured output con schema

  • Lo schema vincola la generazione
  • Solo JSON valido, niente prosa attorno
  • Campi, tipi ed enum garantiti
  • Deserializzazione diretta nel record C#

La promessa nel prompt è un consiglio; lo schema è un contratto.

🎯 Quando usarlo (e differenza col function calling)

Il function calling serve quando il modello deve far eseguire qualcosa al tuo codice; lo structured output serve quando la risposta del modello è essa stessa il dato: una classificazione, un'estrazione di campi, un punteggio, una decisione di routing.

È il mattone dei casi d'uso più concreti dell'AI nei gestionali: smistare email e ticket, estrarre dati da testi non strutturati, normalizzare descrizioni, valutare sentiment. Tutto ciò che finisce in una colonna di database o in un `if` ha bisogno di un tipo, non di prosa.

  • Classificazione: categoria, priorità, sentiment di un ticket o di una recensione — enum, non stringhe libere.
  • Estrazione: date, importi, codici ordine da email e documenti — campi tipizzati con formato garantito.
  • Routing: a quale reparto inoltrare, con quale urgenza — una decisione che il codice a valle può eseguire.
  • Insieme al function calling: prima i tool recuperano i dati, poi lo structured output impacchetta la risposta finale in un tipo.

🔬 Da record C# a JSON Schema

Con Microsoft.Extensions.AI non scrivi lo schema a mano: lo genera `AIJsonUtilities` a partire dal tuo tipo. Chiami `GetResponseAsync<TicketTriage>` e la libreria costruisce lo JSON Schema dal `record`: proprietà, tipi, `enum` con i soli valori ammessi e le descrizioni prese dagli attributi `[Description]`.

Come per i tool del function calling, le descrizioni sono ciò che il modello legge: un `[Description]` chiaro su ogni proprietà guida la compilazione dei campi molto più di qualsiasi istruzione generica nel system prompt.

Il record è lo schema
Da record C# a JSON Schema: a sinistra il record TicketTriage con attributi [Description] e proprietà tipizzate (Category enum, Priority enum, Summary string, Language string, ChurnRisk bool); una freccia mostra come GetResponseAsync di TicketTriage genera lo JSON Schema a destra, con properties, i valori enum ammessi e required.

GetResponseAsync<T> genera lo schema dal tipo: proprietà, enum e [Description] compresi.

🏗️ L'architettura della soluzione

Costruisco una Minimal API in .NET: il triage automatico dei ticket di un help desk. Il testo libero del ticket entra, un oggetto `TicketTriage` tipizzato esce — categoria, priorità, sintesi, lingua e rischio di abbandono — e finisce su MongoDB, pronto per dashboard e filtri LINQ.

Il modello gira in locale con Ollama via OllamaSharp, come nel tutorial sul function calling: stessa base, mattone diverso. Un solo endpoint `POST /tickets/triage`: la struttura della risposta la garantisce lo schema, non il prompt.

  • Modello: Ollama in locale (`llama3.1`), esposto come `IChatClient` da OllamaSharp.
  • Structured output: `GetResponseAsync<TicketTriage>` di Microsoft.Extensions.AI — schema generato dal record.
  • Persistenza: i ticket classificati finiscono in una collection MongoDB tipizzata.
  • Pacchetti NuGet: Microsoft.Extensions.AI, OllamaSharp, MongoDB.Driver.
Architettura: Minimal API + Ollama + MongoDB
Architettura della Minimal API di triage: l'endpoint POST /tickets/triage passa il testo del ticket al TriageService, che chiama IChatClient (Ollama via OllamaSharp) con lo JSON Schema generato dal record TicketTriage; il JSON vincolato viene deserializzato nel record e salvato nella collection tickets di MongoDB.

Testo libero in ingresso, record tipizzato in uscita, MongoDB come destinazione.

⬇️ Passo 1 · Ollama e un modello adatto

Ollama fa girare il modello sul tuo computer, sull'endpoint http://localhost:11434, e supporta lo structured output nativo: accetta uno JSON Schema nel campo `format` della richiesta e vincola la generazione di conseguenza.

Funziona con i modelli generalisti più diffusi (`llama3.1`, `qwen3`, `mistral`): il vincolo lo applica il runtime di Ollama, non il modello. Zero token spesi e dati che non escono dalla macchina.

Scarica un modello e avvia Ollama
# un modello generalista va benissimo: il vincolo lo applica Ollama
$ ollama pull llama3.1

# avvia il server (di solito parte gia' da solo)
$ ollama serve                 # http://localhost:11434

# prova veloce: structured output direttamente dall'API REST
$ curl http://localhost:11434/api/chat -d '{
    "model": "llama3.1",
    "messages": [{"role": "user", "content": "Il cielo e' blu? Rispondi con available e color"}],
    "format": {"type": "object", "properties": {"available": {"type": "boolean"}, "color": {"type": "string"}}},
    "stream": false
  }'

Structured outputs · Ollama

Il campo format accetta uno JSON Schema: è ciò che Microsoft.Extensions.AI compila per te.

🧱 Passo 2 · Progetto e pacchetti NuGet

Creo una Minimal API e aggiungo tre pacchetti: le astrazioni e le extension di Microsoft.Extensions.AI, il provider OllamaSharp e il driver MongoDB.

Attenzione al pacchetto giusto: `GetResponseAsync<T>` vive in Microsoft.Extensions.AI (il pacchetto con le extension di alto livello), non in Microsoft.Extensions.AI.Abstractions. E come per il function calling, il vecchio `Microsoft.Extensions.AI.Ollama` è deprecato: si usa OllamaSharp.

Crea il progetto e aggiungi i pacchetti
# nuova Minimal API
$ dotnet new web -n TriageDemo && cd TriageDemo

# extension AI di alto livello + provider Ollama + driver Mongo
$ dotnet add package Microsoft.Extensions.AI
$ dotnet add package OllamaSharp
$ dotnet add package MongoDB.Driver

Structured output · Microsoft Learn

GetResponseAsync<T> sta nel pacchetto Microsoft.Extensions.AI, non nelle sole Abstractions.

⚙️ Passo 3 · appsettings.json

Tutta la configurazione vive in appsettings.json: endpoint e modello di Ollama, connessione e database MongoDB. L'endpoint di Ollama è http://localhost:11434.

Cambiare modello — o domani passare a un provider cloud — resta una modifica di configurazione, non di codice.

appsettings.json
{
  "Ollama": {
    "Endpoint": "http://localhost:11434",
    "ChatModel": "llama3.1"
  },
  "Mongo": {
    "ConnectionString": "mongodb://localhost:27017",
    "Database": "helpdesk"
  }
}
Modello e host stanno qui: cambi configurazione senza ricompilare.

🧩 Passo 4 · Registrare il chat client

Raccolgo il wiring in una extension method `AddTriage`: registra il client Ollama come `IChatClient`, il database MongoDB e il servizio di triage. Rispetto al function calling qui non serve `.UseFunctionInvocation()`: lo structured output non ha loop, è una singola chiamata vincolata.

Tengo `.UseLogging()`: vedere nei log lo schema inviato e il JSON grezzo ricevuto è il modo più rapido per capire perché un campo torna vuoto.

TriageServiceExtensions.cs
using Microsoft.Extensions.AI;
using MongoDB.Driver;
using OllamaSharp;
using TriageDemo.Services;

namespace TriageDemo.Extensions;

// Un'unica extension method registra lo stack: il chat client Ollama
// (via OllamaSharp), MongoDB e il servizio di triage tipizzato.
public static class TriageServiceExtensions
{
    public static IServiceCollection AddTriage(this IServiceCollection services, IConfiguration config)
    {
        var ollama = config.GetSection("Ollama");
        var chatClient = new OllamaApiClient(new Uri(ollama["Endpoint"]!), ollama["ChatModel"]!);

        // Niente UseFunctionInvocation: lo structured output e' una singola
        // chiamata vincolata dallo schema, non un loop di tool.
        services.AddChatClient(chatClient)
            .UseLogging();

        var mongo = config.GetSection("Mongo");
        var mongoClient = new MongoClient(mongo["ConnectionString"]);
        services.AddSingleton<IMongoDatabase>(_ => mongoClient.GetDatabase(mongo["Database"]));

        services.AddScoped<TriageService>();
        return services;
    }
}

OllamaSharp · GitHub

OllamaApiClient implementa IChatClient: il resto del codice dipende solo dall'astrazione.

📐 Passo 5 · Il record TicketTriage: il tipo È il contratto

Qui succede tutto: il `record` TicketTriage è insieme il DTO della risposta e lo schema che vincola il modello. Gli `enum` sono il dettaglio più potente: nello schema diventano la lista dei soli valori ammessi, quindi il modello non può inventare una categoria che il tuo codice non gestisce.

Mai `object`, `dynamic` o tipi anonimi qui: il senso dello structured output è proprio avere un confine tipizzato. Ogni proprietà ha il suo `[Description]`: è la parte di prompt che vive accanto al dato che descrive.

TicketTriage.cs
using System.ComponentModel;

namespace TriageDemo.Models;

// Il record e' insieme il DTO e lo schema: proprieta', enum e [Description]
// diventano lo JSON Schema che vincola la risposta del modello.
public enum TicketCategory { Billing, Technical, Account, Shipping, Other }

public enum TicketPriority { Low, Medium, High, Urgent }

public record TicketTriage(
    [property: Description("Categoria del problema segnalato nel ticket.")]
    TicketCategory Category,

    [property: Description("Priorita' operativa suggerita in base a impatto e urgenza.")]
    TicketPriority Priority,

    [property: Description("Sintesi del problema in una frase, nella lingua del ticket.")]
    string Summary,

    [property: Description("Lingua del ticket come codice ISO 639-1, es. it, en, de.")]
    string Language,

    [property: Description("true se il cliente minaccia di abbandonare o e' molto frustrato.")]
    bool ChurnRisk);
Gli enum diventano la lista chiusa dei valori ammessi: niente categorie inventate.

🤖 Passo 6 · Il servizio di triage con GetResponseAsync<T>

Il cuore è una riga: `GetResponseAsync<TicketTriage>`. L'extension genera lo schema dal record, lo imposta come `ChatResponseFormat` della richiesta e deserializza la risposta nel tipo. Torna un `ChatResponse<TicketTriage>`.

Per leggere il risultato uso `TryGetResult`: restituisce `false` se il JSON non è deserializzabile, senza lanciare eccezioni — a differenza della proprietà `Result`, che in quel caso lancia. Al confine con un modello, il caso di fallimento è un input previsto, non un'eccezione.

TriageService.cs
using Microsoft.Extensions.AI;
using TriageDemo.Models;

namespace TriageDemo.Services;

public class TriageService(IChatClient chat)
{
    private const string SystemPrompt =
        "Sei il triage di un help desk. Classifica il ticket dell'utente. " +
        "Non inventare informazioni: se la categoria non e' chiara usa Other.";

    // GetResponseAsync<T> genera lo JSON Schema dal record, lo imposta come
    // response format della richiesta e deserializza la risposta nel tipo.
    public async Task<TicketTriage?> TriageAsync(string ticketText, CancellationToken ct = default)
    {
        List<ChatMessage> messages =
        [
            new(ChatRole.System, SystemPrompt),
            new(ChatRole.User, ticketText)
        ];

        var response = await chat.GetResponseAsync<TicketTriage>(messages, cancellationToken: ct);

        // TryGetResult non lancia se il JSON non e' deserializzabile: al confine
        // con un modello il fallimento e' un caso previsto, non un'eccezione.
        return response.TryGetResult(out var triage) ? triage : null;
    }
}

ChatClientStructuredOutputExtensions · Microsoft Learn

Una chiamata tipizzata: schema generato dal record, risposta deserializzata nel record.

🚪 Passo 7 · Endpoint e salvataggio su MongoDB

L'endpoint riceve il testo del ticket, chiama il servizio e salva il risultato in una collection MongoDB tipizzata. Se il triage fallisce rispondo `422 Unprocessable Entity`: il chiamante sa che può ritentare.

Nota il dividendo dello structured output: `Category` e `Priority` arrivano già come `enum`, quindi i filtri a valle sono LINQ tipizzato, non confronti su stringhe sperando nella maiuscola giusta.

TriageEndpoints.cs + Program.cs
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
using MongoDB.Driver;
using TriageDemo.Models;
using TriageDemo.Services;

namespace TriageDemo.Endpoints;

public record TriageRequest(string Text);

// Documento persistito: il triage tipizzato piu' il testo originale.
[BsonIgnoreExtraElements]
public class TriagedTicket
{
    [BsonId]
    public ObjectId Id { get; set; }

    public string Text { get; set; } = "";

    [BsonRepresentation(BsonType.String)]
    public TicketCategory Category { get; set; }

    [BsonRepresentation(BsonType.String)]
    public TicketPriority Priority { get; set; }

    public string Summary { get; set; } = "";
    public string Language { get; set; } = "";
    public bool ChurnRisk { get; set; }
}

public static class TriageEndpoints
{
    // POST /tickets/triage — testo libero in ingresso, record tipizzato in uscita.
    public static void MapTriageEndpoints(this IEndpointRouteBuilder app) =>
        app.MapPost("/tickets/triage", async (
            TriageRequest req,
            TriageService triage,
            IMongoDatabase db,
            CancellationToken ct) =>
        {
            var result = await triage.TriageAsync(req.Text, ct);
            if (result is null)
                return Results.UnprocessableEntity();

            var doc = new TriagedTicket
            {
                Text = req.Text,
                Category = result.Category,
                Priority = result.Priority,
                Summary = result.Summary,
                Language = result.Language,
                ChurnRisk = result.ChurnRisk
            };
            await db.GetCollection<TriagedTicket>("tickets").InsertOneAsync(doc, cancellationToken: ct);

            return Results.Ok(result);
        });
}

// Program.cs — due righe di wiring:
//   builder.Services.AddTriage(builder.Configuration);
//   app.MapTriageEndpoints();
Enum salvati come stringhe leggibili su Mongo, ma tipizzati in ogni query LINQ a valle.

🧪 Passo 8 · Provarlo da terminale

Un `curl` con un ticket realistico e il triage è vivo: il modello legge lo sfogo del cliente e risponde solo con l'oggetto vincolato dallo schema — categoria, priorità, sintesi, lingua e rischio di abbandono.

Prova a variare il testo: un ticket in inglese cambia `language`, una minaccia di disdetta accende `churnRisk`, un problema di fatturazione sposta `category`. La struttura, quella, non cambia mai.

Prova il triage
$ curl http://localhost:5000/tickets/triage \
    -H "Content-Type: application/json" \
    -d '{"text":"Terza volta che vi scrivo: l ordine 4412 non arriva e nessuno risponde. Se non risolvete entro domani disdico tutto."}'

# risposta: JSON valido, sempre con questa forma
{
  "category": "shipping",
  "priority": "urgent",
  "summary": "Ordine 4412 non consegnato, cliente senza risposte da giorni.",
  "language": "it",
  "churnRisk": true
}
Testo libero in ingresso, forma garantita in uscita: il contratto lo fa rispettare lo schema.

🔎 Dietro le quinte: cosa arriva davvero a Ollama

Cosa succede in quella singola chiamata? `GetResponseAsync<T>` genera lo schema con `AIJsonUtilities`, lo imposta come response format e il provider lo traduce nel dialetto del backend: per Ollama finisce nel campo `format` della richiesta, e il runtime scarta i token che violerebbero lo schema.

Con `.UseLogging()` vedi entrambe le metà del contratto nei log: lo schema inviato e il JSON grezzo ricevuto. Se un campo torna sempre vuoto, quasi sempre la causa è lì: un `[Description]` vago o una proprietà ambigua.

Il contratto nei log
Terminale che mostra il flusso: la richiesta POST /tickets/triage arriva, il log mostra lo JSON Schema inviato a Ollama nel campo format (con i valori enum di category e priority), poi il JSON grezzo vincolato restituito dal modello e infine il documento salvato nella collection tickets di MongoDB.

Schema inviato e JSON ricevuto: con UseLogging le due metà del contratto sono tracciate.

🛡️ Limiti, insidie e piano B

Lo structured output garantisce la forma, non la verità: un JSON perfettamente valido può contenere una sintesi sbagliata o una priorità discutibile. La validazione di dominio resta compito tuo, dopo la deserializzazione.

E non tutti i provider supportano lo schema nativo. Per questi casi `GetResponseAsync<T>` ha un piano B integrato: con `useJsonSchemaResponseFormat: false` chiede JSON semplice e inietta lo schema nel prompt — meno garanzie, stessa firma tipizzata.

  • Forma ≠ verità: lo schema garantisce campi e tipi, non la correttezza dei contenuti — convalida le regole di business dopo il parsing.
  • Schemi piatti e piccoli: pochi campi, poca profondità; con strutture molto annidate la qualità dei modelli piccoli degrada in fretta.
  • Enum come valvola: prevedi sempre un valore `Other`/`Unknown`, così l'incertezza del modello ha un posto legittimo dove finire.
  • Retry mirato: se `TryGetResult` torna `false`, un solo retry con temperatura più bassa risolve la maggior parte dei casi.
  • Streaming: lo structured output è pensato per risposte complete; se ti serve il testo token per token, è il caso d'uso sbagliato.
Piano B: schema nel prompt e retry mirato
using Microsoft.Extensions.AI;
using TriageDemo.Models;

// Provider senza schema nativo? useJsonSchemaResponseFormat: false chiede
// JSON semplice e inietta lo schema nel prompt: meno garanzie, stessa firma.
var response = await chat.GetResponseAsync<TicketTriage>(
    messages,
    useJsonSchemaResponseFormat: false,
    cancellationToken: ct);

// Retry mirato: un secondo tentativo a temperatura piu' bassa copre la
// maggior parte dei fallimenti di parsing.
if (!response.TryGetResult(out var triage))
{
    var retry = await chat.GetResponseAsync<TicketTriage>(
        messages,
        new ChatOptions { Temperature = 0 },
        cancellationToken: ct);

    triage = retry.TryGetResult(out var second) ? second : null;
}

Structured output · Microsoft Learn

Stessa firma tipizzata anche senza schema nativo; il retry a temperatura 0 è l'ultima rete.

✅ Checklist finale e prossimi passi

Ricapitolo il percorso. Se hai seguito gli step, ora hai una Minimal API .NET in cui un LLM locale trasforma testo libero in record C# tipizzati, con gli enum a garantire i valori ammessi e MongoDB a valle.

Da qui puoi salire di livello: combinare structured output e function calling nello stesso assistente — i tool recuperano i dati, lo schema impacchetta la decisione — o processare i ticket in batch. L'architettura non cambia: dipendi dalle interfacce, non dai provider.

Il tutorial in 8 mosse
  1. 01
    Ollama + modellollama3.1; structured output nativo
  2. 02
    Progetto + NuGetExtensions.AI + OllamaSharp + Driver
  3. 03
    appsettings.jsonOllama e Mongo
  4. 04
    AddTriageIChatClient senza loop di tool
  5. 05
    TicketTriagerecord + enum + [Description]
  6. 06
    TriageServiceGetResponseAsync<T> + TryGetResult
  7. 07
    Endpoint + Mongo422 sul fallimento, insert tipizzato
  8. 08
    Piano Bschema nel prompt + retry a T=0

Da qui: structured output + tool nello stesso assistente, o triage in batch.

Domande frequenti su structured output .NET

Che cos'è lo structured output di un LLM?

È la capacità di vincolare la risposta del modello a uno JSON Schema deciso da te: campi, tipi e valori ammessi. Non è una richiesta nel prompt ma un vincolo applicato in fase di generazione: il risultato è sempre JSON valido, deserializzabile nel tuo tipo.

Che differenza c'è tra structured output e function calling?

Il function calling fa eseguire funzioni del tuo codice al servizio del modello; lo structured output vincola la forma della risposta finale. Si combinano bene: i tool recuperano i dati, lo schema impacchetta la decisione in un tipo.

Come si usa GetResponseAsync<T> in .NET?

È un'extension method di Microsoft.Extensions.AI su IChatClient: chiami chat.GetResponseAsync<TuoTipo>(messages) e la libreria genera lo JSON Schema dal tipo, lo imposta come response format e deserializza la risposta. Il risultato si legge con TryGetResult o con la proprietà Result.

Ollama supporta lo structured output?

Sì: accetta uno JSON Schema nel campo format della richiesta e vincola la generazione di conseguenza, con qualsiasi modello generalista (llama3.1, qwen3, mistral). Con Microsoft.Extensions.AI e OllamaSharp lo schema viene compilato e inviato automaticamente.

Meglio Result o TryGetResult per leggere la risposta tipizzata?

TryGetResult: restituisce false se il JSON non è deserializzabile, senza lanciare. La proprietà Result lancia un'eccezione nello stesso caso. Al confine con un modello il fallimento di parsing è un caso previsto da gestire, non un'eccezione imprevista.

Lo structured output garantisce che i dati siano corretti?

No: garantisce la forma (campi, tipi, enum), non la verità dei contenuti. Un JSON valido può contenere una sintesi imprecisa o una priorità discutibile: le regole di business vanno convalidate dopo la deserializzazione, come per qualsiasi input esterno.

Parliamone

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