Terug naar Blog

Lokale Gemma was te traag met AIdaemon totdat ik llama.cpp en de promptgrootte heb aangepast

2026-06-0813 min read

Ik draai AIdaemon de meeste dagen op mijn Mac. Het is de zelf-gehoste agent daemon die ik in Rust heb gebouwd. Maandenlang was de LLM backend OpenRouter, plus Gemini, waarvan de gratis tier genereus was. Zodra die op was, betaalde ik een paar euro per maand voor iets dat ik zelf kon hosten. Ik wilde gewoon lokale inferentie proberen met Google's Gemma familie zonder een runtime toe te voegen die ik nog niet gebruikte.

Ik had llama.cpp geïnstalleerd via Homebrew en een Gemma 4 26B MoE GGUF op schijf (unsloth/gemma-4-26B-A4B-it, Q4_K_M), ongeveer zestien gigabyte, op een M4 Pro met 48 GB unificatiegeheugen. Ollama zou het gemakkelijke pad zijn geweest. Ik heb het expres overgeslagen, aangezien het toch llama.cpp omhult en ik de performantie-vlaggen direct wilde hebben.

De stack zag er uiteindelijk zo uit.

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

AIdaemon laadt geen modelgewichten. Het praat met alles dat lijkt op de OpenAI chat API. llama.cpp's llama-server past in dat profiel.

Het überhaupt draaiende krijgen

Eerste struikelblok waren poorten. AIdaemon bindt al 8080 voor health checks en OAuth callbacks. llama-server gebruikt standaard dezelfde poort. Ik zette inferentie op 8081.

Tweede struikelblok was de denkmodus. Gemma 4 wordt geleverd met redeneren/denken ingeschakeld in de chat template. llama-server logde thinking = 1 bij het opstarten. Reacties kwamen terecht in reasoning_content terwijl content leeg terugkwam. AIdaemon leest content. Vanuit Telegram leek het alsof het model stil was geworden.

De oplossing was één vlag.

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 is belangrijk voor de template van Gemma 4. --reasoning off is belangrijk voor AIdaemon. Zonder dat debug je de agent terwijl het model eigenlijk antwoordt in een veld dat niemand leest.

En ik zou het zelfs zonder die bug uit laten staan. Denken verbruikt een hoop extra tokens voor elk antwoord, wat echte latentie is op een lokaal model, en een agent krijgt een deel van die redenering gratis terug door een taak te doorlopen via tool calls met echte feedback in plaats van één lange interne monoloog. Ik ruil een beetje diepe redeneringsruimte in voor snelheid, wat voor een snelle lokale assistent de juiste keuze is, en ik kan het weer aanzetten voor de zeldzame taak die het echt nodig heeft.

AIdaemon kant, de provider block wees naar de lokale server.

[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 = []

Ik hield OpenRouter als een [[provider.fallbacks]] entry zodat een dode llama-server de daemon niet onbruikbaar zou maken. De lokale modelnaam moet overeenkomen met --alias op llama-server, niet de Hugging Face repo slug.

Smoke test voordat je Telegram aanraakt.

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}'

Als content leeg is en reasoning_content vol, staat denken nog aan.

Waarom het nog steeds traag aanvoelde

Simpele berichten waren prima. Agentwerk niet. Ik stuurde iets normaals op Telegram en wachtte. En wachtte.

De llama-server log vertelde het verhaal. Prompts rond de 14.500 tokens. Dat is geen typefout en het is geen één dikke gebruikersbericht. Op een 16k-context model, budgetteert AIdaemon berichten plus tool schema's naar ongeveer 14.8k en reserveert de rest voor output. Ik liep elke beurt tegen de limiet aan.

Een paar dingen vullen die payload.

  • Systeem prompt. Operationele regels, beveiligingsmaatregelen, lijst van specialisten, kanaalcontext. Een grote statische template. Bij latere loop-iteraties wordt de markdown toolgids weggelaten, maar de kernprompt is nog steeds duizenden tokens.
  • Tool JSON schema's, apart van chatberichten verzonden bij elke LLM-oproep. Met mijn volledige installatie zijn dat ruwweg 35 tot 40 ingebouwde tools, plus eventuele MCP-tools die bij de beurt pasten. Namen, parameters, vereiste velden, enums. Beschrijvingen tellen op, zelfs na compactie.
  • Gespreksgeschiedenis. Samengevouwen recente beurten, optionele sessie samenvatting, en volledige toolresultaten voor de huidige interactie. Een paar flinke terminal of read_file outputs kunnen de schema kosten evenaren.
  • Geheugen, en niet je hele feitenopslag gedumpt. Een klein kritisch-feiten-pin en instructies om de rest via geheugentools op te halen wanneer nodig.

Eerste iteratie van een beurt is erger. De systeem prompt bevat nog steeds markdown tool documentatie en de JSON schema's worden parallel verzonden. Chat UI's sturen een bubbel. Agent daemons sturen een operationeel handboek plus een toolcatalogus plus wat het laatste commando teruggaf.

Lokale inferentie heeft twee snelheden, en ze gedragen zich heel anders.

  • Prefill is het model dat je prompt leest. Tijd schaalt met promptgrootte. Hierdoor worden agent workloads zwaar.
  • Generatie is het model dat het antwoord schrijft. Doorvoer blijft ruwweg gelijk, of de prompt nu kort of lang was.

Op mijn machine maakte dat onderscheid meer uit dan enige enkele llama.cpp vlag.

Inferentiesnelheid op mijn M4 Pro

Hardware voor deze cijfers. Apple M4 Pro, 48 GB unificatiegeheugen, gemma-4-26B-A4B-it-UD-Q4_K_M.gguf (Unsloth Q4_K_M), llama.cpp build 9140, volledige Metal offload (-ngl 99), geoptimaliseerde single-slot configuratie hieronder. Gemma 4 26B is MoE, ongeveer 4B actieve parameters tijdens inferentie. Generatie voelt dichter bij een middelgroot model. Prefill loopt nog steeds de hele prompt door.

Ik haalde timings uit het timings veld van llama-server in de OpenAI-compatibele JSON-respons na een warm model. Dezelfde server die vandaag draait.

PromptgrootteInput tokensPrefill wall timePrefill tok/sGeneratie tok/s
Korte chat (warm)490.2 s~230~48
Kleine agent beurt~1.0001.6 s~650~44
Medium context~5.0006.2 s~630~43
Grote context~10.0008.9 s~550~40
Echte AIdaemon beurt~14.5008.4 s~480~35

Generatie bleef rond de 40 tot 48 tok/s over de hele linie. Prefill domineerde. Een agent prompt van ~14.5k tokens kostte ongeveer acht en een halve seconde om de input te verwerken voordat het eerste output token kwam. Dat is geen vastgelopen server. Dat is het model dat de leesfase afrondt.

Ruwe schatting voor één agent LLM-hop op deze opstelling.

  • ~14.5k prefill op ~480 tok/s ≈ 8 s voordat er iets terugkomt
  • ~200 token antwoord op ~40 tok/s ≈ 5 s generatie
  • Eén hop ≈ 13 s minimum, vóór tooluitvoering of een tweede hop

Een drie-iteratie agent loop met tool calls kan gemakkelijk veertig seconden aan modeltijd alleen al kosten. Telegram voelt kapot lang voordat de hardware daadwerkelijk moeite heeft.

Vergelijk dat met een domme chat prompt. "Zeg hallo in één zin" op de standaard vier-slot llama-server configuratie mat ongeveer 48 tok/s prefill en 52 tok/s generatie op dezelfde machine. Na het overschakelen naar --parallel 1 en de batch/cache vlaggen, sprong dezelfde korte curl test naar ongeveer 127 tok/s prefill met generatie nog steeds rond de 57 tok/s. Server tuning verplaatste voornamelijk de naald op prefill voor kleine prompts en geheugen overhead. Het elimineerde de acht-seconden belasting op een 14k agent context niet.

Standaard llama-server instellingen maakten de agent case erger, en de knop die me verraste was --parallel.

llama-server draait intern niet één gesprek tegelijk. Het houdt aparte slots aan. Elk slot is een volledig contextvenster met zijn eigen KV-cache in het geheugen. Wanneer een verzoek binnenkomt, kiest de server een slot, laadt je prompt erin, en genereert van daaruit. Een tweede verzoek kan tegelijkertijd een ander slot gebruiken zonder het eerste gesprek te wissen.

--parallel stelt het aantal slots in. Als je het weglaat, kiezen recente llama.cpp builds auto, wat op mijn Mac vier slots betekende. Opstarten logde n_parallel is set to auto, using n_parallel = 4 en initializing slots, n_slots = 4.

Vier slots zijn logisch wanneer één GPU meerdere clients bedient. Een browser UI, een curl test, misschien een tweede gebruiker. De server kan gelijktijdige chats jongleren.

Binnen één chat is AIdaemon grotendeels serieel. Telegram, Slack en Discord plaatsen berichten per sessie in de wachtrij, zodat je niet twee agent loops hebt die vechten om dezelfde thread. Maar dat is niet het hele plaatje. Een gepland cron doel kan een achtergrondtaak starten terwijl je aan het chatten bent op Telegram. Een tweede doel kan hetzelfde doen. Slack en Telegram zijn verschillende sessies, dus beide kunnen tegelijkertijd het model raken als je op beide actief bent.

Voor mijn opstelling was die overlap zeldzaam. Eén Telegram chat, een paar geplande checks, meestal niet op hetzelfde moment. Standaard --parallel 4 betekende nog steeds dat drie slots meestal leeg bleven terwijl KV-cache en prompt-cache RAM werden gereserveerd. Ik zag de prompt cache groeien tot meer dan drie gigabyte tijdens het testen. Toen ik terugschakelde naar --parallel 1, braken gelijktijdige verzoeken van AIdaemon niet. llama-server plaatst ze in de wachtrij en draait ze één voor één. Je wacht op je beurt in plaats van GPU-geheugen te delen over lege banen.

Als je regelmatig meerdere geplande doelen tegelijkertijd uitvoert, of in Telegram leeft terwijl cron elke minuut afgaat, probeer dan --parallel 2 of 3 in plaats van 1. Je ruilt wat snelheid voor enkele verzoeken in om niet elke overlappende hop te serialiseren. Pas het aantal slots aan op hoeveel LLM-oproepen je daadwerkelijk overlapt, niet op de standaard vier.

Het instellen van --parallel 1 reduceerde het tot één slot. Logs toonden n_slots = 1. Het hele KV-cache budget ging naar de ene agent beurt die ik daadwerkelijk draaide.

llama-server vlaggen die echt hielpen

Ik heb llama-server opnieuw gestart met een single-slot, single-user profiel.

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 was de grootste winst aan de inferentiekant voor mijn opstelling. Niet omdat het model slimmer werd. Omdat ik stopte met betalen voor drie lege gespreksbanen die ik nooit gebruikte. Dat hield stand totdat ik de cache begon te hergebruiken tussen beurten en de achtergrondtaken het probleem werden. Meer daarover hieronder.

--flash-attn on en q8_0 KV cache types hielpen op Apple Silicon. Het beperken van --cache-ram voorkwam dat de prompt cache opzwol tijdens lange sessies. Grotere batchgroottes (-b 4096, -ub 1024) versnelden prefill op dikke prompts. --prio 2 duwde het proces hoger in de scheduler. Klein ding, maar als je aan het configureren bent, helpt het.

Op korte prompts ging prefill van ongeveer 48 tok/s naar 127 tok/s. Generatie bleef rond de 57 tok/s. Dat bevestigde dat server tuning de moeite waard was. Het bevestigde ook iets anders. Bij ~14k tokens kijk je nog steeds naar acht-plus seconden prefill, wat je ook doet. De volgende hendel moest promptgrootte zijn.

Krimpen wat AIdaemon stuurt

Server tuning alleen elimineert geen acht seconden prefill als je elke beurt bijna op 15k tokens zit. De andere helft was AIdaemon leren om een 16k venster te respecteren wanneer het model lokaal is, en wat het stuurt te comprimeren voordat de oproep plaatsvindt in plaats van te hopen dat de server het overleeft.

Ik heb een per-model budget toegevoegd in config.toml.

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

Dat getal moet overeenkomen met -c op llama-server. Als AIdaemon denkt dat het 128k tokens heeft, maar de server slechts 16k vasthoudt, betaal je voor werk dat wordt afgekapt of vreemd faalt.

In code draait de berichtbouw fase fit_tool_definitions_to_budget() voor elke LLM-oproep. Het laat nooit tools vallen. Het trimt metadata in fasen. Beschrijvingen worden korter, schema-annotaties en voorbeelden worden verwijderd, totdat de geserialiseerde tools passen binnen het budget dat overblijft na de systeem prompt en geschiedenis. Er is een tweede pass nadat de volledige prompt is samengesteld, omdat die invoegingen de ruimte kunnen opeten waarvan je dacht dat je die nog had.

De agent stelt nog steeds elke tool bloot. Het stuurt gewoon geen essay-lange schema tekst meer die het lokale model niet nodig heeft om terminal boven read_file te kiezen. Op een 48k of 128k cloud model merk je dat misschien nooit. Op 16k lokaal is het het verschil tussen een bruikbare beurt en acht seconden stilte.

Ik heb ook reasoning_effort op de lokale provider laten vallen. Dat is voor cloud denkmodellen. Gemma's denkpad is anders en we hebben het al uitgeschakeld in llama-server.

Dat maakt lokale Gemma bruikbaar. Het betekent niet dat 14k tokens het doel is. Ik kijk nog steeds waar de prompt verder kan krimpen. Dubbele tool documentatie bij de eerste iteratie, een slankere systeem prompt wanneer het modelbudget klein is, slimmere tool filtering zodat lokale runs geen cloud-formaat catalogus meedragen. Compactie was de oplossing die me heeft geholpen; de volgende ronde gaat over het één keer verzenden van elk stuk context.

De echte oplossing was het hergebruiken van de prompt, niet alleen het verkleinen ervan

Het verkleinen van de prompt hielp, maar ik betaalde nog steeds voor een prefill bij elke beurt. Toen drong het tot me door. Het model hoeft niet twee keer dezelfde 15.000 tokens opnieuw te lezen. Bijna alles van een agent prompt is identiek van de ene beurt naar de andere. De systeem prompt, de tool schema's, de oudere berichten. Alleen het nieuwe gebruikersbericht en het laatste toolresultaat veranderen, en die staan aan het einde.

llama.cpp weet al hoe het daarvan kan profiteren. Het behoudt de KV-cache van de vorige beurt. Als het begin van je volgende prompt byte-voor-byte identiek is aan de vorige, hergebruikt het die gecachte werkzaamheden en springt het direct naar de nieuwe tokens. Dat is een warme start, en het is snel. Als iets aan het begin anders is, zelfs een enkel token, kan het de rest niet vertrouwen, dus gooit het de cache weg en leest het alles opnieuw. Dat is een koude start, en het is het trage pad dat ik elke beurt tegenkwam.

Het probleem was dat AIdaemon de voorkant van de prompt bleef veranderen zonder dat het de bedoeling was. Een tijdstempel die veranderde, een geheugenblok dat opnieuw werd geordend, een oude beurt die een beetje anders werd samengevat. Kleine bewerkingen, maar ze kwamen aan het begin, dus de cache kwam nooit overeen en elke beurt werd koud. De oplossing was om de voorkant saai te maken. Eén stabiel systeemblok, en oude beurten bevroren in een vaste vorm zodra ze uit het live venster schuiven, nooit meer herschreven. Daarna waren de eerste 15.000 tokens van elke prompt identiek aan de beurt ervoor, en kon llama.cpp ze eindelijk hergebruiken.

Dat veranderde ook mijn mening over --parallel. Eén slot was het snelst voor één geïsoleerd verzoek, maar AIdaemon doet geheugen- en samenvattingswerk op de achtergrond op dezelfde server, en elk van die taken kwam in het slot van mijn chat terecht en verwijderde de cache die ik probeerde warm te houden. Dus ik ging naar --parallel 2, pegde mijn gesprek vast aan één slot, en stuurde de achtergrondtaken naar de andere. Nu draait het huishoudwerk in zijn eigen baan en blijft mijn chat warm.

De vlag die niemand noemt

Stabiele prompt, mijn eigen slot, en elke nieuwe beurt was nog steeds koud. Ik gaf bijna op. Toen las ik de llama-server log nog een keer.

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

SWA staat voor sliding-window attention. In de meeste van zijn lagen kijkt Gemma 4 alleen naar een venster van recente tokens in plaats van de hele geschiedenis. Dat is een deel van de reden waarom een 26B model zo goedkoop te draaien is. De vangst is dat, standaard, llama.cpp alleen dat kleine venster opslaat, dus zodra een nieuwe beurt de tokenposities verandert, is er niets meer te hergebruiken, en begint het opnieuw. Al mijn zorgvuldige prompt-stabiliteitswerk kon geen aandachtsschema overleven dat het grootste deel van zijn eigen cache weggooit.

Eén vlag loste het op.

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 vertelt llama.cpp om een cache van volledige grootte te behouden voor de windowed lagen in plaats van de slice. Het kost meer geheugen, behoorlijk wat meer op een model dat grotendeels uit windowed lagen bestaat, maar ik heb 48 GB en een gesprek dat eindelijk warm blijft. Op Gemma is die ene vlag het hele verschil tussen het hergebruiken van de cache tussen beurten en het elke keer opnieuw lezen van de prompt. Zonder dat levert promptstabiliteit en slot pinning je bijna niets op.

De cijfers bewogen zoals ik wilde. Een vervolg dat vroeger ongeveer 15.000 tokens opnieuw las en ongeveer dertig seconden stilstond, leest nu ongeveer 1.300 opnieuw en antwoordt in een paar seconden. Hetzelfde model, dezelfde hardware, hetzelfde antwoord, ongeveer negentig procent minder werk per beurt.

Ik vond dit alleen omdat AIdaemon het me vertelde

Niets hiervan was voelbaar te vinden. "Het lijkt traag" is geen bugrapport. Wat het behandelbaar maakte, is dat AIdaemon de anatomie van elke modeloproep logt. De promptgrootte, hoeveel input tokens uit cache werden geserveerd versus vers gelezen, een vingerafdruk van elk deel van de prompt, en welke achtergrondtaak wanneer draaide.

Het gecachte versus vers gelezen getal was de aanwijzing. Bij een beurt die warm had moeten zijn, betekende het zien van het verse getal dat terugsprong naar vijftienduizend dat de cache kapot was, en de per-sectie vingerafdrukken lieten precies zien welk deel van de prompt was veranderd om het te breken. Zo ving ik eerst de prompt churn, daarna de achtergrondtaken die het slot stalen, daarna SWA. Drie verschillende boosdoeners die zich verstopten achter hetzelfde symptoom van een traag antwoord. Zonder die telemetrie zou ik willekeurig vlaggen hebben gewisseld.

Als je één ding meeneemt uit dit, maak je lokale agent observeerbaar. Het model is een zwarte doos en de server is grotendeels een zwarte doos. Je eigen daemon is de enige plek die je controleert, dus laat hem je vertellen wat hij stuurde en wat er bij elke oproep werd hergebruikt.

Wat ik iemand anders zou vertellen die dit probeert

Begin met het model dat je al hebt. Ik gebruikte Gemma 4 26B MoE omdat de GGUF al was gedownload. De 12B unificatie variant staat als volgende op mijn lijst. Kleinere context, minder RAM, waarschijnlijk sneller voor chat-intensief gebruik.

Stem drie getallen op elkaar af. llama-server -c, AIdaemon model_budgets, en wat je daadwerkelijk verwacht in een drukke agent beurt. Ze moeten overeenkomen.

Bekijk de logs. tail -f ~/.aidaemon/llama-server.log toont prompt token tellingen en slotgedrag. Als je elke beurt multi-duizend-token prefills ziet, fix dan de agent context voordat je snellere hardware koopt.

Houd een cloud fallback bij terwijl je aan het tunen bent. Lokaal-first met OpenRouter (of wat je al betaalt) als back-up betekent dat je llama-server twintig keer kunt herstarten zonder Telegram te verliezen.

Draai llama-server vóór AIdaemon. De daemon start prima zonder en valt dan terug of geeft een fout bij het eerste bericht. Dat ben ik eens vergeten.

Op macOS draai ik AIdaemon onder launchd met caffeinate -i zodat idle slaap een lange agent sessie niet beëindigt. llama-server is nog steeds handmatig tenzij je er een eigen plist aan geeft. De moeite waard als dit je dagelijkse driver wordt.

Blijf Op de Hoogte

Ontvang de nieuwste berichten en inzichten rechtstreeks in uw inbox.

Unsubscribe anytime. No spam, ever.