项目地址
本文配套的完整交付手册《大模型私有化交付操作手册》已开源,涵盖 8 章正文 + 3 篇附录 + 3 张交付模板,覆盖 vLLM / SGLang / TGI / TensorRT-LLM / lmdeploy / ollama 等主流推理引擎。
项目地址:https://gitee.com/wang-guangshuooo/llm-private-deploy-handbook
TL;DR(太长不看版)
- 显存估算必须包含KV Cache,否则并发能力错 8 倍。
- TTFT统一口径(从发送请求算),推理模型单独统计思考段。
- 容量规划看饱和点与尾延迟,不能只看吞吐均值。
先说结论
私有化交付大模型,翻车最多的不是"装不上",而是"算错了"。装不上会报错,你能立刻发现。而算错了不报错——它安静地给你一个错误的数字,然后你在方案里写下这个数字,客户按它配了机器,验收那天才发现不对。
下面三个陷阱,是我认为代价最高的三个,共同特点是:代码不会报错,错误要到很晚才暴露。
| 陷阱 | 症状 | 代价 |
|---|---|---|
| 一 | 显存估算只算权重,忽略 KV Cache,容量高估数倍 | 买错卡 / 并发只有预估的 1/5 |
| 二 | TTFT 口径不统一,同一个词两个人量出差十倍 | 验收现场扯皮 |
| 三 | 只看吞吐均值,漏掉饱和点与尾延迟 | 容量规划拍脑袋 |
陷阱一:显存估算只算权重,KV Cache 被漏掉
这是交付翻车的第一大原因。很多人算显存是这么算的:72B FP16 → 72.7 × 2 = 145.4 GB → 8 张 80G 卡够,收工。
问题在于,显存是三项之和,不是一项:
总显存需求 = 权重显存 + KV Cache 显存 + 激活/框架开销
权重只是"装得下"的门槛,KV Cache 才是决定"能服务多少并发"的那一项。而并发能力才是客户真正要买的东西。
1.1 权重显存:一行乘法,但别用约数
权重显存 = 参数量 × 每参数字节数
| 精度 | 每参数字节 | 说明 |
|---|---|---|
| FP16 / BF16 | 2.0 | 推理默认 |
| FP8 | 1.0 | 需 H100/L40S 等支持 |
| INT8 (W8A8) | 1.0 + 少量 scale | 精度损失小 |
| INT4 (AWQ/GPTQ) | ≈0.55 | 含 scale/zero point,实际约 0.5~0.6 |
常用结果速查(GB):
| 模型 | FP16 | INT8 | INT4 |
|---|---|---|---|
| 7B(7.6B) | 15.2 | 7.6 | 4.2 |
| 14B(14.7B) | 29.4 | 14.7 | 8.1 |
| 32B(32.5B) | 65.0 | 32.5 | 17.9 |
| 72B(72.7B) | 145.4 | 72.7 | 40.0 |
注意用真实参数量(7.6B / 72.7B),不要用"7B / 72B"这种约数。以模型目录下 config.json 的字段为准,或者直接量 safetensors 文件总大小。
1.2 KV Cache:最容易被漏掉的一项
单 token KV 字节 = 2 × 层数 × KV 头数 × head_dim × 字节数(2 = Key 和 Value 各一份)
各模型单 token KV 占用(FP16):
| 模型 | 层数 | KV 头数 | head_dim | 单 token | 8K 上下文 |
|---|---|---|---|---|---|
| Qwen2.5-7B | 28 | 4 | 128 | 56 KiB | 448 MiB |
| Qwen2.5-14B | 48 | 8 | 128 | 192 KiB | 1.5 GiB |
| Qwen2.5-32B | 64 | 8 | 128 | 256 KiB | 2.0 GiB |
| Qwen2.5-72B | 80 | 8 | 128 | 320 KiB | 2.5 GiB |
这里就是那个坑:GQA 的 8:1 分组。Qwen2.5-72B 有 64 个注意力头,但只有 8 个 KV 头(分组查询注意力,8:1)。如果你按 64 个头去算 KV:
2 × 80 层 × 64 头 × 128 × 2 字节 = 2.5 MiB / token ← 错,是实际值的 8 倍
2 × 80 层 × 8 头 × 128 × 2 字节 = 320 KiB / token ← 对
一个字段看错,单 token KV 就错 8 倍,最终并发能力估算也跟着错 8 倍——往哪个方向错取决于你是保守还是激进,但两种都是错的。
✅ 算之前先打开 config.json,确认 num_key_value_heads。这个字段名和 num_attention_heads 长得很像,但它们不是一回事。
1.3 三个完整算例,把并发算出来
算例 1:Qwen2.5-7B FP16,单卡 A100 40G,8K 上下文
| 项 | 计算 | 结果 |
|---|---|---|
| 权重 | 7.6B × 2 | 15.2 GB |
| 可用显存 | 40 GB × 0.90(gpu_memory_utilization) | 36.0 GB |
| 减激活/框架 | 取 1.5 GB | 34.5 GB |
| KV Cache 池 | 36.0 − 15.2 − 1.5 | 19.3 GB |
| 单序列 8K 占用 | 56 KiB × 8192 | 448 MiB |
| 可支持并发 | 19.3 × 1024 / 448 | ≈ 44 路 |
算例 2:Qwen2.5-72B FP16,8 × A100 80G,32K 上下文
| 项 | 计算 | 结果 |
|---|---|---|
| 权重 | 72.7B × 2 | 145.4 GB |
| 可用显存 | 640 GB × 0.90 | 576 GB |
| 减激活/框架/通信 | 4 GB × 8 | 32 GB |
| KV Cache 池 | 576 − 145.4 − 32 | 398.6 GB |
| 单序列 32K 占用 | 320 KiB × 32768 | 10 GiB |
| 可支持并发 | 398.6 / 10 | ≈ 39 路 |
8 张 80G 卡,只能同时服务约 39 路 32K 上下文请求。这个数字往往低于客户的预期——很多方案只报"能跑起来",但并发能力被 KV Cache 死死限制住。
算例 3:Qwen2.5-72B AWQ INT4,8 × A100 40G,8K 上下文
| 项 | 计算 | 结果 |
|---|---|---|
| 权重 | 72.7B × 0.55 | 40.0 GB |
| 可用显存 | 320 GB × 0.90 | 288 GB |
| 减激活/框架 | 3 GB × 8 | 24 GB |
| KV Cache 池 | 288 − 40 − 24 | 224 GB |
| 单序列 8K 占用 | 320 KiB × 8192 | 2.5 GiB |
| 可支持并发 | 224 / 2.5 | ≈ 89 路 |
量化把并发从"约 39 路(32K)"提到"约 89 路(8K)",代价是权重从 145 GB 降到 40 GB,并接受量化精度损失。这就是"精度 vs 容量"的量化决策依据。
1.4 估完之后,一定要反向校验
方案定稿后,实际跑起来用命令核对:
# vLLM 启动日志里会打印: # "GPU KV cache size: XXX tokens" # "Maximum concurrency for XXX tokens per request: Y.Yx" 实时观察显存 nvidia-smi --query-gpu=index,memory.used,memory.total,utilization.gpu --format=csv -l 5 通过 Prometheus 看 KV Cache 使用率 curl -s localhost:8000/metrics | grep gpu_cache_usage_perc一条实用判据:正常负载下 gpu_cache_usage_perc 应在 20% ~ 70% 之间。
- 长期 > 90% → 并发接近上限,请求开始排队,TTFT 会陡增
- 长期 < 10% → 显存浪费,可以调大 --max-num-seqs 或减少卡数
陷阱二:TTFT 这个词,两个人能量出十倍差
TTFT(Time To First Token,首包延迟)是交付现场唯一有意义的体感指标——用户不关心"整个回答花了几秒",他只在意"多久开始出字"。但这个词谁都在用,定义却不一样。同一个服务、同一次压测,两个人给出的 TTFT 可以差十倍。交付现场 90% 的"性能争议",本质是双方对 TTFT 的定义不一样。
有三个口径陷阱:
陷阱 2.1:从哪一刻开始算?
| 口径 | 含义 | 什么时候用 |
|---|---|---|
| 从发送请求算 | 含 HTTP 建连 + TLS 握手 + 排队 + 预填充 | ✅ 推荐,最接近用户真实感受 |
| 从收到 HTTP 200 算 | 不含建连,含排队 + 预填充 | 排除网络噪声,对比引擎性能 |
| 从预填充开始算 | 只含预填充计算 | 纯引擎能力对比,不是用户体验 |
交付压测用第一种,但必须在报告里写明是哪种,并注明"客户端与服务端同机 / 同机房",否则网络 RTT 会被算进去。
顺便说,用流式请求时常见的写法是:
resp = client.chat.completions.create(..., stream=True) ttft = time.perf_counter() - start # 错:这是 HTTP 200 的时刻 for chunk in resp: # 真正的首 token 在这里才到 ...HTTP 200 返回的时候,模型一个字都还没出。
陷阱 2.2:排队时间算不算?
必须算。用户按下回车就开始等了,服务在排队,这段时间他是真实感受到了的。这正是"并发一高 TTFT 就爆炸"的原因——不是模型变慢了,而是请求在队列里等。这恰恰是压测最重要的发现之一,把它排除掉等于把最关键的结论藏起来。
陷阱 2.3:推理模型的"思考段"算不算?(最隐蔽)
像 DeepSeek-R1、QwQ 这类推理模型,会先输出一大段思考内容。在 OpenAI 兼容的 SSE 流式接口里,这段内容往往走不同的字段(如 delta.reasoning_content),而不是 delta.content。
实测逐帧解析的结果:
帧 1 ~ 帧 128 : delta 里只有 reasoning_content(思考段) 帧 129 : 第一个 delta.content 出现 ← 真正的 TTFT 应该在这里 帧 129 ~ 帧 211 : 正常输出内容- 把第一个任意分片当 TTFT → 首帧在第 1 帧就来了,TTFT 被严重低估
- 把 reasoning 也算作输出 token → TPOT 被摊薄,虚低
- 取 content → TTFT 里包含了几十秒思考,数字大得没法跟普通模型对比
三条路都不对,因为这个指标对推理模型本身就不成立。
✅ 正确做法:
- TTFT 定义为第一个 content 字段出现的时刻,思考段单独统计为"思考耗时",或一并计入端到端但明确标注;
- 报告里必须写清目标模型是推理模型还是普通模型;
- 做配置对比时不要混用推理模型和普通模型,指标不可比。
结论:推理模型不适合作为 TTFT 口径对比的基准模型。做压测前先解析一次 SSE 帧结构,确认字段名。
陷阱三:只看吞吐均值,不看饱和点和尾延迟
压测做完,报一个"吞吐 XXX tok/s",这几乎等于没报。因为单看吞吐,你无法回答客户唯一关心的问题:我能服务多少人?
3.1 先看一组真机数据
真机实测三条曲线。环境标注(很重要):RTX 4060 Laptop 8.0 GiB | Windows 11 | ollama | qwen2.5:7b Q4_K_M | 并发 1/2/4/8/16/32 | 每请求 256 tokens | 预热 2 次 | 温度 0 | 流式 | 63 个请求、0 失败、总耗时 222.9 s。
| 并发 | 吞吐 (tok/s) | 加速比 | TTFT 均值 (ms) | TTFT P99 (ms) | TPOT (ms) | 端到端 P99 (ms) |
|---|---|---|---|---|---|---|
| 1 | 26.9 | 1.00× | 168.81 | 168.81 | 21.64 | 2159.52 |
| 2 | 27.3 | 1.02× | 1335.32 | 2436.17 | 23.93 | 4607.57 |
| 4 | 30.4 | 1.13× | 3636.49 | 6869.47 | 23.44 | 8871.39 |
| 8 | 30.8 | 1.14× | 7804.78 | 15156.92 | 22.96 | 17178.87 |
| 16 | 30.0 | 1.11× | 15979.12 | 30537.79 | 21.56 | 32414.35 |
| 32 | 29.3 | 1.09× | 30839.10 | 60821.14 | 21.69 | 62798.84 |
显存峰值 5.6 GiB / 8.0 GiB(70.3%),平均 GPU 利用率 71.9%,63 请求 0 失败。
从这组数据可以读出三个关键信息:
第一,饱和点出现在并发 2 附近。并发从 1 提到 2,吞吐从 26.9 微升到 27.3 tok/s,加速比只有 1.02×;之后并发 4/8/16/32 一路走平甚至回落,到并发 32 时吞吐降到 29.3,加速比只剩 1.09×。也就是说,这张 8G 显存的笔记本卡,在并发 2 时吞吐就已经到顶,继续加并发不仅没有收益,还会因为排队和调度开销让整体吞吐下滑。饱和点就是 2。
第二,尾延迟在并发 2 之后开始失控。TTFT 均值从并发 1 的 168.81 ms 一路涨到并发 32 的 30839.10 ms,涨了约 182 倍;TTFT P99 在并发 2 时已经到 2436.17 ms,并发 8 时冲到 15156.92 ms,并发 32 时更是到 60821.14 ms。TPOT 全程稳定在 21.56~23.93 ms 之间,基本不动,说明单 token 生成速度并没有变慢——瓶颈不在算力,而在调度。尾延迟的恶化远早于吞吐见顶,这才是容量规划真正要盯的指标。
第三,据此确定最大并发数。如果只看吞吐,并发 2 之后基本没有收益;结合尾延迟,并发 2 时 TTFT P99 已经超过 2.4 秒,并发 4 时超过 6.8 秒,体感明显变差。所以这张卡的实际可用并发建议取 2,最多不超过 4——在 2 并发下吞吐仍有 27.3 tok/s,接近峰值,而 TTFT P99 还能压在 2.5 秒以内。容量规划的正确姿势是:先找吞吐饱和点,再往前退一档到尾延迟还能接受的位置,那个并发值才是能写进方案的数字。由于 TPOT 全程恒定,加算力(换更好的卡)无用,要换调度更好的引擎或加副本才能提升并发能力。
参考资料
以下 5 条官方文档与本文三个陷阱直接相关,建议在方案定稿前通读一遍:
- vLLM 官方文档(KV Cache 与 gpu_memory_utilization):Automatic Prefix Caching - vLLM。为什么值得看:本文 1.3 节所有算例里的 gpu_memory_utilization 和 KV Cache 池估算,都能在这里找到官方口径,避免凭经验拍脑袋。
- HuggingFace 关于 GQA(分组查询注意力)的技术博客:https://huggingface.co/blog/gqa。为什么值得看:GQA 的 8:1 分组是 1.2 节最容易算错 8 倍的地方,这篇博客把 KV 头与注意力头的关系讲清楚了,能帮你少踩一个字段看错的坑。
- OpenAI 关于流式响应与 SSE 字段定义的 API 文档:https://platform.openai.com/docs/api-reference/chat。为什么值得看:陷阱 2.1 和 2.3 都依赖对 SSE 流式帧结构的准确理解,这份文档是 OpenAI 兼容接口字段定义的权威来源。
- DeepSeek-R1 技术报告中关于 reasoning_content 输出的说明:https://github.com/deepseek-ai/DeepSeek-R1。为什么值得看:陷阱 2.3 里推理模型思考段与 content 字段的区分,在 DeepSeek-R1 官方仓库里有最直接的说明,是压测前解析 SSE 帧结构的必读材料。
- ollama 官方性能调优指南:https://github.com/ollama/ollama/blob/main/docs/faq.md。为什么值得看:本文 3.1 节真机压测用的是 ollama + qwen2.5:7b,这份 FAQ 覆盖了并发、上下文长度和 KV Cache 相关的常见调优问题,能帮你复现并验证压测结论。