Quando il modello non basta. Middleware deterministici per un agent AI in produzione con un LLM da 8B

Sto lavorando a un progetto che sta mettendo alla prova diverse convinzioni comuni sugli LLM: costruire un ReACT agent in produzione che interroga API aziendali in tempo reale, usando un modello locale da 8 miliardi di parametri.

Non un GPT-4. Non un Claude. Un qwen3:8b che gira su una VM con una GPU consumer.

Questo articolo racconta cosa ha funzionato, cosa non funziona quando usi un modello piccolo per compiti reali, e come i middleware deterministici diventano il vero protagonista dell'architettura.


Il contesto

Sto lavorando a questo progetto con Boosha, dove l'AI non è fumo - è uno strumento concreto per semplificare processi e lasciare alle persone spazio per pensare. È la mia prima collaborazione con loro, e quello che mi ha colpito è l'equilibrio tra spinta all'innovazione e attenzione umana: si parte dai problemi reali, non dalla tecnologia fine a sé stessa. Il fatto che su questo progetto abbiamo scelto un modello da 8B locale invece di puntare tutto su un'API cloud ne è un esempio.

Di Boosha e del loro approccio all'AI e agli UMANI ho scritto anche qui - da quando ci siamo incontrati ai team days di Torino a dicembre, ho capito che era il posto giusto per un Panda come me!.

Il progetto è un assistente conversazionale che interroga API aziendali. L'utente fa domande in linguaggio naturale:

  • "Ci sono anomalie attive sul sistema X?"
  • "Mostrami lo storico delle ultime 24 ore"
  • "Quante volte si è attivato il dispositivo 3 ieri?"

Il cuore è un agent ReACT - un LLM che non si limita a generare testo, ma ragiona in cicli: analizza la domanda, decide quale API chiamare e con quali parametri, legge il risultato, e decide se ha bisogno di altri dati o se può rispondere. Questo processo si chiama tool calling: il modello sceglie autonomamente quali strumenti usare, in che ordine, e quando fermarsi.

Sembra semplice. Non lo è.


Perché un modello piccolo?

La scelta di un modello locale da 8B non è stata ideologica. È stata pratica:

  • Privacy dei dati: dati aziendali in tempo reale, niente cloud
  • Latenza: le API rispondono in millisecondi, il modello deve stare al passo
  • Costi: nessun costo per token, il modello gira su hardware del cliente
  • Controllo: versione pinnata, comportamento riproducibile (in teoria)

Il trade-off? Tutto il resto. Un modello da 8B non è stupido, ma è fragile. E la fragilità emerge nei modi più creativi possibili.


L'architettura: un agent ReACT con LangChain

L'agent usa create_react_agent di LangChain con 13 tool API. Il flusso è classico:

Query utente → Intent Detection → Agent ReACT → Tool calls → Risposta

La parte interessante è cosa c'è tra l'agent e i tool: una catena di 7 middleware che intercetta, valida, corregge e arricchisce ogni chiamata.

Questa catena è il cuore del sistema. Vediamo perché.


Lezione 1: il modello mente sui parametri

La prima cosa che ho scoperto è che un modello da 8B inventa parametri. Non per malevolenza - semplicemente non ha abbastanza capacità per vincolarsi a un set finito di valori.

Esempio reale: un tool accetta un parametro tipo con 5 valori possibili (errori, comandi, tutti, misure, variazioni). Il modello genera tipo="warning". Oppure tipo="errori_attivi". Oppure tipo="alerts".

La soluzione: validazione deterministica con fuzzy matching

TIPO_FUZZY = {
    "warning": "errori", "alert": "errori",
    "accensioni": "comandi", "spegnimenti": "comandi",
    "giorn": "1D", "settiman": "1W", "mensil": "1M",
}

def _validate_tipo(args, tool_name):
    tipo = args.get("tipo")
    if tipo in TipoEvento.__members__:
        return None  # OK, valore valido

    # Fuzzy: cerca match parziale
    for key, correct in TIPO_FUZZY.items():
        if key in tipo.lower():
            args["tipo"] = correct
            return args, True  # Self-healed

    # Block: ritorna errore con valori validi
    validi = ", ".join(TipoEvento.__members__)
    return ToolMessage(f"Tipo '{tipo}' non valido. Valori: {validi}")Code language: Python (python)

Il principio: ogni parametro con valori finiti deve essere validato in Python contro la ground truth (enum Pydantic, CSV, set) prima della chiamata API. Più validazione deterministica, meno dipendenza dal modello.

Questo pattern - fuzzy self-heal → block + tell - si ripete per ogni parametro critico. Il middleware prova prima a capire cosa intendeva il modello; se non ci riesce, blocca la chiamata e dice al modello quali sono i valori validi. Al giro successivo, il modello di solito sceglie giusto.


Lezione 2: il modello non sa che giorno è

Sembra banale, ma è stato uno dei problemi più insidiosi. Quando l'utente chiede "mostrami gli eventi di ieri", il modello deve calcolare le date inizio e fine per la chiamata API.

Un modello da 8B con temperature=0 genera date… creative. Test diretto:

Domanda: "Che giorno è oggi?"
Risposta: "Oggi è mercoledì, 12 dicembre 2023."

Data reale: domenica 22 febbraio 2026.Code language: HTTP (http)

Il modello inventa date con assoluta sicurezza. E non è nemmeno deterministico: ripetendo la stessa domanda con parametri leggermente diversi, risponde "25 ottobre 2023". Sempre sbagliato, ma in modo diverso.

Nota: non è un limite dei modelli piccoli. Nessun LLM ha accesso a un orologio di sistema - un modello da 70B avrebbe lo stesso problema, semplicemente ammetterebbe più spesso di non sapere la risposta invece di inventarla con sicurezza.

La soluzione: il temporal gate

Il middleware temporal_gate intercetta tutte le chiamate a tool temporali e sovrascrive le date con calcoli Python deterministici:

def temporal_gate(tool_call, state):
    temporal_keyword = state.get("temporal_keyword")  # "ieri", "ultima settimana"

    if tool_needs_dates(tool_call) and not temporal_keyword:
        return ToolMessage("Per quale periodo? (es: oggi, ieri, ultima settimana)")

    if temporal_keyword:
        inizio, fine = parse_temporal_keyword(temporal_keyword)
        tool_call.args["inizio"] = inizio
        tool_call.args["fine"] = fine

    return tool_call  # Prosegui con date corretteCode language: PHP (php)

L'estrazione del riferimento temporale avviene a monte, nell'intent detection, con un mix di regex e dateparser:

  • Range manuali: "ultima settimana", "ultime 48 ore"
  • Pattern composti: "dalle 13 alle 18", "dal 4 al 7 ottobre"
  • Dateparser: "ieri", "3 giorni fa", "lunedì scorso"

La cosa elegante è che il modello non deve più occuparsi delle date. Può concentrarsi su cosa chiedere, non su quando.

Il temporal gate applica anche vincoli per-tool: alcune API accettano solo finestre temporali limitate (es. massimo 7 giorni). Se l'utente chiede "tutto l'anno", il middleware restringe automaticamente al massimo consentito.


Lezione 3: il modello si ripete all'infinito

Un pattern che ho visto decine di volte: il modello chiama un tool, riceve un errore (parametro sbagliato, nessun risultato), e richiama lo stesso tool con gli stessi parametri. Fino a 15 volte consecutive.

La soluzione: deduplicazione + limit

def deduplicate_tool_calls(tool_call, state):
    signature = (tool_call.name, frozenset(tool_call.args.items()))
    if signature in state["seen_calls"]:
        return ToolMessage("Hai già fatto questa chiamata. Prova parametri diversi.")
    state["seen_calls"].add(signature)
    return tool_callCode language: JavaScript (javascript)

Combinato con ToolCallLimitMiddleware(run_limit=15), questo ha ridotto i loop da 15x a 2x. Il modello riceve il feedback "l'hai già fatto" e cambia strategia.


Lezione 4: meno token al modello = risposte migliori

Questa è stata controintuitiva. Con modelli grandi, più contesto = meglio. Con un 8B, più contesto = peggio.

Quando un tool restituisce 5000+ caratteri di dati JSON, il modello genera una risposta vuota. Letteralmente un AIMessage con contenuto "". La finestra di contesto si riempie e il modello va in tilt.

La soluzione: una pipeline di 5 trasformazioni

La chiave è stata separare ciò che serve al modello da ciò che serve al codice. Ogni risultato API attraversa 5 step prima di arrivare al LLM:

Il risultato del tool viene salvato in una struttura a due livelli:

ToolMessage(
    content="...",   # Versione slim per il LLM (dopo step ②③④⑤)
    artifact={...},  # Dati completi originali (per codice downstream)
)Code language: PHP (php)

L'artifact resta immutabile - le trasformazioni si applicano solo alla copia che il modello legge. Questo permette follow-up ("e quelli di tipo X?") senza perdere informazioni.

Esempio concreto: prima e dopo

Immaginate la domanda "Ci sono anomalie attive sul sistema X?". L'API restituisce qualcosa del genere:

{
  "sistema": "SIST-0044-X",
  "anomalie_attive": "4",
  "details": [
    {
      "descrizione": "Dispositivo Alpha - soglia superata",
      "inizio": "20/02/2026 11:56:08",
      "classe": "A2",
      "tipo": "analogico",
      "valore": "ATTIVO",
      "soglia": "nd"
    }
    // ... altri 3 elementi
  ]
}Code language: JSON / JSON with Comments (json)

Il modello non sa cosa significa A2, non sa da quante ore è attiva l'anomalia, e se riceve tutti i 4 elementi con tutti i campi potrebbe superare il budget token.

Dopo la pipeline, il modello riceve:

{
  "sistema": "SIST-0044-X",
  "numero_anomalie_attive": "4",
  "details": [
    {
      "descrizione": "Dispositivo Alpha - soglia superata",
      "inizio": "20/02/2026 11:56:08",
      "classe": "Media criticità (A2)",
      "ore_attivo": 26.0,
      "criticita": "Media criticità"
    }
    // trimmed: 2 di 4 elementi (budget token)
  ],
  "analisi": {
    "totale": 4,
    "per_criticita": { "A2 (Media criticità)": 4 },
    "attive_oltre_12h": 4
  },
  "nota": "4 anomalie attive ordinate per criticità. Tutte oltre 12h."
}Code language: JSON / JSON with Comments (json)

Il modello a questo punto ha tutto: conteggi pre-calcolati, codici tradotti, durate calcolate in ore. Può concentrarsi su ciò che sa fare: formulare una risposta chiara in italiano.

Il filtering in base alla domanda

La parte più elegante è che la quantità di dettaglio dipende da cosa l'utente ha chiesto:

DETAIL_KEYWORDS = {"quali", "soglie", "elenco", "lista", "mostra", "dettagli"}

def build_llm_content(data, tool_name, query):
    include_details = wants_details(query)

    humanized = humanize_for_llm(data, tool_name)

    if not include_details:
        for field in LARGE_ARRAY_FIELDS:
            if field in humanized:
                humanized[field] = f"({len(humanized[field])} elementi, chiedi dettagli)"

    return trim_to_budget(json.dumps(humanized), TOKEN_BUDGET=2000)Code language: JavaScript (javascript)

"Quanti dispositivi ci sono?" → solo nota + conteggi (~75% meno token). "Quali sensori hanno soglie?" → dettagli completi. Stesso tool, stessa API, contenuto diverso in base alla domanda.


Lezione 5: l'enrichment automatico è un game changer

L'utente chiede "quante volte si è attivato il dispositivo ieri?". L'API restituisce una serie storica di valori (0, 1, 0, 1, 0…) con timestamp. Il modello dovrebbe contare le transizioni 0→1.

Spoiler: non ci riesce. Un 8B non è affidabile per operazioni su array di dati.

La soluzione: calcoli nel middleware

def enrich_tool_results(tool_message, state):
    data = deepcopy(tool_message.artifact)
    tool_name = tool_message.name

    if tool_name == "get_history":
        values = data["valori"]
        if is_digital(values):
            data["analisi"] = count_transitions(values)
            # → {"attivazioni": 7, "disattivazioni": 7, "tempo_attivo": "6h 23m"}
        else:
            data["analisi"] = calculate_stats(values)
            # → {"media": 4.2, "min": 1.1, "max": 8.7}

    elif tool_name == "get_event_log":
        data["analisi"] = group_and_count(data["records"], key="tipo")
        # → {"Errore": 12, "Cambio stato": 45, ...}

    tool_message.content = build_llm_content(data, tool_name, query)
    return tool_messageCode language: PHP (php)

Il modello riceve direttamente "attivazioni": 7 invece di dover contare una serie storica. La risposta è accurata al 100% - non dipende dal modello per il calcolo.

Questo è il pattern più importante che ho imparato: sposta tutto ciò che è calcolabile fuori dal modello. Il modello deve fare solo ciò che solo un modello può fare: capire la domanda e formulare la risposta.


Lezione 6: StrEnum e Python 3.11+ - la trappola silenziosa

Questa è tecnica ma vale la pena raccontarla perché mi ha fatto perdere ore.

In Python 3.11+, str(StrEnum.VALUE) restituisce "ClassName.VALUE" invece di "VALUE". Quando passi un enum come parametro API:

# Python 3.10: str(Status.ACTIVE) → "active"      ✓
# Python 3.11+: str(Status.ACTIVE) → "Status.active"  ✗ L'API non lo capisceCode language: PHP (php)

La soluzione: usare sempre .value per i parametri API e model_dump(mode="json") per la serializzazione Pydantic. Ma il bug è silenzioso - l'API riceve un parametro sbagliato e restituisce 0 risultati, senza errori.


Lezione 7: conosci i dati prima di automatizzarli

Questa non è una lezione tecnica. È una lezione di metodo, e forse la più importante.

Prima di scrivere una sola riga di middleware, ho passato giorni a esplorare le API. Non limitarmi a leggere la documentazione, ma chiamare ogni endpoint con parametri diversi, salvare le risposte, confrontare i campi, cercare pattern nei dati reali.

Ed è lì che emergono le cose che nessuna documentazione può coprire - perché sono dettagli implementativi che diventano visibili solo nell'uso concreto:

  • Convenzioni di naming diverse tra endpoint: lo stesso concetto ha nomi leggermente diversi in API diverse. Normale in sistemi complessi, ma se non lo mappi, il codice si rompe in modi sottili
  • Valori vuoti con formati diversi: stringa vuota "", placeholder "nd", o null. Tre rappresentazioni dello stesso significato - "dato non disponibile"
  • Codici con struttura posizionale: i codici identificativi seguono una grammatica interna - tipo, categoria, sottotipo, progressivo. Capire questa grammatica permette di costruire validatori che intercettano errori impossibili da catturare altrimenti
  • Relazioni implicite tra entità: certe informazioni emergono solo incrociando le risposte di endpoint diversi. Il singolo endpoint racconta solo una parte della storia

Tutto questo lavoro "noioso" di esplorazione ha un ritorno enorme:

  1. Validatori più precisi: se sai che un codice segue un pattern regex specifico, puoi bloccare valori inventati dal modello prima della chiamata API
  2. Enrichment più intelligente: se conosci le relazioni tra entità, puoi aggiungere contesto che il singolo endpoint non fornisce
  3. Humanization corretta: se sai che T1 in un contesto significa "alta criticità" ma in un altro contesto ha un significato diverso, eviti di confondere il modello
  4. Gestione robusta dei dati: se sai che un campo può arrivare in formati diversi, gestisci tutti i casi a monte con un validatore Pydantic - e il resto del codice non deve preoccuparsene
@field_validator("stato", mode="before")
@classmethod
def clean_empty(cls, v):
    if v in ("", "nd", None):
        return None
    return vCode language: JavaScript (javascript)

La tentazione è saltare questa fase e andare dritti al "codice interessante" - l'agent, i middleware, il prompt. Ma senza una conoscenza profonda dei dati, ogni middleware è una casa costruita sulla sabbia.

Il consiglio: prima di automatizzare qualcosa, capiscilo manualmente. Fai le chiamate API a mano. Leggi le risposte. Confronta. Annota le particolarità. Quel catalogo mentale diventa il fondamento di tutto il codice deterministico che scriverai dopo.


I numeri

Dopo settimane di iterazioni su middleware e prompt:

  • Accuracy intent detection: 95.8% (183/191 domande di test)
  • Accuracy end-to-end: 83% (44/53 domande), ~90% escludendo i casi "difficili" (ambiguità reali, limiti API)
  • Key insight: la validazione deterministica nei middleware contribuisce più del tuning del prompt

Il prompt dell'agent è di ~20 righe. Compatto, quasi minimale. La complessità sta nei middleware.


Il principio guida

Se dovessi riassumere tutto in una frase:

Non chiedere al modello ciò che puoi calcolare.

Un LLM da 8B è sorprendentemente capace nel capire cosa l'utente vuole. Ma è fragile nel come - parametri, date, calcoli, formati. I middleware colmano questo gap in modo deterministico, riproducibile, debuggabile.

Il modello è il direttore d'orchestra. I middleware sono i musicisti che suonano effettivamente le note.


Architettura finale

Cosa portarsi a casa

  1. I modelli piccoli funzionano in produzione, ma serve un'architettura che compensi i loro limiti
  2. Middleware > Prompt engineering per vincoli deterministici (date, enum, formati)
  3. Meno token al modello = risposte migliori - il pattern artifact separa ciò che serve al LLM da ciò che serve al codice
  4. Enrichment automatico sposta i calcoli fuori dal modello, dove sono affidabili al 100%
  5. Fuzzy self-heal → block + tell è più robusto di "spera che il modello ci azzecchi"
  6. Conosci i dati prima di automatizzarli - l'esplorazione manuale delle API è il fondamento di tutto
  7. Il modello è il direttore d'orchestra, i middleware suonano le note

La prossima volta che qualcuno vi dice "basta un buon prompt", ricordategli che un buon prompt è il 20% della soluzione. L'altro 80% è ingegneria deterministica attorno al modello.


Scritto mentre debuggavo l'ennesima risposta vuota alle 2 di notte. I modelli piccoli insegnano l'umiltà.


Fonti e approfondimenti

Per chi vuole approfondire i temi trattati, tre paper che hanno influenzato le scelte architetturali di questo progetto:

Related Post

Maggio 14, 2025
Il mio viaggio con AWS SageMaker

Sono entusiasta di condividere che ho recentemente completato l'AI Engineering Bootcamp: Build, Train and Deploy Models with AWS SageMaker della scuola Zero To Mastery tenuto da Patrik Szepesi! Dopo giornate di apprendimento intensivo e progetti pratici, ho acquisito competenze cruciali che mi hanno fatto progredire nel mio percorso come ingegnere AI. Cos'è AWS SageMaker? Per […]

Ottobre 20, 2025
🎓 Certificazione OpenAI Agent Builder: Deep Research Agent con ChatKit su AWS

Ho recentemente completato il corso "OpenAI Agent Builder" su Udemy, tenuto da Diogo Alves de Resende, dove ho costruito un agente di ricerca avanzato che combina orchestrazione multi-agente, guardrail, e web search capabilities. 🤖 Il Progetto: Deep Research Agent L'agente che ho sviluppato durante il corso implementa un sistema di ricerca approfondita in 5 fasi: […]

veronicaschembri
Copyright © Veronica Schembri

Privacy Policy
Cookie Policy
💬