Un prompt è una specifica: dice cosa serve, in che forma, con quali vincoli e con quali esempi di riferimento. Non è una formula magica e non è una collana di aggettivi: formule come «agisci come un esperto mondiale» non hanno, in questa lezione, nessuna misura che le giustifichi (se vuoi sapere se spostano qualcosa sul tuo compito, c'è il protocollo di misura più sotto). Le leve che propongo sono quattro: contesto, formato, esempi, vincoli. Sopra queste ci sono tecniche con una letteratura alle spalle, ciascuna con un costo in token che conviene conoscere prima di adottarla. E sotto tutto c'è un protocollo di misura, senza il quale ogni modifica a un prompt è una scommessa e non un esperimento.
Che cosa ho eseguito, e che cosa no. Codice provato il 2026-11-12 con Python 3.14.4,
pandas3.0.3 eopenai(client) in un ambiente temporaneo fuori dal repository. Nessun modello è stato chiamato, nemmeno Ollama: dove serve una risposta del modello c'è un sostituto finto, dichiarato come tale, a regole o a copione. Le uscite provano quindi il codice che sta intorno al modello (costruzione del prompt, parametri inviati, misura, controlli, esecuzione) e non la qualità di un modello: ogni numero di accuratezza che vedi sotto è del sostituto, non di un modello vero. Nel codice di produzione l'endpoint è quello compatibile di Ollama (http://localhost:11434/v1) e il modellollama3.1:8b, ma gli stessi prompt valgono su qualunque endpoint; cambia la riga del client. Ogni listato eseguito lo è stato due volte con uscita identica; i listati di una stessa sezione sono pezzi dello stesso file. I riferimenti bibliografici sono ai paper originali su arXiv: dove cito un risultato, cito il lavoro che lo ha misurato.
L'analogia: il brief al collaboratore esterno
Immagina di affidare un lavoro a un collaboratore bravissimo, veloce, che non conosce la tua azienda e che non può farti domande. Hai una sola possibilità: il brief. Se scrivi «fammi una presentazione sui nostri risultati», riceverai qualcosa di generico e formalmente impeccabile. Se scrivi «dieci slide, pubblico: investitori non tecnici, tono sobrio, un dato per slide preso dall'allegato, nessuna proiezione futura, formato 16:9», riceverai una cosa utilizzabile.
Le quattro cose che hai aggiunto non sono stilistiche. Hai aggiunto il contesto (chi è il pubblico), il formato (dieci slide, 16:9, un dato per slide), i vincoli (niente proiezioni, dati dall'allegato) e, se avessi allegato una presentazione precedente, gli esempi. Un modello linguistico è esattamente in questa situazione: non conosce il tuo contesto e non può chiedere. Il prompt è il brief, e vale la stessa regola - la qualità di quello che torna dipende molto dalla precisione di quello che chiedi.
L'analogia regge anche su un punto meno confortante: un brief perfetto non garantisce un lavoro perfetto. Serve comunque controllare, e la parte finale di questa lezione è dedicata a come si controlla quando il lavoro riguarda dati e codice.
Le quattro leve, con il prima e il dopo
Vale la pena vedere le quattro leve applicate allo stesso compito, perché la differenza fra il prima e il dopo è il contenuto operativo di questa lezione.
Prima - una richiesta plausibile che produce un risultato inutilizzabile:
PROMPT_1 = "Analizza questa recensione e dimmi cosa ne pensi: {testo}"
Cosa non funziona: non sai chi leggerà la risposta, quindi il modello sceglie un registro a caso; non sai in che forma tornerà, quindi a valle devi interpretare prosa; non sai cosa fare se la recensione è ambigua, quindi il modello deciderà; e «cosa ne pensi» non è un compito, è un invito a divagare.
Dopo - le quattro leve, una per blocco:
PROMPT_1 = "Analizza questa recensione e dimmi cosa ne pensi: {testo}"
CONTESTO = """\
Sei il sistema di triage delle recensioni di un negozio online. Le tue uscite
vengono lette da un programma, non da una persona, e instradano la recensione
verso il team prodotto, il team logistica o l'archivio."""
FORMATO = """\
Classifica la recensione secondo questo schema JSON, e NIENTE ALTRO:
{{"motivazione": "<max 2 frasi, cita le parole della recensione>",
"tema": "prodotto" | "spedizione" | "assistenza" | "altro",
"sentimento": "positivo" | "neutro" | "negativo",
"azione": "team_prodotto" | "team_logistica" | "archivio" | "revisione_umana"}}"""
ESEMPI = """\
Esempi:
Recensione: "Arrivato in due giorni, imballo perfetto. Il tessuto pizzica."
{{"motivazione": "Loda la spedizione ('due giorni', 'imballo perfetto') ma
critica il tessuto ('pizzica'): il problema è il prodotto.",
"tema": "prodotto", "sentimento": "neutro", "azione": "team_prodotto"}}
Recensione: "Non è mai arrivato e nessuno risponde."
{{"motivazione": "Mancata consegna ('non è mai arrivato') più assenza di
risposta: il problema primario è la spedizione.",
"tema": "spedizione", "sentimento": "negativo", "azione": "team_logistica"}}
Recensione: "ok"
{{"motivazione": "Testo troppo breve per determinare il tema.",
"tema": "altro", "sentimento": "neutro", "azione": "archivio"}}"""
VINCOLI = """\
Vincoli:
- La motivazione viene PRIMA della classificazione e cita le parole della
recensione.
- Se il testo ha contenuto ma non permette di scegliere fra due temi, usa
azione="revisione_umana": non indovinare.
- Se i temi sono due e uno prevale, scegli quello su cui la recensione insiste di più.
- Non inventare dettagli non presenti nel testo.
- Non aggiungere testo fuori dal JSON."""
PROMPT_2 = "\n\n".join([CONTESTO, FORMATO, ESEMPI, VINCOLI, 'Recensione: "{testo}"'])
pezzi = {"contesto": CONTESTO, "formato": FORMATO, "esempi": ESEMPI, "vincoli": VINCOLI}
totale = len(PROMPT_2.format(testo=""))
for nome, testo in pezzi.items():
print(f"{nome:8} {len(testo.format()):4} caratteri")
print(f"PROMPT_1 {len(PROMPT_1.format(testo='')):4} caratteri")
print(f"PROMPT_2 {totale:4} caratteri, di cui esempi {len(ESEMPI.format()) / totale:.0%}")
print(PROMPT_2.format(testo="Bello ma la zip si è rotta").splitlines()[-1])
# Uscita:
# contesto 211 caratteri
# formato 329 caratteri
# esempi 692 caratteri
# vincoli 389 caratteri
# PROMPT_1 50 caratteri
# PROMPT_2 1643 caratteri, di cui esempi 42%
# Recensione: "Bello ma la zip si è rotta"
Il prompt è ora costruito a pezzi, uno per leva, e le quattro variabili si leggono una per una; l'uscita dice quanto pesa ciascuna: contesto, formato e vincoli stanno in circa 930 caratteri, gli esempi da soli ne occupano 692 (il 42% del totale di 1643). È una misura in caratteri, non in token, e serve a mostrare l'ordine di grandezza del costo degli esempi, che si paga a ogni chiamata. Quattro osservazioni su cosa è cambiato, e perché.
Il contesto dice a cosa serve l'output, non solo chi lo legge. «Le tue uscite vengono lette da un programma e instradano la recensione» è un'informazione diversa da «rispondi in modo professionale»: definisce la conseguenza di una risposta sbagliata. Non ho misurato quanto cambi il comportamento di un modello vero; è il motivo per cui lo scrivo così, e il protocollo di misura serve a verificarlo sul tuo compito.
Il formato usa un enum, non una descrizione. Elencare i valori ammessi fra barre verticali chiude lo spazio delle risposte. È la versione «in prosa» del Literal della lezione sull'output strutturato, e quando l'endpoint supporta uno schema forzato si fa meglio con quello, ma il prompt deve dirlo comunque, perché descrive anche il criterio di scelta e non solo i valori.
Gli esempi coprono i casi ambigui, non quelli facili. Il primo esempio è un caso misto (loda la spedizione, critica il prodotto) e mostra la regola di precedenza in azione. Il terzo è un caso degenere («ok») e mostra cosa fare quando non c'è informazione. Un esempio facile insegna poco; un esempio limite mostra una regola che una frase fatica a esprimere (è il ragionamento che seguo, non una misura). Nei vincoli ho tolto una contraddizione della versione precedente: obbligare a citare e insieme prevedere un esempio che non cita nulla («ok»); ora la citazione vale quando c'è qualcosa da citare, e «revisione_umana» è riservato ai testi con contenuto ma ambigui.
La motivazione viene prima della classificazione. È la stessa scelta della lezione sull'output strutturato, e la ragione è la stessa: i modelli generano da sinistra a destra, quindi un campo collocato prima della decisione la influenza, e uno collocato dopo la giustifica. Qui c'è un effetto in più: l'obbligo di citare le parole della recensione rende la risposta verificabile - se la citazione non è nel testo, la classificazione non si appoggia a nulla, e lo scopri con una ricerca di stringa.
I parametri di decodifica: quali toccare
Le quattro leve riguardano cosa chiedi. I parametri di decodifica riguardano come il modello genera, e sono pochi quelli che contano.
from openai import OpenAI
client = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama")
def classifica(testo: str) -> str:
r = client.chat.completions.create(
model="llama3.1:8b",
messages=[{"role": "user", "content": PROMPT_2.format(testo=testo)}],
temperature=0.0, # compito di classificazione: nessuna creatività
max_tokens=300, # tetto duro: evita divagazioni e costi a sorpresa
stop=["\nRecensione:"], # tronca se il modello prova a inventarsi un altro caso
)
return r.choices[0].message.content
# Uscita:
# {"tema": "prodotto"}
# {'model': 'llama3.1:8b', 'max_tokens': 300, 'stop': ['\nRecensione:'], 'temperature': 0.0}
# 1 messaggio, 1669 caratteri
Per provare il listato senza un server ho dato al client un trasporto simulato che registra la richiesta; l'uscita ha tre righe, scritte dal collaudo che circonda il listato: la risposta del sostituto, il corpo inviato (escluso il testo del prompt) e la lunghezza del messaggio, 1669 caratteri. I parametri partono come li ho scritti: temperature 0.0, max_tokens 300 e la sequenza di arresto. La pagina di compatibilità di Ollama elenca stop, temperature, top_p e max_tokens fra i campi supportati dell'endpoint /v1/chat/completions; che cosa ne faccia un modello vero non l'ho provato. top_p non compare nel listato, di proposito (vedi sotto).
temperature controlla quanto la generazione si allontana dal token più probabile. A zero il modello prende sempre il più probabile: è quello che vuoi per classificare, estrarre, decidere. Valori intorno a 0,7-1,0 sono una convenzione d'uso per scrivere testo in cui la varietà è un valore, non una soglia misurata. Il punto che si confonde: temperature alta non rende il modello «più creativo» nel senso di più intelligente, lo rende meno prevedibile, e su un compito deterministico questo è solo rumore.
top_p limita la scelta alla porzione più probabile della distribuzione. È un altro modo di controllare la stessa cosa, e la regola pratica è: regolane uno, non entrambi: è una regola di prudenza, perché muoverli insieme produce comportamenti difficili da attribuire. Con temperature=0 la scelta è sempre il token più probabile e top_p non cambia l'esito, quindi lasciarlo al valore predefinito è la scelta più pulita.
max_tokens è un tetto duro sulla lunghezza dell'uscita, e va messo sempre. Non per la qualità: per il costo e per la latenza. Un modello che divaga costa e fa aspettare.
stop è la sequenza che interrompe la generazione. È sottovalutata e molto utile con i prompt few-shot: un modello che ha visto tre coppie «Recensione → JSON» può continuare inventandosi una quarta recensione. Mettere "\nRecensione:" fra le sequenze di arresto chiude il problema in un parametro.
Il protocollo di misura: come si sa se una modifica ha funzionato
Questa sezione è la ragione per cui il resto della lezione ha senso. Senza una misura, «ho migliorato il prompt» significa «la risposta che ho guardato mi è piaciuta più di prima», che su un sistema probabilistico non è informazione.
Serve poco: venti o trenta casi reali con l'esito corretto annotato a mano, e un ciclo che confronta due versioni.
import json, statistics
from dataclasses import dataclass
@dataclass(frozen=True)
class Caso:
testo: str
tema: str
azione: str
CASI: list[Caso] = [
Caso("Arrivato rotto, confezione schiacciata", "spedizione", "team_logistica"),
Caso("Bellissimo ma la zip si è aperta subito", "prodotto", "team_prodotto"),
Caso("ok", "altro", "archivio"),
Caso("Non è mai arrivato e nessuno risponde", "spedizione", "team_logistica"),
Caso("Il tessuto è ruvido, la taglia corretta", "prodotto", "team_prodotto"),
Caso("Taglia giusta, ma il corriere ha lasciato il pacco sotto la pioggia", "spedizione", "team_logistica"),
Caso("Ottimo acquisto, consigliato", "prodotto", "archivio"),
Caso("Ho scritto tre volte al servizio clienti, mai una risposta", "assistenza", "revisione_umana"),
# ... in un caso vero: venti o trenta, presi dai casi reali e annotati da te
]
# --- sostituti per la prova: nel tuo codice sono la chiamata al modello e l'estrazione del JSON
chiamate = 0
def chiama_modello(prompt: str) -> str:
"""Modello FINTO: regole a parole chiave, con un errore di formato ogni 11 chiamate."""
global chiamate
chiamate += 1
if prompt.startswith("Analizza"):
return "Mi sembra una recensione nel complesso positiva, con qualche riserva."
if chiamate % 11 == 0:
return "Ecco la classificazione richiesta: purtroppo non riesco a decidere."
t = prompt.rsplit('Recensione: "', 1)[1].lower()
if t.startswith("ok"):
tema, azione = "altro", "archivio"
elif "non è mai arrivato" in t or "arrivato rotto" in t:
tema, azione = "spedizione", "team_logistica"
elif "servizio clienti" in t:
tema, azione = "assistenza", "revisione_umana"
elif "taglia" in t or "zip" in t or "tessuto" in t:
tema, azione = "prodotto", "team_prodotto" # sbaglia il caso della pioggia
else:
tema, azione = "prodotto", "team_prodotto"
return json.dumps({"motivazione": "x", "tema": tema, "sentimento": "neutro", "azione": azione})
def estrai_json(testo: str) -> str:
i, j = testo.find("{"), testo.rfind("}")
return testo[i:j + 1] if 0 <= i < j else testo
# --- il protocollo di misura
def valuta(prompt: str, casi: list[Caso], passate: int = 3) -> dict:
"""Esegue ogni caso 'passate' volte. Restituisce media, spread e conformità."""
punteggi_tema, punteggi_azione, conformi = [], [], []
for _ in range(passate):
t = a = c = 0
for caso in casi:
grezzo = chiama_modello(prompt.format(testo=caso.testo))
try:
d = json.loads(estrai_json(grezzo))
except Exception:
continue # non conforme: non conta come corretto
c += 1
t += int(d.get("tema") == caso.tema)
a += int(d.get("azione") == caso.azione)
n = len(casi)
punteggi_tema.append(t / n); punteggi_azione.append(a / n); conformi.append(c / n)
return {
"conformita": round(statistics.mean(conformi), 3), # il formato tiene?
"tema": round(statistics.mean(punteggi_tema), 3), # classifica bene?
"azione": round(statistics.mean(punteggi_azione), 3), # instrada bene?
"spread_azione": round(max(punteggi_azione) - min(punteggi_azione), 3),
}
print("v1:", valuta(PROMPT_1, CASI))
print("v2:", valuta(PROMPT_2, CASI))
# Uscita:
# v1: {'conformita': 0.0, 'tema': 0.0, 'azione': 0.0, 'spread_azione': 0.0}
# v2: {'conformita': 0.917, 'tema': 0.792, 'azione': 0.667, 'spread_azione': 0.125}
L'uscita si legge così. La versione 1 (nessuna delle quattro leve) restituisce prosa: conformità 0, quindi tema e azione a zero, perché a valle nessun programma saprebbe leggerla. La versione 2 ha conformità 0,917 (il sostituto sbaglia il formato una volta ogni undici chiamate, per costruzione), accuratezza sul tema 0,792 e sull'azione 0,667, e uno spread di 0,125 fra le tre passate: lo stesso prompt, sugli stessi otto casi, ha dato punteggi diversi solo perché in passate diverse l'errore di formato è caduto su casi diversi. Tutto questo è del sostituto: dimostra che il metodo separa conformità, correttezza e variabilità, non che il prompt 2 sia buono. Quattro regole che rendono questo confronto onesto, e che sono più importanti del codice.
Cambia una leva per volta. Se nella stessa versione aggiungi gli esempi e riscrivi i vincoli, saprai che è migliorato e non saprai perché, e la prossima volta rifarai entrambe le cose, anche quella inutile, pagandola su ogni chiamata.
Ripeti ogni caso più volte e guarda lo spread. Anche con temperature=0 resta una variabilità, per le ragioni viste nella lezione sulle trappole. Una differenza di tre punti fra due versioni su una singola passata non è una differenza: è rumore.
Tieni i casi separati dallo sviluppo. Se aggiusti il prompt guardando gli stessi venti casi, dopo dieci iterazioni il prompt è cucito su quei venti casi e la misura non predice più niente. La pratica minima è tenere un secondo insieme di dieci casi che guardi solo alla fine.
Misura la conformità separatamente dalla correttezza. Un prompt che producesse JSON valido nel 70% dei casi e classificasse perfettamente quel 70% (esempio ipotetico) è un prompt rotto, e un punteggio aggregato lo nasconderebbe.
Le tecniche avanzate: cosa dicono i paper, e cosa costano
Oltre le quattro leve ci sono tecniche con una letteratura alle spalle. Vale la pena conoscerle con il loro meccanismo e il loro prezzo, perché la differenza fra applicarle a proposito e applicarle a caso è grande.
Few-shot: esempi nel prompt
Mettere alcuni casi svolti nel prompt è la tecnica documentata da Brown e colleghi (Language Models are Few-Shot Learners, arXiv:2005.14165), il lavoro che ha reso popolare l'idea che un modello possa adattarsi a un compito dagli esempi nel contesto, senza riaddestramento. Meccanismo: gli esempi definiscono il compito in modo operativo, mostrando ciò che una descrizione non riesce a dire. Costo: alto e ricorrente: nel prompt di questa lezione gli esempi sono il 42% dei caratteri (misurato sopra) e si pagano su ogni chiamata. Quando conviene: quando il formato è particolare, quando lo stile conta, e soprattutto quando esistono casi ambigui la cui gestione va mostrata. Quando no: quando una frase di vincolo ottiene lo stesso effetto. La verifica è quella del protocollo di misura: togli gli esempi e misura.
Catena di pensiero (chain of thought)
Chiedere al modello di esplicitare i passaggi prima della risposta è la tecnica di Wei e colleghi (Chain-of-Thought Prompting Elicits Reasoning in Large Language Models, arXiv:2201.11903). Esiste anche nella variante «a costo zero» di Kojima e colleghi (Large Language Models are Zero-Shot Reasoners, arXiv:2205.11916), che ottiene un effetto simile con la sola aggiunta di «ragioniamo passo per passo» senza esempi. Meccanismo: la risposta viene generata dopo i passaggi, quindi i passaggi fanno parte del contesto su cui si basa. Costo: i token del ragionamento, in uscita, su ogni chiamata. Quanto pesi dipende dalla lunghezza del ragionamento e dal compito: misuralo sul tuo, non fidarti di un numero generico. Quando conviene: aritmetica, problemi a più passi, scelte con condizioni intrecciate. Quando no: classificazione, estrazione, compiti in cui la risposta non richiede passaggi - lì il ragionamento è costo sicuro con beneficio incerto: non c'è ragione di aspettarsi un guadagno, e su un compito vero va misurato se il risultato peggiora.
Nota pratica importante: con un output strutturato, la catena di pensiero si implementa come un campo prima della decisione, non come testo libero. È la ragione del campo motivazione nel prompt all'inizio di questa lezione.
Autoconsistenza (self-consistency)
Generare più risposte indipendenti e prendere la più frequente è la tecnica di Wang e colleghi (Self-Consistency Improves Chain of Thought Reasoning in Language Models, arXiv:2203.11171). Meccanismo: l'intuizione del lavoro è che un problema complesso ammette più percorsi di ragionamento che portano alla stessa risposta corretta, mentre gli errori tendono a disperdersi; con temperatura non nulla i percorsi differiscono, e il voto di maggioranza sfrutta questa asimmetria. Costo: k volte tutto, dove k è il numero di campioni: è la tecnica più costosa dell'elenco. Quando conviene: su un sottoinsieme di casi ad alto valore, dove sbagliare costa più di cinque chiamate.
from collections import Counter
def con_autoconsistenza(prompt: str, k: int = 5) -> dict:
"""Vota fra k risposte. Costa k volte; usalo solo dove serve."""
risposte = [estrai_decisione(chiama_modello(prompt, temperature=0.8))
for _ in range(k)]
conteggio = Counter(r for r in risposte if r is not None)
if not conteggio:
return {"decisione": None, "accordo": 0.0}
decisione, quante = conteggio.most_common(1)[0]
return {"decisione": decisione,
"accordo": quante / k, # 1.0 = unanimità
"alternative": dict(conteggio)}
def instrada(esito: dict, soglia: float = 0.8) -> str:
"""Con 5 campioni: 4-1 passa (0,8), 3-2 no (0,6)."""
if esito["decisione"] is None or esito["accordo"] < soglia:
return "revisione_umana"
return esito["decisione"]
Il listato ha due funzioni: una vota, l'altra decide dove mandare il caso. Nella versione che avevo scritto in origine il commento diceva «accordo < 0.6 → revisione umana», ma con cinque campioni il 3-2 vale esattamente 0,6 e non sarebbe mai stato rimandato: la soglia giusta per intercettarlo è 0,8. Provato con quattro copioni di risposte (nessun modello):
# Uscita:
# unanime {'decisione': 'a', 'accordo': 1.0, 'alternative': {'a': 5}} -> a
# 4-1 {'decisione': 'a', 'accordo': 0.8, 'alternative': {'a': 4, 'b': 1}} -> a
# 3-2 {'decisione': 'a', 'accordo': 0.6, 'alternative': {'a': 3, 'b': 2}} -> revisione_umana
# illeggibile {'decisione': None, 'accordo': 0.0} -> revisione_umana
Il guadagno più grande di questa tecnica non è la risposta più frequente: è il grado di accordo, che è una misura di incertezza che il modello normalmente non ti dà. Un caso su cui cinque campioni si dividono 3-2 è un caso su cui non dovresti automatizzare, e saperlo vale più della decisione.
Decomposizione (least-to-most)
Spezzare un problema in sotto-problemi e risolverli in ordine, usando le risposte precedenti, è la tecnica di Zhou e colleghi (Least-to-Most Prompting Enables Complex Reasoning in Large Language Models, arXiv:2205.10625). Meccanismo: ogni passo è più semplice del problema intero, e le risposte intermedie diventano contesto verificabile. Costo: una chiamata per sotto-problema. Quando conviene: quando tu conosci la decomposizione: e in quel caso la scrivi nel codice, con una catena di chiamate, invece di chiederla al modello. È la connessione con la prima lezione del modulo: se la sequenza è nota, è una pipeline.
ReAct e riflessione
ReAct (Yao et al., arXiv:2210.03629) alterna ragionamento e azione verso un ambiente esterno: è l'ossatura degli agenti di tutto il modulo 09, e qui la cito per chiudere il cerchio - la tecnica di prompting e l'architettura agentica sono la stessa idea a due livelli di astrazione. Reflexion (Shinn et al., arXiv:2303.11366) aggiunge un passo di critica del proprio tentativo, che diventa contesto per il tentativo successivo.
Su quest'ultima serve un avvertimento preciso, perché è la tecnica che si adotta con più entusiasmo e più spesso senza beneficio. Il lavoro di Huang e colleghi (Large Language Models Cannot Self-Correct Reasoning Yet, arXiv:2310.01798) documenta che, in assenza di un segnale esterno di correttezza, l'autocorrezione può peggiorare la risposta: il modello cambia una risposta giusta perché gli è stato chiesto di rivederla. La conseguenza operativa è netta: la riflessione funziona quando c'è qualcosa di oggettivo da cui ripartire - l'uscita di un compilatore, un test che fallisce, una specifica scritta, un confronto con una fonte. Non funziona come «pensaci meglio».
La tabella dei costi, per decidere
def car(prompt: str) -> int:
return len(prompt.format(testo=""))
senza_esempi = "\n\n".join([CONTESTO, FORMATO, VINCOLI, 'Recensione: "{testo}"'])
base, con_esempi = car(senza_esempi), car(PROMPT_2)
K = 5
righe = [
("tecnica", "ingresso (caratteri)", "uscita"),
("compito nudo (PROMPT_1)", car(PROMPT_1), "quella richiesta"),
("tre leve: contesto, formato, vincoli", base, "quella richiesta"),
("con tre esempi (PROMPT_2)", f"{con_esempi} (+{con_esempi - base})", "quella richiesta"),
("catena di pensiero", "invariato", "più lunga: da misurare sul tuo compito"),
(f"autoconsistenza, k={K}", f"{K} x {con_esempi} = {K * con_esempi}", f"{K} x"),
]
for r in righe:
print(f"{r[0]:40} {str(r[1]):26} {r[2]}")
# Uscita:
# tecnica ingresso (caratteri) uscita
# compito nudo (PROMPT_1) 50 quella richiesta
# tre leve: contesto, formato, vincoli 949 quella richiesta
# con tre esempi (PROMPT_2) 1643 (+694) quella richiesta
# catena di pensiero invariato più lunga: da misurare sul tuo compito
# autoconsistenza, k=5 5 x 1643 = 8215 5 x
Le cifre dell'uscita sono caratteri misurati sul prompt di questa lezione, non token, e valgono per questo prompt; i moltiplicatori (5 x per la autoconsistenza con k=5) vengono dalla definizione della tecnica. La riflessione non compare: il suo costo dipende da quanti tentativi concedi, e il beneficio dal criterio esterno che hai a disposizione. La lettura è l'unica raccomandazione generale che mi sento di dare sul prompt engineering: le quattro leve costano poco e vanno sempre; tutto il resto è un moltiplicatore che va giustificato con una misura.
GenAI sui dati: il pattern codice-come-risposta
Qui si passa dalla tecnica all'applicazione, e c'è una regola che vale da sola tutta la sezione: un modello non deve mai calcolare un numero. Deve scrivere il codice che lo calcola.
La ragione è che le due cose hanno garanzie incomparabili (è un ragionamento di progetto, non una misura su un modello specifico). «Quanto fa la media di questa colonna?» produce un numero plausibile e non verificabile, generato da un sistema che non sa fare l'aritmetica ma sa che forma hanno i numeri. «Scrivi il codice che calcola la media di questa colonna» produce codice che tu esegui: il numero viene da pandas, e se il codice è sbagliato lo vedi perché solleva un'eccezione o perché il risultato non supera un controllo.
import pandas as pd
PROMPT_ANALISI = """\
Sei un assistente di analisi dati. Scrivi codice Python, non risposte in prosa.
Il DataFrame si chiama `df` ed esiste già. Il suo schema è:
{schema}
Vincoli non negoziabili:
- NON inventare nomi di colonna: usa solo quelli dello schema sopra.
- NON scrivere numeri nel codice presi "a occhio" dai dati.
- Assegna il risultato finale alla variabile `risultato`.
- Se la domanda non è rispondibile con queste colonne, scrivi
`risultato = "NON RISPONDIBILE: " + motivo` e nient'altro.
- Nessun import oltre pandas (già importato come pd) e numpy (come np).
Domanda: {domanda}
Rispondi SOLO con il codice, senza spiegazioni e senza blocchi markdown.
"""
def descrivi_schema(df: pd.DataFrame, righe_esempio: int = 2) -> str:
"""Lo schema REALE, generato dai dati: il modello non deve indovinare le colonne."""
righe = [f"- {c}: {t}" for c, t in df.dtypes.astype(str).items()]
return ("colonne:\n" + "\n".join(righe) +
f"\n\nrighe totali: {len(df)}\n\nprime {righe_esempio} righe:\n"
+ df.head(righe_esempio).to_string())
Lo schema generato dai dati è la riga più importante di questo blocco. È la pratica che in letteratura sui sistemi text-to-SQL si chiama schema linking, e risolve il difetto più comune delle analisi assistite: il modello inventa un nome di colonna plausibile (data_ordine invece di ordine_data) e il codice fallisce - o peggio, usa una colonna esistente ma sbagliata e produce un numero credibile.
L'esecuzione richiede altrettanta disciplina. Ho riscritto il listato dopo averlo provato, per un difetto che la prima versione nascondeva: passare a exec due dizionari separati (nomi globali e nomi locali) rende il codice generato incapace di usare df dentro una lambda o una comprensione, perché lì i nomi si cercano nei globali. La versione sotto usa un solo dizionario.
import builtins
import re
import numpy as np
import pandas as pd
BUILTIN_CONSENTITI = {n: getattr(builtins, n) for n in (
"len", "sum", "min", "max", "round", "abs", "sorted", "range", "float", "int", "str",
"list", "dict", "set", "tuple", "bool", "enumerate", "zip", "isinstance")}
VIETATI = ("import os", "import sys", "import subprocess", "open(",
"eval(", "exec(", "__import__", "to_csv", "to_sql")
def ripulisci(codice: str) -> str:
"""Toglie gli eventuali recinti markdown attorno al codice."""
return re.sub(r"^```(?:python)?\s*|\s*```$", "", codice.strip())
def esegui_analisi(df: pd.DataFrame, domanda: str) -> dict:
codice = ripulisci(chiama_modello(PROMPT_ANALISI.format(
schema=descrivi_schema(df), domanda=domanda), temperature=0.0))
# 1. controlli statici PRIMA di eseguire
trovati = [v for v in VIETATI if v in codice]
if trovati:
return {"ok": False, "errore": "codice_non_consentito", "costrutti": trovati}
# 2. nomi di colonna: ogni df['...'] deve esistere nel DataFrame
citate = set(re.findall(r"""df\[['"]([^'"]+)['"]\]""", codice))
inesistenti = sorted(citate - set(df.columns))
if inesistenti:
return {"ok": False, "errore": "colonne_inesistenti", "colonne": inesistenti}
# 3. esecuzione su una COPIA, con builtin ridotti, in UN SOLO dizionario di nomi
spazio = {"__builtins__": BUILTIN_CONSENTITI, "df": df.copy(), "pd": pd, "np": np}
try:
exec(codice, spazio)
except Exception as e:
return {"ok": False, "errore": f"{type(e).__name__}: {e}", "codice": codice}
return {"ok": True, "risultato": spazio.get("risultato"), "codice": codice, "spazio": spazio}
Ho provato il listato con un modello finto che restituisce codice scritto da me, su un DataFrame di cinque righe. Prima lo schema generato dai dati, poi cinque scenari, poi la prova del dizionario unico, poi il caso della somma vuota:
import os, tempfile
df = pd.DataFrame({"categoria": ["libri", "libri", "giochi", "giochi", "giochi"],
"importo": [10, 20, 5, 15, 30]})
print(descrivi_schema(df))
print("---")
def prova(nome, codice):
globals()["chiama_modello"] = lambda prompt, temperature: codice
r = esegui_analisi(df, "domanda")
r.pop("spazio", None); r.pop("codice", None)
print(f"{nome:22}", r)
prova("corretto", "```python\nrisultato = df.groupby('categoria')['importo'].sum().idxmax()\n```")
prova("open()", "risultato = open('/etc/hostname').read()")
prova("colonna inventata", "risultato = df['prezzo'].sum()")
prova("import con 2 spazi", "import os\nrisultato = os.getcwd()")
tmp = tempfile.mkdtemp(); os.chdir(tmp)
open("segreto.csv", "w").write("a,b\nQUESTO_NON_DOVREBBE_USCIRE,2\n")
prova("pd.read_csv", "risultato = pd.read_csv('segreto.csv').iloc[0, 0]")
print("---")
codice = "risultato = int(df['importo'].apply(lambda v: v > df['importo'].mean()).sum())"
for nome, ns in {"due dizionari": ({"__builtins__": BUILTIN_CONSENTITI}, {"df": df, "pd": pd}),
"uno solo": ({"__builtins__": BUILTIN_CONSENTITI, "df": df, "pd": pd}, None)}.items():
try:
g, l = ns
exec(codice, g, l) if l is not None else exec(codice, g)
print(f"{nome:14}", (l or g)["risultato"])
except Exception as e:
print(f"{nome:14}", type(e).__name__ + ":", e)
print("---")
vuoto = df[df["categoria"] == "Libri"] # 'Libri' con la maiuscola: nessuna riga
print("somma:", vuoto["importo"].sum(), "| media:", vuoto["importo"].mean(), "| righe:", len(vuoto))
# Uscita:
# colonne:
# - categoria: str
# - importo: int64
#
# righe totali: 5
#
# prime 2 righe:
# categoria importo
# 0 libri 10
# 1 libri 20
# ---
# corretto {'ok': True, 'risultato': 'giochi'}
# open() {'ok': False, 'errore': 'codice_non_consentito', 'costrutti': ['open(']}
# colonna inventata {'ok': False, 'errore': 'colonne_inesistenti', 'colonne': ['prezzo']}
# import con 2 spazi {'ok': False, 'errore': 'ImportError: __import__ not found'}
# pd.read_csv {'ok': True, 'risultato': 'QUESTO_NON_DOVREBBE_USCIRE'}
# ---
# due dizionari NameError: name 'df' is not defined
# uno solo 2
# ---
# somma: 0 | media: nan | righe: 0
L'uscita si legge in tre parti. Primo, i tre livelli di difesa funzionano sui casi che prevedevano: il codice corretto restituisce «giochi», quello con open( è fermato dal controllo statico, la colonna inventata prezzo dal controllo sui nomi. Secondo, i limiti: import os con due spazi non è catturato dalla ricerca di sottostringhe, ed è fermato solo dall'esecuzione («__import__ not found»); e soprattutto pd.read_csv('segreto.csv') passa tutti i controlli e legge un file, perché pd è nello spazio dei nomi. Terzo, la prova del dizionario unico: con due dizionari il codice con la lambda dà NameError, con uno solo restituisce 2 (due importi sopra la media); la documentazione di Python lo spiega, perché con due oggetti il codice è eseguito «come se fosse dentro la definizione di una classe». L'ultima riga mostra il caso della somma su zero righe, che uso più sotto.
Tre livelli di difesa, e ognuno ferma una cosa diversa. I controlli statici fermano il codice che tocca il filesystem o la rete: non perché il modello sia malevolo, ma perché potrebbe aver letto un'istruzione ostile in un campo dei dati - è la prompt injection della lezione sulle trappole, con i dati come vettore. Il controllo sui nomi di colonna cattura l'errore più frequente prima che produca un risultato sbagliato. L'esecuzione su una copia e in uno spazio di nomi ristretto limita il danno di ciò che passa i primi due.
Va detto chiaramente, perché è una responsabilità che non si delega: exec con un dizionario di builtin ridotti non è una sandbox, e l'uscita sopra lo dimostra con pd.read_csv. È una riduzione della superficie, utile e insufficiente. Il controllo sui nomi di colonna ha anch'esso un limite: guarda solo df['nome'], non df.nome né df[['a', 'b']]; quel che sfugge viene fermato, al più, da un'eccezione del terzo passo. Se il codice generato gira su dati o su una macchina che contano, serve un isolamento vero: un processo separato con permessi minimi, un contenitore, o un ambiente dedicato con limiti di memoria e di tempo.
Il controllo che smaschera un'analisi inventata
Anche con il codice eseguito, resta una domanda: come fai a sapere che il codice risponde alla tua domanda e non a un'altra? Il controllo più efficace costa poco ed è questo: chiedi che il codice produca anche i numeri di controllo, e verificali tu.
# aggiunta al prompt
AGGIUNTA = """\
Oltre a `risultato`, assegna sempre:
- `n_righe_usate`: quante righe del DataFrame entrano nel calcolo finale
- `filtri_applicati`: lista di stringhe che descrive ogni filtro applicato
"""
# verifica dopo l'esecuzione
def coerente(df, spazio: dict) -> list[str]:
allarmi = []
n = spazio.get("n_righe_usate")
if n is None:
allarmi.append("il codice non dichiara quante righe ha usato")
elif n == 0:
allarmi.append("zero righe usate: il risultato non si appoggia a nessun dato")
elif n == len(df) and spazio.get("filtri_applicati"):
allarmi.append("dichiara filtri ma usa tutte le righe: filtro non applicato?")
return allarmi
Provato su quattro codici scritti a mano (stesso DataFrame, stesso modello finto):
import pandas as pd
df = pd.DataFrame({"categoria": ["libri", "libri", "giochi", "giochi", "giochi"], "importo": [10, 20, 5, 15, 30]})
def prova(nome, codice):
globals()["chiama_modello"] = lambda prompt, temperature: codice
r = esegui_analisi(df, "domanda")
print(f"{nome:26} risultato={r['risultato']!r:6} allarmi={coerente(df, r['spazio'])}")
prova("filtro giusto", "d = df[df['categoria'] == 'libri']\nrisultato = int(d['importo'].sum())\nn_righe_usate = len(d)\nfiltri_applicati = ['categoria == libri']")
prova("filtro sbagliato (Libri)", "d = df[df['categoria'] == 'Libri']\nrisultato = int(d['importo'].sum())\nn_righe_usate = len(d)\nfiltri_applicati = ['categoria == Libri']")
prova("filtro dichiarato, non usato", "risultato = int(df['importo'].sum())\nn_righe_usate = len(df)\nfiltri_applicati = ['categoria == libri']")
prova("numeri di controllo assenti", "risultato = int(df['importo'].sum())")
# Uscita:
# filtro giusto risultato=30 allarmi=[]
# filtro sbagliato (Libri) risultato=0 allarmi=['zero righe usate: il risultato non si appoggia a nessun dato']
# filtro dichiarato, non usato risultato=80 allarmi=['dichiara filtri ma usa tutte le righe: filtro non applicato?']
# numeri di controllo assenti risultato=80 allarmi=['il codice non dichiara quante righe ha usato']
Il caso n == 0 è quello che vale la pena cercare sempre: un filtro troppo stretto (un nome di categoria scritto diversamente, una data in un altro formato) produce un DataFrame vuoto, e una media su zero righe diventa NaN o solleva - ma una somma su zero righe diventa zero (misurato nell'ultima riga dell'uscita precedente: somma 0, media nan), e zero è un numero perfettamente credibile da riportare in una riunione. Nella prova il secondo caso è esattamente questo: il filtro scritto con «Libri» invece di «libri» dà 0 righe e il controllo lo segnala. Il terzo e il quarto mostrano gli altri due allarmi: filtri dichiarati ma tutte le righe usate, e numeri di controllo assenti.
GenAI sul codice: dove conviene e dove costa
L'ultima parte riguarda l'uso quotidiano, ed è quella su cui ho più da dire in termini di criterio e meno in termini di tecnica, perché la tecnica è quella di sopra e il criterio è tutto. Quel che segue è la mia valutazione di progetto, non una misura.
Il modello è forte dove il codice è prevedibile e debole dove serve capire un sistema. Questa frase sola spiega la mappa.
Dove il valore è alto e il rischio basso - il codice che avresti scritto tu allo stesso modo. Conversioni di formato, boilerplate di configurazione, test parametrici su una funzione pura, migrazioni meccaniche, espressioni regolari con esempi di riferimento, documentazione di una funzione che si legge interamente. Qui la verifica è immediata: il test passa o no, l'espressione regolare cattura i casi che hai elencato o no.
Dove il valore è alto e il rischio medio - il codice che devi leggere con attenzione. Una funzione nuova con una firma che hai definito tu, una query che poi verifichi su dati reali, un refactoring locale e circoscritto. Qui la regola è una: se non sei in grado di rivedere quel codice, non lo stai usando, lo stai adottando. E adottare codice che non sai valutare è debito tecnico contratto in un momento di ottimismo.
Dove il rischio è alto - tutto ciò che richiede di sapere cose che non sono nel contesto. Modifiche che attraversano più moduli, scelte architetturali, codice su un vincolo di dominio implicito («qui l'IVA si calcola così per una ragione che sta in una mail del 2019»), e soprattutto la sicurezza: un modello produce con la stessa sicurezza una validazione corretta e una che sembra corretta.
Il loop di verifica non è negoziabile, e la sua forma è questa:
- Scrivi tu la firma e i test, prima del codice. Il contratto lo definisci tu.
- Chiedi l'implementazione, con i test nel prompt. Il modello ha un bersaglio.
- Esegui i test. Se sono rossi, passa al modello l'errore vero: è il criterio esterno di cui parla arXiv:2310.01798.
- Con i test verdi, leggi il codice. Se non lo capisci, riscrivilo.
- Aggiungi un test per il caso limite che il modello ha ignorato.
Il passo 1 è quello che si salta e che fa la differenza. Chiedere l'implementazione e i test allo stesso modello significa farsi scrivere il compito e la correzione dalla stessa mano: i test passeranno, perché sono stati scritti per quel codice. I test scritti prima, da te, sono il segnale esterno di correttezza che manca all'autocorrezione - ed è esattamente la condizione che il lavoro di Huang e colleghi indica come necessaria.
Sui costi nascosti, tre voci che nessuno misura (e che qui nomino senza misurarle) e che vale la pena tenere presenti. Il tempo di revisione: leggere e validare codice che non hai scritto può essere più lento che leggere il proprio, e su un blocco lungo può superare il tempo di scrittura. Il debito da codice plausibile: un'implementazione che funziona sui casi provati e sbaglia su un caso limite non verificato è peggio di nessuna implementazione, perché nessuno la sospetterà. E l'erosione della conoscenza: codice che nessuno ha ragionato è codice che nessuno saprà cambiare fra sei mesi : un costo che si paga tutto in una volta, nel momento peggiore.
Trade-off
- Prompt lungo contro costo per chiamata. Ogni token del prompt si paga a ogni chiamata, e in una conversazione a ogni turno rimanente. Vale la riga che elimina un errore ricorrente, non il paragrafo che ripete un concetto.
- Vincoli stretti contro casi che non avevi previsto. Un enum chiuso e un formato rigido eliminano le risposte sbagliate e anche quelle corrette che non avevi immaginato. Mitigazione: un valore «altro» o «revisione umana» che dia al modello un posto dove mettere l'incertezza.
- Tecniche costose contro valore del caso. Autoconsistenza e riflessione moltiplicano il costo. Si applicano su un sottoinsieme - i casi dove sbagliare costa più di cinque chiamate - non su tutto il traffico.
- Velocità contro comprensione, sul codice. Generare un modulo in pochi minuti e revisionarlo in quaranta (numeri ipotetici) non è un guadagno se il modulo è semplice; lo è se il modulo è noioso e lungo. Il discrimine è quanto ti costa verificare, non quanto ti costa scrivere.
Le quattro voci si leggono in coppie: le prime due riguardano quanto spendi per ogni chiamata, le ultime due quanto spendi per fidarti del risultato. Nessuna si risolve con un prompt migliore; si risolvono scegliendo dove applicare la tecnica e dove no.
Le trappole in cui si cade davvero
- Cambiare due cose e misurarne una. È il modo più efficace di accumulare prompt pieni di istruzioni inutili che nessuno osa togliere.
- Far calcolare un numero al modello. Produce cifre plausibili e non verificabili. Il modello scrive il codice, il codice calcola, tu verifichi.
- Non generare lo schema dai dati. Senza lo schema reale nel prompt, il modello inventa nomi di colonna, e nel caso peggiore ne indovina uno esistente ma sbagliato.
- Trattare
execcon builtin ridotti come una sandbox. Non lo è. Serve isolamento di processo se i dati o la macchina contano. - Chiedere codice e test allo stesso modello nello stesso turno. I test passeranno sempre. Il contratto lo scrivi tu, prima.
- Catena di pensiero su compiti che non hanno passaggi. Costo puro, e a volte peggiora: il modello costruisce una giustificazione elaborata per una scelta sbagliata.
- Riflessione senza criterio esterno. Senza un test, un compilatore o una specifica, l'autocorrezione può peggiorare la risposta (arXiv:2310.01798).
- Non prevedere lo stato «non lo so». Un enum senza via d'uscita costringe il modello a scegliere fra opzioni sbagliate, e scegliere è ciò che farà.
- Mettere dati volatili nel prompt. La data di oggi, un listino, un saldo: invecchiano, e il guasto che producono è intermittente. Vanno passati come dato a ogni chiamata.
Le nove trappole si raggruppano in tre famiglie: di metodo (1, 8, 9), di cautela sui dati e sul codice (2, 3, 4, 5) e di scelta della tecnica (6, 7). Le prime si correggono con la disciplina del protocollo di misura, le seconde con un controllo scritto nel codice, le terze con una domanda prima di adottare la tecnica: che cosa mi dà, quanto costa, come lo misuro.
Che cosa sai fare adesso
Un prompt è una specifica, e le leve che spostano l'output in modo misurabile sono quattro: contesto, formato, esempi, vincoli. Le tecniche con un paper alle spalle sono utili quando il meccanismo corrisponde al problema - catena di pensiero dove ci sono passaggi, autoconsistenza dove serve una misura di incertezza, riflessione solo con un criterio esterno - e sono moltiplicatori di costo da giustificare con una misura, mai da adottare per abitudine. Sui dati e sul codice vale una regola sola: il modello scrive codice, il codice produce il risultato, tu verifichi il risultato. È il punto in cui un sistema probabilistico diventa utilizzabile per cose che devono essere giuste.
Sai scrivere un prompt a pezzi, uno per leva, e capire quanto pesa ciascuno; sai impostare un confronto fra versioni che separa formato, correttezza e variabilità; sai riconoscere quando una tecnica con un paper alle spalle vale il suo costo; sai far scrivere codice a un modello e circondarlo di controlli, conoscendo i limiti di quei controlli. Resta fuori ciò che qui non ho potuto provare: come si comporta un modello vero su questi prompt. Nell'articolo successivo si scende di livello: come il testo diventa token.
Nella stessa serie trovi anche Model Context Protocol: la specifica, e un server collegato a un agente (articolo precedente).
Fonti
- Brown et al., Language Models are Few-Shot Learners (arXiv:2005.14165) - l'idea degli esempi nel contesto senza riaddestramento, scheda raggiunta il 2026-11-12 (titolo verificato)
- Wei et al., Chain-of-Thought Prompting Elicits Reasoning in Large Language Models (arXiv:2201.11903) - la catena di pensiero, titolo verificato il 2026-11-12
- Kojima et al., Large Language Models are Zero-Shot Reasoners (arXiv:2205.11916) - la variante senza esempi, titolo verificato il 2026-11-12
- Wang et al., Self-Consistency Improves Chain of Thought Reasoning in Language Models (arXiv:2203.11171) - il voto fra più percorsi di ragionamento, titolo verificato il 2026-11-12
- Zhou et al., Least-to-Most Prompting Enables Complex Reasoning in Large Language Models (arXiv:2205.10625) - la decomposizione in sotto-problemi, titolo verificato il 2026-11-12
- Yao et al., ReAct: Synergizing Reasoning and Acting in Language Models (arXiv:2210.03629) - ragionamento e azione alternati, titolo verificato il 2026-11-12
- Shinn et al., Reflexion: Language Agents with Verbal Reinforcement Learning (arXiv:2303.11366) - il passo di critica del proprio tentativo, titolo verificato il 2026-11-12
- Huang et al., Large Language Models Cannot Self-Correct Reasoning Yet (arXiv:2310.01798) - senza feedback esterno i modelli faticano ad autocorreggersi e a volte peggiorano, abstract letto il 2026-11-12
- Ollama: OpenAI compatibility -
stop,temperature,top_pemax_tokensfra i campi supportati di/v1/chat/completions, consultata il 2026-11-12 - Python: funzioni predefinite, exec - con due dizionari separati il codice è eseguito come dentro una definizione di classe, consultata il 2026-11-12
Eseguiti due volte con uscita identica (Python 3.14.4, pandas 3.0.3): costruzione e peso del prompt, parametri inviati (trasporto HTTP simulato), protocollo di misura, autoconsistenza, tabella dei costi in caratteri, esecuzione controllata del codice generato e controlli di coerenza. Tutte le risposte del modello sono sostituti finti scritti da me. Non eseguiti: qualunque chiamata a un modello vero (nemmeno Ollama), le tecniche descritte solo a parole (catena di pensiero, decomposizione, riflessione), il ciclo di test sul codice. Gli esempi di cifre ipotetiche sono dichiarati nel testo.
Commenti
Caricamento commenti...
Accedi per commentare