La domanda sbagliata e quella giusta
La domanda che viene posta nella riunione di pianificazione è: “Dove possiamo aggiungere l’AI in questo prodotto?”. È la domanda sbagliata, perché parte dalla capacità tecnologica e cerca un posto in cui infilarla. Quello che si ottiene è un elenco di punti in cui un language model funzionerebbe, che coincide solo in qualche caso con i punti di giunzione in cui l’inserimento dell’LLM sarebbe più corretto.
La domanda che vale la pena porsi è più stringente e meno comoda: “Quale parte di questo sistema ha il permesso di sbagliare, e cosa succede quando sbaglia?”.
Ogni sistema che è in produzione da qualche anno ha già una risposta, scritta al suo interno da chi lo ha costruito. Alcuni percorsi sono protetti da validazioni, retry e revisione umana. Altri scrivono direttamente sul sistema di registrazione e vengono considerati affidabili. Un language model è adatto al primo tipo di percorso. Non appartiene al secondo, e nessuna quantità di prompt engineering lo sposta di là.
I nostri sistemi sono in esercizio dal 2014, e gran parte del lavoro sull’AI che facciamo oggi consiste nel retrofit su codebase che precedono i modelli di diversi anni. Questo vincolo si rivela una fortuna: l’architettura ci dice già dove l’incertezza è tollerata.
Il test di confine
Prima di integrare qualsiasi cosa, valutiamo la funzionalità candidata su tre proprietà: tolleranza, verificabilità, costo del retry. Non è un modello di maturità, sono solo tre domande con risposte scomode.
| Proprietà | La domanda | Non passa quando… |
Tolleranza |
Se questo output è sbagliato e nessuno se ne accorge per una settimana, qual è il danno? | … la risposta riguarda aspetti finanziari, dati sanitari come cartelle cliniche, obblighi di legge, insomma tutto ciò che sia sottoposto in qualche modo a un regolatore o debba conformarsi a una normativa. |
Verificabilità |
Qualcosa di diverso da un essere umano può stabilire se l’output è accettabile? | … l’unico controllo è: “Sembra più o meno giusto”. |
Costo del retry |
Se scartiamo l’output e ripieghiamo su altro, quanto costa? | … non esiste un ripiego perché il modello ha sostituito del tutto la persona o il sistema che svolgeva quel lavoro in precedenza. |
Tabella 1 – Le domande per valutare le funzionalità sulla base delle tre proprietà: tolleranza, verificabilità, costo del retry.
Una funzionalità che passa tutte e tre le domande è una buona candidata. Redigere bozze, classificare, riassumere, estrarre struttura da testo libero, ordinare elementi tra cui poi è una persona a scegliere, e trasformare una richiesta in linguaggio naturale in un’azione proposta che qualcuno approva. Quello che hanno in comune è che una risposta sbagliata è visibile, poco costosa e recuperabile.
La terza riga è quella che i team di sviluppo spesso saltano, ma è quella che decide l’architettura. Se non sapete descrivere il comportamento della funzionalità quando il modello non è disponibile, non avete progettato un’integrazione: avete progettato una dipendenza.
Mettere il modello dietro una porta
La decisione strutturale di maggior valore è anche la meno entusiasmante: il modello LLM va dietro un’interfaccia espressa nel linguaggio del vostro dominio, e nessun tipo del fornitore attraversa quella linea.
// The domain speaks in its own terms.
export interface CategorySuggestion {
categoryId: string;
confidence: number;
rationale: string;
}
export interface SuggestsCategories {
// Returns null for every failure mode: timeout, malformed output,
// rate limit, schema drift. The caller decides what null means.
suggest(text: string): Promise<CategorySuggestion | null>;
}
Tutto ciò che è specifico del fornitore vive in un unico adapter dietro quell’interfaccia: l’SDK, il prompt, la politica di retry, il parsing, la validazione dello schema. Il resto del codice importa… e non sa nient’altro.
È un’idea vecchia, e vale la pena ribadirla perché gli SDK attuali la scoraggiano attivamente. Sono comodi da chiamare direttamente, quindi gli oggetti di risposta del fornitore filtrano nei servizi, poi nei controller e infine nel database.
Ma attenzione, perché questa apparente comodità ha il suo risvolto problematico: quando più avanti vorrete far girare due fornitori in parallelo, o spostare una classificazione a basso costo su un modello locale, o scrivere un fake deterministico per la vostra suite di test… sarà proprio quella contaminazione a bloccarvi. Se riflettiamo sulle architetture che abbiamo realizzato, vedremo che non ci siamo mai pentiti dell’adapter: ci siamo invece pentiti più volte di averlo saltato.
Il ritorno null conta quanto l’interfaccia. Un adapter che rilancia le eccezioni del fornitore spinge la semantica del fornitore verso l’alto nello stack, e ogni chiamante finisce con un blocco che tira a indovinare. Comprimete ogni errore in un unico valore al confine, e costringete il chiamante a dichiarare la propria politica in modo esplicito.
La chiave di cache è una questione di correttezza
Tutti mettono in cache le risposte del modello per controllare i costi. La parte interessante non è la cache, è la cache key, perché è nella chiave che scrivete da cosa dipende davvero la risposta.
const cacheKey = sha256([ ‘ticket-category’, // the capability PROMPT_VERSION, // bump when the prompt text changes SCHEMA_VERSION, // bump when the output shape changes MODEL_ID, // pinned, never an alias like “latest” normalise(text) // the only part that varies per request ].join(‘|’));
Quattro di questi cinque componenti non hanno niente a che fare con l’input dell’utente. Basta ometterne uno qualsiasi e la cache vi servirà volentieri una risposta prodotta da un prompt che nel frattempo avete riscritto, in una forma che il vostro parser non accetta più, da un modello che non usate più.
Lo dico per esperienza, perché abbiamo fatto il debug di quel problema: il sintomo è una funzionalità che si comporta correttamente in staging e in modo errato in produzione per un sottoinsieme di utenti, e la causa è invisibile nei log delle richieste.
Due note collegate. Primo; il prompt caching lato fornitore e il caching dei risultati lato vostro sono meccanismi diversi che risolvono problemi diversi, e uno non sostituisce l’altro: il loro meccanismo riduce il costo di una chiamata che state comunque facendo; il vostro elimina la chiamata.
Secondo; siate onesti sugli hit rate. Il testo libero scritto dagli utenti si ripete di rado, quindi una cache a corrispondenza esatta sull’input grezzo è quasi inutile. Normalizzate in modo aggressivo, e mettete in cache al livello in cui la ripetizione esiste davvero, che di solito è una chiave derivata come l’identificativo di un prodotto o la versione di un documento, non il testo in sé.
Progettare per primo il percorso di errore
Scriviamo il fallback prima del percorso felice. Non per esibizione di disciplina. È perché è nel fallback che risiede la decisione di progetto.
async function categorise(ticket: Ticket): Promise<CategoryId> {
const suggestion = await withTimeout(model.suggest(ticket.text), 2000);
if (!suggestion || suggestion.confidence < CONFIDENCE_FLOOR) {
return rules.categorise(ticket); // the system that existed before the model
}
audit.record(ticket.id, suggestion, MODEL_ID, PROMPT_VERSION);
return suggestion.categoryId;
}
Qui tre elementi sono portanti. C’è un timeout, perché una chiamata al modello è una chiamata di rete verso un sistema con una coda lunga e variabile e nessun obbligo verso il vostro SLA. C’è una soglia di confidenza, così che una risposta a bassa confidenza venga trattata come assenza di risposta e non come risposta debole. E c’è un percorso deterministico che continua a funzionare quando il modello non c’è, che in questo caso è il motore a regole che faceva quel lavoro prima, tenuto in vita invece che cancellato il giorno in cui l’integrazione è andata in produzione.
Diverse modalità di errore
Le modalità di errore da progettare, più o meno nell’ordine in cui vi sorprenderanno, sono le seguenti.
- Output malformato. JSON valido non significa risposta valida. Validate contro uno schema, poi validate che i valori esistano nel vostro dominio. Il categoryId restituito deve essere una categoria che effettivamente è presente
- Output sbagliato ma sicuro di sé. Nessuna eccezione, niente nei log, il numero è alto e la risposta è priva di senso. Solo il campionamento e la revisione umana lo intercettano.
- Latenza, non errore. La chiamata riesce dopo nove secondi, e il vostro handler ha tenuto aperta una connessione per tutto il tempo.
- Rate limit nel momento peggiore. Il carico è correlato agli eventi di business, quindi il throttling arriva durante il batch che contava.
- Regressione silenziosa della qualità. Da parte vostra non è cambiato nulla. Si veda la sezione seguente.
Il fornitore cambierà il modello sottostante
Questa è la modalità di errore a cui non è associata alcuna eccezione, ed è quella che distingue i team che hanno già impiegato questi sistemi in produzione da quelli che non lo hanno ancora fatto…
Il comportamento che si nasconde dietro un alias muta. Le finestre di deprecazione sono brevi. Un prompt ottimizzato per una determinata versione del modello LLM non lo è più per il suo successore.
Guardando in retrospettiva, ci sono tre pratiche, tutte a basso costo, che avremmo voluto adottare fin dall’inizio:
Fissare la versione del modello, mai un alias
Gli alias dipendono da terzi. Bloccate l’identificativo datato ed eseguite l’aggiornamento in modo deliberato, trattandolo come una modifica tracciata da una diff e approvata da un revisore.
Mantenere un golden set
Da trenta a cento input reali con i relativi output approvati, conservati nel repository ed eseguibili con un singolo comando. Non si tratta di un benchmark e non è necessario che sia statisticamente ineccepibile. Deve semplicemente fallire in modo rumoroso ed evidente quando il comportamento cambia. È questo l’”artefatto” che trasforma l’aggiornamento di un modello da atto di fede a una vera e propria revisione.
Versionare il prompt come artifact distribuibile
Il prompt non è una semplice configurazione e non è una costante stringa sepolta all’interno di un servizio. È codice a tutti gli effetti: viene rilasciato tramite un processo di revisione, ha una versione, quella versione è inclusa nella chiave di cache ed è registrata nei log di audit accanto a ogni singolo output generato. Quando a giugno qualcuno chiederà per quale motivo a marzo il sistema abbia risposto in un certo modo, potrete dare una risposta esatta, perché avrete ancora a disposizione il prompt, l’identificativo del modello e l’input.
Dove il modello LLM non deve stare
L’elenco delle esclusioni è più breve e più utile di quello positivo. Nei sistemi su cui lavoriamo, un modello linguistico non deve mai trovare posto:
- Come unico componente che scrive su un system of record. Un essere umano o una regola deterministica deve approvare la scrittura. Il modello LLM si limita a proporre.
- All’interno di una transazione di database. Si finirebbe per mantenere attivi ilock durante una chiamata remota caratterizzata da una coda di latenza indefinita (unbounded tail). È un classico errore di progettazione dei sistemi distribuiti sotto mentite spoglie di intelligenza artificiale.
- Per delegare una decisione di autorizzazione. La gestione dei permessi non è un problema da affidare al linguaggio naturale. È una questione che richiede una risposta esatta e verificabile a fini di audit.
- Al posto di un parser. Se l’input segue una grammatica, va analizzato sintatticamente. Un modello che interpreta un formato strutturato funziona nella maggior parte dei casi, il che è considerevolmente peggiore rispetto a unparser che fallisce in modo visibile ed esplicito.
- Come router per azioni irreversibili. Scegliere quale strumento invocare è accettabile solo quando ognitool è reversibile o vincolato a un controllo (gated). Non lo è affatto quando l’operazione sposta denaro o invia un’email a un cliente.
- Laddove il fallback coincida con una schermata vuota. Se non è prevista una modalità a funzionalità ridotta (degraded mode), la funzionalità non è pronta per la produzione, a prescindere dall’accuratezza dimostrata in fase di test.
Quindi… cosa fare da domani?
Prendete la funzionalità che state pianificando e rispondete ad alta voce, in una sola frase, alla domanda fondamentale sul limite del sistema: “Cosa fa questa funzionalità quando il modello non è disponibile?”. Se la risposta è una scrollata di spalle, è proprio lì che risiede il lavoro da fare, ed è un lavoro di architettura software, non di intelligenza artificiale. Solo allora fissate l’identificativo del modello, assegnate un numero di versione al prompt e codificate trenta input con le relative risposte accettabili.
Nulla di tutto questo dipende dalla tabella di marcia (roadmap) del fornitore del modello, ed è esattamente ciò che consente all’integrazione di sopravvivere all’impatto con la versione successiva.
La vera sfida ingegneristica in questo settore non risiede nel prompt: risiede nella linea di giunzione.
