
大模型推理的加速技术量化、投机采样、PD 分离这三个词我这两年几乎每次性能优化讨论都会遇到但真正把原理吃透、能讲清楚每个技术到底省在哪又是另一回事。我给自己定的目标很直接不直接调 vLLM 的接口而是参考它的设计从零写一个最小可跑的 nano-vllm把 KV cache 管理、连续批处理、prefill/decode 调度这些核心功能都亲手实现一遍。这篇文章就是我把量化、投机采样和 PD 分离在 nano-vllm 里逐一落地的完整记录包括中间踩过的坑和实测数据希望对正在研究推理引擎或者准备优化线上服务的人有参考价值。1. 为什么从零写一个 nano-vllm1.1 一次请求在引擎里要经历哪些阶段先明确一个概念搞推理加速本质上就是在优化一条固定流水线——tokenize、prefill、decode、采样、反 tokenize。其中 prefill 和 decode 是两个完全不同的阶段理解不了这一点后面看量化、投机采样、PD 分离都会糊。prefill 阶段你提交的 prompt 里所有 token 会被一次性并行处理。这一步计算量大但 GPU 利用率高属于“算得快、看得清”的阶段。prefill 结束后每一层的 intermediate state 会以 key 和 value 的形式缓存下来这就是 KV cache。后续 decode 阶段每次只处理一个 token这个新 token 产生的 key、value 会被追加到缓存尾部不用重新算前面的内容。decode 阶段看起来简单把上一个输出 token 拼进输入继续前向推理得到 logits采样出下一个 token。但这里有个容易被忽略的代价——每一层都要把模型权重从显存读一遍。7B 模型 FP16 权重接近 14GB哪怕只生成一个 token也要把这 14GB 数据完整读一遍。decode 是极其典型的显存带宽受限场景GPU 的算力大部分时候在空转等工作站把权重送上来。这也是为什么后面聊量化时我反复强调“省的是带宽不只是显存”。1.2 自己写和直接读源码的差别有人会问vLLM、SGLang 这些框架都是开源的注释放得很细直接读源码不更省事说实话我一开始也是这么想的结果被 main 分支那几千个文件劝退。vLLM 是生产级系统里面有 PagedAttention 的 CUDA kernel、分布式通信、量化 kernel、调度器的各种边界条件代码量相当大。对于一个还没搞懂“为什么需要连续批处理”的人来说直接扎进去很容易被细节淹没。nano-vllm 的思路是把这个系统砍到只剩骨架一个简化的 BlockManager 负责 KV cache 内存池一个调度器决定每个 step 把哪些请求放在一起再加一个朴素的前向循环。KV cache 我用 PyTorch tensor 直接管理不写自定义 kernel调度策略先做最简单的 first-come-first-serve。骨架跑通之后再一个个往上加优化点。这样每加一个技术都能清楚看到它对哪个环节起作用、改了什么数据结构、影响了哪段调度逻辑。我始终觉得学习推理引擎最好的状态是“每个决定都能追溯到一处代码”。比如 kv cache 为什么要分 block 而不是一块连续大内存“连续批处理”为什么能在同一步里同时处理 prefill 和 decode这些问题的答案都在代码里。你理解了为什么自然也就知道什么场景下该用什么优化手段。2. 量化先把显存和带宽的账算明白2.1 量化为什么能提速而不是单纯省内存很多文章把量化概括成“提高模型精度、减小显存”这没错但不完整。对推理加速来说量化真正的红利在于它降低了对显存带宽的需求。回到 decode 的特性。生成长对话时每一步都要读一遍全部权重。假设 7B 模型 FP16 权重是 14GB如果推理时权重换成 4-bit权重体积降到 3.5GB。同样带宽下每次读取权重的时间理论上能缩短到原来的四分之一。也就是说量化让 decode 阶段的有效吞吐直接翻倍甚至翻四倍不是因为 GPU 变快了而是因为搬运的数据量变小了。量化还顺带解决了显存容量的问题。7B 模型 FP16 需要 14GB 权重 KV cache在消费级 4090 这类 24GB 显卡上勉强可行但想同时跑长上下文和连续批处理就吃紧。4-bit 量化后权重降到 3.5GB省出的空间可以给 KV cache或者同时加载更大的模型。但要泼一盆冷水量化不是免费的。权重精度降低会带来精度损失不是所有模型都适合激进量化。模型里偶尔会出现 outlier 权重数值特别大量化后整个 group 的误差可能被放大。这也是业内现在做 AWQ、GPTQ 这类方法时要解决的核心问题——选择性地量化和做 channel-wise 缩放尽量保住敏感权重。2.2 常见量化档位怎么选量化格式多到让人眼花但做选择时只需要关注三个维度权重位数、激活位数、group size。量化方式权重精度激活精度7B 权重显存典型场景FP16 / BF1616 bit16 bit~14GB精度优先基线W8A88 bit8 bit~7GB兼顾速度与精度服务器常见INT8 weight-only8 bit16 bit~7GB简单可靠CPU/GPU 都友好INT4 weight-only4 bit16 bit~3.5GB本地部署主流GPTQ/AWQGGUF Q4_K_M4 bit16 bit~4GBllama.cpp 本地跑FP88 bit8 bit~7GBHopper 架构加速明显NVFP44 bit4 bit~3.5GB最新硬件上的低精度探索注意权重位数和激活位数是两个独立维度。W8A8 是把权重和激活都压到 8 bit这个想做好不容易因为激活分布每层都不同需要校准统计值。INT4 weight-only 则只压权重激活保留 FP16实现相对简单落地最多。GGUF 是 llama.cpp 的标准格式本质上是 weight-only 量化加各种简化策略Q4_K_M 里的 K 和 M 代表不同的量化层级。还有一个常见误解activation 不一定要量化。服务于 LLM 解码时激活通常只是临时数据显存里的重头是权重和 KV cache。所以很多部署方案压到 W4A16 就能获得巨大收益不值得为了“全量化”硬上 W4A8反而引入精度风险。2.3 在 nano-vllm 里接入量化的真实情况我第一版天真地觉得在 nano-vllm 里做量化就是“把权重加载时 cast 成 int8前向时再还原”。这个方案很快就被证明了是个伪命题。运行时的前向计算必须处理 INT4 权重。如果你的推理引擎没有配套的低精度 kernel即便把权重存成 INT4前向时也得先把它还原成 FP16 再参与矩阵乘法。结果就是显存确实省了但每一步都多了一次 dequantize 开销decode 延迟几乎没变甚至更差。这恰恰说明一个核心原则量化的收益必须由 kernel 层实现纯 Python/PyTorch 模拟量化对生产有借鉴意义但跑不出真实性能。我在 nano-vllm 里做的实操是两件事。第一把模型权重按 group 存储group size 128前向时用自定义的 dequantize 逻辑还原到 FP16 buffer。第二测量这种“伪量化”对精度的影响——我发现 group size 从 32 换到 128精度损失差别不大但显存占用差距明显所以现代量化普遍用 128 或 256就是为了平衡这两个目标。有一个很值得记录的经验如果只是给 nano-vllm 做精度验证可以用 torch.quantization 的 fake quantize 机制它能让你以接近真实精度的方式跑完前向顺便测出量化误差。但如果要测真实推理性能必须用真正的量化 kernel或者干脆在 llama.cpp/vLLM 里测不要在玩具框架里硬啃。3. 投机采样让一个小模型当“草稿员”3.1 投机解码的核心逻辑投机采样speculative decoding的思路挺反直觉想加速大模型先找一个小模型帮它“预写答案”。传统 autoregressive decode 是串行的每次只生成一个 token。投机采样则分两步走你有一个很小的 draft model先用这个模型快速预测出接下来 K 个 token然后用大模型做一次验证。如果大模型的 logits 和小模型预测的一致这 K 个 token 就都被接受等效于一次前向生成了多个 token。关键在验证那一步。大模型不是简单地说“有没有错”而是对小模型给出的每个 token 计算真实概率。如果某个位置的大模型概率排第二和小模型预测的不一致就从这个概率分布里重新采样。被采纳的部分越多加速效果越明显。用大白话解释小模型负责“尽力猜”大模型负责“点头确认”。猜得准的越多省下来的每一步大模型前向就越多总体也就越快。3.2 接受率决定收益不要只盯着 K投机采样最核心的指标是接受率acceptance rate。如果一个 draft model 的预测结果和目标模型完全一致那么 K4 时理论上一步就能出 4 个 token最多能带来 K 倍的加速。但如果接受率低大模型频繁否决就得退回重采样甚至不如老老实实每一步生成一个 token。实践里我踩过很典型的坑一开始只调大 K 值期望投机采样跑得更快结果发现 K8 的效果反而不如 K4。原因是 K 越长小模型“猜”到第 8 个 token 时偏离目标分布的概率越高被否决的概率也随之上升。被否决的 token 需要大模型重新生成这比“没有投机采样”还慢因为浪费了一次大模型前向算力。想让接受率上去有两个方向。一是让 draft model 和目标模型尽量同源比如同一个模型的蒸馏版本tokenizer 和训练数据都接近接受率就会明显更高。二是用 MTPmulti-token prediction头——在目标模型内部并行预测多个 future token本质上相当于“目标模型自己给自己当草稿员”接受率通常高于外部小模型但这个打法实现复杂度也高。3.3 KV cache 与拒绝 token 的处理投机采样没有让模型变快它只是把模型的前向次数变少了所有额外开销都转移到了调度和 KV cache 管理上。当一个大模型验证一批 draft tokens 时输入里同时包含多个候选 token。即使第 2 个 token 被否决了前面 1 个 token 的 KV cache 已经产生了并且需要保留。这意味着调度器要比普通 decode 更精细地管理 KV cache block 的位置。nano-vllm 里我在验证步骤中先临时为候选序列分配一块 KV cache验证结束后再根据接受情况决定是“commit”还是“rollback”。rollback 这件事听着简单实现起来要小心。如果 KV cache 是按 block 分配的一个 block 里可能有四处分散的序列随便释放会导致内存池管理出问题。我后来采用的办法是把候选 token 的 KV cache 写到独立的临时 block 区域验证通过后再拷贝到正式序列位置。虽然多了一次拷贝开销但换来的是 BlockManager 的稳定在 toy framework 这个阶段完全可以接受。顺带提醒draft model 也是要占显存的。我在本地跑投机采样时第一次没给 draft model 做量化结果一份目标模型、一份小模型显存直接吃紧连续批处理的 batch size 反而下来了。draft model 非常值得量化因为它只承担“预测”任务精度要求可以低一些用小号 INT8 或 INT4 版本完全够用。4. PD 分离让 prefill 和 decode 别再互相打架4.1 两个阶段对资源的诉求完全不同PD 分离Prefill/Decode Disaggregation近年的热度很高。本质上就是要解决一个矛盾prefill 和 decode 对硬件资源的要求完全不同把它们放在同一个引擎里必然互相干扰。prefill 是计算密集型的长 prompt 一次前向就是几百、几千个 tokenGPU 算力基本吃满瓶颈在 FLOPS。decode 是访存密集型的每步只算一个 token大量时间耗在搬运权重上瓶颈在显存带宽。如果这两种请求被调度到同一个 batch 里问题就来了prefill 执行时耗时很长会把 decode 的延迟拉高decode 排队时会占用大量显存和带宽又会拖慢 prefill 的通量。最简单的心智模型是流水线里的两道工序效率不一样却硬要在一个工位上同时做。要么让前工序等后工序要么让后工序等前工序总有一方被拖累。PD 分离的思路是把这两个工序拆成两个独立工位各管各的中间用 KV cache 把结果接力传递。4.2 单机视角下的 prefill 动态拆解PD 分离不只有“多机部署”这一种形态。在单机单卡的 nano-vllm 里也能看到它的一个关键变体chunked prefill。原理很简单与其让一个长 prefill 请求独占 GPU 很久不如把它拆成多个 chunk每次只算一小段。这样调度器可以把 prefill chunk 插到 decode batch 的空隙里两边都不至于饿死。这就引出一个新的调度问题到底该把一个 prefill 请求切成多大我的经验是从延迟预算反推。先估算当前 decode batch 跑一个 step 需要多少时间再估算一个 prefill chunk 跑多久。如果 prefill chunk 的执行时间不超过 decode step 的时间就可以安全地塞进去而不会让正在 decode 的请求感受到明显卡顿。nano-vllm 里我实现了一个很简单的启发式固定 prefill chunk 的 token 数上限比如 512 或 1024 个 token并且每轮调度里只允许插进一个 chunk。后面实测时发现这个上限设得太大batch 里 decode 的请求还是会抖动设得太小又被没完没了地打断。最合适的值跟显卡带宽和 batch size 有关需要跑一组实验才能定。4.3 从 nano-vllm 到分布式部署的思考单机内部做 chunked prefill 只是 PD 分离的入门形态。真正生产级的 PD 分离是把 prefill 和 decode 拆到不同机器上各跑各的实例。prefill 实例专门处理长 prompt算完就把整段 KV cache 传到 decode 实例decode 实例拿到缓存直接从第一个 token 开始往后生成。这种方式的好处很清楚prefill 实例的显存和算力不会被 decode 的带宽瓶颈浪费掉decode 实例也不用维护那么大的 prefill 计算逻辑。坏处是 KV cache 传输要走网络如果网络带宽不够KV cache 传完的时间可能比本地直接计算还慢。这也是为什么大厂搞 PD 分离时经常配套做 KV cache 压缩、量化甚至 “cache offload” 的调度策略。我在 nano-vllm 里没有真做分布式传输但它的设计让我想明白了一个关键点无论分不分离KV cache 的格式和生命周期管理都必须独立于 prefill/decode 的具体位置。谁生成它、谁消费它这是服务架构的事怎么分配、怎么释放、怎么按需扩展这是引擎层面的事。把这两层分开思考后面读任何 PD 分离相关的代码都会顺很多。5. 三个技术叠加实测与踩坑记录5.1 建议的技术组合顺序三个技术单独用都有收益但合在一起时需要想清楚优先级和兼容性。我的实践结论是先量化再做 PD 分离最后考虑投机采样。量化是基础它让显存松绑也给后面的技术留出空间。如果模型权重还是 FP16加载 14B 模型之后显存就不剩多少了KV cache 很容易成为瓶颈PD 分离想多开几个 decode 实例也没戏。PD 分离解决的是系统层面的抖动问题。先把 prefill 和 decode 的节奏理顺让 batch 里的 decode 请求延迟稳定下来再去压缩 decode 步数——也就是投机采样——才有意义。因为投机采样的收益要在大模型前向次数减少之后才能体现如果调度本身乱了小模型预测的等待时间都不稳定收益统计就没法做了。投机采样是最后一个要加的。它增加系统复杂度而且接受率需要验证。我建议先量化、先分离用基线数据跑通确认瓶颈到底在哪个阶段再决定上不上投机采样。5.2 实测的典型问题与排查我整理一下这一路遇到的最典型的几个问题比很多论文里写得直白。第一量化后模型的 KV cache 如果有精度损失投机采样的接受率会莫名下降。我一开始以为是小模型选得不够好排查了很久才发现是目标模型量化后概率分布有些偏移小模型预测的还是原始模型的分布两边不一致导致接受率下降。解决办法很朴素把目标模型和 draft model 用同一套量化策略或者让 draft model 稍微“适应”目标模型的输出分布。第二chunked prefill 的 chunk 大小设错了。它有点像 batch size 的调参值得每换一次硬件就跑一遍小实验。我在一张 24GB 显卡上把 chunk 设到 2048结果 decode 的尾巴延迟直接飙到几百毫秒惨不忍睹。降回 512 之后完全正常。第三投机采样在低接受率场景下是负优化这个必须前置判断。怎么判断上线前拿一段有代表性的 prompt 集合测一下接受率就行低于 0.3 基本不建议开。接受率这事跟业务场景有很大关系代码生成和数据抽取的接受率差异极大别指望一套参数吃遍所有场景。第四多模态模型量化时的维度不匹配问题。我在本地试过某个带视觉编码器的量化版本结果报出 “CLIP 5120 与 4096 不匹配”。这类问题本质是视觉塔输出的 hidden size 和 LLM 输入投影层不匹配常见于社区魔改模型或者量化脚本写错维度。解决办法不是修量化脚本而是先跑一遍未量化版前向确认原模型就没有维度对齐问题再对比量化复现流程往往很快能找到是哪个环节改坏了 shape。5.3 对入门者的推荐路径给同样想通过 nano-vllm 学推理的人一条比较顺的路线。第一步什么都不优化先把调度器跑通搞清楚 request 从进队列到出结果的完整流程。这个过程重点是 KV cache 的生命周期管理。第二步加量化。但不要执着于实现 kernel用现成量化工具生成模型在 nano-vllm 里对比精度和显存占用先建立对量化误差的直觉。第三步加 chunked prefill。把调度逻辑改一改让一个请求可以被拆开执行。这一步会强迫你想明白“一个请求在什么时间点变成多个片段”。第四步加投机采样。先实现朴素版本不用管 MTP 和动态调优。关键是理解 KV cache 的提交与回滚。这四步走完再去读 vLLM 的大源码会发现之前的很多障碍已经被扫清了一半。剩下的主要是工程细节比如分布式通信、kernel 优化、cache 策略。我在实际跑完这些实验之后有个很深的体会推理加速领域的每一项技术都不是银弹。量化解决显存和带宽问题但引入精度损失投机采样解决 decode 串行问题但引入额外的模型和调度复杂度PD 分离解决互相干扰问题但引入网络和调度新瓶颈。真正落地方案时做的永远是取舍而不是把三个技术都无脑堆上去。你现在理解了它们各自在算什么账、卡在哪一环以后面对任何新框架或者新优化点都能比较快地判断出“这到底值不值得上”。