看到“Qwen3.8-27B 本地推理实测:从 14 tok/s 到 159 tok/s 的投机解码调优”这个标题,我先说句实话:27B模型放到本地跑,默认配置往往让人怀疑人生。我这次花了一整天,把 Qwen3.8-27B 塞进一张24GB显存的GPU,从最初只有 14 tok/s 的“能跑但没法用”,一路调到了峰值 159 tok/s 的“日常顺手”。中间真正起决定性作用的,就是投机解码(speculative decoding)这套思路。如果你也正在为本地大模型推理速度犯愁,又不想一上来就动框架底层、写自定义算子,那这篇文章就是给你准备的。
我先交代清楚:这不是某个特殊测试台上的极限超频,而是普通玩家手里的一张卡、一套开源推理引擎、两个模型文件就能复现的路径。下面我会把每一步的思考过程、参数调整依据、以及我踩过的坑都摊开讲。159 只是峰值,日常保持在 90 到 110 tok/s 就已经非常够用了,但你得先知道峰值从哪来,才能稳定复现它。
1. 为什么开局只有 14 tok/s:基线是怎么跑出来的
1.1 测试环境与第一个坑
我的环境不算豪华:一张24GB显存的GPU,CPU是普通桌面级,内存64GB。模型文件用的是社区常见的GGUF量化格式,初始配置是 int8 量化、上下文长度 8192、单并发请求、每次生成 128 个 token。
这套配置叠在一起,第一次测速结果就是 14 tok/s。当时生成一段 100 个字的回复,肉眼可见地一个字一个字往外蹦,体感跟拨号上网差不多。很多人的第一反应是“27B模型本地跑不动”,其实这个判断太早了。14 tok/s 是“默认参数”跑出来的,不代表硬件上限,更不代表“调优后”的水平。
第一个坑出现在上下文长度上。我一开始图省事,按引擎默认值把 KV cache 留到了 8192,结果一个请求还没跑完,显存就报警了。后来我把上下文需求改成按需分配,给 cache 预留降下来,顺便把 int8 换成 int4,速度才勉强到了 24 到 30 tok/s。这个过程不少人忽略:推理引擎的显存分配策略,会影响 decode 阶段对 KV cache 的读取量,而这个读取量是直接叠加在权重读取开销上的。
1.2 为什么 decode 快不起来:带宽单点瓶颈
大模型生成时,真正耗时的不是“思考”,而是“搬运”。自回归解码的每一步,都要把模型全部权重从显存搬到计算单元。以 Qwen3.8-27B 为例,int8 量化后权重文件大约是 27GB,int4 大约是 17GB。GPU 的显存带宽哪怕有 1TB/s 到 1.5TB/s,理论上一秒钟最多只能把 27GB 的权重完整过一遍 37 到 55 次,也就是说单请求 decode 的理论峰值也就 37 到 55 tok/s 上下。
而这个理论值还要被三件事拖后腿:一是每一步都要读取 KV cache,上下文越长,读完权重之后还要额外读几 GB 的 cache;二是引擎的算子调动、内存拷贝、日志输出等琐碎开销;三是单请求场景下计算单元利用率极低,GPU 大部分晶体管都在空转,等着权重慢慢搬过来。
所以 14 tok/s 不代表硬件差,只代表“每一步都把 27GB 权重 + 一堆 cache 从头读一遍”这个模式本身很低效。那怎么破?要么减小单次读取的字节数,也就是量化;要么让一次读取能换来更多 token,也就是投机解码。这两个方向,后者带来的提升远大于前者。
1.3 我为什么没把 14 tok/s 当成“就这样了”
很多人跑出一个慢吞吞的速度之后就直接放弃了,理由是“模型太大了”。但我的看法是:本地推理速度的瓶颈,大部分不在模型本身,而在“推理方式符不符合硬件的脾气”。27B 模型在消费级显存上是能住的,int4 之后权重只有 17GB,24GB 的卡还留得出 5GB 给 KV cache 和临时缓冲。
接下来的调优思路很清晰:先把显存里那些“读一次没用完”的数据管好,再用投机解码让每一次权重读取都生产出更多有效 token。前者让我拿到了 30 tok/s 左右的中间值,后者才真正把数字推到了三位数。这一步的结论是:不要被基线数字吓住,先问清楚基线是怎么测出来的。
2. 投机解码到底在干什么:草稿模型、拒绝采样与理论提速账
2.1 从“一行一行写”到“老师批改作业”:投机解码的直觉
理解投机解码,不需要一上来啃论文。你就想象一个大模型是一个逐字批改作文的老师,平时每看一个字才批注一个字,速度当然慢。投机解码的做法,是让一个写得很快但水平较低的助教先写出一段草稿,老师拿到这段草稿后一次性扫完,把正确的地方放行,把不对的地方当场改掉。
那个“写得快但水平低”的助教,就是草稿模型,通常是一个小很多、部署成本极低的模型。老师就是 Qwen3.8-27B 本体,它做的事情从“一个字一个字写”变成了“一段字一次性审核”。审核通过的内容越多,加速比越大。
这里有个关键点:草稿模型不是用来“替代”大模型的,它只是负责提出候选 token。最终输出由大模型校验决定,所以投机解码不会让生成质量明显下降,更不会让权重大模型“偷懒”。
2.2 一次验证 forward 为什么能“变出”多个 token
很多人以为验证 N 个草稿 token 要跑 N 次前向计算,那就没有加速效果了。实际上,目标模型只需要跑一次 forward,输入的序列是“当前已生成的 token + 草稿模型给出的 N 个候选 token”。这 N+1 个位置的预测可以并行算出来,所有位置共享同一套权重。
GPU 做矩阵乘法的特点是:一次处理的 token 越多,每个 token 摊到的边际成本越低。权重从显存读一次,同时给 5 个位置打分,跟只给 1 个位置打分,读取权重的开销几乎一样。也就是说,投机解码的核心是把“读一次权重只换一个 token”变成“读一次权重换一批 token”。
当然,收到候选序列后有个校验步骤:对于草稿给出的每个 token,如果目标模型的概率足够高,就收下;如果某个位置目标模型不太同意,就从目标模型的分布里重新采样一个 token,然后把“被拒绝的位置”之后的草稿全部作废,从这个 token 重启下一轮。这个过程叫拒绝采样,它保证了生成分布与直接跑大模型在理论上完全一致。
2.3 决定加速比的三件事
我把投机解码的加速比总结成一个粗略公式:
加速比 ≈ 平均接受长度 ÷(草稿生成耗时 + 目标模型单次验证耗时)
平均接受长度,就是每轮草稿里真正被大模型认可的 token 个数。如果草稿模型很强、经常能猜对大模型会写的 token,平均接受长度就会高,比如一猜一个准,每轮接受 4 个 token,那么即使算上验证开销,理论收益也有三到四倍。如果草稿模型和大模型水平差距太大,每轮只接受 1 到 2 个 token,那么省下的次数不够抵消小草稿自己的生成成本,可能还不如直接跑大模型。
第二个变量是草稿长度。草稿越长,理论上能接受的 token 上限越高,但目标模型验证时处理的序列也越长,前向计算量相应增加,显存临时占用也上升。后面实测会看到,这个参数不是越大越好。
第三个变量是验证开销。目标模型的单次 forward 要做的工作,跟草稿长度成正比,但收益是“每个被接受 token 省掉了一次完整权重读取”。只要平均接受长度大于“草稿生成成本折算系数”,投机解码就是赚的。
以上就是投机解码的全部原理,剩下的就是参数和工程细节。
3. 从 14 到 80:投机解码最初几个小时的调优路线
3.1 第一步:选对草稿模型
投机解码的第一步,选草稿模型。我一开始随便找了个 1B 级别的通用小模型,结果接受率很低,平均每轮只能接受 1.3 个 token,速度不升反降。后来换了同家族同分词器的 0.6B 级模型,接受率立刻上来了。
原因是:草稿模型的分布越接近目标模型,被接受的概率越高。同家族模型在预训练数据、tokenizer、指令风格上高度一致,理解起来像是“同一个师傅教出来的师弟”,自然容易猜到师兄下一步会写什么。跨家族的小模型哪怕智商不差,用词习惯不对路,猜中的概率就低。
选草稿还有个硬性条件:分词器必须和目标模型兼容。否则候选 token 跟目标模型的词表对不上,验证过程会出诡异问题,生成内容也可能乱掉。这是投机解码里最容易踩、也最容易排查的坑。
3.2 第二步:采样参数必须“两边一致”
这里是我这次调优中收益最大、也最容易被人忽略的一环。投机解码验证时,目标模型是拿“草稿 token 序列”计算概率的,但它计算时用的采样参数,比如 temperature、top_p、top_k、repetition_penalty,必须与草稿模型生成候选用的一致。
我第一次开投机解码时,目标模型用的是默认 temperature=0.7,而草稿模型那边没显式传参,结果引擎拿默认值跑。两边采样策略不同,草稿模型的候选序列看起来就“不对劲”,接受率掉到 40% 以下。把所有采样参数显式设置成同一组值之后,接受率一下子就回到 75% 以上。
为什么这么敏感?因为拒绝采样算法在决定是否接受某个草稿 token 时,比较的是目标模型和草稿模型在同一时刻、同一上下文、同一采样语境下给出的概率。如果双方语境都不一样,概率比较就失去了意义。这不只是正确性问题,更是性能问题。
3.3 第三步:draft token 数量的黄金区间
草稿长度是个宝藏参数,我从 2 开始试,一路加到 12。实测曲线很有意思:草稿长度 2 到 6 之间,tok/s 稳定上升;到 8 以后开始平台期;到 12 时速度反而掉回去了。
原因是:草稿长,单轮可能接受的 token 上限高,但目标模型验证时要处理更长的序列,前向计算量增加;同时草稿模型自己也要花更多时间生成候选。当边际收益小于边际成本时,加速比就开始倒挂。我的 159 tok/s 峰值出现在 draft=6 的配置下,接受率是 78.4%,平均接受长度大约 4.7 个 token。这个 4.7 与草稿长度 6 的比值,就是每一轮浪费掉 1.3 个 token 的“空转成本”。
3.4 这一步测到的数据
这阶段我从基线一路试出来的数据如下:
| 阶段 | 关键配置 | 平均 tok/s | 备注 |
|---|---|---|---|
| 1 | 默认 int8、长上下文、无投机解码 | 14 | 就是开局基线 |
| 2 | int4 量化、KV cache 按需分配 | 24 ~ 30 | 单纯优化显存搬运 |
| 3 | 开启投机解码 draft=2,草稿 0.6B | 42 ~ 48 | 草稿太短,加速有限 |
| 4 | draft=4,统一采样参数 | 61 ~ 68 | 接受率明显上升 |
| 5 | draft=6,统一采样参数、草稿 int8 | 86 ~ 95 | 日常最稳区间 |
| 6 | draft=6 + 预热 + 短输出 + 验证窗口复用 | 120 ~ 159 | 峰值区间 |
从 14 到 80 这个跳跃,主要靠的就是第四、第五阶段:选对草稿模型、统一采样参数、把草稿长度调到黄金区间。这里面的每一秒提升都不是靠运气,而是靠把公式里的每一项都抠到合理值。
4. 从 80 到 159:把同样的原理“抠”出更多性能
4.1 草稿模型也能量化,但别一次压太狠
目标模型用 int4,草稿模型能省则省?我一开始把草稿模型也压成 int4,结果接受率掉了 10 个百分点,速度反而退回到 70 左右。原因是:草稿模型虽然小,但它在生成候选序列时,任何微小的概率变化都会传导到目标模型的接受判断上。草稿被过度量化后,候选质量下降,节省的那点显存带宽根本补不回被拒绝 token 的损失。
我最终把草稿模型放在 int8、权重约 0.8GB 左右,效果最好。这个档位既保留了草稿模型的分布精度,又不会占用太多显存。
4.2 KV cache 预热与验证窗口复用
这一步属于工程细节,但对最终峰值影响很大。投机解码的验证 forward,需要在每一轮额外处理一大段草稿 token,这部分 KV cache 的读写频率很高。如果引擎每轮都重新分配临时 KV 块,会频繁触发显存分配和释放,拖慢整体速度。
我的做法是:正式测速前,先用一个短 prompt 连续跑 10 轮,让显存池完成预热;然后在同一个会话里连续生成,验证窗口所需的 KV 尽量复用上一轮的缓冲区。这个操作看着不高级,实测却能带来 15% 到 20% 的额外提升。说白了,投机解码不是把性能变出来的,而是把原来浪费在内存管理上的时间匀给了有效计算。
4.3 三种场景下的行为差异:短输出、长输出、多请求
159 这个峰值,是在“短输出 + 单请求 + 短上下文已预热”的理想条件下测到的。换到真实使用场景,数字会明显回落,但依然比基线强很多。我分别测了三种情况:
- 生成 64 token 的短回复:单请求,上下文约 500 token,峰值 159 tok/s。
- 生成 512 token 的长文:单请求,上下文已增长到 4000 以上,速度降到 88 ~ 95 tok/s。原因是长了之后 KV cache 读取开销变大,验证窗口里可提速的空间被稀释。
- 并发 8 个请求:平均每请求约 75 tok/s,总吞吐反而不错,因为并发时 GPU 计算单元利用率上来了。
这说明投机解码最擅长的是“单请求、短间隔、快响应”的场景,比如聊天、问答、代码补全的短片段。如果要做离线批处理,多并发连续批的收益主要是吞吐量,而单请求延迟的提升没短任务那么惊艳。
4.4 峰值 159 tok/s 的完整复现清单
如果你也想复现类似的峰值,我把关键参数列成清单,你拿自己的引擎对比一下,缺哪个补哪个:
| 参数 | 我的最终取值 | 作用 |
|---|---|---|
| 目标模型量化 | int4 | 减少单次权重读取量 |
| 草稿模型 | 同家族 0.6B 级 | 分布接近、分词器兼容 |
| 草稿模型量化 | int8 | 保证候选质量 |
| draft_tokens | 6 | 平衡单轮收益与验证开销 |
| temperature | 0.7 | 两侧显式一致 |
| top_p | 0.9 | 两侧显式一致 |
| top_k | 40 | 两侧显式一致 |
| KV cache | 按需分配、预热后使用 | 减少显存碎片和分配开销 |
| 上下文长度 | 500 以内短 prompt | 降低 cache 读取影响 |
| 生成长度 | 64 token 短输出 | 避开长上下文拖累 |
配置好了之后,我用类似下面这种伪代码流程做测速验证:
# 伪代码:投机解码测速框架,具体 API 按引擎替换 model = load_target(model_path="Qwen3.8-27B-int4.gguf") draft = load_draft(draft_path="draft-0.6B-int8.gguf") sampling = { "temperature": 0.7, "top_p": 0.9, "top_k": 40, "repetition_penalty": 1.0, } engine = InferenceEngine(model, draft, sampling) engine.warmup(prompt="你好", times=10) # KV cache 预热 prompt = "用三句话介绍本地大模型推理的性能调优" tokens_generated = engine.generate(prompt, max_length=64, draft_length=6) measured_speed = engine.measure_speed(prompt, max_length=64, repeat=5) print(measured_speed) # 取 5 次中位数,不取最好成绩这一段流程跑下来,我在短任务下稳定踩在 120 到 159 之间。159 是可复现的,但必须是同样的短上下文、同样的草稿模型、同样的采样参数。
5. 投机解码翻车的三种典型情况与我的检查清单
5.1 加速比反而小于 1:草稿模型拖后腿
如果你开了投机解码,速度不升反降,十有八九是草稿模型选择出了问题。我踩过两种error:第一种是草稿模型太大,比如直接拿了一个 7B 模型当草稿,它自己生成候选就慢,目标模型验证再快也补不过来;第二种是草稿模型跟目标模型分布差太远,平均接受长度低于 1.5,等于每轮都在做无用功。
修法是回归理性:草稿模型尽量选目标模型的同家族最小号,先用“接受率”这个指标卡门槛,不要先看 tok/s。接受率低于 60%,先不要优化别的,回去换草稿模型或调采样参数。
5.2 生成内容与基线对不上:先检查采样配置,再检查随机种子
投机解码理论上应该保持和直接解码相同的生成分布,但实测中偶尔会遇到“开了投机解码后输出跟基线不一致”的情况。我的排查顺序是:
- 先检查两侧采样参数是否完全一致:temperature、top_p、top_k、repetition_penalty 全部显式传入,不允许走默认值。
- 再检查草稿模型是否真的和目标模型共享分词器:不一致时,候选 token 的索引会出现错位。
- 最后检查随机种子:如果你不是用 argmax 采样,而是带随机性的采样,那么哪怕一切配置相同,两次生成的文本本来就应该不同。要做对比,就在同一随机种子下跑。
我实测中遇到的绝大多数“不一致”,最后都是采样参数没对齐造成的,不是投机解码本身的质量损耗。
5.3 显存突然飙升、验证时 OOM
投机解码开启后,目标模型验证时要把整个草稿序列塞进一次 forward,这会产生额外的显存开销,尤其是 KV cache 临时块。如果你把上下文长度和 KV cache 预分配调得过大,再把草稿长度拉到 8 以上,24GB 显存的卡很容易在验证阶段直接 OOM。
我的经验是:开启投机解码前,先看引擎日志里 KV cache 的峰值占用,把预分配压到足够日常使用即可;草稿长度超过 6 后,每加 1,都要重新评估显存余量。宁可草稿短一点,也别让验证 forward 把显存打爆。
5.4 我每次调优前都会过一遍的检查清单
| 检查项 | 通过标准 |
|---|---|
| 草稿模型与目标模型是否同家族 | 是,分词器一致 |
| 草稿模型量化档位 | int8 或 fp16,不低于 int8 |
| 采样参数是否显式统一 | 两侧完全一致 |
| draft_tokens 是否在黄金区间 | 4 到 6,先小后大 |
| KV cache 是否预留验证窗口 | 预留 2 倍草稿长度的临时空间 |
| 测速是否预热 | 连续跑 10 轮后再记录 |
| 随机种子是否需要固定 | 做对比测试时固定 |
这套清单不仅能帮我快速排掉 80% 的问题,也让每次调优变得有据可查。
最后再分享一个小技巧:测速的时候别只看最好成绩,也别看第一次成绩。我会拿同一个 prompt 连续测 5 次,取中位数作为参考值。因为投机解码的耗时会随接受率波动,单次成绩受显存预热、GPU 频率调度等因素影响很大。159 这个数字是短任务好状态下的峰值,日常我真正依赖的其实是 90 到 110 tok/s 这个区间。投机解码不是魔法,它只是把 GPU 每次搬权重时浪费的带宽,尽可能多地换成了有用的 token。