Python & AI Tutorials Logo
LangChain & LangGraph

14. Costruire il ciclo dell'agente

Nel Capitolo 13 abbiamo imparato come eseguire le richieste di chiamata degli strumenti dell'LLM e restituire i risultati. Tuttavia, quel lavoro presupponeva che ogni compito potesse essere gestito con una singola chiamata dello strumento. In pratica, le chiamate degli strumenti spesso devono continuare su più cicli finché l'LLM non ha raccolto tutte le informazioni di cui ha bisogno per una risposta finale.

Consideriamo la richiesta "trova la popolazione della Francia, poi moltiplicala per due". Questo compito richiede almeno due chiamate degli strumenti. Devi prima cercare la popolazione — solo allora puoi eseguire il calcolo. La seconda chiamata dipende dal primo risultato, quindi non c'è modo di gestirla con una singola chiamata dello strumento.

In questo capitolo, trasformiamo il singolo ciclo del Capitolo 13 in un ciclo (loop). Finché l'LLM richiede chiamate degli strumenti, continuiamo a eseguirle — ripetendo finché l'LLM non smette di richiedere strumenti da solo. Poi aggiungiamo limiti di sicurezza per evitare che il ciclo venga eseguito all'infinito, e affrontiamo la gestione degli errori in modo che l'agente non si blocchi quando uno strumento fallisce.

14.1) Dal singolo ciclo al loop

14.1.1) Come funziona il ciclo dell'agente?

Come abbiamo visto nell'introduzione, i compiti in cui l'azione successiva dipende dal risultato del passo precedente spesso non possono essere gestiti con una singola chiamata dello strumento. L'LLM deve chiamare uno strumento, verificare il risultato e decidere di nuovo. Questo è ciò che fa il ciclo dell'agente, e funziona in tre fasi:

  • Think — L'LLM legge la conversazione fino a quel momento e decide cosa fare dopo. Se ha bisogno di uno strumento, richiede una chiamata dello strumento tramite tool_calls. Altrimenti, restituisce una risposta finale.
  • Act — Esegue lo strumento specificato in tool_calls.
  • Observe — Verifica il risultato dell'esecuzione dello strumento e lo aggiunge alla conversazione in un ToolMessage.

Il ciclo dell'agente ripete queste tre fasi finché l'LLM non richiede più alcuno strumento. Questo pattern è noto anche come ReAct (Reason + Act), e l'idea chiave è alternare ragionamento e azione.

No

Input Utente

THINK
Chiama LLM

tool_calls
presenti?

ACT
Esegui strumento

OBSERVE
Verifica risultato

Risposta Finale

Se tool_calls è presente, esegui lo strumento, aggiungi il risultato alla conversazione e chiama di nuovo l'LLM. Se tool_calls è vuoto, l'LLM ha restituito una risposta finale e il ciclo termina. Ora mettiamo tutto questo in codice.

14.1.2) Implementare il ciclo Think-Act-Observe

Trasformiamo in codice il ciclo Think-Act-Observe della sezione precedente. Per prima cosa, prepariamo gli strumenti e il modello.

python
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage, SystemMessage
from langchain.tools import tool
 
# Definisci gli strumenti
@tool
def get_weather(city: str) -> str:
    """Ottieni il meteo attuale per una città."""
    fake_data = {"Tokyo": "18°C, nuvoloso", "Cairo": "31°C, soleggiato"}
    return fake_data.get(city, f"Nessun dato meteo per {city}.")
 
@tool
def calculate(expression: str) -> str:
    """Valuta una semplice espressione aritmetica, ad es. '3 * 21'."""
    return str(eval(expression))  # Attenzione: eval() è un rischio per la sicurezza. Non usare in produzione.
 
# Associa gli strumenti
tools = [get_weather, calculate]
llm = ChatOpenAI(model="gpt-5-mini")
llm_with_tools = llm.bind_tools(tools)
tool_map = {t.name: t for t in tools}

Nel Capitolo 13, abbiamo eseguito uno strumento una volta e ci siamo fermati. Ora ripetiamo finché l'LLM non smette di richiedere chiamate degli strumenti. All'interno di un ciclo while True, chiamiamo l'LLM, e se la risposta contiene tool_calls, eseguiamo gli strumenti e chiamiamo di nuovo l'LLM. Se non ci sono tool_calls, l'LLM ha restituito una risposta finale, quindi usciamo dal ciclo.

python
def run_agent(user_input: str) -> str:
    """Esegue il ciclo Think-Act-Observe finché l'LLM non restituisce una risposta finale."""
    messages = [
        SystemMessage(content="Sei un assistente utile."),
        HumanMessage(content=user_input),
    ]
 
    while True:
        # THINK: Chiedi all'LLM di decidere l'azione successiva
        print("THINK: Chiedo all'LLM di decidere")
        ai_message = llm_with_tools.invoke(messages)
        messages.append(ai_message)
 
        # Condizione di uscita: nessun tool_calls significa che questa è la risposta finale
        if not ai_message.tool_calls:
            return ai_message.content
 
        # ACT + OBSERVE: Esegui gli strumenti richiesti e aggiungi i risultati alla conversazione
        for tool_call in ai_message.tool_calls:
            selected_tool = tool_map[tool_call["name"]]
            print(f"ACT: chiamo '{tool_call['name']}', args={tool_call['args']}")
 
            tool_message = selected_tool.invoke(tool_call)
            print(f"OBSERVE: {tool_message.content}")
 
            messages.append(tool_message)

Confrontiamo questo con il codice del Capitolo 13. Nel Capitolo 13, dopo aver eseguito gli strumenti, abbiamo chiamato l'LLM un'ultima volta per ottenere la risposta. Nel Capitolo 14, mettiamo lo stesso processo all'interno di un while True e controlliamo tool_calls a ogni iterazione per decidere se continuare. I blocchi costitutivi sono gli stessi del Capitolo 13 — li abbiamo solo racchiusi in un ciclo.

Eseguiamolo.

python
answer = run_agent("Che tempo fa a Tokyo, e fa abbastanza caldo per una passeggiata?")
print(f'Risposta finale: {answer}')

Output:

THINK: Chiedo all'LLM di decidere
ACT: chiamo 'get_weather', args={'city': 'Tokyo'}
OBSERVE: 18°C, nuvoloso
THINK: Chiedo all'LLM di decidere
Risposta finale: In questo momento a Tokyo ci sono 18°C (circa 64°F) ed è nuvoloso.
Quella temperatura è generalmente mite e confortevole per una passeggiata per la maggior parte delle persone.

L'output mostra il flusso THINK → ACT → OBSERVE → THINK. Nella prima iterazione l'LLM ha richiesto una chiamata get_weather, e nella seconda iterazione ha visto il risultato del meteo e ha generato una risposta finale. Poiché tool_calls era vuoto, la risposta finale è stata restituita e il ciclo è terminato.

Ora testiamo lo scenario con passi dipendenti dell'introduzione — dove la chiamata successiva può avvenire solo dopo aver visto il risultato della precedente.

python
answer = run_agent("Ottieni la temperatura al Cairo, poi moltiplica il numero per 3.")
print(f'Risposta finale: {answer}')

Output:

THINK: Chiedo all'LLM di decidere
ACT: chiamo 'get_weather', args={'city': 'Cairo'}
OBSERVE: 31°C, soleggiato
THINK: Chiedo all'LLM di decidere
ACT: chiamo 'calculate', args={'expression': '31 * 3'}
OBSERVE: 93
THINK: Chiedo all'LLM di decidere
Risposta finale: Temperatura attuale al Cairo: 31°C. Moltiplicata per 3 = 93.

Questa volta il ciclo ha eseguito tre iterazioni.

  1. Prima iterazione — L'LLM richiede get_weather("Cairo").
  2. Seconda iterazione — Dopo aver visto il risultato "31°C, soleggiato", l'LLM richiede calculate("31 * 3"). Ha potuto formare l'espressione solo dopo aver visto la temperatura.
  3. Terza iterazione — Dopo aver visto entrambi i risultati "31°C, soleggiato" e "93", l'LLM ha restituito la risposta finale.

14.2) Aggiungere limiti di sicurezza

Il ciclo che abbiamo costruito sopra ha una sola condizione di uscita: quando l'LLM risponde senza tool_calls, usciamo dal ciclo. In circostanze normali questo è sufficiente, ma cosa succede se l'LLM non smette mai di richiedere chiamate degli strumenti?

Ad esempio, se uno strumento restituisce sempre risultati ambigui, l'LLM potrebbe continuare a chiamarlo sperando in qualcosa di meglio. Poiché il ciclo è while True, se l'LLM non si ferma, nemmeno il programma si ferma. I costi delle chiamate API continuano ad accumularsi mentre il programma viene eseguito indefinitamente.

La soluzione più semplice è porre un limite superiore al numero di volte in cui il ciclo può essere eseguito. Sostituisci while True con for step in range(max_steps), e il ciclo è garantito terminare dopo max_steps iterazioni indipendentemente da ciò che fa l'LLM.

python
def run_agent(user_input: str, max_steps: int = 10) -> str:
    """Esegue il ciclo Think-Act-Observe entro max_steps iterazioni."""
    messages = [
        SystemMessage(content="Sei un assistente utile."),
        HumanMessage(content=user_input),
    ]
 
    for step in range(max_steps):
        # THINK
        ai_message = llm_with_tools.invoke(messages)
        messages.append(ai_message)
 
        # Condizione di uscita: nessun tool_calls significa che questa è la risposta finale
        if not ai_message.tool_calls:
            return ai_message.content
 
        # ACT + OBSERVE
        for tool_call in ai_message.tool_calls:
            selected_tool = tool_map[tool_call["name"]]
            tool_message = selected_tool.invoke(tool_call)
            messages.append(tool_message)
 
    # max_steps raggiunto: il ciclo è terminato senza una risposta finale
    return f"[Interrotto dopo aver raggiunto il numero massimo di iterazioni ({max_steps})]"

Rispetto al codice precedente, sono cambiate due cose. while True è diventato for step in range(max_steps), ed è stato aggiunto un valore di ritorno per quando il ciclo raggiunge max_steps. Il ciclo ora termina in uno di due modi: l'LLM restituisce una risposta finale da solo (terminazione naturale), oppure viene raggiunto max_steps (terminazione di sicurezza).

Verifichiamo che il limite di sicurezza funzioni davvero. Creeremo uno strumento che non restituisce mai risultati utili, costringendo l'LLM in una situazione in cui non smette mai di richiedere chiamate degli strumenti.

python
@tool
def unhelpful_search(query: str) -> str:
    """Cerca informazioni."""
    return "Nessun risultato trovato. Prova a riformulare la tua richiesta."
 
llm_with_bad_tool = llm.bind_tools([unhelpful_search])
tool_map_bad = {unhelpful_search.name: unhelpful_search}
 
def run_agent_bad(user_input: str, max_steps: int = 5) -> str:
    messages = [
        SystemMessage(content=(
            "Devi SEMPRE usare lo strumento unhelpful_search per trovare informazioni. "
            "NON puoi rispondere basandoti sulle tue conoscenze. "
            "Se lo strumento non restituisce risultati, DEVI riformulare e cercare di nuovo. "
            "Continua a cercare finché non trovi la risposta."
        )),
        HumanMessage(content=user_input),
    ]
 
    for step in range(max_steps):
        print(f"--- Step {step + 1} ---")
        ai_message = llm_with_bad_tool.invoke(messages)
        messages.append(ai_message)
 
        if not ai_message.tool_calls:
            return ai_message.content
 
        for tool_call in ai_message.tool_calls:
            selected_tool = tool_map_bad[tool_call["name"]]
            tool_message = selected_tool.invoke(tool_call)
            print(f"ACT: '{tool_call['name']}' → {tool_message.content}")
            messages.append(tool_message)
 
    return f"[Interrotto dopo aver raggiunto il numero massimo di iterazioni ({max_steps})]"
 
answer = run_agent_bad("Qual è la popolazione della Francia?")
print(f"\nRisposta finale: {answer}")

Output:

--- Step 1 ---
ACT: 'unhelpful_search' → Nessun risultato trovato. Prova a riformulare la tua richiesta.
--- Step 2 ---
ACT: 'unhelpful_search' → Nessun risultato trovato. Prova a riformulare la tua richiesta.
--- Step 3 ---
ACT: 'unhelpful_search' → Nessun risultato trovato. Prova a riformulare la tua richiesta.
--- Step 4 ---
ACT: 'unhelpful_search' → Nessun risultato trovato. Prova a riformulare la tua richiesta.
--- Step 5 ---
ACT: 'unhelpful_search' → Nessun risultato trovato. Prova a riformulare la tua richiesta.
 
Risposta finale: [Interrotto dopo aver raggiunto il numero massimo di iterazioni (5)]

Senza max_steps, questo ciclo sarebbe stato eseguito all'infinito. Grazie a max_steps=5, è stato terminato forzatamente dopo cinque iterazioni.

Il valore corretto per max_steps dipende dalla complessità del tuo agente. Troppo basso, e i compiti complessi vengono interrotti prematuramente. Troppo alto, e un agente che si comporta male accumula costi prima di essere fermato. 15–25 è un punto di partenza comune; regolalo in base ai tuoi carichi di lavoro effettivi.

14.3) Gestire gli errori degli strumenti nel ciclo

Il ciclo che abbiamo costruito in 14.1 e 14.2 presuppone che gli strumenti vengano sempre eseguiti con successo. Ma cosa succede se uno strumento solleva un'eccezione? Il codice attuale non ha alcuna gestione delle eccezioni, quindi se si verifica un'eccezione durante l'esecuzione di uno strumento, l'intero agente si blocca.

Nel Capitolo 12 abbiamo imparato come catturare le eccezioni all'interno dello strumento stesso con try/except e restituire i messaggi di errore come stringhe. Se uno strumento è costruito in questo modo, non c'è alcun problema. Ma non ogni strumento gestisce gli errori internamente. Gli strumenti che chiamano librerie o API esterne possono sollevare eccezioni inaspettate.

Per proteggersi da questo, è una buona idea gestire gli errori anche a livello di ciclo. L'approccio è semplice: racchiudi l'esecuzione dello strumento in un try/except, e se si verifica un'eccezione, metti il messaggio di errore in un ToolMessage e passalo all'LLM. L'LLM può leggere questo messaggio di errore e riprovare con argomenti corretti o scegliere un approccio diverso. Questo si chiama auto-correzione (self-correction).

python
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage, SystemMessage, ToolMessage
from langchain.tools import tool
 
# Definisci gli strumenti
@tool
def calculate(expression: str) -> str:
    """Valuta una semplice espressione aritmetica, ad es. '3 * 21'."""
    return str(eval(expression))  # Attenzione: eval() è un rischio per la sicurezza. Non usare in produzione.
 
# Associa gli strumenti
tools = [calculate]
llm = ChatOpenAI(model="gpt-5-mini")
llm_with_tools = llm.bind_tools(tools)
tool_map = {t.name: t for t in tools}
 
def run_agent(user_input: str, max_steps: int = 10) -> str:
    """Ciclo dell'agente che passa gli errori degli strumenti all'LLM, abilitando l'auto-correzione."""
    messages = [
        SystemMessage(content="Sei un assistente utile."),
        HumanMessage(content=user_input),
    ]
 
    for step in range(max_steps):
        # THINK
        print("THINK: Chiedo all'LLM di decidere")
        ai_message = llm_with_tools.invoke(messages)
        messages.append(ai_message)
 
        if not ai_message.tool_calls:
            return ai_message.content
 
        # ACT + OBSERVE
        for tool_call in ai_message.tool_calls:
            selected_tool = tool_map[tool_call["name"]]
            try:
                print(f"ACT: chiamo '{tool_call['name']}', args={tool_call['args']}")
                tool_message = selected_tool.invoke(tool_call)
                print(f"OBSERVE: {tool_message.content}")
            except Exception as e:
                print(f"OBSERVE: Errore - {e}")
                tool_message = ToolMessage(
                    content=f"Errore: {e}",
                    tool_call_id=tool_call["id"],
                )
            messages.append(tool_message)
 
    return f"[Interrotto dopo aver raggiunto il numero massimo di iterazioni ({max_steps})]"

Rispetto al codice della sezione 14.2, l'unico cambiamento è il try/except. Se uno strumento solleva un'eccezione, il messaggio di errore viene inserito in un ToolMessage e aggiunto alla conversazione. Nota che anche una chiamata fallita deve avere un ToolMessage con il tool_call_id corrispondente. L'LLM vedrà questo errore nella prossima iterazione e deciderà cosa fare dopo.

Verifichiamo che l'auto-correzione funzioni. Attiveremo un'eccezione chiedendo allo strumento calculate di dividere per zero.

python
answer = run_agent("Usa lo strumento calcolatrice per calcolare 10 / 0")
print(f"Risposta finale: {answer}")

Output:

THINK: Chiedo all'LLM di decidere
ACT: chiamo 'calculate', args={'expression': '10 / 0'}
OBSERVE: Errore - division by zero
THINK: Chiedo all'LLM di decidere
Risposta finale: Ho usato lo strumento calcolatrice e ha restituito un errore: "division by zero."
 
Spiegazione: 10 / 0 è indefinito nell'aritmetica ordinaria, quindi non può produrre un numero finito.
 
Vuoi che io:
- calcoli i limiti unilaterali,
- mostri il risultato in virgola mobile IEEE-754,
- oppure valuti un'espressione diversa?

Nella prima iterazione, l'LLM ha richiesto calculate("10 / 0") ed è stato sollevato un ZeroDivisionError. Il try/except ha catturato l'eccezione e ha passato il messaggio di errore all'LLM. Nella seconda iterazione, l'LLM ha visto il messaggio di errore e ha restituito una risposta finale spiegando che la divisione per zero è impossibile. Senza il try/except, il programma si sarebbe bloccato al primo ZeroDivisionError.