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 — «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