Skip to content
Appunti personali
4 min di lettura

Metti un agente AI in un solo processo e il primo giorno trovi due muri

Primo articolo della serie sul sandbox per agenti. Chiedi a un’AI di lavorare due ore di fila e la distanza tra «funziona» e «si può mettere online» si riduce a una cosa: l’isolamento. Un piccolo servizio, ucciso di proposito dal sistema, rende il problema visibile. I termini sono spiegati man mano: non serve conoscere Kubernetes.

Immagina una richiesta ordinaria: un’AI che raccoglie dati da trenta siti, li ripulisce e ne ricava un report. Non è una chat — una domanda, una risposta, finito. Gira per due ore di fila, scarica file, consuma memoria ed esegue codice che l’AI si scrive da sola strada facendo.

Farlo funzionare sul proprio portatile richiede un pomeriggio. Trasformarlo in un servizio usato da più persone insieme: lì comincia la difficoltà.

Questo apre una serie in sei parti sulla costruzione di un sandbox per agenti partendo da zero. Ogni articolo risolve un problema emerso dal precedente, e il codice sta in agent-sandbox-oss, pronto da eseguire. I termini vengono spiegati dove compaiono: non serve una preparazione su Kubernetes.

Una precisazione sul codice: è uno scheletro didattico. Il crawling vero è sostituito da un «dormi un momento» e il cluster gira su un portatile. Non è pensato per andare in produzione. Il suo valore è che ogni incidente che contiene è un incidente che capita nei progetti veri.

Il modo ovvio di scriverlo, e l’assunzione che vi si nasconde

L’implementazione naturale: un endpoint web riceve un task, comincia a lavorare in background e tiene l’avanzamento in memoria.

IN_MEMORY_DB = {}   # l’avanzamento del task vive qui

async def run_data_research_task(task_id, target_pages, simulate_leak=False):
    for i in range(target_pages):
        await asyncio.sleep(0.5)                      # finge di scaricare una pagina
        IN_MEMORY_DB[task_id]["pages_crawled"] += 1   # segna: un’altra pagina fatta

Anche il deployment è naturale: un programma, un container, un tetto di 128MB di memoria.

Un container conviene immaginarlo come un piccolo server usa e getta: il programma e tutto ciò che gli serve, impacchettati insieme, pronti a partire o a essere distrutti in qualsiasi momento. Il tetto di memoria è la linea tracciata attorno a lui: superarla significa essere spenti dal sistema.

Non c’è niente di sbagliato. Funziona, restituisce ID di task, riporta l’avanzamento. Quello che fa è dare per scontato, in silenzio, che tutti i task si comportino bene.

Nel mondo reale non è così. Fra trenta siti ce n’è sempre uno con una pagina enorme, e il codice generato dall’AI prima o poi scrive un ciclo infinito. Le due sezioni che seguono sono ciò che accade quando quell’assunzione salta.

Primo muro: un task cade e trascina tutti gli altri

Nel codice c’è un interruttore che simula l’incontro con una pagina gigantesca: ogni pagina scaricata aggiunge 20MB alla memoria. Alla settima pagina la memoria supera la linea dei 128MB e il sistema spegne l’intero programma.

In Kubernetes (un sistema per far girare container, d’ora in poi K8s) lo si vede accadere:

kubectl get pod -l app=agent -w
# NAME                 READY   STATUS      RESTARTS
# agent-monolith-xxx   1/1     Running     0
# agent-monolith-xxx   0/1     OOMKilled   0      ← oltre il limite di memoria, ucciso
# agent-monolith-xxx   1/1     Running     1      ← riavviato dopo pochi secondi, sembra tutto ok

OOMKilled significa «ha usato troppa memoria, terminato». Vederlo ripartire da solo dà sollievo: il sistema si è auto-riparato.

Poi si chiede di un altro task che stava girando nello stesso momento — quello tranquillo, che scaricava pagine piccole:

curl localhost:8000/status/<id-di-un-altro-task>
# {"detail":"Task not found. Data lost due to Pod crash!"}

Sparito anche lui. Un’ora e quaranta di avanzamento, azzerata.

A essere ucciso non è stato «il task che si comportava male», ma il programma che conteneva tutti i task. Un’analogia: dieci persone condividono un appartamento con una sola cucina, una di loro brucia una pentola e fa scattare gli sprinkler. Gli sprinkler allagano tutto l’appartamento. La cena di tutti è rovinata.

C’è un dettaglio peggiore. Poiché l’avanzamento esisteva solo in memoria, dopo il riavvio non si riesce nemmeno a sapere a che pagina era arrivato. I riavvii di K8s proteggono la raggiungibilità del servizio, non quanto lavoro è stato fatto. Riaprono la porta; non restituiscono le due ore.

La chiamo l’illusione del monolite: RESTARTS: 1 sembra un numero innocuo e costa tutti i task in corso in quel momento.

Secondo muro: un agente esegue codice appena scritto dall’AI

È qui che gli agenti differiscono di più da un normale servizio backend. Un servizio normale esegue codice che hai scritto, revisionato e testato. Un agente esegue codice prodotto da un modello pochi secondi prima.

E tutto ciò che quel programma può raggiungere è perfettamente visibile dall’interno del container:

cat /var/run/secrets/kubernetes.io/serviceaccount/token

Quel comando legge la credenziale di accesso che la piattaforma ha montato per il programma: in pratica una chiave. Un solo programma significa un solo insieme di file condivisi, un solo insieme di variabili d’ambiente, una sola chiave. Così il codice generato per il task A può leggere i dati raccolti dal task B, e la chiave che sta lì accanto.

Nessuna dose di prudenza in fase di scrittura risolve la cosa. Dentro un solo processo non esiste confine: il sistema operativo semplicemente non offre «parti dello stesso processo protette l’una dall’altra».

Cosa stabilisce questo passo

Vale la pena eseguire a mano la versione ingenua, perché smentisce in un colpo solo due idee molto naturali:

  • «basta dargli più memoria» — un tetto di 1GB rimanda l’incidente di qualche decina di pagine, il danno collaterale resta identico;
  • «basta intercettare l’errore» — l’uccisione arriva dal kernel del sistema operativo, e il programma non fa in tempo a dire una parola.

Resta una sola strada: dare a ogni task un container tutto suo. Sembra abbastanza ovvio.

Il conto arriva nel prossimo articolo: creare un container su richiesta lascia l’utente ad aspettare dai 45 ai 90 secondi.

In una riga: dentro un solo processo non esiste confine. L’isolamento non è un’ottimizzazione di prestazioni, è la precondizione per offrire un servizio del genere.

Codice: agent-sandbox-oss/lab1 — bastano un cluster locale e un comando.