Un agente minimo è tre oggetti: una funzione decorata come tool, un Agent con istruzioni e lista di tool, un Runner che esegue il ciclo. Niente altro. In questa lezione lo scriviamo con l'Agents SDK di OpenAI — il pacchetto Python openai-agents, che è l'oggetto didattico del modulo — e poi, senza toccare la logica, lo facciamo girare su un modello locale servito da Ollama, cambiando solo il client. È lo stesso esercizio due volte, e il confronto insegna più di entrambe le versioni prese da sole.
Versione usata e che cosa ho eseguito. Il codice di questa lezione è scritto contro
openai-agents0.24.0 (installato senza vincolo di versione in un ambiente virtuale temporaneo il 2026-11-12) con Python 3.14.4. Questo SDK è giovane e la sua superficie pubblica cambia spesso: fissa la versione nel tuorequirements.txte leggi il changelog prima di aggiornare. Non ho chiamato nessun modello: i blocchi con uscita riportata girano contro un «Ollama finto» (un trasporto HTTP simulato, nessuna rete, nessuna porta) descritto più sotto. I blocchi che parlano con un Ollama vero, con un endpoint gestito, o che usano lo streaming sono schemi da adattare, non eseguiti, e lo dicono.
I tre oggetti e chi parla con chi
Prima del codice, la mappa. Ci sono tre oggetti tuoi (il tool, l'agente, il runner) e due cose fuori dal tuo processo (il modello e il sistema che il tool contatta). La freccia che conta è quella tratteggiata: il modello non arriva mai al tuo sistema. Chiede, e il runner esegue.
Installazione e il «ciao mondo»
python -m venv .venv && source .venv/bin/activate
pip install "openai-agents==0.24.*"
Il vincolo ==0.24.* accetta qualunque patch della 0.24 e rifiuta la 0.25: è il livello di rigidità giusto per un pacchetto che cambia fra minor. Il comando pip non è stato eseguito con questo vincolo (ho installato senza vincolo e ho ottenuto la 0.24.0). Il primo agente, quello che non fa niente di agentico, è questo (schema, non eseguito: senza un modello configurato l'SDK userebbe di default quello dell'API OpenAI, che richiede una chiave).
import asyncio
from agents import Agent, Runner
agente = Agent(
name="Assistente magazzino",
instructions=(
"Rispondi in italiano, in modo asciutto. "
"Non inventare numeri: se non hai un dato, dillo."
),
)
async def main():
esito = await Runner.run(agente, "Spiegami in due righe cos'è una soglia di riordino.")
print(esito.final_output)
asyncio.run(main())
Tre osservazioni che non sono dettagli. La prima: Runner.run è una coroutine, ed è il motivo per cui la lezione precedente parlava di asyncio; esiste anche Runner.run_sync per gli script, ma non va usata dentro codice già asincrono (un server, un notebook con loop attivo), dove solleverebbe un errore di loop già in esecuzione. La seconda: il valore di ritorno non è una stringa ma un oggetto risultato; final_output è la risposta, ma lì dentro c'è molto di più, e ci torniamo fra poco. La terza: non c'è nessun tool, quindi questo non è ancora un agente, è una chiamata a un modello con un wrapper, e il ciclo termina al primo giro.
Un Ollama finto, per provare l'SDK senza un modello
Per eseguire davvero il codice che segue senza un modello ho costruito un trasporto HTTP simulato: AsyncOpenAI accetta un http_client personalizzato, e gli passo un client il cui trasporto risponde con risposte «chat completions» pronte, una per richiesta, prese da un copione. Questo esegue l'SDK vero (generazione degli schemi, ciclo, tetto ai giri, struttura del risultato) e registra ogni corpo di richiesta in RICHIESTE, così posso verificare che cosa l'SDK spedirebbe. Non verifica niente sul comportamento di un modello vero: le risposte «intelligenti» sono scritte da me. Nel mio ambiente di prova il pacchetto HTTP si chiamava httpx2 (lo importo come httpx); nel tuo, con una installazione normale, sarà httpx.
import asyncio, json
import httpx2 as httpx
from openai import AsyncOpenAI
from agents import (Agent, Runner, function_tool, ModelSettings,
OpenAIChatCompletionsModel, set_tracing_disabled)
set_tracing_disabled(True)
# --- un "Ollama" finto: nessuna rete, nessuna porta ---------------------------
RICHIESTE = [] # copia di ogni corpo di richiesta ricevuto
def risposta_tool(n, nome, args):
return {"id": f"r{n}", "object": "chat.completion", "created": 0, "model": "finto",
"choices": [{"index": 0, "finish_reason": "tool_calls", "message": {
"role": "assistant", "content": None,
"tool_calls": [{"id": f"call_{n}", "type": "function",
"function": {"name": nome, "arguments": json.dumps(args)}}]}}],
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}}
def risposta_testo(n, testo):
return {"id": f"r{n}", "object": "chat.completion", "created": 0, "model": "finto",
"choices": [{"index": 0, "finish_reason": "stop",
"message": {"role": "assistant", "content": testo}}],
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}}
def fabbrica_copione(copione):
"""copione: lista di risposte JSON, una per richiesta."""
stato = {"n": 0}
def handler(request: httpx.Request) -> httpx.Response:
RICHIESTE.append(json.loads(request.content))
r = copione[min(stato["n"], len(copione) - 1)]
stato["n"] += 1
return httpx.Response(200, json=r)
return handler
def client_finto(copione):
http = httpx.AsyncClient(transport=httpx.MockTransport(fabbrica_copione(copione)))
return AsyncOpenAI(base_url="http://localhost:11434/v1", api_key="ollama", http_client=http)
Il copione è una lista di risposte: risposta_tool è un messaggio dell'assistente con una richiesta di tool, risposta_testo è la risposta finale. Con min(stato["n"], len(copione) - 1) l'ultima risposta si ripete se il modello viene interrogato più volte del previsto, che è utile per provare il tetto ai giri.
Aggiungere un tool: il momento in cui diventa un agente
Il decoratore @function_tool fa tre cose insieme: espone la funzione al modello, deriva lo schema JSON degli argomenti dai type hint, e usa la docstring come descrizione. Significa che la docstring non è un commento: è parte del prompt, e il modello la legge per decidere se chiamarti. Il magazzino, i due tool e le istruzioni sono quelli della lezione precedente; i tool restituiscono un dizionario d'errore per uno SKU inesistente, invece di sollevare. Il blocco va aggiunto in coda a quello sopra (stesso file).
MAGAZZINO = {
"SKU-11": {"giacenza": 4, "soglia": 10, "in_arrivo": 20},
"SKU-42": {"giacenza": 120, "soglia": 30, "in_arrivo": 0},
}
@function_tool
def leggi_giacenza(sku: str) -> dict:
"""Giacenza attuale e soglia di riordino di un codice prodotto (SKU).
Usa questo tool ogni volta che devi conoscere una quantità in magazzino.
Non stimare mai la giacenza a memoria.
"""
riga = MAGAZZINO.get(sku)
if riga is None:
return {"errore": "sku_inesistente", "sku": sku,
"noti": sorted(MAGAZZINO)} # errore utile, non eccezione
return {"sku": sku, "giacenza": riga["giacenza"], "soglia": riga["soglia"]}
@function_tool
def leggi_ordini_in_arrivo(sku: str) -> dict:
"""Quantità già ordinata e in transito per uno SKU, non ancora in giacenza."""
riga = MAGAZZINO.get(sku)
if riga is None:
return {"errore": "sku_inesistente", "sku": sku}
return {"sku": sku, "in_arrivo": riga["in_arrivo"]}
ISTRUZIONI = ("Decidi se un prodotto va riordinato. "
"Leggi sempre i dati con i tool, mai a memoria. "
"Tieni conto di ciò che è già in arrivo. "
"Concludi con una riga: RIORDINA <quantità> oppure NON RIORDINARE, più la ragione.")
COPIONE = [risposta_tool(1, "leggi_giacenza", {"sku": "SKU-11"}),
risposta_tool(2, "leggi_ordini_in_arrivo", {"sku": "SKU-11"}),
risposta_testo(3, "NON RIORDINARE: 4 in giacenza ma 20 in arrivo.")]
def crea_agente(copione, **extra):
modello = OpenAIChatCompletionsModel(model="llama3.1:8b", openai_client=client_finto(copione))
return Agent(name="Assistente magazzino", instructions=ISTRUZIONI,
tools=[leggi_giacenza, leggi_ordini_in_arrivo], model=modello, **extra)
async def main():
agente = crea_agente(COPIONE)
esito = await Runner.run(agente, "Lo SKU-11 va riordinato?", max_turns=6)
print(esito.final_output)
print(len(RICHIESTE), "richieste al modello")
print(json.dumps(RICHIESTE[0]["tools"][0], ensure_ascii=False, indent=1))
asyncio.run(main())
# NON RIORDINARE: 4 in giacenza ma 20 in arrivo.
# 3 richieste al modello
# {
# "type": "function",
# "function": {
# "name": "leggi_giacenza",
# "description": "Giacenza attuale e soglia di riordino di un codice prodotto (SKU).\n\nUsa questo tool ogni volta che devi conoscere una quantità in magazzino.\nNon stimare mai la giacenza a memoria.",
# "parameters": {
# "properties": {
# "sku": {
# "title": "Sku",
# "type": "string"
# }
# },
# "required": [
# "sku"
# ],
# "title": "leggi_giacenza_args",
# "type": "object",
# "additionalProperties": false
# },
# "strict": true
# }
# }
L'uscita mostra tre cose. Il modello (finto) ha ricevuto tre richieste: due per i tool, una per la risposta, quindi tre giri, e il percorso non lo ha deciso il tuo codice. Lo schema spedito per leggi_giacenza ha il nome della funzione, la intera docstring come descrizione, e sku come stringa obbligatoria derivata dal type hint; compare anche "strict": true con additionalProperties: false, che l'SDK aggiunge per impostazione predefinita (vale per il protocollo che ho provato: controlla cosa accetta il tuo endpoint). Se togli il type hint o la docstring, lo schema peggiora senza avvisarti.
Nota max_turns=6: è il tetto di sicurezza di cui parlavamo. Se viene superato, l'SDK solleva MaxTurnsExceeded, importabile da agents.exceptions: da catturare, perché è un caso previsto. Lo provo più sotto.
Cosa c'è dentro il risultato
Qui si separa chi fa demo da chi fa sistemi. L'oggetto restituito da Runner.run contiene almeno quattro cose che contano. final_output è la risposta finale (una stringa, oppure l'oggetto tipizzato se hai dichiarato un output_type, come in Output strutturato). new_items è l'elenco di tutto ciò che è stato generato nella run (messaggi, richieste di tool, risultati di tool, handoff): è il registro di ciò che è realmente accaduto, e l'unico modo onesto di verificare se un'azione è stata eseguita o solo dichiarata. last_agent dice quale agente ha prodotto la risposta, informazione che serve solo con gli handoff (Handoff). to_input_list() restituisce la cronologia pronta per essere riusata come input del turno successivo, ed è il mattone della memoria (Memoria conversazionale).
Prendi l'abitudine di stampare new_items la prima volta che scrivi un agente nuovo. Quasi tutte le sorprese («perché ha risposto così?») si spiegano guardando quali tool ha chiamato e con quali argomenti. Una funzione di stampa scritta una volta e tenuta per sempre diventa il tuo strumento di diagnosi primario, quello che usi prima di qualunque ipotesi. I nomi delle classi sono quelli del mio ambiente, in agents.items: sono fra le cose che si spostano, e se l'import non risolve puoi ripiegare su type(item).__name__ confrontando le stringhe.
import asyncio
from agents.items import ToolCallItem, ToolCallOutputItem, MessageOutputItem
from base_lib import * # il finto Ollama e i tool dell'esempio sopra, in un file
def stampa_run(esito) -> None:
"""Stampa in ordine tutto ciò che è realmente accaduto nella run."""
for i, item in enumerate(esito.new_items, 1):
tipo = type(item).__name__
if isinstance(item, ToolCallItem):
print(f"{i:2d}. TOOL RICHIESTO {item.raw_item.name} {item.raw_item.arguments}")
elif isinstance(item, ToolCallOutputItem):
print(f"{i:2d}. TOOL RISULTATO {str(item.output)[:160]}")
elif isinstance(item, MessageOutputItem):
print(f"{i:2d}. MESSAGGIO {str(item.raw_item.content[0].text)[:160]}")
else:
print(f"{i:2d}. {tipo}")
print("-" * 60)
print("agente finale:", esito.last_agent.name)
print("risposta :", esito.final_output)
async def main():
esito = await Runner.run(crea_agente(COPIONE), "Lo SKU-11 va riordinato?")
stampa_run(esito)
asyncio.run(main())
# 1. TOOL RICHIESTO leggi_giacenza {"sku": "SKU-11"}
# 2. TOOL RISULTATO {'sku': 'SKU-11', 'giacenza': 4, 'soglia': 10}
# 3. TOOL RICHIESTO leggi_ordini_in_arrivo {"sku": "SKU-11"}
# 4. TOOL RISULTATO {'sku': 'SKU-11', 'in_arrivo': 20}
# 5. MESSAGGIO NON RIORDINARE: 4 in giacenza ma 20 in arrivo.
# ------------------------------------------------------------
# agente finale: Assistente magazzino
# risposta : NON RIORDINARE: 4 in giacenza ma 20 in arrivo.
Qui si legge tutto: due giri di tool, quelli giusti, nell'ordine giusto, con gli argomenti corretti. (Questa è la run del copione, quindi dimostra la funzione di stampa, non il ragionamento di un modello.) Se con un modello vero la risposta finale fosse «RIORDINA 6» con queste stesse righe sopra, sapresti che il problema è nel ragionamento finale e non nella raccolta dati, e la cura sarebbe nelle istruzioni, non nel catalogo. Se invece non ci fosse nessuna riga «TOOL RICHIESTO», sapresti che il modello ha risposto a memoria, che è un problema completamente diverso.
Due altri casi che vale la pena provare subito, sempre con il copione: un modello che non smette di chiedere tool, e un tool che restituisce un errore invece di sollevare.
from agents.exceptions import MaxTurnsExceeded
async def main():
ciclo = [risposta_tool(i, "leggi_giacenza", {"sku": "SKU-11"}) for i in range(1, 10)]
try:
await Runner.run(crea_agente(ciclo), "Giro a vuoto", max_turns=3)
except MaxTurnsExceeded as e:
print("MaxTurnsExceeded:", e)
c = [risposta_tool(1, "leggi_giacenza", {"sku": "SKU-99"}),
risposta_testo(2, "SKU-99 non esiste. Noti: SKU-11, SKU-42.")]
esito = await Runner.run(crea_agente(c), "E lo SKU-99?")
for item in esito.new_items:
if type(item).__name__ == "ToolCallOutputItem":
print(item.output)
asyncio.run(main())
# MaxTurnsExceeded: Max turns (3) exceeded
# {'errore': 'sku_inesistente', 'sku': 'SKU-99', 'noti': ['SKU-11', 'SKU-42']}
La prima riga è il tetto in azione: con tre giri concessi e un modello che richiede sempre lo stesso tool, l'SDK solleva l'eccezione dedicata invece di girare all'infinito. La seconda è il motivo per cui conviene restituire un dizionario d'errore: lo SKU inesistente non ha fermato la run, è tornato al modello con l'elenco degli SKU noti, e il modello (qui, il copione) può correggersi.
Cosa fa l'SDK che non vedi (e che non vale la pena riscrivere)
Nella lezione sull'anatomia abbiamo scritto il ciclo a mano, e la conclusione era che non c'è magia. È vero, e non è una ragione per non usare un SDK: fra il ciclo didattico e qualcosa che sopravvive al contatto con la realtà ci sono cinque strati di lavoro noioso, e sono esattamente gli strati che non vuoi scrivere due volte.
La generazione degli schemi. Derivare uno schema JSON corretto dalla firma di una funzione Python sembra facile e non lo è: tipi opzionali, unioni, modelli annidati, liste di oggetti, valori di default, alias dei campi, e il sottoinsieme di JSON Schema che ciascun endpoint accetta — che non è lo stesso per tutti. L'SDK fa questo lavoro, e lo rifa quando l'endpoint cambia le regole.
La normalizzazione dei protocolli. Esistono almeno due famiglie di API per parlare con un modello, con forme diverse per messaggi, tool e risposte, e ciascuna ha dialetti. Un SDK ti dà un'interfaccia sola; il prezzo è che a volte una funzionalità esiste su un protocollo e non sull'altro — come il caso di Ollama che vediamo fra poco.
La gestione del parallelismo sui tool. Il modello può chiedere tre tool in una risposta. Eseguirli, raccoglierne i risultati nell'ordine giusto, abbinare ciascuno al proprio identificativo di correlazione, e gestire il caso in cui uno dei tre solleva mentre gli altri due riescono: è il codice che in quella lezione ho volutamente semplificato, e in produzione non si può semplificare.
Il conteggio dei giri, dei token e la struttura del risultato. Non è difficile, è facile da dimenticare e tedioso da mantenere coerente fra i vari punti d'uscita del ciclo.
I punti di estensione. Guardrail, hook sul ciclo di vita, tracciamento, sessioni di memoria: sono tutte cose che puoi scrivere, e che se scrivi da zero finisci per intrecciare con la logica di business. Averle come punti di aggancio dichiarativi tiene la logica pulita (guardrail e tracing arrivano più avanti nel modulo).
Resta il rovescio della medaglia, che riguarda una decisione da prendere consapevolmente: un SDK giovane è una dipendenza che si muove. Nomi di classi che cambiano posizione, parametri rinominati, comportamenti di default che si invertono fra una minor e l'altra.
Tre accorgimenti rendono il rischio gestibile. Il primo: fissa la versione con un vincolo stretto nel file delle dipendenze, e aggiorna di proposito leggendo il changelog, non per inerzia di un pip install -U. Il secondo: non spargere gli import dell'SDK in tutto il progetto; un modulo sottile che espone le tue funzioni (crea_agente, esegui) e importa l'SDK solo lì trasforma un aggiornamento rotto da una caccia su quaranta file a una modifica in uno. Il terzo: tieni i tool indipendenti dall'SDK, scrivendo leggi_giacenza_impl(sku) come funzione Python normale e applicando il decoratore a un involucro sottile, così i test girano sulla funzione pura e non si rompono quando il decoratore cambia firma.
Questo terzo punto è anche il motivo per cui scrivere il ciclo a mano non è tempo perso: quando l'astrazione si comporterà in modo inatteso, saprai cosa c'è sotto, e la differenza fra «non capisco perché» e «ah, non sta accodando il messaggio dell'assistente» è un pomeriggio.
Ollama in pratica: scegliere il modello e sapere cosa aspettarsi
La riga di codice che punta l'agente su Ollama è una sola (la vedi più sotto), e nasconde tre decisioni che vale la pena prendere con cognizione.
La prima: il modello deve supportare i tool. È una proprietà del modello, non del server. Un modello addestrato senza esempi di function calling non emetterà richieste di tool strutturate: ignorerà il catalogo e risponderà in prosa, e il sintomo sarà identico a quello di un tool mal descritto. Prima di passare un pomeriggio a riscrivere docstring, verifica sulla scheda del modello nella libreria Ollama se il tool calling è fra le sue capacità dichiarate.
La seconda: la dimensione del modello determina la qualità del tool calling più di qualunque prompt. Un modello da pochi miliardi di parametri tende a gestire meglio due o tre tool con confini netti che dieci (è un'osservazione comune, non una misura mia). La conseguenza di progetto è concreta: su un modello locale piccolo, il catalogo va tenuto più corto e i nomi più espliciti. Non è un difetto da correggere, è un vincolo da rispettare — e spesso produce un'architettura migliore, perché costringe a dividere un agente onnisciente in specialisti.
La terza: la finestra di contesto effettiva potrebbe non essere quella dichiarata. I server locali possono applicare un valore di contesto predefinito inferiore a quello che il modello supporterebbe, per non esaurire la memoria; controlla il valore nella documentazione della tua versione di Ollama. È una delle cause più frustranti di comportamenti inspiegabili: l'agente «dimentica» l'inizio della conversazione al quarto giro non perché il modello sia scarso, ma perché il contesto è stato troncato in silenzio dal server. Vale la pena verificare il parametro di contesto della propria configurazione prima di dare la colpa al modello.
Su cosa cambia nei risultati, due osservazioni oneste. La prima: con pochi tool, istruzioni precise e output tipizzato, ci si aspetta che il divario fra un modello locale di taglia media e uno di frontiera si assottigli, perché gran parte del lavoro lo fanno il vincolo di formato e lo schema. La seconda: con molti passi, molti tool simili o dati contraddittori, ci si aspetta che il divario si allarghi. Sono attese, non risultati miei. La conclusione pratica non è «meglio uno o l'altro» ma: misura sul tuo compito, con i tuoi tool, su venti casi reali. È mezza giornata di lavoro e sostituisce mesi di opinioni.
Streaming: vedere l'agente mentre pensa
Con Runner.run l'utente guarda uno schermo vuoto per tutta la durata della run, che con più giri può essere decine di secondi. Runner.run_streamed restituisce un oggetto su cui iterare man mano che gli eventi arrivano. Il blocco è uno schema da adattare, non eseguito: lo streaming richiede una risposta a flusso che il mio trasporto simulato non produce. Ho verificato soltanto che Runner.run_streamed e ResponseTextDeltaEvent esistano nella 0.24.0.
import asyncio
from agents import Runner
from openai.types.responses import ResponseTextDeltaEvent
async def main():
flusso = Runner.run_streamed(agente, "Lo SKU-11 va riordinato?")
async for evento in flusso.stream_events():
if evento.type == "raw_response_event" and isinstance(evento.data, ResponseTextDeltaEvent):
print(evento.data.delta, end="", flush=True) # testo, token per token
elif evento.type == "run_item_stream_event":
nome = type(evento.item).__name__
if nome == "ToolCallItem":
print(f"\n[sto consultando: {evento.item.raw_item}]")
print("\n\nfinale:", flusso.final_output) # disponibile a flusso esaurito
asyncio.run(main())
Due cose da sapere, entrambe da verificare sulla documentazione sullo streaming. La prima: final_output sull'oggetto di streaming è affidabile solo dopo che hai consumato tutti gli eventi — prima potrebbe non esserci ancora. La seconda, meno ovvia: lo streaming non riduce la latenza totale, riduce la latenza percepita. Il lavoro è lo stesso; cambia che l'utente vede qualcosa succedere. Su un agente multi-giro la mossa che migliora davvero l'esperienza è annunciare i passi («sto consultando il magazzino»), non far scorrere i caratteri.
Parametri del modello: cosa vale la pena toccare
L'oggetto di configurazione del modello, ModelSettings, espone i parametri di decodifica. Tre contano per un agente, gli altri quasi mai. Ho verificato che cosa l'SDK spedisce davvero leggendo il corpo delle richieste nel finto Ollama (il blocco usa await al livello più alto: dentro una funzione async, come negli esempi precedenti).
from agents import Agent, ModelSettings
ag = crea_agente(COPIONE, model_settings=ModelSettings(
temperature=0.0, # decisioni ripetibili: niente creatività
tool_choice="required", # al primo giro DEVE usare un tool
parallel_tool_calls=True, # più tool in un colpo quando sono indipendenti
max_tokens=600,
))
await Runner.run(ag, "Lo SKU-11 va riordinato?")
print([r.get("tool_choice") for r in RICHIESTE]) # una per richiesta al modello
print({k: RICHIESTE[0].get(k) for k in ("temperature", "tool_choice", "parallel_tool_calls", "max_tokens")})
# ['required', None, None]
# {'temperature': 0.0, 'tool_choice': 'required', 'parallel_tool_calls': True, 'max_tokens': 600}
La prima riga è la scoperta: tool_choice compare come required solo nella prima richiesta, nelle due successive è assente. Con la 0.24.0 l'SDK azzera da solo la scelta forzata dopo un uso di tool, altrimenti il modello non potrebbe mai chiudere con un messaggio di testo e sbatteresti contro il limite di giri. Non l'ho verificato per le versioni precedenti; controlla il comportamento della tua. La seconda riga conferma che gli altri tre parametri partono così come li hai scritti.
temperature=0 è quasi sempre giusto per un agente che decide: la creatività serve a scrivere testi, non a scegliere se chiamare leggi_giacenza. Zero non vuol dire però deterministico: sulle infrastrutture reali batching e aritmetica in virgola mobile possono lasciare una variabilità residua, ed è una delle ragioni per cui testare gli agenti è un problema statistico, non un problema di asserzioni. tool_choice="required" è il rimedio diretto all'agente che risponde senza guardare i dati, con una condizione importante: l'endpoint compatibile OpenAI di Ollama elenca tool_choice fra i campi non supportati (pagina di compatibilità, consultata il 2026-11-12), quindi su Ollama non puoi contare su questa leva e devi controllare tu, nel ciclo, che il primo giro abbia chiamato un tool. parallel_tool_calls taglia i giri quando i tool sono indipendenti (due letture su SKU diversi) e va disattivato quando hanno effetti collaterali o dipendono l'uno dall'altro, perché in parallelo l'ordine non è garantito.
Lo stesso agente su Ollama, in locale
Ollama espone un endpoint compatibile con l'API chat completions di OpenAI su http://localhost:11434/v1 (documentazione). Questo basta per riusare lo stesso SDK con un modello che gira sulla tua macchina: nessuna chiave, nessun dato che esce, costo marginale zero. Il blocco è uno schema da adattare, non eseguito: la differenza con le prove di sopra è soltanto che il client punta a un server vero invece che al trasporto simulato.
import asyncio
from openai import AsyncOpenAI
from agents import (Agent, Runner, function_tool,
OpenAIChatCompletionsModel, set_tracing_disabled)
client = AsyncOpenAI(base_url="http://localhost:11434/v1", api_key="ollama")
set_tracing_disabled(True) # il tracing gestito richiederebbe una chiave vera
modello_locale = OpenAIChatCompletionsModel(model="llama3.1:8b", openai_client=client)
agente = Agent(
name="Assistente magazzino",
instructions="...le stesse istruzioni di prima...",
tools=[leggi_giacenza, leggi_ordini_in_arrivo],
model=modello_locale, # unica riga che cambia davvero
)
asyncio.run(Runner.run(agente, "Lo SKU-11 va riordinato?"))
Prima serve avere il modello: ollama pull llama3.1:8b (non eseguito). api_key="ollama" è un segnaposto: il client lo pretende, il server lo ignora. Anche il finto Ollama dei blocchi precedenti usa lo stesso segnaposto, ed è esattamente la costruzione che vedi qui sopra con un trasporto diverso.
Che cosa cambia davvero, da sapere prima di scoprirlo in produzione. Il function calling c'è ma tende a essere più fragile su un modello da 8 miliardi di parametri, che sbaglia più spesso la scelta del tool e gli argomenti rispetto a uno di frontiera (osservazione comune, da misurare sul tuo compito); si recupera con descrizioni più esplicite, meno tool e istruzioni che dicano quando usare cosa. Non tutti i modelli locali supportano i tool, ed è una proprietà del modello, non di Ollama. Il tracing gestito non c'è e va sostituito con un tracciamento tuo, come vedremo più avanti nel modulo. Le funzionalità legate all'API «responses» non sono disponibili: l'endpoint compatibile è quello delle chat completions, e il capitolo sui modelli dell'SDK descrive come l'SDK distingue i due protocolli. Infine la latenza dipende dal tuo hardware: su CPU un modello 8B può essere lento, la differenza con una GPU è grande.
Rendere il modello un parametro
Tutto il ragionamento precedente ha una conseguenza architetturale sola, e conviene applicarla il primo giorno perché dopo costa: il modello non deve comparire nel codice che definisce gli agenti. Se l'oggetto modello arriva da una funzione di configurazione, provare un provider diverso, confrontare due modelli sullo stesso compito o far girare i test senza spendere diventa un cambio di variabile d'ambiente. Nella prima versione di questo esempio il ramo «gestito» costruiva un dizionario con entrambe le variabili d'ambiente e falliva con KeyError se ne mancava una, anche quando ne serviva una sola; l'ho scoperto eseguendo il blocco e l'ho corretto leggendo soltanto la variabile del profilo richiesto.
import os
from functools import lru_cache
from openai import AsyncOpenAI
from agents import Agent, OpenAIChatCompletionsModel, set_tracing_disabled, function_tool
@function_tool
def leggi_giacenza(sku: str) -> dict:
"""Giacenza di uno SKU."""
return {}
@lru_cache(maxsize=None) # un client per processo, non uno per chiamata
def modello(profilo: str = "default"):
"""Restituisce l'oggetto modello secondo la configurazione d'ambiente.
profilo: "default" per il lavoro, "piccolo" per guardrail e classificatori.
"""
backend = os.getenv("AI_BACKEND", "ollama")
if backend == "ollama":
client = AsyncOpenAI(base_url=os.getenv("OLLAMA_URL", "http://localhost:11434/v1"),
api_key="ollama") # segnaposto, ignorato
set_tracing_disabled(True) # niente tracing gestito
nomi = {"default": os.getenv("OLLAMA_MODEL", "llama3.1:8b"),
"piccolo": os.getenv("OLLAMA_MODEL_PICCOLO", "llama3.2:3b")}
return OpenAIChatCompletionsModel(model=nomi[profilo], openai_client=client)
if backend == "gestito":
variabile = {"default": "MODELLO_DEFAULT", "piccolo": "MODELLO_PICCOLO"}[profilo]
return os.environ[variabile] # l'SDK accetta anche il nome come stringa
raise ValueError(f"AI_BACKEND non riconosciuto: {backend!r}")
ISTRUZIONI_MAGAZZINO = "Decidi se riordinare." # in realtà: da file versionato, non inline
def crea_agente_magazzino():
return Agent(name="Assistente magazzino", instructions=ISTRUZIONI_MAGAZZINO,
tools=[leggi_giacenza], model=modello("default"))
a = crea_agente_magazzino()
print(type(a.model).__name__, a.model.model)
print(modello("default") is modello("default"))
print(type(modello("piccolo")).__name__, modello("piccolo").model)
os.environ["AI_BACKEND"] = "gestito"; os.environ["MODELLO_DEFAULT"] = "nome-del-modello"
modello.cache_clear()
print(modello("default"))
os.environ["AI_BACKEND"] = "boh"; modello.cache_clear()
try:
modello("default")
except ValueError as e:
print("ValueError:", e)
# OpenAIChatCompletionsModel llama3.1:8b
# True
# OpenAIChatCompletionsModel llama3.2:3b
# nome-del-modello
# ValueError: AI_BACKEND non riconosciuto: 'boh'
L'uscita verifica quattro comportamenti. Il ramo Ollama costruisce un modello OpenAIChatCompletionsModel con il nome predefinito; la cache restituisce lo stesso oggetto alla seconda chiamata (True), quindi un client per processo; il profilo «piccolo» seleziona un altro nome; il ramo «gestito» restituisce il nome come stringa; un backend sconosciuto fallisce subito con un messaggio chiaro. tool, ISTRUZIONI_MAGAZZINO e il resto sono ridotti al minimo per far girare il blocco: nel codice vero i tool sono quelli sopra e le istruzioni stanno in un file.
Quattro cose che questa funzione fa e che vale la pena copiare. Il lru_cache garantisce un client per processo: senza di esso ogni chiamata ne crea uno nuovo, butta via il pool di connessioni e paga un handshake TLS a vuoto. Il profilo separa il modello «da lavoro» da quello piccolo per guardrail e classificatori, che girano su ogni richiesta e dove un modello costoso raddoppia il conto del sistema senza migliorare nulla. I nomi dei modelli da variabile d'ambiente permettono di cambiare taglia senza toccare il codice, che è ciò che vorrai fare la prima volta che misuri. E il fallimento esplicito su un backend non riconosciuto evita il peggiore degli esiti: una configurazione sbagliata che silenziosamente usa il default e ti fa misurare la cosa sbagliata. Una nota su cosa non mettere in quella funzione: le istruzioni. Cambia il modello, non il mandato; tenerle in un file versionato accanto al codice ti permette di sapere, dalla git history, quale prompt era attivo quando quel comportamento strano si è manifestato.
Trade-off: SDK gestito o modello locale?
Cinque assi per decidere. Sulla qualità del tool calling il vantaggio è in genere dei modelli di frontiera, specie su cataloghi di tool numerosi e su ragionamenti a più passi (da misurare, vedi sotto). Sul costo, l'endpoint gestito si paga a token e il conto cresce con la cronologia, il locale si paga in hardware e corrente con costo marginale per richiesta quasi nullo, e il punto di pareggio dipende dal volume e va calcolato, non sentito. Su privacy e vincoli contrattuali, con un modello locale i dati non lasciano la macchina, e per documenti sensibili è spesso l'unica opzione ammissibile.
Sulla riproducibilità, un modello locale con un tag fissato è lo stesso modello fra sei mesi, mentre un endpoint gestito può aggiornare il modello sotto di te e i tuoi test cambiano risultato senza che tu abbia toccato nulla. Sull'operatività, il locale lo devi far girare, aggiornare, dimensionare e monitorare tu. La scelta che vale sempre, qualunque sia il verdetto: scrivi il codice in modo che il modello sia un parametro, come nella sezione precedente.
Il primo confronto da fare: venti casi, due modelli
Misurare sul proprio compito sostituisce mesi di opinioni, ed è più semplice di quanto sembri. Non serve un framework di valutazione: servono venti richieste reali, prese dai log e non inventate, e per ciascuna la decisione che tu consideri corretta. Poi si guardano tre numeri, e nessuno dei tre è «la qualità della risposta». Il blocco qui sotto è lo scheletro, provato sul finto Ollama con soli tre casi e tre risposte scritte da me, di cui una volutamente «a memoria»: serve a verificare il codice di misura, non a confrontare modelli.
import asyncio
from base_lib import *
async def confronta(costruisci_agente, casi) -> dict:
usati_giusti = esiti_giusti = giri_totali = 0
for richiesta, tool_attesi, esito_atteso in casi:
esito = await Runner.run(costruisci_agente(richiesta), richiesta, max_turns=8)
chiamati = {i.raw_item.name for i in esito.new_items
if type(i).__name__ == "ToolCallItem"}
usati_giusti += int(tool_attesi.issubset(chiamati)) # ha guardato i dati?
esiti_giusti += int(esito.final_output.startswith(esito_atteso))
giri_totali += len(esito.raw_responses) # una risposta del modello per giro
n = len(casi)
return {"copertura_tool": usati_giusti / n,
"esito_corretto": esiti_giusti / n,
"giri_medi": giri_totali / n}
COPIONI = {
"Lo SKU-11 va riordinato?": COPIONE,
"E il 42?": [risposta_tool(1, "leggi_giacenza", {"sku": "SKU-42"}),
risposta_testo(2, "NON RIORDINARE: 120 in giacenza, soglia 30.")],
"Mi serve il SKU-99": [risposta_testo(1, "RIORDINA 10: lo SKU-99 sembra finito.")], # risponde a memoria
}
CASI = [("Lo SKU-11 va riordinato?", {"leggi_giacenza", "leggi_ordini_in_arrivo"}, "NON RIORDINARE"),
("E il 42?", {"leggi_giacenza"}, "NON RIORDINARE"),
("Mi serve il SKU-99", {"leggi_giacenza"}, "DATI INSUFFICIENTI")]
async def main():
print(await confronta(lambda r: crea_agente(COPIONI[r]), CASI))
asyncio.run(main())
# {'copertura_tool': 0.6666666666666666, 'esito_corretto': 0.6666666666666666, 'giri_medi': 2.0}
Il risultato si legge subito: il terzo caso («Mi serve il SKU-99») ha una risposta scritta apposta senza consultare i tool e con un esito sbagliato, e questo abbassa copertura ed esito a 2/3; i giri medi sono 2,0 (3 + 2 + 1 risposte del modello, su 3 casi). Con un modello vero i tre numeri sarebbero le tue misure. Perché proprio questi tre. La copertura dei tool misura la cosa che si rompe per prima sui modelli piccoli: la disciplina di consultare i dati invece di rispondere a memoria; se è bassa, il problema non è il ragionamento ma l'aderenza alle istruzioni, e si attacca con il catalogo e con le istruzioni, non cambiando modello. L'esito corretto è il numero che conta per l'utente, ma da solo non dice dove intervenire. I giri medi sono il costo: due modelli con lo stesso esito ma uno a 2,3 giri e l'altro a 4,8 non sono equivalenti, perché il secondo costa il doppio e risponde il doppio più tardi (numeri d'esempio).
La lettura combinata dà diagnosi utili. Copertura alta ed esito basso: il modello guarda i dati e ragiona male, e qui una taglia più grande aiuta davvero. Copertura bassa ed esito alto: il modello indovina, e indovinerà fino al giorno in cui non indovinerà; va corretto anche se il punteggio finale sembra buono. Giri medi che esplodono con esito invariato: il modello esita, di solito per un catalogo ambiguo o un criterio di completamento vago. Un'ultima accortezza: ogni caso va eseguito più di una volta. Con temperature=0 la variabilità è piccola ma non nulla, e un confronto su una sola passata può invertirsi alla seconda. Tre passate su venti casi sono sessanta run, che su un modello locale costano solo tempo.
Le trappole in cui si cade davvero
- Dimenticare il type hint o la docstring su un tool. Senza type hint lo schema non si genera (o diventa permissivo); senza docstring il modello non sa quando chiamarlo e lo ignora. Il sintomo è identico: «il tool c'è ma non lo usa mai».
- Sollevare eccezioni dentro un tool per input sbagliati. Uno SKU inesistente non è un bug: è una situazione che l'agente può correggere, se gliela racconti. Restituisci un dizionario d'errore e il modello riprova con l'argomento giusto.
- Credere a
final_outputsulle azioni. «Ho aggiornato il magazzino» è prosa. La prova sta innew_itemse nel tuo log applicativo. - Tool bloccanti. Una funzione sincrona che fa una query lenta blocca l'event loop. Dichiarala
async defe usa un client async, oppure incapsulala conasyncio.to_thread. - Mettere credenziali negli argomenti dei tool. Se un parametro è
token: str, il modello proverà a inventarlo. I segreti si leggono dall'ambiente dentro la funzione; nello schema non devono comparire. - Non fissare la versione dell'SDK. Su un pacchetto giovane, un
pip install -Unon pianificato è un modo collaudato di rompere la build di venerdì sera.
Le sei trappole si dividono in due famiglie. Le prime tre sono errori di contratto fra te e il modello (schema, errori, fiducia nella prosa): il programma gira e fa la cosa sbagliata. Le ultime tre sono errori di ingegneria (blocco dell'event loop, segreti nello schema, dipendenze mobili): il programma funziona finché non lo metti sotto carico o non lo aggiorni. Entrambe si prevengono con le stesse abitudini di questa lezione: leggere new_items, tenere i tool piccoli e puri, fissare le versioni.
Che cosa sai fare adesso
Sai scrivere un agente con tre oggetti, tool, agente e runner; leggere che cosa ha fatto davvero con new_items; provare l'SDK senza modello con un trasporto simulato; e spostare lo stesso agente su un modello locale cambiando una riga, tenendo il modello come parametro di configurazione. L'SDK non aggiunge intelligenza, aggiunge ergonomia: schema automatico dai type hint, gestione del ciclo, tetto ai giri, struttura del risultato. Nella prossima lezione, Tool: function calling, schemi JSON e errori, entriamo dentro quei tool: come nasce lo schema, come si validano gli argomenti, e perché gli errori vanno restituiti e non sollevati.
Fonti
- OpenAI Agents SDK (documentazione) - concetti di Agent, Runner, function tool, risultato, consultata il 2026-11-12; il comportamento riportato è quello verificato con la versione 0.24.0
- OpenAI Agents SDK: modelli - protocolli chat completions e Responses, tracing con modelli non OpenAI, consultata il 2026-11-12
- OpenAI Agents SDK: streaming -
run_streamede gli eventi, raggiunta il 2026-11-12 (HTTP 200); il blocco di streaming non è eseguito - Ollama: compatibilità OpenAI - endpoint
/v1e campi supportati (tool_choicenon supportato), consultata il 2026-11-12
Eseguiti due volte con uscita identica, contro il finto Ollama (Python 3.14.4, openai-agents 0.24.0, pacchetto HTTP httpx2 nell'ambiente di prova): schema dei tool, ciclo e risultato, tetto ai giri, errore restituito, ModelSettings, funzione modello, misura del confronto. Non eseguiti: il «ciao mondo» senza modello, lo streaming, l'agente su un Ollama vero, pip install con vincolo, ollama pull.
Commenti
Caricamento commenti...
Accedi per commentare