cool-solution — dev.blog
Tecnologie

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.

Schema del function calling: una domanda entra in un LLM che, invece di rispondere subito, chiede di eseguire due tool C# (stato ordine da MongoDB e conversione prezzo via Refit) e usa i risultati per la risposta finale, palette Cool Solution.

🧠 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 loop di tool calling
Il loop del function calling in quattro passi: la domanda dell'utente arriva al modello; il modello risponde con una richiesta di chiamata (function call) e gli argomenti; il tuo codice esegue il metodo e restituisce il risultato; il modello usa il risultato per la risposta finale. Il ciclo può ripetersi più volte.

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.

Recuperare vs. agire

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.

Da metodo C# a schema del tool
Anatomia di un tool: a sinistra un metodo C# GetOrderStatusAsync con attributo [Description] sul metodo e sul parametro orderCode; una freccia mostra come AIFunctionFactory.Create lo trasforma nello schema JSON a destra, con name, description e properties (orderCode di tipo string) che il modello legge.

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.
Architettura: Minimal API + Ollama + tool
Architettura della Minimal API: l'endpoint POST /assistant passa la domanda a IChatClient (Ollama via OllamaSharp) avvolto dal FunctionInvokingChatClient; quando il modello lo richiede, vengono eseguiti i tool OrderTools (verso MongoDB) e PricingTools (verso un'API esterna via Refit), e il risultato torna al modello per la risposta.

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 con i tool
# 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?"

Tool calling · Ollama

Verifica che il modello scelto elenchi il supporto ai tool: non tutti ce l'hanno.

🧱 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`.

Crea il progetto e aggiungi i pacchetti
# 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.Driver

Microsoft.Extensions.AI · Microsoft Learn

OllamaSharp sostituisce il deprecato Microsoft.Extensions.AI.Ollama e implementa IChatClient.

⚙️ 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.

appsettings.json
{
  "Ollama": {
    "Endpoint": "http://localhost:11434",
    "ChatModel": "llama3.1"
  },
  "ExchangeRates": {
    "BaseUrl": "https://api.exchangerate.host"
  },
  "Mongo": {
    "ConnectionString": "mongodb://localhost:27017",
    "Database": "shopdemo"
  }
}
Modello, host e provider stanno qui: cambi configurazione senza ricompilare.

🧩 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.

AssistantServiceExtensions.cs
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;
    }
}

OllamaSharp · GitHub

Un solo punto registra Ollama, la function invocation, MongoDB, Refit e i tool.

🚀 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.

Program.cs
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();
AddAssistant e un endpoint: tutto qui. I provider restano dietro le interfacce.

📦 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.

OrderTools.cs
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; }
}

MongoDB C# Driver · Docs

Un metodo + [Description] = un tool. Ritorno tipizzato e stato not_found esplicito.

🌐 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.

PricingTools.cs
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);
    }
}

Refit · GitHub

L'interfaccia È il contratto: Refit genera il client HTTP tipizzato usato dal tool.

💬 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.

AssistantEndpoints.cs
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));
        });
}
AIFunctionFactory.Create trasforma i metodi in tool; ChatOptions.Tools li offre al modello.

🧪 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.

Prova l'assistente
# 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?"}'
Stessa API, due tool diversi: la scelta è del modello, non tua.

🔎 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.

Il loop di function calling nei log
Terminale che mostra il loop: la richiesta arriva, il log segnala una function call GetOrderStatus con argomento orderCode ORD-1024, poi il risultato del tool (status shipped, consegna 2026-07-15), infine la risposta finale del modello in linguaggio naturale all'utente.

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.
Paletti sul loop di function calling
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

Round-trip limitati, errori sotto controllo e nessuno stack trace restituito al modello.

✅ 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.

Il tutorial in 8 mosse
  1. 01
    Ollama + modellollama3.1; endpoint 11434
  2. 02
    Progetto + NuGetExtensions.AI + OllamaSharp + Refit + Driver
  3. 03
    appsettings.jsonOllama, API esterna, Mongo
  4. 04
    AddAssistantIChatClient + UseFunctionInvocation
  5. 05
    Program.csuna riga di wiring + endpoint
  6. 06
    Tool ordiniMongoDB + [Description]
  7. 07
    Tool prezziAPI esterna via Refit
  8. 08
    Endpoint /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.

Altri articoli del blog

tips-claude-resume-luglio-2026.md10 luglio 2026claude-code-hooks-quality-gate-2026.md9 luglio 2026orchestrazione-agenti-ai-multi-agent-dotnet-2026.md8 luglio 2026