Salta al contenuto principale

81 - Che cos'è un agente AI (e quando non ti serve)

Un agente è un modello che sceglie da solo quali strumenti usare e quando fermarsi. Ti mostro il confine con una pipeline e come decidere.

Cosa ne pensi di questo articolo?

81 - Che cos'è un agente AI (e quando non ti serve)
Machine Learning Engineerfedericocalo.dev

In Breve

Un agente è un modello che sceglie da solo quali strumenti usare e quando fermarsi. Ti mostro il confine con una pipeline e come decidere.

Un agente è un programma in cui un modello linguistico decide da solo quali azioni compiere, in quale ordine e quando fermarsi. La differenza con una normale chiamata a un modello non sta nella potenza del modello: sta in chi tiene il volante. In una pipeline il percorso lo scrivi tu e il modello riempie delle caselle; in un agente il percorso è una scelta del modello, presa a ogni giro sulla base di quello che ha scoperto al giro precedente. Questo ti regala flessibilità e ti costa prevedibilità, costo e testabilità — e il mestiere consiste quasi tutto nel sapere quando quello scambio conviene.

Il problema: tre richieste che sembrano uguali e non lo sono

Guarda queste tre, rivolte a un sistema che ha accesso al tuo archivio documenti:

  1. «Riassumi questo PDF in cinque righe.»
  2. «Riassumi questo PDF e traducilo in inglese.»
  3. «Trova nei nostri contratti tutte le clausole di rinnovo automatico che scadono nei prossimi 90 giorni e dimmi quali vanno disdette.»

La prima è una chiamata singola. La seconda è una pipeline: due chiamate in fila, l'output della prima entra nella seconda, il grafo lo decidi tu a tempo di scrittura e non cambia mai. La terza no. Non sai in anticipo quanti documenti andranno aperti, non sai se servirà una ricerca per parola chiave o una semantica, non sai se dopo aver letto il contratto 7 servirà tornare su una tabella allegata. Il numero di passi dipende da cosa trovi strada facendo. È qui che un agente guadagna il suo stipendio.

Il confine è questo, e vale la pena memorizzarlo: se puoi disegnare il grafo delle chiamate prima di eseguire, non ti serve un agente. Scrivi la pipeline, che è più veloce, più economica, più facile da testare e non ti sorprende alle tre di notte.

Il ciclo, in un disegno

Prima di scendere nel codice, guarda la forma. Un agente è un anello chiuso con una sola via d'uscita: il modello osserva lo stato, decide, agisce tramite un tool, osserva il risultato e torna a decidere. Esce solo quando decide di parlare invece di agire — o quando sbatte contro un limite che hai messo tu.

Il ciclo di un agente: percezione, decisione, azione, osservazione 1. Percezione istruzioni + stato + tool 2. Decisione parlare o agire? 3. Azione il tuo codice esegue il tool 4. Osservazione: il risultato entra nello stato uscita 1: risponde con testo uscita 2: limite di giri, budget, timeout Il modello non esegue: chiede. L'esecuzione è sempre codice tuo.
Il ciclo di un agente. L'anello 1-2-3-4 si ripete; le due frecce verso destra sono le sole uscite.

Il vocabolario minimo, definito una volta

Nel resto del modulo userò questi termini continuamente. Qui li definisco, in modo che nessuna lezione li dia per noti.

  • Token. L'unità in cui un modello conta il testo: un frammento di parola. Il rapporto fra parole e token cambia da modello a modello e da lingua a lingua, quindi non si stima a occhio: lo si misura con il contatore del modello che usi. Si pagano in ingresso e in uscita, ed è l'unità in cui si misura tutto il costo di un agente.
  • Contesto (o finestra di contesto). La quantità massima di token che il modello può avere davanti in una singola chiamata: istruzioni, cronologia, descrizioni dei tool e risposta in corso, tutto insieme. È un tetto fisico: superato, l'endpoint rifiuta la richiesta.
  • Prompt. Tutto ciò che mandi al modello. Non solo la domanda dell'utente: anche le istruzioni di sistema e, cosa che si dimentica, le descrizioni dei tool.
  • Tool (o strumento). Una funzione del tuo programma che hai reso visibile al modello, descrivendola con un nome, una spiegazione e lo schema degli argomenti che accetta.
  • Tool calling (o function calling). Il meccanismo con cui il modello, invece di rispondere in prosa, emette un oggetto strutturato del tipo «chiama leggi_giacenza con {"sku": "SKU-11"}». Il modello non esegue: formula una richiesta che il tuo codice può decidere di eseguire.
  • Giro (o turno del ciclo). Una iterazione completa: una chiamata al modello più l'eventuale esecuzione dei tool che ha chiesto.
  • Handoff. Il passaggio del controllo da un agente a un altro: dal giro successivo risponde l'agente nuovo. Lo vediamo in Handoff: delegare il controllo a un agente specializzato.
  • Guardrail. Un controllo che gira accanto all'agente e può interromperlo se qualcosa non va — un filtro in ingresso o in uscita. Ci arriviamo più avanti nel modulo.
  • Tracing. La registrazione strutturata di ogni passo di una esecuzione, per poterla ricostruire dopo. Anche questo più avanti nel modulo.
  • Allucinazione. Un'affermazione del modello formalmente plausibile e priva di fondamento nei dati che ha visto. Negli agenti si manifesta nella forma peggiore: «ho fatto X» quando X non è stato fatto.

Se dovessi tenere a mente una sola riga di questo glossario, sceglierei quella sul tool calling: il modello formula una richiesta, il tuo codice la esegue. Quasi tutto il resto (costo, sicurezza, testabilità) discende da questa divisione di ruoli, e per questo token e contesto stanno nella lista accanto: sono le due grandezze che il ciclo consuma a ogni giro. Handoff, guardrail e tracing sono invece estensioni: li troverai quando il ciclo base ti sarà familiare, e non servono per capire il resto di questa lezione.

L'analogia: il navigatore e l'autista

Una pipeline è un itinerario stampato: svolta a destra al terzo semaforo, poi prendi la tangenziale, poi esci a Casalecchio. Se la tangenziale è chiusa, il foglio non serve più a niente.

Un agente è un autista con un navigatore. Gli dici la destinazione e i vincoli («evita i pedaggi, non più di due ore»), lui a ogni incrocio guarda la situazione e sceglie. Gestisce la tangenziale chiusa. Però può anche girare in tondo, può imboccare una strada che costa il doppio, e non puoi predire al minuto quando arriverà. Un itinerario stampato è stupido ma verificabile; un autista è adattivo ma va supervisionato.

Questa immagine spiega anche perché gli agenti si guastano in modi strani: il guasto tipico non è un'eccezione, è «è arrivato, ma passando per il percorso sbagliato» oppure «ha detto di essere arrivato e non è vero».

Il meccanismo: cosa succede davvero a ogni giro

Dentro ogni framework agentico, sotto gli strati di astrazione, c'è lo stesso ciclo:

  1. Mandi al modello le istruzioni, la cronologia della conversazione e la descrizione dei tool disponibili.
  2. Il modello risponde in uno di due modi: con del testo per l'utente, oppure con una o più richieste di chiamata a un tool, complete di argomenti.
  3. Se ha chiesto un tool, il tuo codice (non il modello) esegue la funzione e rimette il risultato nella cronologia.
  4. Torni al punto 1.
  5. Esci quando il modello risponde con testo invece di chiedere tool, oppure quando sbatti contro un limite che hai imposto tu.

Due conseguenze pesanti nascono da qui. La prima: il modello non esegue nulla, chiede. Ogni capacità di agire che un agente ha, gliel'hai data tu scrivendo una funzione e registrandola. La seconda: la cronologia cresce a ogni giro, e a ogni giro la rimandi tutta. Il costo di una conversazione agentica cresce molto più che linearmente nel numero di passi — un dettaglio che vediamo con i numeri in Memoria conversazionale: sessioni, finestra di contesto e riassunto.

Lo schema del punto 1-4 ha un nome in letteratura: è la struttura di ReAct (Yao et al., ReAct: Synergizing Reasoning and Acting in Language Models, arXiv:2210.03629), che alternava ragionamento esplicito e azione. Nei modelli attuali il «ragionamento» può restare interno al modello e la parte «azione» passa dal function calling, ma l'ossatura è la stessa. Se vuoi il quadro teorico più ampio, la serie dedicata agli agenti sul sito lo copre in fondamenti: OODA, ReAct e tool calling, mentre la definizione formale di agente in letteratura classica (razionalità, percezione, ambiente) sta in cos'è un agente intelligente: definizioni e tassonomia. Qui non li ripeto: parto da dove serve per scrivere codice.

Non due categorie, ma una scala di autonomia

Finora ho contrapposto «pipeline» e «agente» come se fossero due scatole. È una semplificazione utile per cominciare e sbagliata se ci si ferma lì: in realtà esiste una scala, e il mestiere consiste nel collocarsi sul gradino più basso che risolve il problema. Ogni gradino aggiunge una capacità e toglie una garanzia, e la storia delle applicazioni LLM finite male è in gran parte la storia di gradini saliti senza bisogno.

Gradino 0 — una chiamata sola. Un prompt, una risposta. Riassumi, traduci, classifica, riformula. È il gradino più sottovalutato: una quantità enorme di problemi reali vive qui, e qui tutto è prevedibile — un costo, una latenza, un punto di fallimento. Se il tuo problema sta al gradino 0, qualunque cosa in più è peso.

Gradino 1 — la catena fissa. Più chiamate in sequenza, ciascuna con un compito diverso, collegate da te. Estrai i dati, poi valuta, poi scrivi la mail. Il grafo è nel codice e non cambia mai. Testabile con asserzioni normali, costo noto in anticipo, e un difetto facile da dimenticare: ogni passo eredita gli errori del precedente senza poterli correggere. Se l'estrazione sbaglia un numero, la mail lo ripeterà con sicurezza.

Gradino 2 — il router. Una chiamata il cui unico scopo è classificare, seguita da un ramo nel tuo codice. «Di cosa parla questa richiesta? fatturazione / tecnico / commerciale» e poi tre pipeline diverse. Qui il modello prende una decisione e tu mantieni il controllo del flusso. È il gradino che risolve la maggior parte dei casi che la gente affronta con un agente completo, e costa una frazione.

Gradino 3 — l'agente con tool. Il modello decide, a ogni giro, se agire e come. Il numero di passi non è noto. È il soggetto di questo modulo, e serve quando il percorso dipende da ciò che si scopre eseguendo.

Gradino 4 — più agenti. Specialisti che si passano il controllo o si chiamano a vicenda. Aggiunge la capacità di dare istruzioni e permessi diversi a parti diverse del sistema, e aggiunge una classe di guasti tutta nuova: cicli fra agenti, contesto che si perde nei passaggi, costi moltiplicati. Ne parliamo nelle lezioni sugli handoff e sull'orchestrazione.

Il criterio per fermarsi è sempre lo stesso: sali di un gradino solo quando hai visto, con dati tuoi, che il gradino sotto non basta. «Non basta» significa aver raccolto esempi reali in cui fallisce, non aver immaginato che potrebbe fallire. La differenza fra le due cose è un trimestre di lavoro.

Perché adesso: cos'è cambiato tecnicamente

Vale la pena capire perché questo modo di programmare è diventato praticabile solo di recente, perché spiega anche dove è ancora fragile. L'idea non è nuova: il lavoro di Yao e colleghi su ReAct (arXiv:2210.03629, ottobre 2022) mostrava che alternare passi di ragionamento espliciti e azioni verso un ambiente esterno migliorava le risposte su compiti che richiedono informazione non presente nel modello. Pochi mesi dopo Toolformer (Schick et al., arXiv:2302.04761) mostrava che un modello può imparare da sé quando conviene chiamare uno strumento esterno, senza supervisione umana su ogni esempio.

Entrambi i lavori però vivevano in un mondo in cui l'unica interfaccia era il testo. L'agente «chiamava» un tool scrivendo una stringa in un formato convenuto, e il programma la riconosceva con un'espressione regolare. Funzionava nei paper e si rompeva in produzione per ragioni stupide: una virgoletta sbagliata, un formato cambiato a metà, un parametro omesso. Tre cose hanno cambiato la situazione.

Il function calling nativo. I modelli vengono addestrati con esempi in cui la richiesta di uno strumento è un oggetto strutturato, non prosa — e l'endpoint restituisce quell'oggetto in un campo separato dal testo. Il parsing fragile scompare. Non scompare l'errore (il modello può chiedere il tool sbagliato o riempire male un campo), ma diventa un errore semantico, non sintattico: controllabile con uno schema e con la validazione.

Le finestre di contesto ampie. Il ciclo di un agente ri-invia tutta la cronologia a ogni giro. Con una finestra di poche migliaia di token un agente satura il contesto dopo pochi giri, perché ogni giro aggiunge risultati di tool alla cronologia. Con finestre molto più grandi molti più giri diventano possibili, a un prezzo: ogni giro rimanda tutta la cronologia, e il conto lo vedi nella lezione sulla memoria.

L'aderenza alle istruzioni. È la parte meno visibile e la più determinante: un agente è governato da un testo che descrive vincoli («non chiamare questo tool sopra 1000 euro»). Se il modello lo ignorasse con frequenza apprezzabile, il sistema non sarebbe usabile; se lo segue quasi sempre, lo è — a patto di avere comunque i controlli nel codice, perché «quasi sempre» non è «sempre» e non lo sarà mai.

Da questa ricostruzione si capisce dove il terreno è ancora cedevole. I tre progressi riguardano la meccanica; nessuno dei tre dà garanzie sul merito. Un modello che formatta perfettamente una richiesta di tool può averla scelta per ragioni sbagliate, e un modello che segue le istruzioni può seguire quelle sbagliate, cioè quelle arrivate dentro il testo che ha letto da un tool. È il tema della lezione sulle trappole reali, e non è un problema di versione del modello: è strutturale.

I quattro ingredienti minimi

Togline uno e non hai più un agente. Le istruzioni sono il mandato: obiettivo, vincoli, stile, cosa non fare. Si sottovalutano perché «è solo testo», eppure a tool identici due set di istruzioni producono agenti che si comportano in modo diverso. Il catalogo di tool è l'insieme di funzioni, ciascuna con nome, descrizione e schema degli argomenti: il modello sceglie fra queste e solo fra queste. Lo stato è la cronologia dei messaggi, dei tool chiamati e dei risultati ottenuti; senza stato ogni giro ricomincia da zero e l'agente gira in cerchio. La condizione di terminazione è in realtà doppia: quella «buona» (il modello dichiara di aver finito) e quella «di sicurezza» (numero massimo di giri, budget di token, scadenza temporale). La seconda non è opzionale, e la anticipo nel codice qui sotto prima di scomporla in Anatomia di un agente.

Lo stesso compito, due architetture

Mettiamoci a confronto diretto. Il compito: dato un codice prodotto, dire se va riordinato. Serve leggere la giacenza e la soglia di riordino. Come modello uso Ollama in locale, così non servono chiavi né spesa; con un endpoint gestito cambia l'indirizzo e il formato dei messaggi, che va riletto nella documentazione del fornitore. Attenzione: in questa lezione i due blocchi che chiamano Ollama sono schemi da adattare, non eseguiti: sulla macchina in cui li ho controllati non c'è un modello da interrogare. Il modello llama3.1:8b è un esempio di modello che Ollama dichiara capace di tool calling; verifica sulla pagina del modello che il tuo lo sia. Gli altri blocchi, quelli senza rete, li ho eseguiti due volte con uscita identica (Python 3.14.4).

Versione pipeline — il grafo è mio, il modello fa solo il lavoro linguistico. Il dato lo leggo io dal dizionario (passo 1) e al modello affido soltanto la frase (passo 2): se il modello sbaglia, sbaglia a scrivere, non a decidere quale dato leggere. httpx.post con stream: False restituisce la risposta intera in un colpo; raise_for_status() trasforma un errore HTTP in eccezione invece di lasciarti un JSON di errore da interpretare. Non è eseguito.

import httpx

MAGAZZINO = {"SKU-11": {"giacenza": 4, "soglia": 10}}

def chiedi(prompt: str) -> str:
    r = httpx.post("http://localhost:11434/api/generate", timeout=120,
                   json={"model": "llama3.1:8b", "prompt": prompt, "stream": False})
    r.raise_for_status()
    return r.json()["response"]

def valuta(sku: str) -> str:
    riga = MAGAZZINO[sku]                      # passo 1: io leggo il dato
    return chiedi(                              # passo 2: il modello scrive la frase
        f"Giacenza {riga['giacenza']}, soglia {riga['soglia']}. "
        "In una frase: va riordinato? Rispondi SI o NO e la ragione."
    )

print(valuta("SKU-11"))

Due passi, sempre due, in quest'ordine. Il numero di chiamate è noto prima di partire, quindi il costo è prevedibile. Il punto debole è la rigidità: se la richiesta diventa «controlla tutti gli SKU del fornitore X e, dove serve, verifica prima se c'è un ordine già in viaggio», questa funzione va riscritta.

Versione agente — il grafo lo decide il modello, io espongo solo capacità. Il ciclo è scritto a mano di proposito, per mostrare che non c'è magia; nelle prossime lezioni un SDK fa le stesse cose con meno righe. Ho isolato in chiama_modello l'unico punto che parla con il modello: così il ciclo si può provare senza modello, passandogli un copione, e il resto del codice resta identico quando lo colleghi a un modello vero. Due dettagli nel ciclo: l'eccezione di un tool diventa un messaggio {"errore": ...} restituito al modello, che può correggersi; e il messaggio di ritorno usa il campo tool_name, che è quello descritto nella documentazione di Ollama sul tool calling.

import json

MAGAZZINO = {"SKU-11": {"giacenza": 4, "soglia": 10, "in_arrivo": 20}}

def leggi_giacenza(sku: str) -> dict:
    r = MAGAZZINO[sku]
    return {"sku": sku, "giacenza": r["giacenza"], "soglia": r["soglia"]}

def leggi_ordini_in_arrivo(sku: str) -> dict:
    return {"sku": sku, "in_arrivo": MAGAZZINO[sku]["in_arrivo"]}

TOOLS = {"leggi_giacenza": leggi_giacenza,
         "leggi_ordini_in_arrivo": leggi_ordini_in_arrivo}

def run(richiesta: str, chiama_modello, max_giri: int = 6) -> str:
    messaggi = [
        {"role": "system", "content": "Decidi se riordinare. Usa i tool per i dati. "
                                      "Non inventare numeri. Concludi con una riga sola."},
        {"role": "user", "content": richiesta},
    ]
    for giro in range(1, max_giri + 1):
        msg = chiama_modello(messaggi)                     # l'unico punto che parla col modello
        messaggi.append(msg)
        chiamate = msg.get("tool_calls") or []
        if not chiamate:                                   # terminazione "buona"
            print(f"giro {giro}: risposta finale")
            return msg.get("content", "")
        for c in chiamate:
            nome = c["function"]["name"]
            args = c["function"]["arguments"]
            if isinstance(args, str):
                args = json.loads(args)
            try:
                esito = TOOLS[nome](**args)
            except Exception as e:                         # l'errore torna al modello
                esito = {"errore": f"{type(e).__name__}: {e}"}
            print(f"giro {giro}: {nome}({args}) -> {esito}")
            messaggi.append({"role": "tool", "tool_name": nome,
                             "content": json.dumps(esito, ensure_ascii=False)})
    raise RuntimeError(f"nessuna conclusione in {max_giri} giri")   # terminazione di sicurezza

Collegare il ciclo a Ollama richiede una sola funzione, quella che spedisce la cronologia e restituisce il campo message della risposta. Anche questa è uno schema da adattare, non eseguito. Gli SCHEMI descrivono i due tool al modello (nome, spiegazione, argomenti); se la descrizione è vaga il modello sceglie male, ed è la ragione per cui la lezione sui tool le dedica spazio.

import httpx

SCHEMI = [
    {"type": "function", "function": {
        "name": "leggi_giacenza",
        "description": "Giacenza attuale e soglia di riordino di uno SKU.",
        "parameters": {"type": "object", "required": ["sku"],
                       "properties": {"sku": {"type": "string"}}}}},
    {"type": "function", "function": {
        "name": "leggi_ordini_in_arrivo",
        "description": "Quantità già ordinata e in transito per uno SKU.",
        "parameters": {"type": "object", "required": ["sku"],
                       "properties": {"sku": {"type": "string"}}}}},
]

def chiama_ollama(messaggi: list) -> dict:
    r = httpx.post("http://localhost:11434/api/chat", timeout=180,
                   json={"model": "llama3.1:8b", "messages": messaggi,
                         "tools": SCHEMI, "stream": False})
    r.raise_for_status()
    return r.json()["message"]

print(run("Lo SKU-11 va riordinato? Tieni conto di quello che è già in viaggio.", chiama_ollama))

Per provare il ciclo senza modello, sostituisco chiama_ollama con un copione: un modello finto che a ogni giro restituisce la risposta successiva di una lista. Non dimostra che un modello vero si comporti così; dimostra che il tuo ciclo si comporta bene nei tre casi che contano: un percorso a più giri, un tool che solleva un'eccezione, e un modello che non smette mai di chiedere. Questo è eseguito, due volte, con uscita identica.

def modello_finto(copione):
    it = iter(copione)
    return lambda messaggi: next(it)

def chiede(nome, **args):
    return {"role": "assistant", "content": "",
            "tool_calls": [{"function": {"name": nome, "arguments": args}}]}

copione = [
    chiede("leggi_giacenza", sku="SKU-11"),
    chiede("leggi_ordini_in_arrivo", sku="SKU-11"),
    {"role": "assistant", "content": "Non riordinare: 4 in giacenza + 20 in arrivo coprono la soglia 10."},
]
print(run("Lo SKU-11 va riordinato?", modello_finto(copione)))

copione = [chiede("leggi_giacenza", sku="SKU-99"),
           {"role": "assistant", "content": "SKU-99 non esiste: non posso decidere."}]
print(run("E lo SKU-99?", modello_finto(copione)))

try:
    run("Giro a vuoto", lambda m: chiede("leggi_giacenza", sku="SKU-11"), max_giri=3)
except RuntimeError as e:
    print("RuntimeError:", e)

Uscita ottenuta:

# giro 1: leggi_giacenza({'sku': 'SKU-11'}) -> {'sku': 'SKU-11', 'giacenza': 4, 'soglia': 10}
# giro 2: leggi_ordini_in_arrivo({'sku': 'SKU-11'}) -> {'sku': 'SKU-11', 'in_arrivo': 20}
# giro 3: risposta finale
# Non riordinare: 4 in giacenza + 20 in arrivo coprono la soglia 10.
# giro 1: leggi_giacenza({'sku': 'SKU-99'}) -> {'errore': "KeyError: 'SKU-99'"}
# giro 2: risposta finale
# SKU-99 non esiste: non posso decidere.
# giro 1: leggi_giacenza({'sku': 'SKU-11'}) -> {'sku': 'SKU-11', 'giacenza': 4, 'soglia': 10}
# giro 2: leggi_giacenza({'sku': 'SKU-11'}) -> {'sku': 'SKU-11', 'giacenza': 4, 'soglia': 10}
# giro 3: leggi_giacenza({'sku': 'SKU-11'}) -> {'sku': 'SKU-11', 'giacenza': 4, 'soglia': 10}
# RuntimeError: nessuna conclusione in 3 giri

Leggi l'uscita come tre storie. Nella prima il percorso ha tre giri: due letture e la risposta, e il numero di giri è deciso dal copione, non dal tuo codice. Nella seconda il KeyError non ferma il programma: diventa un dato che il modello legge e a cui risponde. Nella terza il modello chiede sempre lo stesso tool, e solo il tetto max_giri interrompe il ciclo. Il costo di questa flessibilità è chiaro: rispetto ai due passi fissi della pipeline il codice è più lungo e il numero di chiamate non è più noto in anticipo, perché lo decide il modello (nel copione, io).

Il criterio di decisione, in quattro domande

Prima di scrivere un agente, rispondi a queste. Se la risposta alla prima è «sì», fermati: hai finito, scrivi una pipeline.

  1. Posso disegnare il grafo delle chiamate prima di eseguire? Sì → pipeline.
  2. Il numero di passi dipende da cosa trovo strada facendo? No → pipeline, magari con un paio di rami if.
  3. Le azioni sono reversibili, o c'è una revisione umana? No a entrambe → non dare autonomia su quelle azioni: separale e mettile dietro un'approvazione.
  4. Posso dire, guardando un output, se è giusto? No → non hai un criterio di valutazione, e senza quello non saprai mai se l'agente funziona. Costruisci prima il criterio.

Le prime due domande decidono se hai bisogno di un agente; la terza decide dove metterlo, perché l'autonomia su azioni irreversibili (inviare denaro, cancellare dati) è un rischio che nessun prompt elimina; la quarta decide se potrai accorgerti che funziona. È quella che viene ignorata più spesso ed è la più costosa da ignorare: un agente senza criterio di valutazione si giudica a sensazione, e le sensazioni peggiorano proprio quando i casi difficili aumentano.

Il conto della scelta d'architettura, con i numeri

Mettiamo i due approcci sullo stesso problema e contiamole, le chiamate. Il compito: per un elenco di 200 SKU, dire quali riordinare tenendo conto degli ordini in transito.

Con la pipeline non c'è nessuna chiamata al modello nel caso normale: la regola è giacenza + in_arrivo < soglia, cioè una riga di Python eseguita 200 volte, che su una macchina qualsiasi è questione di frazioni di millisecondo (non l'ho cronometrata: il punto è l'ordine di grandezza rispetto a un round-trip di rete verso un modello). Il modello serve solo alla fine, per scrivere il riepilogo: una chiamata, qualche centinaio di token. Costo totale: una chiamata.

Con l'agente, se lasci che decida lui come procedere, ogni SKU richiede almeno due letture e due o tre giri di modello. Duecento SKU diventano 400 chiamate di tool e, con quelle ipotesi, dalle 400 alle 600 chiamate al modello, ciascuna con una cronologia che si allunga. Sono ipotesi di conteggio, non misure: il numero reale dipende dal modello e dal prompt. Il risultato atteso è lo stesso della riga di Python, con un costo e una latenza di ordini di grandezza superiori — e con la possibilità che su qualche SKU il modello sbagli l'aritmetica.

Questo esempio è volutamente estremo, e serve a isolare il principio: un agente non è un modo di eseguire regole, è un modo di decidere quali regole applicare quando non lo sai in anticipo. Appena la regola è nota, l'agente diventa un interprete costoso e inaffidabile di qualcosa che il tuo linguaggio di programmazione esegue perfettamente.

La forma mista è spesso quella giusta, e vale la pena esplicitarla perché è la risposta a molti problemi reali: il ciclo deterministico sta nel codice, l'agente gestisce le eccezioni. Scorri i 200 SKU con la regola; i sette casi in cui la regola non decide (una nota del fornitore in testo libero, una quantità contraddittoria, uno SKU senza soglia) li passi all'agente, uno per uno. Con questi numeri ipotetici, sette run invece di duecento, su esattamente i casi che richiedono giudizio.

La forma mista, in codice

Dato che è la forma che useresti davvero su un problema reale, vale la pena vederla scritta. Il blocco è eseguibile: l'agente è una coroutine che passi tu, e qui ne uso una finta che risponde sempre «da_verificare», per mostrare il percorso senza modello (eseguito due volte, uscita identica). La regola deterministica decide la grande maggioranza dei casi; l'agente interviene solo sui casi che la regola dichiara ambigui, e il motivo dell'ambiguità gli viene passato esplicitamente.

import asyncio
from dataclasses import dataclass

@dataclass(frozen=True)          # immutabile: nessuno modifica una riga sotto il naso
class Riga:
    sku: str
    giacenza: int | None
    soglia: int | None
    in_arrivo: int
    nota_fornitore: str = ""

def decidi_con_regola(r: Riga) -> dict | None:
    """Ritorna la decisione se la regola basta, altrimenti None (caso da escalare)."""
    if r.giacenza is None or r.soglia is None:
        return None                                   # dato mancante: serve giudizio
    if r.nota_fornitore.strip():
        return None                                   # testo libero: serve lettura
    coperto = r.giacenza + r.in_arrivo
    if coperto < r.soglia:
        return {"sku": r.sku, "esito": "riordina",
                "quantita": r.soglia * 2 - coperto, "da": "regola"}
    return {"sku": r.sku, "esito": "non_riordinare", "quantita": 0, "da": "regola"}

async def valuta_catalogo(righe: list[Riga], agente, max_escalation: int = 20) -> dict:
    """`agente` e' una coroutine: riceve il testo del caso, restituisce un dict."""
    decise, da_escalare = [], []
    for r in righe:
        d = decidi_con_regola(r)
        if d:
            decise.append(d)
        else:
            da_escalare.append(r)

    if len(da_escalare) > max_escalation:             # tetto di sicurezza sul costo
        return {"ok": False, "errore": "troppi_casi_ambigui",
                "quanti": len(da_escalare),
                "nota": "controlla la qualità dei dati prima di spendere in agente"}

    async def una(r: Riga) -> dict:
        esito = await agente(
            f"SKU {r.sku}: giacenza={r.giacenza}, soglia={r.soglia}, "
            f"in arrivo={r.in_arrivo}. Nota del fornitore: {r.nota_fornitore!r}. "
            f"Decidi se riordinare e con quale quantità."
        )
        return {**esito, "da": "agente"}

    dall_agente = await asyncio.gather(*(una(r) for r in da_escalare))
    return {"ok": True, "decisioni": [*decise, *dall_agente],
            "da_regola": len(decise), "da_agente": len(dall_agente)}

async def agente_finto(testo: str) -> dict:
    """Al posto del modello: risponde sempre 'da_verificare', cosi' si vede il percorso."""
    sku = testo.split()[1].rstrip(":")
    return {"sku": sku, "esito": "da_verificare", "quantita": 0}

RIGHE = [
    Riga("A1", 4, 10, 20),
    Riga("A2", 4, 10, 0),
    Riga("A3", None, 10, 0),
    Riga("A4", 50, 10, 0, nota_fornitore="consegna sospesa fino a gennaio"),
]

async def main():
    out = await valuta_catalogo(RIGHE, agente_finto)
    for d in out["decisioni"]:
        print(d)
    print(out["da_regola"], out["da_agente"])
    tetto = await valuta_catalogo(RIGHE, agente_finto, max_escalation=1)
    print(tetto)

asyncio.run(main())

Uscita ottenuta:

# {'sku': 'A1', 'esito': 'non_riordinare', 'quantita': 0, 'da': 'regola'}
# {'sku': 'A2', 'esito': 'riordina', 'quantita': 16, 'da': 'regola'}
# {'sku': 'A3', 'esito': 'da_verificare', 'quantita': 0, 'da': 'agente'}
# {'sku': 'A4', 'esito': 'da_verificare', 'quantita': 0, 'da': 'agente'}
# 2 2
# {'ok': False, 'errore': 'troppi_casi_ambigui', 'quanti': 2, 'nota': 'controlla la qualità dei dati prima di spendere in agente'}

Quattro dettagli che rendono questo codice diverso da una demo. La funzione decidi_con_regola restituisce None invece di indovinare: è lei a dichiarare la propria incompetenza, e la dichiara su due condizioni precise (dato mancante, testo libero da leggere). Il tetto max_escalation trasforma un problema di qualità dei dati in un errore esplicito invece che in una bolletta: se metà del catalogo è ambiguo, non è un lavoro per l'agente, è un lavoro per chi mantiene l'anagrafica. Il campo da su ogni decisione dice se l'ha presa la regola o il modello, che è l'informazione che vorrai avere quando qualcuno contesterà un riordino. E le run dell'agente girano in parallelo con gather, perché sono indipendenti — il perché, e i modi di sbagliarlo, sono in asyncio: l'infrastruttura su cui gira un agente.

Nell'uscita, A1 e A2 sono decisi dalla regola (A2: copertura 4 + 0 sotto la soglia 10, quantità 10 × 2 − 4 = 16), A3 per dato mancante e A4 per la nota in testo libero vanno all'agente; con il tetto abbassato a 1 la funzione rifiuta di spendere e lo dice. La quantità «soglia × 2 − copertura» è una regola inventata per l'esempio: la tua dipende dal fornitore.

Il rapporto fra i due contatori finali, da_regola e da_agente, è anche la metrica che ti dice se l'architettura è sana. Se su mille righe l'agente ne tocca dieci, stai pagando intelligenza solo dove serve. Se ne tocca ottocento, la regola è scritta male oppure il problema non era quello che pensavi — e in entrambi i casi la risposta non è un prompt migliore.

Diagnosi: riconoscere dai log che hai scelto il gradino sbagliato

La teoria serve poco se non sai verificare, con i tuoi dati, se la scelta era giusta. Questi quattro segnali si leggono su un campione di cento run e dicono tutto.

1. La varianza del numero di giri è bassa. Se il 95% delle run fa esattamente tre giri, non hai un agente: hai una catena di tre passi con un overhead sopra. L'autonomia che paghi non la stai usando. Scendi di un gradino: metti i tre passi nel codice.

2. La sequenza dei tool è sempre la stessa. Variante del punto precedente, più specifica e più facile da misurare: raggruppa le run per sequenza di tool chiamati. Se tre sequenze coprono il 98% dei casi, quelle tre sono tre pipeline, e il restante 2% è il lavoro vero per un agente.

3. La distribuzione dei giri ha una coda lunga che tocca il tetto. Se il 4% delle run finisce per max_turns esaurito, quelle run non sono «lente»: sono fallite, e probabilmente l'utente ha ricevuto una risposta troncata o un errore. La coda lunga è dove vive il costo e dove vive l'insoddisfazione, e si guarda sempre, perché la media la nasconde.

4. L'agente afferma azioni che non ha compiuto. Si misura confrontando due cose che hai già: le azioni dichiarate nella risposta finale e il registro delle chiamate ai tool effettivamente eseguite. Ogni divergenza è un caso di studio. Non si risolve con un prompt migliore — si risolve non fidandosi mai della prosa e verificando sul registro, e in alcuni casi togliendo all'agente la facoltà di dire di aver fatto cose, sostituendo la frase finale con un testo generato dal tuo codice sulla base di ciò che è davvero accaduto.

I primi due segnali dicono «sei salito troppo», il terzo dice «hai sottostimato il peggior caso», il quarto dice «stai credendo alla narrazione invece che ai fatti». Sono le quattro diagnosi che tornano più spesso, e tutte e quattro si leggono da dati che l'agente già produce — purché tu li registri. Da qui la raccomandazione più noiosa e più redditizia di tutta questa lezione: prima di mettere un agente davanti a degli utenti, assicurati di registrare per ogni run il numero di giri, la sequenza dei tool chiamati, il motivo di uscita e i token consumati. Sono quattro campi. Senza di loro non potrai rispondere a nessuna delle domande che ti farai la settimana dopo.

Trade-off

Ogni vantaggio dell'agente ha un prezzo, e conviene dirli a coppie. Flessibilità contro prevedibilità: l'agente gestisce casi che non hai previsto, e proprio per questo non puoi prevedere cosa farà; si può solo spostare il cursore. Costo: una pipeline di due passi fa due chiamate, un agente ne fa da due a max_giri, ognuna con una cronologia più lunga della precedente, e il peggior caso (non quello medio) è ciò che paghi quando le cose vanno male. Latenza: sequenziale per costruzione, perché ogni giro aspetta il precedente; cinque giri sono cinque round-trip, ed è il motivo per cui la lezione sull'asincronia viene subito dopo.

Testabilità: una pipeline si testa con asserzioni, un agente ha percorsi diversi sullo stesso input e servono valutazione statistica e registrazione delle run, non assertEqual. Superficie di attacco: ogni tool è una porta, e lasciare a un modello la scelta di quali aprire, con argomenti che decide lui in base a testo che arriva da fuori, è un problema di sicurezza nuovo e serio.

Le trappole in cui si cade davvero

  1. Usare un agente per un problema deterministico. Sintomo: nei log, l'agente chiama sempre gli stessi tool nello stesso ordine. Quello è un grafo fisso con un overhead sopra. Trasformalo in una pipeline e risparmi soldi e grattacapi.
  2. Nessun limite di giri. Due tool che si rimandano la palla («per rispondere devo cercare» → «la ricerca non basta, devo cercare meglio») producono cicli che bruciano budget finché qualcuno non li nota. Il limite va messo il primo giorno, non dopo il primo incidente.
  3. Confondere «ha risposto» con «ha fatto». Un agente può dichiarare «ho inviato la mail» senza aver chiamato il tool. L'unica verità è il log delle chiamate effettivamente eseguite, non la prosa finale.
  4. Troppi tool. Più il catalogo è grande, più il modello sbaglia a scegliere, e intanto ogni descrizione occupa contesto a ogni singolo giro. Pochi tool con confini netti battono venti tool sovrapposti.
  5. Dare per scontato che il tool venga chiamato bene. Gli argomenti li genera un modello: arriveranno stringhe dove vuoi interi, date in tre formati, SKU inesistenti. Ogni tool è un confine di sistema e va validato come tale.

Le cinque trappole hanno una radice comune: trattare l'agente come se fosse codice deterministico, quando è un componente che decide. Le prime due costano soldi (autonomia non necessaria, nessun freno), la terza e la quinta costano correttezza (credere alla narrazione, fidarsi degli argomenti), la quarta costa entrambe. Il rimedio è lo stesso in tutti i casi: dichiarare i confini nel codice, registrare ciò che accade e non chiedere al modello di essere affidabile al posto tuo.

Che cosa sai fare adesso

Un agente è un ciclo in cui un modello sceglie quale funzione chiamare, legge il risultato e decide se continuare. Serve quando il numero e l'ordine dei passi dipendono da ciò che si scopre eseguendo; in tutti gli altri casi una pipeline è migliore sotto ogni aspetto misurabile. Sai decidere con le quattro domande se un problema richiede un agente, collocarlo sul gradino più basso della scala che lo risolve, e provare un ciclo agentico senza modello con un copione. Nella prossima lezione, Anatomia di un agente, smontiamo il ciclo pezzo per pezzo — istruzioni, catalogo, stato, terminazione — e vediamo quali decisioni di progetto si nascondono in ciascuno.

Nella serie, la lezione precedente è Deep Q-Learning: dalla tabella alla rete neurale, e perché l'addestramento è instabile.

Fonti

I due paper sono stati raggiunti (HTTP 200) ma non riletti per intero in questo lavoro: le affermazioni su di essi si limitano a ciò che dicono titolo e abstract. I blocchi eseguiti sono quelli con uscita riportata (Python 3.14.4, due esecuzioni identiche); i due blocchi che chiamano Ollama non sono stati eseguiti.

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti