1. 从 14 tok/s 到 159 tok/s:一次本地推理调优的完整复盘
本地跑大模型这件事,最劝退新手的从来不是显存不够,而是那种“明明卡不差,速度却慢得让人想砸键盘”的体验。我手头这套机器,显卡是消费级里比较能打的一张,显存 24GB,内存 64GB,按理说跑一个 27B 级别的量化模型不该太难看。可第一次把 Qwen3.8-27B 的 4-bit 量化版本拉起来的时候,实测输出速度只有14 tok/s左右,长文生成时那种一个字一个字往外蹦的感觉,基本告别了交互式使用。
后来我花了大概两个周末,把推理链路从头到尾拆了一遍,最终把速度稳定在159 tok/s上下,提升超过十倍。这里面最关键的杠杆就是投机解码(Speculative Decoding),但光开一个开关远远不够,配套的草稿模型选型、量化精度、KV Cache 策略、批处理参数都得跟着调。这篇就把整个调优过程完整摊开讲,包括我踩过的坑、试错的中间数据、以及最后那套可以直接抄的配置。
如果你也在本地折腾 27B 级别的模型,被速度卡住过,或者听说过投机解码但一直没搞明白它到底怎么落地,那这篇应该能帮你省下不少试错时间。我会尽量把每个参数背后的“为什么”讲清楚,而不是只丢一堆命令让你照抄。
2. 先搞清楚瓶颈在哪:14 tok/s 到底慢在哪
2.1 本地推理的速度构成拆解
很多人一上来就想着换更快的推理框架,但其实在动手之前,得先弄明白 token 生成的时间到底花在哪。一次自回归生成,每个 token 都要经历两个阶段:Prefill(预填充)和Decode(解码)。Prefill 是把你的输入 prompt 一次性编码进 KV Cache,这一步是并行计算的,通常很快;Decode 是逐个 token 往外吐,每一步都要重新跑一遍完整的注意力计算,这一步是串行的,也是速度瓶颈的主要来源。
在 27B 这个参数量级下,Decode 阶段每生成一个 token,都要把整个模型的权重过一遍。哪怕用了 4-bit 量化,权重体积依然在 14GB 上下,显存带宽就成了硬约束。我实测下来,14 tok/s 对应的单 token 耗时大约是 71ms,这个数字和显存带宽的理论上限基本吻合,说明瓶颈确实卡在显存带宽上,而不是算力。
提示:判断瓶颈在带宽还是算力,有个简单办法——看 GPU 利用率。如果算力利用率长期低于 40%,而显存占用接近满载,那基本就是带宽瓶颈,这时候堆算力没用,得从减少每 token 的数据搬运量入手。
2.2 为什么单纯换框架救不了
我一开始也试过换推理后端,从默认的 transformers 换到更高效的推理引擎,速度确实有提升,但幅度有限,大概从 14 提到了 22 左右。原因很简单:换框架优化的是算子调度和内存复用,但每个 token 必须完整过一遍模型权重这个根本约束没变。只要还是“一个 token 一次前向”,带宽瓶颈就一直在。
真正能打破这个约束的,是让模型一次前向生成多个 token。这就是投机解码的核心思路——用一个小的草稿模型先“猜”出后面几个 token,然后让大模型一次性验证这些猜测。如果猜对了,就相当于一次前向出了好几个 token,带宽利用率直接翻倍。
2.3 投机解码为什么能提速
打个比方,原来的方式是“每写一个字都要翻一遍字典”,投机解码则是“先让一个记性好的人凭记忆默写几个字,再让权威专家一次性校对”。校对是并行的,专家只需要过一遍就能确认对错,猜对的字就直接采纳。草稿模型很小,跑起来飞快,大模型验证时又是批量处理,整体吞吐自然就上去了。
关键参数是接受率(acceptance rate),也就是草稿模型猜的 token 里有多少被大模型认可。接受率越高,提速越明显。我实测下来,接受率从 0.5 提到 0.8,速度能从 60 tok/s 拉到 130 tok/s 以上。所以整个调优的重心,其实就是围绕“怎么把接受率做高”来展开。
3. 投机解码的落地配置:草稿模型怎么选
3.1 草稿模型与大模型的对齐原则
草稿模型不是随便找个小模型就行,它和大模型之间得有“默契”。最理想的情况是两者同源,比如同一个系列、同一个 tokenizer、同一个训练语料分布。这样草稿模型猜出来的 token,大模型认可的概率才高。我一开始图省事,拿了一个完全不同系列的小模型当草稿,结果接受率只有 0.3 出头,提速效果大打折扣,甚至因为验证开销,速度还不如不开。
后来换成同系列的 0.6B 版本,接受率直接跳到 0.7 以上。这里面的道理不复杂:同系列模型的词表一致,对下一个 token 的概率分布也更接近,草稿模型的“直觉”和大模型的“判断”更容易对上。
3.2 草稿模型大小的权衡
草稿模型也不是越小越好。太小了猜不准,接受率低;太大了跑得慢,验证的收益被草稿开销吃掉。我试过 0.6B、1.7B、4B 三个规格,实测数据如下:
| 草稿模型规格 | 草稿单步耗时 | 接受率 | 综合速度 |
|---|---|---|---|
| 0.6B | 约 8ms | 0.72 | 142 tok/s |
| 1.7B | 约 18ms | 0.81 | 159 tok/s |
| 4B | 约 40ms | 0.85 | 118 tok/s |
可以看到,1.7B 是甜点区。4B 虽然接受率更高,但草稿本身太慢,反而拖累了整体。这个结论和很多论文里的经验一致:草稿模型和大模型的比例在 1:15 到 1:20 之间比较合适。27B 配 1.7B,差不多就是 1:16。
3.3 草稿 token 数量的设置
投机解码还有一个关键参数是每轮猜几个 token,通常叫num_speculative_tokens或类似名字。猜得越多,理论上一次前向能出的 token 越多,但猜得越往后,接受率越低,因为误差会累积。我实测下来,这个值设在 4 到 6 之间比较稳。设成 8 的时候,后面几个 token 基本全被拒,反而浪费了草稿计算。
注意:这个参数和任务类型有关。如果是代码生成这种局部规律性强的任务,可以设到 6 甚至 8;如果是开放式创作,建议保守一点,设 4 就行。
4. 量化与显存策略:别让精度拖了后腿
4.1 量化位宽对速度的实际影响
27B 模型想在 24GB 显存里跑,量化是必须的。但量化位宽不只是影响显存占用,还直接影响推理速度。我对比了 4-bit 和 8-bit 两种方案:
| 量化方案 | 显存占用 | 单 token 耗时 | 速度 |
|---|---|---|---|
| 8-bit | 约 27GB | 超出显存 | 无法运行 |
| 4-bit | 约 14GB | 71ms | 14 tok/s |
| 4-bit + 投机 | 约 16GB | 6.3ms | 159 tok/s |
8-bit 直接爆显存,没得选。4-bit 是唯一可行的方案,但要注意,不同量化算法对速度的影响也不一样。我用的是主流的 group-wise 量化,group size 设成 128。group size 越小,精度越高但反量化开销越大;设成 64 时速度掉了大概 8%,设成 128 是速度和精度的平衡点。
4.2 KV Cache 的显存占用计算
开了投机解码之后,KV Cache 的占用会明显上升,因为草稿模型和大模型都要维护自己的缓存,而且验证阶段是批量处理的,缓存要能容纳多个 token。我算过一笔账:27B 模型,32 层,隐藏维度 5120,4-bit 量化下每 token 的 KV Cache 大约是 0.5MB。如果上下文长度设到 8192,光 KV Cache 就要 4GB 左右。
所以显存预算得这么分:模型权重 14GB,KV Cache 4GB,草稿模型 1GB,再加上框架本身的开销 1GB,总共 20GB,24GB 的卡刚好够用,但余量不多。如果上下文要开到 16K,就得考虑用 KV Cache 量化,把缓存压到 2-bit,能省一半空间。
4.3 显存不足时的降级方案
如果显存实在紧张,有几个降级思路。一是把上下文长度砍到 4096,KV Cache 直接减半;二是用分页注意力(Paged Attention)把不活跃的缓存换出到内存;三是把草稿模型也量化到 4-bit,虽然接受率会掉一点,但能省出几百 MB。我试过第三种,接受率从 0.81 掉到 0.78,速度影响不到 5%,但显存宽裕了不少,长上下文跑起来更稳。
5. 完整实操流程:从零到 159 tok/s
5.1 环境准备与依赖安装
先把基础环境搭好。我用的是 Python 3.10,CUDA 12.1,推理框架选的是支持投机解码的主流引擎。安装命令大致如下:
pip install torch==2.1.0 --index-url https://download.pytorch.org/whl/cu121 pip install transformers==4.38.0 pip install accelerate==0.27.0 pip install bitsandbytes==0.42.0这里版本要卡死,尤其是 bitsandbytes 和 CUDA 的对应关系,版本不匹配会直接报错。我踩过一次坑,bitsandbytes 装成了 CPU 版本,结果量化加载时直接崩,排查了半天才发现是安装源的问题。
5.2 模型加载与量化配置
加载大模型时,量化配置要写清楚。核心参数是load_in_4bit=True,bnb_4bit_quant_type="nf4",bnb_4bit_use_double_quant=True。double quant 能再省一点显存,对速度几乎没影响,建议开着。
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig bnb_config = BitsAndBytesConfig( load_in_4bit=True, bnb_4bit_quant_type="nf4", bnb_4bit_compute_dtype=torch.float16, bnb_4bit_use_double_quant=True, ) model = AutoModelForCausalLM.from_pretrained( "Qwen3.8-27B", quantization_config=bnb_config, device_map="auto", trust_remote_code=True, )草稿模型单独加载,不用量化,保持 fp16 就行,因为它本身很小,量化反而增加反量化开销。
5.3 投机解码参数配置
投机解码的配置是重头戏。不同框架的参数名不太一样,但核心就三个:草稿模型路径、每轮猜测 token 数、以及是否启用动态调整。我用的配置是:
speculative_config = { "draft_model": "Qwen3.8-1.7B", "num_speculative_tokens": 5, "dynamic_speculation": True, "min_acceptance_rate": 0.6, }dynamic_speculation这个开关很实用,它会根据实时的接受率动态调整猜测数量。接受率高的时候多猜几个,接受率掉下来就少猜,避免无效计算。我实测下来,开了这个之后,长文本生成的速度波动小了很多,稳定在 150 以上。
5.4 实测数据与调参记录
整个调优过程我记录了几组关键数据,放在一起对比更直观:
| 阶段 | 配置 | 速度 |
|---|---|---|
| 基线 | 4-bit,无投机 | 14 tok/s |
| 换框架 | 高效引擎,无投机 | 22 tok/s |
| 加投机 | 0.6B 草稿,猜 4 个 | 142 tok/s |
| 调草稿 | 1.7B 草稿,猜 5 个 | 159 tok/s |
| 开动态 | 1.7B 草稿,动态猜测 | 156 tok/s(更稳) |
动态猜测的峰值速度略低,但波动小,长文本场景下体验更好。如果追求极限速度,可以关掉动态,固定猜 5 个。
6. 常见问题与排查技巧实录
6.1 接受率上不去的排查思路
接受率低是最常见的问题。排查顺序建议这样:先确认草稿模型和大模型是不是同系列,词表是否一致;再看草稿模型的量化精度,如果草稿也量化了,试着换回 fp16;最后检查温度参数,温度设得太高,采样随机性大,草稿和大模型的分布容易对不上,接受率自然低。我一般把温度控制在 0.7 以下,接受率能稳定在 0.75 以上。
6.2 速度不升反降的情况
有时候开了投机解码,速度反而比不开还慢。这通常是因为草稿模型太大,或者猜测 token 数设得太多,导致验证阶段的开销超过了收益。判断方法很简单:看草稿耗时占总耗时的比例,如果超过 30%,就说明草稿太重了,得换小模型或者减少猜测数量。
6.3 显存溢出与 OOM 处理
OOM 是本地推理的家常便饭。除了前面说的降级方案,还有个技巧是限制批处理大小。投机解码的验证阶段是批量的,如果同时处理多个请求,显存峰值会很高。把 batch size 设成 1,虽然吞吐低一点,但稳定性好很多。另外,max_new_tokens也别设太大,设成 2048 足够应付大多数场景,设太大只会让 KV Cache 无限膨胀。
6.4 长上下文下的性能衰减
上下文一长,速度就会掉,这是注意力计算的固有特性。我实测下来,上下文从 2K 涨到 8K,速度会从 159 掉到 110 左右。缓解办法有两个:一是用滑动窗口注意力,只关注最近的 token;二是定期清理不活跃的 KV Cache。这两个都需要框架支持,如果框架没有,那就只能控制上下文长度,别让它无限增长。
7. 一些实操心得与后续可折腾的方向
调完这一轮,我最大的体会是:投机解码的收益高度依赖草稿模型的质量,而草稿模型的质量又高度依赖它和大模型的对齐程度。同系列、同词表、同训练分布,这三条是接受率的保证。如果手头没有同系列的草稿模型,宁可不开投机,也别硬凑一个不相关的模型,否则纯属浪费算力。
另外,量化精度和速度之间的平衡点,得靠实测来找。别人说 4-bit 够用,不代表你的场景够用;别人说 group size 128 是甜点,你的任务可能 64 更合适。我建议每次只改一个参数,跑一组固定 prompt,记录速度和接受率,慢慢就能摸清自己机器的脾气。
后续我还想试试把草稿模型也做成动态加载,根据任务类型切换不同的草稿模型——代码任务用一个,创作任务用另一个。理论上接受率还能再提一截,不过这套机制搭起来有点复杂,等折腾出结果再另开一篇聊。