Avec Gemma 4 sous MLX, le prompt cache de mlx_lm.server ne servait à rien d’une conversation à l’autre : les couches à fenêtre glissante (sliding window) de Gemma 4 ont un cache qui ne se tronque pas, donc le serveur ne pouvait pas reprendre au milieu d’un prompt déjà lu. Gemma 4 12B relisait ~2 800 jetons et mettait ~20 s à écrire son premier mot. Le correctif : un petit script, servir_socle.py, qui fait du socle (le prompt système commun) un segment de cache à lui seul. Une nouvelle conversation ne lit plus que 160 à 230 jetons, et le premier mot tombe de 20,7 s à 2,2 s.
Le contexte : des candidats servis par mlx_lm.server
Je cherche un modèle plus léger pour l’assistant du Journal des Nuisances, une application de suivi de nuisances que j’ai co-fondée. Les candidats tournent sur mac-train, un Mac mini M2 Pro 16 Go sous macOS 26.3, avec mlx-lm 0.32.0, et passent le banc d’essai : 22 cas pilotes, notés par un LLM-juge, en moyenne de trois passages.
Chaque requête porte deux messages système : le socle (~2 700 jetons, le même pour tout le monde), puis le contexte de la personne, propre à chaque personne et à chaque heure. mlx_lm.server garde un cache de prompt à la fin des messages système, donc après le contexte : un point de reprise qui ne sert à aucune autre conversation.
Pourquoi Ministral s’en sortait et pas Gemma 4
Le même serveur donnait un premier mot en 1,4 s à Ministral 3 8B : son cache se tronque à la longueur commune avec la conversation précédente, socle compris.
Gemma 4 alterne des couches d’attention globale et des couches à fenêtre glissante, qui ne regardent que les derniers jetons. Sous MLX, le cache de ces couches ne se tronque pas : on ne peut pas le ramener à « la fin du socle ». Résultat au banc du 16 octobre, Gemma 4 12B QAT : premier mot 20,7 s, réponse complète 28 s.
Avec un modèle à fenêtre glissante, le cache de préfixe ne se coupe pas après coup : il faut un point de cache exactement là où les conversations divergent.
C’est la même famille de problème que côté llama.cpp, où Gemma 4 demande --swa-full pour réutiliser le préfixe du prompt, un réglage que j’avais aussi posé pour les mesures llama-server sur Snapdragon. Sous MLX, pas d’option équivalente : j’ai changé l’endroit où le serveur coupe.
Le correctif : faire du socle un segment de cache
servir_socle.py remplace ResponseGenerator._tokenize de mlx_lm.server sans toucher à mlx-lm. Il cherche où le prompt cesse de coïncider avec le socle seul, et coupe le premier segment à cet endroit :
fin = fin_du_socle(tokenizer, tete, prompt, kwargs)
# Le socle doit tomber à l'intérieur du premier segment, sans le remplir.
if not 0 < fin < len(segments[0]):
return prompt, segments, types, etat
if types[0] == "system" and len(segments) > 1:
segments = [segments[0][:fin], segments[0][fin:] + segments[1], *segments[2:]]
types = ["system", *types[1:]]
Le segment socle + contexte disparaît, fondu dans le suivant : à ~0,4 Go par cache pour Gemma 4 12B, il chassait celui du socle, puisque le serveur évince d’abord les plus anciens caches système. Le plafond --prompt-cache-bytes est passé de 1 à 2 Go dans servir-candidat.sh.
Qwen 3.5 et Ministral 3 refusent un second message système : socle et contexte arrivent fondus. Le script prend alors pour socle la partie commune avec le message système précédent, coupée entre deux paragraphes pour que les jetons tombent comme dans le message entier :
fin = systeme.rfind("\n\n", 0, len(os.path.commonprefix([precedent, systeme])))
if fin < SOCLE_MIN: # 1000 caractères : en dessous, un début de contexte commun
return None
Gemma 4 MLX : le prompt cache, avant et après
Mesures des 16 et 17 octobre 2026, moyenne de trois passages, latences en médiane mesurées depuis le PC à travers le mesh :
| Modèle | Lecture d’une nouvelle conversation | Premier mot | Réponse complète |
|---|---|---|---|
| Gemma 4 12B QAT, sans segment socle | ~2 800 jetons | 20,7 s | 28 s |
| Gemma 4 12B QAT, segment socle | 160 à 230 jetons | 2,2 s | 9,0 s |
| Qwen 3.5 9B, segment socle (message unique) | 100 à 300 jetons | 1,2 s | 4,7 s |
Dix fois plus rapide au premier mot, trois fois pour la réponse complète. Seul le tout premier cas d’un passage paie encore la lecture à froid du socle (21,1 s). Côté mémoire, le journal du serveur a relevé 2,85 Go de cache de prompt pour un plafond de 2 Go : ça tient sur les 16 Go du Mac, mais le plafond n’est pas une limite stricte.
Une nuance : j’avais noté que qwen3.6:35b-a3b, modèle hybride, ne réutilise pas le préfixe sous Ollama. Ici, c’est Qwen 3.5 9B sous mlx_lm.server, avec un point de cache posé à la main : autre modèle, autre moteur, la mesure ne se transpose pas.
Lessons learned
- Sous MLX, le cache des couches à fenêtre glissante de Gemma 4 ne se tronque pas. Le serveur ne peut reprendre qu’à un point de cache existant, pas au milieu.
- Un point de cache doit tomber là où les conversations divergent. Après le socle, pas après le contexte de la personne : 160 à 230 jetons lus au lieu de ~2 800.
- Un cache inutile chasse le cache utile. À ~0,4 Go par cache, le segment socle + contexte évinçait le socle ; le fondre dans le suivant l’a réglé.
- Un message système unique se découpe par préfixe commun. Coupé entre deux paragraphes, avec un minimum de 1 000 caractères pour ne pas prendre un bout de contexte pour le socle.
- Le plafond de cache de mlx_lm.server se dépasse. 2,85 Go relevés pour 2 Go configurés : à compter dans le budget mémoire d’un Mac 16 Go.