Retour au Blog

Gemma local était trop lent avec AIdaemon jusqu'à ce que je corrige llama.cpp et la taille du prompt

2026-06-0813 min read

Je fais tourner AIdaemon sur mon Mac la plupart des jours. C'est le daemon d'agent auto-hébergé que j'ai construit en Rust. Pendant des mois, le backend LLM était OpenRouter, plus Gemini, dont le niveau gratuit était généreux. Une fois qu'il a été épuisé, je payais quelques dollars par mois pour quelque chose que je pouvais héberger moi-même. Je voulais juste essayer l'inférence locale avec la famille Gemma de Google sans ajouter un runtime que je n'utilisais pas déjà.

J'avais llama.cpp installé via Homebrew et un Gemma 4 26B MoE GGUF sur disque (unsloth/gemma-4-26B-A4B-it, Q4_K_M), environ seize gigaoctets, sur un M4 Pro avec 48 Go de mémoire unifiée. Ollama aurait été le chemin le plus simple. Je l'ai sauté exprès, car il encapsule llama.cpp de toute façon et je voulais les indicateurs de performance directement.

La pile a fini par ressembler à ceci.

Telegram / Slack → AIdaemon → llama-server (API compatible OpenAI) → Gemma 4 26B GGUF

AIdaemon ne charge pas les poids du modèle. Il communique avec tout ce qui ressemble à l'API de chat OpenAI. Le llama-server de llama.cpp correspond à cette forme.

Le faire fonctionner du tout

Le premier obstacle était les ports. AIdaemon utilise déjà le port 8080 pour les vérifications de santé et les rappels OAuth. llama-server utilise par défaut le même port. J'ai mis l'inférence sur le port 8081.

Le deuxième obstacle était le mode de réflexion. Gemma 4 est livré avec le raisonnement/la réflexion activés dans le modèle de chat. llama-server a enregistré thinking = 1 au démarrage. Les réponses sont arrivées dans reasoning_content tandis que content revenait vide. AIdaemon lit content. Depuis Telegram, il semblait que le modèle s'était tu.

La solution était un indicateur.

llama-server \
  -m ~/models/llm/gemma-4-26b/gemma-4-26B-A4B-it-UD-Q4_K_M.gguf \
  --jinja \
  --reasoning off \
  -c 16384 \
  -ngl 99 \
  --alias gemma-4-26b \
  --host 127.0.0.1 \
  --port 8081

--jinja est important pour le modèle de Gemma 4. --reasoning off est important pour AIdaemon. Sans cela, vous déboguez l'agent alors que le modèle répond en fait dans un champ que rien ne lit.

Et je le laisserais désactivé même sans ce bug. La réflexion consomme une quantité importante de jetons supplémentaires avant chaque réponse, ce qui entraîne une latence réelle sur un modèle local, et un agent récupère une partie de ce raisonnement gratuitement en travaillant sur une tâche à travers des appels d'outils avec un retour d'information réel au lieu d'un long monologue interne. Je troque un peu de marge de manœuvre pour le raisonnement profond contre la vitesse, ce qui, pour un assistant local rapide, est le bon choix, et je peux le réactiver pour la tâche rare qui en a vraiment besoin.

Côté AIdaemon, le bloc fournisseur pointait vers le serveur local.

[provider]
api_key = "local"
base_url = "http://127.0.0.1:8081/v1"
kind = "openai_compatible"
max_tokens = 4096

[provider.models]
default = "gemma-4-26b"
fallback = []

J'ai gardé OpenRouter comme entrée [[provider.fallbacks]] afin qu'un llama-server défaillant ne bloque pas le daemon. Le nom du modèle local doit correspondre à --alias sur llama-server, et non au slug du dépôt Hugging Face.

Test de fumée avant de toucher Telegram.

curl http://127.0.0.1:8081/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{"model":"gemma-4-26b","messages":[{"role":"user","content":"Say hi."}],"max_tokens":50}'

Si content est vide et reasoning_content est plein, la réflexion est toujours activée.

Pourquoi cela semblait toujours lent

Les messages simples allaient bien. Le travail de l'agent non. J'envoyais quelque chose de normal sur Telegram et j'attendais. Et j'attendais.

Le journal de llama-server racontait l'histoire. Prompts autour de 14 500 jetons. Ce n'est pas une faute de frappe et ce n'est pas un seul message utilisateur volumineux. Sur un modèle à contexte 16k, AIdaemon alloue les messages plus les schémas d'outils à environ 14,8k et réserve le reste pour la sortie. Je touchais le plafond à chaque tour.

Quelques éléments remplissent cette charge utile.

  • Prompt système. Règles de fonctionnement, garde-fous de sécurité, liste des spécialistes, contexte du canal. Un grand modèle statique. Sur les itérations de boucle ultérieures, il supprime le guide d'outils markdown, mais le prompt principal représente toujours des milliers de jetons.
  • Schémas JSON d'outils, envoyés séparément des messages de chat à chaque appel LLM. Avec mon installation complète, cela représente environ 35 à 40 outils intégrés, plus tous les outils MCP qui correspondent au tour. Noms, paramètres, champs requis, énumérations. Les descriptions s'accumulent même après la compaction.
  • Historique de conversation. Tours récents compressés, résumé de session optionnel et résultats complets des outils pour l'interaction actuelle. Quelques résultats volumineux de terminal ou read_file peuvent rivaliser avec le coût du schéma.
  • Mémoire, et pas tout votre magasin de faits déversé. Une petite épingle de faits critiques et des instructions pour récupérer le reste via des outils de mémoire si nécessaire.

La première itération d'un tour est pire. Le prompt système inclut toujours la documentation des outils markdown et les schémas JSON sont envoyés en parallèle. Les interfaces utilisateur de chat envoient une bulle. Les daemons d'agent envoient un manuel d'utilisation plus un catalogue d'outils plus tout ce que la dernière commande a retourné.

L'inférence locale a deux vitesses, et elles se comportent très différemment.

  • Prefill est le modèle qui lit votre prompt. Le temps est proportionnel à la taille du prompt. C'est là que les charges de travail des agents font mal.
  • Génération est le modèle qui écrit la réponse. Le débit reste à peu près constant, que le prompt soit court ou long.

Sur ma machine, cette distinction était plus importante que n'importe quel indicateur llama.cpp unique.

Vitesse d'inférence sur mon M4 Pro

Matériel pour ces chiffres. Apple M4 Pro, 48 Go de mémoire unifiée, gemma-4-26B-A4B-it-UD-Q4_K_M.gguf (Unsloth Q4_K_M), llama.cpp build 9140, déchargement Metal complet (-ngl 99), configuration optimisée à une seule fente ci-dessous. Gemma 4 26B est MoE, environ 4 milliards de paramètres actifs au moment de l'inférence. La génération ressemble davantage à un modèle de taille moyenne. Le préremplissage parcourt toujours tout le prompt.

J'ai extrait les chronométrages du champ timings de llama-server dans la réponse JSON compatible OpenAI après un modèle chaud. Le même serveur qui fonctionne aujourd'hui.

Taille du promptJetons d'entréeTemps de préremplissagePrefill tok/sGénération tok/s
Chat court (chaud)490.2 s~230~48
Petit tour d'agent~1 0001.6 s~650~44
Contexte moyen~5 0006.2 s~630~43
Grand contexte~10 0008.9 s~550~40
Tour AIdaemon réel~14 5008.4 s~480~35

La génération est restée proche de 40 à 48 tok/s dans l'ensemble. Le préremplissage a dominé. Un prompt d'agent d'environ 14,5k jetons a nécessité environ huit secondes et demie pour traiter l'entrée avant le premier jeton de sortie. Ce n'est pas un serveur bloqué. C'est le modèle qui termine la phase de lecture.

Calcul rapide pour un saut d'agent LLM sur cette configuration.

  • ~14,5k préremplissage à ~480 tok/s ≈ 8 s avant que quoi que ce soit ne revienne
  • ~200 jetons de réponse à ~40 tok/s ≈ 5 s de génération
  • Un saut ≈ 13 s minimum, avant l'exécution de l'outil ou un deuxième saut

Une boucle d'agent à trois itérations avec des appels d'outils peut facilement atteindre plus de quarante secondes de temps machine seul. Telegram semble défaillant bien avant que le matériel ne soit réellement en difficulté.

Comparez cela à un prompt de chat simple. "Dis bonjour en une phrase" sur la configuration par défaut à quatre emplacements de llama-server a mesuré environ 48 tok/s en préremplissage et 52 tok/s en génération sur la même machine. Après être passé à --parallel 1 et aux indicateurs de lot/cache, le même test curl court a bondi à environ 127 tok/s en préremplissage avec une génération toujours autour de 57 tok/s. L'optimisation du serveur a principalement déplacé l'aiguille sur le préremplissage pour les petits prompts et la surcharge mémoire. Cela n'a pas effacé la taxe de huit secondes sur un contexte d'agent de 14k.

Les paramètres par défaut de llama-server ont aggravé le cas de l'agent, et le bouton qui m'a surpris était --parallel.

llama-server n'exécute pas une conversation à la fois en interne. Il conserve des emplacements distincts. Chaque emplacement est une fenêtre de contexte complète avec son propre cache KV en mémoire. Lorsqu'une requête arrive, le serveur choisit un emplacement, y charge votre prompt et génère à partir de là. Une deuxième requête peut utiliser un emplacement différent en même temps sans effacer la première conversation.

--parallel définit le nombre d'emplacements. Si vous l'omettez, les versions récentes de llama.cpp choisissent auto, ce qui sur mon Mac signifiait quatre emplacements. Au démarrage, il a enregistré n_parallel is set to auto, using n_parallel = 4 et initializing slots, n_slots = 4.

Quatre emplacements ont du sens lorsqu'un GPU sert plusieurs clients. Une interface de navigateur, un test curl, peut-être un deuxième utilisateur. Le serveur peut gérer des chats simultanés.

Dans une seule conversation, AIdaemon est principalement séquentiel. Telegram, Slack et Discord mettent en file d'attente les messages par session afin que vous n'ayez pas deux boucles d'agent qui se disputent le même fil. Mais ce n'est pas toute l'histoire. Un objectif cron planifié peut lancer un responsable de tâche d'arrière-plan pendant que vous discutez sur Telegram. Un deuxième objectif peut faire de même. Slack et Telegram sont des sessions différentes, donc les deux peuvent atteindre le modèle en même temps si vous êtes actif sur les deux.

Pour ma configuration, ce chevauchement était rare. Une conversation Telegram, quelques vérifications planifiées, pas généralement à la même seconde. Le --parallel 4 par défaut signifiait toujours que trois emplacements restaient inactifs la plupart du temps tout en réservant le cache KV et la RAM du cache de prompt. J'ai vu le cache de prompt dépasser trois gigaoctets pendant les tests. Lorsque je suis passé à --parallel 1, les requêtes simultanées d'AIdaemon n'ont pas échoué. llama-server les met en file d'attente et en exécute une à la fois. Vous attendez votre tour au lieu de partager la mémoire GPU sur des voies vides.

Si vous exécutez régulièrement plusieurs objectifs planifiés simultanément, ou si vous vivez sur Telegram pendant que cron se déclenche chaque minute, essayez --parallel 2 ou 3 au lieu de 1. Vous sacrifiez une certaine vitesse de requête unique pour ne pas sérialiser chaque saut chevauchant. Faites correspondre le nombre d'emplacements au nombre d'appels LLM que vous chevauchez réellement, et non au quatre par défaut.

Définir --parallel 1 l'a réduit à un seul emplacement. Les journaux ont montré n_slots = 1. Tout le budget du cache KV est allé au seul tour d'agent que j'exécutais réellement.

Les indicateurs llama-server qui ont réellement aidé

J'ai redémarré llama-server avec un profil à emplacement unique et à utilisateur unique.

llama-server \
  -m ~/models/llm/gemma-4-26b/gemma-4-26B-A4B-it-UD-Q4_K_M.gguf \
  --jinja \
  --reasoning off \
  -c 16384 \
  -ngl 99 \
  --parallel 1 \
  --flash-attn on \
  --cache-type-k q8_0 \
  --cache-type-v q8_0 \
  --cache-ram 1024 \
  -b 4096 \
  -ub 1024 \
  --prio 2 \
  --alias gemma-4-26b \
  --host 127.0.0.1 \
  --port 8081

--parallel 1 a été le plus grand gain côté inférence pour ma configuration. Pas parce que le modèle est devenu plus intelligent. Parce que j'ai arrêté de payer pour trois voies de conversation vides que je n'ai jamais utilisées. Cela a tenu jusqu'à ce que je commence à réutiliser le cache entre les tours et que les tâches d'arrière-plan deviennent le problème. Plus à ce sujet ci-dessous.

--flash-attn on et les types de cache KV q8_0 ont aidé sur Apple Silicon. Limiter --cache-ram a empêché le cache de prompt de gonfler pendant les longues sessions. Des tailles de lot plus importantes (-b 4096, -ub 1024) ont accéléré le préremplissage sur les prompts volumineux. --prio 2 a poussé le processus plus haut dans le planificateur. Petite chose, mais lorsque vous itérez sur la configuration, cela aide.

Sur les prompts courts, le préremplissage est passé d'environ 48 tok/s à 127 tok/s. La génération est restée autour de 57 tok/s. Cela a confirmé que l'optimisation du serveur valait la peine. Cela a également confirmé autre chose. À environ 14k jetons, vous regardez toujours plus de huit secondes de préremplissage, quoi qu'il arrive. Le prochain levier devait être la taille du prompt.

Réduire ce qu'AIdaemon envoie

L'optimisation du serveur seule n'efface pas un préremplissage de huit secondes lorsque vous êtes bloqué près de 15k jetons à chaque tour. L'autre moitié consistait à apprendre à AIdaemon à respecter une fenêtre de 16k lorsque le modèle est local, et à compacter ce qu'il envoie avant l'appel au lieu d'espérer que le serveur y survive.

J'ai ajouté un budget par modèle dans config.toml.

[state.context_window.model_budgets]
gemma-4-26b = 16384

Ce nombre doit correspondre à -c sur llama-server. Si AIdaemon pense avoir 128k jetons mais que le serveur n'en contient que 16k, vous payez pour un travail qui est tronqué ou échoue étrangement.

En code, la phase de construction des messages exécute fit_tool_definitions_to_budget() avant chaque appel LLM. Elle ne supprime jamais les outils. Elle réduit les métadonnées par étapes. Les descriptions deviennent plus courtes, les annotations de schéma et les exemples sont supprimés, jusqu'à ce que les outils sérialisés correspondent au budget restant après que le prompt système et l'historique soient comptés. Il y a une deuxième passe après que le prompt complet soit assemblé, car ces insertions peuvent manger la marge que vous pensiez avoir.

L'agent expose toujours tous les outils. Il arrête simplement d'envoyer du texte de schéma de la longueur d'un essai dont le modèle local n'a pas besoin pour choisir terminal plutôt que read_file. Sur un modèle cloud de 48k ou 128k, vous ne le remarquerez peut-être jamais. Sur 16k local, c'est la différence entre un tour utilisable et huit secondes de silence.

J'ai également supprimé reasoning_effort sur le fournisseur local. C'est pour les modèles de réflexion cloud. Le chemin de réflexion de Gemma est différent et nous l'avons déjà désactivé dans llama-server.

Cela rend Gemma local utilisable. Cela ne signifie pas que 14k jetons est la cible. Je regarde toujours où le prompt peut encore être réduit. Documentation d'outils dupliquée lors de la première itération, un prompt système plus léger lorsque le budget du modèle est faible, un filtrage d'outils plus intelligent afin que les exécutions locales ne transportent pas un catalogue de taille cloud. La compaction a été la solution qui m'a débloqué ; la prochaine étape consiste à envoyer chaque morceau de contexte une seule fois.

La vraie solution était de réutiliser le prompt, pas seulement de le réduire

Réduire le prompt a aidé, mais je payais toujours un préremplissage à chaque tour. Puis j'ai compris. Le modèle ne devrait pas avoir à relire les mêmes 15 000 jetons deux fois. Presque tout le prompt d'un agent est identique d'un tour à l'autre. Le prompt système, les schémas d'outils, les messages plus anciens. Seul le nouveau message utilisateur et le dernier résultat de l'outil changent, et ils se trouvent à la fin.

llama.cpp sait déjà comment en tirer parti. Il conserve le cache KV du tour précédent. Si le début de votre prochain prompt est identique octet pour octet au dernier, il réutilise ce travail mis en cache et passe directement aux nouveaux jetons. C'est un démarrage à chaud, et c'est rapide. Si quelque chose près du début diffère, même un seul jeton, il ne peut pas faire confiance au reste, il jette donc le cache et relit tout à nouveau. C'est un démarrage à froid, et c'est le chemin lent que j'avais emprunté à chaque tour.

Le problème était qu'AIdaemon continuait de modifier le début du prompt sans le vouloir. Une horodatage qui changeait, un bloc de mémoire qui se réorganisait, un ancien tour qui était résumée un peu différemment. De minuscules modifications, mais elles se situaient près du début, de sorte que le cache ne correspondait jamais et que chaque tour devenait froid. La solution était de rendre le début ennuyeux. Un bloc système stable, et les anciens tours figés dans une forme fixe dès qu'ils sortent de la fenêtre active, jamais réécrits. Après cela, les 15 000 premiers jetons de chaque prompt étaient identiques à ceux du tour précédent, et llama.cpp pouvait enfin les réutiliser.

Cela a également changé mon opinion sur --parallel. Un seul emplacement était le plus rapide pour une requête isolée, mais AIdaemon effectue un travail de mémoire et de résumé en arrière-plan sur le même serveur, et chacun de ces travaux continuait d'atterrir dans l'emplacement de mon chat et d'effacer le cache que j'essayais de garder chaud. J'ai donc déplacé vers --parallel 2, j'ai épinglé ma conversation à un emplacement, et j'ai envoyé les tâches d'arrière-plan à l'autre. Maintenant, le nettoyage tourne dans sa propre voie et mon chat reste au chaud.

L'indicateur que personne ne mentionne

Prompt stable, mon propre emplacement, et chaque nouveau tour était toujours froid. J'ai presque abandonné. Puis j'ai relu le journal de llama-server une fois de plus.

forcing full prompt re-processing due to lack of cache data
(likely due to SWA or hybrid/recurrent memory)

SWA signifie attention à fenêtre glissante. Dans la plupart de ses couches, Gemma 4 ne regarde qu'une fenêtre de jetons récents au lieu de l'historique complet. C'est une partie de ce qui rend un modèle 26B si peu coûteux à exécuter. Le hic, c'est que, par défaut, llama.cpp ne stocke que cette petite fenêtre, de sorte qu'au moment où un nouveau tour déplace les positions des jetons, il n'y a plus rien à réutiliser, et il recommence. Tout mon travail minutieux de stabilité du prompt ne pouvait pas survivre à un schéma d'attention qui jette la plupart de son propre cache.

Un indicateur l'a résolu.

llama-server \
  -m ~/models/llm/gemma-4-26b/gemma-4-26B-A4B-it-UD-Q4_K_M.gguf \
  --jinja --reasoning off \
  -c 131072 \
  -ngl 99 \
  --parallel 2 \
  --swa-full \
  --flash-attn on \
  --cache-type-k q8_0 --cache-type-v q8_0 \
  --cache-ram 12288 \
  -b 4096 -ub 1024 \
  --alias gemma-4-26b --host 127.0.0.1 --port 8081

--swa-full indique à llama.cpp de conserver un cache de taille complète pour les couches fenêtrées au lieu de la tranche. Cela coûte plus de mémoire, considérablement plus sur un modèle qui est principalement composé de couches fenêtrées, mais j'ai 48 Go et une conversation qui reste enfin chaude. Sur Gemma, cet indicateur unique fait toute la différence entre réutiliser le cache entre les tours et relire le prompt à chaque fois. Sans lui, la stabilité du prompt et l'épinglage de l'emplacement ne vous apportent presque rien.

Les chiffres ont bougé comme je le voulais. Un suivi qui lisait auparavant environ 15 000 jetons et s'arrêtait pendant environ trente secondes relit maintenant environ 1 300 et répond en quelques secondes. Même modèle, même matériel, même réponse, environ quatre-vingt-dix pour cent de travail en moins par tour.

Je n'ai trouvé cela que parce qu'AIdaemon me l'a dit

Rien de tout cela n'était trouvable au toucher. "Cela semble lent" n'est pas un rapport de bug. Ce qui l'a rendu gérable, c'est qu'AIdaemon enregistre l'anatomie de chaque appel modèle. La taille du prompt, combien de jetons d'entrée ont été servis depuis le cache par rapport à la lecture fraîche, une empreinte de chaque partie du prompt, et quel travail d'arrière-plan s'est exécuté quand.

Le nombre de cache par rapport à frais était l'indice. Lors d'un tour qui aurait dû être chaud, voir le nombre de frais remonter à quinze mille signifiait que le cache était cassé, et les empreintes par section montraient exactement quelle partie du prompt avait changé pour le casser. C'est ainsi que j'ai d'abord attrapé le cycle du prompt, puis les tâches d'arrière-plan qui volaient l'emplacement, puis SWA. Trois coupables différents se cachant derrière le même symptôme d'une réponse lente. Sans cette télémétrie, j'aurais échangé des indicateurs au hasard.

Si vous retenez une chose de ceci, rendez votre agent local observable. Le modèle est une boîte noire et le serveur est principalement une boîte noire. Votre propre daemon est le seul endroit que vous contrôlez, alors faites-lui dire ce qu'il a envoyé et ce qui a été réutilisé à chaque appel.

Ce que je dirais à quelqu'un d'autre essayant cela

Commencez avec le modèle que vous avez déjà. J'ai utilisé Gemma 4 26B MoE car le GGUF était déjà téléchargé. La variante unifiée 12B est ma prochaine liste. Contexte plus petit, moins de RAM, probablement plus réactif pour une utilisation intensive du chat.

Faites correspondre trois nombres. llama-server -c, AIdaemon model_budgets, et ce que vous attendez réellement dans un tour d'agent chargé. Ils devraient correspondre.

Regardez les journaux. tail -f ~/.aidaemon/llama-server.log affiche les nombres de jetons de prompt et le comportement des emplacements. Si vous voyez des préremplissages de plusieurs milliers de jetons à chaque tour, corrigez le contexte de l'agent avant d'acheter du matériel plus rapide.

Gardez une solution de repli cloud pendant que vous optimisez. Local-first avec OpenRouter (ou tout ce que vous payez déjà) en secours signifie que vous pouvez redémarrer llama-server vingt fois sans perdre Telegram.

Exécutez llama-server avant AIdaemon. Le daemon démarre bien sans lui, puis se rabat ou échoue au premier message. J'ai oublié cela une fois.

Sur macOS, j'exécute AIdaemon sous launchd avec caffeinate -i afin que la mise en veille prolongée n'interrompe pas une longue session d'agent. llama-server est toujours manuel à moins que vous ne lui donniez sa propre plist. Cela vaut la peine de le faire si cela devient votre outil quotidien.

Restez Informé

Recevez les derniers articles et analyses directement dans votre boîte de réception.

Unsubscribe anytime. No spam, ever.