Gemma locale era troppo lento con AIdaemon finché non ho corretto llama.cpp e la dimensione del prompt
Eseguo AIdaemon sul mio Mac quasi tutti i giorni. È il demone di agenti self-hosted che ho costruito in Rust. Per mesi il backend LLM è stato OpenRouter, più Gemini, il cui livello gratuito era generoso. Una volta esaurito, pagavo qualche dollaro al mese per qualcosa che potevo ospitare da solo. Volevo solo provare l'inferenza locale con la famiglia Gemma di Google senza aggiungere un runtime che non usavo già.
Avevo installato llama.cpp tramite Homebrew e un Gemma 4 26B MoE GGUF su disco (unsloth/gemma-4-26B-A4B-it, Q4_K_M), circa sedici gigabyte, su un M4 Pro con 48 GB di memoria unificata. Ollama sarebbe stata la strada più facile. L'ho saltata di proposito, dato che comunque incapsula llama.cpp e volevo direttamente i flag di performance.
Lo stack è finito per assomigliare a questo.
Telegram / Slack → AIdaemon → llama-server (API compatibile con OpenAI) → Gemma 4 26B GGUF
AIdaemon non carica i pesi del modello. Parla con qualsiasi cosa che assomigli all'API di chat di OpenAI. llama-server di llama.cpp si adatta a quella forma.
Farlo funzionare del tutto
Il primo intoppo sono state le porte. AIdaemon utilizza già la porta 8080 per i controlli di integrità e i callback OAuth. llama-server utilizza la stessa porta per impostazione predefinita. Ho impostato l'inferenza sulla porta 8081.
Il secondo intoppo è stata la modalità di pensiero. Gemma 4 ha il ragionamento/pensiero attivato nel template di chat. llama-server ha registrato thinking = 1 all'avvio. Le risposte sono finite in reasoning_content mentre content tornava vuoto. AIdaemon legge content. Da Telegram sembrava che il modello fosse diventato silenzioso.
La soluzione è stata un flag.
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 è importante per il template di Gemma 4. --reasoning off è importante per AIdaemon. Senza di esso, stai debuggando l'agente quando il modello sta effettivamente rispondendo in un campo che nessuno legge.
E lo terrei disattivato anche senza quel bug. Il pensiero consuma una pila di token extra prima di ogni risposta, il che si traduce in latenza reale su un modello locale, e un agente ottiene gratuitamente parte di quel ragionamento lavorando su un'attività attraverso chiamate di strumenti con feedback reale invece di un lungo monologo interno. Sto scambiando un po' di profondità di ragionamento per la velocità, il che per un assistente locale veloce è la scelta giusta, e posso riattivarlo per il raro compito che lo richiede veramente.
Dal lato di AIdaemon, il blocco provider puntava al server locale.
[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 = []
Ho mantenuto OpenRouter come voce [[provider.fallbacks]] in modo che un llama-server inattivo non bloccasse il demone. Il nome del modello locale deve corrispondere a --alias su llama-server, non allo slug del repository Hugging Face.
Test di fumo prima di toccare 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}'
Se content è vuoto e reasoning_content è pieno, il pensiero è ancora attivo.
Perché sembrava comunque lento
I messaggi semplici andavano bene. Il lavoro dell'agente no. Inviavo qualcosa di normale su Telegram e aspettavo. E aspettavo.
Il log di llama-server raccontava la storia. Prompt intorno ai 14.500 token. Non è un errore di battitura e non è un singolo messaggio utente corposo. Su un modello con contesto 16k, AIdaemon alloca messaggi più schemi di strumenti a circa 14,8k e riserva il resto per l'output. Stavo raggiungendo il limite ogni turno.
Alcune cose riempiono quel payload.
- Prompt di sistema. Regole operative, guardrail di sicurezza, elenco specialisti, contesto del canale. Un ampio template statico. Nelle iterazioni successive del loop, scarta la guida degli strumenti markdown, ma il prompt principale è ancora di migliaia di token.
- Schemi JSON degli strumenti, inviati separatamente dai messaggi di chat ad ogni chiamata LLM. Con la mia installazione completa, si tratta di circa 35-40 strumenti integrati, più eventuali strumenti MCP che corrispondono al turno. Nomi, parametri, campi obbligatori, enum. Le descrizioni si accumulano anche dopo la compattazione.
- Cronologia della conversazione. Turni recenti compressi, riepilogo opzionale della sessione e risultati completi degli strumenti per l'interazione corrente. Alcuni risultati corposi di
terminaloread_filepossono competere con il costo dello schema. - Memoria, e non l'intero archivio di fatti scaricato. Un piccolo pin di fatti critici e istruzioni per recuperare il resto tramite strumenti di memoria quando necessario.
La prima iterazione di un turno è peggiore. Il prompt di sistema include ancora la documentazione degli strumenti markdown *e* gli schemi JSON vengono inviati in parallelo. Le interfacce utente di chat inviano una bolla. I demone di agenti spediscono un manuale operativo più un catalogo di strumenti più qualunque cosa abbia restituito l'ultimo comando.
L'inferenza locale ha due velocità e si comportano in modo molto diverso.
- Prefill è il modello che legge il tuo prompt. Il tempo scala con la dimensione del prompt. È qui che i carichi di lavoro degli agenti fanno male.
- Generazione è il modello che scrive la risposta. Il throughput rimane approssimativamente costante, indipendentemente dal fatto che il prompt fosse breve o lungo.
Sulla mia macchina, questa distinzione contava più di qualsiasi singolo flag di llama.cpp.
Velocità di inferenza sul mio M4 Pro
Hardware per questi numeri. Apple M4 Pro, 48 GB di memoria unificata, gemma-4-26B-A4B-it-UD-Q4_K_M.gguf (Unsloth Q4_K_M), build 9140 di llama.cpp, offload Metal completo (-ngl 99), configurazione ottimizzata a slot singolo di seguito. Gemma 4 26B è MoE, circa 4B parametri attivi al momento dell'inferenza. La generazione sembra più vicina a un modello di medie dimensioni. Il prefill cammina ancora attraverso l'intero prompt.
Ho estratto i tempi dal campo timings di llama-server nella risposta JSON compatibile con OpenAI dopo un modello caldo. Lo stesso server che è in esecuzione oggi.
| Dimensione prompt | Token di input | Tempo di wall prefill | Prefill tok/s | Generazione tok/s |
|---|---|---|---|---|
| Chat breve (calda) | 49 | 0,2 s | ~230 | ~48 |
| Piccolo turno agente | ~1.000 | 1,6 s | ~650 | ~44 |
| Contesto medio | ~5.000 | 6,2 s | ~630 | ~43 |
| Contesto grande | ~10.000 | 8,9 s | ~550 | ~40 |
| Turno AIdaemon reale | ~14.500 | 8,4 s | ~480 | ~35 |
La generazione è rimasta intorno ai 40-48 tok/s in generale. Il prefill ha dominato. Un prompt di agente di circa 14,5k token ha impiegato circa otto secondi e mezzo per elaborare l'input prima del primo token di output. Non è un server bloccato. È il modello che finisce la fase di lettura.
Calcoli a spanne per un singolo salto LLM dell'agente su questa configurazione.
- Prefill ~14,5k a ~480 tok/s ≈ 8 s prima che arrivi qualcosa
- Risposta ~200 token a ~40 tok/s ≈ 5 s di generazione
- Un salto ≈ 13 s minimo, prima dell'esecuzione dello strumento o di un secondo salto
Un loop di agente a tre iterazioni con chiamate di strumenti può facilmente superare i quaranta secondi di tempo del modello da solo. Telegram sembra rotto molto prima che l'hardware sia effettivamente in difficoltà.
Confronta questo con un prompt di chat stupido. "Di' ciao in una frase" sulla configurazione predefinita di llama-server a quattro slot ha misurato circa 48 tok/s di prefill e 52 tok/s di generazione sulla stessa macchina. Dopo essere passato a --parallel 1 e ai flag di batch/cache, lo stesso test curl breve è saltato a circa 127 tok/s di prefill con la generazione ancora intorno ai 57 tok/s. L'ottimizzazione del server ha principalmente spostato l'ago sul prefill per prompt brevi e overhead di memoria. Non ha cancellato la tassa di otto secondi su un contesto agente da 14k.
Le impostazioni predefinite di llama-server hanno peggiorato il caso dell'agente, e la manopola che mi ha sorpreso è stata --parallel.
llama-server non esegue una conversazione alla volta sotto il cofano. Mantiene slot separati. Ogni slot è una finestra di contesto completa con la propria cache KV in memoria. Quando arriva una richiesta, il server sceglie uno slot, ci carica il tuo prompt e genera da lì. Una seconda richiesta può utilizzare uno slot diverso contemporaneamente senza cancellare la prima conversazione.
--parallel imposta quanti slot esistono. Se lo ometti, le build recenti di llama.cpp scelgono auto, che sul mio Mac significava quattro slot. L'avvio ha registrato n_parallel is set to auto, using n_parallel = 4 e initializing slots, n_slots = 4.
Quattro slot hanno senso quando una GPU serve più client. Un'interfaccia utente del browser, un test curl, forse un secondo utente. Il server può gestire chat concorrenti.
All'interno di una singola chat, AIdaemon è per lo più seriale. Telegram, Slack e Discord mettono in coda i messaggi per sessione in modo da non avere due loop di agenti che competono per lo stesso thread. Ma non è tutto. Un obiettivo cron programmato può avviare un responsabile di attività in background mentre stai chattando su Telegram. Un secondo obiettivo può fare lo stesso. Slack e Telegram sono sessioni diverse, quindi entrambi possono raggiungere il modello contemporaneamente se sei attivo su entrambi.
Per la mia configurazione, questa sovrapposizione era rara. Una chat di Telegram, una manciata di controlli programmati, di solito non nello stesso secondo. Il --parallel 4 predefinito significava ancora che tre slot rimanevano inattivi per la maggior parte del tempo, riservando memoria per la cache KV e la cache del prompt. Ho visto la cache del prompt crescere oltre i tre gigabyte durante i test. Quando sono sceso a --parallel 1, le richieste concorrenti da AIdaemon non si sono interrotte. llama-server le mette in coda ed esegue una alla volta. Aspetti il tuo turno invece di condividere la memoria della GPU su corsie vuote.
Se esegui regolarmente diversi obiettivi programmati contemporaneamente, o vivi su Telegram mentre cron parte ogni minuto, prova --parallel 2 o 3 invece di 1. Scambi un po' di velocità per singola richiesta per non serializzare ogni salto sovrapposto. Abbina il numero di slot a quante chiamate LLM sovrapponi effettivamente, non al predefinito quattro.
Impostare --parallel 1 lo ha ridotto a uno slot. I log hanno mostrato n_slots = 1. Tutto il budget della cache KV è andato all'unica svolta dell'agente che stavo effettivamente eseguendo.
Flag di llama-server che hanno effettivamente aiutato
Ho riavviato llama-server con un profilo a slot singolo e utente singolo.
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 è stata la vittoria più grande sul lato inferenza per la mia configurazione. Non perché il modello sia diventato più intelligente. Perché ho smesso di pagare per tre corsie di conversazione vuote che non ho mai usato. Questo è durato finché non ho iniziato a riutilizzare la cache tra i turni e i lavori in background sono diventati il problema. Maggiori dettagli di seguito.
--flash-attn on e i tipi di cache KV q8_0 hanno aiutato su Apple Silicon. Limitare --cache-ram ha impedito alla cache del prompt di gonfiarsi durante sessioni lunghe. Dimensioni del batch più grandi (-b 4096, -ub 1024) hanno accelerato il prefill su prompt corposi. --prio 2 ha spinto il processo più in alto nello scheduler. Piccola cosa, ma quando si itera sulla configurazione aiuta.
Su prompt brevi, il prefill è passato da circa 48 tok/s a 127 tok/s. La generazione è rimasta intorno ai 57 tok/s. Ciò ha confermato che l'ottimizzazione del server valeva la pena. Ha anche confermato qualcos'altro. A circa 14k token, stai ancora guardando otto o più secondi di prefill, non importa cosa. La leva successiva doveva essere la dimensione del prompt.
Rimpicciolire ciò che AIdaemon invia
L'ottimizzazione del server da sola non cancella un prefill di otto secondi quando sei bloccato vicino a 15k token ad ogni turno. L'altra metà è stata insegnare ad AIdaemon a rispettare una finestra di 16k quando il modello è locale e a compattare ciò che invia prima della chiamata invece di sperare che il server sopravviva.
Ho aggiunto un budget per modello in config.toml.
[state.context_window.model_budgets]
gemma-4-26b = 16384
Quel numero dovrebbe corrispondere a -c su llama-server. Se AIdaemon pensa di avere 128k token ma il server ne contiene solo 16k, stai pagando per un lavoro che viene troncato o fallisce in modo strano.
Nel codice, la fase di costruzione del messaggio esegue fit_tool_definitions_to_budget() prima di ogni chiamata LLM. Non scarta mai gli strumenti. Riduce i metadati in fasi. Le descrizioni diventano più brevi, le annotazioni dello schema e gli esempi vengono rimossi, finché gli strumenti serializzati non rientrano nel budget rimanente dopo che il prompt di sistema e la cronologia sono stati conteggiati. C'è un secondo passaggio dopo che l'intero prompt è stato assemblato, perché quegli inserimenti possono consumare lo spazio che pensavi di avere a disposizione.
L'agente espone ancora ogni strumento. Smette semplicemente di inviare testo dello schema lungo come un saggio che il modello locale non ha bisogno per scegliere terminal invece di read_file. Su un modello cloud da 48k o 128k potresti non notarlo mai. Su 16k locale, è la differenza tra un turno utilizzabile e otto secondi di silenzio.
Ho anche rimosso reasoning_effort dal provider locale. Quello è per i modelli di pensiero cloud. Il percorso di pensiero di Gemma è diverso e lo abbiamo già disabilitato in llama-server.
Questo rende utilizzabile Gemma locale. Non significa che 14k token sia l'obiettivo. Sto ancora guardando dove il prompt può ridursi ulteriormente. Documentazione degli strumenti duplicata alla prima iterazione, un prompt di sistema più snello quando il budget del modello è piccolo, filtraggio degli strumenti più intelligente in modo che le esecuzioni locali non trasportino un catalogo di dimensioni cloud. La compattazione è stata la soluzione che mi ha sbloccato; il prossimo round riguarda l'invio di ogni pezzo di contesto una sola volta.
La vera soluzione è stata riutilizzare il prompt, non solo rimpicciolirlo
Rimpicciolire il prompt ha aiutato, ma stavo ancora pagando un prefill ad ogni singolo turno. Poi ho capito. Il modello non dovrebbe dover rileggere gli stessi 15.000 token due volte. Quasi tutto il prompt di un agente è identico da un turno all'altro. Il prompt di sistema, gli schemi degli strumenti, i messaggi più vecchi. Solo il nuovo messaggio dell'utente e il risultato dello strumento più recente cambiano, e si trovano alla fine.
llama.cpp sa già come sfruttare questo. Mantiene la cache KV dal turno precedente. Se l'inizio del tuo prossimo prompt è byte per byte identico all'ultimo, riutilizza quel lavoro memorizzato nella cache e salta direttamente ai nuovi token. Questo è un avvio a caldo ed è veloce. Se qualcosa vicino all'inizio differisce, anche un singolo token, non può fidarsi del resto, quindi scarta la cache e legge di nuovo tutto. Questo è un avvio a freddo, ed è il percorso lento che avevo colpito ad ogni turno.
Il problema era che AIdaemon continuava a cambiare l'inizio del prompt senza volerlo. Un timestamp che cambiava, un blocco di memoria che si riordinava, un vecchio turno che veniva riassunto un po' diversamente. Piccole modifiche, ma atterravano vicino all'inizio, quindi la cache non corrispondeva mai e ogni turno diventava freddo. La soluzione è stata rendere l'inizio noioso. Un blocco di sistema stabile e i vecchi turni congelati in una forma fissa nel momento in cui scorrono fuori dalla finestra attiva, mai riscritti di nuovo. Dopo di che, i primi 15.000 token di ogni prompt erano identici al turno precedente, e llama.cpp poteva finalmente riutilizzarli.
Ciò ha anche cambiato la mia opinione su --parallel. Uno slot singolo era il più veloce per una singola richiesta isolata, ma AIdaemon esegue lavori di memoria e riepilogo in background sullo stesso server, e ognuno di quei lavori continuava ad atterrare nello slot della mia chat e a cancellare la cache che stavo cercando di mantenere calda. Quindi sono passato a --parallel 2, ho bloccato la mia conversazione su uno slot e ho inviato i lavori in background all'altro. Ora la gestione della casa lavora nella sua corsia e la mia chat rimane calda.
Il flag che nessuno menziona
Prompt stabile, il mio slot, e ogni nuovo turno era *ancora* freddo. Quasi ho rinunciato. Poi ho letto di nuovo il log di llama-server.
forcing full prompt re-processing due to lack of cache data
(likely due to SWA or hybrid/recurrent memory)
SWA sta per sliding-window attention. Nella maggior parte dei suoi layer, Gemma 4 guarda solo una finestra di token recenti invece dell'intera cronologia. Questo è parte del motivo per cui un modello da 26B è così economico da eseguire. Il problema è che, per impostazione predefinita, llama.cpp memorizza solo quella piccola finestra, quindi nel momento in cui un nuovo turno sposta le posizioni dei token, non rimane nulla da riutilizzare e ricomincia da capo. Tutto il mio attento lavoro di stabilità del prompt non poteva sopravvivere a uno schema di attenzione che scarta la maggior parte della propria cache.
Un flag lo ha risolto.
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 dice a llama.cpp di mantenere una cache di dimensioni complete per i layer con finestra invece della fetta. Costa più memoria, parecchio di più su un modello che è per lo più layer con finestra, ma ho 48 GB e una conversazione che finalmente rimane calda. Su Gemma, quel singolo flag è l'intera differenza tra riutilizzare la cache tra i turni e rileggere il prompt ogni volta. Senza di esso, la stabilità del prompt e il blocco dello slot ti danno quasi nulla.
I numeri si sono mossi come volevo. Un follow-up che prima rileggeva circa 15.000 token e si bloccava per circa trenta secondi, ora rilegge circa 1.300 e risponde in un paio. Stesso modello, stesso hardware, stessa risposta, circa il novanta percento in meno di lavoro per turno.
Ho trovato tutto questo solo perché AIdaemon me lo ha detto
Niente di tutto ciò era trovabile a sensazione. "Sembra lento" non è un rapporto di bug. Ciò che lo ha reso gestibile è che AIdaemon registra l'anatomia di ogni chiamata al modello. La dimensione del prompt, quanti token di input sono stati serviti dalla cache rispetto a quelli letti freschi, un'impronta digitale di ogni parte del prompt e quale lavoro in background è stato eseguito quando.
Il numero cache-vs-fresco è stato l'indizio. In un turno che avrebbe dovuto essere caldo, vedere il conteggio fresco tornare a quindicimila significava che la cache si era rotta, e le impronte digitali per sezione mostravano esattamente quale parte del prompt era cambiata per romperla. È così che ho catturato prima il churn del prompt, poi i lavori in background che rubavano lo slot, poi SWA. Tre diversi colpevoli nascosti dietro lo stesso sintomo di una risposta lenta. Senza quella telemetria, avrei scambiato flag a caso.
Se prendi una cosa da questo, rendi il tuo agente locale osservabile. Il modello è una scatola nera e il server è per lo più una scatola nera. Il tuo demone è l'unico posto che controlli, quindi fallo dire cosa ha inviato e cosa è stato riutilizzato ad ogni chiamata.
Cosa direi a qualcuno che prova questo
Inizia con il modello che hai già. Ho usato Gemma 4 26B MoE perché il GGUF era già scaricato. La variante unificata da 12B è la prossima sulla mia lista. Contesto più piccolo, meno RAM, probabilmente più scattante per un uso intensivo di chat.
Abbina tre numeri. -c di llama-server, model_budgets di AIdaemon e ciò che ti aspetti effettivamente in un turno di agente impegnato. Dovrebbero concordare.
Guarda i log. tail -f ~/.aidaemon/llama-server.log mostra i conteggi dei token del prompt e il comportamento degli slot. Se vedi prefill di migliaia di token ad ogni turno, correggi il contesto dell'agente prima di acquistare hardware più veloce.
Mantieni un fallback cloud mentre stai ottimizzando. Locale-first con OpenRouter (o qualunque cosa tu paghi già) come backup significa che puoi riavviare llama-server venti volte senza perdere Telegram.
Esegui llama-server prima di AIdaemon. Il demone si avvia bene senza di esso e poi va in fallback o dà errore al primo messaggio. L'ho dimenticato una volta.
Su macOS eseguo AIdaemon sotto launchd con caffeinate -i in modo che l'inattività non interrompa una lunga sessione dell'agente. llama-server è ancora manuale a meno che tu non gli dia il suo plist. Vale la pena farlo se questo diventa il tuo driver quotidiano.