Introduction : pourquoi Foundation Models change la donne pour les développeurs iOS
Imaginez un instant : vous ajoutez de la génération de texte, du résumé automatique, de l'extraction d'entités ou de l'analyse de sentiment à votre app iOS — le tout sans aucun appel réseau, sans frais d'API, et avec une confidentialité totale des données. Ça semble trop beau ? C'est pourtant exactement ce que propose le framework Foundation Models, qu'Apple a dévoilé à la WWDC 2025 avec iOS 26.
Concrètement, ce framework expose le modèle de langage de 3 milliards de paramètres qui propulse Apple Intelligence, directement via une API Swift. Tout tourne on-device : aucune donnée ne quitte l'appareil, le modèle ne gonfle pas la taille de votre app (il fait partie du système d'exploitation), et il fonctionne même sans connexion internet.
Mais ce qui m'a vraiment impressionné en tant que développeur, c'est l'approche type-safe. Grâce aux macros @Generable et @Guide, on peut demander au modèle de produire des structures Swift typées — pas du JSON brut à parser à la main, mais de vraies instances de vos propres structs et enums. Apple appelle ça la génération guidée (guided generation), et honnêtement, c'est un vrai changement de paradigme.
Alors, dans ce guide, on couvre tout de A à Z. De la configuration initiale à l'appel d'outils (tool calling), en passant par la génération guidée, le streaming et la gestion de la fenêtre de contexte. Avec des exemples de code fonctionnels à chaque étape. Allons-y.
Prérequis et vérification de la disponibilité
Avant de plonger dans le code, quelques points à vérifier.
Configuration requise
- Xcode 26 ou supérieur
- macOS Tahoe (macOS 26) sur votre machine de développement
- Appareil ou simulateur sous iOS 26+, iPadOS 26+, macOS 26+ ou visionOS 26+
- Apple Intelligence doit être activé sur l'appareil cible
Tous les appareils ne prennent pas en charge Apple Intelligence — c'est important de le garder en tête. Il est donc essentiel de vérifier la disponibilité du modèle avant de proposer des fonctionnalités IA à l'utilisateur.
Vérifier la disponibilité du modèle
import FoundationModels
let model = SystemLanguageModel.default
switch model.availability {
case .available:
print("Modèle disponible et prêt")
case .unavailable(.deviceNotEligible):
print("Cet appareil ne supporte pas Apple Intelligence")
case .unavailable(.appleIntelligenceNotEnabled):
print("Apple Intelligence n'est pas activé")
case .unavailable(.modelNotReady):
print("Le modèle est en cours de téléchargement")
default:
print("Modèle non disponible")
}
Cette vérification est vraiment indispensable. Si le modèle n'est pas dispo, vous pouvez afficher un message explicatif ou prévoir un fallback vers une API cloud. Personne n'aime une app qui plante silencieusement parce qu'une fonctionnalité n'est pas supportée.
LanguageModelSession : votre premier échange avec le modèle
Toute interaction avec le modèle on-device passe par un objet LanguageModelSession. Ce qu'il faut retenir : cette session est stateful. Elle conserve l'historique des échanges entre chaque appel, ce qui permet au modèle de garder le fil de la conversation.
Génération de texte simple
import FoundationModels
let session = LanguageModelSession()
let response = try await session.respond(to: "Explique le pattern MVVM en trois phrases.")
print(response.content)
Oui, c'est aussi simple que ça. Pas de clé API, pas de SDK externe. Le framework est inclus nativement dans le SDK iOS 26. Trois lignes et vous avez une réponse du modèle.
Configurer des instructions (l'équivalent du system prompt)
Si vous avez déjà bossé avec d'autres LLM, vous connaissez le concept de system prompt. Dans Foundation Models, on parle d'instructions. Elles définissent le comportement du modèle pour toute la durée de la session :
@State var session = LanguageModelSession {
"""
Tu es un assistant spécialisé dans le développement iOS.
Tu réponds toujours en français, de manière concise et avec des exemples de code Swift quand c'est pertinent.
Tu ne réponds qu'aux questions liées au développement Apple.
"""
}
Point crucial ici : les instructions ne doivent jamais venir de l'utilisateur. C'est le développeur qui les définit à la création de la session. Les prompts, eux, peuvent être fournis par l'utilisateur. Cette séparation est fondamentale pour la sécurité (et c'est un piège classique quand on débute avec les LLM).
Préchauffage du modèle avec prewarm
Le chargement initial du modèle en mémoire prend quelques instants. Si votre interface offre un temps d'attente naturel — par exemple pendant que l'utilisateur tape son texte — vous pouvez préchauffer le modèle pour réduire la latence ressentie :
try await session.prewarm(promptPrefix: "Analyse le code suivant")
Le préchauffage est surtout utile quand l'utilisateur va déclencher la génération manuellement. Pour des suggestions proactives qui s'affichent automatiquement, c'est moins pertinent.
Génération guidée avec @Generable et @Guide
Bon, on arrive à ce qui est probablement la fonctionnalité la plus impressionnante du framework. La génération guidée permet au modèle de retourner directement des instances typées de vos structures Swift. Pas du texte brut, pas du JSON à parser manuellement — de vrais objets Swift, prêts à l'emploi.
Le principe : décodage contraint
Quand vous annotez une struct avec @Generable, le compilateur génère automatiquement un schéma JSON qui correspond à votre type. Au moment de la génération, le modèle est contraint de produire une sortie conforme à ce schéma. C'est du décodage contraint au niveau des tokens — le modèle ne peut littéralement pas produire une sortie invalide par rapport à votre struct. Plutôt élégant, non ?
Exemple simple : générer un quiz
import FoundationModels
@Generable
struct Question {
@Guide(description: "La question posée à l'utilisateur")
var texte: String
@Guide(description: "Les quatre options de réponse possibles")
var options: [String]
@Guide(description: "L'index de la bonne réponse (0-3)")
var indexReponseCorrecte: Int
@Guide(description: "Explication de la bonne réponse")
var explication: String
}
let session = LanguageModelSession()
let response = try await session.respond(
to: "Génère une question de quiz sur le framework SwiftUI.",
generating: Question.self
)
let question = response.content
print(question.texte) // La question générée
print(question.options) // Les 4 options
print(question.explication) // L'explication
Un détail important à ne pas négliger : l'ordre de déclaration des propriétés compte. Le modèle génère les valeurs séquentiellement, dans l'ordre où elles sont déclarées. Ici, explication vient après indexReponseCorrecte, ce qui garantit que l'explication se réfère à la bonne réponse. Inversez l'ordre et vous risquez d'obtenir une explication incohérente.
Types composables et imbriqués
La vraie puissance de @Generable, c'est sa composabilité. Vous pouvez imbriquer des types pour créer des structures de données aussi complexes que nécessaire :
@Generable
enum TypeActivite: String {
case visite, restaurant, transport, repos
}
@Generable
struct Activite {
@Guide(description: "Type de l'activité")
var type: TypeActivite
@Guide(description: "Nom de l'activité")
var nom: String
@Guide(description: "Description en une phrase")
var description: String
@Guide(description: "Heure estimée au format HH:mm")
var heure: String
}
@Generable
struct JourItineraire {
@Guide(description: "Titre du jour, ex: Jour 1 - Arrivée")
var titre: String
@Guide(description: "Liste des activités de la journée")
var activites: [Activite]
}
@Generable
struct Itineraire {
@Guide(description: "Ville de destination")
var destination: String
@Guide(description: "Les journées de l'itinéraire")
var jours: [JourItineraire]
}
let session = LanguageModelSession()
let response = try await session.respond(
to: "Crée un itinéraire de 3 jours pour visiter Lyon.",
generating: Itineraire.self
)
let itineraire = response.content
for jour in itineraire.jours {
print(jour.titre)
for activite in jour.activites {
print(" \(activite.heure) - \(activite.nom) (\(activite.type))")
}
}
Les enums @Generable sont particulièrement intéressantes. Le modèle est garanti de ne produire que des valeurs valides de votre enum — zéro risque d'hallucination sur des cas inexistants. C'est le genre de garantie que les développeurs Swift adorent.
Contraintes avancées avec @Guide
La macro @Guide ne se limite pas aux descriptions textuelles. On peut aussi imposer des contraintes programmatiques, et c'est là que ça devient vraiment puissant :
@Generable
struct Recette {
@Guide(description: "Nom de la recette")
var nom: String
@Guide(description: "Niveau de difficulté", .anyOf(["Facile", "Moyen", "Difficile"]))
var difficulte: String
@Guide(description: "Temps de préparation en minutes", .range(5...180))
var tempsPrepMinutes: Int
@Guide(description: "Liste des ingrédients", .count(5...15))
var ingredients: [String]
@Guide(description: "Étapes de la recette")
var etapes: [String]
}
Les contraintes disponibles incluent :
.anyOf([...])— restreint la sortie à un ensemble de valeurs autorisées.count(...)— fixe le nombre d'éléments dans un tableau.range(...)— définit un intervalle pour les valeurs numériques
L'avantage ? Au lieu de détailler dans le prompt « donne-moi entre 5 et 15 ingrédients », vous le spécifiez directement dans le type. Le modèle est garanti de respecter la contrainte. Vos prompts deviennent plus simples et plus ciblés.
Streaming des réponses : affichage progressif dans SwiftUI
Pour les réponses un peu longues, attendre la génération complète avant d'afficher quoi que ce soit, c'est une mauvaise expérience utilisateur. Le streaming résout ça en affichant la réponse au fur et à mesure qu'elle est générée.
Streaming de texte brut
struct ChatView: View {
@State private var session = LanguageModelSession()
@State private var reponse = ""
@State private var isGenerating = false
var body: some View {
VStack {
ScrollView {
Text(reponse)
.padding()
}
Button("Générer") {
Task {
isGenerating = true
reponse = ""
let stream = session.streamResponse(to: "Explique les closures en Swift.")
for try await partialResponse in stream {
reponse = partialResponse.content
}
isGenerating = false
}
}
.disabled(isGenerating)
}
}
}
Streaming structuré avec @Generable
Et là, le vrai tour de force. Le streaming fonctionne aussi avec les types @Generable. La macro génère automatiquement un type PartiallyGenerated — un miroir de votre struct où chaque propriété est optionnelle. Au fil de la génération, les propriétés se remplissent progressivement :
struct ItineraireView: View {
@State private var session = LanguageModelSession()
@State private var itineraire: Itineraire.PartiallyGenerated?
@State private var isGenerating = false
var body: some View {
List {
if let destination = itineraire?.destination {
Section("Destination") {
Text(destination)
.font(.title2)
}
}
if let jours = itineraire?.jours {
ForEach(jours.indices, id: \.self) { index in
if let jour = jours[index] {
Section(jour.titre ?? "Chargement...") {
if let activites = jour.activites {
ForEach(activites.indices, id: \.self) { i in
if let activite = activites[i] {
HStack {
Text(activite.heure ?? "")
.monospacedDigit()
Text(activite.nom ?? "...")
}
}
}
}
}
}
}
}
}
.task {
isGenerating = true
let stream = session.streamResponse(
to: "Itinéraire de 2 jours à Bordeaux.",
generating: Itineraire.self
)
for try await partial in stream {
itineraire = partial.content
}
isGenerating = false
}
}
}
Le résultat à l'écran est vraiment saisissant. Le titre de la destination apparaît d'abord, puis les jours se remplissent un par un, avec leurs activités qui s'affichent progressivement. C'est infiniment mieux qu'un spinner de chargement classique — l'utilisateur voit la réponse se construire en temps réel, propriété par propriété.
Tool Calling : donner de nouvelles capacités au modèle
Le modèle on-device a beau être puissant, il ne peut pas tout savoir. Il n'a pas accès à la météo actuelle, à votre base de données, ni à d'autres données en temps réel. C'est là qu'entre en jeu le Tool Calling : vous définissez des « outils » (en gros, des fonctions Swift) que le modèle peut invoquer automatiquement pendant une conversation.
Créer un outil personnalisé
Un outil implémente le protocole Tool. Il déclare ses paramètres via une struct @Generable imbriquée et retourne un ToolOutput :
import FoundationModels
import CoreLocation
import WeatherKit
struct ObtenirMeteo: Tool {
let name = "obtenirMeteo"
let description = "Retourne la température actuelle pour une ville donnée"
@Generable
struct Arguments {
@Guide(description: "Le nom de la ville")
var ville: String
}
func call(arguments: Arguments) async throws -> ToolOutput {
let geocoder = CLGeocoder()
let placemarks = try await geocoder.geocodeAddressString(arguments.ville)
guard let location = placemarks.first?.location else {
return ToolOutput("Ville introuvable : \(arguments.ville)")
}
let weather = try await WeatherService.shared.weather(for: location)
let temperature = weather.currentWeather.temperature
return ToolOutput("\(arguments.ville) : \(temperature.value.formatted(.number.precision(.fractionLength(1))))°C")
}
}
Utiliser l'outil dans une session
let session = LanguageModelSession(tools: [ObtenirMeteo()])
let response = try await session.respond(
to: "Quel temps fait-il à Paris aujourd'hui ?"
)
print(response.content)
// Exemple : "Il fait actuellement 18.5°C à Paris."
Ce qui est élégant ici, c'est que le modèle décide de manière autonome quand utiliser l'outil. Pas besoin de détecter l'intention côté développeur. Si la question concerne la météo, il appelle ObtenirMeteo ; sinon, il répond normalement. Les appels sont transparents — le modèle intègre le résultat directement dans sa réponse finale.
Combiner plusieurs outils
On peut bien sûr enregistrer plusieurs outils dans une même session. Le modèle choisira celui (ou ceux) qui sont pertinents selon le contexte :
struct RechercherContact: Tool {
let name = "rechercherContact"
let description = "Recherche un contact par nom dans le carnet d'adresses"
@Generable
struct Arguments {
@Guide(description: "Le nom du contact à rechercher")
var nom: String
}
func call(arguments: Arguments) async throws -> ToolOutput {
// Logique de recherche dans vos données
return ToolOutput("Contact trouvé : \(arguments.nom) - 06 12 34 56 78")
}
}
let session = LanguageModelSession(
tools: [ObtenirMeteo(), RechercherContact()]
)
// Le modèle choisit automatiquement le bon outil
let r1 = try await session.respond(to: "Quelle est la météo à Lyon ?")
let r2 = try await session.respond(to: "Trouve le numéro de Marie Dupont")
Le framework gère automatiquement les graphes d'appels — y compris les appels parallèles et séquentiels — quand plusieurs outils sont nécessaires pour répondre à une seule question. Pas mal pour un framework intégré au système.
Gestion de la fenêtre de contexte
Parlons maintenant d'une limite qu'il faut absolument connaître. Le modèle on-device d'Apple (foundation-small) dispose d'une fenêtre de contexte de 4 096 tokens. C'est le budget total pour les instructions, tous les prompts et toutes les réponses de la session.
C'est sensiblement moins que les LLM cloud (GPT-4 Turbo monte à 128K tokens), mais c'est cohérent avec les contraintes d'un modèle de 3 milliards de paramètres qui tourne sur un téléphone.
Que se passe-t-il quand la limite est atteinte ?
Quand la transcription de la session dépasse la fenêtre de contexte, la session lance une erreur exceededContextWindowSize. Il faut anticiper ce cas :
do {
let response = try await session.respond(to: userMessage)
// Traiter la réponse
} catch let error as LanguageModelSession.GenerationError {
switch error {
case .exceededContextWindowSize:
// Créer une nouvelle session avec un résumé de la conversation
let summary = try await summarizeConversation(session.transcript)
session = LanguageModelSession {
"Voici un résumé de la conversation précédente : \(summary)"
}
default:
print("Erreur de génération : \(error)")
}
}
Bonnes pratiques pour économiser les tokens
- Soyez concis dans vos instructions — chaque token d'instruction est « consommé » à chaque échange
- Privilégiez
@Generableau texte libre — la génération guidée est plus économe en tokens que de demander au modèle de formater du JSON en texte - Créez des sessions ciblées — une session par fonctionnalité, pas une session unique pour toute l'app
- Implémentez une stratégie de résumé — quand la conversation s'allonge, résumez et démarrez une nouvelle session
Apple fournit d'ailleurs une note technique dédiée à ce sujet (TN3193) qui détaille les stratégies de gestion du budget de tokens. Je recommande de la lire si vous comptez gérer des conversations longues.
Intégration complète dans SwiftUI : exemple d'app assistant
Mettons tout ça ensemble avec un exemple concret. On va construire un mini assistant de code SwiftUI, avec streaming et gestion d'état propre :
import SwiftUI
import FoundationModels
struct AssistantCodeView: View {
@State private var session = LanguageModelSession {
"""
Tu es un assistant spécialisé en SwiftUI.
Réponds en français avec des exemples de code concis.
Utilise les conventions de nommage Swift.
"""
}
@State private var prompt = ""
@State private var messages: [(role: String, content: String)] = []
@State private var currentResponse = ""
@State private var isGenerating = false
var body: some View {
NavigationStack {
VStack(spacing: 0) {
ScrollView {
LazyVStack(alignment: .leading, spacing: 12) {
ForEach(messages.indices, id: \.self) { index in
MessageBubble(
role: messages[index].role,
content: messages[index].content
)
}
if !currentResponse.isEmpty {
MessageBubble(role: "assistant", content: currentResponse)
}
}
.padding()
}
HStack {
TextField("Pose ta question SwiftUI...", text: $prompt)
.textFieldStyle(.roundedBorder)
Button {
Task { await sendMessage() }
} label: {
Image(systemName: "arrow.up.circle.fill")
.font(.title2)
}
.disabled(prompt.isEmpty || isGenerating)
}
.padding()
}
.navigationTitle("Assistant SwiftUI")
}
}
private func sendMessage() async {
let userMessage = prompt
prompt = ""
messages.append((role: "user", content: userMessage))
isGenerating = true
currentResponse = ""
do {
let stream = session.streamResponse(to: userMessage)
for try await partial in stream {
currentResponse = partial.content
}
messages.append((role: "assistant", content: currentResponse))
currentResponse = ""
} catch {
messages.append((role: "assistant", content: "Erreur : \(error.localizedDescription)"))
}
isGenerating = false
}
}
Cet exemple rassemble les points clés qu'on a vus : session configurée avec des instructions, streaming pour un affichage progressif, et gestion de l'état avec @State. La propriété isGenerating empêche l'envoi de messages pendant qu'une réponse se génère — et c'est important, parce que LanguageModelSession ne supporte pas les appels concurrents à respond sur une même session.
Bonnes pratiques et limites à connaître
Le framework Foundation Models est impressionnant, mais il faut être réaliste sur ses limites pour bien l'utiliser. Voici ce qu'il faut garder en tête.
Ce que le modèle fait bien
- Classification et catégorisation — trier des éléments dans des catégories prédéfinies
- Extraction d'entités — identifier des noms, dates, lieux dans du texte
- Résumé de texte court — condenser un paragraphe en une phrase ou deux
- Génération structurée — produire des données conformes à un schéma défini
- Analyse de sentiment — évaluer le ton d'un texte
Ce qu'il faut éviter
- Raisonnement complexe — avec 3 milliards de paramètres, le modèle reste moins performant que GPT-4 ou Claude pour les tâches de raisonnement avancé
- Génération de texte très long — la fenêtre de 4 096 tokens limite sérieusement la longueur des réponses
- Connaissances factuelles pointues — le modèle peut halluciner sur des détails spécifiques, donc ne l'utilisez pas comme source de vérité
- Le fine-tuning — impossible d'entraîner ou d'affiner le modèle. Vous guidez son comportement via instructions, exemples few-shot et outils, point final
Conseils pour la production
- Toujours vérifier la disponibilité avant de proposer une fonctionnalité IA
- Prévoir un fallback pour les appareils non compatibles
- Tester avec Xcode Playgrounds — c'est de loin le moyen le plus rapide d'itérer sur vos prompts
- Utiliser les guardrails par défaut — Apple applique automatiquement des filtres de sécurité, ne tentez pas de les contourner
- Respecter l'ordre des propriétés
@Generable— les propriétés dépendantes doivent être déclarées après celles dont elles dépendent - Créer des sessions multiples si vous avez besoin de requêtes parallèles (une session = une requête à la fois)
FAQ : questions fréquentes sur Foundation Models
Le framework Foundation Models est-il gratuit ?
Oui, totalement gratuit. Contrairement aux API cloud (OpenAI, Anthropic), l'utilisation du modèle on-device ne coûte rien. Pas de facturation au token, pas de limite de requêtes, pas d'abonnement. Le modèle fait partie du système d'exploitation — votre seule contrainte, c'est la puissance de calcul de l'appareil.
Le modèle fonctionne-t-il hors ligne ?
Oui. Une fois téléchargé sur l'appareil (ce qui se fait automatiquement quand Apple Intelligence est activé), toutes les inférences tournent en local. Aucune connexion internet n'est requise pour la génération de texte, la génération guidée ou le tool calling — à condition que vos outils eux-mêmes n'aient pas besoin de réseau (comme WeatherKit par exemple).
Peut-on utiliser Foundation Models avec des apps SwiftUI existantes ?
Absolument. Le framework s'intègre naturellement avec SwiftUI grâce au support natif d'async/await et au streaming via les séquences asynchrones. On stocke une LanguageModelSession comme @State dans une vue, et les mises à jour se font automatiquement via la réactivité de SwiftUI. Pas besoin de refonte architecturale.
Quelle est la différence entre Foundation Models et Core ML ?
Core ML est un framework généraliste pour exécuter des modèles de machine learning (vision, classification d'images, etc.) sur appareil. Foundation Models, lui, est spécifiquement conçu pour les modèles de langage — il expose le LLM d'Apple Intelligence avec une API Swift native, optimisée pour la génération de texte et les sorties structurées. Les deux sont complémentaires : Core ML pour la vision, Foundation Models pour le langage.
Le modèle supporte-t-il le français et d'autres langues ?
Oui, le modèle prend en charge plusieurs langues dont le français. Cela dit, les performances varient — l'anglais reste la langue où le modèle est le plus à l'aise. Pour des apps francophones, testez bien vos prompts et utilisez les instructions pour imposer la langue de réponse. Dans mon expérience, les résultats en français sont tout à fait corrects pour la plupart des cas d'usage courants.