这是《AI 私有化实战:把模型跑在你自己的机器上》第 3 讲。
第 1 讲我们算清了「装不装得下」,第 2 讲我们量清了「换档位换来了什么」。这一讲接着算第二笔账:上下文长度。
这一笔要算的账更多:模型标称能支持几十万 token 的上下文,你开多少?多开的那部分,要付多少显存?开了之后会不会变慢?
这一讲我本来准备照着第 1 讲那条公式讲下去。结果一开跑就发现——那条公式我写错了一倍(多乘了一个2)。是我自己的错,而且是实测把它抓出来的。
更有意思的是第二个模型:我第一次预测差了384 倍,一度写成「公式在混合架构上失灵」。后来查清了——错的是我读元数据的那段代码,公式本身一次都没错。所以这一讲有一半的篇幅在讲这个更正:公式、实测、两者对不上的那一刻到底发生了什么。
先给结论:
上下文长度的代价是显存,不是速度。斜率由模型结构决定,不由参数量决定。而最要紧的一句是:任何你算出来的账,都要用实测校准一遍——公式和现实差一倍这种事,比你以为的常见得多。
先拿走:不装模型,算出任意一个 GGUF 的缓存开销
下面这段只读 GGUF 的元数据(不加载、不占显卡、不用 ollama 在跑),算出这个模型每 token 吃多少缓存。把/path/to/model.gguf换成你的文件,后面几个数字是你要试的上下文长度:
python3 - /path/to/model.gguf819232768131072<<'PY' import struct, sys, os # 用法:bash 这段 <某个 .gguf 路径> [ctx...] path, ctxs = sys.argv[1], [int(x) for x in sys.argv[2:]] f = open(os.path.expanduser(path), "rb"); assert f.read(4) == b"GGUF", "不是 GGUF 文件" ver, ntensor, nkv = struct.unpack("<IQQ", f.read(20)) S = {0:"<B",1:"<b",2:"<H",3:"<h",4:"<I",5:"<i",6:"<f",7:"<?",10:"<Q",11:"<q",12:"<d"} def gs(): n = struct.unpack("<Q", f.read(8))[0]; return f.read(n).decode("utf-8", "replace") def gv(t): if t == 8: return gs() if t == 9: et, n = struct.unpack("<IQ", f.read(12)) return ([gs() for _ in range(n)] if et == 8 else list(struct.unpack("<" + S[et][1] * n, f.read(struct.calcsize(S[et]) * n)))) return struct.unpack(S[t], f.read(struct.calcsize(S[t])))[0] M = {} for _ in range(nkv): k = gs(); M[k] = gv(struct.unpack("<I", f.read(4))[0]) # 元数据:只读头部,不碰权重 a = M.get("general.architecture", ""); g = lambda k, d=0: M.get(f"{a}.{k}", d) L, H = g("block_count"), g("attention.head_count") kv = g("attention.head_count_kv") or H kv = kv if isinstance(kv, list) else [kv] * L # 数组里 0 = 这一层没有注意力(混合架构) per = g("attention.key_length") or g("embedding_length") // (H or 1) # 每头维度,不是整层宽度 vper = g("attention.value_length") or per bpt = sum(h * (per + vper) * 2 for h in kv if h) # fp16:每个元素 2 字节 print(f"{os.path.basename(path)}") print(f"arch={a} 层={L} 其中注意力层={sum(1 for h in kv if h)} 每头维度={per}") print(f"每 token KV = {bpt/1024:.1f} KiB({bpt} 字节)") for c in ctxs: print(f" ctx {c:>7} → {bpt*c/2**30:>7.2f} GB") PY我在本机两个结构完全不同的模型上各跑一遍,同一条命令、只换路径:
LFM2.5-8B-A1B-Q4_K_M.gguf arch=lfm2moe 层=24 其中注意力层=6 每头维度=64 每 token KV = 12.0 KiB(12288 字节) ctx 8192 → 0.09 GB ctx 32768 → 0.38 GB ctx 131072 → 1.50 GB Qwen3-4B-Instruct-2507-Q4_K_M.gguf arch=qwen3 层=36 其中注意力层=36 每头维度=128 每 token KV = 144.0 KiB(147456 字节) ctx 8192 → 1.12 GB ctx 32768 → 4.50 GB ctx 131072 → 18.00 GB两个数字放一起就是这一讲的全部结论:一个 8.5B 的 MoE 每 token 12 KiB,一个 4B 的稠密模型 144 KiB,差 12 倍。参数多一倍的模型,缓存只有另一个的 1/12——因为前者 24 层里只有 6 层带注意力(混合架构),后者 36 层全带。参数量不告诉你这笔账,层数和 KV 头数才告诉你。
正文讲这个公式,以及它被我算错过的两次。
先给结论
三个可以直接用的判断:
- 显存账是线性的,而且很准。我那条公式修正后,预测斜率 140.625 MiB / 1000 token,实测 141.6016,差 0.7%。第二个模型(混合架构)修正后预测 11.72、实测 12.6953,差 8%——公式在两种结构上都成立。
- 开到装不下,不是「变慢」,是起不来。Qwen3-4B 开到标称的 262144 上下文,进程直接崩:报
allocating 36864.00 MiB on device 0: cudaMalloc failed: out of memory。而 36864.00 MiB,正是公式提前算出来的那个数。 - 速度基本不受影响。ctx 从 2048 开到 65536,生成速度中位数分别是 172.3、172.4、174.1、171.9、172.1 tok/s——一条平线。大家怕的「开大上下文会变慢」,在短请求下不成立。
原理:这一笔账的斜率,是模型的指纹
KV cache 为什么随长度线性增长
模型每生成一个字,都要回头看之前所有的字。那些「之前」不会消失,它被缓存下来——这就是 KV cache(键值缓存,第 1 讲提过)。
缓存的大小正比于你留着多少历史:每多留一个 token,就要多存一份它的键和值。所以显存随上下文长度线性增长,不是平方、不是指数。这也是第 1 讲那句「先定长度再选模型」的技术依据。
斜率公式,和它被我自己算错的那一次
第 1 讲我给过公式:
每 token 缓存 = 有注意力的层数 × KV 头数 ×(键长 + 值长)× 2 字节(fp16)
这一讲我先按公式预测,再实测。第一个模型对上了:Qwen3-4B 预测 140.625、实测 141.6016,差 0.7%。(公式本身我也写错过一次——第 1 讲那版多乘了一个2(K,V),而「键长 + 值长」本身就已经含 K 和 V 两份,是第一个模型当场把它抓出来的。)
第二个模型,我第一次预测4875、实测12.6953——差 384 倍。
我一开始把这写成了「公式在 LFM2.5 上失灵」。这是错的,而且错得很具体:错的是我那个算公式的脚本,不是公式;实测的 12.6953 从头到尾都是对的。
真因有两层,都在元数据里:
- LFM2.5 是混合架构。24 层里只有6 层是全注意力层,其余 18 层是短卷积层,根本不占 KV cache。GGUF 把
attention.head_count_kv写成每层一个值的数组,卷积层写 0:[0, 0, 8, 0, 0, 0, 8, …]。这个 0 的意思是「这一层没有注意力」,我却按「没单独指定 ⇒ 用默认值」去填,于是 18 个卷积层被当成 32 头——头数从真实的 48 变成了 624。 - GQA 的维度得自己算。这个文件里没有
key_length,我拿embedding_length(2048)当「每头宽度」用了。可每头宽度是 2048 ÷ 32 头 = 64,一层 K 的总宽 = 8 个 KV 头 × 64 = 512。
改完这两处,脚本的预测变成11.72:
| 模型 | 公式预测 | 实测 | 差 |
|---|---|---|---|
| Qwen3-4B(标准 GQA,28 层全是注意力) | 140.625 | 141.6016 | 0.7% |
| LFM2.5-8B-A1B(MoE,24 层里只有 6 层是注意力) | 11.72 | 12.6953 | 8% |
而且这 8% 你可以自己算出来:8B 那条曲线跨 5 档 ctx(2048 → 65536)显存只涨了 806 MiB,除以 63488 个 token,得 12.7 KiB/token。实测斜率一直是对的,错的只是我那份「预测」。
那怎么确认公式没问题?问运行时。llama.cpp 启动时自己会把这笔账打出来:
llama_kv_cache: size = 192.00 MiB (16384 cells, 6 layers, 4/1 seqs), K (f16): 96.00 MiB, V (f16): 96.00 MiB6 个注意力层、192 MiB、16384 token ⇒ 12 KiB/token,和脚本算的分毫不差。别猜,去问运行时——这是这一讲最实用的一条。
结论有两层:第一层,「这套算法能错,而且能错三百多倍」——但错的通常不是算法,而是读元数据的那段代码;第二层更重要——遇到结构不一样的模型,先拿运行时的账校准你的实现,再下结论。这一讲的两个模型,正好一个教「公式怎么对」,一个教「实现怎么错」。
斜率是结构指纹,不是参数规模
把两个模型放一起看,第二件有意思的事出现了:
LFM2.5-8B-A1B 的斜率只有 12.70 MiB / 1000 token,是 Qwen3-4B 的 1/11——尽管它的参数多了一倍。
原因还是第 1 讲那句:斜率由「有注意力的层数× KV 头数 × 头长」决定,这三个都是结构参数。LFM2.5-8B 的 24 层里只有 6 层是注意力层(省了 4.7 倍),每头宽度 64(又省了 2 倍)——两项一乘,1/11 就出来了。而参数量在这笔账里几乎不参与:8B 比 4B 大,缓存反而只有它的 1/9。
所以选模型时,先看结构参数,再看参数量——这句话在第 1 讲是推论,在这里是实测。
总量里还藏着一笔固定开销
表里我故意没写「固定开销」这一列,因为它不是直接测出来的——但它恰恰是让你算得准的东西。
把三笔账拆开:显存总量 = 权重 + 缓存 + 固定开销。权重是死的(Qwen3-4B 的 2382 MiB),缓存是公式算的(斜率 × ctx),剩下的就是固定开销:
| ctx | 显存总量 | 权重 | 缓存(公式) | 固定开销 |
|---|---|---|---|---|
| 2048 | 3043 MiB | 2382 | 288 | 373 MiB |
| 8192 | 3913 MiB | 2382 | 1152 | 379 MiB |
| 16384 | 5073 MiB | 2382 | 2304 | 387 MiB |
| 32768 | 7393 MiB | 2382 | 4608 | 403 MiB |
| 65536 | 12033 MiB | 2382 | 9216 | 435 MiB |
固定开销大约370–435 MiB,且随 ctx 缓慢上涨。它是推理框架、CUDA context、计算缓冲这些「不随 token 变、但确实要占」的东西。
为什么这列重要:它让你的估算从「大概装得下」变成「还剩多少余量」。第 1 讲说「两笔账加起来超过卡的总显存就一定会失败」——现在多了一笔,所以更准的说法是:权重 + 缓存 + 固定开销 > 可用显存 ⇒ 一定起不来。而固定开销这一项,只能实测。
预填充和解码,是两笔不同的账
长上下文的代价有两个面,别混:一个是显存(缓存),一个是时间(把你的输入读进去)。
我测的「预填充」是:把一段 749 token 的提示读进模型,看它花多久。结果四档 ctx 下分别是 112.2 / 110.8 / 110.6 / 111.5 ms,约 6700 tok/s——几乎不随 ctx 变。
为什么?因为预填充是并行的,一次把几百个 token 算完;而解码(一个字一个字往外吐)才是串行的。今天的解码速度中位数是 172 tok/s 上下,也不随 ctx 变。
但有个前提:请求本身要够长。我测的 749 token 相对 ctx 8192 来说很短,注意力扫过的历史少,所以看不出长上下文的注意力开销。请求真的接近 ctx 上限时,速度会掉——这是下一讲(并发与延迟)要单独测的东西,别在这里下结论。
缓存是预分配的,不是用一点占一点
有个细节容易误解:显存是在服务启动时按 ctx 一次性分配好的,不是「聊到多少占多少」。
我每次量显存都是「刚起来、还没发过请求」的时候量的——这时候显存已经涨到满额。所以:即使你的会话实际只用了 500 个 token,只要你把 ctx 设成了 16384,那 2.3 GB 的缓存就已经在卡上躺着了。
这带来一条很实用的结论:ctx 是一次性决策,不是运行时旋钮。你不能「先小点聊着,需要了再调大」——除非重启服务。所以定 ctx 要按最坏情况(你允许的最长会话)来定,而不是按平均情况。
标称长度、训练长度、你要的长度
日志里有一行值得看:
W llama_context: n_ctx_seq (2048) < n_ctx_train (128000) -- the full capacity of the model will not be utilized意思是:你开的上下文比模型训练时用的短,模型的能力用不满。所以上下文这件事两头都有浪费:
- 开小了:缓存省了,但模型用不满(质量上的损失,测不出来,只在效果上体现);
- 开大了:缓存吃掉了,显存按斜率线性涨(今天测得清清楚楚)。
你要的是中间那个点:任务真实需要的长度 + 一点余量。第 1 讲那句话在这里又成立一次。
顺便:第 1 讲那张表,已经按修正后的公式重算了
公式既然错了,第 1 讲那张表里的 KV 数字就全部偏大一倍。我把台架修了、重跑了、正文也改了。改完要说明三件事:
一、哪些数字变了。KV 那一列全部除以 2:qwen3.8:27b 从 0.5078 变成 0.2539 MB/token,phi4-reasoning 从 15.6250 变成 7.8125,glm-4.7-flash 从 1.8359 变成 0.9180,其余同理。
二、哪些结论没变。比例关系完全不变(整体除以 2,谁比谁高多少倍还是多少倍)。第 1 讲那个核心结论照样成立:同一条 24 GB 的卡上,权重 11 GB 的 phi4-reasoning 在 ctx 4096 仍然装不下(缓存一项就要 31.25 GB),而权重 17 GB 的 qwen3.8:27b 在 ctx 8192 装得下(19.03 GB)。
三、哪个结论被加强了。「决定能不能跑的是两笔账之和,不是参数量」——因为缓存那一笔被修正得比原来小,这一讲的实测又证明斜率由结构决定(LFM2.5 的斜率是 Qwen3-4B 的 1/11)。两条合起来,这句话的证据比第 1 讲当时更硬。
顺带一个方法论:如果这个系列只写「我算出来的数」,没人会发现那个 2 是多的。只有把预测和实测放在一张表里对着看,错误才会自己冒出来。这一讲就是第 1 讲的验钞机。
动手做:画出一条「显存 ↔ ctx」的曲线
怎么测
台架在tools/private_lab/kv_probe.py。它做的事很朴素:对同一个模型,按不同的--ctx-size各起一次服务,量显存增量、加载时间、生成速度、预填充速度。
有个细节值得说:llama-server 的--ctx-size是总上下文,要分成几条序列用就加--parallel(我固定用 1,避免分摊带来的歧义)。另外测量前后的显存差要减去别的进程占的量——这台机器上你自己常驻了一个模型,所以每次都测「起服务前」和「起来之后」的差值。
怎么自己量斜率
台架做的事可以拆成三步,你自己量也行:
第一步,选两个(最好三个)ctx 档,各起一次服务,量显存总量。为什么要两档?因为单档只知道总量,除以 ctx 会把固定开销也算进去;两档相减,固定开销就消掉了:
斜率 = (总量₂ − 总量₁) / (ctx₂ − ctx₁)
第二步,把权重从总量里扣掉。权重就是 GGUF 文件的大小(Qwen3-4B 是 2382 MiB)。剩下的才是「权重以外」的部分。
第三步,注意测量的边界。我这台机器上你自己常驻了一个模型,所以每次都要测「起服务前」的基线,用差值而不是绝对值。另外服务要--parallel 1,否则多条序列会分摊上下文,斜率算出来是错的。
还有一个坑:llama-server 有--cache-type-k/--cache-type-v可以改 KV 缓存的精度(f16 或 q8_0)。我一开始怀疑「实测只有预测的一半,是不是因为默认用了 q8_0」,于是显式指定 f16 重测——结果一模一样,说明默认本来就是 f16,偏差另有原因(就是我那个多乘的 2)。所以:怀疑归怀疑,要用实验去排。
跑
python3 tools/private_lab/kv_probe.py\--modelqwen3-4b=/data/model/modelscope/models/unsloth/Qwen3-4B-Instruct-2507-GGUF/Qwen3-4B-Instruct-2507-Q4_K_M.gguf\--modellfm2-8b=/data/model/gguf/LFM2.5-8B-A1B-Q4_K_M.gguf\--ctx20488192163843276865536--fill-tokens1000\--jsonout/private/L03/kv_probe.jsonQwen3-4B 的结果:
| ctx | 显存增量 MiB | 公式预测 KV MiB | 加载 s | 生成 tok/s | 预填充 749 tok |
|---|---|---|---|---|---|
| 2048 | +3043 | 288 | 2.0 | 171.3 | 112.2 ms |
| 8192 | +3913 | 1152 | 2.0 | 157.3 | 110.8 ms |
| 16384 | +5073 | 2304 | 2.0 | 167.7 | 110.6 ms |
| 32768 | +7393 | 4608 | 2.0 | 75.8 ⚠️ | 176.5 ms ⚠️ |
| 65536 | +12033 | 9216 | 2.0 | 171.4 | 111.5 ms |
带 ⚠️ 的那两格是我特意标出来的:它们是噪声。单独跑一次会抖(75.8 tok/s 看着像掉了一半,其实不是),所以我每个 ctx 各跑 6 次取中位数:
ctx2048 → 172.3、8192 → 172.4、16384 → 174.1、32768 → 171.9、65536 → 172.1 tok/s。一条平线。
这就是「速度不受影响」的证据来源。单次采样会骗人,中位数不会——这条纪律在第 2 讲也用过一次(判分器和页缓存),它其实是这一整系列的方法论。
LFM2.5-8B 的曲线更平:+5289 → +6095 MiB,跨 5 档 ctx 只涨806 MiB。除以多出来的 63488 个 token,得12.7 KiB/token——这就是那个 12.6953 的来源,也是它和 llama.cpp 自己打的 192 MiB(16384 token)对得上的原因。
那个「起不来」的现场
把 ctx 开到模型标称的 262144 会怎样?实测:
E ggml_backend_cuda_buffer_type_alloc_buffer: allocating 36864.00 MiB on device 0: cudaMalloc failed: out of memory E alloc_tensor_range: failed to allocate CUDA0 buffer of size 38654705664进程直接段错误退出了。不是「慢一点」,是「起不来」。
而最漂亮的地方在最后:报错里那个 36864.00 MiB,正是公式提前算出来的数(0.1406 MB × 262144 = 36,864 MB)。也就是说,你的账算对了,就等于在下载 15 GB 权重之前,已经知道这次会崩。
那正确的做法是什么?启动前就算出上限。用斜率算:
# 可用显存(MiB)− 权重(MiB)− 固定开销(MiB)÷ 斜率(MiB/1000 token)= 最多能开多少 token# 例:可用 14504 − 权重 2382 − 固定开销约 400,斜率 141.60 ⇒ 约 8 万 token这台卡上按 Qwen3-4B 的实测斜率算,纯缓存最多约102428token;而按标称的 262144 去开,会直接崩。这两个数之间的差距,就是「能跑」和「跑崩」之间的差距。
按场景该开多长
同一个模型,不同场景该开的 ctx 差很远,而这个差值是可以用斜率换算成显存的:
| 场景 | 你需要的历史 | 建议 ctx | 按 141.60 MiB/1000 token 的缓存 |
|---|---|---|---|
| 单轮问答 | 几千字 | 4096 | 约 580 MiB |
| 多轮对话(留 10 轮) | 几万字 | 16384 | 约 2.3 GB |
| 长文档问答 | 几十万字 | 分段 + 检索,别硬开 | 每段 8192,约 1.2 GB |
第三行是关键:任务需要几十万 token 时,正确做法不是把 ctx 开大,而是分段 + 检索——把长文档切块、按需取回,每段只占一小份缓存。这正是第 8 讲要讲的事:检索是缓存的替代品。
顺便说清楚这跟第 1 讲的区别:第 1 讲讲的是「装得下模型」,这一讲补的是「装得下上下文」。两笔账都要过,缺一个都会在半夜出事。
验证:五条判据
判据一:预测斜率与实测斜率相差不超过 10%。超过就是实现错了(这一讲那个 384 倍就是),这时候以实测为准,并且去跟运行时自己打的账对一遍。
判据二:同一个 ctx 重复跑,显存增量一致。不一致说明有别的进程在抢显存,或者服务没真正起来。
判据三:速度要取多次的中位数。单次采样能差一倍(第 32768 那格的 75.8 就是),别拿它下结论。
判据四:预填充要单独测。生成速度不随 ctx 变,不代表「长上下文不花钱」——预填充(把你那 749 个 token 读进去)也是实打实的时间。
判据五:一定要真开一次装不下的长度。上面那段 OOM 报错是这一讲最有说服力的证据,而它只有真跑了才有。
常见坑
坑一:拿标称上下文当可用上下文。标称 262144,你的卡在 ctx 4096 就该把它降下来。标称是模型的极限,不是你的预算。
坑二:以为「开大一点没关系,反正快」。显存是真金白银(这里就是你的卡),而且超了不是慢,是崩。
坑三:只用一次采样就下结论。我自己在 ctx 32768 上拿到过 75.8 tok/s,差点写成「上下文开大速度掉一半」。六次中位数一跑,那条线是平的。写进教程的数字,必须是能复现的中位数。
坑四:算出来的账不跟运行时对一遍就用。我这条公式本身一次没错,错的是它的两次实现:第 1 讲多乘了一个 2(整讲数字偏大一倍),这一讲读错元数据(差了 384 倍)。凡是能测的,就别只算;算出来的,一定要拿运行时的账(比如 llama.cpp 启动时那行llama_kv_cache: size = ...)对一遍。
坑五:拿 MoE 的直觉套稠密模型,反过来也一样。斜率、速度、装得下多大的上下文,三个结论都跟着结构走。
坑六:只算缓存,不算固定开销。缓存是斜率乘长度算出来的,固定开销(370 MiB 上下)只能实测。省掉它,你的估算会乐观一截——而乐观的那一截,往往正好是崩掉的那一截。
坑七:拿「模型支持多长」回答「我能开多长」。这是两个问题:前者是模型能力,后者是你的显存预算。今天实测的直接后果就是:同一个模型,标称 262144、实际能开 8 万上下——差三倍多。而差的这三倍,就是你半夜被 OOM 叫醒的概率。
课后思考题
第一题:你的模型,在你这张卡上最多能开多长的上下文?
别用标称值,用斜率算:先量或查这个模型的「MiB / 1000 token」,再拿(空闲显存 − 权重占用 − 固定开销)除以它。这台卡上按 Qwen3-4B 的实测斜率算,纯缓存最多约 102428 token;而按标称的 262144 去开,会直接崩。这个数你算出来了吗?
第二题:标称 26 万的上下文,你的任务真的需要吗?
每多开 1000 token,就是每 1000 token 的显存。Qwen3-4B 的斜率是 141.60 MiB / 1000 token——为了用 1% 的标称长度,你可能先得为 100% 的长度付账。你的答案应该是「我只需要 X」,而不是「越大越好」。
下一讲我们把第三笔账算掉:一张卡能扛几个并发。前三讲都是「一个请求要多少显存」,这一讲是「同时来十个请求要多少」——排队、超时、首字延迟,这些数字会告诉你并发开到几就开始崩。评论区报个数:你的服务现在并发多少、p95 多少秒?我按这两讲的方法帮你算一遍该开多少。