Kotonia
登录立即开始

Kotonia Articles

明明是同一个 LoRA,缓存却被丢弃:把 3 万 token 的重读问题追到 llama.cpp

某位用户一说话,所有人都变慢。原因是 llama-server 从内存恢复缓存后立刻把它丢弃。记录从 77% 的未命中率,到回归测试被加入上游 PR 的全过程。

作者 6分钟阅读
#llm#llamacpp#lora#性能优化#个人开发

前言

我在家里用两张 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,02524.5 ms66 ms
那位用户正在生成2961.3 ms204 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 里)38110
LRU(slot 被占用,走从内存恢复的路径)107114

未命中几乎都集中在内存路径上。但是,有 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 次)

只有右下角格外突出。读代码后,流程是这样的:

  1. LoRA 设置按 slot 保存,不会存进缓存条目
  2. 带 LoRA 的对话被淘汰后,别人在这个 slot 上以不带 LoRA 的方式运行
  3. 原来的对话回来,状态从内存正确恢复
  4. 但 slot 里还留着上一位用户的设置(不带 LoRA)
  5. 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 分钟以上对话继续
不用 LoRA10 次中 10 次—2 次中 2 次37 次中 10 次(27%)
使用 LoRA4 次中 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)
不用 LoRA42%(23/55)36%(18/50)21%(81/383)
使用 LoRA77%(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/8032/40
刚做完历史摘要10/1024/28
间隔 100 分钟以上14/2013/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 换过人之后未命中

直接运行上面的测试,就能确认你手头的版本是否受影响。

Kotonia 将语音 AI、AI 聊天、图像生成和团队协作整合到一个 AI 工作区中。

试用 Kotonia