Venerdì 5 settembre 2025, alle 17, in Finanz, il primo prompt prende il suo voto: 8,97 su 10. Alle 17 una versione riscritta prende 9,03. Alle 17 la stessa versione, senza una riga cambiata, prende 8,74.
Alle 17 il ciclo aveva provato tredici prompt. Alle 17 ho fatto il commit e l'ho chiamato v_0. Nel report finale quei tre minuti non li segnala niente: c'è una tabella di voti, e in fondo un vincitore.
Nella prima risposta giudicata quel pomeriggio, il giudice mette 9 e lascia tre consigli. Uno dice: «Aggiungere casi studio o testimonianze reali». A un prompt che parla di soldi, con un disclaimer obbligatorio e una lista di parole vietate. Il ciclo raccoglieva tutti i consigli e li passava al prompt che riscriveva il prompt.
Il repo si chiama funny-optimizer. Quattro giorni e cinque commit dopo, il notebook era a 631 righe. Del prompt non dico niente: serve una feature che non è ancora uscita. Qui conta il metodo.
Un anno dopo l'ho riaperto. Nel frattempo ho messo Jev dentro Lexi, il mio plugin per Claude Code, per giudicare i diff prima del merge. Rileggendo il notebook ho visto che un anno fa stavo cercando di costruire la stessa cosa, a mano, con gli strumenti sbagliati.
Il ciclo: genera, giudica, riscrivi
L'idea era semplice. Non volevo giudicare il prompt a occhio, leggendo tre risposte e decidendo che "sembrava meglio". Volevo un numero, e un ciclo che lo facesse salire da solo.
Il ciclo aveva quattro passi, tutti sullo stesso modello, claude-3-5-haiku:
- genera una decina di scenari di test, ognuno con un profilo utente e i criteri attesi
- risponde: il prompt in prova riceve ogni scenario e produce la risposta
- giudica: un secondo prompt legge risposta e scenario, e dà un voto da 1 a 10 su sei criteri. Compliance, tono, personalizzazione, esempi numerici, completezza, disclaimer
- riscrive: un terzo prompt riceve i punti deboli e restituisce una configurazione nuova del prompt
Poi si ricomincia, finché il voto medio supera 9,4 o finiscono le iterazioni.
Due cose le avevo fatte bene, e reggono ancora. Dalla seconda versione il dataset era diviso in train e test: sette scenari per ritoccare il prompt, tre tenuti da parte per il voto finale. E ogni prompt provato finiva in uno storico, con la sua versione, il suo voto e tutte le risposte. È grazie a quello storico che oggi posso rileggere i numeri.
Cosa dicono i numeri, riletti oggi
Nel primo giro completo il sistema ha provato 13 versioni del prompt, in tre esecuzioni. I voti vanno da 8,74 a 9,16. La migliore, la 1.0.8, ha preso 9,16. Il target di 9,4 non l'ha mai raggiunto nessuna.
Fin qui sembra un ottimizzatore che lavora. Poi guardi due righe della tabella.
La prima è la 1.0.2 delle 17 e delle 17:21: 9,03 a chiusura della prima esecuzione, 8,74 alla ripartenza della seconda. Stesso prompt, 0,29 punti di differenza. La seconda è la 1.0.6: 8,76, poi 8,88.
Quindi il rumore sullo stesso testo vale 0,29 punti. Lo scarto fra il prompt peggiore e il migliore di tutto il giro vale 0,42. Quasi il 70 % della "crescita" che vedevo poteva essere il giudice di umore diverso.
Il secondo giro, dopo una riscrittura del notebook, conferma il sospetto da un'altra direzione. 8,82 alla prima iterazione, poi 9,14, 9,14, 9,12, 9,14. Tre riscritture del prompt e il numero non si muove. Su una scala da 1 a 10 un modello tende a fermarsi intorno al 9: è un voto buono, e un voto buono non ha motivo di cambiare.
Il "prompt migliore" che il sistema mi consegnava era, con buona probabilità, il prompt che aveva pescato il giudice nel giorno giusto.
Quattro errori che allora non vedevo
Un modello solo faceva da autore e da giudice. Haiku scriveva gli scenari, rispondeva, giudicava e riscriveva. Nel primo giro il giudice girava a temperature=1.0, cioè con la massima variabilità. Un voto dato così non è una misura. È un'opinione, estratta a sorte fra quelle plausibili.
Il voto finale lo sceglieva il modello. Nel README avevo scritto i pesi dei criteri: compliance 30 %, tono 25 %, esempi numerici 20 %, personalizzazione 15 %, completezza 10 %. Nel codice quei pesi non ci sono. Il prompt di valutazione chiedeva al modello un overall_score, e il modello metteva il numero che gli sembrava giusto. Avevo documentato una regola che nessuna riga applicava.
Metà dei criteri non aveva bisogno di un modello. Il disclaimer obbligatorio è una frase fissa: c'è o non c'è. Le parole vietate sono una lista. Sono due controlli su stringa, deterministici e gratuiti. Io li facevo giudicare da un LLM con un voto da 1 a 10. Il disclaimer prendeva 10/10, e quel 10 finiva nella media come se fosse informazione.
L'ultimo commit rompeva il ciclo senza dirlo. Il 9 settembre ho cambiato il modo in cui il prompt viene costruito: build_system_prompt parte dal file di testo iniziale e ci aggiunge le sole note di ottimizzazione. Le regole e il tono che l'ottimizzatore riscriveva a ogni giro finivano nel file di configurazione, e da lì non arrivavano più al modello. Il commit si chiama fixed. L'ho scoperto un anno dopo, rileggendolo per questo articolo.
Nessuno dei quattro è un errore di codice difficile. Sono tutti lo stesso errore: chiedere a un modello di decidere cose che doveva decidere il codice.
Lo stesso lavoro, con Jev
Jev è il modello di TypeSafe che fa una cosa sola: risponde a domande chiuse. Sì o no, un punteggio su una scala definita, una scelta fra opzioni. Ogni risposta arriva con una probabilità calibrata, cioè un numero che dice quanto è sicuro.
Jev non decide niente. Decide il codice, con soglie scritte in un file. È lo schema che uso per la review dentro Lexi: le domande stanno in checks.json, le soglie in policy.json, e il verdetto su un diff lo calcola uno script. Nessuna domanda e nessuna soglia vivono dentro lo script.
Applicato al problema di un anno fa, il giudice diventa tre pezzi. Prima i controlli che non servono a un modello:
ha_disclaimer = DISCLAIMER in risposta
parole_vietate = [p for p in BLACKLIST if p in risposta.lower()]
Poi le domande che un modello deve davvero leggere, scritte chiuse:
{
"promette_rendimento": {
"type": "noul",
"instructions": {"question": "La `risposta` promette o lascia intendere un rendimento, anche senza cifre?"},
"criteria": {
"true": "«con questo metodo i tuoi risparmi crescono», «non puoi sbagliare»",
"false": "scenari ipotetici dichiarati come tali, con i rischi scritti"
}
},
"tono_adatto": {
"type": "noul",
"instructions": {"question": "Il tono della `risposta` è adatto al `profilo` descritto?"}
},
"esempio_numerico": {
"type": "noul",
"instructions": {"question": "La `risposta` contiene almeno un esempio numerico concreto, applicabile al `profilo`?"}
}
}
E le soglie, fuori da tutto:
{"verdicts": [
{"name": "SCARTA", "rules": [{"check": "promette_rendimento", "op": "gte", "value": 0.5}]},
{"name": "RISCRIVI", "rules": [
{"check": "tono_adatto", "op": "lte", "value": 0.5},
{"check": "esempio_numerico", "op": "lte", "value": 0.5}
]},
{"name": "TIENI", "rules": []}
]}
Cambiano tre cose, e sono le tre che mancavano.
Il giudice non è più il modello che scrive. Chi risponde e chi valuta sono due sistemi diversi, e il secondo non ha niente da difendere.
Il voto finale smette di essere un'opinione. Le soglie stanno in un file e si applicano sempre uguali. Se una risposta viene scartata, so quale regola è scattata e con che probabilità.
Un giudizio sbagliato si corregge in un punto preciso. La regola che mi sono dato in Lexi vale anche qui: se Jev sbaglia un caso, quel caso si aggiunge ai criteria della domanda, dalla parte giusta. La soglia non si sposta per far passare l'ultimo esempio. La soglia è il costo che accetti per quell'errore, non una manopola.
Cosa resta delle 631 righe
Il ciclo resta. Generare scenari, far rispondere il prompt, riscriverlo sui punti deboli: quella parte era giusta, e per scrivere servirà sempre un modello.
Se ne va tutto il resto del giudice. evaluate_response, che chiedeva un JSON al modello e sperava di riceverlo. safe_json_parse, che in caso di errore restituiva un dizionario vuoto senza dirlo a nessuno. analyze_weaknesses, che contava i "problemi critici" scritti a parole libere dal giudice, dove due frasi diverse per lo stesso difetto valevano due difetti.
Al loro posto: due righe di Python, un file di domande, un file di soglie, una chiamata HTTP per risposta. Sul costo, il README della review di Jev dà circa 2 secondi e circa $0,002 per chiamata su un diff intero. Una risposta di un assistente è molto più corta di un diff. Sette risposte da giudicare, in parallelo, sono pochi secondi e pochi centesimi per iterazione.
Il lavoro che un anno fa mi ha preso quattro giorni oggi è quasi tutto configurazione. Non perché io scriva codice più in fretta. Perché la parte difficile, un giudice che non cambia idea da un giro all'altro, non la devo più costruire io.
Quello che mi porto via
Un anno fa avevo l'istinto giusto: non fidarmi del mio occhio, misurare. Mi mancava la distinzione che rende la misura una misura.
Un voto da 1 a 10 dato da un modello è un'opinione con un numero sopra. Una misura risponde a domande chiuse, con una probabilità, e lascia la decisione al codice.
Se il giudice cambia idea sullo stesso testo, non stai ottimizzando il prompt. Stai ottimizzando la fortuna.