Cosa ci è voluto per creare un'app di finanza sull'LLM on-device di Apple
Il modello on-device è reale, è gratuito e ti mentirà con assoluta sicurezza. Ecco l'architettura che è sopravvissuta al confronto.
Aggiornamento, 29 settembre 2026: questo articolo descrive la versione per iOS 26. Da Smart Budget 2.0 su iOS 27, l'IA dell'app preferisce il Private Cloud Compute di Apple e ripiega sul modello on-device: ne parlo qui. Le tue transazioni continuano a non arrivare mai a noi né a terzi.
Con iOS 26 Apple ha rilasciato un modello linguistico on-device. Niente chiave API, niente chiamate di rete, niente costo per token, nessun limite di frequenza visibile. Per un'app come la mia, un'app per il budget la cui premessa è i tuoi dati non lasciano mai il telefono, non è un optional. È l'unico modo in cui le funzioni «intelligenti» potevano esistere.
Ci ho costruito sopra The Smart Budget. Questo è il resoconto onesto: in cosa il modello è davvero bravo, l'unica cosa che ha fatto e che ha quasi ucciso la funzione, e l'architettura a cui sono arrivato dopo aver imparato a mie spese. Tutto il codice qui viene dall'app pubblicata.
La promessa e la realtà dei 3B
La narrazione della WWDC è inebriante: crei funzioni basate su LLM che girano in locale, le pubblichi, fatto. E molto di questo è vero. Ma il modello on-device di iOS 26 ha circa 3 miliardi di parametri. Non è GPT-4 in tasca. Trattarlo come tale è il modo migliore per pubblicare qualcosa di cui vergognarsi.
Ecco la divisione che ho trovato dopo qualche settimana:
In cosa il modello da 3B è davvero bravo: l'estrazione strutturata in un solo passaggio. Dagli un compito breve e ben delimitato («leggi questa frase e scegli uno di questi cinque intenti») con uno schema che vincola l'output, ed è eccellente. Veloce, affidabile, e resta dentro i confini che tracci.
In cosa è scarso: tutto ciò che è aperto. Conversazioni a più turni. Prosa libera che deve anche essere precisa sui fatti. Nel momento in cui gli lasci scrivere frasi sui numeri, inventerà dettagli attorno ai numeri con la faccia più seria del mondo.
L'intera architettura discende dal rispetto di questa divisione.
Lo schema che funziona: interprete → Swift deterministico → narratore
La regola a cui sono arrivato: lascia che il modello capisca il linguaggio, e non lasciargli mai fare calcoli o affermare fatti. In concreto, ogni richiesta intelligente nell'app passa per tre fasi, e solo la prima tocca il modello.
// Stage 1: LLM extraction (constrained). Falls back to a tiny keyword
// pass automatically if FM is unavailable / errors.
let question = await QuerySpecExtractor.extract(
from: trimmed,
previous: conversationContext?.lastQuestion,
knownCategoryNames: knownCategoryNames,
knownMerchantsByFrequency: knownMerchantNames
)
// Stage 2: deterministic query.
let answer = await FinanceQueries.answer(question, controller: controller)
// Stage 3: deterministic prose render.
let reply = FinanceNarrator.compose(answer: answer, userQuestion: trimmed)
La fase 1 trasforma «quanto ho speso per mangiare fuori il mese scorso?» in una FinanceQuestion tipizzata: un intento come enum, un periodo, un esercente o una categoria facoltativi. La fase 2 è semplice Core Data. La fase 3 formatta il risultato. Nota cosa hanno in comune le fasi 2 e 3: nessun modello. L'intestazione del livello delle query lo dice chiaramente:
/// `context.perform`. NO LLM involvement; no `Tool` protocol; no orchestration.
Lo schema di estrazione, e il trucco che lo rende affidabile
La fase 1 usa DynamicGenerationSchema, che permette di costruire lo schema di output a runtime partendo dai dati dell'utente. Quel «a runtime» conta più di quanto sembri. Lo schema viene assemblato per ogni domanda e, ecco il trucco, una proprietà esiste nello schema solo quando nella richiesta compare un segnale che la riguarda:
var properties: [DynamicGenerationSchema.Property] = [
.init(name: "intent", schema: intentSchema),
.init(name: "period", schema: periodSchema),
.init(name: "otherPeriod", schema: otherPeriodSchema, isOptional: true),
.init(name: "limit", schema: limitSchema, isOptional: true),
]
if !merchantNames.isEmpty {
let merchantSchema = DynamicGenerationSchema(
name: "merchant",
description: "Merchant name the user named. Copy EXACTLY from this list.",
anyOf: merchantNames
)
properties.append(.init(name: "merchant", schema: merchantSchema, isOptional: true))
}
Se l'utente non ha nominato un esercente, la proprietà merchant non è proprio nello schema, quindi il modello strutturalmente non può inventarsene uno. Non gli stai chiedendo gentilmente di non inventare un esercente; hai reso impossibile inventarlo. Il decoding vincolato trasforma «per favore, comportati bene» in «non puoi comportarti male». Poi lo esegui:
let session = LanguageModelSession(model: SystemLanguageModel.default)
let response = try await session.respond(
to: prompt,
schema: schema,
includeSchemaInPrompt: true,
options: GenerationOptions(temperature: 0.1) // we want deterministic-ish picks
)
Temperatura 0.1, perché questa è una classificazione, non una poesia. E anche con anyOf a vincolare le scelte, dopo c'è un controllo di sicurezza in più che scarta qualsiasi esercente o categoria restituiti dal modello, a meno che un token corrispondente compaia letteralmente nel testo dell'utente. Fidarsi, poi verificare, poi verificare di nuovo: è un'app di finanza.
Lo schema che non funziona: lasciargli fare il narratore
Ecco la parte che vorrei ogni sviluppatore leggesse prima di pubblicare.
La fase 3 originale era un LLM. Chiedevo al modello on-device di «riscrivere la risposta deterministica in una o due frasi cordiali», con istruzioni esplicite di non cambiare nessun numero. Ha rispettato l'istruzione sui numeri. E poi ha inventato tutto il resto. Dal commento nel codice che ora sta dove prima c'era quella funzione:
/// The previous version asked the on-device Foundation Model to "rewrite the
/// deterministic answer in 1-2 warm sentences," with explicit rules against
/// changing numbers. The model honoured the numbers but invented EVERYTHING
/// ELSE — McDonald's transactions the user never made, merchant names like
/// "Bubble-bro.com" pulled out of thin air, fake per-item amounts. Pure
/// hallucination. On a small on-device model the prompt rules don't stick
/// reliably enough for a finance app where any invented detail destroys trust.
«Bubble-bro.com». Un esercente mai esistito, nel riepilogo delle spese reali di qualcuno, presentato come un fatto. In un'app per il budget. Non esiste un prompt furbo che renda accettabile una cosa del genere. Che il numero sia giusto non conta, se la frase attorno è finzione.
Così ora la fase 3 è un template di stringa. Non perché i template siano eleganti, ma perché un template non può mentire:
static func compose(answer: FinanceAnswer, userQuestion: String) -> String {
if answer.detail.isEmpty {
return answer.summary
}
return answer.summary + "\n\n" + answer.detail.joined(separator: "\n")
}
(L'app lascia ancora fare il narratore al modello in qualche punto, le schede-racconto delle analisi, ma solo con i rigidi controlli sugli esiti descritti più avanti, e mai dove potrebbe inventare una transazione.)
Prima di questa ho provato anche le due architetture «ovvie», ed entrambe sono fallite con il modello da 3B. Le motivazioni sono conservate nell'intestazione del tipo:
/// **Why structured + LLM-extracted:** prior architectures using either
/// (a) `LanguageModelSession`-with-tools or (b) hand-written regex parser
/// both failed. (a) the on-device 3B model can't reliably orchestrate tool
/// calls across multi-turn chat; (b) regex didn't generalise to the long
/// tail of natural phrasings ("on what?", "what are these?", "and dining
/// only?"). The current architecture uses constrained DynamicGenerationSchema
/// for natural-language UNDERSTANDING (front of pipeline) + deterministic
/// Swift for everything else.
Il tool calling è quello seducente, perché è così che lo faresti con un modello di frontiera. On-device, il modello non riusciva a orchestrare in modo affidabile gli strumenti tra un turno e l'altro. L'estrazione dentro una struct è meno affascinante e molto più robusta.
Non usare nemmeno la trascrizione della sessione come memoria
Una chat a più turni ti tenta ad affidarti alla trascrizione della conversazione del modello come memoria. Non farlo. La finestra di contesto è piccola e si riempie in fretta. Tengo lo stato della conversazione in semplice Swift e reinserisco la domanda precedente in ogni nuovo prompt:
/// **Why not use the model's session transcript for multi-turn memory:**
/// the on-device 4096-token context fills up fast and Apple's developer
/// guidance is that the transcript is an optimisation, not a memory system.
/// We maintain `ConversationContext` ourselves in Swift and inline the
/// previous spec into each turn's prompt — same effect, deterministic memory.
4096 token. È tutto il budget. Ed è anche il motivo per cui l'elenco degli esercenti passato all'estrattore è ridotto in anticipo a circa 30 voci: l'elenco reale può superare le 1000, e scaricarlo tutto farebbe saltare la finestra prima ancora che il modello veda la domanda.
I controlli di cui hai davvero bisogno
Ogni chiamata al modello passa per un unico client che incapsula il risultato in un esito tipizzato, così ogni chiamante può decidere: riprovare, ripiegare sul percorso deterministico o mostrare l'errore. I tre che si sono guadagnati il posto:
Backoff sui limiti di frequenza. Apple non pubblica i limiti, quindi li scopri a runtime. Backoff esponenziale con jitter, poi si rinuncia, così il chiamante può ripiegare invece di restare bloccato:
case .rateLimited:
guard attemptsLeft > 1 else {
logger.warning("FM rate-limited ... giving up so caller can fall back")
return .rateLimited
}
let baseSeconds = pow(2.0, Double(attemptIndex + 1)) // 2s, 4s, 8s
let jitter = Double.random(in: 0.75...1.25) // ±25%
try? await Task.sleep(nanoseconds: UInt64(baseSeconds * jitter * 1_000_000_000))
return await generateOnce(..., attemptsLeft: attemptsLeft - 1, attemptIndex: attemptIndex + 1)
L'overflow del contesto non si riprova. Se sfori la finestra da 4096 token, riprovare lo stesso prompt non serve a nulla: il chiamante deve dividere il lavoro. Per questo viene segnalato come esito distinto, non mescolato ai fallimenti temporanei.
Serializza tutto. Tutte le chiamate al modello passano per un unico actor, così due funzioni non possono aprire sessioni in parallelo e bruciare il budget invisibile dei limiti al doppio della velocità. E c'è un interruttore generale, un'opzione in Impostazioni messa in AND con la disponibilità del dispositivo, che, quando è spento, fa restituire .unavailable a ogni chiamata, così l'intera app segue il suo percorso deterministico: parsing degli SMS con espressioni regolari, un categorizzatore deterministico, una chat di ripiego a parole chiave. Il livello intelligente è un miglioramento, mai una dipendenza. L'app è pienamente utilizzabile anche con il modello completamente disattivato.
La funzione che ho creato, finito e poi nascosto
Nel codice c'è una chat conversazionale completa, «Chiedi qualsiasi cosa» (Ask Anything): puoi fare domande sui tuoi soldi in linguaggio naturale e lei risponde usando la pipeline descritta sopra. È implementata. Compila. E ogni punto di accesso è commentato, con il tag ASK-ANYTHING-DEFERRED-IOS27:
// ASK-ANYTHING-DEFERRED-IOS27: chat sheet presentation
// disabled. Implementation kept intact ...
// The 3B model in iOS 26 was unable to reliably extract
// intent + filters even with constrained DynamicGenerationSchema
// — revisit when iOS 27 / a larger on-device model lands.
È stata la decisione più difficile del progetto. La funzione funziona, quasi sempre. Ma «quasi sempre» è un voto insufficiente quando la funzione è «chiedimi qualsiasi cosa sui tuoi soldi». Anche con tutti i vincoli descritti sopra, l'estrazione degli intenti del modello da 3B non era abbastanza affidabile da puntarci la fiducia nell'app. Quindi è lì, finita, in attesa del modello che arriverà con iOS 27. Pubblicare la pipeline ma nascondere la chat è stata la differenza tra «demo brillante» e «app che farei usare alla mia famiglia».
Cosa direi a me stesso il primo giorno
- Progetta subito per lo schema vincolato. Il modello interpreta, Swift fa da verità. Non partire da un chatbot cercando di aggiungere affidabilità in seguito; finirai per riscriverlo.
- Non lasciare mai che il modello affermi un fatto. In ingresso l'estrazione, in uscita logica deterministica e template. Se il modello può emettere il nome di un esercente, prima o poi ne emetterà uno falso.
- Rendi le allucinazioni strutturalmente impossibili, non solo sconsigliate. Una proprietà che non è nello schema non può essere inventata. Le regole nel prompt sono suggerimenti; gli schemi sono muri.
- Tratta il livello intelligente come un miglioramento. Pubblica prima l'app deterministica, poi lascia che il modello la migliori dove ci si può fidare.
Il modello on-device è uno strumento davvero ottimo, una volta che smetti di chiedergli di essere ciò che non è. Non è un cervello a cui cedere il volante. È un interprete di intenti molto bravo e molto veloce che, guarda caso, gira gratis sul telefono, e a quanto pare è quasi tutto ciò di cui un'app di finanza privata ha davvero bisogno.
The Smart Budget è disponibile ora su iPhone (iOS 26, iPhone 16 e successivi). Su iOS 26 tutto quanto descritto sopra gira on-device; su iOS 27 l'app preferisce il Private Cloud Compute di Apple, e le tue transazioni continuano a non arrivare mai a noi né a terzi. Se vuoi il lato CloudKit della storia, due account iCloud che condividono un unico budget senza server, è nel prossimo articolo.
— Omar