← Tous les articles

Vous avez déjà un LLM gratuit et privé, et vous l'ignorez

Tout est parti d’un besoin minuscule. Sur un projet perso (un suivi de prix qui scrape quelques boutiques), j’avais des titres de produits à filtrer : « cette offre correspond-elle vraiment à une pompe à eau 12V, ou est-ce un accessoire ? ». Un filtre par mots-clés s’en sortait mal. Le réflexe moderne, c’est d’appeler un LLM dans le cloud.

Et là, un léger malaise. Pour une fonctionnalité accessoire, j’allais : envoyer des données à un tiers, créer une clé d’API, payer au token, et dépendre d’un service distant, le tout pour une tâche que n’importe quel petit modèle saurait faire. Sauf que mon Mac embarque déjà un modèle d’IA. Pourquoi ne pas l’utiliser ?

Cette question, anodine sur un projet perso, est exactement celle qu’on se pose en entreprise dès qu’on parle d’IA : où vont les données, à qui, et que reste-t-il sous notre contrôle ?

La confidentialité, concrètement

Soyons précis sur les mots. On pourrait être tenté de parler de « souveraineté », mais ce serait exagéré : c’est un modèle américain (Apple), sur une puce américaine. L’indépendance technologique, on repassera. 😄

Ce qu’on gagne, en revanche, est bien réel et se résume à un mot : la donnée ne bouge pas.

  • Aucune donnée ne quitte la machine. Le texte traité n’est jamais transmis à un tiers. Pas de question RGPD sur un sous-traitant, pas de fuite possible, pas de clause à auditer.
  • Aucune clé, aucun quota, aucun coût marginal. On appelle le modèle autant qu’on veut, y compris dans une boucle.
  • Ça marche hors-ligne. Dans le train, dans un avion, derrière un réseau coupé.
  • Pas de dépendance à un service distant qui peut changer ses prix, ses conditions ou disparaître.

Ce n’est évidemment pas la réponse à tous les usages (on y revient plus bas, honnêtement). Mais pour une large classe de tâches (résumer, classer, extraire, reformuler), un petit modèle qui tourne chez vous est non seulement suffisant : pour des données sensibles, il est préférable.

Le modèle d’Apple, et son verrou

Depuis macOS 26, Apple expose son modèle embarqué via le framework FoundationModels : un modèle d’environ 3 milliards de paramètres, optimisé pour tourner sur la puce, sans réseau. C’est ce qui alimente une partie d’Apple Intelligence.

Le hic : ce modèle n’est accessible que par une API Swift / Objective-C. Pas de binding Python ou JavaScript, pas d’endpoint HTTP natif. Autrement dit, pour l’appeler, il faut un processus Swift lié au framework. Cette contrainte va dessiner toute l’architecture.

L’architecture : un bridge HTTP

L’idée tient en une phrase : écrire le strict minimum en Swift pour parler au modèle, et l’exposer en HTTP au format de l’API OpenAI. À partir de là, tout le reste de l’écosystème (n’importe quelle UI de chat, le SDK openai en Python, un simple fetch en JavaScript) sait déjà lui parler, sans une ligne de Swift supplémentaire.

flowchart LR
    subgraph mac["Votre Mac : rien ne sort de la machine"]
        direction LR
        UI["UI / app / script"] -->|"HTTP OpenAI-compatible"| Shim["Bridge Swift"]
        Shim -->|"API Swift FoundationModels"| Model["Modèle on-device (~3B)"]
        Model -.->|"réponse"| Shim
        Shim -.-> UI
    end

Ce petit bridge (un shim) fait du modèle un drop-in : la même base de code qui appelait OpenAI continue de fonctionner, en changeant juste l’URL.

Installation reproductible

Prérequis

  • Un Mac Apple Silicon (M1 ou plus récent).
  • macOS 26 ou supérieur.
  • Apple Intelligence activé : Réglages → Apple Intelligence & Siri → activer (le modèle se télécharge alors, quelques Go).
  • Les Command Line Tools (xcode-select --install) pour disposer de swiftc.

Pour vérifier que le modèle est prêt, ce mini-script Swift suffit :

import FoundationModels

if case .available = SystemLanguageModel.default.availability {
    print("Modèle disponible ✅")
} else {
    print("Indisponible : Apple Intelligence est-il activé ?")
}
swift check.swift

Le bridge Swift

Voici une version minimale mais fonctionnelle du bridge : un serveur HTTP qui implémente POST /v1/chat/completions et délègue au modèle. Elle n’a aucune dépendance (framework Network du système).

import Foundation
import Network
import FoundationModels

let port = NWEndpoint.Port(rawValue: 11435)!

// Appelle le modèle on-device et renvoie le texte généré.
func generate(_ prompt: String) async -> String {
    let session = LanguageModelSession()
    let opts = GenerationOptions(temperature: 0)
    let response = try? await session.respond(to: prompt, options: opts)
    return response?.content ?? ""
}

// Extrait le prompt d'un corps OpenAI {messages:[{role,content}]}.
func prompt(from body: Data) -> String {
    let obj = try? JSONSerialization.jsonObject(with: body) as? [String: Any]
    let messages = obj?["messages"] as? [[String: Any]] ?? []
    return messages.compactMap { $0["content"] as? String }.joined(separator: "\n\n")
}

final class Conn {
    let c: NWConnection
    var buf = Data()
    init(_ c: NWConnection) { self.c = c }
    func start() { c.start(queue: .global()); read() }

    func read() {
        // Capture FORTE de self : sinon la connexion est libérée et ne répond jamais.
        c.receive(minimumIncompleteLength: 1, maximumLength: 65536) { data, _, done, err in
            if let data { self.buf.append(data) }
            if let r = self.buf.range(of: Data("\r\n\r\n".utf8)) {
                let body = self.buf.subdata(in: r.upperBound..<self.buf.endIndex)
                Task { await self.reply(body) }
                return
            }
            if err != nil || done { self.c.cancel(); return }
            self.read()
        }
    }

    func reply(_ body: Data) async {
        let text = await generate(prompt(from: body))
        let json = try! JSONSerialization.data(withJSONObject: [
            "choices": [["message": ["role": "assistant", "content": text]]]
        ])
        var out = Data("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: \(json.count)\r\nConnection: close\r\n\r\n".utf8)
        out.append(json)
        c.send(content: out, completion: .contentProcessed { _ in self.c.cancel() })
    }
}

let listener = try NWListener(using: .tcp, on: port)
listener.newConnectionHandler = { Conn($0).start() }
listener.start(queue: .global())
print("LLM local → http://127.0.0.1:11435/v1")
dispatchMain()

Une subtilité qui m’a coûté un moment : si la connexion n’est pas retenue par une référence forte, elle est libérée immédiatement et le client attend une réponse qui ne vient jamais. Le compilateur le signale par un avertissement « weak reference will always be nil », à ne pas ignorer.

On compile un vrai binaire (le mode interprété recompile à chaque lancement) :

swiftc -O fm-shim.swift -o ~/fm-shim/fm-shim

Toujours disponible : un service launchd

Pour que le bridge démarre à l’ouverture de session et redémarre tout seul s’il tombe, on en fait un LaunchAgent. Fichier ~/Library/LaunchAgents/com.gluendo.fm-shim.plist :

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>            <string>com.gluendo.fm-shim</string>
    <key>ProgramArguments</key> <array><string>/Users/VOUS/fm-shim/fm-shim</string></array>
    <key>RunAtLoad</key>        <true/>
    <key>KeepAlive</key>        <true/>
    <key>StandardErrorPath</key><string>/Users/VOUS/fm-shim/fm-shim.log</string>
</dict>
</plist>
# charger le service (API launchd moderne)
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.gluendo.fm-shim.plist
# le redémarrer / l'arrêter
launchctl kickstart -k gui/$(id -u)/com.gluendo.fm-shim
launchctl bootout      gui/$(id -u)/com.gluendo.fm-shim

Détail important : un LaunchAgent (et non un LaunchDaemon) tourne dans la session graphique de l’utilisateur, ce qui est nécessaire pour accéder à Apple Intelligence, indisponible pour les processus système.

Une UI de chat

Comme le bridge parle l’API OpenAI, n’importe quel client compatible s’y branche. Jan, une application de chat open-source et native, fait très bien l’affaire :

brew install --cask jan

Puis dans Jan → Settings → Model Providers → Add Provider (type OpenAI-compatible) :

  • Base URL : http://127.0.0.1:11435/v1
  • API Key : local (peu importe, le bridge l’ignore)

Et voilà une interface de chat complète, privée, branchée sur le modèle de la machine.

L’utiliser depuis n’importe où

C’est tout l’intérêt du format OpenAI : le même endpoint sert vos scripts comme vos applications.

curl http://127.0.0.1:11435/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{"messages":[{"role":"user","content":"Résume ce texte en une phrase : ..."}]}'
from openai import OpenAI
client = OpenAI(base_url="http://127.0.0.1:11435/v1", api_key="local")
resp = client.chat.completions.create(
    model="apple-foundation",
    messages=[{"role": "user", "content": "Classe ce ticket : bug, feature ou question ?"}],
)
print(resp.choices[0].message.content)

Dans une application Node, c’est le même endpoint via un simple fetch. Une base de code qui parlait à OpenAI bascule en local en changeant une URL.

Ce que ce modèle sait (et ne sait pas) faire

Soyons honnêtes : un modèle de 3 milliards de paramètres n’est pas GPT-4. Mettre cela au clair avant d’industrialiser un usage évite les déconvenues. Voici ce qu’a donné une petite batterie de tests, en français :

TâcheVerdictTemps
Résumé d’un paragraphe✅ Fidèle et propre~1,7 s
Extraction structurée (JSON)✅ Valeurs correctes~0,6 s
Classification (catégorie)✅ Juste~0,2 s
Reformulation à consigne ambiguë⚠️ A parfois mal interprété~0,9 s
Nuance lexicale (synonymes)⚠️ Approximatif~1,5 s
Raisonnement chiffré multi-étapes❌ Faux~3,5 s

La leçon est nette. Ce modèle est rapide (souvent sous la seconde) et fiable sur les tâches « mécaniques » du langage : résumer, classer, extraire, tagger, produire du JSON. Il est à éviter pour le calcul, le raisonnement en plusieurs étapes ou les connaissances de pointe. C’est un couteau d’office, pas un couteau suisse, et un couteau d’office bien aiguisé rend d’énormes services.

En conclusion

La confidentialité de l’IA n’est pas un choix binaire entre « tout cloud » et « tout local ». C’est un curseur. Beaucoup de tâches du quotidien (résumer un email, classer un ticket, extraire des champs d’un document client) n’ont aucune raison de quitter la machine. Les réserver à un modèle local, c’est gagner sur la confidentialité, le coût et l’autonomie, en réservant le cloud aux tâches qui l’exigent vraiment.

Non, ce n’est pas de la souveraineté, le modèle et la puce restent ceux d’un géant américain. Mais c’est une victoire concrète sur le seul point qui se négocie au quotidien : vos données restent chez vous. Et le plus frappant, c’est l’effort requis : quelques dizaines de lignes de Swift, un fichier de service, et le modèle déjà présent dans nos machines devient une brique d’infrastructure réutilisable. La confidentialité, parfois, est surtout une question de se donner la peine de brancher ce qu’on a déjà sous la main.