返回博客

本地 Gemma 在 AIdaemon 上运行缓慢,直到我修复了 llama.cpp 和提示词大小

2026-06-0813 min read

我大部分时间都在 Mac 上运行 AIdaemon。这是我用 Rust 构建的 自托管代理守护进程。几个月以来,LLM 后端一直是 OpenRouter,加上 Gemini,它的免费套餐很慷慨。一旦用完,我每个月都要为我能自己托管的东西支付几美元。我只是想在不添加我尚未使用的运行时的情况下,尝试使用 Google 的 Gemma 系列进行本地推理。

我通过 Homebrew 安装了 llama.cpp,并且磁盘上有一个 Gemma 4 26B MoE GGUF(unsloth/gemma-4-26B-A4B-it,Q4_K_M),大约十六千兆字节,运行在具有 48 GB 统一内存的 M4 Pro 上。Ollama 会是简单的选择。我特意跳过了它,因为它毕竟是 llama.cpp 的包装器,而我想要直接的性能标志。

最终的堆栈看起来是这样的。

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

AIdaemon 不加载模型权重。它与任何看起来像 OpenAI 聊天 API 的东西进行通信。llama.cpp 的 llama-server 符合该形状。

如何运行起来

第一个障碍是端口。AIdaemon 已经为健康检查和 OAuth 回调绑定了 8080。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 很重要。没有它,你就是在调试代理,而模型实际上是在回复一个没有人读取的字段。即使没有那个 bug,我也会关闭它。思考会在每次回复前消耗大量额外 token,这对于本地模型来说是真实的延迟,而代理通过跨工具调用进行真实反馈来处理任务,而不是进行一次冗长的内部独白,从而免费获得一些推理能力。我正在用速度换取一点深度推理空间,对于一个快速的本地助手来说,这是正确的选择,并且对于真正需要它的罕见任务,我可以重新启用它。

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 就不会导致守护进程崩溃。本地模型名称必须与 llama-server 上的 --alias 匹配,而不是 Hugging Face 的仓库 slug。

在接触 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 个 token。这不是笔误,也不是一个大的用户消息。在 16k 上下文的模型上,AIdaemon 将消息加上工具模式分配给大约 14.8k,并将剩余部分保留给输出。我每个回合都在接近上限。

有几件事会填充那个 payload。

  • 系统提示。操作规则、安全护栏、专家列表、频道上下文。一个大的静态模板。在后面的循环迭代中,它会删除 markdown 工具指南,但核心提示仍然是数千个 token。
  • 工具 JSON 模式,每次 LLM 调用时与聊天消息分开发送。在我完整的安装中,大约有 35 到 40 个内置工具,加上任何匹配回合的 MCP 工具。名称、参数、必需字段、枚举。即使经过压缩,描述也会累加。
  • 对话历史。折叠的最近回合、可选的会话摘要以及当前交互的完整工具结果。一些庞大的 terminalread_file 输出的成本可能与模式相当。
  • 记忆,而不是将你的整个事实库倾倒进去。一个小的关键事实固定以及在需要时通过记忆工具获取其余部分的说明。

回合的第一次迭代更糟。系统提示仍然包含 markdown 工具文档以及 JSON 模式并行发送。聊天 UI 发送一个气泡。代理守护进程发送一个操作手册加上一个工具目录加上上一个命令返回的任何内容。

本地推理有两种速度,它们的行为非常不同。

  • 预填充是模型读取你的提示。时间与提示大小成正比。这就是代理工作量受损的地方。
  • 生成是模型编写回复。无论提示是长是短,吞吐量大致保持不变。

在我的机器上,这种区别比任何单个 llama.cpp 标志都重要。

我的 M4 Pro 上的推理速度

这些数字的硬件。Apple M4 Pro,48 GB 统一内存,gemma-4-26B-A4B-it-UD-Q4_K_M.gguf (Unsloth Q4_K_M),llama.cpp 构建 9140,完整 Metal 卸载 (-ngl 99),下面的优化单槽配置。Gemma 4 26B 是 MoE,推理时大约有 4B 个活动参数。生成感觉更接近中等大小的模型。预填充仍然会遍历整个提示。

我从 OpenAI 兼容 JSON 响应中的 llama-server timings 字段中提取了计时数据,模型已预热。与今天运行的服务器相同。

提示大小输入 token预填充墙上时间预填充 tok/s生成 tok/s
短聊天 (预热)490.2 秒~230~48
小型代理回合~1,0001.6 秒~650~44
中等上下文~5,0006.2 秒~630~43
大型上下文~10,0008.9 秒~550~40
实际 AIdaemon 回合~14,5008.4 秒~480~35

生成在整个过程中保持在 40 到 48 tok/s 左右。预填充占主导地位。一个 ~14.5k token 的代理提示在第一个输出 token 之前花费了大约 八分半钟 来处理输入。这不是服务器卡住了。这是模型完成了读取阶段。

粗略计算一下这个设置上的一次代理 LLM 跳跃。

  • ~14.5k 预填充,约 480 tok/s ≈ 8 秒,然后才有东西返回
  • ~200 token 回复,约 40 tok/s ≈ 5 秒 生成
  • 一次跳跃 ≈ 13 秒 最少,不包括工具执行或第二次跳跃

一个 三回合代理循环 加上工具调用,模型时间本身很容易达到四十几秒。在硬件真正吃力之前,Telegram 就已经感觉坏了。

与一个简单的聊天提示相比。“用一句话说你好”在默认的四槽 llama-server 配置上,在同一台机器上测量到大约 48 tok/s 的预填充52 tok/s 的生成。切换到 --parallel 1 以及 batch/cache 标志后,相同的短 curl 测试跃升到大约 127 tok/s 的预填充,生成仍然在 57 tok/s 左右。服务器调优主要在小提示和内存开销方面提高了预填充速度。它并没有消除 14k 代理上下文的八秒钟开销。

默认的 llama-server 设置使代理案例更糟,而让我惊讶的旋钮是 --parallel

llama-server 在底层并非一次只运行一个对话。它维护独立的。每个槽都是一个完整的上下文窗口,在内存中拥有自己的 KV 缓存。当请求到达时,服务器会选择一个槽,将你的提示加载到其中,然后从那里生成。第二个请求可以同时使用另一个槽,而不会擦除第一个对话。

--parallel 设置了存在的槽数。如果省略它,最近的 llama.cpp 构建会选择 auto,在我的 Mac 上意味着四个槽。启动时记录了 n_parallel is set to auto, using n_parallel = 4initializing slots, n_slots = 4

当一个 GPU 服务多个客户端时,四个槽是有意义的。一个浏览器 UI,一个 curl 测试,也许是第二个用户。服务器可以处理并发聊天。

在一个对话中,AIdaemon 大部分是串行的。Telegram、Slack 和 Discord 会为每个会话排队消息,这样你就不会有两个代理循环争夺同一个线程。但这并不是全部。一个计划好的 cron 目标可以在你与 Telegram 聊天时启动一个后台任务。第二个目标也可以这样做。Slack 和 Telegram 是不同的会话,所以如果你同时在两者上活动,它们都可以同时访问模型。

对于我的设置,这种重叠很少见。一个 Telegram 对话,几个计划好的检查,通常不会在同一秒发生。默认的 --parallel 4 仍然意味着三个槽大部分时间处于空闲状态,同时保留 KV 缓存和提示缓存 RAM。我在测试期间看到提示缓存增长到超过三千兆字节。当我将其降至 --parallel 1 时,AIdaemon 的并发请求并没有中断。llama-server 会将它们排队并一次运行一个。你排队等待,而不是在空车道上共享 GPU 内存。

如果你经常同时运行多个计划目标,或者在 cron 每分钟触发时还在使用 Telegram,请尝试 --parallel 23 而不是 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 和 q8_0 KV 缓存类型在 Apple Silicon 上有所帮助。限制 --cache-ram 阻止了在长会话期间提示缓存的膨胀。更大的批处理大小 (-b 4096, -ub 1024) 加快了大型提示的预填充速度。--prio 2 将进程在调度器中提升了优先级。虽然是小事,但在你迭代配置时很有帮助。

在短提示上,预填充从大约 48 tok/s 提高到 127 tok/s。生成仍然保持在 57 tok/s 左右。这证实了服务器调优是值得的。这也证实了其他事情。在 ~14k token 时,无论如何你仍然需要八秒以上的预填充。下一个杠杆必须是提示大小。

缩小 AIdaemon 发送的内容

仅靠服务器调优无法消除八秒钟的预填充,当你每个回合都接近 15k token 时。另一半是教 AIdaemon 在模型本地运行时尊重 16k 的窗口,并在调用前压缩它发送的内容,而不是希望服务器能承受它。

我在 config.toml 中添加了一个每个模型的预算。

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

这个数字应该与 llama-server 上的 -c 匹配。如果 AIdaemon 认为它有 128k token,但服务器只容纳 16k,那么你就是在为被截断或奇怪失败的工作付费。

在代码中,消息构建阶段在每次 LLM 调用前运行 fit_tool_definitions_to_budget()。它从不丢弃工具。它分阶段修剪元数据。描述变短,模式注解和示例被剥离,直到序列化的工具适合在系统提示和历史记录计数后剩余的任何预算。在完整提示组装后还有第二次传递,因为那些插入可能会吃掉你认为剩余的空间。

代理仍然暴露所有工具。它只是停止发送本地模型不需要用来选择 terminal 而不是 read_file 的长篇模式文本。对于 48k 或 128k 的云模型,你可能永远不会注意到。对于 16k 的本地模型,这是可用回合和八秒钟沉默之间的区别。

我还删除了本地提供商上的 reasoning_effort。那是为云思考模型准备的。Gemma 的思考路径不同,我们已经在 llama-server 中禁用了它。

这使得本地 Gemma 可用。但这并不意味着 14k token 是目标。我仍在寻找进一步缩小提示的方法。第一次迭代中的重复工具文档,在模型预算较小时更精简的系统提示,更智能的工具过滤,以便本地运行不携带云大小的目录。压缩是我解除阻塞的修复;下一轮是关于只发送一次每个上下文片段。

真正的修复是重用提示,而不仅仅是缩小它

缩小提示有帮助,但我仍然为每个回合支付预填充费用。然后我明白了。模型不应该重复阅读相同的 15,000 个 token 两次。代理提示几乎所有内容从一个回合到下一个回合都是相同的。系统提示、工具模式、旧消息。只有新的用户消息和最新的工具结果会改变,它们位于末尾。

llama.cpp 已经知道如何利用这一点。它保留前一个回合的 KV 缓存。如果下一个提示的开头与上一个提示逐字相同,它会重用该缓存的工作并直接跳到新的 token。这是一个预热启动,而且速度很快。如果开头附近有任何不同,即使是一个 token,它也无法信任其余部分,因此它会丢弃缓存并重新读取整个内容。这是一个冷启动,也是我每个回合都遇到的慢路径。

问题在于 AIdaemon 在不知不觉中不断改变提示的开头。一个时间戳滴答作响,一个内存块重新排序,一个旧的回合被稍微不同地重新总结。微小的编辑,但它们位于开头附近,所以缓存从未匹配,每个回合都变冷。修复方法是让开头变得无聊。一个稳定的系统块,以及一旦滚动出活动窗口就固定形状的旧回合,永远不再重写。在那之后,每个提示的前 15,000 个 token 与前一个回合相同,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 代表滑动窗口注意力。在其大多数层中,Gemma 4 只查看最近 token 的窗口,而不是整个历史记录。这是 26B 模型运行成本如此之低的原因之一。问题在于,默认情况下,llama.cpp 只存储那个小窗口,所以一旦新回合移动了 token 位置,就没有什么可以重用了,它会重新开始。我所有仔细的提示稳定性工作都无法在注意力机制下幸存下来,该机制会丢弃它自己的大部分缓存。

一个标志解决了这个问题。

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 GB,而且对话终于保持预热了。在 Gemma 上,那个标志是跨回合重用缓存和每次重新读取提示之间的全部区别。没有它,提示稳定性和槽固定几乎买不到任何东西。

数字如我所愿地移动了。一个过去会重新读取大约 15,000 个 token 并停顿大约三十秒的后续操作,现在重新读取大约 1,300 个 token,并在几秒钟内得到答案。相同的模型,相同的硬件,相同的答案,每个回合的工作量减少了约百分之九十。

我只因为 AIdaemon 告诉我才找到这个

这一切都无法凭感觉找到。“感觉很慢”不是一个 bug 报告。使其可处理的原因是 AIdaemon 记录了每次模型调用的解剖结构。提示大小,有多少输入 token 是从缓存提供的,有多少是新鲜读取的,每个提示部分的指纹,以及哪个后台作业何时运行。

缓存与新鲜读取的数量是关键。在一个本应预热的回合中,看到新鲜读取的数量跳回一万五千意味着缓存已损坏,而每个部分的指纹显示了导致缓存损坏的提示部分。这就是我首先发现提示变化,然后是后台作业窃取槽位,然后是 SWA。三个不同的罪魁祸首隐藏在缓慢回复的相同症状之后。没有这些遥测数据,我将随机更换标志。

如果你从中得到一件事,那就是让你的本地代理可观察。模型是一个黑箱,服务器也大多是一个黑箱。你自己的守护进程是你唯一能控制的地方,所以让它告诉你每次调用发送了什么以及重用了什么。

我会告诉别人尝试这个的建议

从你已有的模型开始。我使用了 Gemma 4 26B MoE,因为 GGUF 已经下载了。 12B 统一变体是我接下来要尝试的。上下文更小,RAM 更少,对于重度聊天使用可能更敏捷。

匹配三个数字。llama-server -c,AIdaemon model_budgets,以及你实际期望在繁忙的代理回合中使用的内容。它们应该一致。

观察日志。tail -f ~/.aidaemon/llama-server.log 显示提示 token 数量和槽行为。如果你在每个回合都看到数千个 token 的预填充,请在购买更快的硬件之前修复代理上下文。

在调优时,保留一个云备用方案。本地优先,OpenRouter(或你已付费的任何服务)作为备份,意味着你可以在不丢失 Telegram 的情况下重新启动 llama-server 二十次。

先运行 llama-server 再运行 AIdaemon。守护进程在没有它时也能正常启动,然后在第一次消息时回退或出错。我曾忘记过一次。

在 macOS 上,我使用 caffeinate -i 在 launchd 下运行 AIdaemon,这样空闲睡眠就不会中断一个长的代理会话。除非你给 llama-server 自己的 plist,否则它仍然是手动的。如果这成为你的日常驱动程序,那么这样做是值得的。

保持更新

将最新文章和见解发送到您的收件箱。

Unsubscribe anytime. No spam, ever.