前言
我在家里用两张 GPU 跑本地 LLM,运营一个角色聊天服务。模型是 GLM-5.3-Flash 的 3bit GGUF,由 llama-server 以 4 个 slot 提供服务。
有一天,我查了一件一直在意的事:某位用户在说话时,所有人的推理都会变得很慢。
原因既不是那位用户的使用方式,也不是 GPU 性能或缓存容量。llama-server 正确地从内存恢复了缓存,紧接着又自己把它丢掉了。
从内存缓存恢复这条路径的未命中率(3,000 token 以上)
不用 LoRA 42%(55 次中 23 次)
使用 LoRA 77%(79 次中 61 次)
修复后,按同样的方法统计,使用 LoRA 时的未命中率降到了 15〜22%。上游已经有一个思路相同的 PR,我在评论里提议了一个能复现线上症状的回归测试,它被加进了这个 PR。PR 本身还在等待审查,截至 2026-11-07 尚未合并。
这篇文章记录了调查的全过程,也包括中途猜错的假设。
症状
那位用户只用文字,不用语音,也不生成图片。提示词的中位数是 3.2 万 token,是一位在持续进行长对话的用户。
查看 16 小时的日志,40 轮中有 25 轮未命中缓存,每次都要跑 40〜68 秒的 prefill。对其他用户的影响如下。
| 情况(最近 3 天,每个字符的生成时间) | 次数 | 中位数 | p90 |
|---|---|---|---|
| 双方都没有运行 | 1,025 | 24.5 ms | 66 ms |
| 那位用户正在生成 | 29 | 61.3 ms | 204 ms |
样本较少,而且请求越长越容易重叠,所以这个倍率只是粗略的数字。即便如此,在 prefill 重叠的时段,生成速度还是从 58 tok/s 掉到了 19 tok/s。
prefill 让别人的生成停下来的机制是另一个问题,写在另一篇文章里。这篇要追的是:为什么每次都要重读 3 万 token?
假设 1:缓存容量不够
llama-server 的日志里排满了淘汰旧条目的记录。
alloc: - making room for prompt cache entry, removing oldest entry (size = 747.843 MiB)
内存里的提示词缓存已经到了上限,看起来这就是原因。
这里犯了第一个错误。我和 Claude 一起调查时,把情况读成了“没有指定 --cache-ram,所以上限是默认的 8 GiB”,Claude 还建议增加到 16 GiB。实际上它已经是 16 GiB 了。我们没有看实际值,就从默认值编出了一套解释。
更重要的是,一数数字,容量假设就站不住了。我把 639 轮 3,000 token 以上的请求,按 slot 的选择方式分开。
| slot 的选择方式 | 命中 | 未命中 |
|---|---|---|
| LCP(对话还留在 GPU 的 slot 里) | 381 | 10 |
| LRU(slot 被占用,走从内存恢复的路径) | 107 | 114 |
未命中几乎都集中在内存路径上。但是,有 58 轮距上一轮不到 5 分钟,仍然未命中。 每个条目 600〜1,000 MiB,淘汰间隔的中位数是 5.3 分钟,按此计算,一段对话应该能在缓存里留 50 分钟以上,不可能 5 分钟就消失。而且不管两轮之间有没有发生淘汰,未命中率都差不多是 33%。
增加容量也解决不了这 58 轮。
假设 2:重新生成改变了历史
这是我的猜测。重新生成或删除消息会让历史分叉,缓存自然不会命中。
猜对了一半。重新生成在 24 次中有 8 次未命中,但在总量里只占少数。最常见的情况是,对话只是正常继续、提示词只是变长了,却依然未命中。
没有日志,也不能开 TRACE
我想看判定过程,但“比较了哪些缓存候选”“是否从 checkpoint 恢复”这些日志都在 TRACE 级别。
这里有个陷阱。开启 TRACE 也会开启 DEBUG,所有提示词 token 都会写进日志。 在线上服务中,不能把用户的对话写进 journal。
于是我在 fork 里,只把输出位置和大小的 9 行提升到了 INFO。
- SRV_TRC(" - looking for better prompt, base f_keep = %.3f, f_sim = %.3f\n", ...);
+ SRV_INF(" - looking for better prompt, base f_keep = %.3f, f_sim = %.3f\n", ...);
奇怪的例子马上出现了。有一轮找到并恢复了一个匹配度 97% 的条目(39,027 token 中的 37,906),之后却从头 prefill 了全部 3.9 万 token,耗时 68 秒。既没有从 checkpoint 恢复的日志,也没有“全部重新处理”的日志。
状态恢复了,却没有被使用。
原因:刚恢复就自己丢掉了
我开始怀疑 LoRA,于是按有无 LoRA 分开统计。当时这个服务按请求指定 lora,只应用在部分角色的轮次上。
| 留在同一个 slot | 从内存恢复 | |
|---|---|---|
| 不用 LoRA | 未命中 3% | 未命中 42% |
| 使用 LoRA | 未命中 4% | 未命中 77%(79 次中 61 次) |
只有右下角格外突出。读代码后,流程是这样的:
- LoRA 设置按 slot 保存,不会存进缓存条目
- 带 LoRA 的对话被淘汰后,别人在这个 slot 上以不带 LoRA 的方式运行
- 原来的对话回来,状态从内存正确恢复
- 但 slot 里还留着上一位用户的设置(不带 LoRA)
launch_slot_with_task判定“LoRA 变了”,清除了刚恢复的状态
if (!are_lora_equal(task_loras, slot.lora)) {
if (lora_should_clear_cache(slot.lora, task_loras)) {
SLT_TRC(slot, "clearing cache for lora change..."); // 是 TRACE,所以看不到
slot.prompt.clear();
}
...
}
明明是为使用同一 LoRA 的请求恢复的同一 LoRA 状态,却拿去和无关的上一位用户的值比较,然后丢弃。日志又是 TRACE,什么都看不到。
再次怀疑数字
42% 和 77% 这组数字让我觉得不对劲,我问了一句“是不是比例的问题?”。也许里面混进了本来就不可能命中的轮次。
重新统计后,果然如此。
| 第一次 LLM 轮次 | 刚做完摘要 | 间隔 100 分钟以上 | 对话继续 | |
|---|---|---|---|---|
| 不用 LoRA | 10 次中 10 次 | — | 2 次中 2 次 | 37 次中 10 次(27%) |
| 使用 LoRA | 4 次中 4 次 | 3 次中 3 次 | 4 次中 4 次 | 52 次中 35 次(67%) |
不用 LoRA 时的 42%,被原理上不可能命中的轮次抬高了不少。只比较本应命中的“对话继续”,就是 27% 和 67%。LoRA 的影响体现在这约 40 个百分点的差距上,清楚多了。
修复
思路很简单:把“这个状态是用哪种 LoRA 设置算出来的”一起存进缓存条目。
- 让条目保存 LoRA 设置
- 恢复时,只考虑设置与请求一致的条目
- 恢复后,把 slot 的设置同步为条目的设置
- 不把带 LoRA 算出的状态复用给不带 LoRA 的请求
应用哪个 LoRA,依然按请求决定。
用合成请求测试,6,036 token 中有 6,023 token 被复用。线上也出现了这样的例子:从内存恢复的路径复用 7,137 token,prefill 用时 1.5 秒;复用 8,635 token,用时 1.7 秒。
顺便发现:监控造成的淘汰
新日志里还有另一件怪事。一个 21 token 的小请求大约每 6.5 分钟来一次,抢走大对话的 slot。
罪魁祸首是我自己的健康检查。有两个原因叠加:
- 仅聊天模板的开头就有 42% 的匹配度,超过了 10% 的阈值,大对话的 slot 因此被判定为“相似”
- 这个版本在
--kv-unified下,每次任务启动都会把所有空闲 slot 存到内存并清空
我起初考虑为监控单独开路径,或者判断请求是否为一次性,但其实不需要判断。提示词很短,就几乎不占 KV 单元。 我只在 fork 里做了修改:不到 256 token 的任务不再淘汰空闲对话,也不抢大对话的 slot。修改后,即使刚发生健康检查,对话也留在 slot 里,连从内存恢复都不需要了。
上游的这部分已经重写,这项修复大概不需要。不过我没有确认。
线上结果
我用修复后的日志重复了同样的统计。修复前的数字,我也用当时的日志重新统计过,结果一致。
| 从内存恢复路径的未命中率 | 修复前(16 小时) | 修复后(11/1〜2,约 25 小时) | 之后(11/2〜6) |
|---|---|---|---|
| 不用 LoRA | 42%(23/55) | 36%(18/50) | 21%(81/383) |
| 使用 LoRA | 77%(61/79) | 22%(17/77) | 15%(68/459) |
只看“对话继续”,修复后(11/1 傍晚〜11/6)的未命中率是:不用 LoRA 3.5%(33/932),使用 LoRA 2.9%(35/1,197)。有无 LoRA 的差距消失了。
修复后剩下的未命中,大多是机制上无法避免的。
| 修复后的未命中(3,000 token 以上) | 不用 LoRA | 使用 LoRA |
|---|---|---|
| 会话的第一轮 | 74/80 | 32/40 |
| 刚做完历史摘要 | 10/10 | 24/28 |
| 间隔 100 分钟以上 | 14/20 | 13/20 |
摘要插在历史的开头,它之后的内容全都会变。对 3 万 token 级别的对话,这里的完整 prefill 要一分钟左右。这个问题现在还在。
这个比较有几点需要注意:
- “对话继续”的前后比较,统计方法不同。 修复前是按时间与数据库匹配的估算,修复后来自带会话 ID 的日志
- LoRA 修复和监控修复是同一天上线的,无法分开各自的效果。 监控不再淘汰后,容易命中的轮次离开了内存路径,内存路径未命中率的含义也有些变化
- 11/2 我把内存缓存上限扩到了 24 GiB,共享 KV 扩到了 256K。 最右一列混入了这个影响
- 用户构成每天都不同。我没有在与最初 16 小时相同的负载下重新测量
- 统计截止到 11/6 凌晨。 之后我把几乎所有轮次改成了同一种 LoRA 设置,后面的数据不能用于这项比较
准备向上游报告时,发现 PR 已经有了
上游也有同样的代码,所以我开始准备 PR。结果发现,思路相同的 PR #30275 从 10 月 10 日起就已经开着了。
这个 PR 的动机是 #30129 的正确性问题,即不同 LoRA 的 KV 混在一起。我看到的是性能问题:同一 LoRA 的 KV 每次都被丢弃。根源相同,症状却方向相反。PR 的测试检查的是前者。
我也确认了 llama.cpp 的贡献方针:
- 不开重复的 PR,在已有的 PR 上评论
- AI 写的代码在披露后可以使用
- 禁止让 AI 写 issue、PR、评论和提交信息的文字
于是,复现环境、测试代码和数字整理交给 Claude,评论的文字由我自己写。Claude 检查我的英文时,指出的是事实错误,比如“应该是 miss rate 而不是 hit rate”“72% 是 77% 的笔误”。它还指出,我在线上验证的是自己的 fork,并没有在线上试过这个 PR,所以不要写“这样就全都解决了”。
测试就是这些:只用一个 slot,依次发送带 LoRA、不带 LoRA、带 LoRA 的请求。
def test_lora_ram_prompt_cache_restore_same_lora():
global server
server.n_slots = 1
server.n_ctx = 2048
server.n_predict = 4
server.cache_ram = 256
server.start()
prompt = "Look in thy glass and tell the face thou viewest. " * 15
other = "The quick brown fox jumps over the lazy dog. " * 15
lora = [{"id": 0, "scale": 0.5}]
def complete(text: str, lora_cfg):
data = {"prompt": text, "temperature": 0.0, "cache_prompt": True}
if lora_cfg is not None:
data["lora"] = lora_cfg
res = server.make_request("POST", "/completion", data=data)
assert res.status_code == 200
return res.body
first = complete(prompt, lora)
complete(other, None) # 把第一段对话从唯一的 slot 挤到内存
res = complete(prompt + "And then", lora)
n_prompt = first["timings"]["prompt_n"]
assert res["timings"]["cache_n"] >= n_prompt - 1
它只看第三个请求的 cache_n。不需要线上的 GPU 和模型,用测试用的小模型和 CPU 就能复现。
| 这个测试 | |
|---|---|
| 上游(060b2fe) | 失败(cache_n = 0,prompt_n = 183) |
| 应用 PR #30275 的版本 | 通过 |
发布几天后,PR 作者回复了:已在 4e94dc5 中添加了基于这个测试的回归测试。在作者的环境里也有差别:应用 PR 后缓存 181 token,基线(1623d8c)为 0 token。
学到的东西
1. 用未命中的具体情况检验“看似合理的原因”。 淘汰日志是真的,容量假设也说得通。但它解释不了“5 分钟内未命中的 58 轮”。
2. 看到比例,先怀疑分母里混了什么。 42% 与 77% 的比较里,混有不可能命中的轮次。拆开后的 27% 和 67%,才更准确地反映了 bug 的影响。
3. 默默丢弃的处理没有日志。 判定日志只有 TRACE,而 TRACE 还会输出用户的文本。性能问题不会抛异常,所以首先要让它可见。
4. 用不必事后匹配的形式留下测量数据。 按时间匹配服务器日志和数据库的估算有局限。现在每次调用都会写一行日志,记录用途、会话、有无 LoRA 和复用的 token 数。
5. 给大型开源项目做贡献,不只有 PR 一种方式。 我没有开自己的 PR,但线上症状和最小复现测试,让已有 PR 的验证前进了一步。
还不清楚的事
- PR #30275 尚未合并。 审查还没开始,形式可能会变
- 这个 PR 本身没有在线上运行过。 线上用的是自己的 fork,是同一思路的另一种实现。PR 只通过测试验证过
- 修复前不用 LoRA 的“对话继续”为什么有 27% 未命中,由于旧日志没有判定记录,未能查明
- 监控修复和 LoRA 修复各自单独的效果,无法分开
这些数字来自 GLM-5.3-Flash 3bit、两张 RTX PRO 6000 Blackwell、4 个 slot、--kv-unified 的环境。尤其是,GLM-5.3-Flash 在缓存中途分叉时很难回退,所以未命中的代价因模型而异。
致可能遇到同样症状的人
如果你按请求指定 lora,并且使用 --cache-ram 的内存缓存,可以看看是否出现以下情况:
found better prompt找到了高匹配度的条目,但紧接着的 prefill 覆盖了全部 token- 只有带 LoRA 的请求,在 slot 换过人之后未命中
直接运行上面的测试,就能确认你手头的版本是否受影响。
