LLM local sur iOS 27 : ce qu'Apple ne dit pas dans la doc
Embarquer un LLM dans une app iOS est devenu possible avec iOS 27. Cet article est l'inventaire du prix à payer : ce que le catalogue autorise, le setup imposé, ce que la machine encaisse, et ce que le modèle produit réellement quand on le mesure.
Depuis iOS 27, un iPhone peut exécuter un modèle de langage sans serveur, sans clé d'API et sans connexion. Le framework Core AI d'Apple charge un modèle exporté au format .aimodel, tandis que le package CoreAILanguageModels l'adapte aux API de FoundationModels : même LanguageModelSession, même structured output avec @Generable. Écrire le code qui appelle un Qwen embarqué prend une dizaine de lignes.
// Sources/Data/DataSources/CoreAIDataSource.swift
let model = try await loadModel()
defer { model.unload() }
let session = LanguageModelSession(model: model)
let prompt = SaladPrompt.text(ingredients: ingredients, seed: seed)
let options = GenerationOptions(maximumResponseTokens: maxResponseTokens)
let response = try await session.respond(
to: prompt,
generating: GeneratedSaladRecipe.self,
options: options
)
return response.content
Pourquoi embarquer son propre modèle, alors qu'Apple en fournit un, gratuit et déjà installé ? Cinq raisons.
Le parc. Apple Intelligence demande l'A17 Pro. Core AI tourne partout où iOS 27 tourne, sur CPU, GPU ou Neural Engine, sans exigence de puce. Entre les deux, il reste tous les iPhone que l'app doit servir et qui n'ont pas de modèle système. Et c'est là que ça coince : ce parc est aussi celui qui subit le premier lancement le plus lent, puisque la compilation ahead-of-time ne produit d'artefacts que pour les puces éligibles à Apple Intelligence.
La disponibilité. Même sur un appareil compatible, le modèle système peut être absent : Apple Intelligence désactivé dans les Réglages, ou modèle pas encore téléchargé. Le code doit prévoir le cas et proposer autre chose. Un modèle dans le bundle est toujours là.
La version. Apple met à jour son modèle quand il veut, et les sorties changent sans qu'on ait touché une ligne de code. Quand on mesure la conformité d'une fonctionnalité, les scores ne valent alors que pour la version du jour. Un checkpoint embarqué est un artefact versionné, rejouable, auditable.
Le format. Taille, compression, contexte : on choisit, et on sait ce que ça pèse dans le bundle. Le modèle système, c'est à prendre tel quel.
Le modèle affiné. C'est la raison la plus structurante. La documentation de FoundationModels n'expose aucun moyen d'entraîner le modèle système : on l'oriente par des instructions, des profils dynamiques et des outils, jamais par ses poids. Un modèle affiné sur ses propres données, son vocabulaire métier ou une langue mal couverte n'existe donc qu'en l'embarquant. Une seule contrainte, et elle vient du registre : partir de l'une des trois architectures portées, puisque c'est l'architecture qui conditionne l'export, pas le checkpoint.
La faisabilité n'est donc plus la question, l'intérêt non plus. Reste le prix de ces cinq raisons, et c'est lui qui est mal documenté.
J'ai embarqué Qwen2.5-1.5B-Instruct dans une petite app qui génère des recettes de salade quand on secoue le téléphone, puis j'ai écrit un harnais d'évaluation pour vérifier ce qu'elle produisait vraiment. Le projet propose aujourd'hui cinq façons de générer : deux modèles Core AI portables, leurs deux variantes compilées à l'avance pour l'A18, et le modèle système d'Apple Intelligence. Tous passent par le même schéma et le même prompt, sans quoi il n'y a rien à comparer.
Les chiffres qui suivent ont été relevés sur un iPhone 16 (8 Go, A18) sous iOS 27.0 beta, avec des modèles exportés sous Xcode 27.0 beta et coreai-torch 0.4.1. Ce sont des mesures de laboratoire, pas des estimations. Tous les rapports bruts et toutes les configurations de build n'ont pas été conservés : le tableau de la troisième section porte donc une colonne « n / build » qui dit, ligne par ligne, ce que vaut la mesure.
Ce que le catalogue autorise vraiment
Le dépôt apple/coreai-models annonce un catalogue généreux : Gemma 3, Mistral, Mixtral, gpt-oss-20b, Phi-3 et Phi-4-mini, Muse-Glimmer, Qwen2.5, Qwen3, Qwen3-MoE, Qwen3-VL. En lisant la liste, on se croit devant un choix. Sur iOS, trois architectures sont réellement exportables : Mistral, Qwen2 / 2.5 et Qwen3. Les sept autres entrées n'ont pas d'implémentation iOS et font échouer l'export.
On peut le voir ici models/registry.py :
"gemma3_text": ModelEntry(macos_class=Gemma3ForCausalLM), # pas de ios_class
"gpt_oss": ModelEntry(macos_class=GptOssForCausalLM), # pas de ios_class
"mistral": ModelEntry(macos_class=MistralForCausalLM,
ios_class=MistralForCausalLMForiOS),
"mixtral": ModelEntry(macos_class=MixtralForCausalLM), # pas de ios_class
"muse_glimmer_text": ModelEntry(macos_class=MuseGlimmerForCausalLM), # pas de ios_class
"phi3": ModelEntry(macos_class=Phi3ForCausalLM), # pas de ios_class
"qwen2": ModelEntry(macos_class=Qwen2ForCausalLM,
ios_class=Qwen2ForCausalLMForiOS),
"qwen3": ModelEntry(macos_class=Qwen3ForCausalLM,
ios_class=Qwen3ForCausalLMForiOS),
"qwen3_moe": ModelEntry(macos_class=Qwen3MoeForCausalLM), # pas de ios_class
"qwen3_vl": ModelEntry(macos_class=Qwen3VLForCausalLM), # pas de ios_class
(Les commentaires sont les miens. Les hooks multimodaux du fichier d'origine sont élidés.)
Un export iOS exige une implémentation dédiée, distincte de celle de macOS : les tailles de tenseurs sont fixées lors de la conversion. Ce sont les trois ios_class du commit, Qwen2 couvrant Qwen2.5 via MODEL_TYPE_REMAPPING.
Aucun flag ne rattrape les sept autres : le pipeline d'export s'arrête sur ValueError: Model '<type>' does not support iOS variant. Ce n'est pas une restriction éditoriale, l'implémentation iOS correspondante manque simplement.
Par-dessus ces architectures, le registre ne propose que trois presets iOS clés en main (qwen3-0.6b, qwen2.5-1.5b-instruct, qwen3-4b), tous réglés sur 4 096 tokens de contexte. IOS_DEFAULT_MAX_CONTEXT_LENGTH fixe ce défaut, que --max-context-length relève jusqu'au max_position_embeddings du checkpoint, le jeu de poids publié que l'on convertit. Trois presets ne font pas trois modèles : un preset n'est qu'un jeu de valeurs par défaut, et l'export iOS accepte de s'en passer, dans un mode qu'il qualifie d'expérimental.
En fournissant soi-même la précision, la compression et le contexte, on exporte n'importe quel checkpoint dont l'architecture figure parmi les trois portées sur iOS. Mistral en fait partie, sans avoir de preset associé. C'est par là que j'ai sorti un Qwen3-4B-Instruct-2507, absent du registre :
uv run coreai.llm.export Qwen/Qwen3-4B-Instruct-2507 \
--platform iOS --experimental \
--compression-config models/qwen3/qwen3_4b_mixed_4bit_8bit.yaml \
--compute-precision float16 \
--max-context-length 4096
Ce contexte dimensionne le KV cache statique, le tampon des tokens déjà traités : 4 096 positions réservées d'emblée, figées à la conversion, donc payées en mémoire dès le chargement. Le flag ne sert qu'à dépasser le défaut, plafonné au max_position_embeddings du checkpoint, qui vaut ici 262 144 tokens.
Sans --experimental, l'export échoue immédiatement. Le message :
Error: 'Qwen/Qwen3-4B-Instruct-2507' has no registry preset. Pass --experimental to try exporting it anyway (requires --compute-precision).
See models/README.md for supported models.
This model may not be suitable for iOS application due to its memory requirements.
Trois informations : le checkpoint n'a pas de preset, l'option --experimental autorise l'export quand même, et elle oblige alors à déclarer --compute-precision, puisque plus aucun preset ne fournit cette valeur. La dernière ligne résume assez bien le reste de cet article.
Le nombre total de modèles disponibles est donc bien plus important, tout en restant limité à trois familles. Entre le 0,6B et le 4B des presets, Qwen3-1.7B occupe précisément le palier qui manque à un iPhone doté de 8 Go : plus grand que le Qwen2.5-1.5B testé, sans atteindre les 2,3 Go de l'export Qwen3-4B. Il n'est pas dans le registre, et je ne l'ai pas testé.
Le simulateur et le setup qu'il impose
La contrainte suivante apparaît à la première compilation, et elle ne concerne plus le modèle mais la façon de travailler. Le framework d'inférence n'est livré que dans le SDK appareil :
iPhoneOS27.0.sdk → System/Library/Frameworks/CoreAI.framework
iPhoneSimulator27.0.sdk → absent
Deux vérifications valent mieux qu'une intuition. Avec un runtime iOS 27.0 explicitement ciblé (installé et fonctionnel malgré un hôte macOS 26), le build échoue sur Unable to resolve module dependency: 'CoreAI'. Et comme un SDK est un dossier à l'intérieur d'Xcode.app, aucune mise à jour de macOS ne fera apparaître ce framework côté simulateur.
Sans framework dans le SDK, aucune inférence en simulateur. Plus agaçant : la compilation échoue aussi, à cause d'un guard manquant dans le package lui-même. C'est l'objet de l'issue #49.
The library targets currently guard only the macOS-x86_64 case (
#if !((os(macOS) || targetEnvironment(macCatalyst)) && arch(x86_64))), so every source thatimport CoreAIfails to compile for an iOS Simulator destination.
Un #if canImport(CoreAI) suffirait à faire compiler la slice simulateur en éliminant le chemin Core AI. On ne pourrait toujours pas y exécuter d'inférence Core AI, mais on récupérerait les previews SwiftUI, la navigation et les UI tests. Le modèle Apple Intelligence peut au moins y être compilé, puisque FoundationModels est livré dans le SDK simulateur. La disponibilité effective du modèle système reste une question distincte. J'ai fini par écrire ce guard à la main, dans un fichier qui ne fait pourtant que déclarer un DTO :
// Sources/Data/DTOs/GeneratedSaladRecipe.swift
import FoundationModels
// La target de previews ne lie pas CoreAILM : sans ce canImport, ce fichier — qui
// n'a par ailleurs aucun lien avec Core AI — empêche à lui seul toute preview de
// compiler.
#if canImport(CoreAILanguageModels)
import CoreAILanguageModels
#endif
Une target de previews séparée, plus un canImport dans les fichiers partagés qui mentionnent le package : c'est le workaround retenu dans le projet. Le rapport est ouvert, accompagné d'un Feedback Apple (FB23189921). Apple ne documente pas explicitement cette limitation dans les pages consultées. L'attribuer à l'absence d'équivalent simulé de l'accélérateur matériel serait plausible, mais resterait une hypothèse.
En attendant, la facture se paie à chaque itération. L'app entière lie CoreAILM, le produit SPM qui enveloppe CoreAI.framework : elle ne compile donc pas pour le simulateur. Le bundle de tests est réservé à l'appareil et doit être signé.
Sans DEVELOPMENT_TEAM sur la target de tests, les évaluations ne démarrent même pas.
Et chaque mesure exige un iPhone branché et déverrouillé : sur la session qui a produit les chiffres de cet article, six exécutions sont mortes en attendant un déverrouillage, dont deux sans produire une seule génération.
C'est un coût de développement et non d'exécution, ce qui explique peut-être qu'on n'en parle jamais : il ne se voit pas dans l'app finie, seulement dans la boucle qui l'a produite.
Ce que la machine encaisse
L'iPhone 16 testé dispose de 8 Go de mémoire unifiée. C'est le budget dans lequel doivent tenir le modèle, le cache KV, le runtime et l'app elle-même. Qwen2.5-1.5B en 4 bits palettisés occupe 874 Mo une fois exporté, le Qwen3-4B 2,3 Go.
Le Qwen3-4B tient dans le bundle, pas dans la machine
Et contre toute attente, ce n'est pas son poids sur disque qui disqualifie le 4B portable. Il charge en 1,1 s et n'a pas déclenché la limite mémoire pendant ces essais, mais ne produit rien d'utilisable : 26 minutes sur une génération guidée, puis plus de 9 minutes sans achever 32 tokens de texte libre sans schéma. Le décodage contraint n'explique donc pas, à lui seul, l'écart observé. La cause reste inconnue : c'est ce que cherche à isoler Tests/Evaluations/DecodeSpeedProbe.swift :
@Test("Décodage libre, plafond 32 tokens")
func plainDecode32() async throws {
let model = try await CoreAILanguageModel(resourcesAt: try Self.modelURL())
defer { model.unload() }
let session = LanguageModelSession(model: model)
let start = ContinuousClock.now
let response = try await session.respond(
to: "Nomme trois légumes verts.",
options: GenerationOptions(maximumResponseTokens: 32)
)
InferenceMetrics.emit("probe.plain32", since: start, detail: "chars=\(response.content.count)")
}
Si le texte libre est rapide et le guidé bloque, le coût est dans le décodage contraint. Si les deux sont lents, le problème se situe plus bas. Les deux le sont, et le Qwen3-4B de base, exporté depuis le preset du registre, reproduit le problème. Le schéma guidé, le checkpoint Instruct-2507 et le chemin --experimental sont donc écartés comme causes uniques.
Reste une hypothèse : le modèle ne tient pas sur l'Apple Neural Engine (ANE) et se replie sur un chemin plus lent. Quatre indices la soutiennent.
- L'export iOS vise l'Apple Neural Engine.
ModelStructureassocie la structurechunkedStaticàSpecializationOptions(preferredComputeUnitKind: .neuralEngine). - La compilation Apple Neural Engine a lieu sur l'appareil. L'artefact ne contient pas du code prêt à l'emploi mais du bitcode MLIR par région, nommé
..._ANE_region_0_0.mlir.bc. - C'est elle qui échoue sur le Qwen3-4B.
Failed to allocate memory for file backing, puisANECCompile() FAILEDcode 22 — alors qu'elle passe sur le 1.5B, dont le paquet fait 868 Mio contre 2,1 Gio. - Le budget est plus serré que ne le suggèrent les poids. À 4 096 tokens, le KV cache statique du Qwen3-4B occupe 576 Mio en fp16 contre 112 Mio pour le 1.5B, cinq fois plus : 36 couches et 8 têtes KV contre 28 et 2, calculé depuis les configs des checkpoints. Les journaux montrent aussi des échecs ponctuels de
mach_vm_allocate().
L'hypothèse est donc que ce qui échoue franchement en en compilation ahead-of-time (AOT, détaillée plus bas) dégrade silencieusement le chemin portable : les régions Apple Neural Engine du Qwen3-4B ne se matérialisent pas dans le budget disponible, et le décodage se replie sur un chemin bien plus lent. Elle est réfutable, et le test tient en une ligne : forcer preferredComputeUnitKind: .gpu sur le 1.5B. Si sa latence s'effondre au même ordre de grandeur, le repli est confirmé. Je ne l'ai pas encore mesuré. En pratique : le 1.5B répond, le 4B testé non. D'où l'intérêt d'un palier intermédiaire.
Cinq configurations sur le même iPhone
C'est aussi pour cela que l'app rend tous ces modèles sélectionnables depuis un menu développeur. Cinq entrées, un seul schéma @Generable, un seul prompt, un seul seed : le modèle chargé est la seule variable qui change d'une mesure à l'autre. Les deux modèles portables totalisent environ 3,2 Go. Avec leurs variantes AOT, le bundle de laboratoire approche 6,8 Go. Cette configuration n'est pas destinée à être distribuée : elle sert à comparer les mêmes modèles et les mêmes ingrédients, sur le même appareil, l'un après l'autre.
| Modèle | Poids dans l'app | Premier lancement | Génération mesurée | n / build |
|---|---|---|---|---|
| Qwen2.5-1.5B portable | 874 Mo | ≈ 2 min | 14 à 25 s | séries du dépôt, build non consigné |
| Qwen3-4B-Instruct-2507 portable | 2,3 Go | non isolé | aucun résultat utilisable en 26 min | 1 génération guidée, 1 sonde de 32 tokens |
Qwen2.5-1.5B AOT (h17p) | 1,1 Go | 12,6 s | 8 à 20 s | n = 3, Debug |
Qwen3-4B-Instruct-2507 AOT (h17p) | 2,5 Go | échec de compilation Apple Neural Engine sur l'appareil | aucun token avant l'arrêt de l'app après ≈ 42 min | 1 tentative |
| Apple Intelligence | 0 Mo | géré par le système | 4,4 à 5,8 s | conditions non consignées |
Cinq configurations sur un même iPhone 16, pas un benchmark de modèles : la colonne de droite dit ce que vaut chaque ligne. Les variantes h17p ne servent que sur l'architecture pour laquelle elles ont été compilées. |
Ce que chaque ligne du menu doit dire, l'enum le porte :
// Sources/Infrastructure/DI/GenerationBackend.swift
var footprint: String {
switch self {
case .qwen25: "874 MB dans l'app · Core AI"
case .qwen3: "2,3 GB dans l'app · Core AI"
case .qwen25AOT: "1,1 GB dans l'app · Core AI précompilé"
case .qwen3AOT: "2,5 GB dans l'app · Core AI précompilé"
case .appleIntelligence: "0 MB dans l'app · modèle système"
}
}
/// Non-nil quand le backend est connu pour mal se comporter : le menu le dit,
/// plutôt que de laisser quelqu'un le redécouvrir en attendant.
var knownIssue: String? { … }
Le dernier modèle est celui qui recadre tous les chiffres de cette section : le modèle système d'Apple Intelligence pèse 0 Mo dans l'app. Pas de .aimodel à exporter, pas d'entitlement mémoire propre au modèle, pas de spécialisation de graphe à gérer, et il compile pour le simulateur. Son prix est ailleurs, et il est aussi réel : le modèle peut être indisponible (appareil non compatible, Apple Intelligence désactivé dans les Réglages ou modèle pas encore prêt), et on ne choisit ni sa taille, ni sa version, ni le moment où elle change. Embarquer son propre modèle, c'est acheter ces trois certitudes au prix fort.
Encore faut-il obtenir le droit d'occuper cette place. C'est le Qwen3-4B qui l'a imposé : sans les entitlements com.apple.developer.kernel.increased-memory-limit et extended-virtual-addressing, il dépasse la limite mémoire dès le chargement et l'app meurt sur EXC_RESOURCE … high watermark memory limit exceeded. Ces entitlements exigent un provisioning profile valide, donc une development team configurée, y compris sur la target de tests.
Le premier lancement coûte deux minutes
Exemple de métriques produites par l'instrumentation :
[METRIC] phase=model.load ms=1026
[METRIC] phase=generate.respond ms=16639 chars=448 chars_per_s=26.9
Le chargement du modèle coûte environ une seconde à chaud. La spécialisation du graphe pour l'appareil, elle, se paie une fois : environ deux minutes au premier décodage après installation pour le 1.5B portable, puis Core AI réutilise son cache.
Point important découvert par la mesure : session.prewarm() seul ne déclenche pas cette spécialisation. Le projet effectue donc une génération libre d'un token au lancement.
La génération d'une recette complète prend ensuite, selon les séries consignées dans le dépôt, environ 14 à 25 secondes. Sur une interaction où l'utilisateur secoue son téléphone et attend, cette latence dicte le design de l'écran d'attente.
// Sources/Data/DataSources/CoreAIDataSource.swift
let session = LanguageModelSession(model: model)
session.prewarm()
_ = try? await session.respond(
to: "Bonjour",
options: GenerationOptions(maximumResponseTokens: 1)
)
model.unload()
Déplacer les 2 minutes de compilation du modèle sur le Mac
Ces deux minutes, on peut cependant les déplacer sur le Mac. C'est l'objet de la compilation ahead-of-time, dont la doc résume l'intérêt en une phrase : « The tool moves the most expensive part of specialization, model compilation, to your build machine ». Il faut pour cela le Metal Toolchain (Xcode > Settings > Components, ou xcodebuild -downloadComponent MetalToolchain) et viser un appareil éligible à Apple Intelligence, ce que la doc borne explicitement : iPhone ou iPad à partir de l'A17 Pro, Mac à partir du M1, Vision Pro à partir du M2.
xcrun coreai-build compile MyModel.aimodel \
--platform iOS --min-deployment-version 27.0 --output compiled/
Et c'est là que se cache le vrai coût : la sortie n'est pas un binaire universel, mais un .aimodelc par architecture d'appareil, nommé MyModel.<arch>.aimodelc, où <arch> correspond à la valeur renvoyée par AIModel.deviceArchitectureName. Chaque appareil n'en utilise qu'un. On échange donc une grande partie du coût du premier lancement contre une matrice d'artefacts à produire, distribuer et versionner.
Je l'ai finalement testé pour l'architecture h17p de l'iPhone 16 : le premier lancement du 1.5B est passé d'environ 125 secondes à 12,6 secondes, au prix d'un artefact 226 Mo plus lourd. Les temps de décodage, mesurés en Debug sur seulement trois recettes, restent du même ordre de grandeur. Et le troc n'est pas total : même précompilé, l'artefact demande encore une spécialisation résiduelle sur l'appareil.
Trois règles qu'on n'apprend qu'en mesurant
Ces mesures ne tiennent que si trois règles sont respectées. Aucune n'est documentée comme telle, et les deux premières se paient en silence : l'app continue de tourner, en moins bien.
Une session par génération. Un transcript réutilisé conditionne chaque réponse sur les précédentes, ce qui annule la variation qu'on cherche à obtenir avec un seed. Décharger le moteur entre deux appels en guided generation. Dans la version testée, une fois qu'il a produit un structured output, l'appel guidé suivant échoue en quelques millisecondes avec The selected model does not support guided generation. Il s'agit d'un comportement observé du runtime, pas d'une limitation générale documentée. Recréer la session ne suffit pas : il faut décharger explicitement le modèle pour reconstruire le moteur.
// Sources/Data/DataSources/CoreAIDataSource.swift
func generate(ingredients: [Ingredient], seed: Int) async throws -> GeneratedSaladRecipe {
let model = try await loadModel()
defer { model.unload() } // sinon : une seule salade par session
let session = LanguageModelSession(model: model) // une session par génération
let prompt = SaladPrompt.text(ingredients: ingredients, seed: seed)
let options = GenerationOptions(maximumResponseTokens: 400)
let response = try await session.respond(
to: prompt,
generating: GeneratedSaladRecipe.self,
options: options
)
return response.content
}
Sans ce unload, seule la première salade de la session sortait du modèle : le repository attrapait l'erreur et servait silencieusement son fallback local pour toutes les suivantes. Un bug qui ne casse rien, qui ne lève rien, et qui rend l'app moins bonne sans le dire.
Le rechargement qui suit coûte environ une seconde, négligeable devant le temps de décodage.
Fixer soi-même le plafond de tokens. Ce n'est pas un détail de confort : sans GenerationOptions(maximumResponseTokens:), c'est le package Swift qui impose le sien, et il dépend du modèle chargé. CoreAILanguageModel sonde le tokenizer pour reconnaître un reasoning model, puis choisit son défaut en conséquence :
// CoreAILanguageModels/LanguageModel/CoreAILanguageModel.swift
supportsReasoning =
tokenizer.convertTokenToId("<think>") != nil
|| tokenizer.convertTokenToId("<|reasoning_start|>") != nil
// …
let defaultMaxTokens = model.supportsReasoning ? 2048 : 512
Il suffit qu'un marqueur d'ouverture figure dans le vocabulaire : le test interroge le vocabulaire, pas le comportement. Qwen2.5-1.5B n'a pas <think> dans son tokenizer et tombe donc à 512.
Qwen3-4B-Instruct-2507 l'a, alors que cette variante 2507 est justement la version sans reasoning : elle hérite du token sans jamais l'émettre, et reçoit quand même 2 048 tokens de budget par défaut. Un modèle se retrouve donc classé « reasoning » par son dictionnaire.
Mon plafond de 400 tokens ne risque pas de tronquer une trace de raisonnement sur ce checkpoint, puisqu'il n'en produit pas ; il reste que le budget par défaut se décide sur un indice indirect, et qu'il vaut mieux le fixer soi-même que le laisser déduire.
Ce que le modèle produit vraiment
Reste la question que personne ne pose : la sortie est-elle bonne ? Je l'ai mesurée avec le framework Evaluations d'iOS 27, sur trente jeux d'ingrédients. Le harnais lui-même est un sujet à part, que je traiterai dans un article séparé. Je ne garde ici que trois résultats, ceux qui changent la façon d'écrire la fonctionnalité. Les séries citées comptent 29 puis 28 générations abouties, et leurs rapports bruts ne sont plus dans le dépôt.
Premier résultat : ce qui peut devenir une contrainte de schéma est tenu, le reste ne l'est pas.
| Règle | Écrite dans le prompt | Inscrite dans le schéma |
|---|---|---|
| Six étapes au maximum | 0,00 | 1,00 avec .maximumCount(6) |
| Sauce parmi celles que l'app connaît | 0,79 | 0,96 avec .anyOf |
| Aucune cuisson | 0,62 | inexprimable |
// Sources/Data/DTOs/GeneratedSaladRecipe.swift
@Guide(description: "Sauce choisie parmi celles disponibles",
.anyOf(["Huile d'olive", "Citron", "Vinaigrette miel", "Sauce yaourt"]))
var sauce: String
@Guide(description: "Liste de 4 à 6 étapes de préparation, chacune commençant par un verbe d'action (ex : Couper, Mélanger, Assaisonner)", .maximumCount(6))
var steps: [String]
Ce sont des taux de conformité : 1,00 signifie que toutes les générations respectent la règle. Les deux premières lignes disent la même chose : une règle déplacée du prompt vers le schéma ne change pas de contenu, seulement de statut, elle passe de demande à impossibilité technique. Et .maximumCount(6) a même généré plus vite qu'en l'absence de contrainte.
La troisième ligne dit la limite. « Aucune cuisson » ne s'énumère pas : aucun @Guide ne l'exprime, la règle reste donc au prompt, à 0,62, et le seul recours est de la mesurer. Les contraintes du schéma ont d'ailleurs leur propre bord : le plancher de quatre étapes n'est garanti par rien, le schéma ne borne que le maximum. Et .anyOf énumère les quatre sauces que l'app connaît, pas celles dont l'utilisateur dispose pour la recette en cours : le modèle ne peut plus inventer une cinquième sauce, il peut encore en choisir une qui n'est pas dans les ingrédients tirés.
Deuxième résultat : les mots du prompt ressortent dans la sortie. Le prompt disait « chef créatif » et « salade fun », et le guide du champ name parlait de « nom fun ». Sur vingt-neuf générations, 86 % des noms contenaient « Fun » et quatre s'appelaient « Chef Cuisinier » ou « Chef Gourmand ». Le modèle n'était pas à court d'imagination, il obéissait à la lettre : à 1,5 milliard de paramètres, il ne distingue pas un adjectif qui décrit une intention d'un adjectif qui décrit un contenu. Les retirer n'a pas suffi, il a fallu décrire la forme attendue et donner trois exemples. À l'exécution suivante, vingt-huit noms distincts sur vingt-huit, contre neuf sur vingt-neuf. Même mécanique avec le seed : des angles de style numérotés « Variation N/7 » se sont retrouvés recopiés dans le champ sauce.
Troisième résultat : une interdiction aggrave ce qu'elle interdit. J'avais écrit, pour la punchline, « n'écris ni voici ni servez ». Le taux de punchlines correctes est tombé de 0,276 à 0,107, et vingt-cinq sur vingt-huit commençaient par « Servez » ou « Voici ».
Sur ce modèle, une interdiction qui nomme le mot interdit augmente sa probabilité.
À cette échelle, la négation n'est pas pondérée, elle est consommée comme des tokens. La seule consigne qui ait fonctionné était formulée entièrement en positif, avec des exemples et aucun interdit.
Verdict
Embarquer un modèle n'est pas la partie difficile. Le package expose les mêmes API que FoundationModels, même LanguageModelSession, même @Generable, et une dizaine de lignes suffisent à sortir une première recette. Le prix est ailleurs, dans le setup et dans l'attente.
C'est cette même API qui rend le verdict facile à formuler : sur un iPhone compatible, le modèle système d'Apple Intelligence gagne presque partout. 0 Mo dans le bundle contre 874 Mo, 4,4 à 5,8 secondes contre 14 à 25, aucun export à produire, aucun artefact par architecture à distribuer, et il compile pour le simulateur. Un détail achève la comparaison : la compilation ahead-of-time, seul moyen de ramener le premier lancement à 12,6 secondes, exige un appareil à partir de l'A17 Pro. Elle n'existe donc que sur les appareils qui savent déjà faire tourner le modèle système.
Ce « presque » compte. Embarquer son modèle achète trois certitudes que le modèle système ne vend pas : tourner sur un appareil non éligible, figer une version, connaître sa taille. Une app grand public qui doit fonctionner sur les iPhone antérieurs à l'A17 Pro n'a pas le choix, et le parc en compte beaucoup. Un service soumis à une exigence de conformité, qui doit pouvoir rejouer une sortie sur une version auditable du modèle, non plus. Dans ces deux cas, tout ce que cet article a inventorié n'est pas un surcoût mais le prix d'entrée.
Partout ailleurs, autant commencer par FoundationModels et garder son énergie pour ce que le schéma doit imposer.
Sources
apple/coreai-models: recettes d'export, presets iOS et package Swift CoreAILM- RecipeAI / local-llm-ios : code de l'app, sondes, instrumentation et commentaires de mesure
- Commit coreai-models vérifié : 244d8e0cc7811a30099e90dd89492521cf734e27
models/registry.py: les dix familles et leursios_class- Issue #49 : Swift package fails to compile for the iOS Simulator, le guard manquant et le correctif proposé
- Compiling Core AI models ahead of time : Apple Developer Documentation
- Managing model specialization and caching : Apple Developer Documentation
GenerationGuide:count,minimumCount,maximumCount,anyOfcom.apple.developer.kernel.increased-memory-limit: Apple Developer Documentation- Evaluating language model responses : Apple Developer Documentation
- Book Tracker: Using Evaluations to evaluate an intelligent feature : échantillon de code Apple
- Meet the Evaluations framework : WWDC26, session 298
- Improve your prompts by hill-climbing with Evaluations : WWDC26, session 335
