FUNCTION CALLING con QWEN3 e PYTHON
Pubblicato da Michele Saba
Function Calling con modelli LLM in locale e Python
Indice degli Argomenti
Introduzione
Note
Cos'è il Function Calling?
Gli strumenti: LM Studio e Prompting / OpenAI SDK
Function Calling via Prompting
Le funzioni che vogliamo eseguire, e come dichiararle al modello
Gestione della conversazione
Esempi d'uso
Considerazioni Finali
I modelli linguistici sono davvero molto potenti: possiamo parlarci di qualunque cosa ottenendo risposte sensate e utili.
Tuttavia hanno un limite fondamentale: non possono interagire direttamente e in autonomia con il mondo esterno, e questo ne limita notevolmente le possibilità d'impiego.
Ad esempio: se chiediamo a un modello che ore sono lui potrà comunque soltanto rispondere in base ai dati con cui è stato addestrato. E non avendo accesso a un orologio reale, nel migliore dei casi ammetterà di non saperlo; nel peggiore, inventerà un orario.
Tuttavia anche noi esseri umani operiamo, in un certo senso, allo stesso modo. Possiamo fornire risposte ancorate alla realtà dei fatti solo quando ne veniamo a conoscenza. Tuttavia, a differenza dei modelli linguistici sappiamo, nella gran parte dei casi, come ottenere le informazioni che ci servono per formulare una risposta.
Così che, se chiediamo a un nostro amico che ore sono, questo darà uno sguardo a un orologio fornendoci poi la risposta.
La domanda allora è: se il modello può comprendere istruzioni testuali e generalizzare, perché non abilitarlo a invocare funzioni del nostro codice, così che possa fornirci informazioni o compiere azioni reali?
Perché non spiegargli che abbiamo preparato delle funzioni, che hanno uno specifico scopo e che si aspettano determinati parametri necessari alla loro esecuzione, così che il modello possa intuire da se che in un determinato punto di una conversazione con un utente, potrebbe essere utile invocare una di queste funzioni?
È esattamente questo l’obiettivo del Function Calling.
In questo articolo vedremo come implementare un semplice sistema di function calling con Python, sfruttando LM Studio e la sua compatibilità con lo standard OpenAI chat completions.
Note
Come mostrato nel video, assicuratevi di creare un ambiente virtuale ed installare al suo interno la libreria ufficiale di OpenAI.
Per far girare il modello Qwen 3 (o qualsiasi modello decidiate di usare) ed esporre l'endpoint tramite il quale comunicheremo col modello, scaricate LM Studio.
Come precisato nel video, anche nella versione scritta di questa guida non vedremo il funzionamento della funzione process_stream(); abbiamo parlato della ricezione di risposte tramite stream nel dettaglio, in un'altra guida dedicata. Se siete curiosi/e di saperne di più, date uno sguardo a questo articolo del blog!
Rispetto a quanto mostrato nel video, questa guida scritta risulta semplificata. Potete pensare a questa come agli "appunti" del video, che vi consiglio dunque di visionare comunque in quanto ricco di esempi e dettagli che, per brevità, non hanno qui trovato spazio.
Cos'è il Function Calling?
Il function calling è una "tecnica" che ci permette di eseguire funzioni Python tramite gli LLM, il tutto in linguaggio naturale. Questo permette di arricchire le nostre conversazioni con dati reali (reperiti da un database, da servizi di terzi, ecc.) ed eseguire operazioni di varia natura.
Il funzionamento è semplice:
- 1) Definiamo delle funzioni Python (ad esempio: ottenere l’orario di sistema o il prezzo di un prodotto).
- 2) Comunichiamo al modello che queste funzioni sono disponibili, descrivendole secondo lo standard di definizione richiesto.
- 3) Quando il modello riconosce, a partire dal nostro scambio di messaggi con esso, che la risposta a una nostra domanda richiede una di queste funzioni, produce un output strutturato (un tool_call) con il nome della funzione e i parametri.
- 4) Il nostro codice intercetta la richiesta, esegue la funzione Python, e rimanda il risultato dell'esecuzione al modello.
- 5) Il modello integra il risultato nella risposta finale che darà all’utente.
Per l’utente finale l’esperienza rimane una normale conversazione, ma dietro le quinte il modello ha potuto accedere a funzioni reali, restituendo informazioni che non avrebbe diversamente potuto conoscere o eseguendo (seppur indirettamente) azioni concrete nel mondo reale.
Gli strumenti: LM Studio e Prompting / OpenAI SDK
Per eseguire i modelli in locale utilizzeremo LM Studio, che consente di scaricare ed eseguire diversi modelli open-weight (ovvero modelli scaricabili ed utilizzabili gratuitamente; tuttavia le licenze d'impiego possono variare ed essere più o meno permissive).
Nel nostro esempio useremo Qwen 3 4B, ma la procedura è valida anche per gli altri modelli. Consigliamo di scaricare un modello che includa, tra le sue capabilities, anche "tool use": questi modelli sono stati ottimizzati durante l'addestramento per questo genere di operazioni e forniscono quindi di norma risultati migliori quando così impiegati.
Per semplificare il nostro codice useremo la libreria ufficiale OpenAI, utilizzando con questa un endpoint esposto da LM Studio con cui poter comunicare: http://127.0.0.1:1234/v1
Function Calling via Prompting
Prima di procedere con la scrittura del codice usando l'SDK di OpenAI è bene però chiedersi: e se usassimo solo un prompt, attentamente studiato, per ottenere lo stesso risultato?
Volendo potremmo infatti definire da noi le istruzioni da dare al modello linguistico, gestendo poi sempre da noi l'output.
Di seguito un esempio di prompt per il modello Qwen, dove presentiamo a questo la possibilità di impiegare una funzione get_delivery_date() che consente agli utenti di ottenere informazioni in merito alla data di consegna di un prodotto, dato un valore ID per lo stesso.
Il "cuore" della faccenda sta proprio qui: nell'informare il modello che esistono delle specifiche funzioni che hanno uno scopo, chiedendoli di risponderci in un determinato modo qualora reputasse opportuno impiegarle:
<|im_start|>system
You are Qwen, created by Alibaba Cloud. You are a helpful assistant.
# Tools
You may call one or more functions to assist with the user query.
You are provided with function signatures within <tools></tools> XML tags:
<tools>
{"type": "function", "function": {"name": "get_delivery_date", "description": "Get the delivery date for a customer's order", "parameters": {"type": "object", "properties": {"order_id": {"type": "string"}}, "required": ["order_id"]}}}
</tools>
For each function call, return a json object with function name and arguments within <tool_call></tool_call> XML tags:
<tool_call>
{"name": <function-name>, "arguments": <args-json-object>}
</tool_call><|im_end|>
Mentre interagiamo col nostro modello, possiamo ora chiedere qualcosa del tipo:
"What is the delivery date for my product with ID 123?"
Ed ecco che in output otteniamo, tra i tag <tool_call> specificati nel prompt, proprio la richieste di esecuzione della funzione get_delivery_date(), con la corretta identificazione di 123 come argomento per il parametro order_id:
<tool_call>
{"name": "get_delivery_date", "arguments": {"order_id": "123"}}
</tool_call>
Il modello è sufficientemente intelligente da poter intuire che la nostra richiesta faceva riferimento ad una richiesta che avrebbe potuto assolvere tramite la funzione get_delivery_date(), così come è inoltre stato capace di intuire che il numero d'ordine fosse, nel nostro caso, 123.
Per quanto questo sia di fatto il funzionamento base del processo, gestire gli output del modello e il parsing del loro contenuto potrebbe diventare un lavoro lungo e pieno di insidie.
Per questo utilizziamo la libreria ufficiale di OpenAI, che ci permette di interagire anche con i nostri modelli in locale, purché l'endpoint con il quale decideremo di comunicare rispetti uno degli standard accettati dalla libreria.
Nello specifico, in questa guida useremo lo standard relativo a "chat completions". Faccio notare come al momento OpenAI stia consigliando l'impiego della "responses" api, che è tuttavia al momento non supportata da LM Studio.
Per il nostro caso d'uso, "chat completions" andrà comunque benissimo.
Le nostre funzioni, e come dichiararle al modello
Nell'analisi del codice, partiamo dalle nostre semplici funzioni Python che intendiamo impiegare. Per dettagli aggiuntivi ed esempi ulteriori vi invito comunque a guardare il video nella sua interezza.
La funzione get_current_time() restituisce l'orario di sistema, opportunamente formattato, mentre la funzione get_menu_item_price(), che accetta un parametro "item_name" di tipo stringa, restituisce il prezzo di un elemento presente nel menù, definito in "prices"; prices è qui un semplice dizionario, ma nessuno ci vieta di accedere ai valori associati ad un determinato item_name tramite una qualsiasi altra sorgente dati.
A queste funzioni abbiniamo le rispettive descrizioni in formato JSON schema, secondo lo standard richiesto dall’SDK. Per ciascuna definiamo la tipologia di strumento (nel nostro caso function), il nome, una descrizione significativa che sarà utile al modello per decidere quando usare la funzione, e un elenco dei parametri che dovranno essere passati e quindi, dedotti dalla conversazione con l'utente.
Le descrizioni vengono poi accorpate in un'unica lista AVAILABLE_TOOLS, per facilitarne l'importazione successiva nel resto del codice.
Infine, creiamo una semplice mappa TOOL_DISPATCH per collegare i nomi delle funzioni ai rispettivi oggetti Python. Questo ci permette di semplificare l'accesso alle funzioni nel resto del codice, senza doverci preoccupare di verificare di volta in volta il nome della funzione richiesta dal modello.
# my_tools.py
import time
def get_menu_item_price(item_name: str):
prices = {
"pizza": 10.00,
"salad": 5.00,
"drink": 2.00,
}
price = prices.get(item_name.lower())
if price is None:
return {"error": f"Unknown item '{item_name}'"}
return {"price": price, "item": item_name}
def get_current_time():
return {"time": time.strftime("%H:%M:%S")}
TIME_TOOL = {
"type": "function",
"function": {
"name": "get_current_time",
"description": "Get the current time, only if asked",
"parameters": {"type": "object", "properties": {}},
},
}
MENU_ITEM_PRICE_TOOL = {
"type": "function",
"function": {
"name": "get_menu_item_price",
"description": "Get the price of a menu item, only if asked",
"parameters": {
"type": "object",
"properties": {
"item_name": {
"type": "string",
"description": "The name of the menu item",
},
},
"required": ["item_name"],
},
},
}
AVAILABLE_TOOLS = [TIME_TOOL, MENU_ITEM_PRICE_TOOL]
TOOL_DISPATCH = {
"get_current_time": lambda args: get_current_time(),
"get_menu_item_price": lambda args: get_menu_item_price(args.get("item_name", "")),
}
if __name__ == "__main__":
func_get_menu_price = TOOL_DISPATCH.get("get_menu_item_price")
print(func_get_menu_price({"item_name": "pizza"}))
func_get_current_time = TOOL_DISPATCH.get("get_current_time")
print(func_get_current_time({}))
Gestione della conversazione
Presentiamo ora il codice del secondo file, llm_fc.py, che contiene la logica necessaria alla comunicazione col modello.
Qui:
- 1) Manteniamo una lista di messaggi della conversazione.
- 2) Inviamo input dell’utente al modello, assieme alla lista AVAILABLE_TOOLS.
- 3) Interpretiamo l’output: se è testo lo stampiamo, se è un tool call lo intercettiamo
- 4) Eseguiamo le funzioni richieste, aggiungendo il loro output alla conversazione.
- 5) Chiediamo al modello di formulare la risposta finale integrando i risultati ottenuti dall'esecuzione delle funzioni.
Ecco la parte chiave del ciclo.
Notate come al parametro "tools" stiamo passando la lista AVAILABLE_TOOLS, e come process_stream() restituisca sia response_text che tool_calls
chat_response_stream = client.chat.completions.create(
model=MODEL,
messages=messages,
tools=AVAILABLE_TOOLS,
stream=True,
temperature=0.2,
)
response_text, tool_calls = process_stream(chat_response_stream)
Se sono presenti richieste di esecuzione di funzioni (tool_calls) li eseguiamo, interfacciandoci con TOOL_DISPATCH.
Il risultato dell'esecuzione della nostra funzione viene quindi salvato come valore per "content" all'interno di tool_message, che rappresenta nella lista di messaggi che compongono la conversazione quello contenente la risposta effettiva alla richiesta di esecuzione di una data funzione.
Come mostrato negli esempi illustrati nel video, tool_call_id permette al modello di abbinare correttamente, nel testo che genererà, gli output ottenuti dall'esecuzione di una o più funzioni.
for tool_call in tool_calls:
name = tool_call["function"]["name"]
raw_args = tool_call["function"]["arguments"] or "{}"
try:
args = json.loads(raw_args)
except json.JSONDecodeError:
args = {}
func = TOOL_DISPATCH.get(name)
result = (
{"error": f"Unknown tool '{name}'"} if func is None else func(args)
)
tool_message = {
"role": "tool",
"tool_call_id": tool_call["id"],
"content": json.dumps(result),
}
messages.append(tool_message)
Aggiungendo tool_message alla lista di messaggi "messages", possiamo ora inviare la conversazione aggiornata al nostro modello per ottenere la risposta finale:
final_response, _ = process_stream(
client.chat.completions.create(model=MODEL, messages=messages, stream=True),
add_assistant_label=False,
)
# llm_fc.py
import json
from my_tools import AVAILABLE_TOOLS, TOOL_DISPATCH
from openai import OpenAI
client = OpenAI(base_url="http://127.0.0.1:1234/v1", api_key="lm-studio")
MODEL = "qwen/qwen3-4b-2507"
def process_stream(stream, add_assistant_label=True):
"""Handle streaming responses from the API"""
collected_text = ""
tool_calls = []
first_chunk = True
for chunk in stream:
delta = chunk.choices[0].delta
# Handle regular text output
if getattr(delta, "content", None):
if first_chunk:
print()
if add_assistant_label:
print("Assistant:", end=" ", flush=True)
first_chunk = False
print(delta.content, end="", flush=True)
collected_text += delta.content
# Handle tool calls (streamed in pieces)
if getattr(delta, "tool_calls", None):
for tc in delta.tool_calls:
# Ensure slot exists
while len(tool_calls) <= tc.index:
tool_calls.append(
{
"id": "",
"type": "function",
"function": {"name": "", "arguments": ""},
}
)
# Concatenate streamed fragments
slot = tool_calls[tc.index]
slot["id"] += tc.id or ""
slot["function"]["name"] += tc.function.name or ""
slot["function"]["arguments"] += tc.function.arguments or ""
return collected_text, tool_calls
def chat_loop():
messages = []
print(
"Assistant: Hi! I am an AI agent empowered with the ability to tell the current time and menu item prices! (Type 'quit' to exit)"
)
while True:
user_input = input("\nYou: ").strip()
if user_input.lower() == "quit":
break
messages.append({"role": "user", "content": user_input})
# Get initial response
chat_response_stream = client.chat.completions.create(
model=MODEL,
messages=messages,
tools=AVAILABLE_TOOLS,
stream=True,
temperature=0.2,
)
response_text, tool_calls = process_stream(chat_response_stream)
# print("\n\n--------------------------------")
# print(f"response_text: {response_text}")
# print(f"tool_calls: {tool_calls}")
# print("--------------------------------\n\n")
# continue
if not tool_calls:
print()
text_in_response = len(response_text) > 0
if text_in_response:
messages.append({"role": "assistant", "content": response_text})
# Handle tool calls if any
if tool_calls:
if not text_in_response:
print("Assistant:", end=" ", flush=True)
tool_names = [tc["function"]["name"] for tc in tool_calls]
print(f"**Calling Tool(s): {', '.join(tool_names)}**")
messages.append(
{"role": "assistant", "content": None, "tool_calls": tool_calls}
)
# print(messages)
# Execute tool calls
for tool_call in tool_calls:
name = tool_call["function"]["name"]
raw_args = tool_call["function"]["arguments"] or "{}"
try:
args = json.loads(raw_args)
except json.JSONDecodeError:
args = {}
func = TOOL_DISPATCH.get(name)
result = (
{"error": f"Unknown tool '{name}'"} if func is None else func(args)
)
tool_message = {
"role": "tool",
"tool_call_id": tool_call["id"],
"content": json.dumps(result), # return JSON string
}
# print("\n\n--------------------------------")
# print(f"tool_message: {tool_message}")
# print("--------------------------------\n\n")
messages.append(tool_message)
# Get final response after tool execution
final_response, _ = process_stream(
client.chat.completions.create(
model=MODEL, messages=messages, stream=True
),
add_assistant_label=False,
)
if final_response:
print()
messages.append({"role": "assistant", "content": final_response})
if __name__ == "__main__":
chat_loop()
Esempi d'uso
Eseguendo il programma otteniamo un’interfaccia testuale con cui possiamo interagire.
Notate come, una volta che viene intercettata una tool_call, mostriamo all'utente che stiamo eseguendo la chiamata per lo stesso strumento.
Assistant: Hi! I am an AI agent empowered with the ability to tell the current time and menu item prices! (Type 'quit' to exit)
You: What time is it?
Assistant: **Calling Tool(s): get_current_time**
Assistant: It's 14:37:12
You: What is the price of a pizza and a drink?
Assistant: **Calling Tool(s): get_menu_item_price, get_menu_item_price**
Assistant: A pizza costs 10€, and a drink costs 2€.
Abbiamo visto un esempio pratico di come abilitare il function calling in locale con Python e LM Studio.
Questa tecnica permette di estendere i modelli linguistici con funzionalità reali, mantenendo un’interfaccia conversazionale naturale.
Ovviamente, aprendo l’accesso a funzioni “reali” emergono anche questioni di sicurezza e di robustezza: bisogna sempre validare gli input, gestire le eccezioni e controllare che i modelli non allucinino chiamate a funzioni inesistenti. Diventa inoltre di grande importanza il discorso relativo all'allineamento dei modelli, discusso nel dettaglio in questo fantastico video di Enkk.
In generale, vi invito quindi a prestare grande attenzione nell'impiego di queste funzionalità, per evitare risultati nel mondo reale che potrebbero essere anche catastrofici!
Con queste attenzioni, però, il function calling diventa uno strumento potente per trasformare un LLM in un vero agente intelligente, capace di interagire con dati e servizi esterni.
Happy coding!