1. Perché a un agente serve la «mentalità di produzione»
Quando scrivete un normale backend, l’idea stessa di «andare in produzione» attiva automaticamente la modalità paranoia: autorizzazione, logging, gestione degli errori, limiti, segreti in .env e non nel codice.
Con un agente bisogna attivare la stessa modalità, solo ancora più rigida. La ragione è semplice: un normale backend esegue esattamente ciò che avete scritto, mentre un agente fa ciò che il modello decide da sé di fare nel perimetro degli strumenti e delle istruzioni assegnate. L’illusione del controllo qui è più forte che nel codice classico: sembra che il prompt descriva tutto, ma in realtà controllate solo l’ambiente e le azioni disponibili, non tutti i «pensieri» del modello.
Pertanto in questa lezione circonderemo gradualmente il nostro agente con «strati di protezione»:
- prima limiteremo ciò che può fare (permessi degli strumenti e separazione degli agenti),
- poi isoleremo l’ambiente di esecuzione (sandbox e limiti),
- metteremo ordine con i segreti e i PII,
- e infine attiveremo l’osservabilità: log, metriche e tracing di base.
Per essere concreti, continueremo la storia del nostro GiftGenius: un agente che aiuta a scegliere un regalo e mette un piede nel mondo del commerce (tramite ordine e checkout, ma senza i dettagli di ACP — arriveranno più avanti).
2. Permessi: all’agente non servono «tutti i pulsanti del mondo»
Principio del minimo privilegio (Least Privilege)
Prima regola: all’agente non serve saper fare tutto. Più strumenti ha, maggiore è la probabilità che invochi la «funzione sbagliata» nel «momento sbagliato». Invece di un mostruoso manageEverything(), che legge e scrive qualsiasi cosa, progettiamo funzioni piccole e chiare, separate almeno tra lettura e scrittura.
Per GiftGenius questo è particolarmente evidente: una cosa è leggere l’elenco dei regali e le preferenze dell’utente, un’altra è creare o confermare un ordine (lì ci sono i soldi). Perciò normalmente facciamo:
- un set di strumenti «read‑only» sicuri (ricerca regali, visualizzazione dettagli),
- strumenti «write» separati (creazione di una bozza d’ordine, annullamento ordine),
- e, se serve, un ulteriore livello per le operazioni particolarmente pericolose (conferma del pagamento, modifiche massive).
Agenti diversi per compiti diversi
Un’altra tecnica potente è separare gli agenti per area di responsabilità. Un agente per la «selezione dei regali», un altro per la «gestione degli ordini». Così, anche se il modello nell’agente dei regali «deraglia» un po’, non potrà fisicamente invocare lo strumento di pagamento, perché semplicemente non è presente nella sua configurazione.
Immaginiamo un tipo minimalista di configurazione di agente e strumenti:
// Tipi semplificati per spiegare i concetti
type ToolName = 'suggest_gifts' | 'get_gift_details' |
'create_order_draft' | 'confirm_order';
type AgentConfig = {
id: string;
allowedTools: ToolName[];
maxSteps: number;
};
Descriviamo ora due agenti di GiftGenius:
export const giftPlannerAgent: AgentConfig = {
id: 'gift-planner',
allowedTools: ['suggest_gifts', 'get_gift_details'],
maxSteps: 6,
};
export const orderAgent: AgentConfig = {
id: 'order-manager',
allowedTools: ['create_order_draft', 'confirm_order'],
maxSteps: 4,
};
Sì, è ancora un’astrazione, ma il punto è semplice: anche se nel codice esistono tutti e quattro gli strumenti, il singolo agente riceve solo il sottoinsieme necessario.
Associazione dei permessi all’utente e ai ruoli
È importante ricordare che abbiamo due entità diverse:
- l’utente e i suoi permessi (se a questo user_id sia consentito acquistare, annullare, vedere la cronologia),
- l’agente e i suoi strumenti consentiti.
Idealmente ogni chiamata a uno strumento dovrebbe passare due controlli: «è consentito all’agente?» e «è consentito anche all’utente?».
In pratica:
type UserRole = 'guest' | 'customer' | 'admin';
function canUserCallTool(role: UserRole, tool: ToolName): boolean {
if (tool === 'confirm_order') {
return role === 'customer' || role === 'admin';
}
if (tool === 'create_order_draft') {
return role !== 'guest';
}
return true; // lettura consentita a tutti
}
Sul lato MCP/backend, durante l’elaborazione della chiamata allo strumento, possiamo effettuare un doppio controllo:
function assertToolAllowed(
agent: AgentConfig,
userRole: UserRole,
tool: ToolName,
) {
if (!agent.allowedTools.includes(tool)) {
throw new Error(`Tool ${tool} vietato per l'agente ${agent.id}`);
}
if (!canUserCallTool(userRole, tool)) {
throw new Error(`L'utente con ruolo ${userRole} non può chiamare ${tool}`);
}
}
Di conseguenza, anche se il modello decidesse improvvisamente di chiamare confirm_order dall’agente sbagliato o a nome di un ospite, la chiamata si scontrerebbe con questo controllo e diventerebbe un errore gestito, non un pagamento non pianificato.
Configurazioni diverse per ambiente
Negli ambienti dev e staging spesso volete dare all’agente più libertà: strumenti di test, servizi di pagamento finti, funzioni sperimentali. In production, al contrario, la configurazione è il più rigida possibile: parte dei tools è disabilitata, gli endpoint sono solo quelli reali, i token sono solo quelli effettivi.
Schema semplicissimo:
type Env = 'dev' | 'staging' | 'production';
const env = (process.env.APP_ENV as Env) ?? 'dev';
const orderAgentByEnv: Record<Env, AgentConfig> = {
dev: {
id: 'order-manager-dev',
allowedTools: ['create_order_draft', 'confirm_order'],
maxSteps: 8,
},
staging: {
id: 'order-manager-staging',
allowedTools: ['create_order_draft', 'confirm_order'],
maxSteps: 6,
},
production: {
id: 'order-manager-prod',
allowedTools: ['create_order_draft'], // confirm solo tramite un percorso separato
maxSteps: 4,
},
};
export const currentOrderAgent = orderAgentByEnv[env];
In produzione, confirm_order può essere spostato in un agente «pericoloso» separato, che invocate solo dopo un click esplicito «Conferma ordine» nel widget e dopo controlli aggiuntivi.
3. Sandbox: all’agente non serve l’accesso root al vostro universo
Livelli di isolamento
Dopo aver impostato i permessi per agenti e utenti, passiamo al livello successivo di protezione — sandbox e isolamento dell’ambiente di esecuzione.
La sandbox per l’agente e i suoi strumenti può essere suddivisa convenzionalmente in diversi livelli:
- Livello del codice degli strumenti. Limitiamo l’accesso al file system, alla rete e alle risorse del processo: non permettiamo di scrivere ovunque, di raggiungere domini arbitrari, di girare all’infinito in CPU o di consumare gigabyte di memoria.
- Livello dell’Agents SDK. Impostiamo limiti sui passi del run‑cycle, sul numero di chiamate agli strumenti e sulla dimensione del contesto (token limit). Il modello non può «pensare» all’infinito e generare tool‑call senza sosta — a un certo punto il run termina con un errore «limite di passi» o «timeout».
Tutto ciò si combina in una classica «architettura difensiva», che si può rappresentare con uno schema.
graph TD
A[Prompt / istruzioni di sistema] --> B[JSON Schema degli strumenti]
B --> C[Permessi dell'agente e dell'utente]
C --> D[Sandbox dell'infrastruttura]
D --> E[Servizi esterni / DB]
subgraph Agente
A
B
C
end
subgraph Infrastruttura
D
end
Il prompt è la difesa più debole; la vera forza inizia dove limitate fisicamente ciò che il vostro codice può fare e quali API sono disponibili.
Limiti sul ciclo di esecuzione: passi, tempo, tool‑call
Parte della sandbox può essere espressa direttamente nella configurazione dell’agente: numero massimo di passi, tempo totale di esecuzione, limite di tool‑call. Non è solo protezione dai runaway‑cycle, ma anche controllo dei costi.
Esempio di configurazione astratta delle opzioni di esecuzione:
type RunLimits = {
maxSteps: number;
maxToolCalls: number;
timeoutMs: number;
};
const defaultLimits: RunLimits = {
maxSteps: 8,
maxToolCalls: 10,
timeoutMs: 30_000,
};
Tali limiti li passerete poi al wrapper che avvia l’agente. Se il modello decidesse di richiamare lo strumento per l’undicesima volta, interrompete l’esecuzione e informate onestamente l’utente che il compito è troppo complesso, invece di lasciare l’agente bruciare il budget senza controllo.
Isolamento del codice e della rete
A livello di container/processo, le pratiche comuni sono queste:
Il codice del server MCP e/o del servizio dell’agente viene eseguito in un container con file system in sola lettura (tranne una directory di lavoro appositamente prevista) e risorse limitate (CPU, RAM). La rete è configurata con allow‑list: si può accedere solo ai servizi esterni necessari (il vostro backend di commerce, il pagamento, un paio di API esterne), non a Internet arbitrario.
Per gli scenari con agenti questo è particolarmente critico: il modello potrebbe tentare di raggiungere qualche API «sbagliata» o leggere file inaspettati, e va bene se, anche in caso di tali tentativi, non abbia fisicamente i permessi per accedere a risorse superflue.
Nel codice questo di solito non appare come una «magica riga TypeScript», ma come impostazioni del vostro orchestratore (Docker Compose, Kubernetes, Vercel, Fly.io, ecc.). Tuttavia, è utile pensarci già in fase di progettazione:
- uno strumento che esegue codice di terze parti (per esempio, generazione di report con comandi shell) deve lavorare in un ambiente separato e rigidamente isolato;
- gli strumenti non devono poter leggere file altrui, segreti, configurazioni;
- l’accesso di rete è meglio limitarlo esplicitamente per domini o IP.
4. Segreti e dati riservati: ciò che l’agente non ha bisogno di sapere
Dove devono vivere i segreti e dove no
Regola di base: nessun segreto — chiavi API, password, access token — deve finire nel prompt del modello, nel widget, nei log o nel repository. Vivono:
- nelle variabili d’ambiente (process.env.SOMETHING),
- in un gestore di segreti (AWS Secrets Manager, GCP Secret Manager, Vault, ecc.),
- in store separati e cifrati, con accesso rigorosamente controllato.
Nel nostro GiftGenius, per esempio, c’è una chiave per le API di commerce del negozio. Abbiamo bisogno che l’agente possa creare una bozza d’ordine tramite uno strumento MCP, ma il modello non deve vedere la chiave.
// mcp/tools/createOrderDraft.ts
const COMMERCE_API_KEY = process.env.COMMERCE_API_KEY!;
export async function createOrderDraft(args: {
userId: string;
giftId: string;
quantity: number;
}) {
// Il modello non vedrà mai COMMERCE_API_KEY — è solo qui, sul server
const res = await fetch(`${process.env.COMMERCE_API_URL}/orders/draft`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${COMMERCE_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(args),
});
if (!res.ok) {
throw new Error(`Commerce API returned ${res.status}`);
}
return res.json(); // Nel response restituiremo all’agente un oggetto sicuro
}
Importante: nella risposta dello strumento non dovete «trascinare» chiavi o altri dettagli sensibili. All’agente basta conoscere draftOrderId, l’elenco delle posizioni e, eventualmente, lo stato.
PII e minimizzazione dei dati nel contesto
Oltre ai segreti c’è la categoria dei PII (dati personali degli utenti): nomi, telefoni, indirizzi di consegna, email, ecc. Spesso all’agente non serve tutto questo testo «grezzo». Basta un profilo strutturato: «ama i giochi da tavolo», «età 30–35», «budget indicativo 50–70$».
Invece di inserire nel prompt l’intera cronologia degli ordini dell’utente, potete creare lo strumento get_user_profile_summary, che restituirà un profilo già aggregato e anonimizzato.
type ProfileSummary = {
ageRange: '18-25' | '26-35' | '36-50' | '50+';
interests: string[];
preferredBudget: { min: number; max: number };
};
export async function getUserProfileSummary(userId: string): Promise<ProfileSummary> {
// Qui si interroga il DB, ma all'esterno si restituisce solo informazione aggregata
return {
ageRange: '26-35',
interests: ['giochi da tavolo', 'gadget'],
preferredBudget: { min: 30, max: 80 },
};
}
Il modello vede esattamente quanto basta per selezionare un regalo, e non di più.
Pulizia dei log (scrubbing)
I log sono il luogo naturale dove i segreti e i PII possono affiorare per errore. Soprattutto se si scrive un logger «comodo» come console.log(...) e si stampa «di tutto».
Un buon approccio è avere un logger centrale che, prima di stampare, percorre il payload e maschera i campi sensibili.
type LogPayload = Record<string, unknown>;
const SENSITIVE_KEYS = ['email', 'phone', 'cardNumber', 'token'];
function scrub(payload: LogPayload): LogPayload {
const result: LogPayload = {};
for (const [key, value] of Object.entries(payload)) {
if (SENSITIVE_KEYS.includes(key)) {
result[key] = '***redacted***';
} else {
result[key] = value;
}
}
return result;
}
export function logEvent(event: string, payload: LogPayload) {
const safe = scrub(payload);
console.log(JSON.stringify({ event, ...safe }));
}
Così, invece di ritrovarvi prima o poi con un incidente in produzione del tipo «stiamo loggando telefoni e token dei clienti da sei mesi», costruite il sistema fin dall’inizio in modo che ciò sia semplicemente impossibile. Non è solo una questione di accuratezza, ma anche di futuri requisiti di compliance (GDPR e leggi locali): meno PII ci sono nei log, più semplice è la vita del prodotto.
5. Monitoraggio e osservabilità dell’agente
Che cosa bisogna vedere
Abbiamo limitato ciò che l’agente può fare, quali dati vede e cosa finisce nei log. La domanda successiva è: come capire che in tutto questo «zoo» l’agente in produzione si comporta come previsto?
Il monitoraggio tradizionale «servizio vivo / non vivo» è quasi inutile per un agente. Non basta sapere che il processo è attivo, bisogna comprenderne il comportamento: quali passi compie, quali strumenti invoca, dove sbaglia, dove si incastra in loop.
Set minimo di dati per ogni run:
- agent_run_id — identificatore univoco dell’esecuzione;
- user_id anonimo o ID di sessione;
- nome dell’agente e ambiente;
- elenco degli strumenti invocati: nome, conteggio, tempo totale;
- passi del workflow e a quale passo ci siamo fermati;
- stato finale: success, partial_success, failed, canceled, timeout, limits_exceeded.
Si può modellare come una struttura:
type RunStatus =
| 'success'
| 'partial_success'
| 'failed'
| 'canceled'
| 'timeout'
| 'limits_exceeded';
type ToolCallLog = {
name: ToolName;
durationMs: number;
success: boolean;
};
type AgentRunLog = {
runId: string;
agentId: string;
userId: string;
env: Env;
startedAt: string;
finishedAt: string;
status: RunStatus;
toolCalls: ToolCallLog[];
errorMessage?: string;
};
Esempio di «wrapper» attorno all’avvio dell’agente
Supponiamo di avere una funzione runAgent che incapsula la chiamata reale all’Agents SDK. Avvolgiamola con il monitoraggio:
async function runAgentWithLogging(
agent: AgentConfig,
input: string,
userId: string,
): Promise<string> {
const runId = crypto.randomUUID();
const startedAt = new Date();
const toolCalls: ToolCallLog[] = [];
try {
const result = await runAgent(agent, input, {
userId,
limits: defaultLimits,
onToolCall: (name, durationMs, success) => {
toolCalls.push({ name, durationMs, success });
},
});
const finishedAt = new Date();
const log: AgentRunLog = {
runId,
agentId: agent.id,
userId,
env,
startedAt: startedAt.toISOString(),
finishedAt: finishedAt.toISOString(),
status: 'success',
toolCalls,
};
logEvent('agent_run', log);
return result;
} catch (err) {
const finishedAt = new Date();
const log: AgentRunLog = {
runId,
agentId: agent.id,
userId,
env,
startedAt: startedAt.toISOString(),
finishedAt: finishedAt.toISOString(),
status: 'failed',
toolCalls,
errorMessage: (err as Error).message,
};
logEvent('agent_run', log);
throw err;
}
}
Qui runAgent è una scatola nera che può essere implementata tramite un vero Agents SDK; noi mostriamo come aggiungere osservabilità senza legarsi a una API specifica.
Log vs metriche vs tracing
È utile distinguere tre livelli di osservabilità:
| Livello | Che cos’è | Esempio per l’agente GiftGenius |
|---|---|---|
| Log | «Storie» su esecuzioni specifiche | AgentRunLog dettagliato con passi e strumenti |
| Metriche | Indicatori numerici aggregati | p95 della durata dei run, numero medio di tool‑call, error‑rate |
| Tracing | Albero/grafo di richieste e sottorichieste | Run → passi → tool‑call → chiamate a API esterne (commerce, DB, ecc.) |
Le metriche servono per capire «va tutto bene in generale?» (per esempio, l’error‑rate dell’ultima ora). Log e tracing servono per capire «perché va male proprio qui?» e riprodurre una specifica esecuzione problematica.
Un esempio embrionale di metriche si può costruire sopra i log: un job periodico aggrega gli eventi agent_run e calcola p95 delle durate, conteggio degli errori, ecc.
6. Come appare il tutto in GiftGenius
Per non far sembrare tutto un insieme di astrazioni, mettiamo insieme il quadro per la nostra app didattica.
L’agente gift-planner nell’ambiente di production ha solo strumenti sicuri: selezione dei regali e ottenimento dei dettagli. Non vede né pagamenti né gestione degli ordini. Le sue istruzioni di sistema dicono che non deve promettere all’utente «pago tutto io per te», al massimo — preparare raccomandazioni e, eventualmente, una bozza di lista dei regali.
L’agente order-manager esiste separatamente e sa lavorare solo con gli ordini. In produzione può creare solo la bozza dell’ordine (create_order_draft), mentre la conferma dell’ordine (confirm_order) è eseguita dall’utente tramite un trigger esplicito nell’interfaccia del widget, oppure è disponibile solo in dev/staging. I suoi strumenti usano i segreti (le chiavi API del negozio) esclusivamente sul lato backend, e nella risposta inoltrano solo i campi necessari.
Entrambi gli agenti vengono avviati tramite il wrapper runAgentWithLogging, che applica i limiti e registra i log con agent_run_id, userId, ambiente ed elenco degli strumenti. Nei log non ci sono email e numeri di telefono; questi campi vengono mascherati in anticipo dallo scrubber. Il profilo utente è usato in forma anonimizzata: fascia d’età, interessi, budget, ma non il testo completo della cronologia degli acquisti.
L’infrastruttura in cui vivono il server MCP e il servizio dell’agente è isolata: container con file system in sola lettura (tranne /tmp o una directory dedicata), limiti di CPU/RAM, rete basata su allow‑list di domini. Se l’agente prova a chiamare «qualcosa di sbagliato», semplicemente non può raggiungerlo fisicamente.
Se a un certo punto vedete un picco della metrica «quota di run con stato limits_exceeded» o «numero medio di tool‑call > 10», capite che o il prompt è diventato troppo prolisso, o uno degli strumenti ha problemi e costringe l’agente a ripetere i passi.
Questo è il comportamento di un servizio maturo, non di un agente sperimentale «va bene così».
7. Errori tipici nel portare gli agenti in produzione
Tutto ciò di cui abbiamo discusso sopra è il «quadro corretto» di un agente da produzione. Nella pratica, però, si incontrano spesso trappole tipiche. Raccogliamole in un elenco: evitare almeno questi errori renderà il go‑live molto più sereno.
Errore n. 1: all’agente è «consentito tutto».
Scenario comune: avete descritto un sacco di strumenti MCP (ricerca, modifica, eliminazione, pagamenti) e, creando l’agente, gli avete semplicemente passato l’intero elenco. Il risultato è che il modello può invocare per errore un’eliminazione o un pagamento laddove volevate solo la lettura. Si risolve separando gli strumenti per ruolo e creando diversi agenti più mirati, ognuno con il proprio allowedTools.
Errore n. 2: controllo dei permessi solo nel prompt.
A volte gli sviluppatori scrivono nelle istruzioni di sistema: «non acquistare mai nulla senza conferma dell’utente» e si tranquillizzano. Ma il prompt è una difesa debole, e i jailbreak o i semplici errori non sono esclusi. Servono controlli reali a livello backend: «allo strumento questo tool è consentito per l’agente» e «il tool è consentito per l’utente», altrimenti una generazione imprecisa può portare ad azioni inattese.
Errore n. 3: segreti nei prompt e nei log.
Talvolta si vuole «accelerare l’integrazione» e si mette la chiave API nel system‑prompt o la si passa negli argomenti del tool, in modo che l’agente chiami da solo un’API esterna. Alla fine la chiave finisce nei log del modello e potenzialmente in sistemi di terze parti. È la strada diretta alle fughe di dati e al ban nello Store. I segreti devono vivere solo sul lato server, nelle variabili d’ambiente o in un gestore di segreti, e non devono mai finire nel contesto del modello.
Errore n. 4: log «grezzi» senza scrubbing.
In fase di debug è comodo scrivere console.log(...) e poi dimenticarsene. Dopo un paio di mesi si scopre che nei log ci sono indirizzi degli utenti, numeri di telefono, numeri d’ordine con PII. Particolarmente spiacevole nel mondo GDPR e delle altre normative. Meglio introdurre subito un logger centrale e la mascheratura automatica dei campi sensibili, anche se «logghiamo solo in dev».
Errore n. 5: assenza di limiti sul comportamento dell’agente.
Senza limiti su passi, tempo e numero di tool‑call, l’agente può andare in loop: invocare più volte lo stesso strumento, cercare di correggere all’infinito lo stesso errore, consumare un sacco di token e caricare le API esterne. Nel migliore dei casi avrete fatture enormi per i modelli, nel peggiore manderete giù il backend e farete arrabbiare gli utenti. I limiti sul run‑cycle e i sane default sui timeout sono parte obbligatoria della configurazione.
Errore n. 6: mescolare operazioni di read e write nello stesso strumento.
A volte si creano metodi «comodi» come getOrCreateOrder, che, in assenza di un ordine, ne creano uno nuovo. Per un backend classico è un pattern accettabile, ma nel mondo degli agenti può portare a effetti collaterali inattesi: il modello voleva solo conoscere lo stato, e lo strumento ha creato qualcosa. È molto più sicuro separare get_order_details e create_order_draft, così, anche in caso di chiamate ripetute, le conseguenze sono più controllabili.
Errore n. 7: ignorare l’osservabilità.
Molti iniziano con «aggiungeremo log e metriche dopo, ora l’importante è che funzioni». Gli agenti senza monitoraggio sono una scatola nera: non sapete quali strumenti invocano, quanti passi fanno, dove sbagliano. Qualsiasi reclamo dell’utente si trasforma in un’indagine al buio. È molto più semplice impostare subito la struttura dei log (agent_run_id, tools, stato) e le metriche di base, piuttosto che provare a costruirle in seguito sopra un codice caotico.
GO TO FULL VERSION