Ce qu'il a fallu pour bâtir une app de finances sur le LLM embarqué d'Apple
Le modèle embarqué est bien réel, il est gratuit, et il vous mentira avec un aplomb total. Voici l'architecture qui a survécu au contact avec lui.
Mise à jour du 29 septembre 2026 : cet article décrit la version iOS 26. Depuis Smart Budget 2.0 sur iOS 27, l'IA de l'app privilégie le Private Cloud Compute d'Apple et se rabat sur le modèle embarqué — plus de détails sur ce changement. Vos transactions ne nous parviennent toujours jamais, pas plus qu'à un tiers.
Apple a livré un modèle de langage embarqué avec iOS 26. Pas de clé d'API, pas d'appel réseau, pas de facturation au token, pas de limite de débit visible. Pour une app comme la mienne — une app de budget dont toute la prémisse est que vos données ne quittent jamais le téléphone —, ce n'est pas un plus. C'est la seule façon dont les fonctionnalités « intelligentes » pouvaient exister.
J'ai bâti The Smart Budget dessus. Voici le bilan honnête : ce que le modèle fait vraiment bien, la chose qu'il a faite et qui a failli tuer la fonctionnalité, et l'architecture à laquelle je suis arrivé après avoir appris à mes dépens. Tout le code présenté ici vient de l'app publiée.
La promesse face à la réalité du 3B
Le discours de la WWDC est enivrant : créez des fonctionnalités LLM qui tournent en local, publiez-les, c'est fait. Et une bonne partie est vraie. Mais le modèle embarqué d'iOS 26 est un modèle d'environ 3 milliards de paramètres. Ce n'est pas GPT-4 dans votre poche. Le traiter comme tel, c'est le meilleur moyen de publier quelque chose dont vous aurez honte.
Voici la répartition que j'ai constatée au bout de quelques semaines :
Ce que le modèle 3B fait vraiment bien : l'extraction structurée en un seul passage. Donnez-lui une tâche courte et bien délimitée — « lis cette phrase et choisis l'une de ces cinq intentions » — avec un schéma qui contraint la sortie, et il est excellent. Rapide, fiable, et il reste dans les lignes que vous tracez.
Ce qu'il fait mal : tout ce qui est ouvert. La conversation sur plusieurs tours. La prose libre qui doit en plus être factuellement exacte. Dès que vous le laissez écrire des phrases à propos de chiffres, il invente des détails autour des chiffres avec un sérieux imperturbable.
Toute l'architecture découle du respect de cette répartition.
Le schéma qui marche : comprendre → Swift déterministe → narrer
La règle à laquelle je suis arrivé : laissez le modèle comprendre le langage, et ne le laissez jamais faire de calculs ni énoncer des faits. Concrètement, chaque requête intelligente de l'app s'exécute en trois étapes, et seule la première fait appel au modèle.
// 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)
L'étape 1 transforme « combien ai-je dépensé en restaurants le mois dernier ? » en un FinanceQuestion typé — une intention sous forme d'enum, une période, un marchand ou une catégorie optionnels. L'étape 2, c'est du Core Data pur. L'étape 3 met en forme le résultat. Remarquez ce que les étapes 2 et 3 ont en commun : aucun modèle. L'en-tête de la couche de requêtes le dit noir sur blanc :
/// `context.perform`. NO LLM involvement; no `Tool` protocol; no orchestration.
Le schéma d'extraction — et l'astuce qui le rend fiable
L'étape 1 utilise DynamicGenerationSchema, qui permet de construire le schéma de sortie à l'exécution, à partir des données de l'utilisateur lui-même. Cet aspect « à l'exécution » compte plus qu'il n'y paraît. Le schéma est assemblé pour chaque question, et — c'est là l'astuce — une propriété n'existe dans le schéma que si un indice la concernant apparaît dans la requête :
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))
}
Si l'utilisateur n'a pas nommé de marchand, la propriété merchant n'est pas du tout dans le schéma — le modèle ne peut donc structurellement pas en halluciner un. Vous ne lui demandez pas gentiment d'éviter d'inventer un marchand ; vous avez rendu l'invention impossible. Le décodage contraint transforme « s'il te plaît, sois sage » en « tu ne peux pas mal te conduire ». Ensuite, on l'exécute :
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
)
Température 0.1, parce qu'il s'agit d'une classification, pas d'un poème. Et même avec anyOf qui contraint les choix, une double vérification a lieu ensuite : elle écarte tout marchand ou toute catégorie renvoyés par le modèle, sauf si un mot correspondant apparaît littéralement dans le texte de l'utilisateur. Faire confiance, puis vérifier, puis vérifier encore — c'est une app de finances.
Le schéma qui ne marche pas : le laisser narrer
Voici la partie que j'aimerais que chaque développeur lise avant de publier.
L'étape 3 d'origine était un LLM. Je demandais au modèle embarqué de « réécrire la réponse déterministe en une ou deux phrases chaleureuses », avec la consigne explicite de ne modifier aucun chiffre. Il a respecté la consigne sur les chiffres. Et il a inventé tout le reste autour. Voici le commentaire de code qui se trouve aujourd'hui à la place de cette fonctionnalité :
/// 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 marchand qui n'a jamais existé, dans le résumé des dépenses réelles de quelqu'un, présenté comme un fait. Dans une app de budget. Aucun prompt astucieux ne rend ça acceptable. Que le chiffre soit juste n'a aucune importance si la phrase qui l'entoure est une fiction.
L'étape 3 est donc désormais un modèle de texte. Pas parce que les modèles de texte sont élégants — parce qu'un modèle de texte ne peut pas mentir :
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 laisse quand même le modèle narrer à quelques endroits — les cartes d'analyse narratives —, mais uniquement sous les garde-fous stricts décrits plus bas, et jamais là où il pourrait inventer une transaction.)
J'ai aussi essayé les deux architectures « évidentes » avant celle-ci, et les deux ont échoué avec le modèle 3B. La justification est conservée dans l'en-tête du type :
/// **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.
L'appel d'outils est la plus séduisante des deux, parce que c'est comme ça qu'on ferait avec un modèle de pointe. En embarqué, le modèle n'arrivait pas à orchestrer les outils de façon fiable d'un tour à l'autre. L'extraction vers une struct est moins glamour et infiniment plus robuste.
Ne vous fiez pas non plus à la transcription de session comme mémoire
La conversation sur plusieurs tours pousse à s'appuyer sur la transcription du modèle pour la mémoire. Ne le faites pas. La fenêtre de contexte est petite et se remplit vite. Je conserve l'état de la conversation en Swift pur et je réinjecte la question précédente dans chaque nouveau 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 tokens. C'est tout le budget. C'est aussi pour ça que la liste des marchands transmise à l'extracteur est réduite à l'avance à une trentaine d'entrées — la vraie liste peut en compter plus de 1000, et tout y déverser ferait exploser la fenêtre avant même que le modèle ne voie la question.
Les garde-fous dont vous avez vraiment besoin
Chaque appel au modèle passe par un client unique qui enveloppe le résultat dans une issue typée, pour que chaque appelant puisse décider : réessayer, se rabattre sur le déterministe ou remonter l'erreur. Les trois qui ont prouvé leur utilité :
Attente progressive en cas de limite de débit. Apple ne publie pas les limites, donc vous les découvrez à l'exécution. Attente exponentielle avec gigue, puis abandon pour que l'appelant puisse se rabattre sur autre chose plutôt que de rester bloqué :
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)
Un dépassement de contexte ne se réessaie pas. Si vous faites exploser la fenêtre de 4096 tokens, renvoyer le même prompt ne sert à rien — l'appelant doit découper le travail. C'est donc remonté comme une issue distincte, pas mélangé avec les échecs passagers.
Tout sérialiser. Tous les appels au modèle passent par un seul actor, pour que deux fonctionnalités ne puissent pas lancer des sessions en parallèle et épuiser deux fois plus vite le budget de débit invisible. Et il y a un interrupteur général — un réglage de l'app combiné par un ET avec la disponibilité de l'appareil — qui, une fois désactivé, fait renvoyer .unavailable à chaque appel, si bien que toute l'app emprunte son chemin déterministe : analyse des SMS par regex, catégoriseur déterministe, repli du chat sur des mots-clés. La couche intelligente est une amélioration, jamais une dépendance. L'app reste entièrement utilisable avec le modèle complètement désactivé.
La fonctionnalité que j'ai créée, terminée, puis cachée
Il y a dans le code un chat conversationnel complet « Ask Anything » (« posez n'importe quelle question ») — vous pouvez poser vos questions d'argent en langage naturel, et il répond grâce au pipeline décrit plus haut. Il est implémenté. Il compile. Et chacun de ses points d'entrée est commenté, marqué 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.
Ce fut la décision la plus difficile du projet. La fonctionnalité marche — la plupart du temps. Mais « la plupart du temps », c'est une note éliminatoire quand la fonctionnalité s'appelle « demandez-moi n'importe quoi sur votre argent ». Même avec toutes les contraintes ci-dessus, l'extraction d'intention du modèle 3B n'était pas assez fiable pour que j'y mise la confiance qu'on accorde à l'app. Elle est donc là, terminée, en attendant le modèle qu'apportera iOS 27. Publier le pipeline mais cacher le chat, c'était la différence entre « démo astucieuse » et « app que je laisserais utiliser à ma propre famille ».
Ce que je me dirais le premier jour
- Concevez tout de suite pour le schéma contraint. Le modèle pour comprendre, Swift pour la vérité. Ne commencez pas par un chatbot en espérant y greffer la fiabilité plus tard ; vous le réécrirez.
- Ne laissez jamais le modèle énoncer un fait. Extraction en entrée, logique déterministe et modèles de texte en sortie. Si le modèle peut produire un nom de marchand, il finira par en produire un faux.
- Rendez l'hallucination structurellement impossible, pas seulement découragée. Une propriété absente du schéma ne peut pas être inventée. Les règles du prompt sont des suggestions ; les schémas sont des murs.
- Traitez la couche intelligente comme une amélioration. Publiez d'abord l'app déterministe, puis laissez le modèle l'améliorer là où on peut lui faire confiance.
Le modèle embarqué est un outil vraiment excellent dès que vous cessez de lui demander d'être ce qu'il n'est pas. Ce n'est pas un cerveau à qui confier le volant. C'est un analyseur d'intentions très bon et très rapide, qui se trouve tourner gratuitement sur le téléphone — et il s'avère que c'est l'essentiel de ce dont une app de finances privée a réellement besoin.
The Smart Budget est disponible dès maintenant sur iPhone (iOS 26, iPhone 16 et ultérieurs). Sur iOS 26, tout ce qui précède tourne sur l'appareil ; sur iOS 27, l'app privilégie le Private Cloud Compute d'Apple, et vos transactions ne nous parviennent toujours jamais, pas plus qu'à un tiers. Si vous voulez le côté CloudKit de l'histoire — deux comptes iCloud qui partagent un même budget sans serveur —, c'est dans l'article suivant.
— Omar