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.
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.
È 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*.
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())
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.
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.
time.sleep) ferma tutto il servizio.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.
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
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.
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 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.
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).
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.
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.
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.
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.
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.
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.
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.
L'unica regola generale è che il chunk deve portare con sé il contesto (titolo del documento e della sezione nel testo che viene convertito).
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
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.
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.
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.
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à e correttezza non coincidono: una risposta può essere fedele a un chunk sbagliato o superato, e quello è un errore di retrieval, non di generazione.
Protocol) con upsert, search, delete, count; tre adapter (pgvector, Qdrant, FAISS) passano la stessa suite di test di contratto.while True con un prompt: è una macchina a stati con un budget, che deve poter morire e ripartireUn 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.
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.
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.
kill -9 al passo 7, ripresa dal passo 7Lo 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.
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.
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.
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 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.
failed con motivo.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.
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.
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
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.
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.
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.
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.
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
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.
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.
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.
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%
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.
Muovi la soglia del classificatore: a sinistra il traffico legittimo, a destra gli attacchi. Osserva recall e falsi positivi muoversi in direzioni opposte.
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.
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.
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.
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.
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.
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.
| Strato | La domanda | Il meccanismo che conta | Il numero che lo dimostra |
|---|---|---|---|
| 0 · Async | Scrivi Python come un senior backend? | Concorrenza strutturata: TaskGroup, Semaphore, timeout, cancellazione; porte e adapter | speedup sequenziale → concorrente (atteso ≈ 3× su tre chiamate), 0 errori pyright strict |
| 1 · Gateway | Come gestisci costo e latenza in produzione? | Contabilità per token con listino versionato; cache esatta e semantica; breaker, retry con jitter, timeout per fase; tracing | p50/p95/p99 a 10/50/200 utenti con e senza cache; costo per 1000 richieste in 3 configurazioni; falsi positivi della cache |
| 2 · RAG | Come 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 · Agenti | Cosa c'è dentro un agente, oltre alla chiamata? | Macchina a stati pura + side effect separati; budget; errori dei tool come dati; stato persistito e ripresa; MCP | task completati su 20; run interrotte e riprese con esito identico; confronto a mano / LangGraph / PydanticAI |
| 4 · Memoria | Come ricorda cosa ha detto l'utente tre settimane fa? | Tre memorie; estrazione con provenienza; consolidamento in coda; punteggio α·rilevanza + β·recency + γ·importanza entro budget | richiamo a 1/3/5/10 sessioni per 4 configurazioni; token e costo per turno |
| 5 · Eval | Come 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 positivi | kappa giudice/umano prima e dopo la calibrazione; pass rate per versione del prompt con IC; falsi positivi dei guardrail |
| 6 · Capstone | Raccontami un sistema portato in produzione da un obiettivo vago | Composizione dei cinque repo; OIDC; deploy con osservabilità; eval come cancello; DESIGN prima, POSTMORTEM dopo | costo reale del mese, uptime, retention alla settimana 2, incidenti e MTTR |