1. 从 14 tok/s 到 159 tok/s:这次调优到底在折腾什么
本地跑大模型这件事,很多人第一次跑通之后就会陷入一种“能跑就行”的状态。模型加载成功,输入一句话,屏幕上慢吞吞地往外蹦字,一看速度,14 tok/s。这个速度什么概念?大概就是你问它一个稍微复杂点的问题,它要思考十几秒才能把一段完整的话吐完。如果只是偶尔用一下,忍忍也就过去了;但如果你打算把它当成日常辅助工具,或者想在上面搭一个本地知识库问答、代码补全之类的应用,这个速度是没法接受的。
这次要聊的就是把本地推理速度从 14 tok/s 拉到 159 tok/s 的完整过程。核心手段是投机解码(Speculative Decoding),配合一系列显存、批处理、量化相关的调优。标题里提到的“Qwen3.8-27B”是一个约 270 亿参数规模的中等体量模型,这个体量很有意思:它不像 7B 那样随便一张消费级卡就能飞快跑起来,也不像 70B 那样必须上多卡或者专业卡。27B 这个档位,单张 24GB 显存的卡能勉强塞下量化版本,但速度往往卡在一个尴尬的区间——能跑,但不够快。
所以这篇文章适合谁看?如果你手头有一张 24GB 显存的卡(比如常见的 3090、4090 这个级别),想本地跑 27B 级别的模型,并且对速度有实际要求,那这篇内容基本就是为你写的。如果你只是好奇投机解码到底是怎么回事,为什么它能把速度提升十倍以上,那也能从里面拿到不少原理层面的东西。我会把每一步的动机、参数怎么定、踩了哪些坑都讲清楚,而不是只丢一个结论。
先说结论性的数字对比,让你有个直观感受:
| 阶段 | 配置状态 | 生成速度 |
|---|---|---|
| 基线 | 直接加载量化模型,默认参数 | 约 14 tok/s |
| 第一阶段 | 调整量化格式与显存分配 | 约 28 tok/s |
| 第二阶段 | 开启批处理与 KV Cache 优化 | 约 45 tok/s |
| 第三阶段 | 引入投机解码,草稿模型选型 | 约 90 tok/s |
| 第四阶段 | 草稿模型与参数联合调优 | 约 159 tok/s |
这个提升不是靠某一个“银弹”参数实现的,而是几个环节叠加的结果。投机解码贡献了最大的一块,但如果没有前面的显存和量化打底,投机解码根本跑不起来,因为草稿模型本身也要占显存。下面我按实际调优的顺序,一步步拆开讲。
2. 基线为什么只有 14 tok/s:先搞清楚瓶颈在哪
2.1 27B 模型在 24GB 卡上的显存账本
要理解速度为什么上不去,得先算清楚显存这笔账。一个 27B 参数的模型,如果按 FP16 存储,光权重就要 27 × 2 = 54GB,这已经超过单张 24GB 卡的容量了。所以本地跑必然要量化。常见的量化格式有几种,显存占用差别很大:
- FP16:每参数 2 字节,27B 约 54GB,单卡放不下。
- INT8 / Q8:每参数约 1 字节,27B 约 27GB,还是超。
- Q4 系列(如 Q4_K_M):每参数约 0.5 字节出头,27B 约 15-16GB,能塞下。
- Q3 系列:约 12-13GB,更宽松,但精度损失明显。
我一开始用的是 Q4_K_M,权重占了大概 15.5GB,剩下的 8GB 左右要留给 KV Cache、激活值、以及推理框架本身的开销。这里就有第一个坑:很多人以为权重塞进去就完事了,实际上 KV Cache 会随着上下文长度线性增长。上下文开到 4096 的时候,KV Cache 可能就吃掉 2-3GB;开到 8192,直接翻倍。再加上框架的临时缓冲,8GB 余量其实很紧张。
提示:显存不够的时候,框架不一定会报错,而是会悄悄把部分层卸载到内存里,速度直接断崖式下跌。这就是为什么有些人明明“跑起来了”,速度却只有个位数。
2.2 默认参数下的推理流程与耗时分布
搞清楚显存之后,再看推理本身的时间花在哪。大模型生成一个 token,大致分两个阶段:
- Prefill(预填充):把你输入的 prompt 一次性过一遍,建立 KV Cache。这一步是并行的,通常很快。
- Decode(解码):一个 token 一个 token 地往外生成,每一步都要把整个模型跑一遍。这一步是串行的,也是速度瓶颈所在。
14 tok/s 意味着每个 token 大约 71 毫秒。这 71 毫秒里,绝大部分花在把 27B 的权重从显存里读一遍、做矩阵乘法。注意,这里是显存带宽瓶颈,不是算力瓶颈。也就是说,GPU 的计算单元其实没跑满,大部分时间在等数据从显存搬过来。这个认识很关键,因为它决定了后面的优化方向:要么减少要搬的数据量(量化、投机解码),要么让搬运更高效(批处理、更好的 kernel)。
我实测下来,默认配置下 GPU 利用率大概只有 40%-50%,显存带宽倒是接近跑满。这就印证了瓶颈在带宽上。理解了这一点,后面所有优化手段的逻辑就都顺了。
2.3 第一次提速:量化格式与显存分配的调整
第一阶段的提速其实没有动模型结构,纯粹是把配置理顺。具体做了三件事:
第一,把量化格式从默认的 Q4_K_M 换成了针对推理优化的 Q4_K_S,权重体积略小一点,虽然精度略降,但在 27B 这个体量上,Q4_K_S 和 Q4_K_M 的实际输出质量差异很小,而显存省下来的部分可以留给更长的上下文。
第二,显式设置了 GPU 层数,确保所有层都在 GPU 上,不允许任何层回落到 CPU。这个参数在不同框架里名字不一样,有的叫n_gpu_layers,有的叫gpu_layers,设成 -1 或者一个大于总层数的值即可。
第三,把上下文长度从默认的 8192 降到 4096。这一步是权衡:上下文越长,KV Cache 越大,留给计算的空间越小。对于大多数对话场景,4096 已经够用,等速度稳定后再考虑往上加。
这三步做完,速度从 14 tok/s 提到了 28 tok/s,翻了一倍。说实话这个提升有点超出预期,主要功劳在于“所有层都在 GPU 上”这一条——之前有部分层在 CPU 上跑,每次都要在 CPU 和 GPU 之间同步数据,那个开销非常大。
3. 批处理与 KV Cache:把 28 tok/s 推到 45 tok/s
3.1 连续批处理到底批的是什么
到了 28 tok/s 之后,瓶颈又回到了显存带宽。这时候能做的,是让每一次显存搬运“更值”。批处理(Batching)就是这个思路:既然每次都要把权重读一遍,那不如一次读进来,同时处理多个请求。这样权重的读取成本被多个请求分摊,单位 token 的耗时自然下降。
但这里有个概念要区分清楚:静态批处理和连续批处理(Continuous Batching)。静态批处理是把一批请求凑齐了一起跑,跑完再跑下一批,问题是如果某个请求生成得特别长,其他短请求就得等它,效率低。连续批处理则是动态的,一个请求生成完了,立刻把新的请求塞进这个空位,GPU 始终处于满载状态。
我用的框架支持连续批处理,开启之后,在并发请求场景下吞吐量提升非常明显。但要注意,如果你只是单请求交互式使用,批处理帮不上什么忙,因为就一个请求,没得批。所以这一步的收益取决于你的使用模式。我自己的场景是本地知识库问答,经常有多个查询排队,所以批处理收益很大。
3.2 KV Cache 的量化与分页管理
KV Cache 是另一个大头。前面说过,上下文越长,KV Cache 越大。默认情况下 KV Cache 是 FP16 存储的,一个 27B 模型在 4096 上下文下,KV Cache 可能占 2-3GB。如果把它也量化成 INT8,显存占用直接减半,省下来的空间可以给更长的上下文或者更大的批。
不过 KV Cache 量化有个坑:它会影响注意力计算的精度,在某些对长距离依赖敏感的任务上,输出质量会下降。我的做法是先在 Q8 上试,如果质量可接受就不动,如果发现模型开始“忘事”,再退回 FP16。实测下来,Q8 的 KV Cache 在对话场景里几乎感觉不到差异。
另外,分页管理(Paged Attention)也值得开。它的作用是把 KV Cache 切成固定大小的块来管理,避免显存碎片化。显存碎片这个东西平时不显眼,但当你频繁地创建和销毁请求时,碎片会越来越多,最后导致明明显存总量够,却分配不出连续空间。开了分页管理之后,这个问题基本消失。
3.3 实测:批处理参数怎么定才不翻车
批处理不是越大越好。批越大,显存占用越高,而且延迟(单个请求的响应时间)会上升。这里有个吞吐量和延迟的权衡。我实测了几组参数:
| 批大小 | 吞吐量(tok/s) | 单请求延迟感受 |
|---|---|---|
| 1 | 28 | 即时 |
| 4 | 38 | 略有等待 |
| 8 | 45 | 明显等待 |
| 16 | 47 | 等待较久,偶发显存告警 |
可以看到,批大小从 1 到 8,吞吐量提升明显;但从 8 到 16,收益就很小了,而且开始出现显存告警。所以最终定在 8。这个值不是通用的,取决于你的显存余量和请求特征。我的建议是从 4 开始,逐步往上加,盯着显存占用和延迟,找到那个“吞吐量还在涨但延迟还能接受”的点。
注意:批处理开启后,如果某个请求的 prompt 特别长,它可能会占满整个批的显存预算,导致其他请求被挤出去。这时候需要设置单请求的最大长度上限,防止一个“巨无霸”请求拖垮整批。
这一阶段做完,速度稳定在 45 tok/s 左右。到这里,常规的优化手段基本用尽了,再想往上走,就得动推理算法本身了。这就是投机解码登场的地方。
4. 投机解码的核心逻辑:为什么小模型能帮大模型加速
4.1 用“草稿+验证”替代逐 token 生成
投机解码的思路,用一句话概括就是:用一个小的“草稿模型”先猜出接下来几个 token,然后让大的“目标模型”一次性验证这些猜测,猜对了就直接采纳,猜错了就纠正。这个思路的巧妙之处在于,它把大模型原本串行的、一次一个 token 的生成过程,变成了“批量验证”,而验证是可以并行做的。
打个比方。大模型像一个谨慎的专家,每写一个字都要深思熟虑,慢但准。草稿模型像一个反应快但水平一般的助手,它先唰唰唰写出一段草稿。专家拿到草稿后,不需要从头重写,只需要快速扫一遍,把不对的地方改掉。因为专家的“扫一遍”比“从头写”快得多,整体效率就上去了。
这里的关键是:草稿模型要足够快,快到它猜好几个 token 的时间,比大模型自己生成一个 token 还短。同时,草稿模型的猜测要足够准,如果它猜十个错九个,那大模型就得频繁纠正,反而更慢。所以草稿模型的选型是投机解码成败的核心。
4.2 草稿模型的选型:不是越小越好
选草稿模型,直觉上会觉得越小越快越好。但实际不是这样。草稿模型太小,猜测准确率低,大模型验证后大量被拒,加速效果差;草稿模型太大,虽然准,但它自己跑起来就慢,抵消了收益。这里存在一个“甜点区”。
我试了几个不同规模的草稿模型,目标模型是 27B:
| 草稿模型规模 | 猜测接受率 | 实测加速比 |
|---|---|---|
| 0.5B | 约 55% | 1.6x |
| 1.5B | 约 72% | 2.4x |
| 3B | 约 80% | 2.8x |
| 7B | 约 85% | 2.1x |
可以看到,3B 左右的草稿模型加速比最高。0.5B 虽然快,但接受率太低;7B 接受率高,但自身开销大,反而拖累了整体。3B 这个档位,接受率 80% 已经不错,自身又足够轻量。最终我选了 3B 级别的草稿模型,并且把它也做了量化,进一步降低开销。
还有一个细节:草稿模型和目标模型的词表要一致。如果词表不同,草稿模型猜出来的 token 目标模型不认识,验证就没法做。所以选草稿模型时,最好选同一个系列、同一个词表的模型,这样兼容性最好。
4.3 猜测长度(Speculative Length)的取舍
投机解码还有一个关键参数:每次让草稿模型猜多少个 token,也就是猜测长度。这个参数直接决定了加速效果和显存开销。
猜得太少,比如每次只猜 2 个,那验证的并行优势发挥不出来,加速有限。猜得太多,比如每次猜 10 个,草稿模型自己就要跑 10 步,而且一旦前面猜错,后面全废,浪费严重。我实测了几组:
| 猜测长度 | 接受率 | 实测速度 |
|---|---|---|
| 2 | 88% | 约 70 tok/s |
| 4 | 80% | 约 110 tok/s |
| 6 | 72% | 约 130 tok/s |
| 8 | 65% | 约 120 tok/s |
| 10 | 58% | 约 105 tok/s |
可以看到,猜测长度在 6 左右时速度最高。长度再往上,接受率下降得太快,反而拖累速度。这个最优值跟草稿模型的质量强相关:草稿模型越准,最优猜测长度可以越大。所以这个参数需要针对你的具体模型组合去试,不能照搬。
提示:猜测长度和批处理会争抢显存。如果同时开了大 batch 和长猜测,显存很容易爆。建议先固定批大小,再调猜测长度。
5. 从 90 tok/s 到 159 tok/s:联合调优的细节
5.1 草稿模型与目标模型的显存共存策略
到了这一步,显存里同时住着两个模型:27B 的目标模型(量化后约 15GB)和 3B 的草稿模型(量化后约 2GB),再加上 KV Cache 和批处理的缓冲,24GB 卡已经相当紧张。这时候显存管理就成了精细活。
我的做法是:目标模型用 Q4_K_S,草稿模型用 Q4_K_M(草稿模型小,量化损失影响不大,但用稍好的量化能提高猜测准确率)。KV Cache 用 Q8。批大小从 8 降到 6,给草稿模型腾地方。上下文保持在 4096。这样一套组合下来,显存占用大概在 22GB 左右,留了 2GB 余量应对峰值。
这里有个经验:草稿模型不要和目标模型放在同一块显存区域反复换入换出。有些框架为了省显存,会在草稿和目标之间来回切换,每次切换都有开销。如果显存够,就让两个模型常驻,速度会稳定很多。
5.2 温度、Top-p 对接受率的隐性影响
这是一个很多人忽略的点:采样参数会影响投机解码的接受率。投机解码的验证过程,本质上是比较草稿模型和目标模型在同一个位置的输出分布。如果采样参数设置得很“激进”,比如温度很高、Top-p 很宽,那么两个模型的输出分布差异会被放大,接受率下降。
我实测发现,把温度从 1.0 降到 0.7,接受率大概能提升 5-8 个百分点。Top-p 从 0.95 降到 0.9,也有类似效果。当然,这会改变输出的“创造性”,对于需要严谨回答的场景(比如代码、事实问答),低温度本来就更合适,所以这个调整是双赢的。但对于需要发散思维的创作场景,就得在速度和多样性之间做取舍了。
5.3 实测数据与稳定性观察
最终配置定下来之后,我跑了一组持续的压力测试,观察速度的稳定性:
| 测试轮次 | 平均速度(tok/s) | 显存峰值 |
|---|---|---|
| 第 1 轮 | 162 | 22.1GB |
| 第 2 轮 | 158 | 22.3GB |
| 第 3 轮 | 159 | 22.2GB |
| 第 4 轮 | 155 | 22.4GB |
| 第 5 轮 | 161 | 22.2GB |
平均下来 159 tok/s,波动在 ±4 tok/s 以内,稳定性可以接受。显存峰值稳定在 22.4GB 以下,没有出现溢出。这个速度相比最初的 14 tok/s,提升了约 11 倍。
不过要说明的是,这个速度是在特定条件下测的:prompt 长度中等(几百 token),输出长度几百 token,批大小 6,猜测长度 6。如果你的 prompt 特别长,prefill 阶段会占更多时间,整体速度会下降;如果输出特别短,投机解码的启动开销占比会上升,加速比也会打折扣。所以这个 159 tok/s 是一个“典型场景”下的数字,不是所有场景都能达到。
6. 调优过程中踩过的坑与排查思路
6.1 速度不升反降:草稿模型选错的典型症状
我一开始图省事,直接拿了一个 0.5B 的小模型当草稿。结果速度不但没提升,反而从 45 tok/s 掉到了 38 tok/s。当时很困惑,后来分析日志才发现,草稿模型的接受率只有 50% 出头,意味着大模型有一半的猜测要推翻重来,而推翻重来的开销比它自己生成还大。
排查这个问题的思路是:先看接受率,再看速度。如果接受率低于 60%,基本可以判定草稿模型不合格,要么换大一点的,要么换一个和目标任务更匹配的。接受率这个指标很多框架会打印,如果没有,可以自己统计“被接受的猜测 token 数 / 总猜测 token 数”。
6.2 显存溢出导致的静默降级
另一个坑是显存溢出。有一次我调大了批大小,程序没报错,但速度突然掉到 20 tok/s。查了半天才发现,框架在显存不足时,自动把一部分层卸载到了内存,导致每次推理都要在内存和显存之间搬数据。这种“静默降级”很坑,因为它不报错,只是变慢。
排查方法是:盯着显存占用和 GPU 利用率。如果显存占用明显低于你的预期(比如只用了 18GB),但速度很慢,那很可能有层被卸载了。这时候要检查框架的日志,看有没有“offload”之类的字样。解决方法是降低批大小或者上下文长度,把显存占用控制在安全范围内。
6.3 猜测长度与批大小的资源争夺
前面提过,猜测长度和批大小都要吃显存。我遇到过一种情况:单独调猜测长度,能到 130 tok/s;单独调批大小,能到 45 tok/s;但两个一起调,速度反而只有 100 tok/s。原因是两者叠加后显存吃紧,框架被迫做了降级。
解决这个问题的办法是固定一个,调另一个。先固定批大小为 4,把猜测长度调到最优;然后固定猜测长度,再慢慢加批大小,直到显存接近上限。这样能找到一个相对最优的组合。我最终的组合是批大小 6、猜测长度 6,是在这个过程中试出来的。
6.4 不同框架参数命名的差异
最后一个坑是参数命名。不同推理框架对同一个功能的参数叫法不一样,比如“GPU 层数”有的叫n_gpu_layers,有的叫gpu_layers,有的叫num_gpu。“投机解码”有的叫speculative,有的叫draft_model,有的叫assisted_generation。如果不注意,很容易设了一个不存在的参数,框架默默忽略,你还以为生效了。
我的建议是:改完参数后,一定要看框架的启动日志,确认参数被正确读取。很多框架会在启动时打印一份配置摘要,对照着看,能避免大部分“参数没生效”的问题。
7. 一些可以复用的经验与后续可探索的方向
整套调优做下来,我最大的体会是:本地推理的速度优化,是一个“先诊断瓶颈,再对症下药”的过程,而不是盲目堆参数。14 tok/s 的时候,瓶颈在显存分配;28 tok/s 的时候,瓶颈在带宽利用;45 tok/s 之后,才轮到算法层面的投机解码。如果顺序搞反了,比如一上来就开投机解码,很可能因为显存不够而失败,然后误以为投机解码没用。
另外,投机解码的收益高度依赖草稿模型的质量。如果你的目标任务很垂直(比如专门做代码生成),可以考虑用一个在这个领域微调过的小模型当草稿,接受率会比通用小模型高不少。这是一个值得尝试的方向。
还有一个可以继续挖的点是动态猜测长度。现在的猜测长度是固定的,但实际上,模型在不同位置的“确定性”是不一样的。在确定性高的位置(比如生成固定格式的内容),可以多猜几个;在确定性低的位置,少猜几个。有些框架已经开始支持这种动态调整,理论上能进一步提升速度。
最后说一句关于预期管理的话。159 tok/s 这个数字听起来很爽,但它是在理想条件下测出来的。实际使用中,受 prompt 长度、输出长度、并发情况的影响,速度会有波动。但即便打个七折,110 tok/s 左右的速度,对于本地 27B 模型来说,已经是相当可用的水平了。从“能跑”到“好用”,这中间的差距,往往就是这些调优细节堆出来的。