LLM en local — Apple Silicon, Ollama, LM Studio, llamafile
TL;DR En 2026, un MacBook M3/M4 Pro avec 36-64 GB de RAM unifiée fait tourner confortablement Llama 3.3 8B, Qwen 2.5 14B, Mistral Small 24B (Q4-Q5), Phi 4 (14B). MLX (Apple) est le runtime natif, ultra-rapide sur GPU intégré. Ollama est le wrapper UX standard (1 commande pour pull + run), LM Studio offre une GUI, llamafile packe tout en 1 exécutable. Use cases reines : (1) avocat/médecin qui veut "data ne quitte jamais mon poste", (2) dev qui code offline (Cursor + local model), (3) démo client banque sans contrat data, (4) assistant perso (Obsidian, notes, recherche locale). Limite : qualité reste un cran sous la frontier cloud — un Mistral Small 24B Q5 tient la route sur de l'extraction/résumé mais reste loin de
claude-opus-4-8(5 $/25 $ par M tok, 1M ctx) ou même declaude-haiku-4-5(1 $/5 $) sur du raisonnement multi-étapes — et le coût RAM est élevé. Mais en 2026, on bascule sur du local sans honte pour 60% des tâches "assistant". La décision senior n'est pas "local vs cloud" en absolu : c'est un routage par tâche, où le local absorbe le volume confidentiel/offline et le cloud encaisse le reste (voir le pattern hybride plus bas).
🧠 Mental model
┌──────────────────────────────────────────┐
│ Pourquoi local plutôt que API ? │
├──────────────────────────────────────────┤
│ - Data sensible (avocat, médecin, M&A) │
│ - Pas de réseau (TGV, audience, mission) │
│ - Coût zéro à l'usage │
│ - Latence stable (pas de network var.) │
│ - Démo client "votre data reste chez vous"│
└──────────────────────────────────────────┘
Stack local 2026 :
┌────────────────────────────────────────────────────────┐
│ Application (Cursor, app desktop, Obsidian plugin) │
└─────────────────────────┬──────────────────────────────┘
│ HTTP local localhost:11434
┌─────────────────────────▼──────────────────────────────┐
│ Runtime local │
│ Ollama → wrapper simple, REST API │
│ LM Studio → GUI + serveur OpenAI-compatible │
│ llamafile → single binary cross-OS │
│ llama.cpp → bas niveau, Metal/CUDA/Vulkan │
│ MLX-LM (Apple) → natif Apple Silicon, ultra-rapide │
└─────────────────────────┬──────────────────────────────┘
│
┌─────────────────────────▼──────────────────────────────┐
│ Modèle GGUF / MLX (quantizé) │
│ Mistral Small 24B Q5_K_M = 17 GB RAM │
│ Llama 3.2 3B Q8 = 3.4 GB RAM │
│ Phi 4 14B Q5_K_M = 10 GB RAM │
│ Qwen 2.5 7B Q5_K_M = 5.5 GB RAM │
└────────────────────────────────────────────────────────┘Analogie : entre API cloud (= consultant externe que tu paies à la mission) et self-host datacenter (= équipe interne dédiée), le local laptop c'est l'assistant qui voyage avec toi — limité en capacité mais toujours là, sans coût marginal, et confidentialité absolue.
🛠️ Code minimal
Ollama (le standard)
# Installer
brew install ollama
ollama serve # daemon en arrière-plan
# Récupérer un modèle
ollama pull mistral-small:24b-instruct-2502-q5_K_M
# Chat
ollama run mistral-small "Explique-moi la TVA sur autoliquidation"
# Mode REST API (compat OpenAI partielle)
curl http://localhost:11434/api/chat -d '{
"model": "mistral-small:24b-instruct-2502-q5_K_M",
"messages": [{"role": "user", "content": "Hello"}],
"stream": false
}'MLX-LM (Apple Silicon natif)
# Installation
pip install mlx-lm
# Génération directe
mlx_lm.generate --model mlx-community/Mistral-Small-Instruct-2502-4bit \
--prompt "Explique le ratio liquidité immédiate" \
--max-tokens 500
# Serveur OpenAI-compatible
mlx_lm.server --model mlx-community/Mistral-Small-Instruct-2502-4bit \
--port 8080# Python client
from openai import OpenAI
client = OpenAI(base_url="http://localhost:8080/v1", api_key="local")
resp = client.chat.completions.create(
model="mlx-community/Mistral-Small-Instruct-2502-4bit",
messages=[{"role": "user", "content": "Bonjour"}],
)llamafile (1 fichier exécutable)
# Télécharger un llamafile (modèle + runtime en 1 .exe)
wget https://huggingface.co/Mozilla/Mistral-Small-Instruct-2502-llamafile/resolve/main/mistral-small.llamafile
chmod +x mistral-small.llamafile
./mistral-small.llamafile --server --host 0.0.0.0 --port 8080🎬 Cas d'usage concrets
1. Avocat solo Paris — assistant 100% local sur MacBook M4 Pro
Avocate en droit des affaires, 1 collaboratrice. Dossiers M&A et capital-investissement = data ultra-sensible (NDA stricts avec clients corporates). Pas question d'envoyer à OpenAI ou Anthropic. Setup : MacBook M4 Pro 48GB + Ollama + Mistral Small 24B Q5 + LM Studio. Cas d'usage : rédaction préliminaire de pactes d'associés, résumés de bilans, recherche dans archives clientes locales (RAG local avec Chroma + nomic-embed-text). Latence : 18 tok/s, acceptable. Pas de cloud, pas d'abonnement, conformité absolue. La cliente loue le setup à 12k€ HT pour le freelance (audit + install + formation 3 jours).
2. Médecin libéral — HDS = local plutôt que cloud HDS
Médecin généraliste rural. Veut un assistant pour reformuler CR de consultation et lui suggérer codes CIM-11. Cloud HDS = trop cher (300-800€/mois pour solo). Alternative : Mac mini M4 24GB en cabinet + Ollama + Mistral Small 7B FT médical (modèle communauté HuggingFace BioMistral) + plugin Obsidian pour ses notes patient. Zéro cloud, RGPD trivial (data jamais ne quitte le cabinet). Investissement : 1 800€ Mac mini + 4 jours setup freelance (4 800€). Run rate : 0.
3. POC client banque — démo "vos données ne quittent jamais notre laptop"
Freelance en avant-vente chez une grande banque française. Doit présenter un assistant IA pour analystes. La banque refuse tout cloud externe pour le POC. Setup démo : MacBook M3 Max 128GB + Ollama + Mistral Large 2 (123B Q4_K_M = ~ 65 GB) + scripts custom pour démo en live. Le client voit que rien ne sort de l'écran du laptop, ce qui débloque psychologiquement la conversation. Le contrat de prod (qui sera self-host Scaleway HDS) signe pour 600k€/an.
4. Développeur freelance — assistant code offline (TGV, café sans wifi)
Dev Python freelance qui travaille 30% en TGV / coworking. Setup Cursor + Continue.dev avec backend Ollama local (Qwen 2.5 Coder 14B Q5). Quand wifi : route vers Sonnet API. Quand offline : route vers Qwen local. UX continue, pas de frustration "j'ai pas de réseau". Bonus : data code client jamais envoyée à un provider (utile pour missions où le code est sensible).
🛠️ Exemple end-to-end — Tauri app pour avocat avec Ollama embarqué
Contexte : éditeur logiciel juridique veut packager une app desktop (Mac + Windows) pour avocats individuels et petits cabinets, avec assistant IA 100% local. Argument commercial : "votre data ne quitte JAMAIS votre poste". Cible : 3 500 abonnés × 49€/mois = 170k€ MRR.
Étape 1 — Choix techniques
- Frontend : Tauri (Rust + WebView, plus léger qu'Electron, app ~ 30 MB)
- Backend AI : Ollama embarqué (sidecar) + modèle Mistral Small 7B Q5 (téléchargé à la 1ère lancement, ~ 5.5 GB)
- Stockage : SQLite chiffré pour notes/dossiers utilisateur
- RAG local : SQLite-vss ou Chroma local +
nomic-embed-textpour embeddings
Étape 2 — Architecture Tauri
my-legal-app/
├── src-tauri/
│ ├── src/
│ │ ├── main.rs # entry point, démarre Ollama sidecar
│ │ ├── ollama_manager.rs # spawn/monitor Ollama process
│ │ └── ai_commands.rs # Tauri commands #[command]
│ ├── binaries/
│ │ ├── ollama-darwin-arm64 # embedded binaries per platform
│ │ ├── ollama-darwin-x64
│ │ └── ollama-win-x64.exe
│ └── tauri.conf.json
├── src/ # React + Tailwind
│ ├── components/
│ ├── pages/
│ └── lib/ai.ts # client OllamaÉtape 3 — Code Rust : sidecar Ollama
// src-tauri/src/ollama_manager.rs
use std::process::{Child, Command};
use std::sync::Mutex;
use tauri::api::path::app_data_dir;
pub struct OllamaState(pub Mutex<Option<Child>>);
#[tauri::command]
pub async fn start_ollama(app: tauri::AppHandle, state: tauri::State<'_, OllamaState>) -> Result<(), String> {
let ollama_bin = app.path_resolver()
.resolve_resource("binaries/ollama-darwin-arm64")
.ok_or("binary not found")?;
let data_dir = app_data_dir(&app.config()).unwrap();
let child = Command::new(ollama_bin)
.arg("serve")
.env("OLLAMA_MODELS", data_dir.join("ollama_models"))
.env("OLLAMA_HOST", "127.0.0.1:11434")
.spawn()
.map_err(|e| e.to_string())?;
*state.0.lock().unwrap() = Some(child);
Ok(())
}
#[tauri::command]
pub async fn ensure_model_pulled(model: String) -> Result<(), String> {
let client = reqwest::Client::new();
client.post("http://127.0.0.1:11434/api/pull")
.json(&serde_json::json!({"name": model}))
.send().await.map_err(|e| e.to_string())?;
Ok(())
}Étape 4 — Frontend : appel à Ollama
// src/lib/ai.ts
import { invoke } from "@tauri-apps/api/tauri";
const OLLAMA = "http://127.0.0.1:11434";
const MODEL = "mistral-small:7b-instruct-q5_K_M";
export async function bootstrap() {
await invoke("start_ollama");
await invoke("ensure_model_pulled", { model: MODEL });
}
export async function chat(messages: Message[]) {
const r = await fetch(`${OLLAMA}/api/chat`, {
method: "POST",
body: JSON.stringify({ model: MODEL, messages, stream: true }),
});
const reader = r.body!.getReader();
const decoder = new TextDecoder();
let buf = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buf += decoder.decode(value);
// parse NDJSON lines, yield to UI
}
}Étape 5 — RAG local (dossiers de l'avocat)
// indexer.ts — index les fichiers du cabinet
import { invoke } from "@tauri-apps/api/tauri";
async function embed(text: string): Promise<number[]> {
const r = await fetch(`${OLLAMA}/api/embeddings`, {
method: "POST",
body: JSON.stringify({
model: "nomic-embed-text",
prompt: text,
}),
});
const data = await r.json();
return data.embedding;
}
async function indexDocument(path: string) {
const text = await invoke<string>("read_file", { path });
const chunks = chunkText(text, 800, 100); // 800 tokens, overlap 100
for (const chunk of chunks) {
const v = await embed(chunk);
await invoke("sqlite_vss_insert", { path, chunk, vector: v });
}
}
async function search(query: string, k: number = 5) {
const v = await embed(query);
return invoke("sqlite_vss_search", { vector: v, k });
}Étape 6 — UX d'onboarding
1. Premier lancement
"Bienvenue. Pour activer l'assistant IA, nous devons télécharger
un modèle (5.5 GB). Ce modèle restera intégralement sur votre Mac.
[Démarrer le téléchargement]"
2. Download
Progress bar Ollama API /api/pull (stream)
3. Test
"Posez votre première question pour vérifier que tout fonctionne"
4. Indexation dossiers (optionnel)
"Voulez-vous que l'assistant puisse rechercher dans vos dossiers ?
Sélectionnez un dossier à indexer (les fichiers restent sur votre Mac)"Étape 7 — Packaging et distribution
# Cargo.toml + tauri.conf.json
{
"tauri": {
"bundle": {
"identifier": "fr.legalsoft.assistant",
"resources": ["binaries/*"],
"macOS": {
"minimumSystemVersion": "12.0",
"signingIdentity": "Developer ID Application: LegalSoft SARL"
},
"windows": {
"wix": {"language": "fr-FR"}
}
}
}
}Distribution : notarized DMG sur landing page, signed MSI pour Windows. Auto-update via Tauri updater (binaire signé). App finale ~ 35 MB (sans le modèle qui se télécharge au premier run).
Étape 8 — Mesure et ROI client (l'éditeur)
Coût dev initial :
- Tauri app + intégration Ollama : 35j × 1200€ = 42 000€
- RAG local + UX : 15j × 1200€ = 18 000€
- QA, notarization, builds CI : 8j × 1200€ = 9 600€
- Total : 69 600€
Run rate :
- Maintenance/updates : 30k€/an
- Support N1 (mutualisé) : 25k€/an
Revenus prévus :
3500 abonnés × 49€/mois = 171 500€/mois = ~ 2M€/an
Marge brute énorme : pas de coût d'inference par utilisateur (tout local).
C'est exactement pourquoi le local est un game-changer pour le pricing SaaS B2B.🎯 Patterns courants
- Choisir le modèle par RAM : règle =
RAM_GB ≥ params_B × 0.7pour Q5. Donc 24B en Q5 ≈ 17 GB. - Pré-warmer le modèle : 1ère inference après load = 3-5s. Faire un ping "hello" au boot.
- Streaming UX : sans streaming, l'utilisateur croit que c'est planté. Toujours streamer.
- Fallback offline gracieux : détecter si Ollama process down → message clair + bouton restart.
- Contexte court : sur local, garder ctx ≤ 8K tokens. Au-delà, latence explose (KV cache).
- GGUF Q5_K_M : sweet spot qualité/taille. Q4 dégrade trop, Q6+ trop gros pour pas grand-chose.
- MLX > llama.cpp sur Mac : 30-50% plus rapide sur M3/M4 Pro/Max.
- Auto-update modèles : faire
ollama pullmensuel pour récupérer améliorations. - Privacy by design : aucun télémétrie qui sort du laptop sans consentement explicite.
🧮 Mental model — la quantization, vraiment
C'est le concept que les juniors récitent ("Q4 c'est 4 bits") sans savoir raisonner dessus. Un staff engineer sait défendre le chiffre.
Ce qu'on quantize. Les poids d'un LLM sont des floats. En FP16, chaque poids = 2 octets. Un 24B en FP16 = 24e9 × 2 = 48 GB. Inutilisable sur un laptop. La quantization remplace ces floats par des entiers de N bits (+ un facteur d'échelle par bloc). Q4 ≈ 4 bits/poids + overhead → ~0.5 octet effectif → le 24B passe de 48 GB à ~14-17 GB.
Taille_RAM ≈ params_B × (bits_par_poids / 8) × overhead
+ KV_cache(ctx, batch) ← souvent oublié, explose en contexte longLe KV cache, le piège du débutant. La formule params_B × 0.7 ne couvre QUE les poids. Le KV cache (les clés/valeurs attention mises en cache pour ne pas recalculer) grandit linéairement avec le contexte. Sur un 24B à 8K tokens il est négligeable ; à 32K il peut ajouter plusieurs GB. C'est pourquoi le pitfall "ctx ≤ 8K sur local" n'est pas du folklore : au-delà, soit tu OOM, soit le runtime spill sur le swap et le débit s'effondre.
Pourquoi Q5_K_M et pas Q4 ni Q8. La dégradation n'est pas linéaire. Mesure-la avec la perplexité (à quel point le modèle est "surpris" par un texte de référence — plus bas = mieux) :
| Quant | Bits eff. | Δ perplexité vs FP16 | Verdict senior |
|---|---|---|---|
| Q8_0 | ~8 | ~+0.1% | gaspillage : 2× la RAM pour un gain imperceptible |
| Q6_K | ~6 | ~+0.3% | OK si la RAM est là, rarement justifié |
| Q5_K_M | ~5.5 | ~+0.6% | sweet spot qualité/taille |
| Q4_K_M | ~4.5 | ~+1.5% | acceptable, sweet spot RAM si serré |
| Q3_K | ~3.5 | ~+5-8% | dégradation visible, hallucinations |
| Q2_K | ~2.5 | ~+15-30% | cassé, ne JAMAIS livrer en prod |
Les _K_M désignent la quantization "K-quant medium" de llama.cpp : elle garde plus de précision sur les couches sensibles (attention, derniers blocs) et compresse agressivement le reste. C'est pour ça que Q4_K_M bat un Q4_0 "plat" à taille égale.
La règle qu'un senior applique : descends en quant jusqu'à ce que la perplexité ou un eval métier dépasse un seuil que tu as défini, pas un seuil de blog. Sur du français juridique, un Q4 qui hallucine un article de loi est inacceptable même si la perplexité globale est correcte — l'eval doit être sur ta tâche, pas sur WikiText.
🔀 Pattern senior — routage hybride local ↔ cloud
Le use case #4 (dev offline) le mentionne, mais c'est LE pattern de production. Tu n'opposes pas local et cloud : tu routes. Local pour le confidentiel/offline/volume ; claude-opus-4-8 (ou claude-haiku-4-5 pour le cheap) en escalade quand la tâche dépasse les capacités locales ou quand le réseau est là et la data non sensible.
Côté serveur, on utilise AsyncAnthropic (jamais le client sync dans un event loop), avec retries SDK, exceptions typées et logging d'usage pour le coût :
import asyncio
import httpx
from anthropic import (
AsyncAnthropic,
APIConnectionError,
RateLimitError,
APIStatusError,
)
# Client cloud : retries + timeout par appel. max_retries gère 429/5xx/overload.
cloud = AsyncAnthropic(max_retries=3, timeout=30.0)
OLLAMA = "http://127.0.0.1:11434/api/chat"
LOCAL_MODEL = "mistral-small:24b-instruct-2502-q5_K_M"
async def local_chat(prompt: str) -> str:
"""Inference locale via Ollama. Confidentiel, gratuit, offline-friendly."""
async with httpx.AsyncClient(timeout=120.0) as client:
r = await client.post(
OLLAMA,
json={
"model": LOCAL_MODEL,
"messages": [{"role": "user", "content": prompt}],
"stream": False,
},
)
r.raise_for_status()
return r.json()["message"]["content"]
async def cloud_chat(prompt: str) -> str:
"""Escalade vers la frontier. On streame pour les gros outputs et on logue le coût."""
async with cloud.messages.stream(
model="claude-opus-4-8",
max_tokens=4096,
thinking={"type": "adaptive"}, # 4.8 : adaptatif, pas de budget_tokens
messages=[{"role": "user", "content": prompt}],
) as stream:
msg = await stream.get_final_message()
u = msg.usage
# Coût observable : input 5 $/Mtok, output 25 $/Mtok pour opus-4-8
cost = u.input_tokens / 1e6 * 5 + u.output_tokens / 1e6 * 25
print(f"[cloud] in={u.input_tokens} out={u.output_tokens} ~${cost:.4f}")
return msg.content[0].text
async def route(prompt: str, *, sensitive: bool, offline: bool) -> str:
"""
Politique de routage senior :
- data sensible OU offline → local, point.
- sinon, on tente le cloud ;
s'il est down/rate-limited/réseau coupé → fallback local gracieux.
"""
if sensitive or offline:
return await local_chat(prompt)
try:
return await cloud_chat(prompt)
except (APIConnectionError, RateLimitError, APIStatusError) as e:
# Fallback : on dégrade vers local plutôt que de faire échouer la requête.
print(f"[route] cloud indisponible ({type(e).__name__}) → fallback local")
return await local_chat(prompt)Pourquoi ce design est "production-grade" :
asyncio.gatherpour le parallélisme. Si tu dois traiter 50 résumés confidentiels en local, ne les fais pas en série —await asyncio.gather(*[local_chat(p) for p in prompts])sature le GPU intégré sans bloquer. (Attention : le débit local est borné par la VRAM partagée — au-delà de ~2-3 requêtes concurrentes sur un Mac, tu thrashes le KV cache.)- Exceptions typées, pas de
except Exception.RateLimitError≠APIConnectionError≠APIStatusError: un rate-limit se retry (le SDK le fait déjà), une coupure réseau bascule en local, une 400 est un bug à corriger. usagelogué = coût observable. Sans ça, tu découvres ta facture cloud en fin de mois. La règle : tout appel cloud logueinput_tokens/output_tokens. Le local, lui, logue les tok/s et la RAM.- Le routage est une policy, pas un
iféparpillé. Centralise la décision dans une fonction. Demain le critère change (taille de prompt, langue, latence cible) — un seul endroit à toucher.
⚠️ Sur 4.8 : pas de
budget_tokens. L'ancienne formethinking={"type": "enabled", "budget_tokens": N}renvoie HTTP 400 sur Opus 4.7/4.8. On utilisethinking={"type": "adaptive"}+ éventuellementoutput_config={"effort": "low|medium|high"}.temperature/top_psont aussi retirés sur ces modèles. Sonnet 4.6 / Haiku ne prennent pas de budget de thinking.
🔭 Observabilité d'un déploiement local
Le cloud te donne usage gratuitement. En local, tu es ton propre observability stack. Ce qu'un senior instrumente avant de livrer :
| Métrique | Pourquoi | Comment |
|---|---|---|
| tok/s (génération) | UX chat, dimensionnement | Ollama logue eval_count / eval_duration dans la réponse /api/chat |
| TTFT (time-to-first-token) | Le user croit que c'est planté si >1.5s | timestamp avant requête → premier delta du stream |
| RAM résidente + swap | OOM = crash silencieux ou throttle | Activity Monitor / psutil ; alerter si swap > 0 |
| Température / throttle GPU | Sustained = thermal throttle après ~20 min | powermetrics (macOS) ; surveiller la fréquence GPU |
| Taux de fallback cloud | Si ça explose, le local ne suffit pas | compteur dans la route() ci-dessus |
| Quality drift | Une maj de modèle peut régresser | eval métier en CI sur chaque ollama pull |
Sans ces métriques, "ça marche sur mon Mac" devient "ça plante chez le client après 20 min de démo" — le scénario qui tue la confiance commerciale.
🔄 Versions & écosystème 2026
- MLX (Apple) 0.20+ : runtime natif Apple Silicon, support training et inference. Bibliothèque officielle Apple.
- MLX-LM : wrapper haut niveau pour text generation.
- MLX-VLM : models vision-language sur Mac (Idefics, LLaVA, Qwen2-VL).
- Ollama 0.5+ : structured output, vision models, multi-GPU, OpenAI-compatible API.
- LM Studio 0.3+ : GUI mature, multi-modèles concurrents, MLX backend disponible.
- llamafile (Mozilla) : un binary cross-OS, jusqu'à 70B sur RAM suffisante.
- llama.cpp : moteur en C++ qui sous-tend tout l'écosystème, support Metal, Vulkan, ROCm, CUDA.
- Continue.dev : extension VSCode/JetBrains qui route vers local ou cloud.
- Cursor : route vers Ollama en local pour autocomplete privé (mode "Privacy").
- Modèles 2026 qui tournent en local :
| Modèle | Quant | RAM | Use case |
|---|---|---|---|
| Llama 3.2 1B | Q8 | 1.5 GB | classification, NER |
| Llama 3.2 3B | Q8 | 3.4 GB | assistant léger, edge |
| Phi 4 14B | Q5_K_M | 10 GB | reasoning, math |
| Qwen 2.5 7B | Q5_K_M | 5.5 GB | polyvalent FR/EN |
| Qwen 2.5 14B | Q5_K_M | 10 GB | polyvalent ++ |
| Mistral Small 7B 2024 | Q5_K_M | 5 GB | FR-friendly |
| Mistral Small 24B 2502 | Q5_K_M | 17 GB | qualité Sonnet-1 |
| DeepSeek-R1 Distill 14B | Q5 | 10 GB | reasoning |
| Qwen 2.5 Coder 14B | Q5 | 10 GB | dev offline |
| Llama 4 Maverick 17B | Q5 | 13 GB | tout-terrain 2026 |
⚠️ Pitfalls
- RAM insuffisante : Mac M3 16GB ne tient pas Mistral Small 24B. Vérifier
params_B × 0.7avant promesse client. - Thermal throttling MacBook : sur Air ou Pro 14" sans ventilation forte, le GPU throttle après 20 min sustained. Préférer Mac mini ou Studio en cabinet.
- Modèles trop quantizés : Q2/Q3 hallucinent énormément. Ne JAMAIS aller sous Q4_K_M en prod.
- Ollama version drift :
ollama pullramène la dernière version par défaut, qui peut casser des comportements. Pinner les tags exacts. - Pas de garde-fous offline : un modèle local peut halluciner librement (pas de guardrails cloud). Implémenter ses propres checks.
- Coût Apple non amorti : un client qui achète 5 MacBook M4 Pro 64GB à 4 800€ HT = 24k€ pour 5 utilisateurs. Sur 3 ans = 800€/user/mois équivalent. Pas toujours rentable vs cloud.
- Mises à jour sécurité oubliées : un Mac qui ne se patch pas = porte d'entrée. Imposer auto-update.
- Battery drain : génération soutenue draine la batterie en 1-2h. Prévenir l'utilisateur, ne pas générer en background non sollicité.
- Modèle pas FR-friendly : Llama 3.2 3B est moyen en français. Tester chaque modèle sur prompts FR avant de promettre.
- Confusion utilisateur : "l'IA est sur mon Mac" est un concept étranger. UX doit clairement séparer "stockage local" / "calcul local" / "réseau désactivé".
💰 Pricing / ROI client
Mission "intégrer Ollama dans app desktop" (freelance senior FR) :
- Audit faisabilité + benchmark modèles : 4j × 1200€ = 4 800€
- Intégration Tauri/Electron + sidecar : 12j × 1200€ = 14 400€
- RAG local + UX + packaging : 10j × 1200€ = 12 000€
- Tests cross-platform + signing : 5j × 1200€ = 6 000€
- Total mission packageur : 37 200€
Mission "setup poste de travail avocat/médecin" :
- Audit besoin + choix Mac + modèle : 1j × 1200€ = 1 200€
- Installation + config + RAG perso : 2j × 1200€ = 2 400€
- Formation 1/2 journée : 600€
- Total mission solo : 4 200€ + matériel Mac ~ 4 800€ HT
Argument commercial client final :
- Mensualité abonnement SaaS classique : 50-150€/user/mois
- Setup local one-shot : 4 200€ + Mac payback ~ 18 mois
- ROI = data sovereignty + pas de récurrent
🧪 Testing / Eval
- Bench tokens/sec sur cibles : MacBook M3, M3 Pro, M4 Pro, Mac mini M4, M4 Max. Mesurer tok/s sur prompts standardisés.
- Bench latence first token : critique pour UX chat.
- Eval qualité FR : 50 prompts FR (technique, juridique, créatif), juger contre Sonnet de référence.
- Stress test RAM : ouvrir 5 onglets Chrome + Spotify + l'app, vérifier que l'IA tourne encore.
- Mode batterie : tester l'autonomie avec usage typique (4h éclatées) pour communiquer une attente réaliste.
- Multi-user : si l'app est partagée (cabinet de 3), tester avec 3 conversations en parallèle.
🔁 Quand utiliser / éviter
Local quand :
- Data ultra-sensible (avocat, médecin, M&A, brevet).
- Use case offline (mobilité, terrain).
- Argument commercial "data reste chez vous" différenciant.
- Volume faible / utilisateur (assistant perso, pas chat haute fréquence).
- Mac/PC haut de gamme disponible (RAM ≥ 24 GB).
- Modèle 7B-30B suffit pour la tâche.
Évite local quand :
- Besoin de qualité frontier (Opus, GPT-4o pro).
- Volume élevé (chat haute fréquence = batterie morte + thermal).
- Hardware utilisateur hétérogène/faible.
- Tâche nécessite contexte long (> 16K tokens).
- Tâche nécessite latence < 200ms (local Mac fait 500ms-2s).
- Multi-utilisateurs sur même device.
🏋️ Exercices
Progressifs, du "fais marcher" au "défends le chiffre / casse-le puis répare-le". L'objectif n'est jamais de changer une constante — c'est de raisonner comme un staff engineer.
1. Bench de quantization sur TA tâche
Objectif : prouver, chiffres en main, quel niveau de quant tu peux livrer pour un use case donné.
Prends Mistral Small 24B en Q3_K_M, Q4_K_M, Q5_K_M et Q8_0. Construis un eval de 30 prompts métier (extraction d'entités sur des CR médicaux fictifs, ou résumé de contrats). Mesure pour chacun : tok/s, RAM résidente, et un score qualité (exactitude des entités extraites, jugé par claude-opus-4-8 en LLM-judge ou à la main). Trace la courbe qualité/RAM.
Indice/Solution : tu dois trouver un coude — souvent Q4_K_M ou Q5_K_M. Le piège : la perplexité globale peut être bonne alors que la qualité s'effondre sur ta tâche précise (un nom propre rare mal tokenizé). C'est pour ça qu'on évalue sur la tâche, pas sur WikiText. Conclus par UNE phrase : "pour ce client, on livre Q_, parce que ___".
2. Routage hybride résilient
Objectif : implémenter la route() du pattern senior et la rendre incassable.
Reprends le code de routage. Ajoute : (a) asyncio.gather pour traiter un batch de 20 prompts, (b) un timeout par appel local ET cloud, (c) un compteur de fallback exposé en métrique, (d) du prompt caching côté cloud (cache_control sur le préfixe système stable) pour ne pas repayer le contexte à chaque appel.
Indice/Solution : le cache_control va sur le bloc système stable (instructions figées), JAMAIS sur la partie variable (la question). Vérifie usage.cache_read_input_tokens > 0 au 2e appel — sinon un invalidateur silencieux (timestamp dans le system prompt ?) casse le cache. Pour gather, borne la concurrence locale avec un asyncio.Semaphore(2) : au-delà, tu thrashes le KV cache du Mac.
3. Casse-le : OOM en contexte long
Objectif : reproduire un crash de production, le diagnostiquer, le réparer.
Charge un 24B Q5 sur un Mac 36 GB. Envoie un prompt de 40K tokens (colle un long document). Observe : soit OOM, soit débit qui s'effondre (swap). Diagnostique avec le monitoring RAM/swap. Puis répare sans changer de modèle : réduis le contexte, chunke le doc + RAG local, ou passe en Q4 pour libérer de la RAM au KV cache.
Indice/Solution : le coupable est le KV cache, pas les poids. La "vraie" réparation prod est rarement "achète plus de RAM" — c'est repenser l'architecture (RAG au lieu de stuffer 40K dans le contexte). Calcule de tête combien de RAM le KV cache prend à 40K vs 8K et défends ton choix.
4. Défends le ROI face au CFO
Objectif : transformer une intuition technique en argument financier défendable.
Un client veut équiper 5 analystes. Compare sur 3 ans : (a) 5 MacBook M4 Pro 64GB local vs (b) abonnement cloud claude-opus-4-8 à volume estimé. Tu dois estimer le volume de tokens/analyste/jour, calculer la facture cloud (5 $/25 $ par M tok), amortir le matériel, et inclure le coût caché (maintenance, throttle, batterie, support N1).
Indice/Solution : il n'y a pas de bonne réponse universelle — c'est le point. Le local gagne sur le volume élevé + data sensible ; le cloud gagne sur le volume faible/variable. Le vrai livrable senior est le seuil : "au-dessus de X tokens/jour/user, le local s'amortit en Y mois". Le piège classique est d'oublier que le local a un coût marginal non-nul (élec, throttle, support).
5. Production-grade : Tauri app qui survit à une démo client
Objectif : durcir l'app embarquée du end-to-end pour qu'elle ne plante pas en live.
Reprends l'app Tauri. Ajoute : pré-warm du modèle au boot (ping "hello"), détection de process Ollama down + bouton restart, fallback gracieux si le modèle n'est pas encore pull, et un indicateur de throttle thermique qui prévient l'utilisateur. Simule une coupure (kill le process Ollama au milieu d'une génération).
Indice/Solution : la démo banque échoue toujours sur l'imprévu — pas sur le happy path. Le pré-warm élimine le "premier token à 5s" qui fait croire au plantage. Le kill-process doit donner un message clair, pas un spinner infini. Bonus : log le tok/s en continu pour détecter le throttle avant que le client ne le remarque.
6. (Hardcore) MLX vs llama.cpp — mesure et explique l'écart
Objectif : ne pas répéter "MLX est 30-50% plus rapide", le mesurer et l'expliquer.
Fais tourner le MÊME modèle (Mistral Small 24B) en MLX et en llama.cpp/Ollama sur le même Mac. Mesure tok/s en génération et TTFT. Puis explique l'écart : Metal kernels natifs MLX, layout mémoire, unified memory exploitée différemment.
Indice/Solution : l'écart varie selon la génération de puce (M3 vs M4) et la quant. MLX brille sur la génération soutenue grâce à ses kernels Metal optimisés et son graphe lazy ; llama.cpp est plus portable mais paie une couche d'abstraction. Si tu ne mesures PAS l'écart sur ta config, tu ne peux pas le promettre à un client — c'est la différence entre un chiffre de blog et un chiffre que tu signes.
🎤 En entretien
Q : "On veut un assistant IA pour des avocats, data ultra-sensible. Cloud ou local ?" R : Local par défaut pour la souveraineté data (rien ne quitte le poste), avec un routage qui n'escalade vers la frontier (claude-opus-4-8) que sur des tâches explicitement non-confidentielles — la décision est une policy centralisée, pas un choix binaire absolu.
Q : "Pourquoi Q5_K_M et pas Q8 ou Q4 ?" R : Q8 double la RAM pour un gain de perplexité imperceptible (~0.1%) ; Q4 économise la RAM mais dégrade visiblement sur les tâches sensibles (noms propres, faits précis). Q5_K_M est le coude qualité/taille — mais le seuil se mesure sur la tâche métier, pas sur WikiText.
Q : "Un 24B Q5 prend 17 GB. Pourquoi ça OOM à 32K de contexte sur un Mac 24 GB ?" R : Les 17 GB ne couvrent que les poids ; le KV cache grandit linéairement avec le contexte et peut ajouter plusieurs GB à 32K. La fix prod n'est pas "plus de RAM" mais repenser l'archi (RAG/chunking au lieu de stuffer le contexte).
Q : "Comment tu observes un déploiement 100% local, sans usage cloud ?" R : Tu deviens ton propre observability stack : tok/s et TTFT pour l'UX, RAM+swap pour l'OOM, fréquence GPU pour le throttle thermique, taux de fallback, et un eval métier en CI à chaque ollama pull pour détecter la régression qualité après une maj de modèle.
🔗 Liens
- MLX (github.com/ml-explore/mlx) — Apple official
- MLX-LM (github.com/ml-explore/mlx-lm)
- Ollama (ollama.com)
- LM Studio (lmstudio.ai)
- llamafile (github.com/Mozilla-Ocho/llamafile)
- llama.cpp (github.com/ggerganov/llama.cpp)
- Hugging Face — mlx-community (modèles convertis MLX)
- TheBloke (HF) — GGUF quantizations
- Simon Willison — blog "local LLMs" (régulièrement à jour)
- Fichier voisin :
04-vllm-tgi-llamacpp.md(servir des modèles serveur-side) - Fichier voisin :
05-self-host.md(alternative datacenter) - Section voisine :
09-verticales-fr/(cas d'usage métiers FR)