In questo articolo
- 🧊 Cos'è lo structured output
- 🎯 Quando usarlo (e differenza col function calling)
- 🔬 Da record C# a JSON Schema
- 🏗️ L'architettura della soluzione
- ⬇️ Passo 1 · Ollama e un modello adatto
- 🧱 Passo 2 · Progetto e pacchetti NuGet
- ⚙️ Passo 3 · appsettings.json
- 🧩 Passo 4 · Registrare il chat client
- 📐 Passo 5 · Il record TicketTriage: il tipo È il contratto
- 🤖 Passo 6 · Il servizio di triage con GetResponseAsync<T>
- 🚪 Passo 7 · Endpoint e salvataggio su MongoDB
- 🧪 Passo 8 · Provarlo da terminale
- 🔎 Dietro le quinte: cosa arriva davvero a Ollama
- 🛡️ Limiti, insidie e piano B
- ✅ Checklist finale e prossimi passi
🧊 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#.
«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.
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.
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.
# 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
}'🧱 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.
# 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.DriverStructured output · Microsoft Learn ↗
⚙️ 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.
{
"Ollama": {
"Endpoint": "http://localhost:11434",
"ChatModel": "llama3.1"
},
"Mongo": {
"ConnectionString": "mongodb://localhost:27017",
"Database": "helpdesk"
}
}🧩 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.
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;
}
}📐 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.
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);🤖 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.
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 ↗
🚪 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.
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();🧪 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.
$ 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
}🔎 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.
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.
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 ↗
✅ 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.
- 01Ollama + modellollama3.1; structured output nativo
- 02Progetto + NuGetExtensions.AI + OllamaSharp + Driver
- 03appsettings.jsonOllama e Mongo
- 04AddTriageIChatClient senza loop di tool
- 05TicketTriagerecord + enum + [Description]
- 06TriageServiceGetResponseAsync<T> + TryGetResult
- 07Endpoint + Mongo422 sul fallimento, insert tipizzato
- 08Piano 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.


