Le 17 octobre 2026, DeepSeek a publié DeepSeek-V4.1-Flash-1017, une mise à jour de son modèle V4.1 Flash, sous licence MIT. Les poids sont téléchargeables sur Hugging Face, l’usage commercial est libre. Sur le papier, c’est exactement ce que cherchent les entreprises qui veulent garder leurs données chez elles. En pratique, les fichiers de poids pèsent 510 Go. Aucun ordinateur portable ne les fait tourner.
« Open-weight » ne veut donc pas dire « tourne sur mon poste ». La bonne question pour une PME est plutôt : quel modèle ouvert tient sur la machine que j’ai déjà, et est-il assez bon pour une tâche réelle ? Nous avons fait le test sur un MacBook Pro M5 Pro avec 24 Go de mémoire, avec trois modèles récents sous licence Apache 2.0 et une tâche concrète : trier les e-mails d’un service client. Ce tutoriel reprend le code exact que nous avons exécuté, les résultats, et les quatre pièges qui nous ont fait perdre du temps.
Ce qu’on construit
Un service client reçoit des e-mails : colis en retard, double prélèvement, demande de devis, résiliation, remerciement. Pour chaque e-mail, le modèle remplit une fiche :
- la catégorie (livraison, facturation, support technique, avant-vente, résiliation, autre) ;
- l’urgence (basse, normale, haute) ;
- le nom, le numéro de commande ou de contrat, le téléphone, le montant en euros ;
- l’action à mener ;
- ce qui est contradictoire et doit être confirmé par un humain.
Rien ne sort de la machine : pas de clé d’API, pas de cloud, pas de sous-traitant à déclarer au registre RGPD. Trois fichiers suffisent :
emails.json: 12 e-mails de test avec la fiche attendue pour chacun ;trier.py: envoie un e-mail au modèle local et contrôle la fiche reçue ;comparer.py: passe les 12 e-mails à chaque modèle et compte les champs justes.
Les modèles testés
| Modèle | Éditeur | Licence | Mis en ligne | Téléchargement |
|---|---|---|---|---|
| Granite 4.2 8B | IBM | Apache 2.0 | août 2026 | 5,3 Go |
| Qwen 3.5 9B | Alibaba | Apache 2.0 | février 2026 | 7,6 Go |
| Gemma 4 12B | Apache 2.0 | mai 2026 | 7,7 Go | |
| DeepSeek V4.1 Flash 1017 | DeepSeek | MIT | octobre 2026 | 510 Go : hors jeu |
Les trois premiers tiennent dans les 24 Go d’un Mac récent, avec de la marge pour le reste des applications. Nous les avons fait tourner l’un après l’autre, jamais ensemble : deux modèles chargés en même temps saturent la mémoire et faussent les mesures de vitesse.
Prérequis : un Ollama à jour (premier piège)
Nous utilisons Ollama, qui télécharge les modèles et les sert sur http://localhost:11434.
Première installation ? Nos guides pas à pas, écrits pour quelqu’un qui n’a jamais ouvert le Terminal, détaillent chaque étape pour un modèle : installer Granite 4.2, installer Qwen 3.5 et installer Gemma 4. Ici, la version courte :
brew install ollama # ou brew upgrade ollama
ollama serve # dans un terminal à part
ollama pull granite4.2:8b
ollama pull qwen3.5:9b
ollama pull gemma4:12bNotre Mac avait une version d’Ollama datant de février. Qwen et Gemma refusaient de se télécharger avec un message clair (« requires a newer version of Ollama »). Granite, lui, se téléchargeait sans broncher… et tournait sans son gabarit de conversation. Le modèle recevait le texte brut, sans les consignes, et répondait n’importe quoi : des numéros de téléphone inventés, des e-mails de remerciement classés en « résiliation ». Résultat : 38 champs justes sur 72, et aucun message d’erreur. Après la mise à jour, le même modèle est passé à 66 sur 72.
Le réflexe à prendre : ollama show granite4.2:8b doit afficher des capacités (completion, tools, thinking). Si le gabarit affiché par ollama show --template se résume à {{ .Prompt }}, votre version d’Ollama ne sait pas parler à ce modèle.
Côté Python, une seule dépendance :
python3 -m venv .venv && source .venv/bin/activate
pip install requestsÉtape 1 : des e-mails de test avec la réponse attendue
Pour juger un modèle, il faut connaître la bonne réponse. Nous avons écrit 12 e-mails réalistes, avec quelques pièges : un client ironique (« Super, encore un colis arrivé écrasé. Bravo »), un e-mail signé par une équipe plutôt que par une personne, un montant contradictoire. Voici le plus piégeux :
[
{
"id": "e12",
"texte": "Objet : avoir\n\nBonjour, vous m'aviez promis un avoir de 75 € suite au retard de la commande CMD-20301, puis on m'a parlé de 50 €. Au final je n'ai rien vu sur ma dernière facture. Merci de régulariser.\nCamille Petit\n07 50 40 30 20",
"attendu": {
"categorie": "facturation",
"urgence": "normale",
"nom": "Camille Petit",
"commande": "CMD-20301",
"telephone": "07 50 40 30 20",
"montant": 75,
"action": "appliquer l'avoir promis"
}
}
]Le client parle de 75 €, puis de 50 €. Un bon modèle doit garder un montant, mais surtout signaler la contradiction dans a_confirmer, pour qu’un humain vérifie avant de répondre.
Étape 2 : trier un e-mail
Le script envoie la consigne et l’e-mail à l’API /api/chat d’Ollama, puis contrôle la réponse : JSON lisible, toutes les clés présentes, catégorie et urgence dans les listes autorisées.
"""Trie un e-mail client avec un modèle local servi par Ollama.
Le modèle renvoie une fiche JSON : catégorie, urgence, coordonnées, montant,
action à mener. Deux façons d'obtenir le JSON :
consigne (par défaut) -> le schéma est décrit dans la consigne, la réponse est
contrôlée ensuite ; marche avec tous les moteurs d'Ollama
imposé -> Ollama contraint la génération au schéma ; indisponible
avec le moteur MLX (« structured output is unavailable »)
Usage : python trier.py granite4.2:8b "texte de l'e-mail"
"""
import json
import re
import sys
import time
import requests
OLLAMA = "http://localhost:11434/api/chat"
CATEGORIES = ["livraison", "facturation", "support technique", "avant-vente", "résiliation", "autre"]
URGENCES = ["basse", "normale", "haute"]
SCHEMA = {
"type": "object",
"required": ["categorie", "urgence", "nom", "commande", "telephone", "montant", "action", "a_confirmer"],
"properties": {
"categorie": {"type": "string", "enum": CATEGORIES},
"urgence": {"type": "string", "enum": URGENCES},
"nom": {"type": ["string", "null"]},
"commande": {"type": ["string", "null"]},
"telephone": {"type": ["string", "null"]},
"montant": {"type": ["number", "null"]},
"action": {"type": "string"},
"a_confirmer": {"type": "array", "items": {"type": "string"}},
},
}
INSTRUCTIONS = """Tu tries les e-mails reçus par le service client d'un fournisseur de bureau.
Remplis la fiche à partir de l'e-mail, sans rien inventer.
- categorie : livraison, facturation, support technique, avant-vente, résiliation ou autre.
- urgence : haute si le client est bloqué, lésé financièrement, très mécontent ou si un délai
imminent risque d'être manqué ; basse pour une question simple ou un remerciement ; sinon normale.
- nom : nom et prénom de la personne qui écrit, null si l'e-mail n'est pas signé par une personne.
- commande : le numéro de commande, de contrat ou de client cité, tel qu'il est écrit, sinon null.
Un numéro de facture n'est pas un numéro de commande.
- telephone : tel qu'il est écrit, sinon null.
- montant : le montant en euros dont parle l'e-mail, sinon null.
- action : ce que le service client doit faire, en quelques mots.
- a_confirmer : les informations contradictoires ou douteuses ; liste vide s'il n'y en a pas.
Réponds uniquement par un objet JSON avec les clés categorie, urgence, nom, commande,
telephone, montant, action et a_confirmer."""
class FicheInvalide(ValueError):
pass
def lire_json(texte: str) -> dict:
"""Extrait l'objet JSON de la réponse et vérifie qu'il respecte le schéma."""
debut, fin = texte.find("{"), texte.rfind("}")
try:
fiche = json.loads(texte[debut : fin + 1])
except ValueError as e:
raise FicheInvalide(f"JSON illisible : {texte[:80]!r}") from e
manquants = set(SCHEMA["required"]) - fiche.keys()
if manquants:
raise FicheInvalide(f"clés manquantes : {sorted(manquants)}")
if fiche["categorie"] not in CATEGORIES or fiche["urgence"] not in URGENCES:
raise FicheInvalide(f"valeur hors liste : {fiche['categorie']!r}, {fiche['urgence']!r}")
if isinstance(fiche["montant"], str): # « 389,90 € » -> 389.9
nombre = re.sub(r"[^\d,.]", "", fiche["montant"]).replace(",", ".")
fiche["montant"] = float(nombre) if nombre else None
return fiche
def trier(model: str, email: str, think: bool = False, impose: bool = False) -> tuple[dict, dict]:
"""Renvoie la fiche et les mesures (durées en secondes, débit en jetons/s)."""
start = time.perf_counter()
corps = {
"model": model,
"messages": [
{"role": "system", "content": INSTRUCTIONS},
{"role": "user", "content": email},
],
"think": think,
"stream": False,
"keep_alive": "10m",
# num_predict borne la réponse : sans lui, un modèle qui raisonne peut tourner en boucle.
"options": {"temperature": 0, "seed": 42, "num_ctx": 8192, "num_predict": 3000},
}
if impose:
corps["format"] = SCHEMA
r = requests.post(OLLAMA, json=corps, timeout=600)
if not r.ok:
raise RuntimeError(f"{r.status_code} {r.text}")
data = r.json()
stats = {
"total": time.perf_counter() - start,
"chargement": data.get("load_duration", 0) / 1e9,
"jetons_sortie": data.get("eval_count", 0),
"debit": data.get("eval_count", 0) / max(data.get("eval_duration", 1) / 1e9, 1e-9),
}
return lire_json(data["message"]["content"]), stats
if __name__ == "__main__":
fiche, stats = trier(sys.argv[1], sys.argv[2])
print(json.dumps(fiche, ensure_ascii=False, indent=2))
print(f"{stats['total']:.1f} s, {stats['debit']:.0f} jetons/s")Trois réglages méritent une explication.
temperature: 0etseed: 42rendent les réponses aussi stables que possible. Elles ne le sont pas totalement : d’un passage à l’autre, nous avons vu un ou deux champs changer.num_predict: 3000borne la longueur de la réponse. C’est notre deuxième piège. Sans cette limite, Granite en mode raisonnement (think: true) a produit plus de 4 500 jetons de réflexion pour un seul e-mail, a dépassé sa fenêtre de contexte et a continué à tourner en boucle, même après l’arrêt du script : il a fallu redémarrer Ollama.imposechoisit entre deux façons d’obtenir du JSON. C’est le troisième piège, détaillé plus bas.
Pour essayer sur un e-mail :
python trier.py qwen3.5:9b "Bonjour, le lien pour télécharger ma facture ne marche pas. Commande CMD-20455."Étape 3 : comparer les modèles
Le script passe les 12 e-mails à chaque modèle et vérifie six champs par e-mail, soit 72 champs. Il décharge le modèle précédent avant de passer au suivant. Le premier e-mail sert aussi à mesurer le temps de chargement ; il est exclu de la moyenne de temps par e-mail.
"""Compare plusieurs modèles locaux sur les 12 e-mails de emails.json.
Pour chaque modèle : champs justes (6 par e-mail), catégories et urgences
justes, doute signalé sur l'e-mail contradictoire (e12), temps de chargement,
temps moyen par e-mail, débit et mémoire occupée. Les fiches sont gardées
dans resultats/.
Usage : python comparer.py granite4.2:8b qwen3.5:9b gemma4:12b
Suffixes : « +think » active le raisonnement, « +schema » fait imposer le JSON par
Ollama (moteur llama.cpp seulement). Exemple : granite4.2:8b+schema
"""
import json
import os
import re
import sys
import unicodedata
import requests
from trier import FicheInvalide, trier
CHAMPS = ["categorie", "urgence", "nom", "commande", "telephone", "montant"]
def norme(valeur):
if valeur is None:
return None
if isinstance(valeur, (int, float)):
return round(float(valeur), 2)
texte = unicodedata.normalize("NFKD", str(valeur)).encode("ascii", "ignore").decode().lower()
if re.fullmatch(r"[\d\s.+-]{10,}", texte): # téléphone : on ne garde que les chiffres
return re.sub(r"\D", "", texte)[-9:]
return re.sub(r"\s+", " ", texte).strip(" .")
def juste(champ: str, obtenu, attendu) -> bool:
if champ == "nom" and attendu and obtenu:
return set(norme(attendu).split()) <= set(norme(obtenu).split())
return norme(obtenu) == norme(attendu)
def memoire(model: str) -> float:
"""Mémoire occupée par le modèle chargé, en Go."""
for m in requests.get("http://localhost:11434/api/ps", timeout=10).json()["models"]:
if m["name"] == model:
return m["size"] / 1e9
return 0.0
def decharger() -> None:
"""Libère la mémoire occupée par les modèles déjà chargés."""
for m in requests.get("http://localhost:11434/api/ps", timeout=10).json()["models"]:
requests.post("http://localhost:11434/api/generate", json={"model": m["name"], "keep_alive": 0}, timeout=60)
if __name__ == "__main__":
emails = json.load(open("emails.json"))
os.makedirs("resultats", exist_ok=True)
print(f"{'modèle':<24}{'champs':>8}{'catég.':>8}{'urgence':>9}{'doute e12':>10}"
f"{'charg.':>8}{'s/mail':>8}{'jet./s':>8}{'Go':>6} erreurs")
for nom in sys.argv[1:]:
model, *options = nom.split("+")
decharger() # un seul modèle en mémoire à la fois
fiches, erreurs, durees, debits, chargement = {}, [], [], [], 0.0
for i, email in enumerate(emails):
try:
fiche, stats = trier(model, email["texte"], "think" in options, "schema" in options)
except FicheInvalide as e: # fiche inexploitable : tous ses champs comptent faux
fiche, stats = {"invalide": str(e)}, None
if stats is None:
erreurs += [f"{email['id']}.{champ}=invalide" for champ in CHAMPS]
fiches[email["id"]] = fiche
continue
fiches[email["id"]] = fiche
if i == 0:
chargement = stats["chargement"]
else: # le premier appel inclut le chargement du modèle
durees.append(stats["total"])
debits.append(stats["debit"])
for champ in CHAMPS:
if not juste(champ, fiche.get(champ), email["attendu"][champ]):
erreurs.append(f"{email['id']}.{champ}={fiche.get(champ)!r}")
with open(f"resultats/{nom.replace(':', '_')}.json", "w") as f:
json.dump(fiches, f, ensure_ascii=False, indent=2)
total = len(emails) * len(CHAMPS)
n_cat = len(emails) - sum(".categorie=" in e for e in erreurs)
n_urg = len(emails) - sum(".urgence=" in e for e in erreurs)
doute = "oui" if fiches["e12"].get("a_confirmer") else "non"
print(f"{nom:<24}{total - len(erreurs):>3}/{total:<4}{n_cat:>4}/12{n_urg:>5}/12{doute:>10}"
f"{chargement:>7.1f}s{sum(durees) / len(durees):>7.1f}s{sum(debits) / len(debits):>8.0f}"
f"{memoire(model):>6.1f} {', '.join(erreurs) or '-'}")python comparer.py granite4.2:8b+schema granite4.2:8b qwen3.5:9b gemma4:12bLes résultats
| Modèle | Champs justes | Catégorie | Urgence | Doute signalé | Temps par e-mail | Mémoire |
|---|---|---|---|---|---|---|
| Granite 4.2 8B, JSON imposé | 65/72 | 10/12 | 9/12 | non | 7,9 s | 6,4 Go |
| Granite 4.2 8B, schéma dans la consigne | 63/72 | 10/12 | 8/12 | oui | 6,4 s | 6,4 Go |
| Qwen 3.5 9B | 68/72 | 10/12 | 10/12 | oui | 4,4 s | 9,2 Go |
| Gemma 4 12B | 67/72 | 12/12 | 8/12 | oui | 3,3 s | 11,5 Go |
Lecture : « Doute signalé » indique si le modèle a relevé la contradiction 75 € / 50 € de l’e-mail ci-dessus.
Les trois modèles remplissent correctement 87 à 94 % des champs, sur un portable, sans connexion. Aucun n’a inventé de coordonnées : les noms, numéros de commande et téléphones sont recopiés tels quels. Les écarts se jouent ailleurs.
- Qwen 3.5 9B fait le meilleur score (68 sur 72) et juge le mieux l’urgence. Il a signalé la contradiction sur l’avoir et détaillé ce qu’il fallait vérifier.
- Gemma 4 12B ne se trompe sur aucune catégorie et lit correctement l’ironie du colis écrasé. Il est le plus rapide de notre série, mais aussi le plus lourd en mémoire, et il surestime volontiers l’urgence.
- Granite 4.2 8B est le plus léger : 6,4 Go en mémoire, contre 9 à 12 Go pour les deux autres. Il est un cran en dessous sur la compréhension. Il a pris le client ironique au premier degré, a classé son colis écrasé en « support technique » et, en mode JSON imposé, n’a vu aucune contradiction dans l’histoire de l’avoir.
Sur l’e-mail contradictoire, la différence se lit directement. Qwen :
{
"categorie": "facturation",
"urgence": "normale",
"nom": "Camille Petit",
"commande": "CMD-20301",
"telephone": "07 50 40 30 20",
"montant": 75.0,
"action": "Vérifier l'état de l'avoir promis (75 €) et le montant évoqué ultérieurement (50 €), puis régulariser la situation sur la facture.",
"a_confirmer": [
"Montant de l'avoir : le client mentionne initialement 75 €, puis 50 €.",
"Application de l'avoir : le client n'a rien vu sur sa dernière facture."
]
}Granite, en mode JSON imposé :
{
"a_confirmer": [],
"action": "Régulariser l'avoir de 75 € pour la commande CMD-20301 et vérifier la mention sur la dernière facture.",
"categorie": "facturation",
"commande": "CMD-20301",
"montant": 75,
"nom": "Camille Petit",
"telephone": "07 50 40 30 20",
"urgence": "normale"
}Les deux fiches sont « justes » champ par champ. Seule la première évite au service client de rembourser le mauvais montant.
Une note sur la vitesse
Comptez de 3 à 8 secondes par e-mail selon le modèle, une fois le modèle chargé (une à trois secondes au premier appel). Nos mesures de débit ont varié d’un passage à l’autre : pendant le banc d’essai, Granite est descendu à 18 jetons par seconde, avec un Mac branché mais dont la batterie était presque vide. En conversation directe, un peu plus tard, nous avons mesuré 52 jetons par seconde pour Granite, 67 pour Qwen et 51 pour Gemma. Prenez ces chiffres comme des ordres de grandeur. Pour un tri d’e-mails en arrière-plan, n’importe lequel des trois suffit largement : 200 e-mails par jour représentent une vingtaine de minutes de calcul.
Le JSON imposé ne marche pas partout (troisième piège)
Ollama sait contraindre la génération pour que la réponse respecte exactement un schéma JSON (paramètre format). C’est la méthode la plus sûre… quand elle est disponible. Sur Mac, les versions récentes d’Ollama font tourner Qwen 3.5 et Gemma 4 avec MLX, le moteur d’Apple, et ce moteur répond structured output is unavailable. Granite, servi par llama.cpp, l’accepte.
D’où les deux modes de trier.py : par défaut, le schéma est décrit dans la consigne et la réponse est contrôlée ensuite ; avec +schema, Ollama l’impose. Sur nos 12 e-mails, aucun modèle n’a renvoyé de JSON invalide en mode consigne. Le contrôle reste indispensable : en production, une fiche invalide doit partir en relecture humaine, pas dans votre outil de gestion.
Quand tous les modèles se trompent pareil, c’est la consigne (quatrième piège)
Lors de notre premier passage, les trois modèles ont rangé le numéro de facture FA-2026-0912 dans le champ « commande ». Ce n’était pas un défaut des modèles : notre consigne parlait de « numéro de commande, de contrat ou de client » sans dire quoi faire d’une facture. Nous avons ajouté une ligne : « Un numéro de facture n’est pas un numéro de commande. » Qwen a corrigé ; Granite et Gemma ont continué. Une erreur partagée par tous les modèles pointe presque toujours vers la consigne ou vers la fiche attendue, qu’il faut alors revoir.
Même chose pour l’urgence, le champ où tous perdent des points. « Normale » ou « haute » pour un client qui a passé une commande à la mauvaise adresse ce matin ? Deux personnes du service client ne répondraient pas forcément pareil. Si l’urgence pilote vos priorités, donnez des exemples concrets dans la consigne plutôt qu’une définition.
Lequel choisir ?
- Un poste avec 16 Go de mémoire ou moins : Granite 4.2 8B. Il tient en 6,4 Go et accepte le JSON imposé. Prévoyez une relecture humaine des cas ambigus.
- Un Mac avec 24 Go ou plus : Qwen 3.5 9B pour la qualité du tri et la détection des doutes, Gemma 4 12B si les catégories comptent plus que l’urgence.
- Dans tous les cas : construisez votre propre jeu de 20 à 50 e-mails réels anonymisés avec la réponse attendue, et refaites ce test. Le meilleur modèle sur nos e-mails de fournitures de bureau n’est pas forcément le meilleur sur les vôtres.
Et DeepSeek V4.1 Flash ? Il reste intéressant pour une entreprise qui dispose de serveurs avec plusieurs centaines de Go de mémoire GPU, ou via un hébergeur qui le sert en Europe. Sur un poste de travail, ce n’est pas une option.
Limites de ce test
- 12 e-mails écrits pour l’occasion, tous en français et tous fictifs : c’est un banc d’essai, pas une étude statistique.
- Un seul Mac, et des vitesses mesurées dans des conditions d’alimentation imparfaites.
- Les modèles tournent dans leur version quantifiée par défaut sur Ollama (4 bits environ). Les versions complètes seraient un peu meilleures et beaucoup plus lourdes.
- Nous n’avons pas testé le mode raisonnement de façon systématique : sur Granite, il multipliait le temps par e-mail par vingt environ, et nous l’avons écarté.
Pour libérer la mémoire après le test :
ollama stop granite4.2:8b; ollama stop qwen3.5:9b; ollama stop gemma4:12b
# et pour supprimer les modèles du disque (environ 20 Go) :
ollama rm granite4.2:8b qwen3.5:9b gemma4:12b


