大模型推理优化实战:从权重量化到投机采样,打造低延迟高吞吐服务

发布时间:2026/10/1 14:07:42
大模型推理优化实战:从权重量化到投机采样,打造低延迟高吞吐服务
今年有一大半时间我都泡在“把大模型推理延迟再压下来一点”这件事上。Model-Optimizer 这个项目就是在这个背景下一点点攒出来的。它不是什么颠覆性的新算法而是一套把权重量化、KV Cache 优化、算子融合、动态批处理、投机采样这些已知手段拧在一起落到具体工程里的组合拳。如果你正在跑 LLM 推理服务对 TTFT、TPOT、吞吐量这几个指标有执念又不满足于“能用就行”的状态那这篇里记录的配置、参数和踩坑记录应该能帮你省下不少排查时间。我先把项目放在一个具体的语境里讲线上有部署在私有化环境里的对话模型7B 到 13B 不等卡是 A10/A30 这一档。业务方给的硬指标是首 token 延迟低于 2 秒、每秒吞吐不低于 30 个请求、长上下文场景不能 OOM。最开始用原版 Transformers 直接起服务14B 模型在 A30 上显存就差一点不够用延迟更是惨不忍睹。打磨 Model-Optimizer 的过程本质上就是回答“在算力不变的情况下怎么让模型跑得更快更省”这个问题。1. 项目背景与整体设计思路1.1 为什么需要 Model-Optimizer推理优化的现实痛点很多人对“推理优化”有一种误解以为就是调个 batch size、换一下精度大不了上 vLLM 就完事了。真到生产环境就会发现问题比想象中复杂得多。模型在训练阶段是“重前向、轻部署”但推理阶段要面对的是有限的显存、严格的延迟上限、波动的请求流量还有“显存越少、KV Cache 越小、有效吞吐越低”这个硬约束。显存不光是给模型权重用的还要留给激活值、KV Cache 和临时计算图任何一个环节爆掉整个服务就全垮。另一个现实痛点是单点优化往往按下葫芦浮起瓢。只做量化模型小了但吞吐没有本质提升只开动态批处理显存又撑不住长上下文只做算子融合在低并发下收益又聊胜于无。Model-Optimizer 的设计初衷就是把这些手段按生产链路组织起来像流水线一样逐级优化先让模型塞进显存再把访存压下来最后把算力用满。它不是替代推理框架而是沉淀在框架之上的一层优化编排逻辑——测基线、找准瓶颈、套相应手段、验证收益整个过程固化成可复用的流水线。1.2 方案选型逻辑从单点优化走向组合优化项目启动时我列过一张技术选型表对比了三种路线一是基于原始 Transformers 加 TorchScript/图优化二是直接迁移到 vLLM/TensorRT-LLM 这类推理框架三是自研轻量优化层把精度压缩和调度策略掌握在自己手里。第一条路最省事但 TorchScript 对动态 shape 和复杂控制流的支持并不友好GPTQ 量化后的模型在导出时经常因为算子不兼容而失败。第二条路收益最直接但业务系统里有几个自定义算子框架适配成本不低而且线上想单独调整某种量化策略改源码难度大。最终选了第三条以 PyTorch 为基础配合 llama.cpp 的量化工具链估算方案再按需接入 vLLM 的 PagedAttention 思路做显存管理。这样做的代价是自己要写不少兼容层换来的自由度是任何优化手段都能独立开关方便做对照实验。组合优化的核心在于“顺序”。我最终固定下来的流程是权重量化在前KV Cache 压缩紧随其后然后是算子融合和批处理策略最后才是投机采样这种带概率性质的加速手段。顺序错了会很别扭比如先做 KV Cache 量化却没有先压缩权重显存腾出来的空间不够支撑更大的批优化效果会被严重打折。1.3 项目目标与衡量指标优化项目如果没有清晰的衡量指标很容易变成自我感动。这个项目的量化指标只有三个TTFTTime To First Token首 token 延迟、TPOTTime Per Output Token每输出 token 耗时和整体吞吐量tokens/s。TTFT 反映“响应快不快”TPOT 反映“生成流畅不流畅”吞吐量反映“服务能扛多少并发”。但这三个指标是彼此打架的。想降低 TTFT就要减少 prefill 阶段的计算量最好把 batch 拆小想提高吞吐又要把 batch 尽量填满让 GPU 算力不闲置。Model-Optimizer 里所有优化决策最后都要在这三个指标之间找平衡点。我在项目里给每个实验都记录了一张类似下面的对照表没有数据支撑的优化建议一律不接受。实验项TTFT (ms)TPOT (ms)吞吐量 (tokens/s)显存峰值 (GB)原生 FP168528921813.8加 INT8 量化633712857.2加 KV Cache INT8592663126.5加算子融合548583486.0加动态批处理601554267.82. 核心优化手段的原理与实操要点2.1 权重量化INT8 / INT4 / FP8 怎么选量化是 Model-Optimizer 里收益最直接的一步。原理不复杂把 FP16 的权重矩阵从 16 bit 压到 8 bit 或 4 bit模型体积直接减半甚至减到四分之一访存压力也同步下降。但量化不是一个“无脑降精度”的事不同方案的底层逻辑差别很大。INT8 量化适合想快速扩容显存的场景。我用过两种主流做法一种是基于校准集统计激活值分布的 PTQ训练后量化另一种是 LLM.int8() 那种混合分解方案——把绝大部分权重量化到 INT8而对那些激活值特别大、像“离群点”一样的列保持 FP16。实测下来7B 模型用 INT8 的困惑度损失通常可以控制在 0.1 以内基本无感。如果你用的显卡支持 FP8比如 H 系列可以优先考虑 FP8它在数值表示上比 INT8 更平滑量化误差更小。真正要小心的是 INT4。GPTQ 和 AWQ 是两种主流做法。GPTQ 基于二阶 Hessian 信息做逐层重建理论上误差最小AWQ 则根据激活值的分布来保护重要权重通道。我在实际操作里的体感是在同等压缩率下AWQ 的稳定性更好尤其当模型规模在 13B 以下时GPTQ 容易出现个别层误差异常放大。而 AWQ 校准速度快对校准数据集的质量要求也低一些。实操层面参考配置如下AWQ 校准用 128 条指令数据group_size 设为 128用 per-channel 的缩放因子。不要用 256 的 group size虽然能再省一点显存但在长上下文场景下生成的流畅度下降明显。混合量化是我后来加上去的策略先量化全部层再计算每一层输出的余弦相似度把相似度低于 0.99 的层挑出来恢复成 FP16通常这样做的代价是只多占 5% 显存但困惑度能挽回一大截。2.2 KV Cache 压缩与注意力优化如果权重是“静态显存”KV Cache 就是“动态显存杀手”。它的大小公式很简单2K 和 V乘以 batch size、序列长度、层数、头维度再乘以精度字节数。一个 13B 模型batch size 为 8、上下文 4096 时光 KV Cache 就可能吃掉 4GB 以上而且它随并发线性增长。所以 KV Cache 优化是决定服务稳定性的一步棋。KV Cache 量化的思路和权重量化类似维度上要注意“按通道量化优于按张量量化”。K 和 V 的数值分布完全不同K 的分布通常更尖锐V 更平缓分开处理能降低量化误差。int8 KV Cache 量化配合按通道缩放因子实测显存能省 40% 左右性能几乎不跌。如果再激进一点用 int4就需要小心某些层在长序列下确实会出现失真建议对前几层保留 int8。另一个有效的策略是用 GQAGrouped Query Attention替换 MHAMulti-Head Attention。GQA 让多个 query 头共享一组 key/value 头KV Cache 的大小能压缩到原来的 1/n 甚至更小。如果你是重新训练或微调模型这个改造非常值得如果只是做推理优化那更多是通过 KV Cache 的显存上限来控制服务稳定性。我在项目里推行一个经验法则KV Cache 的显存占用不要超过总显存的一半超过就说明批大小或上下文长度设得太激进。2.3 算子融合与前向图优化算子融合是那种“不做不知道做了离不开”的优化。在 PyTorch 默认执行方式里一个简单的Q K^T注意力计算会分解成多个内核调用每一次调用都要把中间结果写回 HBM显存再读出来。HBM 的带宽比计算单元慢得多所以很多时候 GPU 都在“等数据”而不是“算数据”。FlashAttention 的思路就是抓这一点把整个 attention 计算融合进一个 kernel分块计算把中间状态留在寄存器或共享内存里不落回 HBM。我的项目里这个融合改造贡献了约 15% 的 TPOT 改善。另外一类常见的融合是激活函数和线性层融合比如x W b之后立刻接 GELU可以直接熔成一个 kernel省掉一次读写。实操要点有两个。第一用 PyTorch 自带的torch.compile时要留意动态 shape 对图捕获的影响如果序列长度频繁变化编译后的图可能会反复回退到 eager 模式收益大打折扣。我的做法是对 prefill 和 decode 分别编译因为这两个阶段的 shape 模式完全不同。第二在自定义 CUDA kernel 或使用 FlashAttention 周边库时必须检查它是否支持你当前模型的头数和 head_dim——常见的不兼容就发生在 MQA/GQA 和某些 flash 实现之间不兼容时通常会自动回退到普通 attention速度不升反降。2.4 动态批处理与投机采样吞吐量提升的两板斧传统批处理是静态的一个 batch 开始前定好哪些请求一起跑要等 batch 里最慢的那个解码完才整体结束。这在请求到达时间不确定的生产场景下非常浪费算力。动态批处理也叫 continuous batching把“请求”和“生成”解耦一个请求完成解码就立刻出队新请求马上占位进来GPU 在每一轮都能保持高利用率。vLLM 的 PagedAttention 是这套思路的典型实现它把 KV Cache 分页管理像操作系统管理内存一样分配和回收。Model-Optimizer 也借鉴了这个思路不过我为了轻量化了只实现了 chunked prefill把长请求的 prefill 阶段切成小块和正在 decode 的短请求交错执行避免一个长请求独占 GPU。这个改造让线上 TTFT 的 P99 从 2100ms 降到了 1400ms。投机采样则是另一维度的技巧。它用一个小号草稿模型先生成 K 个候选 token再让大模型一次性地验证这些 token。如果草稿模型的预测和真值匹配大模型一次前向就“免费”生成了 K 个 token。实测下来7B 大模型配 0.5B 草稿模型对代码生成任务有约 1.8 倍加速但对一些专有领域文本草稿模型猜不准收益接近于零甚至因为额外开销而变慢。所以投机采样应该是“最后打开”的优化开关不能当作默认配置。3. 项目实操过程与关键节点记录3.1 第一步建立基线先跑通再谈优化很多优化项目失败不是优化手段不行而是没有一条可信的基线。我在 Model-Optimizer 里第一件事就是把原生 FP16 模型跑通记录下三个指标和显存峰值。这一步的价值不是“看它有多慢”而是给后面每次改动提供一个对照坐标系。基线环境是 Python 3.10、PyTorch 2.1、CUDA 12.1模型用 transformers 的默认 pipeline 加载。测试集包含三组128 条代码生成请求短上下文 512、64 条文档总结请求中等上下文 2048、32 条长文档对话请求上下文 8192。每组都记录 TTFT、TPOT、总吞吐量、显存峰值和 P99 延迟。跑完基线的数据很不好看——长上下文请求几乎把显存打满P99 的 TTFT 直接到 3 秒以上。但正因为基线足够“差”后面的每一步优化都能量化出真实收益。这里有个容易被忽略的细节做基线测试时一定要固定随机种子和输入长度分布否则两次测试的波动会掩盖真实的优化收益。我用固定种子生成测试数据并且把 warmup 轮数设为 5、正式测试轮数设为 20取后 15 轮的平均值作为基线。踩过的坑是GPU 在低温状态下跑第一次推理耗时会比稳定后高一倍不 warmup 直接测基线数据会虚高后面优化收益会显得很小。3.2 第二步性能画像用数据定位瓶颈基线建立后下一步不是急着优化而是先搞清楚时间都花在哪了。我给项目里接了一个简单的 profiling 工具给 prefill 阶段和 decode 阶段分别埋点统计各阶段耗时占比。为什么要区分这两个阶段因为它们的瓶颈完全不同prefill 是计算密集型主要受算力限制decode 是访存密集型受显存带宽限制。用不同的手段去优化不同阶段效率才会高。实际跑下来的 profiling 结果很典型短请求场景下decode 阶段占总时长的 62%说明瓶颈在显存带宽这时候最优解是权重量化和 KV Cache 量化——它们能直接减少每轮 decode 读取的数据量。长请求场景下prefill 占比飙到 70% 以上瓶颈在计算这时优先考虑算子融合和 chunked prefill。这些结论听起来像是常识但没有 profiling 数据支撑时很容易把优化顺序搞反。我还做了一件事监测 GPU 显存分配曲线。用torch.cuda.memory_reserved()和torch.cuda.memory_allocated()定期打点能看到 KV Cache 的增长是不是平稳。如果曲线出现陡峭台阶多半是缓存分配策略有问题如果曲线持续线性上涨就要怀疑是否某个请求的 KV Cache 没有及时释放形成了隐式泄漏。3.3 第三步逐项优化落地的具体参数与步骤整个优化流程按固定顺序逐项落地每次只改一个变量。第一步做权重量化。我用 AutoAWQ 对 7B 模型做 4bit 量化校准集用的是代码生成下游任务的 128 条样本group_size128, zero_pointTrue。量化的过程大概需要 20 分钟产出是一个quantized_model/目录。加载量化模型时要注意不要直接from_pretrained加载整个目录而是把量化权重转成 safetensors 后再加载前者的加载速度会慢一倍。第二步做 KV Cache 量化。因为在 PyTorch 侧现成的 KV Cache 量化工具不多我参照 vLLM 的实现思路自己写了 K 和 V 的量化/反量化 kernel用 int8 存储按通道缩放。注意 K 和 V 的量化和反量化必须在设备上完成不能搬运到 CPU否则通信开销会吃掉全部收益。这一步完成后长上下文场景的显存峰值下降了约 35%。第三步是算子融合。模型里的 attention 子图替换成 FlashAttention 实现。注意这里不能用简单粗暴的全局替换当use_flash_attentionTrue时要检查模型配置里的 attention head 类型是否兼容。我遇到的最大坑是 GQA 模型在某些 flash 实现下没有被正确处理需要手动验证 attention 输出和原实现的数值误差误差超过 1e-3 就说明回退或实现有问题。第四步是调整批处理策略。引入动态批处理调度器设置max_num_batched_tokens4096max_num_seqs64。这两个参数一个限制总 token 数一个限制请求数相当于给显存用量画了一条安全线。经验值是它们应该根据量化后的模型显存重新计算而不是沿用 FP16 时的旧值。最后才是投机采样草稿模型选择 0.5B 的 Qwen 系列蒸馏模型num_speculative_tokens3。这个参数不是越大越好草稿模型质量不高时3 和 5 的差距几乎看不到反而会增加验证失败的机率。3.4 第四步联合调优与效果对照单项优化的风险在于手段之间存在互相牵制的效应。INT8 权重虽然省了显存但如果同时又把 KV Cache 压成 INT4在 8192 长上下文的压力测试下输出质量可能出现肉眼可见的下降。所以我在每一轮改动后都会跑一遍 3.1 里固定的基线测试用同一套测试集做对照。最终的效果对照如下在这个 7B 模型上TTFT 从 852ms 降到 548msTPOT 从 89ms 降到 55ms吞吐量从 218 tokens/s 提升到 348 tokens/s此数据不含投机采样。等开机投机采样之后代码生成类任务的吞吐量直接冲到了 586 tokens/s但文档总结类任务只到 377 tokens/s——这说明“同一套优化组合在不同任务类型下收益差距非常大”。这里要特别强调“灰度”意识。不要在一次变更里同时上量化和批处理改造最好分开上、分开测。我在项目里制定了一个原则一次变更只碰一个变量提交记录里写清楚改了什么配置、预期收益是多少、实际收益是多少。这个习惯后来救了我很多次——当线上指标异常时回滚一个变更比排查五个叠加变更要容易得多。4. 优化过程中踩过的坑与排查实录4.1 量化后效果骤降问题可能不在校准集我最初做 INT4 量化时困惑度从 8.2 直接涨到 12.5对话质量肉眼可见地变傻。第一反应是校准集不够于是换成了更通用的 512 条指令数据重新量化后效果依然很差。后来逐层排查才发现问题出在模型的前几层 embedding 层——量化时没有排除这些层导致输入向量被严重压缩。解决方案是混合精度对所有 attention 层做 INT4 量化但把 embedding 和 lm_head语言模型头保留 FP16。原因在于embedding 层的权重虽然只占总参数量的一小部分但它直接影响所有 token 的初始表示压缩误差会被后续层级放大。这个改动单独就挽回了一半的困惑度损失代价只是显存增加了约 0.3GB。另一个启发是量化后一定要做“层敏感度分析”。具体做法是逐层把某个量化层的权重换成 FP16其他层保持量化看困惑度是否明显下降。如果某一层单独恢复 FP16 就能让困惑度大幅改善说明该层是敏感层需要在量化时特别保护。我在 13B 模型上跑过一遍发现敏感层通常集中在中间位置的少数几层而非均匀分布。4.2 显存估算总是失准OOM 到底是谁的锅动态批处理上线后的第一次压测显存直接爆了。按公式算max_num_batched_tokens4096时 KV Cache 占用大约 1.5GB加上权重和激活值远应该不到 14GB 的上限。但实际运行时torch.cuda.OutOfMemoryError还是出现了。排查后发现罪魁祸首是 PyTorch 的显存缓存分配器。它为了减少分配调用会预留一部分显存作为缓存碎片碎片量取决于请求 shape 的变化频率。当请求长度跨度很大时缓存碎片可能达到几百 MB 甚至 1GB。解决方法是调整PYTORCH_CUDA_ALLOC_CONF环境变量设置expandable_segments:True让显存按可扩展段分配能显著减少碎片。这个坑值得单独记录一笔不要完全相信公式计算出来的显存值一定要留 10% 到 15% 的余量。KV Cache 的显存统计也只是理论值实际分配可能因为分页对齐、张量形状 padding 等原因多出几倍。4.3 TPOT 抖动严重批处理调度被忽视动态批处理上线后平均 TPOT 很漂亮但 P95 和 P99 抖动得厉害。最夸张的一次P99 是平均值的 3 倍。初步怀疑是某个大请求的 prefill 阻塞了 decode 请求后来用 profiling 数据确认确实是 chunked prefill 的切块策略有问题——我把 prefill 切成 512 token 一块但混入 decode 时没有考虑当前 batch 里 decode 请求的剩余长度导致某些轮次的计算量超了。修复的策略叫“水位线控制”每一轮调度之前先估算本轮新增计算量如果加上 decode 请求的剩余 token 数超过预算就推迟 prefill 块到下一轮。调优后的效果是P99 抖动从 3 倍降到了 1.6 倍虽然牺牲了一点平均吞吐但线上体验的重心从来都是“别让部分用户卡太久”。在这个问题上我深刻体会到平均指标是给管理层看的P99 才是给用户看的。优化过程中不要只盯着平均值将 P99 纳入每次实验的记录表能早期发现很多隐蔽的调度问题。4.4 投机采样在某些场景下反而变慢投机采样的原理决定了它的收益有很强的前置条件草稿模型的预测必须和大模型足够一致。如果草稿模型是通用领域的面对领域专用术语时它生成的候选 token 可能一个都匹配不上大模型的验证会变成纯粹多出来的负担更慢且更耗显存。我在政务文档处理场景就遇到这个问题。草稿模型是通用聊天模型对法律文书的用词习惯完全没概念候选匹配率不到 20%。关掉后TPOT 反而快了约 30ms。所以项目里给投机采样加了个动态开关监控最近 50 个请求的候选 token 接受率低于 40% 就自动关闭高于 55% 才重新开启。这个自适应的经验法则是从实践中打磨出来的比任何理论分析都直接。另一个投机采样提速的技巧是调整草稿模型的采样温度。草稿模型的温度越低生成的候选越保守匹配率越高但太保守又会导致候选多样性下降大模型的验证收益变小。实测下来草稿温度设为 0.6 左右时整体收益最大。5. 一点个人体会做了大半年 Model-Optimizer 这个项目最大的感受是推理优化更像一门“权衡的艺术”而不是“堆叠绝招”的活儿。每次量化省下来的显存最终都应该用于扩大 batch 或延长上下文而不是眼睁睁看着 GPU 占用率不升反降。我个人的建议是无论用什么框架或工具先把 profiling 基本功打牢。很多问题其实一眼就能看出来只是大多数人没有在优化之前先做这一步的耐心。配置参数宁可保守一点也不要一上来就追求极限否则线上出现性能抖动时排查代价会成倍增加。最后每一次优化改动都要留记录数据和收益都记下来这些内容就是你下一台机器上线的避坑手册。