Function calling in .NET: far chiamare il tuo codice a un LLM con Microsoft.Extensions.AI
Un modello linguistico sa parlare, ma da solo non sa fare: non conosce lo stato dei tuoi ordini né il cambio EUR/USD di oggi. Il function calling colma il divario — descrivi al modello alcune funzioni del tuo codice e lui, quando serve, chiede di eseguirle. In questo articolo partiamo dal concetto (e dalla differenza con la RAG), vediamo come un metodo C# diventa un tool, poi costruiamo passo passo una Minimal API .NET in cui Ollama chiama da solo i tuoi metodi: stato ordini da MongoDB, conversione prezzi via Refit. Tutto in locale, con Microsoft.Extensions.AI.
🧠 Cos'è il function calling (o tool calling)
Un LLM da solo sa produrre testo, non agire. Il function calling (o tool calling) ribalta la situazione: descrivi al modello alcune funzioni del tuo codice e lui, quando serve, chiede lui stesso di eseguirle per ottenere dati reali o compiere azioni.
Attenzione: il modello non esegue nulla. Decide quale funzione invocare e con quali argomenti; il tuo runtime esegue il metodo e gli restituisce il risultato, che il modello usa per formulare la risposta finale. È il mattone che trasforma un chatbot in un assistente collegato ai tuoi sistemi.
Il modello chiede, il tuo codice esegue, il modello risponde. Il ciclo si ripete finché servono tool.
🎯 Quando usarlo (e differenza con la RAG)
La RAG recupera testo e lo mette nel prompt: perfetta quando la risposta è dentro dei documenti. Il function calling fa fare qualcosa: interrogare un database transazionale, chiamare un'API, calcolare, avviare un processo.
Le due tecniche non competono, si sommano: spesso un assistente usa la RAG per il sapere e i tool per i dati vivi e le azioni. La regola pratica: se la domanda richiede un dato dinamico o un'operazione, è lavoro da tool.
RAG — recuperare
- Risponde da documenti e knowledge base
- Dati testuali, semi-statici
- Nessun effetto collaterale
- Ottima contro le allucinazioni
Function calling — agire
- Interroga sistemi e API in tempo reale
- Dati dinamici: ordini, prezzi, stato
- Può compiere azioni (con cautela)
- Il modello sceglie il tool giusto
Non si escludono: RAG per il sapere, tool per i dati vivi e le operazioni.
🔬 Anatomia di un tool: da metodo C# a schema
Con Microsoft.Extensions.AI un tool è un metodo C# più un attributo. `AIFunctionFactory.Create` legge la firma del metodo e gli attributi `[Description]` e genera lo schema JSON che il modello vede: nome, cosa fa, quali parametri servono e di che tipo.
Più le descrizioni sono chiare, meglio il modello sceglie e compila gli argomenti. Nomi parlanti, un `[Description]` per il metodo e uno per ogni parametro: è il 90% della qualità del tool calling.
Il nome, i tipi dei parametri e gli attributi [Description] diventano lo schema che il modello legge.
🏗️ L'architettura della soluzione
Costruiamo una Minimal API in .NET: un assistente per un piccolo shop che risponde a domande su ordini e prezzi. Il modello gira in locale con Ollama via OllamaSharp; la function invocation è gestita da Microsoft.Extensions.AI.
Due famiglie di tool: una legge lo stato ordini da MongoDB, l'altra converte un prezzo chiamando un'API esterna con Refit. Un solo endpoint `POST /assistant`: il resto lo decide il modello.
- Modello: Ollama in locale (`llama3.1`), esposto come `IChatClient` da OllamaSharp.
- Function calling: `.UseFunctionInvocation()` aggiunge il loop automatico di Microsoft.Extensions.AI.
- Tool: `OrderTools` (MongoDB) e `PricingTools` (API esterna via Refit).
- Pacchetti NuGet: Microsoft.Extensions.AI, OllamaSharp, Refit.HttpClientFactory, MongoDB.Driver.
Un endpoint, un chat client con function invocation, due gruppi di tool: MongoDB e API esterna.
⬇️ Passo 1 · Ollama e un modello con i tool
Ollama fa girare il modello sul tuo computer, sull'endpoint http://localhost:11434. Non tutti i modelli supportano il tool calling: scegline uno che lo dichiara, come `llama3.1`, `qwen3` o `mistral`.
Zero token spesi e dati che non escono dalla macchina. Con 16 GB di RAM giri comodo i modelli da 7-8 miliardi di parametri.
# scarica un modello che supporta il tool calling (llama3.1, qwen3, mistral...)
$ ollama pull llama3.1
# avvia il server (di solito parte gia' da solo)
$ ollama serve # http://localhost:11434
# prova veloce dal terminale
$ ollama run llama3.1 "Ciao, chi sei?"🧱 Passo 2 · Progetto e pacchetti NuGet
Creiamo una Minimal API e aggiungiamo quattro pacchetti: le astrazioni Microsoft.Extensions.AI, il provider OllamaSharp, Refit per l'API esterna e il driver MongoDB.
Nota importante: il vecchio `Microsoft.Extensions.AI.Ollama` è deprecato. Oggi si usa OllamaSharp, il cui `OllamaApiClient` implementa direttamente `IChatClient`.
# nuova Minimal API
$ dotnet new web -n ToolCallingDemo && cd ToolCallingDemo
# astrazioni AI + provider Ollama (OllamaSharp) + Refit + driver Mongo
$ dotnet add package Microsoft.Extensions.AI
$ dotnet add package OllamaSharp
$ dotnet add package Refit.HttpClientFactory
$ dotnet add package MongoDB.DriverMicrosoft.Extensions.AI · Microsoft Learn ↗
⚙️ Passo 3 · appsettings.json
Tutta la configurazione vive in appsettings.json: l'endpoint e il modello di Ollama, la base URL dell'API di cambio e la connessione a MongoDB. L'endpoint di Ollama è http://localhost:11434.
Tenere qui questi valori significa cambiare modello, host o provider senza toccare il codice.
{
"Ollama": {
"Endpoint": "http://localhost:11434",
"ChatModel": "llama3.1"
},
"ExchangeRates": {
"BaseUrl": "https://api.exchangerate.host"
},
"Mongo": {
"ConnectionString": "mongodb://localhost:27017",
"Database": "shopdemo"
}
}🧩 Passo 4 · Registrare il chat client (UseFunctionInvocation)
Raccogliamo tutto il wiring in una extension method `AddAssistant`. Registra il chat client di Ollama come `IChatClient`, abilita la function invocation, e aggiunge MongoDB, il client Refit e i due gruppi di tool.
La chiave è `.UseFunctionInvocation()`: aggiunge il `FunctionInvokingChatClient`, che esegue da solo i tool richiesti dal modello e reinserisce i risultati nella conversazione.
using Microsoft.Extensions.AI;
using MongoDB.Driver;
using OllamaSharp;
using Refit;
using ToolCallingDemo.Tools;
namespace ToolCallingDemo.Extensions;
// Un'unica extension method registra tutto lo stack: il chat client Ollama
// (via OllamaSharp), la pipeline di function invocation, MongoDB, il client
// Refit e i "tool" come servizi tipizzati.
public static class AssistantServiceExtensions
{
public static IServiceCollection AddAssistant(this IServiceCollection services, IConfiguration config)
{
var ollama = config.GetSection("Ollama");
var chatClient = new OllamaApiClient(new Uri(ollama["Endpoint"]!), ollama["ChatModel"]!);
// OllamaApiClient implementa IChatClient. UseFunctionInvocation aggiunge il
// FunctionInvokingChatClient, che esegue da solo i tool richiesti dal modello.
services.AddChatClient(chatClient)
.UseFunctionInvocation()
.UseLogging();
var mongo = config.GetSection("Mongo");
var mongoClient = new MongoClient(mongo["ConnectionString"]);
services.AddSingleton<IMongoDatabase>(_ => mongoClient.GetDatabase(mongo["Database"]));
// Client HTTP tipizzato con Refit: niente HttpClient scritto a mano.
services.AddRefitClient<IExchangeRateApi>()
.ConfigureHttpClient(c => c.BaseAddress = new Uri(config["ExchangeRates:BaseUrl"]!));
services.AddScoped<OrderTools>();
services.AddScoped<PricingTools>();
return services;
}
}🚀 Passo 5 · Program.cs: due righe di wiring
Il Program.cs di una Minimal API resta minuscolo: una chiamata ad `AddAssistant` e la mappatura dell'endpoint. Tutto il resto vive nei servizi.
L'app dipende solo dalle interfacce: domani passi da Ollama a OpenAI o Azure cambiando la sola riga del chat client, senza toccare endpoint e tool.
using ToolCallingDemo.Endpoints;
using ToolCallingDemo.Extensions;
var builder = WebApplication.CreateBuilder(args);
// Una riga registra Ollama (chat + function invocation), MongoDB, Refit e i tool.
builder.Services.AddAssistant(builder.Configuration);
var app = builder.Build();
app.MapAssistantEndpoints(); // POST /assistant
app.Run();📦 Passo 6 · Il primo tool: stato ordine da MongoDB
Il primo gruppo di tool legge lo stato di un ordine da MongoDB. È un normale metodo C#: l'attributo `[Description]` — sul metodo e sul parametro — è ciò che il modello legge per decidere se e come chiamarlo.
Il tipo di ritorno è un `record` tipizzato: mai `object`, `dynamic` o tipi anonimi al confine di un tool. Se l'ordine non esiste, restituiamo uno stato `not_found` esplicito, così il modello sa dirlo invece di inventare.
using System.ComponentModel;
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
using MongoDB.Driver;
namespace ToolCallingDemo.Tools;
// I tool sono normali metodi C#. L'attributo [Description] e' cio' che il modello
// legge per capire QUANDO e COME chiamarli; parametri e tipo di ritorno diventano
// lo schema JSON del tool.
public class OrderTools(IMongoDatabase database)
{
private readonly IMongoCollection<Order> _orders = database.GetCollection<Order>("orders");
[Description("Recupera stato, totale e data di consegna di un ordine dato il suo codice.")]
public async Task<OrderStatus> GetOrderStatusAsync(
[Description("Il codice ordine, es. ORD-1024")] string orderCode,
CancellationToken ct = default)
{
var order = await _orders.Find(o => o.Code == orderCode).FirstOrDefaultAsync(ct);
if (order is null)
return new OrderStatus(orderCode, "not_found", 0m, null);
return new OrderStatus(order.Code, order.Status, order.Total, order.EstimatedDelivery);
}
}
// DTO tipizzati: mai object/dynamic/anonimi al confine di un tool.
public record OrderStatus(string OrderCode, string Status, decimal Total, DateOnly? EstimatedDelivery);
// Documento MongoDB letto dal tool. [BsonId] mappa la chiave _id e
// [BsonIgnoreExtraElements] evita che campi extra sul documento rompano
// la deserializzazione (utile quando lo schema evolve).
[BsonIgnoreExtraElements]
public class Order
{
[BsonId]
public ObjectId Id { get; set; }
public string Code { get; set; } = "";
public string Status { get; set; } = "";
// decimal -> Decimal128 e' gia' il default; l'attributo lo rende esplicito
// ed evita sorprese se un vecchio documento salvo' l'importo come double.
[BsonRepresentation(BsonType.Decimal128)]
public decimal Total { get; set; }
public DateOnly? EstimatedDelivery { get; set; }
}🌐 Passo 7 · Un tool che chiama un'API esterna con Refit
Il secondo gruppo converte un prezzo tra due valute chiamando un'API esterna. Il contratto HTTP è un'interfaccia Refit: niente `HttpClient` scritto a mano, il client lo genera Refit.
I parametri non presenti nel path diventano querystring: `ConvertAsync("EUR", "USD", 100)` chiama `/convert?from=EUR&to=USD&amount=100`. Adatta il percorso alla tua API di cambio.
using System.ComponentModel;
using Refit;
namespace ToolCallingDemo.Tools;
// Il contratto dell'API esterna e' l'interfaccia stessa: Refit genera il client.
// I parametri non nel path diventano querystring: /convert?from=EUR&to=USD&amount=100
public interface IExchangeRateApi
{
[Get("/convert")]
Task<ConvertResponse> ConvertAsync(string from, string to, decimal amount, CancellationToken ct = default);
}
public record ConvertResponse(decimal Result);
// Secondo gruppo di tool: converte un importo tra due valute al tasso corrente.
public class PricingTools(IExchangeRateApi api)
{
[Description("Converte un importo da una valuta a un'altra al tasso di cambio corrente.")]
public async Task<decimal> ConvertPriceAsync(
[Description("Importo da convertire")] decimal amount,
[Description("Valuta di partenza ISO 4217, es. EUR")] string from,
[Description("Valuta di arrivo ISO 4217, es. USD")] string to,
CancellationToken ct = default)
{
var conversion = await api.ConvertAsync(from, to, amount, ct);
return Math.Round(conversion.Result, 2);
}
}💬 Passo 8 · L'endpoint /assistant con ChatOptions.Tools
L'endpoint mette tutto insieme: crea gli `AIFunction` con `AIFunctionFactory.Create` a partire dai metodi dei tool, li passa in `ChatOptions.Tools` e chiama `GetResponseAsync`. Il `FunctionInvokingChatClient` fa il resto.
Il system prompt impone di usare i tool per i dati reali e non inventare. Da qui in poi, se la domanda richiede lo stato di un ordine o una conversione, è il modello a chiamare il metodo giusto.
using Microsoft.Extensions.AI;
using ToolCallingDemo.Tools;
namespace ToolCallingDemo.Endpoints;
public record AssistantRequest(string Question);
public record AssistantReply(string Answer);
public static class AssistantEndpoints
{
// POST /assistant — il modello risponde e, se serve, chiama i tool da solo.
public static void MapAssistantEndpoints(this IEndpointRouteBuilder app) =>
app.MapPost("/assistant", async (
AssistantRequest req,
IChatClient chat,
OrderTools orderTools,
PricingTools pricingTools,
CancellationToken ct) =>
{
// I due gruppi di metodi diventano i tool disponibili per questa richiesta.
var options = new ChatOptions
{
Tools =
[
AIFunctionFactory.Create(orderTools.GetOrderStatusAsync),
AIFunctionFactory.Create(pricingTools.ConvertPriceAsync)
]
};
var messages = new List<ChatMessage>
{
new(ChatRole.System,
"Sei l'assistente di un negozio. Usa i tool per dati reali su ordini e prezzi. " +
"Non inventare stati o importi: se un tool non trova nulla, dillo."),
new(ChatRole.User, req.Question)
};
var response = await chat.GetResponseAsync(messages, options, ct);
return Results.Ok(new AssistantReply(response.Text));
});
}🧪 Passo 9 · Provarlo da terminale
Due `curl` e l'assistente è vivo. Alla prima domanda il modello capisce di dover chiamare `GetOrderStatus`; alla seconda, `ConvertPrice`. Tu non instradi nulla: decide lui.
Nota che nel corpo passiamo solo la domanda in linguaggio naturale: la scelta del tool e degli argomenti è interamente del modello.
# lo stato di un ordine: il modello chiamera' GetOrderStatus da solo
$ curl http://localhost:5000/assistant \
-H "Content-Type: application/json" \
-d '{"question":"A che punto e' l ordine ORD-1024?"}'
# una conversione: il modello chiamera' ConvertPrice
$ curl http://localhost:5000/assistant \
-H "Content-Type: application/json" \
-d '{"question":"Quanto fa 149,90 EUR in USD?"}'🔎 Dietro le quinte: il loop di invocazione
Cosa succede davvero a ogni richiesta? Il modello risponde con una function call, il `FunctionInvokingChatClient` esegue il metodo, ne rimette il risultato in conversazione e richiama il modello: il ciclo si ripete finché non c'è più nulla da chiamare.
Con `.UseLogging()` vedi il tutto nei log: la richiesta di chiamata, gli argomenti scelti, il risultato del tuo metodo e la risposta finale. È il modo migliore per capire perché il modello ha scelto un tool.
Richiesta di chiamata, argomenti, risultato del tool e risposta finale: tutto tracciato con UseLogging.
🔒 Passo 10 · Limiti, errori e sicurezza dei tool
Lasciar chiamare funzioni a un LLM è potente, ma va recintato. Gli argomenti li sceglie il modello: trattali come input non fidato. E metti dei paletti al loop, così una catena di chiamate non gira all'infinito.
`Microsoft.Extensions.AI` espone le leve giuste sul `FunctionInvokingChatClient`: numero massimo di round-trip, tolleranza agli errori e livello di dettaglio degli errori restituiti al modello.
- Convalida gli argomenti: verifica formati e permessi prima di toccare il database o l'API.
- Occhio agli effetti collaterali: un tool che scrive (ordini, rimborsi) va protetto; per operazioni critiche valuta un passo di approvazione umana.
- Errori come dati: se un tool lancia un'eccezione, il client può reinviarla al modello perché ritenti; `MaximumConsecutiveErrorsPerRequest` evita loop di fallimenti.
- Chiamate parallele: `AllowConcurrentInvocation` esegue in parallelo più tool nella stessa risposta (default: seriale).
- Modelli e streaming: non tutti i modelli Ollama supportano i tool; con lo streaming i tool funzionano, ma il testo finale arriva dopo l'esecuzione.
using Microsoft.Extensions.AI;
// Stessi tool, ma con limiti espliciti sul loop di function calling.
services.AddChatClient(chatClient)
.UseFunctionInvocation(configure: c =>
{
c.MaximumIterationsPerRequest = 5; // al massimo 5 round-trip col modello
c.MaximumConsecutiveErrorsPerRequest = 2; // stop dopo 2 errori di fila
c.IncludeDetailedErrors = false; // non passare stack trace al modello
})
.UseLogging();FunctionInvokingChatClient · Microsoft Learn ↗
✅ Checklist finale e prossimi passi
Ricapitoliamo il percorso. Se hai seguito gli step, ora hai una Minimal API .NET in cui un LLM locale chiama i tuoi metodi C# come tool, leggendo dati veri da MongoDB e da un'API esterna.
Da qui puoi salire di livello: aggiungere altri tool, combinare i tool con la RAG per unire sapere e azioni, o coordinare più agenti — mantenendo la stessa architettura, perché dipendi dalle interfacce, non dai provider.
- 01Ollama + modellollama3.1; endpoint 11434
- 02Progetto + NuGetExtensions.AI + OllamaSharp + Refit + Driver
- 03appsettings.jsonOllama, API esterna, Mongo
- 04AddAssistantIChatClient + UseFunctionInvocation
- 05Program.csuna riga di wiring + endpoint
- 06Tool ordiniMongoDB + [Description]
- 07Tool prezziAPI esterna via Refit
- 08Endpoint /assistantChatOptions.Tools + GetResponseAsync
Da qui: più tool, RAG + tool insieme, o più agenti con la stessa struttura.
Domande frequenti su function calling .NET
Che cos'è il function calling in un LLM?
È la capacità del modello di chiedere l'esecuzione di funzioni del tuo codice: non le esegue lui, ma decide quale chiamare e con quali argomenti. Il tuo runtime le esegue e restituisce il risultato, che il modello usa per la risposta finale.
Che differenza c'è tra function calling e RAG?
La RAG recupera testo dai tuoi documenti e lo mette nel prompt; il function calling esegue codice per ottenere dati dinamici o compiere azioni. Spesso si usano insieme: RAG per il sapere, tool per i dati vivi e le operazioni.
Ollama supporta il function calling?
Sì, ma solo alcuni modelli (es. llama3.1, qwen3, mistral). Con Microsoft.Extensions.AI e OllamaSharp il modello locale espone i tool come qualsiasi provider cloud; verifica che il modello scelto dichiari il supporto ai tool.
Devo usare il pacchetto Microsoft.Extensions.AI.Ollama?
No: quel pacchetto è deprecato. Oggi si usa OllamaSharp, il cui OllamaApiClient implementa IChatClient. Il resto del codice dipende solo dalle astrazioni, quindi passare a OpenAI o Azure cambia una riga.
Come fa il modello a sapere quando chiamare un tool?
Legge il nome del metodo e gli attributi [Description] (sul metodo e sui parametri), che diventano lo schema JSON del tool. Descrizioni chiare e parametri tipizzati sono ciò che guida la scelta e la compilazione degli argomenti.
È sicuro lasciar chiamare funzioni a un LLM?
Con cautela. Gli argomenti li sceglie il modello: vanno trattati come input non fidato e convalidati. Per i tool che scrivono (ordini, rimborsi) usa permessi e, quando serve, un'approvazione umana; limita i round-trip con MaximumIterationsPerRequest.
Parliamone
Se questo tema ti riguarda, scrivimi: confrontarsi su codice e AI è sempre tempo speso bene.