资讯详情

资讯详情

AI 私有化实战:上下文长度也在吃显存

这是《AI 私有化实战把模型跑在你自己的机器上》第 3 讲。第 1 讲我们算清了「装不装得下」第 2 讲我们量清了「换档位换来了什么」。这一讲接着算第二笔账上下文长度。这一笔要算的账更多模型标称能支持几十万 token 的上下文你开多少多开的那部分要付多少显存开了之后会不会变慢这一讲我本来准备照着第 1 讲那条公式讲下去。结果一开跑就发现——那条公式我写错了一倍多乘了一个2。是我自己的错而且是实测把它抓出来的。更有意思的是第二个模型我第一次预测差了384 倍一度写成「公式在混合架构上失灵」。后来查清了——错的是我读元数据的那段代码公式本身一次都没错。所以这一讲有一半的篇幅在讲这个更正公式、实测、两者对不上的那一刻到底发生了什么。先给结论上下文长度的代价是显存不是速度。斜率由模型结构决定不由参数量决定。而最要紧的一句是任何你算出来的账都要用实测校准一遍——公式和现实差一倍这种事比你以为的常见得多。先拿走不装模型算出任意一个 GGUF 的缓存开销下面这段只读 GGUF 的元数据不加载、不占显卡、不用 ollama 在跑算出这个模型每 token 吃多少缓存。把/path/to/model.gguf换成你的文件后面几个数字是你要试的上下文长度python3 - /path/to/model.gguf819232768131072PY 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) bGGUF, 不是 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, d0: 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(farch{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 archlfm2moe 层24 其中注意力层6 每头维度64 每 token KV 12.0 KiB12288 字节 ctx 8192 → 0.09 GB ctx 32768 → 0.38 GB ctx 131072 → 1.50 GB Qwen3-4B-Instruct-2507-Q4_K_M.gguf archqwen3 层36 其中注意力层36 每头维度128 每 token KV 144.0 KiB147456 字节 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 讲那版多乘了一个2K,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_length2048当「每头宽度」用了。可每头宽度是 2048 ÷ 32 头 64一层 K 的总宽 8 个 KV 头 × 64 512。改完这两处脚本的预测变成11.72模型公式预测实测差Qwen3-4B标准 GQA28 层全是注意力140.625141.60160.7%LFM2.5-8B-A1BMoE24 层里只有 6 层是注意力11.7212.69538%而且这 8% 你可以自己算出来8B 那条曲线跨 5 档 ctx2048 → 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显存总量权重缓存公式固定开销20483043 MiB2382288373 MiB81923913 MiB23821152379 MiB163845073 MiB23822304387 MiB327687393 MiB23824608403 MiB6553612033 MiB23829216435 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 那一列全部除以 2qwen3.8:27b 从 0.5078 变成 0.2539 MB/tokenphi4-reasoning 从 15.6250 变成 7.8125glm-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 tok204830432882.0171.3112.2 ms8192391311522.0157.3110.8 ms16384507323042.0167.7110.6 ms32768739346082.075.8 ⚠️176.5 ms ⚠️655361203392162.0171.4111.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 MiB16384 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 多少秒我按这两讲的方法帮你算一遍该开多少。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →