KV 缓存:模型为什么越写不会越慢
目录
每写一个字,都要回头看一遍#
预测每个新 token 都要用到前面所有 token 的信息;前面 token 的 K、V 算好就不会再变,可以缓存复用。
实测:开不开缓存,差多少#
在本机(Mac,CPU)用 SmolLM2-135M-Instruct 的 8 位量化版实际跑了一遍:同一段 42 个 token 的提示词,各生成 200 个 token,一次每步从头算,一次用 KV 缓存。每个点取附近 5 个 token 的平均耗时,代码附在文末。
代价:缓存要占内存#
按 这个模型的配置:30 层、3 个 KV 头、每个头 64 维,缓存里每个数是 4 字节的浮点数,每个 token 要存 2 × 30 × 3 × 64 × 4 = 46,080 字节,和本机读出来的缓存大小一致。缓存省了计算,代价是内存随长度线性增长。
跨请求:只认开头一模一样#
DeepSeek API 的上下文硬盘缓存默认开启,后续请求只有完整匹配缓存前缀单元才命中;命中的输入价格是未命中的五十分之一。DSH 压缩上下文时把总结指令接在原对话最后,计划模式也不改工具清单,都是为了复用提供方的 KV 缓存。
附:实验代码#
开关 KV 缓存的耗时对比
// 本机对比:生成 200 个 token,不用 KV 缓存 vs 用 KV 缓存,每个 token 的耗时
// 运行:node kv-article.mjs(需要先装 @huggingface/transformers)
import { AutoTokenizer, AutoModelForCausalLM, Tensor } from "@huggingface/transformers";
const ID = "HuggingFaceTB/SmolLM2-135M-Instruct";
const tok = await AutoTokenizer.from_pretrained(ID);
const model = await AutoModelForCausalLM.from_pretrained(ID, { dtype: "q8" });
const N = 200;
const prompt = tok.apply_chat_template([{ role: "user", content: "Write a long story about a robot who learns to paint." }], { tokenize: false, add_generation_prompt: true });
const prompt_ids = Array.from(tok(prompt).input_ids.data, Number);
const ints = (arr) => new Tensor("int64", BigInt64Array.from(arr.map(BigInt)), [1, arr.length]);
const range = (a, b) => ints(Array.from({ length: b - a }, (_, i) => a + i));
const ones = (n) => ints(Array(n).fill(1));
function argmaxLast(logits) {
const [, L, V] = logits.dims, off = (L - 1) * V;
let best = 0;
for (let i = 1; i < V; i++) if (logits.data[off + i] > logits.data[off + best]) best = i;
return best;
}
async function run(useCache) {
const ids = prompt_ids.slice(), times = [];
let past = null;
for (let s = 0; s < N; s++) {
const t0 = performance.now();
const out = useCache && past
// 有缓存:只送最后一个 token,前面的 K、V 从缓存里拿
? await model({ input_ids: ints([ids.at(-1)]), attention_mask: ones(ids.length), position_ids: range(ids.length - 1, ids.length), past_key_values: past })
// 没缓存:每一步把整段从头算一遍
: await model({ input_ids: ints(ids), attention_mask: ones(ids.length), position_ids: range(0, ids.length) });
if (useCache) {
past = {};
for (const k in out) if (k.startsWith("present")) past[k.replace("present", "past_key_values")] = out[k];
}
ids.push(argmaxLast(out.logits));
times.push(performance.now() - t0);
}
return times;
}
await run(true); // 热身
const noCache = await run(false), withCache = await run(true);
const at = [1, 10, 50, 100, 150, 200];
const near = (t, i) => { const w = t.slice(Math.max(0, i - 4), i + 1); return (w.reduce((a, b) => a + b, 0) / w.length).toFixed(1); };
console.log("提示词 token 数:", prompt_ids.length);
console.log("第 n 个 token: ", at.join("\t"));
console.log("不用缓存(ms):", at.map((i) => near(noCache, i - 1)).join("\t"));
console.log("用缓存(ms): ", at.map((i) => near(withCache, i - 1)).join("\t"));
console.log("总耗时(s): ", (noCache.reduce((a, b) => a + b) / 1000).toFixed(2), "对", (withCache.reduce((a, b) => a + b) / 1000).toFixed(2));本机(Mac,CPU)的一次输出:
提示词 token 数: 42
第 n 个 token: 1 10 50 100 150 200
不用缓存(ms): 35.4 39.6 60.1 88.5 122.6 163.9
用缓存(ms): 42.0 11.6 12.4 13.4 13.1 13.0
总耗时(s): 18.99 对 2.60读出每个 token 的缓存大小
import { AutoTokenizer, AutoModelForCausalLM } from "@huggingface/transformers";
const ID = "HuggingFaceTB/SmolLM2-135M-Instruct";
const m = await AutoModelForCausalLM.from_pretrained(ID, { dtype: "q8" });
const tok = await AutoTokenizer.from_pretrained(ID);
const x = tok("The quick brown fox jumps over the lazy dog, again and again and again.");
const o = await m(x);
const keys = Object.keys(o).filter((k) => k.startsWith("present"));
let bytes = 0;
for (const k of keys) bytes += o[k].data.byteLength;
const L = Number(x.input_ids.dims[1]);
console.log({ tensors: keys.length, dims: o[keys[0]].dims, type: o[keys[0]].type, per_token_bytes: bytes / L });
// 输出:{ tensors: 60, dims: [ 1, 3, 16, 64 ], type: 'float32', per_token_bytes: 46080 }