Локальная Gemma была слишком медленной с AIdaemon, пока я не исправил llama.cpp и размер промпта
Я запускаю AIdaemon на своем Mac почти каждый день. Это самостоятельно размещаемый демон агента, который я создал на Rust. Несколько месяцев бэкендом LLM был OpenRouter, плюс Gemini, чей бесплатный уровень был щедрым. Как только он закончился, я платил несколько долларов в месяц за то, что мог бы разместить сам. Я просто хотел попробовать локальный инференс с семейством Gemma от Google, не добавляя среду выполнения, которую я еще не использовал.
У меня был установлен llama.cpp через Homebrew и GGUF Gemma 4 26B на диске (unsloth/gemma-4-26B-A4B-it, Q4_K_M), около шестнадцати гигабайт, на M4 Pro с 48 ГБ унифицированной памяти. Ollama был бы простым путем. Я намеренно его пропустил, так как он все равно оборачивает llama.cpp, а я хотел получить флаги производительности напрямую.
В итоге стек выглядел так.
Telegram / Slack → AIdaemon → llama-server (совместимый с OpenAI API) → Gemma 4 26B GGUF
AIdaemon не загружает веса модели. Он общается с любым, что похоже на API чата OpenAI. llama-server из llama.cpp подходит под эту форму.
Запуск вообще
Первая загвоздка была с портами. AIdaemon уже использует 8080 для проверок работоспособности и обратных вызовов OAuth. llama-server по умолчанию использует тот же порт. Я выставил инференс на 8081.
Вторая загвоздка была с режимом мышления. Gemma 4 поставляется с включенным рассуждением/мышлением в шаблоне чата. llama-server регистрировал thinking = 1 при запуске. Ответы попадали в reasoning_content, в то время как content возвращался пустым. AIdaemon читает content. Из Telegram казалось, что модель замолчала.
Решение было в одном флаге.
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 важен для шаблона Gemma 4. --reasoning off важен для AIdaemon. Без него вы отлаживаете агента, когда модель фактически отвечает в поле, которое никто не читает.
И я бы оставил его выключенным даже без этой ошибки. Мышление тратит кучу дополнительных токенов перед каждым ответом, что приводит к реальной задержке на локальной модели, а агент получает часть этого рассуждения бесплатно, работая над задачей через вызовы инструментов с реальной обратной связью вместо одного длинного внутреннего монолога. Я обмениваю немного пространства для глубоких рассуждений на скорость, что для быстрого локального помощника является правильным решением, и я могу включить его обратно для редкой задачи, которая действительно в этом нуждается.
Со стороны AIdaemon блок провайдера указывал на локальный сервер.
[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 = []
Я оставил OpenRouter как запись [[provider.fallbacks]], чтобы неработающий llama-server не вывел демон из строя. Локальное имя модели должно совпадать с --alias на llama-server, а не с идентификатором репозитория Hugging Face.
Дымовой тест перед тем, как трогать 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}'
Если content пуст, а reasoning_content заполнен, мышление все еще включено.
Почему все равно казалось медленным
Простые сообщения были в порядке. Работа агента — нет. Я отправлял что-то обычное в Telegram и ждал. И ждал.
Лог llama-server рассказывал историю. Промпты около 14 500 токенов. Это не опечатка, и это не одно большое сообщение пользователя. На модели с контекстом 16k AIdaemon выделяет сообщения плюс схемы инструментов примерно до 14.8k и резервирует остальное для вывода. Я упирался в потолок каждый ход.
Несколько вещей заполняют этот пакет.
- Системный промпт. Правила работы, меры безопасности, список специалистов, контекст канала. Большой статический шаблон. На более поздних итерациях цикла он отбрасывает руководство по инструментам в формате markdown, но основной промпт все равно состоит из тысяч токенов.
- JSON-схемы инструментов, отправляемые отдельно от сообщений чата при каждом вызове LLM. При моей полной установке это примерно 35-40 встроенных инструментов плюс любые инструменты MCP, соответствующие ходу. Названия, параметры, обязательные поля, перечисления. Описания накапливаются, даже после сжатия.
- История разговора. Свернутые последние ходы, необязательное резюме сессии и полные результаты инструментов для текущего взаимодействия. Несколько объемных результатов
terminalилиread_fileмогут сравниться по стоимости со схемой. - Память, и не весь ваш банк фактов, сброшенный туда. Небольшой набор критически важных фактов и инструкции по извлечению остального с помощью инструментов памяти при необходимости.
Первая итерация хода хуже. Системный промпт все еще включает документацию по инструментам в формате markdown *и* JSON-схемы отправляются параллельно. Чат-интерфейсы отправляют пузырь. Демоны агентов отправляют руководство по эксплуатации плюс каталог инструментов плюс все, что вернула последняя команда.
Локальный инференс имеет две скорости, и они ведут себя очень по-разному.
- Prefill — это когда модель читает ваш промпт. Время зависит от размера промпта. Именно здесь страдают рабочие нагрузки агентов.
- Generation — это когда модель пишет ответ. Пропускная способность остается примерно постоянной, независимо от того, был ли промпт коротким или длинным.
На моей машине это различие имело большее значение, чем любой отдельный флаг llama.cpp.
Скорость инференса на моем M4 Pro
Оборудование для этих цифр. Apple M4 Pro, 48 ГБ унифицированной памяти, gemma-4-26B-A4B-it-UD-Q4_K_M.gguf (Unsloth Q4_K_M), сборка llama.cpp 9140, полная выгрузка Metal (-ngl 99), оптимизированная однослотовая конфигурация ниже. Gemma 4 26B — это MoE, около 4 миллиардов активных параметров во время инференса. Генерация ощущается ближе к модели среднего размера. Prefill все еще проходит через весь промпт.
Я взял замеры времени из поля timings llama-server в JSON-ответе, совместимом с OpenAI, после прогретой модели. Тот же сервер, который работает сегодня.
| Размер промпта | Входные токены | Время prefill | Prefill токен/с | Generation токен/с |
|---|---|---|---|---|
| Короткий чат (прогретый) | 49 | 0.2 с | ~230 | ~48 |
| Малый ход агента | ~1,000 | 1.6 с | ~650 | ~44 |
| Средний контекст | ~5,000 | 6.2 с | ~630 | ~43 |
| Большой контекст | ~10,000 | 8.9 с | ~550 | ~40 |
| Реальный ход AIdaemon | ~14,500 | 8.4 с | ~480 | ~35 |
Генерация держалась около 40-48 токен/с повсеместно. Prefill доминировал. Промпт агента на ~14.5k токенов занимал около восьми с половиной секунд на обработку ввода, прежде чем появился первый выходной токен. Это не зависший сервер. Это модель, завершающая фазу чтения.
Примерный расчет для одного скачка LLM агента на этой установке.
- ~14.5k prefill при ~480 токен/с ≈ 8 с до получения чего-либо
- ~200 токенов ответа при ~40 токен/с ≈ 5 с генерации
- Один скачок ≈ минимум 13 с, до выполнения инструмента или второго скачка
Цикл агента из трех итераций с вызовами инструментов может легко занять более сорока секунд только времени модели. Telegram кажется неработающим задолго до того, как оборудование действительно начнет испытывать трудности.
Сравните это с простым чат-промптом. "Скажи привет одним предложением" на стандартной конфигурации llama-server с четырьмя слотами показал около 48 токен/с prefill и 52 токен/с generation на той же машине. После переключения на --parallel 1 и флагов пакетной обработки/кэширования, тот же короткий тест curl подскочил примерно до 127 токен/с prefill, при этом генерация осталась около 57 токен/с. Настройка сервера в основном повлияла на prefill для коротких промптов и использование памяти. Она не устранила восьмисекундный налог на контекст в 14k агента.
Стандартные настройки llama-server сделали случай агента хуже, и флаг, который меня удивил, был --parallel.
llama-server под капотом не обрабатывает один разговор за раз. Он поддерживает отдельные слоты. Каждый слот — это полное окно контекста со своим KV-кэшем в памяти. Когда приходит запрос, сервер выбирает слот, загружает в него ваш промпт и генерирует оттуда. Второй запрос может использовать другой слот одновременно, не стирая первый разговор.
--parallel устанавливает количество существующих слотов. Если вы его опустите, последние сборки llama.cpp выбирают auto, что на моем Mac означало четыре слота. При запуске было зарегистрировано n_parallel is set to auto, using n_parallel = 4 и initializing slots, n_slots = 4.
Четыре слота имеют смысл, когда один GPU обслуживает несколько клиентов. Браузерный интерфейс, тест curl, возможно, второй пользователь. Сервер может жонглировать одновременными чатами.
В рамках одного чата AIdaemon в основном последователен. Telegram, Slack и Discord ставят сообщения в очередь для каждой сессии, чтобы два цикла агента не боролись за одну и ту же ветку. Но это не вся картина. Запланированная задача cron может запустить фоновый ведущий процесс, пока вы общаетесь в Telegram. Вторая задача может сделать то же самое. Slack и Telegram — это разные сессии, поэтому обе могут одновременно обращаться к модели, если вы активны в обеих.
Для моей установки такое перекрытие было редким. Один чат в Telegram, несколько запланированных проверок, обычно не в одну и ту же секунду. Стандартный --parallel 4 все еще означал, что три слота простаивали большую часть времени, резервируя KV-кэш и память для кэша промптов. Я видел, как кэш промптов рос выше трех гигабайт во время тестирования. Когда я уменьшил до --parallel 1, одновременные запросы от AIdaemon не сломались. llama-server ставит их в очередь и выполняет по одному. Вы ждете своей очереди вместо того, чтобы делить GPU-память между пустыми каналами.
Если вы регулярно запускаете несколько запланированных задач одновременно или живете в Telegram, пока cron срабатывает каждую минуту, попробуйте --parallel 2 или 3 вместо 1. Вы жертвуете некоторой скоростью одного запроса, чтобы не сериализовать каждый перекрывающийся скачок. Сопоставьте количество слотов с тем, сколько вызовов LLM вы фактически перекрываете, а не со стандартными четырьмя.
Установка --parallel 1 свела его к одному слоту. Логи показали n_slots = 1. Весь бюджет KV-кэша пошел на один ход агента, который я фактически выполнял.
Флаги llama-server, которые действительно помогли
Я перезапустил llama-server с профилем одного слота для одного пользователя.
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 был самым большим выигрышем на стороне инференса для моей установки. Не потому, что модель стала умнее. Потому что я перестал платить за три пустых канала разговора, которые я никогда не использовал. Это продолжалось до тех пор, пока я не начал повторно использовать кэш между ходами, и фоновые задачи не стали проблемой. Об этом ниже.
--flash-attn on и типы KV-кэша q8_0 помогли на Apple Silicon. Ограничение --cache-ram остановило раздувание кэша промптов во время долгих сессий. Большие размеры пакетов (-b 4096, -ub 1024) ускорили prefill на объемных промптах. --prio 2 поднял процесс в планировщике. Мелочь, но когда вы итерируете по конфигурации, это помогает.
На коротких промптах prefill увеличился примерно с 48 токен/с до 127 токен/с. Генерация осталась около 57 токен/с. Это подтвердило, что настройка сервера стоила того. Это также подтвердило кое-что еще. При ~14k токенах вы все равно получаете более восьми секунд prefill, независимо от чего-либо. Следующим рычагом должен был стать размер промпта.
Уменьшение того, что отправляет AIdaemon
Одна только настройка сервера не устраняет восьмисекундный prefill, когда вы каждый ход находитесь в пределах 15k токенов. Другая половина заключалась в том, чтобы научить AIdaemon уважать окно в 16k, когда модель локальная, и сжимать то, что он отправляет, перед вызовом, а не надеяться, что сервер это выдержит.
Я добавил бюджет на модель в config.toml.
[state.context_window.model_budgets]
gemma-4-26b = 16384
Это число должно совпадать с -c на llama-server. Если AIdaemon думает, что у него есть 128k токенов, а сервер держит только 16k, вы платите за работу, которая усекается или странно сбоит.
В коде фаза сборки сообщений выполняет fit_tool_definitions_to_budget() перед каждым вызовом LLM. Она никогда не отбрасывает инструменты. Она обрезает метаданные поэтапно. Описания становятся короче, аннотации схем и примеры удаляются, пока сериализованные инструменты не поместятся в любой бюджет, оставшийся после системного промпта и истории. Есть второй проход после сборки полного промпта, потому что эти вставки могут съесть запас, который, как вы думали, у вас остался.
Агент по-прежнему предоставляет все инструменты. Он просто перестает отправлять схемы длиной в эссе, которые локальной модели не нужны, чтобы выбрать terminal вместо read_file. На облачной модели 48k или 128k вы можете никогда не заметить. На локальной 16k это разница между используемым ходом и восемью секундами тишины.
Я также убрал reasoning_effort у локального провайдера. Это для облачных моделей с мышлением. Путь мышления Gemma другой, и мы уже отключили его в llama-server.
Это делает локальный Gemma пригодным для использования. Это не означает, что 14k токенов — это цель. Я все еще смотрю, где промпт можно сократить дальше. Дублирующиеся документации по инструментам при первой итерации, более лаконичный системный промпт, когда бюджет модели мал, более умная фильтрация инструментов, чтобы локальные запуски не несли каталог размером с облачный. Сжатие было исправлением, которое меня разблокировало; следующий раунд — это отправка каждого фрагмента контекста один раз.
Настоящее исправление заключалось в повторном использовании промпта, а не просто в его сокращении
Сокращение промпта помогло, но я все равно платил за prefill при каждом отдельном ходе. Затем меня осенило. Модель не должна перечитывать одни и те же 15 000 токенов дважды. Почти весь промпт агента идентичен от одного хода к другому. Системный промпт, схемы инструментов, старые сообщения. Меняются только новое сообщение пользователя и последний результат инструмента, и они находятся в конце.
llama.cpp уже умеет этим пользоваться. Он сохраняет KV-кэш с предыдущего хода. Если начало вашего следующего промпта байт в байт совпадает с предыдущим, он повторно использует эту кэшированную работу и переходит прямо к новым токенам. Это теплый старт, и он быстрый. Если что-то в начале отличается, даже один токен, он не может доверять остальному, поэтому он отбрасывает кэш и читает все заново. Это холодный старт, и это медленный путь, который я проходил каждый ход.
Проблема заключалась в том, что AIdaemon постоянно менял начало промпта без необходимости. Метка времени, которая менялась, блок памяти, который переупорядочивался, старый ход, который немного иначе пересуммировался. Крошечные правки, но они попадали в начало, поэтому кэш никогда не совпадал, и каждый ход становился холодным. Исправлением было сделать начало скучным. Один стабильный системный блок и старые ходы, зафиксированные в неизменной форме в тот момент, когда они выходят из активного окна, никогда больше не перезаписываются. После этого первые 15 000 токенов каждого промпта были идентичны предыдущему ходу, и llama.cpp наконец смог их повторно использовать.
Это также изменило мое мнение о --parallel. Один слот был самым быстрым для одного изолированного запроса, но AIdaemon выполняет работу с памятью и суммированием в фоновом режиме на том же сервере, и каждая из этих задач продолжала попадать в слот моего чата и стирать кэш, который я пытался сохранить теплым. Поэтому я перешел на --parallel 2, закрепил свой разговор за одним слотом и отправил фоновые задачи в другой. Теперь обслуживание выполняется в своей собственной полосе, а мой чат остается теплым.
Флаг, о котором никто не говорит
Стабильный промпт, мой собственный слот, и каждый новый ход все еще был холодным. Я почти сдался. Затем я еще раз прочитал лог llama-server.
forcing full prompt re-processing due to lack of cache data
(likely due to SWA or hybrid/recurrent memory)
SWA означает скользящее окно внимания (sliding-window attention). В большинстве своих слоев Gemma 4 смотрит только на окно недавних токенов, а не на всю историю. Это часть того, почему модель 26B так дешева в эксплуатации. Но есть нюанс: по умолчанию llama.cpp хранит только это маленькое окно, поэтому в тот момент, когда новый ход меняет позиции токенов, ничего не остается для повторного использования, и он начинает заново. Вся моя тщательная работа по стабильности промпта не могла пережить схему внимания, которая отбрасывает большую часть своего собственного кэша.
Один флаг исправил это.
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 говорит llama.cpp хранить кэш полного размера для оконных слоев вместо среза. Это требует больше памяти, довольно много больше на модели, которая в основном состоит из оконных слоев, но у меня есть 48 ГБ, и разговор наконец остается теплым. На Gemma этот один флаг — вся разница между повторным использованием кэша между ходами и повторным чтением промпта каждый раз. Без него стабильность промпта и закрепление слота дают вам почти ничего.
Цифры изменились так, как я хотел. Последующий запрос, который раньше перечитывал около 15 000 токенов и зависал примерно на тридцать секунд, теперь перечитывает около 1300 и отвечает за пару секунд. Та же модель, то же оборудование, тот же ответ, примерно на девяносто процентов меньше работы за ход.
Я нашел это только потому, что AIdaemon мне сказал
Ничего из этого нельзя было найти на ощупь. "Кажется медленно" — это не отчет об ошибке. Что сделало это решаемым, так это то, что AIdaemon регистрирует анатомию каждого вызова модели. Размер промпта, сколько входных токенов было подано из кэша против прочитанных свежими, отпечаток каждой части промпта и какая фоновая задача выполнялась когда.
Число кэшированных против свежих было показателем. В ход, который должен был быть теплым, наблюдение за тем, как свежее число снова подскочило до пятнадцати тысяч, означало, что кэш сломался, а отпечатки по разделам показывали, какая именно часть промпта изменилась, чтобы сломать его. Именно так я сначала поймал перетасовку промпта, затем фоновые задачи, крадущие слот, затем SWA. Три разных виновника, скрывающиеся за одним и тем же симптомом медленного ответа. Без этой телеметрии я бы менял флаги случайным образом.
Если вы возьмете что-то одно из этого, сделайте ваш локальный агент наблюдаемым. Модель — это черный ящик, а сервер — в основном черный ящик. Ваш собственный демон — единственное место, которое вы контролируете, поэтому пусть он расскажет вам, что он отправил и что было повторно использовано при каждом вызове.
Что бы я сказал кому-то еще, кто пытается это сделать
Начните с модели, которая у вас уже есть. Я использовал Gemma 4 26B MoE, потому что GGUF уже был загружен. 12B унифицированный вариант — мой следующий список. Меньший контекст, меньше ОЗУ, вероятно, более отзывчивый для использования в чате.
Сопоставьте три числа. llama-server -c, AIdaemon model_budgets и то, что вы действительно ожидаете в активном ходе агента. Они должны совпадать.
Следите за логами. tail -f ~/.aidaemon/llama-server.log показывает количество токенов в промпте и поведение слотов. Если вы видите prefill на много тысяч токенов при каждом ходе, исправьте контекст агента, прежде чем покупать более быстрое оборудование.
Держите облачный резервный вариант во время настройки. Локальный приоритет с OpenRouter (или чем-либо еще, за что вы уже платите) в качестве резерва означает, что вы можете перезапускать llama-server двадцать раз, не теряя Telegram.
Запустите llama-server перед AIdaemon. Демон запускается нормально без него, а затем переходит на резервный вариант или выдает ошибку при первом сообщении. Я забыл об этом однажды.
На macOS я запускаю AIdaemon под launchd с caffeinate -i, чтобы режим ожидания не прерывал долгую сессию агента. llama-server по-прежнему запускается вручную, если вы не дадите ему собственный plist. Стоит сделать, если это станет вашим основным инструментом.