Cinque pattern di function calling che uso in produzione
Cinque pattern di function calling che uso in sistemi in produzione, e gli anti-pattern che ho sostituito con questi.
Ho tradotto questo articolo dall’inglese con un modello linguistico. Leggi l’originale in inglese
Aggiornamento, ottobre 2026. Ho ristretto il titolo e le frasi d’apertura su dove ho usato questi pattern; il resto del testo è invariato. Il motivo è nella sezione di aggiornamento verso la fine.
Perché conta
L’uso dei tool è un punto di errore frequente nei sistemi LLM. Il problema non dipende solo dal ragionamento del modello o dal disegno del prompt. Spesso nasce nello spazio tra "il modello sa quale tool chiamare" e "il tool esegue e restituisce qualcosa di utile". Nei sistemi LLM ricorrono i pattern che seguono. Ricorrono anche tre anti-pattern che aumentano la latenza, saturano la finestra di contesto e lasciano gli agenti in loop indefiniti durante esecuzioni non presidiate.
Raccomando questi pattern per agenti potenziati con tool, per esempio in workflow finanziari, pipeline di ricerca e sistemi multi-agente. I pattern sotto sono approcci che uso quando mi serve un’interfaccia tool esplicita, più le alternative che hanno fallito.
1. Contratti dei tool schema-first
Un anti-pattern comune è definire una funzione Python, decorarla con @tool e fidarsi che l’LLM passi argomenti validi. Funziona finché smette di funzionare. Input come max_results: "ten", date: "last week" o query: null producono un TypeError da qualche parte nell’executor. Se mi limito a catturare quell’errore, loggarlo e continuare, ho reso il tool silenziosamente inaffidabile.
Lo evito dando a ogni tool uno schema che valida gli input prima dell’esecuzione: un modello Pydantic nell’agent framework, un JSON Schema nella definizione del tool del provider, oppure una modalità "strict" / structured outputs del provider che rifiuta argomenti non conformi al boundary dell’API. Lo schema documenta anche il tool: è ciò che il modello legge quando decide come chiamarlo. Le API dei framework cambiano (args_schema=…, inferenza da type annotation, oggetti parameters compatibili con OpenAI); l’invariante no.
# Anti-pattern: raw function with no input schema
@tool
def search_documents (query: str , max_results: int = 5 ):
# LLM can pass anything — no validation before executor runs
return document_store.search(query, limit=max_results)
# Pattern: explicit schema with field constraints and descriptions
class SearchInput (BaseModel ):
query: str = Field(description="Search terms for semantic retrieval. No boolean operators." )
max_results: int = Field(default=5 , ge=1 , le=20 , description="Number of documents to return." )
date_filter: str | None = Field(default=None , description="ISO 8601 date prefix, e.g. '2024-Q1'." )
@tool(args_schema=SearchInput) # or the current framework equivalent
def search_documents (query: str , max_results: int , date_filter: str | None ):
results = document_store.search(query, limit=max_results)
if date_filter:
results = [r for r in results if r.date.startswith(date_filter)]
return results
Lo schema svolge tre compiti: validazione, documentazione e conversione dei tipi. Intercetta gli input non validi prima che l’executor venga eseguito. Espone i campi description che il modello usa per costruire chiamate valide. Può anche convertire valori come max_results: "5" in 5, quando il runtime lo consente. Una funzione priva di schema non offre queste garanzie. Anche quando il provider impone la conformità degli argomenti allo schema, mantengo un controllo nell’applicazione: l’API può rifiutare argomenti malformati, ma i vincoli operativi, come intervalli ammessi, enum e permessi, restano nel mio codice.
Le descrizioni dei campi non sono commenti. Sono parte della specifica del tool che l’LLM riceve. Descrizioni poco chiare producono chiamate poco chiare. Gli schemi più utili che ho scritto somigliano a brevi riferimenti API: cosa significa ogni campo, quale formato si aspetta, quali vincoli si applicano e qual è il default. Quando vedo chiamate tool malformate in produzione, riscrivo le descrizioni dei campi prima di cambiare modello o prompt.
2. Union di errori tipizzati, non traceback
Quando un tool fallisce, l’approccio ingenuo è catturare l’eccezione e restituire il traceback come stringa. Il modello legge il traceback, può inferire l’errore e prova una chiamata diversa. Nella mia esperienza, a volte funziona. Il problema inizia quando il traceback diventa l’interfaccia di recovery.
Un traceback Python è un contesto di errore debole per un modello. È non strutturato, verboso e pieno di nomi interni che il modello potrebbe interpretare male. Inoltre gonfia la context window a ogni retry. Se l’errore è transitorio, come un rate limit o un network timeout, il modello potrebbe non sapere se fare retry, back off o abort. Nei sistemi che ho osservato, questa ambiguità può causare retry immediati e cascate.
# Anti-pattern: exception trace as error recovery context
try :
result = search_documents(query=query, max_results=max_results)
return result
except Exception as e:
return f"Error: {traceback.format_exc()}" # 20 lines of internal stack trace
# Pattern: typed error union in the return schema
class SearchResult (BaseModel ):
status: Literal ["ok" , "empty" , "rate_limited" , "auth_error" ]
documents: list [Document] = []
retry_after_s: int | None = None # set when status == "rate_limited"
error_detail: str | None = None # set on auth_error
def search_documents (query: str , max_results: int ) -> SearchResult:
try :
docs = document_store.search(query, limit=max_results)
if not docs:
return SearchResult(status="empty" )
return SearchResult(status="ok" , documents=docs)
except RateLimitError as e:
return SearchResult(status="rate_limited" , retry_after_s=e.retry_after)
except AuthError:
return SearchResult(status="auth_error" , error_detail="API key invalid or expired." )
Il modello può ragionare su status: rate_limited e retry_after_s: 30 perché quella struttura corrisponde a pattern API comuni. Uno stack trace gli dà materiale meno affidabile. La typed error union rende anche esplicito il contratto del tool: queste sono le modalità di failure note, e questo è ciò che ciascuna significa per il caller.
Includo retry_after_s per i rate limit. I modelli con supporto a function calling possono usare questo segnale per fare back off o escalation, in base all’agent loop e alla logica dell’executor. Senza questo campo, un agent loop può ritentare subito e amplificare i problemi di rate limit.
3. Dispatch parallelo e perché il sequenziale è l’anti-pattern di default
La maggior parte degli agent tutorial invia le chiamate tool in modo sequenziale: chiama il tool A, aspetta il risultato, poi decide se chiamare il tool B. Funziona. Per tool indipendenti, però, può essere più lento del necessario.
La maggior parte delle principali API di tool calling ora emette più chiamate tool in un singolo turn di risposta quando il modello decide che il lavoro è indipendente. Non è più un caso esotico, ma l’executor deve rispettarlo. Se serializzo quelle chiamate una per una, perdo il vantaggio di latenza che il modello ha già richiesto. Quando non c’è dipendenza tra le chiamate, le mando in esecuzione concorrente.
import asyncio
# Anti-pattern: sequential dispatch
async def run_tools_sequential (tool_calls: list [ToolCall]) -> list [ToolResult]:
results = []
for call in tool_calls:
result = await execute_tool(call) # wait for each before starting next
results.append(result)
return results
# Pattern: concurrent dispatch
async def run_tools_parallel (tool_calls: list [ToolCall]) -> list [ToolResult]:
tasks = [execute_tool(call) for call in tool_calls]
results = await asyncio.gather(*tasks, return_exceptions=True )
return [
ToolResult(error=str (r)) if isinstance (r, Exception) else r
for r in results
]
Il guadagno concreto dipende dalla latenza dei tool, dall’overhead dell’executor, dall’overhead di scheduling, dall’overhead di rete e dal fatto che le chiamate siano davvero indipendenti. Tre chiamate tool indipendenti con media di 300ms ciascuna richiedono circa 900ms in sequenza. Se domina la latenza dei tool e l’executor può eseguirle in modo concorrente, il parallel dispatch può ridurre il wall-clock time verso la durata della chiamata più lenta, più overhead. In un agent loop con più step tool indipendenti, questo può ridurre il wall-clock time nelle stesse condizioni.
Tengo presenti due cautele. Primo, asyncio.gather con return_exceptions=True è importante: un singolo tool che fallisce non dovrebbe far crashare l’intero gather. Gestisco le eccezioni per risultato. Secondo, non tutte le chiamate tool sono indipendenti. Se la chiamata B dipende dal risultato della chiamata A, l’esecuzione sequenziale è corretta. L’LLM può rappresentarlo mettendo le chiamate dipendenti in turn separati. Se vedo chiamate sequenziali nello stesso turn per tool che dovrebbero essere indipendenti, ispeziono prima le descrizioni dei tool. Potrebbero non essere abbastanza chiare su cosa restituisce ciascun tool.
4. Rilevamento dei loop tramite fingerprint delle chiamate
Il loop infinito dei tool è una failure di produzione che ho visto più volte. Il modello chiama ripetutamente lo stesso tool con gli stessi argomenti, perché non riceve un risultato soddisfacente o perché è entrato in un reasoning loop. Se nulla lo ferma, il loop esaurisce il budget di token e comunque non risolve il task.
Controllo un fingerprint prima di ogni dispatch del tool:
from hashlib import sha256
import json
class LoopDetector :
def __init__ (self, max_repeats: int = 2 ):
self .call_counts: dict [str , int ] = {}
self .max_repeats = max_repeats
def is_looping (self, tool_name: str , args: dict ) -> bool :
key = sha256(
json.dumps({"tool" : tool_name, "args" : args}, sort_keys=True ).encode()
).hexdigest()[:16 ]
self .call_counts[key] = self .call_counts.get(key, 0 ) + 1
return self .call_counts[key] > self .max_repeats
# In the executor:
detector = LoopDetector(max_repeats=2 )
for call in tool_calls:
if detector.is_looping(call.name, call.arguments):
return ToolResult(
status="loop_detected" ,
message=f"Tool '{call.name}' called with identical arguments {detector.max_repeats + 1 } times."
)
result = await execute_tool(call)
Quando l’executor restituisce status: loop_detected, indirizzo il conditional edge del graph verso un nodo di escalation, di solito un agente supervisore o un interrupt HITL. Non lo rimando al tool-calling agent. Il modello riceve un segnale chiaro e strutturato: deve usare una strategia diversa.
Il fingerprint è calcolato da (tool_name, sorted args) dopo normalizzazione JSON. max_results: 5 e max_results: 5 sono lo stesso fingerprint. query: "revenue Q3" e query: "revenue Q4" sono diversi. Uso max_repeats: 2 come punto di partenza. Due chiamate identiche possono significare "failure transitorio, sto ritentando". Nei sistemi che ho ispezionato, tre chiamate identiche sono spesso un segno che l’agent è bloccato.
5. Routing basato sullo status tra chiamate tool
Una volta che ho typed error unions, posso indirizzare il graph in base allo status invece di fare parsing del contenuto del tool. Il principio è lo stesso dei LangGraph conditional edges: la logica di routing vive nello stato, non nelle edge function.
Dopo ogni risultato tool, un router node leggero legge result.status e scrive un segnale di routing nello stato. Il conditional edge legge quel segnale. La routing map diventa diretta: rate_limited → aspetta e ritenta; auth_error → escalation a HITL; empty → prova un tool alternativo; ok → continua. Il comportamento resta pulito, testabile e visibile nelle trace di stato.
Fare parsing del contenuto del tool dentro la edge function è più fragile. Il formato dell’output del tool può variare con versioni del modello e system prompt. Una decisione di routing basata su "l’output contiene la parola Error" può rompersi quando cambia il formato dell’output. Una decisione di routing basata su status: Literal["ok", "rate_limited", "auth_error", "empty"] è più stabile.
La conseguenza pratica è semplice: posso scrivere unit test per la logica di routing senza fare mocking dell’LLM. Posso creare un SearchResult(status="rate_limited", retry_after_s=30), passarlo attraverso il router e verificare che il prossimo nodo sia "wait_and_retry". Quel test è veloce, deterministico e copre la modalità di failure che mi interessa.
6. Tool choice e igiene del prompt
Un segnale che le definizioni dei tool richiedono lavoro è la necessità di forzare un tool nominato tramite tool_choice (in stile OpenAI {"type": "function", "function": {"name": "…"}}, o l’equivalente del provider per "required" / selezione nominata). Forzare la tool choice è una escape hatch valida. Come pattern di default, però, è un cattivo odore.
Quando forzo la tool choice, spesso sto compensando un prompt o uno schema che non rende abbastanza chiara la selezione del tool. Preferisco chiarire le descrizioni dei tool e il system prompt finché tool_choice="auto" (o il default del provider) instrada correttamente sulla distribuzione reale delle query. Questo riduce la logica di controllo a inference time e rende il comportamento meno dipendente da una singola forma di query ristretta.
C’è un’eccezione. Per l’ultimo step di una pipeline di structured output, dove voglio che il modello chiami sempre un tool "finalize" — oppure emetta uno schema JSON fisso tramite structured outputs / strict mode — forzare il canale è corretto. È un vincolo strutturale, non una compensazione per descrizioni poco chiare.
Per agent con molti tool, tengo il system prompt focalizzato sul ruolo e sullo scope dell’agent. Evito di metterci logica di selezione dei tool. Se mi trovo a scrivere "usa il search tool quando l’utente chiede informazioni sui documenti" nel system prompt, lo tratto come un segnale che la descrizione del tool non lo dice abbastanza chiaramente. Correggo la descrizione del tool.
Il quadro di categoria
I sei pattern condividono un principio: rendere l’interfaccia tra l’LLM e il layer dei tool esplicita, tipizzata e ispezionabile. Uso schema contracts invece di raw dict. Uso typed error unions invece di exception traces. Uso concurrent dispatch invece di sequential dispatch quando le chiamate sono indipendenti e domina la latenza dei tool. Uso loop detection invece di sperare che il modello si auto-corregga. Uso status signals invece del content parsing nella logica di routing. Uso descrizioni chiare dei tool e igiene del prompt invece di affidarmi, di default, alla tool choice forzata.
Gli anti-pattern sono versioni di "lascia che sia l’LLM a capirlo". Può funzionare a qualità demo. In condizioni simili alla produzione, con distribuzioni di query reali e modalità di errore reali, ho visto rompersi per primi i contratti impliciti.
Quando costruisco o revisiono un tool-use agent e vedo problemi di affidabilità o latenza, cerco queste modalità di failure e applico le correzioni corrispondenti.
Aggiornamento — ottobre 2026
Ho ristretto il titolo da «Cinque pattern di function calling che hanno retto in produzione» a «Cinque pattern di function calling che uso in produzione». Il vecchio titolo presentava la mia esperienza come un risultato. I sistemi che ho costruito o revisionato non sono pubblici, quindi ciò che ha retto in quei sistemi è un mio resoconto, non un’evidenza che puoi ispezionare. Il codice di ogni sezione è la parte che puoi verificare.
Anche due frasi della sezione 1 sono più ampie di quanto intendessi. La conversione dei tipi dipende dalla modalità di validazione: Pydantic converte "5" in 5 nella modalità lax predefinita e lo rifiuta in modalità strict. E una funzione senza schema esplicito non è sempre priva di controlli: il decoratore @tool di LangChain, per esempio, ricava uno schema degli argomenti dai type hint. Il mio punto è più circoscritto: uno schema esplicito mette validazione, documentazione dei campi e conversione dei tipi in un unico punto che posso leggere e testare. Ora la risposta della FAQ sulla validazione dice questo.
FAQ
Come dovrei validare gli argomenti dei tool LLM prima dell’esecuzione?
Uso uno schema Pydantic per ogni tool e lo passo come schema degli argomenti del tool. Lo schema valida gli input prima che l’executor venga eseguito, documenta ogni campo per il modello e, nella modalità lax predefinita di Pydantic, converte i tipi, per esempio max_results: "5" in 5.
Perché evitare di restituire traceback Python al modello?
Evito i traceback perché sono non strutturati, verbosi e pieni di nomi interni che il modello potrebbe interpretare male. Gonfiano anche la context window durante i retry. Una typed error union dà al modello status espliciti come rate_limited, auth_error, empty e ok.
Quando le chiamate tool dovrebbero essere eseguite in parallelo invece che in sequenza?
Eseguo il dispatch delle chiamate tool in modo concorrente quando sono indipendenti. L’esecuzione sequenziale è corretta quando la chiamata B dipende dalla chiamata A. Le chiamate indipendenti nello stesso turn possono essere raccolte in modo concorrente e gestite per risultato, se l’executor lo supporta e se domina la latenza dei tool.
Come posso rilevare un loop infinito dei tool LLM?
Calcolo il fingerprint di ogni chiamata a partire dal nome del tool e dagli argomenti normalizzati in JSON e ordinati prima del dispatch. Se lo stesso fingerprint supera il limite di ripetizione configurato, l’executor restituisce status: loop_detected e indirizza verso escalation invece di rimandare l’agent nello stesso loop.
Come dovrebbe funzionare il routing dopo un risultato tool?
Eseguo il routing da valori di status strutturati, non facendo parsing del contenuto del tool. Un router legge result.status e scrive un segnale di routing nello stato. In questo modo casi come rate_limited, auth_error, empty e ok possono mappare a prossimi step chiari, visibili nelle trace di stato e facili da testare con unit test.
Articoli correlati
RAG in produzione: prima chunking e re-ranking, poi gli embedding
Quando il retrieval è debole, cambiare gli embedding raramente lo sistema. Diagnostico prima chunking e re-ranking.
20 dic 202414 min di lettura#RAG#Retrieval#LLM#ProduzionePerché gli agenti di ricerca autonomi allucinano, e come un ciclo di critica fa emergere le affermazioni senza fonte
Gli agenti di ricerca possono sembrare autorevoli e inventare citazioni. Un critico con fonti intercetta le affermazioni non supportate: non azzera il rischio.
15 nov 202410 min di lettura#Agenti AI#Ricerca#LLM#ProduzioneIl prototipo LLM funziona. Quattro domande prima della produzione
Stato, ripristino, eval, costi: quattro domande da porsi prima di portare in produzione un prototipo LLM, con esempi reali e i limiti dichiarati.
10 ott 20265 min di lettura#mklang#LLM#Produzione#Valutazione#Costi