量化分析不同模型每 token 的 KV 缓存占用:Llama 3.1 8B 每 token 128 KiB,70B 每 token 320 KiB,与量化无关。
会话中的每一个 token,无论来自哪一方,都会在 key/value cache 中留下一个永久条目。其成本是精确可计算的。但并不显而易见的是:在最常见的本地运行时中,内存并不会随着聊天进行而增长——它在模型加载时就已经全部占用,到达上限时表现为静默截断,而不是渐进式增长。
每一层为每个 key/value head 每个 token 存储一个 key 向量和一个 value 向量:
bytes per token = 2 * L * H_kv * D * b
Llama 3.1 8B 2 * 32 * 8 * 128 * 2 = 131,072 = 128 KiB
Yi-34B 2 * 60 * 8 * 128 * 2 = 245,760 = 240 KiB
Llama 3.1 70B 2 * 80 * 8 * 128 * 2 = 327,680 = 320 KiB
Llama 2 13B 2 * 40 * 40 * 128 * 2 = 819,200 = 800 KiB
成本取决于层数和 key/value head 的数量,除此之外与任何其他因素无关——与参数量无关,与权重量化无关,与前馈网络宽度无关。这就是为什么一个没有分组查询注意力的 13B 模型每个 token 的成本是有分组查询注意力的 70B 的 2.5 倍,这也是本地部署选型中最反直觉的数字。相关机制在 KV cache 词条下有详细解释。
每个 token 只计算一次且永久存在:系统提示词、每条用户消息、每条助手回复、每个工具结果。没有任何 per-message 重置,因为模型在每一步都在重新读取整个序列。
Llama 3.1 8B at 128 KiB/token, fp16 cache:
system prompt, 400 tokens 0.05 GiB
after 10 turns of ~300 tokens each (3.4k) 0.42
after 40 turns (12.4k) 1.51
after pasting a 20,000-token file (32.4k) 3.96
at the full 131,072-token window 16.00 GiB
Llama 2 13B at 800 KiB/token, same session:
after 40 turns (12.4k) 9.46 GiB
有趣的一行是粘贴文件。40 轮普通对话的成本还不如一份被粘贴的文档,因为增长与 token 数成线性关系,而文档一次就是数千个 token。如果你的助手在不可预测的时刻变慢变卡,看粘贴了什么,而不是看你聊了多久。
有两件人们以为能降低总量的事情实际上并不能。界面中删除一条消息不会释放任何内存,除非客户端在请求中重建时不包含它,这种情况下 cache 会从那个点失效并重新计算,而不是收缩。对更早的轮次做摘要确实有帮助,但只对下一次请求有效:它产生一个更短的序列来发送,节省体现在运行时处理那个更短序列时,而不是事后追溯。Cache 保存的是特定 token 序列的一个前缀,所以任何改变中间某个 token 的操作都会丢弃其后的所有内容。
这里需要纠正一个错误。llama.cpp 和 Ollama 在模型加载时就为整个配置的 context 分配 key/value cache,而不是随着 token 到来逐步分配。加载器会打印其大小,而且在进程生命周期内不会改变:
llama_kv_cache_init: CUDA0 KV buffer size = 4096.00 MiB
for -c 32768 on an 8-layer-group model at 128 KiB/token:
32768 * 131072 = 4.295e9 bytes = 4.00 GiB — taken at load, not at turn 40
这改变了你推理方式的三个要点:
空聊天和满聊天消耗的内存相同。内存由 -c 决定,所以在第一条消息之前"这会增长到多少"这个问题就已经被回答了。
不设置 -c 是危险的情况。如果运行时将模型宣传的窗口大小作为默认值,而那个窗口是 128k,它会尝试为 8B 模型分配 16 GiB 的 cache,失败看起来像是模型太大,但实际上不是。
长聊天不会导致 out-of-memory 错误。如果它加载成功了,cache 就放得下。如果它在会话中途死了,那是别的什么问题——最常见的是第二个进程,或者随着更长 prompt 增长 compute buffer。
不是每个运行时都这样工作。为并发构建的服务器以 page 为单位分配 cache 并在序列需要时分配出去,这样多个短对话不会各自预留一个完整的窗口。在那些运行时上,内存确实随着使用而增长,失败模式是驱逐或拒绝请求,而不是截断。
当会话达到配置的 context 时,运行时做三件事之一,而选择哪一个决定了你是否注意到:
它拒绝。 服务器返回一个错误,说请求的 token 数超过 context 大小。这是诚实的行为,也最容易处理——参见 local context-length-exceeded 错误。
它偏移。 最老的 token 被丢弃,剩余的 cache 条目被重新索引以便生成继续。对话继续工作,而模型悄然丢失了最早的指令,包括——通常——系统提示词。
客户端截断。 一些前端在发送前丢弃旧消息,这是同一损失的上一层,而且他们很少说明这一点。
这三种都表现为"模型过了一段时间后不再遵循系统提示词",这就是为什么那个抱怨通常是内存预算症状而不是模型质量症状。
上下文偏移值得特别警惕,因为它 keeps working。拒绝是一个 bug 报告;偏移是一个继续回答的模型,流畅地回答着,却悄然忘记了它的指令和被问到文档的前半部分。输出中没有任何内容说明这一点。如果一个长会话开始产生与你顶部设置的约束相矛盾的答案,在对模型下任何结论之前,先检查总 token 数是否已经超过了你的 -c 值。明确设置 context 并在运行时允许的情况下选择拒绝而非偏移,把一个静默的质量失败变成了一个可见的失败——这几乎总是更好的权衡。
令 cache 期限等于 weights 期限并求解 context。使用 llama.cpp quantize README 中发布的每权重 4.8944 bits,以 fp16 cache 为基准:
C = weights_bytes / kv_bytes_per_token
Llama 3.1 8B 4.58 GiB / 128 KiB = 37,482 tokens
Yi-34B 19.59 GiB / 240 KiB = 85,545 tokens
Llama 3.1 70B 40.20 GiB / 320 KiB = 131,687 tokens
Llama 2 13B 7.42 GiB / 800 KiB = 9,720 tokens
超过这些线,你的卡上存放的内容超过一半是对话。这也告诉你该拉哪个杠杆:交叉点以下,更小的量化释放更多内存;交叉点以上,用 --cache-type-k q8_0 --cache-type-v q8_0 减半 cache 释放更多内存,而且在质量上代价更小,因为 cache 存储的是激活值而非学到的结构。特定卡的预算在 8 GiB、12 GiB 和 24 GiB 上有详细计算。
一个窗口为 16,000 token 的本地助手在有人粘贴一份合同之前都 OK。两个诚实的选择是摘要并丢失细节,或者把那个请求发送到有空间处理的地方——这意味着一个 call site 要处理一个本地运行时和一个有不同限制和不同截断行为的托管模型。网关是放置长度检查和 fallback 的合理位置,这样溢出情况不会在三个地方变成应用代码。


