Function calling: quando l’LLM usa gli strumenti

Un modello linguistico non sa che ore sono e non può leggere un database. Con il function calling gli diamo strumenti da usare: lui decide quando chiamarli, il nostro codice li esegue. Tutorial pratico con Python, dal primo tool al loop completo.

Chiedete a un modello linguistico che tempo fa a Bologna e vi risponderà, nel migliore dei casi, che non ha accesso a dati in tempo reale. Nel peggiore, inventerà un meteo plausibile. Non è un limite aggirabile con un prompt più furbo: il modello è una funzione che trasforma testo in testo, sigillata rispetto al mondo esterno. Eppure oggi gli assistenti AI prenotano voli, interrogano database e mandano email. Il ponte tra le due cose ha un nome preciso, function calling (o tool use), ed è il mattone fondamentale su cui è costruito tutto ciò che chiamiamo «agente». In questo tutorial lo costruiamo da zero, in Python, senza framework: solo l’SDK del provider e una funzione nostra.

L’idea: il modello non esegue, chiede

Il malinteso più comune è immaginare che il modello esegua le funzioni. Non succede mai. Il meccanismo è più simile a un direttore d’orchestra che non suona nessuno strumento: legge la partitura (la vostra richiesta), decide che serve il violino (lo strumento giusto), e fa un gesto preciso al violinista (il vostro codice). Il suono lo produce il violinista; il direttore ascolta il risultato e decide come proseguire.

In concreto, il protocollo ha quattro passi:

  • Voi inviate al modello la richiesta dell’utente più la lista degli strumenti disponibili, ognuno con nome, descrizione e schema dei parametri.
  • Il modello, se decide che serve uno strumento, non risponde con testo: risponde con una richiesta strutturata, del tipo «chiama get_weather con {"city": "Bologna"}«.
  • Il vostro codice esegue davvero la funzione e rimanda il risultato al modello.
  • Il modello legge il risultato e formula la risposta finale in linguaggio naturale. Oppure chiede un altro strumento, e il giro ricomincia.

Utente «che tempo fa?» Modello decide se e quale strumento usare 1. tool_use: get_weather(«Bologna») Il tuo codice esegue la funzione 2. tool_result: «18 °C, sereno» 3. risposta finale in linguaggio naturale Il modello non esegue mai nulla: chiede. L’esecuzione resta nel vostro codice, e il giro 1→2 può ripetersi più volte prima della risposta (3).

Il loop del function calling: il modello riceve la richiesta, emette una chiamata strutturata (tool_use), il vostro codice esegue e restituisce il risultato (tool_result), il modello compone la risposta. I passi 1 e 2 possono ripetersi più volte in un singolo turno.

Definire uno strumento

Uno strumento è descritto da tre cose: un nome, una descrizione in linguaggio naturale e uno schema JSON dei parametri. La descrizione non è un commento decorativo: è l’informazione che il modello usa per decidere quando chiamare lo strumento. Prepariamo l’ambiente (serve una API key di Anthropic nella variabile ANTHROPIC_API_KEY; il pattern è identico con gli altri provider, cambia solo la sintassi):

pip install anthropic

Poi definiamo lo strumento e la funzione Python che lo implementa. Per rendere il tutorial eseguibile senza chiavi di servizi meteo, usiamo dati finti (nel mondo reale qui ci sarebbe una chiamata HTTP):

import anthropic

client = anthropic.Anthropic()

# Lo schema che il modello vede: nome, descrizione, parametri
tools = [
    {
        "name": "get_weather",
        "description": (
            "Restituisce il meteo attuale per una città italiana. "
            "Usala quando l'utente chiede che tempo fa o la temperatura."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "city": {
                    "type": "string",
                    "description": "Nome della città, es. 'Bologna'",
                }
            },
            "required": ["city"],
        },
    }
]

# L'implementazione vera: il modello non la vede mai
def get_weather(city: str) -> str:
    finti_dati = {
        "bologna": "18 °C, sereno, umidità 60%",
        "milano": "16 °C, nuvoloso, umidità 75%",
        "palermo": "24 °C, soleggiato, umidità 50%",
    }
    return finti_dati.get(city.lower(), f"Nessun dato disponibile per {city}")

Notate la separazione: lo schema è il contratto pubblico, la funzione è l’implementazione privata. Il modello ragiona solo sul contratto.

Il primo giro: il modello chiede uno strumento

Inviamo la domanda insieme alla lista degli strumenti e osserviamo cosa torna indietro:

messages = [{"role": "user", "content": "Che tempo fa a Bologna?"}]

response = client.messages.create(
    model="claude-opus-4-8",
    max_tokens=4096,
    tools=tools,
    messages=messages,
)

print(response.stop_reason)  # -> "tool_use"
for block in response.content:
    if block.type == "tool_use":
        print(block.name)   # -> "get_weather"
        print(block.input)  # -> {'city': 'Bologna'}

Il campo chiave è stop_reason. Quando vale "tool_use", il modello si è fermato apposta: sta aspettando che voi eseguiate la funzione. Nel contenuto della risposta c’è un blocco strutturato con il nome dello strumento, i parametri già estratti dalla frase dell’utente e un identificativo (block.id) che servirà per collegare il risultato alla richiesta, come il numero d’ordine su uno scontrino.

Chiudere il cerchio: il loop completo

Ora la parte che trasforma il meccanismo in qualcosa di utile: un loop che continua finché il modello chiede strumenti e si ferma quando ha la risposta. È il cuore di ogni agente, e sta in una ventina di righe:

def esegui_tool(name: str, tool_input: dict) -> str:
    if name == "get_weather":
        return get_weather(tool_input["city"])
    return f"Errore: strumento sconosciuto '{name}'"


messages = [{"role": "user", "content": "Che tempo fa a Bologna? E a Palermo?"}]

while True:
    response = client.messages.create(
        model="claude-opus-4-8",
        max_tokens=4096,
        tools=tools,
        messages=messages,
    )

    # Il modello ha finito: nessun altro strumento richiesto
    if response.stop_reason != "tool_use":
        break

    # 1. Aggiungiamo alla storia la risposta del modello (con i tool_use)
    messages.append({"role": "assistant", "content": response.content})

    # 2. Eseguiamo ogni strumento richiesto e raccogliamo i risultati
    tool_results = []
    for block in response.content:
        if block.type == "tool_use":
            risultato = esegui_tool(block.name, block.input)
            tool_results.append({
                "type": "tool_result",
                "tool_use_id": block.id,  # aggancia risultato e richiesta
                "content": risultato,
            })

    # 3. I risultati tornano al modello come messaggio utente
    messages.append({"role": "user", "content": tool_results})

# Risposta finale in linguaggio naturale
testo = next(b.text for b in response.content if b.type == "text")
print(testo)

Eseguendo lo script, il modello chiama get_weather due volte (spesso nello stesso turno, con due blocchi tool_use paralleli) e poi compone qualcosa come: «A Bologna ci sono 18 °C con cielo sereno, mentre a Palermo fa più caldo: 24 °C e soleggiato.» La frase finale è generata, ma i numeri dentro arrivano dalla vostra funzione: è questa la differenza tra chiedere al modello di ricordare e dargli il modo di consultare.

Tre dettagli del loop meritano attenzione. Primo: la risposta del modello va sempre riaggiunta alla conversazione prima dei risultati, altrimenti l’API rifiuta la richiesta. La storia deve contenere la domanda dello strumento seguita dalla sua risposta. Secondo: se il modello chiede più strumenti nello stesso turno, tutti i tool_result vanno rimandati in un unico messaggio. Terzo: il loop va sempre limitato in produzione (un contatore di iterazioni massime), perché un modello confuso può teoricamente continuare a chiamare strumenti all’infinito.

Gestire gli errori: dite al modello cosa è andato storto

Cosa succede se la funzione fallisce (città inesistente, API esterna giù, parametro assurdo)? La tentazione è gestire l’errore in silenzio. La pratica corretta è l’opposto: raccontarlo al modello, marcando il risultato come errore:

tool_results.append({
    "type": "tool_result",
    "tool_use_id": block.id,
    "content": "Errore: 'Bolonia' non trovata. Forse intendevi 'Bologna'?",
    "is_error": True,
})

Un messaggio d’errore informativo permette al modello di correggersi da solo: ritenta con il parametro giusto o spiega il problema all’utente. È lo stesso principio di un buon collega: se gli dite solo «non funziona» resterà bloccato, se gli dite cosa non funziona troverà un’alternativa.

Due avvertenze di sicurezza, brevi ma non negoziabili. I parametri che arrivano dal modello sono input non fidato: vanno validati come validereste un form compilato da uno sconosciuto, perché il modello può sbagliare o essere manipolato dal testo che legge. E per strumenti con effetti irreversibili (inviare email, cancellare dati, muovere denaro) serve un passaggio di conferma umana prima dell’esecuzione: il modello propone, la persona dispone.

Perché vi riguarda

Il function calling è il punto in cui i modelli linguistici smettono di essere generatori di testo e diventano componenti software: sistemi che leggono i vostri dati veri, interrogano i vostri servizi veri, agiscono. Ogni agente di cui sentite parlare (dagli assistenti di programmazione ai sistemi che gestiscono ticket di supporto) è, spogliato del marketing, il loop che avete appena scritto: modello, strumenti, risultati, ripetere. Anche protocolli più recenti come MCP, che standardizzano il modo in cui gli strumenti vengono scoperti e condivisi tra applicazioni, poggiano esattamente su questo meccanismo. Le venti righe di questo tutorial non sono un giocattolo: sono l’unità minima di tutto quello che viene dopo. Sostituite get_weather con una query al vostro database, e avete già qualcosa che ieri non esisteva.

I commenti sono riservati agli iscritti.

Accedi per commentare