Ingegneria dei sistemi LLM · guida interattiva

Un modello non è un sistema: le sei cose che stanno intorno a una chiamata

Chiamare un LLM (Large Language Model, modello linguistico di grandi dimensioni) sono tre righe di codice. Farlo in produzione, per utenti veri, con un costo che sai prevedere, una latenza che sai misurare, una memoria che non esplode e un modo per accorgerti quando peggiora, è ingegneria. Questa guida percorre i sei strati che il portfolio ai-engineering-portfolio costruisce uno alla volta, dal gateway di inferenza al deploy con utenti reali, e per ogni strato ti fa toccare con mano il meccanismo che conta: percentili e cache, fusione di ranking, macchina a stati di un agente, punteggio dei ricordi, accordo tra giudice e umano.

0 · Concorrenza strutturata

Un servizio LLM aspetta quasi sempre qualcun altro: la concorrenza è la prima competenza, non l'ultima

Esempio · il tempo a Bologna

Immagina di chiedere «com'è il tempo a Bologna?» a un servizio che deve prima trasformare «Bologna» in coordinate, poi chiedere meteo e fuso orario. Le ultime due chiamate non dipendono l'una dall'altra: farle una dopo l'altra costa il doppio, lanciarle insieme costa quanto la più lenta.

Un sistema LLM è fatto quasi solo di attese di questo tipo: verso il provider del modello, verso il vector database, verso i tool di un agente. Il tempo di CPU (Central Processing Unit, il processore) è trascurabile; il tempo di attesa è tutto.

Per questo il percorso comincia da asyncio, e in particolare dalla concorrenza strutturata: un task figlio non sopravvive allo scope che lo ha creato, e se un figlio fallisce i fratelli vengono cancellati e l'errore risale al padre. In Python 3.11+ lo scope è asyncio.TaskGroup; chi arriva da Kotlin lo riconosce come coroutineScope.

Dove Python differisce da Kotlin

È la stessa idea, e le differenze sono di dettaglio: una coroutine Python è inerte finché non la si attende, CancelledError è una BaseException che un except Exception non cattura, e gli errori dei figli arrivano raggruppati in un ExceptionGroup da intercettare con except*.

Python · fan-out strutturato
async def city_report(name: str) -> CityReport:
    loc = await geocode(name)                 # ← dipendenza: prima
    async with asyncio.timeout(2.0), asyncio.TaskGroup() as tg:
        w = tg.create_task(weather(loc))       # ← indipendenti: insieme
        t = tg.create_task(timezone(loc))
    return CityReport(location=loc, weather=w.result(), tz=t.result())

Il limite di concorrenza è una scelta, non un incidente

Lanciare tutto insieme non è sempre possibile: un provider ha un limite di richieste al minuto, un vector database regge un certo numero di connessioni. asyncio.Semaphore(n) dice «al massimo n in volo»: le altre aspettano il loro turno. Il costo si calcola a mente prima di misurarlo, ed è proprio quello che il laboratorio vuole allenare: se sai prevedere il numero, sai anche spiegarlo a colloquio.

Laboratorio: sequenziale, parallelo, con semaforo

matematica reale

Scegli quante chiamate esterne fa la richiesta e quanto dura ciascuna; muovi il semaforo e osserva come cambia il tempo totale rispetto al sequenziale.

Modello ideale: latenze identiche e nessun overhead di rete o di event loop. Nella realtà il tempo del parallelo è quello della chiamata più lenta, non della media: è il motivo per cui la sezione 1 ragiona in percentili.

Mappa concettuale · concorrenza strutturata
  • Concorrenza strutturata
    • nasce dalle Attese verso altri servizi
      • permettono chiamate indipendenti in parallelo
      • costo del parallelo quello della più lenta sezione 1: percentili
    • si scrive con TaskGroup
      • regola il figlio non sopravvive allo scope
      • se un figlio fallisce i fratelli vengono cancellati
      • gli errori arrivano in ExceptionGroup
    • si limita con Semaphore(n)
      • dice al massimo n in volo
      • perché limiti del provider e del database
    • è fermato da funzioni bloccanti
      • ferma l'event loop
      • si isolano con asyncio.to_thread
Event loop
Il ciclo che, su un solo thread, alterna le coroutine quando ognuna si ferma ad aspettare. Una funzione bloccante (es. time.sleep) ferma tutto il servizio.
await asyncio.to_thread(fn) per isolarla
TaskGroup
Scope strutturato: i task creati dentro finiscono prima che lo scope esca; se uno fallisce gli altri vengono cancellati.
async with asyncio.TaskGroup() as tg
Semaphore
Limite al numero di task contemporaneamente in volo verso una risorsa.
asyncio.Semaphore(8)
SSE
Server-Sent Events: canale unidirezionale server→client su HTTP semplice, usato per lo streaming dei token.
event: token · data: {…}
1 · Costo e latenza · llm-gateway

Tra la tua applicazione e il modello serve uno strato che conti, ricordi e protegga

Ogni chiamata al modello costa denaro e tempo, e nessuno dei due è costante: dipende da quanti token entrano ed escono, dal provider, dall'ora, da quanto il provider è in salute.

Un gateway è un proxy che tutte le chiamate attraversano, e che per questo può fare quattro cose che nessun singolo punto dell'applicazione potrebbe fare da solo: contare (token e costo per richiesta, per utente, per feature), ricordare (una cache per non ripagare la stessa domanda), proteggere (rate limiting, budget, circuit breaker verso un provider in errore, fallback su un altro) e osservare (una traccia per ogni richiesta con i tempi di ogni fase).

La sequenza di una richiesta è questa: verifica del limite e del budget della chiave; ricerca nella cache esatta; se manca, ricerca nella cache semantica; se manca ancora, scelta del provider, chiamata con retry e timeout, conteggio dei token, salvataggio in cache e una riga di contabilità.

Il laboratorio più sotto fa vedere come le cache cambiano latenza e costo insieme.

POST /v1/chat · chiave, alias del modello, messaggi, tag
↓
rate limit e budget della chiave
↓
cache esatta (hash della richiesta normalizzata)
↓ miss
cache semantica (embedding dell'ultimo messaggio, similarità ≥ soglia)
↓ miss
router → circuit breaker → retry con backoff e jitter → provider
↓
ogni esito Contabilità
token in/out × listino versionato → costo, per chiave, utente, tag
ogni esito Osservabilità
uno span per fase (cache, router, provider), metriche, log correlati

La media mente: p50, p95, p99

Se dieci richieste durano 200 ms e una dura 6 secondi, la media dice 730 ms e non descrive nessuna delle undici.

Calcolo · la media di undici richieste

tempo totale=10 · 200 ms + 6.000 ms = 2.000 ms + 6.000 ms = 8.000 ms
dieci richieste da 200 ms e una da 6 secondi
richieste=10 + 1 = 11
media=8.000 ms 11 ≈ 730 ms
un valore che non descrive nessuna delle undici richieste

I percentili descrivono tutte: il p50 (mediana) è il tempo sotto cui cade metà delle richieste, il p95 quello sotto cui ne cade il 95 %, il p99 il 99 %. Il p50 racconta l'utente tipico; il p95 gli utenti sfortunati, che sono tanti se hai molto traffico; il p99 le code: retry, breaker che si apre, provider lento a partire.

Per lo streaming si misura a parte il TTFT (Time To First Token): il tempo fino al primo token è ciò che l'utente percepisce come «sta rispondendo».

Una parte del TTFT non dipende dalla rete: prima di emettere il primo token il modello deve elaborare tutto il prompt in ingresso (la fase di prefill), quindi il TTFT cresce con la lunghezza del contesto, mentre il resto della risposta arriva un token alla volta (la fase di decode).

Perché le due fasi si comportano in modo così diverso, e perché i token in uscita costano più di quelli in ingresso, lo spiega la guida sull'inferenza.

Due cache, due promesse diverse

La cache esatta riusa la risposta se la richiesta è identica byte per byte dopo normalizzazione (stesso modello, stessi messaggi, stessa temperatura): la chiave è un hash.

È coerente con ciò che l'API (Application Programming Interface) offre solo a temperatura zero: con temperatura maggiore di zero ogni chiamata campiona una risposta potenzialmente diversa, mentre la cache restituisce sempre la stessa (e anche a temperatura zero molti provider non garantiscono un output identico byte per byte).

La cache semantica riusa la risposta se la nuova domanda è abbastanza simile a una già vista: si calcola l'embedding dell'ultimo messaggio e si cerca il più vicino per similarità coseno sopra una soglia. È qui che sta il compromesso che vale un ADR (Architecture Decision Record): una soglia bassa aumenta gli hit ma riusa risposte non pertinenti (falsi positivi), una soglia alta riduce entrambi.

I falsi positivi non si vedono

I falsi positivi non compaiono in nessuna metrica automatica: si contano a mano su un campione di hit, e chi non li misura non sa quanto la cache sta sbagliando.

Da non confondere

Nessuna delle due va confusa con il prompt caching del provider. Le cache del gateway riusano una risposta e il modello non viene chiamato; il prompt caching invece riusa il lavoro già fatto su un prefisso identico tra una richiesta e l'altra (system prompt, definizioni dei tool, documenti fissi): il modello genera comunque una risposta nuova, ma il TTFT scende e i token in ingresso letti dalla cache sono fatturati a una frazione del prezzo pieno (alcuni provider fanno pagare un sovrapprezzo alla prima scrittura).

Regola del prompt caching

Funziona solo se il prefisso è identico byte per byte, quindi conviene mettere in testa al prompt ciò che non cambia e in coda ciò che cambia.

Laboratorio: percentili, cache e costo per 1000 richieste

simulazione

Muovi l'hit rate della cache e il modello; osserva come p50 e p99 si muovono in modo diverso, e quanto costa il migliaio di richieste.

p50
p95
p99
costo / 1000 req
falsi positivi stimati

Latenze generate da una distribuzione log-normale (un miss ≈ 700 ms di mediana con il modello piccolo e ≈ 1400 ms con il grande, con coda lunga; un hit ≈ 40 ms); prezzi indicativi per token e falsi positivi che crescono al calare della soglia. I numeri veri li produce il load test del gateway con k6; qui conta la forma delle curve, non il valore.

Resilienza: retry con jitter, timeout per fase, circuit breaker

Un provider che risponde 500 tre volte di fila non va richiamato subito: si riprova con backoff esponenziale e jitter (attese crescenti e casuali, così mille client non ripartono insieme), solo su errori riprovabili, mai a metà di uno stream già iniziato.

I timeout sono due: uno breve per il primo token, uno lungo per la risposta intera, perché un provider che non parte va abbandonato prima di uno che sta scrivendo.

Il circuit breaker osserva gli errori in una finestra e, superata una soglia, si apre: le richieste vanno subito al fallback senza nemmeno tentare, finché dopo un tempo di raffreddamento una richiesta di prova (half-open) decide se richiuderlo.

Laboratorio: il circuit breaker

comportamento reale, semplificato

Manda chiamate al provider con i pulsanti e guarda lo stato del breaker: si apre dopo 3 errori in una finestra di 5 chiamate, resta aperto 4 «tick», poi prova una chiamata.

closed→open→half-open

Mappa concettuale · costo e latenza
  • Gateway LLM
    • conta token e costo
      • per richiesta, utente, feature
      • con listino versionato
    • ricorda con due cache
      • esatta richiesta identica, chiave hash
      • semantica domanda simile, sopra una soglia
      • la soglia bassa dà più hit e più falsi positivi
    • protegge con resilienza
      • chiamate in errore retry con backoff e jitter
      • attese timeout per fase
      • provider in errore circuit breaker
    • osserva con tracce e percentili
      • la media mente, si usano p50, p95, p99
      • per lo streaming TTFT
    • da non confondere con prompt caching del provider
      • riusa il prefisso, non la risposta
Percentile
Il valore sotto cui cade una data frazione delle misure. p95: il 95 % delle richieste è stato più veloce di così.
p50 = mediana
TTFT
Time To First Token: tempo fino al primo token in streaming. Si riporta separato dal tempo totale.
timeout TTFT 5 s, totale 60 s
Cache semantica
Riuso di una risposta quando la nuova domanda ha un embedding vicino a una già vista, sopra una soglia scelta misurando hit rate e falsi positivi.
coseno ≥ 0.92
Circuit breaker
Interruttore che smette di chiamare un provider in errore ripetuto e lo riprova dopo un tempo di raffreddamento.
closed → open → half-open
Backoff con jitter
Attesa crescente e casuale tra un tentativo e l'altro, per non far ripartire tutti i client nello stesso istante.
sleep(random(0, base·2^n))
Listino versionato
Tabella prezzi datata, salvata nel repo: ogni riga di contabilità registra quale versione ha usato, così i report restano ricostruibili quando i prezzi cambiano.
pricing/2026-09.yaml
2 · Retrieval · rag-bench

Un RAG non si sceglie, si misura: chunking, retrieval e vector database sono tre variabili, non tre certezze

RAG (Retrieval-Augmented Generation) significa: prima di far rispondere il modello, cerca nei tuoi documenti i passaggi pertinenti e mettiglieli nel prompt. Il sistema fa due lavori in due momenti diversi.

Prima, una volta sola, l'ingestion: spezza i documenti in pezzi (chunk), trasforma ogni pezzo in un vettore che ne rappresenta il significato (embedding) e lo salva in un vector database insieme al testo; in parallelo costruisce un indice lessicale BM25 (Best Match 25) sulle parole.

Poi, a ogni domanda, la query: trasforma la domanda in vettore, trova i chunk vicini per significato (dense) e quelli con le parole più simili (sparse), fonde le due liste (hybrid), eventualmente le riordina con un modello più preciso ma più lento (reranking), e passa i primi al modello, che risponde citando i chunk usati o dichiara di non aver trovato nulla.

Ogni scelta lungo questa strada cambia qualità, latenza e costo, e l'unico modo di sapere quale vince sul tuo corpus è un banco di misura che cambi una variabile alla volta. Per i fondamenti di embedding, distanze e indici approssimati vedi la guida sui database vettoriali; qui interessa ciò che sta sopra.

Il chunking è la scelta che nessuno misura e che cambia tutto il resto

Tre strategie, stesso documento. Fissa: finestre di 512 token con sovrapposizione di 64, cieca alla struttura, prevedibile. Ricorsiva: prova a spezzare per paragrafo, se troppo lungo per frase, se ancora troppo lungo per token, così i confini cadono dove cade il senso. Strutturale: segue i titoli e le sezioni del documento, ottima se il corpus è pulito e pessima se non lo è.

Un chunk troppo grande diluisce il significato nell'embedding e spreca contesto; troppo piccolo perde il filo.

Regola

L'unica regola generale è che il chunk deve portare con sé il contesto (titolo del documento e della sezione nel testo che viene convertito).

strategia Fissa
512 token, overlap 64
confini arbitrari: può tagliare una frase a metà; nessuna dipendenza dalla struttura
strategia Ricorsiva
paragrafo → frase → token
confini dove cade il senso; buon default
strategia Strutturale
per heading e sezione
vince su corpus ben strutturati, perde su PDF sporchi

Hybrid: fondere due classifiche che parlano lingue diverse

Il retrieval dense restituisce chunk ordinati per similarità coseno (numeri tra −1 e 1); BM25 li ordina per un punteggio lessicale (numeri arbitrari, dipendenti dal corpus). Sommare i punteggi non ha senso.

La RRF (Reciprocal Rank Fusion) ignora i punteggi e usa solo le posizioni: ogni lista assegna a un chunk il suo contributo, con k tipicamente 60, e i contributi si sommano.

Formula · Reciprocal Rank Fusion

contributo di una lista=1 k + rank
conta la posizione, non il punteggio; k è tipicamente 60
punteggio RRF=somma dei contributi di tutte le liste

Un chunk primo in entrambe vince; uno primo in una e assente nell'altra prende un solo contributo. Non serve normalizzare nulla, ed è per questo che funziona tra sistemi eterogenei.

Il reranking con un cross-encoder viene dopo: legge domanda e chunk insieme e produce un punteggio molto più preciso del coseno, ma costa un'inferenza per chunk, quindi si applica solo ai primi 20–50.

Laboratorio: RRF, recall@k e MRR su un caso minimo

matematica reale

Sei chunk (A–F), due classifiche. Clicca un chunk per portarlo in cima alla sua classifica, segna quali chunk sono davvero pertinenti e guarda come cambiano la lista fusa e le metriche.

Classifica dense (coseno)
Classifica BM25 (lessicale)
recall@k (dense)
recall@k (BM25)
recall@k (hybrid RRF)
MRR (hybrid)

recall@k = frazione dei chunk pertinenti che compaiono nei primi k; MRR (Mean Reciprocal Rank) = 1 / posizione del primo pertinente, mediato sulle domande (qui una sola domanda). Nel benchmark vero le metriche si calcolano su decine di domande e si accompagnano a nDCG (Normalized Discounted Cumulative Gain), latenza e costo per cella.

Citazioni verificate e «non ho trovato la risposta»

Due dettagli separano un RAG dimostrativo da uno affidabile. Le citazioni: il modello risponde in structured output con l'elenco degli id dei chunk usati, e un passo deterministico controlla che ogni id esista nel contesto passato e che il testo citato sia contenuto nel chunk; una citazione inventata è un errore misurabile, non un dettaglio di presentazione.

Il «non trovato»: due segnali combinati, il punteggio massimo di retrieval sotto una soglia calibrata e il campo found: false nello structured output; il benchmark include domande senza risposta nel corpus proprio per misurare quante volte il sistema inventa invece di ammettere.

Entrambi i controlli contengono un difetto che ha un nome preciso: l'allucinazione, una risposta plausibile, scritta con sicurezza, ma non supportata da nessuna fonte. Non è un guasto occasionale: il modello genera il testo più probabile dato il contesto, non un testo verificato, e una frase inventata può essere probabile quanto una vera.

Il rimedio del RAG si chiama grounding (ancoraggio al contesto): ogni affermazione della risposta deve essere supportata dai chunk recuperati, e la proprietà che lo misura è la fedeltà (faithfulness). Si misura in due modi complementari: le citazioni verificabili descritte sopra, controllate da codice, e un giudice LLM (Large Language Model) che scompone la risposta in affermazioni e per ciascuna dice se il contesto la sostiene. È lo stesso giudice della sezione 5, e va calibrato allo stesso modo prima di fidarsi del suo punteggio.

Fedeltà non è correttezza

Fedeltà e correttezza non coincidono: una risposta può essere fedele a un chunk sbagliato o superato, e quello è un errore di retrieval, non di generazione.

Mappa concettuale · retrieval
  • RAG
    • prima, una volta: ingestion spezza e indicizza
      • in pezzi con chunking fisso, ricorsivo, strutturale
      • in vettori embedding
      • sulle parole indice BM25
    • poi, a ogni domanda: query cerca
      • per significato dense
      • per parole sparse (BM25)
      • fonde le liste con RRF
      • riordina con reranking
    • risponde con citazioni verificate
      • oppure «non trovato»
      • contro allucinazione
      • si misura come fedeltà
    • si sceglie con un banco di misura
      • cambiando una variabile alla volta
      • con recall@k e MRR
Chunk
Frammento di documento convertito in un embedding a sé; la sua dimensione e i suoi confini sono la prima variabile del benchmark.
fisso · ricorsivo · strutturale
BM25
Best Match 25: punteggio lessicale basato su frequenza dei termini e lunghezza del documento; forte sui termini esatti (codici, sigle).
sparse retrieval
RRF
Reciprocal Rank Fusion: fusione di più classifiche sommando 1/(k+rank); non richiede di normalizzare i punteggi.
k = 60
Reranking
Riordino dei primi candidati con un cross-encoder che legge domanda e chunk insieme.
top 20–50, poi top n
HyDE
Hypothetical Document Embeddings: si cerca con l'embedding di una risposta ipotetica scritta dal modello, spesso più vicina ai documenti della domanda stessa.
componente opzionale, da misurare in ablation
Allucinazione
Risposta plausibile e sicura ma non supportata dalle fonti: il modello genera il testo probabile, non quello verificato.
una citazione a un chunk che non esiste
Grounding (ancoraggio al contesto)
Ogni affermazione della risposta è supportata dal contesto recuperato; si misura come fedeltà (faithfulness) con citazioni verificate e un giudice calibrato.
affermazioni supportate / affermazioni totali
Porta VectorStore
Interfaccia (Protocol) con upsert, search, delete, count; tre adapter (pgvector, Qdrant, FAISS) passano la stessa suite di test di contratto.
architettura esagonale
3 · Agenti · agent-runtime

Un agente non è un while True con un prompt: è una macchina a stati con un budget, che deve poter morire e ripartire

Un agente è un modello che, invece di rispondere subito, decide di usare un tool (una ricerca web, una query, l'esecuzione di codice), guarda il risultato e decide di nuovo, finché non ha abbastanza per rispondere.

La versione minima è un ciclo di trenta righe che funziona finché non lo si rompe: un tool che va in timeout, un tool che non esiste, argomenti invalidi, un modello che chiede lo stesso tool all'infinito, un processo che muore al settimo passo. Ognuna di queste rotture insegna un requisito, e i requisiti messi insieme sono un runtime.

Che cosa sia un tool, come il modello lo chiede e perché il ciclo pensa-agisci-osserva funziona è il tema della guida sugli agenti; qui si parte da lì e si guarda che cosa serve perché quel ciclo regga in produzione.

Il cuore è una macchina a stati esplicita: plan (il modello produce un piano in structured output), act (chiede il prossimo passo; il modello risponde con una o più chiamate a tool, che il runtime esegue in parallelo con un TaskGroup), observe (i risultati, o gli errori strutturati, vengono appesi alla conversazione e il budget aggiornato), reflect (ogni N passi il modello valuta se il piano regge), stop o failed.

Ogni transizione è una funzione pura (stato, evento) → nuovo stato; i side effect, cioè chiamare il modello, eseguire i tool, scrivere sul database, stanno in un layer separato. È questa separazione a rendere il runtime testabile con tool finti e senza modello, e ripristinabile dopo un crash.

plan piano in structured output: sotto-domande e tool previsti
↓
act il modello chiede tool; esecuzione parallela
↓
tool eseguiti observe
risultati ed errori strutturati appesi ai messaggi; budget aggiornato → act, o reflect ogni N passi
tool che richiede approvazione waiting_approval
stato persistito, processo fermo; alla ripresa: approvato → act, rifiutato → stop
nessun tool richiesto stop
report finale validato (schema + citazioni)
budget esaurito o errore non recuperabile failed
passi, token o tempo oltre il limite

Gli errori dei tool sono dati, il budget è un contratto

Quando web_search va in timeout, il runtime non si ferma: restituisce al modello un ToolResult con ok: false, kind: "timeout", retryable: true, e il modello decide se riprovare, cambiare tool o proseguire senza.

Il budget è in tre valute, passi, token e tempo, e quando una si esaurisce la run finisce in failed con un motivo leggibile, invece di girare per sempre.

Da testare per prima

Le rotture sono la parte da testare per prima: la suite di traiettorie in CI (Continuous Integration) esegue l'agente con tool finti e verifica, senza chiamare alcun modello, che ogni rottura produca la transizione attesa.

Esecuzione durevole: kill -9 al passo 7, ripresa dal passo 7

Lo stato viene salvato a ogni passo (fase, piano, messaggi, budget, tool richiesti e risultati) su SQLite o Postgres.

Se il processo muore, agent resume <run_id> ricarica l'ultimo stato e riparte da lì, non da zero.

Perché la ripresa dia lo stesso risultato serve l'idempotenza dei tool o la registrazione dei loro esiti prima di proseguire: un tool eseguito prima del crash non va rieseguito.

Lo stesso meccanismo dà il human-in-the-loop gratis: un tool che richiede approvazione (eseguire codice) porta la run in waiting_approval, persiste e si ferma; l'approvazione è un evento che la fa ripartire, anche giorni dopo.

È il filo che collega questa guida a quella sulle transazioni: uno stato persistito a ogni passo e un log da cui ripartire sono la stessa idea del write-ahead log.

Laboratorio: la macchina a stati, passo per passo

simulazione

Avanza l'agente di ricerca un passo alla volta; fai fallire un tool, uccidi il processo e riprendi la run, e osserva stato, budget e traiettoria.

passo
token usati
tool ok / falliti
stato persistito

Le risposte del «modello» sono una sceneggiatura fissa (ricerca tecnica sull'autenticazione mobile); ciò che è reale è la logica di transizione, il budget e la ripresa dallo stato salvato. Nel runtime vero ogni passo è anche uno span OpenTelemetry.

A mano prima del framework, poi il confronto

Lo stesso agente viene poi riscritto con LangGraph (grafo esplicito con checkpointer e interrupt nativi) e con PydanticAI (controllo del flusso implicito, tracing nativo), e messo in una tabella: righe di codice del loop, task completati, costo e passi medi, testabilità con tool finti, persistenza, human-in-the-loop, tempo di implementazione.

Il punto del confronto

Il punto non è vincere: è saper dire, con numeri, quando un framework restituisce più di quanto costa e quando nasconde ciò che serve controllare.

MCP (Model Context Protocol) entra come sorgente di tool: il runtime è client di server MCP esterni ed espone i propri tool come server.

Mappa concettuale · agenti
  • Agente in produzione
    • è una macchina a stati
      • stati plan · act · observe · reflect · stop o failed
      • transizioni funzioni pure
      • side effect in un layer separato
    • è limitato da budget
      • in tre valute passi, token, tempo
      • se si esaurisce failed, con un motivo
    • tratta gli errori come dati
      • un tool in errore dà ToolResult con ok: false
      • il modello decide riprovare, cambiare tool o proseguire
    • sopravvive grazie a esecuzione durevole
      • stato salvato a ogni passo
      • la ripresa richiede idempotenza dei tool
      • dà gratis human-in-the-loop come il write-ahead log, guida sulle transazioni
    • si testa con tool finti
      • senza chiamare un modello
Tool calling
Il modello risponde con una richiesta strutturata di eseguire una funzione con certi argomenti; il runtime la esegue e restituisce il risultato come messaggio.
tool_calls: [web_search(q)]
Structured output
Risposta del modello vincolata a uno schema JSON (JavaScript Object Notation) generato da una classe Pydantic e validata; mattone di piano e riflessione.
Plan(sub_questions, tools)
Budget
Limiti di passi, token e tempo per run; esauriti, la run termina in failed con motivo.
BudgetExceeded(kind="steps")
Esecuzione durevole
Stato persistito a ogni passo e ripresa idempotente dopo crash o pausa per approvazione.
agent resume run_42
MCP
Model Context Protocol: standard per esporre tool e risorse a un agente; il runtime è client e server.
tools/mcp_source.py
Test di traiettoria
Esecuzione dell'agente con tool finti e asserzioni sulla sequenza di stati, senza chiamare un modello reale.
gira in CI a costo zero
4 · Memoria · companion-coach

La finestra di contesto non è memoria: ricordare è estrarre, consolidare e scegliere dentro un budget

Esempio · il coach di allenamento

Il 3 marzo l'utente scrive al suo coach di allenamento: «mi fa male il ginocchio destro da ieri, ho saltato gli squat». Il 24 marzo chiede: «cosa metto nella sessione gambe di domani?». Nella conversazione il 3 marzo non c'è più, sono passate sei sessioni.

Mettere tutto nel contesto non scala (costa a ogni turno e a un certo punto non entra) e non basta (il modello si perde nei dettagli).

Serve una memoria che faccia quattro cose: aver estratto all'epoca un fatto («infortunio: ginocchio destro, dal 2 marzo, evita squat») con provenienza al turno originale; recuperarlo ora, perché «sessione gambe» è semanticamente vicino e l'infortunio ha importanza alta; inserirlo nel prompt dentro un budget di token; e, se il 15 marzo l'utente aveva detto «il ginocchio è a posto», aver consolidato il fatto come risolto, così il coach non chiede «come va il ginocchio?» per sempre.

Memoria di lavoro ultimi turni + riassunto rolling dei turni vecchi
sempre nel prompt; quando il budget è superato, i turni più vecchi vengono riassunti
Memoria episodica eventi datati con embedding e timestamp
scritta a fine turno; letta per ricerca semantica pesata per tempo
Profilo semantico fatti stabili: obiettivi, preferenze, infortuni, record, vincoli
estrazione con structured output e provenienza; consolidamento in background: unione, aggiornamento, contraddizione

Consolidare: duplicati, aggiornamenti, contraddizioni

L'estrazione dei fatti è costosa (una chiamata al modello con schema) e non deve rallentare la risposta: il turno accoda un job «estrai e consolida» e una coda lo processa in background, serializzata per utente così due sessioni concorrenti non producono profili divergenti.

Il consolidamento confronta ogni fatto nuovo con quelli esistenti: nessuno simile → inserisci con provenienza; duplicato → unisci e aggiorna importanza e ultimo accesso; aggiornamento → nuova versione, la vecchia diventa superseded; contraddizione → marca e chiedi conferma al prossimo turno.

Prima di tutto questo, un classificatore scarta le categorie escluse (dati sanitari oltre l'infortunio sportivo dichiarato, terzi, finanze, identificativi): il fatto non viene mai scritto, e «dimentica che…» è un intent che cancella fatto, episodi collegati e riassunto.

Scegliere i ricordi: rilevanza, recency, importanza, budget

A ogni turno i ricordi candidati sono più di quanti ne entrino nel prompt. Ognuno riceve un punteggio, sul modello di «Generative Agents»:

Formula · punteggio di un ricordo

punteggio=α · rilevanza + β · recency + γ · importanza
α, β, γ sono i pesi, configurabili
recency=0,5giorni / emivita
decadimento esponenziale sul tempo dall'ultimo accesso

La rilevanza è la similarità coseno con la query, la recency un decadimento esponenziale sul tempo dall'ultimo accesso, l'importanza un valore da 1 a 10 assegnato in estrazione (un infortunio vale più di «preferisce allenarsi la mattina»). I ricordi entrano in ordine di punteggio finché il budget di token è pieno.

I pesi sono configurabili e fanno parte delle eval: il numero che chiude la fase è l'accuratezza del richiamo a 1, 3, 5 e 10 sessioni di distanza per quattro configurazioni di memoria, a parità di budget.

Laboratorio: quali ricordi entrano nel prompt

matematica reale

La domanda è «cosa metto nella sessione gambe di domani?». Muovi i pesi e il budget e osserva quali ricordi vengono selezionati e quale resta fuori.

Rilevanza e importanza dei sei ricordi sono valori assegnati a mano per l'esempio; recency = 0.5^(giorni / emivita). La selezione greedy per punteggio fino al budget è la stessa del sistema reale.

Mappa concettuale · memoria
  • Memoria del coach
    • è fatta di tre memorie
      • ultimi turni memoria di lavoro
      • eventi datati memoria episodica
      • fatti stabili profilo semantico
    • si scrive con estrazione
      • porta provenienza
      • gira in background, in coda
    • si tiene pulita con consolidamento
      • duplicato unisci
      • aggiornamento la vecchia diventa superseded
      • contraddizione chiedi conferma
    • si legge con un punteggio
      • somma di rilevanza, recency, importanza
      • entro un budget di token
Memoria di lavoro
Gli ultimi turni più un riassunto rolling dei precedenti, dentro un budget di token esplicito.
sempre nel prompt
Memoria episodica
Eventi datati, con embedding e timestamp, recuperati per similarità e tempo.
«il 3 marzo ha detto che…»
Profilo semantico
Fatti stabili estratti con structured output, con provenienza al turno di origine, consolidati in background.
infortunio · obiettivo · preferenza
Provenienza
Ogni fatto punta al turno da cui viene: permette di spiegarlo, correggerlo e cancellarlo in cascata.
source_turn_id
Recency
Decadimento esponenziale sul tempo dall'ultimo accesso al ricordo.
0.5^(giorni/emivita)
5 · Valutazione e guardrail · llm-evals

Se cambi una riga del prompt, come sai di non aver peggiorato il sistema? Con un giudice che hai prima verificato tu

Un sistema LLM non ha test nel senso classico: la stessa domanda può avere molte risposte giuste, e «giusta» spesso è un giudizio.

Un harness di eval risolve così: un dataset versionato di casi (input, atteso opzionale, tag), un runner asincrono che li esegue contro il sistema sotto test con concorrenza limitata e una cache (stesso input, stesso prompt, stesso modello → stessa risposta, non ripagata), e degli scorer di tre famiglie: assertion deterministiche (JSON valido? cita almeno una fonte?), metriche classiche (exact match, F1 sui token), e un giudice LLM con una rubrica che valuta ciò che una regex non può (la risposta è fedele al contesto?).

In CI (Continuous Integration) il tutto diventa un cancello: la pull request che cambia il prompt fa girare i 200 casi, il report confronta la pass rate con la baseline del ramo principale, e se scende sotto soglia il check è rosso e il commento sulla PR mostra i casi peggiorati.

PR: cambia prompts/answer.md → l'hash del prompt cambia
↓
runner async · Semaphore · cache per (input, prompt, modello)
↓
scorer Deterministici
schema, regex, citazioni presenti
scorer Classici
exact match, F1, contenimento
scorer Giudice LLM
rubrica calibrata, verdetto strutturato con motivazione
↓
aggregazione con intervallo di confidenza (bootstrap) → pass rate vs baseline → soglia → check verde o rosso + report sulla PR

Un giudice sembra funzionare finché non lo confronti con un umano

Il punto che distingue «ho usato un tool di eval» da «so valutare» è la calibrazione. Prima di mettere il giudice in CI, si etichettano a mano 150 risposte (e 50 due volte, per sapere quanto due umani concordano tra loro), si fa giudicare al modello e si confrontano i verdetti.

Perché l'accordo percentuale inganna

L'accordo percentuale inganna: se il 90 % delle risposte è buono, un giudice che dice sempre «buona» concorda con l'umano il 90 % delle volte senza aver capito nulla.

Il kappa di Cohen corregge per l'accordo che ci si aspetterebbe per caso:

Formula · kappa di Cohen

κ=po − pe 1 − pe
po è l'accordo osservato e pe quello atteso dalle frequenze marginali
Come leggere κ

Un κ vicino a 0 dice «tira a indovinare», tra 0,6 e 0,8 «accordo sostanziale», oltre 0,8 «quasi perfetto».

Si itera sulla rubrica (criteri binari, esempi ancorati) finché il κ è accettabile, e lo si registra: da lì in poi il giudice ha un numero che dice quanto fidarsi.

Laboratorio: accordo percentuale contro kappa di Cohen

matematica reale

Compila la matrice di confusione tra giudice e umano (quante risposte ciascuno ha detto «buona» o «cattiva») e osserva quanto l'accordo percentuale e il kappa raccontano storie diverse.

umano: buona
umano: cattiva
giudice: buona
giudice: cattiva
casi
accordo osservato po
accordo atteso per caso pe
kappa di Cohen

Interpretazione di riferimento (Landis e Koch): κ < 0,2 scarso, 0,2–0,4 discreto, 0,4–0,6 moderato, 0,6–0,8 sostanziale, > 0,8 quasi perfetto. Nel repo il kappa si accompagna a un intervallo di confidenza via bootstrap.

Un guardrail che blocca troppo è un bug di prodotto

I guardrail stanno prima e dopo il modello: in input rilevano prompt injection, dati personali (PII, Personally Identifiable Information) e argomenti sensibili; in output verificano lo schema, la tossicità e che le citazioni esistano davvero nelle fonti.

Ognuno ha un dataset con esempi positivi e negativi, e si misura in due numeri, non uno: il recall (quanti attacchi blocca) e il tasso di falsi positivi (quanto traffico legittimo blocca).

Un classificatore con soglia bassa ha recall alto e blocca conversazioni normali; con soglia alta lascia passare attacchi. La soglia si sceglie guardando entrambi, con un budget di latenza esplicito, e il red teaming (una suite di attacchi per categoria, provata prima e dopo) dice se i guardrail servono a qualcosa.

È lo stesso compromesso della soglia tra precisione e recall della guida sul machine learning, con una coppia di numeri diversa. La precisione dice quante delle richieste bloccate erano davvero attacchi, e dipende da quanto sono rari: su mille richieste con un solo attacco, un tasso di falsi positivi dell'1% blocca una decina di richieste legittime, e la precisione scende sotto il 10% anche se il guardrail ferma l'attacco.

Calcolo · mille richieste, un solo attacco, falsi positivi all'1%

richieste legittime=1.000 − 1 = 999
legittime bloccate=1% · 999 ≈ 10
il tasso di falsi positivi si applica al solo traffico legittimo: una decina di richieste
richieste bloccate=1 + 10 = 11
l'attacco fermato più le richieste legittime bloccate
precisione=1 11 ≈ 9%
sotto il 10% anche se il guardrail ferma l'attacco
Da guardare in un guardrail

Il tasso di falsi positivi invece si misura sul solo traffico legittimo e dice direttamente quanti utenti normali il guardrail disturba: per questo, qui, è il secondo numero da guardare.

Laboratorio: la soglia del guardrail

simulazione

Muovi la soglia del classificatore: a sinistra il traffico legittimo, a destra gli attacchi. Osserva recall e falsi positivi muoversi in direzioni opposte.

recall (attacchi bloccati)
falsi positivi (legittimi bloccati)

Due distribuzioni normali di punteggio, sintetiche. Nel repo i due numeri vengono dal dataset del guardrail, e i falsi positivi si verificano a mano su un campione.

Mappa concettuale · valutazione e guardrail
  • Valutare un sistema LLM
    • si fa con un harness di eval
      • parte da un dataset versionato
      • esegue con un runner con cache
      • giudica con tre famiglie di scorer
    • lo scorer più delicato è il giudice LLM
      • si calibra con etichette umane
      • si misura con kappa di Cohen
      • l'accordo percentuale inganna
    • in CI diventa un cancello
      • confronta pass rate con la baseline
      • se scende sotto soglia check rosso
    • protegge con i guardrail
      • si misurano con recall e falsi positivi
      • si provano con red teaming
      • la soglia è un compromesso come precisione e recall: guida sul machine learning
Dataset versionato
File di casi con manifesto (hash, origine, data); non si modifica in place, si crea una versione nuova.
datasets/rag-qa/v3.jsonl
Pass rate
Frazione dei casi che superano gli scorer; si confronta con la baseline con un intervallo di confidenza via bootstrap.
soglia in evals/threshold.yaml
Giudice LLM
Modello che valuta una risposta con una rubrica e restituisce un verdetto strutturato; va calibrato contro etichette umane.
kappa registrato in docs/calibration.md
Kappa di Cohen
Accordo tra due valutatori corretto per l'accordo atteso per caso.
κ = (p_o − p_e) / (1 − p_e)
Prompt versionato
Prompt come artefatto con versione e hash; ogni run registra quale ha usato, così il diff tra versioni è misurabile.
answer@v2 → answer@v3
Falso positivo
Traffico legittimo bloccato da un guardrail (o risposta riusata a torto da una cache semantica): il costo nascosto di ogni filtro.
si misura a mano su un campione
6 · Composizione e capstone · coach-capstone

Il capstone non aggiunge una competenza: compone le altre cinque in un sistema che sta in piedi da solo

Esempio · una richiesta attraverso tutto il sistema

Una persona apre l'URL del coach dal telefono, fa login, scrive «oggi ho sentito tirare il polpaccio durante le ripetute». La richiesta passa dal browser al backend, che la autentica con Keycloak (OIDC, OpenID Connect), la fa passare dai guardrail di input, recupera dalla memoria che tre settimane fa l'utente aveva raccontato un problema allo stesso polpaccio, passa conversazione e ricordi selezionati al gateway, che sceglie il modello, applica la cache e contabilizza il costo per utente, e restituisce la risposta in streaming; i guardrail di output controllano schema, tono e affermazioni mediche; il turno viene salvato e un job in background estrae i fatti.

Ogni salto è tracciato, ogni token è contato, ogni notte un job consolida la memoria. Niente di questo è nuovo: è il contenuto delle sezioni 1–5 assemblato.

Livello 3 · prodotto companion-coach (memoria) + coach-capstone (deploy, auth, osservabilità)
usa il runtime dell'agente per i tool del coach; passa dal gateway per ogni chiamata
Livello 2 · capacità rag-bench (retrieval come tool e come libreria) · agent-runtime (loop durevole)
tutte le chiamate al modello attraversano il livello 1
Livello 1 · infrastruttura di chiamata llm-gateway: routing, costi, cache, tracing
llm-evals non è un livello: è una lente applicata a tutti (eval regression in CI, guardrail a runtime)

Il diagramma si legge così: il gateway è l'unico punto da cui passano le chiamate ai modelli, quindi costo e latenza di agente e companion sono misurati con lo stesso strumento; rag-bench entra nell'agente come tool di retrieval e nel companion come adapter pgvector per la memoria episodica; llm-evals è una lente e non un livello; il capstone non aggiunge codice di dominio, deploya il companion e ne racconta il ciclo di vita.

Tutti i repo nascono da uno stesso template (uv, ruff, pyright in modalità strict, pytest-asyncio, GitHub Actions, docs/adr/) creato nella fase 0: un pomeriggio che toglie attrito per cinque mesi.

Scritto prima, scritto dopo: DESIGN.md e POSTMORTEM.md

La frase di partenza è vaga («voglio un coach che mi conosca»). Il DESIGN.md la trasforma, prima di scrivere codice, in requisiti verificabili, rischi con mitigazione, milestone datate e target numerici dichiarati (p95, costo per utente, uptime).

Il POSTMORTEM.md, scritto dopo due settimane con 5–10 utenti reali, mette accanto ai target i numeri veri: costo del mese (modelli e infrastruttura), latenza, uptime, retention, il momento in cui la memoria ha ricordato qualcosa di sbagliato e se il cancello di eval lo avrebbe intercettato, l'incidente e il runbook, tre decisioni da rifare. La distanza tra i due documenti è il contenuto del capstone.

Gli ADR (Architecture Decision Record) di tutti i repo sono la stessa idea a grana più fine: per ogni scelta non ovvia, contesto, opzioni, decisione, conseguenze, e un ADR non si modifica, si supera con uno nuovo.

DESIGN.md prima
requisiti verificabili dalla frase vaga
rischi e mitigazioni
milestone datate
target dichiarati: p95, €/utente, uptime, retention
→2 settimane, utenti reali
POSTMORTEM.md dopo
numeri veri accanto ai target
rischi materializzati / previsti
l'incidente, il runbook, l'MTTR (Mean Time To Recovery)
tre decisioni da rifare

Un coach che ricorda è un coach da cui si può dipendere

Un prodotto conversazionale «relazionale» ha implicazioni che un CRUD (Create, Read, Update, Delete: un'applicazione che si limita a gestire dati) non ha, e il portfolio le tratta come requisiti, non come disclaimer: trasparenza sull'essere un'AI (intelligenza artificiale), categorie di informazioni mai memorizzate, cancellazione mirata ed export completo funzionanti prima del primo utente reale, un prompt di policy dedicato per gli argomenti sensibili, e una pagina ethics.md che spiega dipendenza e limiti.

Il consenso informato fa parte dell'onboarding.

Mappa concettuale · composizione e capstone
  • Il capstone
    • compone le sezioni 1–5
      • livello 1 llm-gateway
      • livello 2 rag-bench e agent-runtime
      • livello 3 companion-coach e coach-capstone
    • attraversa tutto llm-evals
      • è una lente, non un livello
      • agisce come cancello in CI e guardrail
    • si racconta con DESIGN.md e POSTMORTEM.md
      • prima requisiti, rischi, target
      • dopo numeri veri
      • le scelte stanno negli ADR
    • richiede etica come requisito
      • include trasparenza, cancellazione, export
      • si spiega in ethics.md
Persistenza poliglotta
Più datastore convivono (Postgres con pgvector, Redis, il vector store): ogni coppia è un confine transazionale spezzato, gestito con fonte di verità unica e code.
vedi la guida sulle transazioni
OIDC
OpenID Connect: login delegato a un provider (Keycloak); il backend verifica la firma del token con le chiavi pubbliche in cache.
Authorization Code + PKCE
ADR
Architecture Decision Record: contesto, opzioni, decisione, conseguenze; numerato; non si modifica, si supera.
docs/adr/0002-soglia-cache-semantica.md
Definition of Done
Lista di condizioni verificabili che chiude una fase; se manca un punto la fase resta aperta anche se il tempo è finito.
nessun placeholder «da misurare» residuo
Eval come cancello di deploy
Nessuna release se la pass rate di memoria o guardrail scende sotto soglia in CI.
eval.yml commenta la PR
Riepilogo

Le fondamenta e sei strati: sette domande, sette numeri

Ogni strato del portfolio risponde a una domanda da colloquio con un meccanismo e con un numero riproducibile da un comando. La tabella è anche una lista di controllo: se di uno strato non sai enunciare la domanda, il meccanismo e il numero, è lì che conviene tornare.

StratoLa domandaIl meccanismo che contaIl numero che lo dimostra
0 · AsyncScrivi Python come un senior backend?Concorrenza strutturata: TaskGroup, Semaphore, timeout, cancellazione; porte e adapterspeedup sequenziale → concorrente (atteso ≈ 3× su tre chiamate), 0 errori pyright strict
1 · GatewayCome gestisci costo e latenza in produzione?Contabilità per token con listino versionato; cache esatta e semantica; breaker, retry con jitter, timeout per fase; tracingp50/p95/p99 a 10/50/200 utenti con e senza cache; costo per 1000 richieste in 3 configurazioni; falsi positivi della cache
2 · RAGCome scegli e valuti una pipeline di retrieval?Chunking × retrieval × DB in un harness; RRF; reranking; citazioni verificate; «non trovato»recall@5, MRR, nDCG@10, latenza e costo per cella; delta di ogni componente in ablation
3 · AgentiCosa c'è dentro un agente, oltre alla chiamata?Macchina a stati pura + side effect separati; budget; errori dei tool come dati; stato persistito e ripresa; MCPtask completati su 20; run interrotte e riprese con esito identico; confronto a mano / LangGraph / PydanticAI
4 · MemoriaCome ricorda cosa ha detto l'utente tre settimane fa?Tre memorie; estrazione con provenienza; consolidamento in coda; punteggio α·rilevanza + β·recency + γ·importanza entro budgetrichiamo a 1/3/5/10 sessioni per 4 configurazioni; token e costo per turno
5 · EvalCome sai che una modifica non ha peggiorato il sistema?Dataset versionati, runner con cache, tre famiglie di scorer, giudice calibrato, soglia in CI; guardrail misurati su recall e falsi positivikappa giudice/umano prima e dopo la calibrazione; pass rate per versione del prompt con IC; falsi positivi dei guardrail
6 · CapstoneRaccontami un sistema portato in produzione da un obiettivo vagoComposizione dei cinque repo; OIDC; deploy con osservabilità; eval come cancello; DESIGN prima, POSTMORTEM dopocosto reale del mese, uptime, retention alla settimana 2, incidenti e MTTR