ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

投机解码实战:27B本地大模型生成速度从14提升到159 token/s

投机解码实战:27B本地大模型生成速度从14提升到159 token/s

最近一直在折腾本地大模型部署,目标很单纯:在不把业务数据送出去的前提下,让一个 27B 参数级别的大模型老老实实帮我做文档改写、总结和润色。模型选的是 Qwen 系列的 27B 档位。第一次跑起来的时候,我心里落差非常大——单用户交互场景,生成速度只有 14 token/s 左右。这个速度意味着,让它写一篇三百字的短文,你要盯着屏幕等差不多半分钟,别提多难受了。

后面我把投机解码(speculative decoding)调通,输出速度直接跑到 159 token/s 的峰值,长期均值也稳定在 100 token/s 以上,直接解决了“等模型像等老式拨号上网”的问题。这篇文章就记录我这次完整调优过程:14 tok/s 是怎么测出来的、投机解码到底是什么、每一步参数怎么调、哪些配置让效果不升反降,给同样在本地跑大模型的朋友一个可以照着抄的参考。适合有两三天本地部署经验、正在被生成速度困扰的开发者。

1. 先说清楚背景:为什么非要和 27B 模型较劲

很多人第一反应是:既然本地这么慢,怎么不直接调 API?我也考虑过。但我这边有三条硬约束,导致本地跑大模型成了刚需。第一是数据敏感,文档里全是客户资料,送出去这件事本身就有合规风险;第二是离线场景,某些环境网络隔离,云端模型根本连不上;第三是成本,长上下文多轮对话如果按字符计费,一个月下来够买一张显卡了。所以方案就变成:找一张消费级显卡,在本地把 27B 模型跑起来。

硬件其实没什么好吹的,一张 24GB 显存的普通消费级显卡,没有上多卡也没有服务器级的显存带宽。模型也不能直接用原版 fp16 权重——27B 参数光是权重就 50 多 GB,必须量化后才能塞进 24GB 显存。我用的 4bit 量化版本,权重落地大约 16GB 左右,剩下接近 6GB 给 KV Cache 和临时计算用。这个配置其实很典型,很多想“一张卡跑大模型”的人最后都会走到这一步。

真正让我难受的不是显存放不下,而是生成速度。模型装好了,简单问一句能跑通,但只要让它多说几句话,基本就是你在屏幕前刷一次牙回来,它刚打了半行字。14 token/s 是什么概念呢?一个汉字在主流分词器里大约对应 1 到 2 个 token,就算往宽了算每秒 10 个汉字,写一段 300 字的总结也要 30 秒。这在交互场景里基本没法用,稍微多改几版我就想弃坑。

1.1 基线数据是怎么测出来的

既然要调优,第一步就是把“慢”量化。我整理了一套固定的测试请求:输入一份大约 600 token 的会议纪要,让它输出 400 token 左右的要点总结。每轮测试跑三次,取中间值,避免偶然波动影响判断。

测试的时候要注意,tok/s 通常统计的是“输出阶段”的速度,也就是第一个 token 出来之后,后续每个 token 的平均生成速率。你不能把首 token 之前的预填充时间也算进去,否则测出来的数字会忽高忽低。比如我把上下文从 2k 调到 8k,首 token 延迟明显变长,但输出阶段的 tok/s 变化不大。所以记录数据时分两列:首 token 延迟、输出速率。我这边初始状态就是首 token 大概 1 到 2 秒,输出速率稳定在 14 tok/s 左右。

把基线钉死之后,接下来就要看为什么快不起来,以及投机解码为什么能起这么大的作用。

2. 投机解码的原理:为什么它能撬动 11 倍速度差

先讲一个反常识的结论:大模型生成慢,瓶颈往往不在算力,而在显存带宽。

自回归模型生成文本时,每产生一个 token,都要把模型权重从头到尾读一遍。拿我这张显卡来说,显存带宽看起来挺高,但 4bit 量化后的模型权重也有 16GB 打底,读一遍权重理论上就要十几毫秒,再加上计算、访存调度、采样这些开销,单个 token 花上 60 到 70 毫秒很正常。一秒钟真正能生成的 token 自然就被钉在 14 到 18 这个区间。算力再猛也没用,因为每个 token 都卡在“必须把权重全读一遍”这道坎上。

投机解码的思路就很有意思:既然读一遍权重要 70ms,那能不能让这一次读取,换回更多 token?答案是找个又小又快的模型,让它先快速“写草稿”,生成一段候选 token;大模型拿到这段草稿后,并行验证,一次前向同时判断这一整段里每个位置的概率。如果草稿里连续几个 token 都被验证通过,那这一次读取就直接换回了多个有效 token。用大白话说,以前是一个字一个字憋,现在是“小助理先写一整段,你再批量审阅”,审一次等于过去写了好几个字。

我一开始担心这个机制会很复杂,实际跑起来之后发现,框架把它封装得相当简单,核心就是多加载一个草稿模型,然后调几个参数。但想拿到理想的加速,必须理解背后三个条件。

2.1 草稿模型必须同时满足三个条件

第一,词表必须和目标模型完全一致。投机解码最怕的就是草稿模型和目标模型各说各话,同一个词在不同词表里的编号都不同,验证根本没法定点比对,速度会断崖式下跌。

第二,草稿模型的输出概率分布要尽量贴近目标模型。如果草稿模型写的建议和目标模型真正想写的方向完全不同,验证时一个 token 都过不了,等于白读了一遍草稿。这就是为什么我优先选同系列的 0.5B 和 1.5B 小模型,它们的“脾气”和 27B 大模型天然更接近,接受率会明显高于跨系列组合。

第三,草稿模型本身要足够小、足够快。它生成 16 个候选 token 的时间必须远远小于大模型验证一次的时间,否则省下来的带宽又被草稿模型的耗时吃回去了。实测中,1.5B 模型已经是我这张卡上的甜点,3B 草稿模型虽然接受率更高,但生成草稿的时间太长,整体收益反而下降。

条件清楚了,接下来就是我当时一步一步操作的完整过程。

3. 调优实操:四步把 14 tok/s 拉到 159 tok/s

调优过程中我把它拆成四步:确认框架支持、选草稿模型、调投机步数、对齐采样参数。很多人只换草稿模型不同步调参数,最后速度没起来,问题就出在后面两步。

3.1 第一步:确认推理框架版本和参数入口

投机解码不是所有推理框架都有,需要确认你用的框架支持这个特性。我当时用的基于 llama.cpp 的本地推理方案,目标是加载主模型,同时额外指定一个草稿模型路径。命令行大致长这样:

./main \ -m /models/qwen-27b-q4_k_m.gguf \ -md /models/qwen-1.5b-q4_k_m.gguf \ -n_draft 16 \ --temp 0.8 \ --top-k 40 \ --top-p 0.9

其中-md就是草稿模型参数,-n_draft是投机步数。如果你用的是别的支持投机解码的框架,参数名可能不同,但核心概念是一样的。这里有个心得:第一次跑的时候,先在日志里确认框架真的识别出了草稿模型,如果识别失败,它通常不会报错,而是静默退回普通模式,光看速度你根本不知道投机解码没生效。

3.2 第二步:挑选合适的草稿模型

这一步我几乎把同系列的 27B、14B、3B、1.5B、0.5B 都试了一遍。你可能好奇为什么还试了 14B 当草稿,理论上接受率会很高,但 14B 模型权重也有 8GB 左右,显存压力大,而且它自己生成候选 token 的速度也不快。

最终建议是:目标模型 27B,草稿模型选同系列的 1.5B 量级最均衡;如果你显存又小又想要低延迟,可以用 0.5B;3B 在部分内容上接受率更高,但收益会被草稿生成时间抵消。一个很重要的细节是草稿模型也最好用和主模型一致的量化格式,甚至可以直接用更低的量化位深,但词表和采样风格必须保持同源。

顺带提一句显存计算:27B 的 4bit 权重约 16GB,1.5B 的 4bit 权重约 1GB 出头,加上 KV Cache 和临时缓冲,在 24GB 显卡上还有 5GB 左右余量。这就是为什么长上下文和投机解码可以同时开,不需要二选一。

3.3 第三步:投机步数到底该设多少

投机步数(draft length)表示草稿模型一次生成多少个候选 token。框架默认值通常很保守,有的只有 4 或 8,实际跑下来收益不明显。我建议从 16 起步,再往 24、32 试,观察两个指标:输出速率和接受率。

为什么不能无限加大?因为步数越大,草稿模型生成完整草稿的耗时就越长,而且草稿越长,后面位置被验证通过的难度也越高,接受率会自然下降。超过某个点以后,多出来的步数只是白白增加延迟。

我的 27B 模型搭配 1.5B 草稿模型,接受率通常在 0.7 到 0.78 之间。此时步数从 8 提到 16,明显看到吞吐往上走;提到 32,接受率会掉到 0.7 以下,虽然偶尔跑出更高的瞬时峰值,但平均速度反而没有再提升多少。所以我最后长期用 16,测试峰值时才用 24 或 32。

3.4 第四步:采样参数必须对齐,否则白调

这一步是很多人忽略的坑。投机解码的验证过程,实际上是对草稿模型生成的候选 token,按照目标模型的概率分布重新筛选。如果两者的 temperature、top-k、top-p 不一样,目标模型眼中的概率分布和草稿模型生成时用的分布对不上,接受率会大幅跳水。

我做过一个对比实验:保持其他不变,把草稿模型的 temperature 从 0.8 改成 0.6,目标模型还是 0.8,结果接受率直接从 0.75 掉到 0.4,输出速度几乎腰斩。原因很简单,高温采样让草稿模型更“发散”,而目标模型在较低温下更“保守”,两者意见分歧变大。

所以最稳妥的做法是让草稿模型沿用和目标模型完全相同的采样参数。如果你开了重复惩罚或者自定义 logits bias,更要同步过去,因为这些修改也会改变输出分布。

4. 实测数据对照:不同配置下的真实差距

口说无凭,我把自己机器上几组典型配置的实测数据整理成了表格。测试环境保持同一张显卡、同一份测试请求,只改草稿模型和投机步数。

配置草稿模型投机步数平均输出速率瞬时峰值接受率显存占用
基线(无投机)无/14 tok/s19 tok/s/18.2 GB
投机方案 A同系列 0.5B1668 tok/s92 tok/s0.6319.0 GB
投机方案 B同系列 1.5B16102 tok/s139 tok/s0.7519.4 GB
投机方案 C同系列 1.5B32105 tok/s159 tok/s0.7219.6 GB

可以看到,从无投机到投机方案 B,平均输出速率从 14 提升到 102 tok/s,提升超过 7 倍;如果只盯着峰值,方案 C 能跑到 159 tok/s。这个数据本身很有冲击力,但我要诚实说明,159 这个数字是在一次输出长度为 500 token 的任务中记录到的瞬时峰值,不是可持续的稳定均值。中位数和均值更接近 100 到 110 tok/s。标题写 159 是为了直观,实际使用请以平均值为准。

4.1 为什么 1.5B 草稿模型会赢过 0.5B

从表格里看,0.5B 草稿模型把速度提升到了 68 tok/s,看起来也不错,但接受率只有 0.63,说明它和小模型的“思路同步率”不够。1.5B 草稿模型体积大了三倍,但权重也只有 1GB 多,生成草稿的耗时仍然远低于目标模型的验证耗时,接受率却高出一截,整体经济性更好。

如果拿生活打比方,0.5B 草稿模型像一个只看了目录的实习生,写出来的建议经常跑偏,大部分需要返工;1.5B 像是跟了导师半年的学生,虽然水平有限,但至少知道导师喜欢什么句式、什么结构,写出来的草稿命中率高很多。返工少,整体效率自然更高。

4.2 159 是怎么出现的,以及它的水分

方案 C 在投机步数 32 的时候,偶尔会看到瞬时吞吐冲到 159 tok/s。原因是一次验证中连续接受了 12 个以上候选 token,等于目标模型只读了一次权重就换回了十几个 token,瞬时效率相当惊人。但为什么平均还是只有 105?因为接受率降到 0.72,有些轮次只接受了三四个 token,甚至更少,这些轮次会把整体拉回平均值。

这里也有一个判断技巧:调优时不要只看吞吐数字,要在日志里打开详细输出,看目标模型每次验证后接受的 token 数。如果这个数经常超过 8,说明草稿模型和大模型配合得很好;如果长期在 2 到 3 徘徊,那就该考虑换草稿模型或调整采样参数了。

5. 常见问题排查与避坑清单

调优过程不会一帆风顺,我前后踩了不少坑,这里整理成一张速查表,方便你直接对照。

现象可能原因解决办法
打开投机解码后速度反而下降草稿模型词表与目标模型不一致换同系列同词表的草稿模型
速度提升不明显,只有一两倍采样参数不一致导致接受率低对齐 temperature、top-k、top-p
显存不足启动失败主模型量化位深太高或投机步数过大降低量化位数、减小投机步数、缩短上下文
首 token 延迟变大投机解码需要先让草稿模型预热接受这部分开销,或使用更小的草稿模型
多轮对话后速度明显回落上下文变长后 KVCache 占用和草稿更新变慢清理历史、限制最大上下文长度
日志里看不到接受率信息框架未真正启用投机解码检查草稿模型参数名是否正确,确认日志输出

5.1 速度不升反降,先查词表

我第一个踩的坑就是直接用了一个跨系列的 1B 小模型当草稿,结果输出速度从 14 掉到 12。当时日志里没有特别明显的报错,但我观察到接受率几乎为 0。后来才意识到两个模型的词表相差太大,草稿生成的高频 token 在目标模型里根本不是大概率选项,验证基本全军覆没。

判断词表是否一致,最直接的办法是看框架日志里加载模型时报出的 tokenizer 信息。如果两个模型的 special token 数量、词表大小完全一致,大概率同源;如果对不上,就不要强行组合。

5.2 OOM 虽然少见,但一定要留缓冲

24GB 显存跑 27B 量化模型,理论上够用,但投机解码会额外加载草稿模型的权重和 KVCache。如果上下文长度调到 16k 以上,同时草稿模型又选了 3B,显存就会非常紧张。

我建议如果 OOM,优先把投机步数调小,而不是急着换更小的草稿模型。因为投机步数直接影响草稿模型 KVCache 的大小,调低一档往往就能解决问题。其次可以缩短 max context,多数场景 8k 上下文已经够用了。

5.3 输出质量异常,大概率不是投机解码的锅

不少朋友担心投机解码会影响生成质量。实际上投机解码只改变生成路径,不改变最终概率分布,理论上质量不会下降。但如果采样参数没对齐,草稿模型在低概率区域输出一些奇怪 token,而目标模型的验证机制又因为数值精度问题放行了少数低分 token,偶尔会导致整段输出风格漂移。

遇到这类问题,先把草稿模型的 temperature 调成和目标完全一致,同时关闭重复惩罚的差异设置。我通常还会把输出样本和目标模型单独跑一遍做对照,如果差异在可接受范围内,就说明投机解码没有引入额外质量损失。

5.4 多轮对话后速度回落,记得看上下文

我一开始没注意,投机解码在长对话里也会出现“越聊越慢”的现象。原因一方面是目标模型的 KVCache 越来越大,另一方面,草稿模型需要参考的上下文也在变长,它自己生成草稿的速度会变慢。

解决方案比较朴实:限制上下文的硬上限,或者每隔几轮主动做一次上下文压缩。不要指望投机解码能解决长上下文的所有性能问题,它更多是优化稳态生成,而不是扩大模型记忆。

6. 冷静看待 159:哪些场景适合投机解码,哪些不适合

调优到这一步,我已经很满意了,但仍要泼一点冷水。投机解码不是万灵药,它适合“生成很长内容”的场景,比如长文总结、日志分析、代码注释生成。在这些场景下,草稿模型有机会连续命中,收益最明显。

但如果你只是做一次“你好”或“请翻译这个词”这种短回复,第一 token 的等待时间和投机解码帮忙省下的平均吞吐无关,甚至因为草稿模型预热,首 token 延迟反而更大。所以不要一看到十倍加速就盲目给所有场景开启投机解码,先评估你的请求是偏长还是偏短、是流式输出还是整段生成。

另外,显存实在紧张的情况下,投机解码不一定划算。如果你连主模型都只能勉强塞进显存,再强行加载草稿模型只会让可用的 KVCache 缩水,导致有效上下文长度下降。这时候优先考虑减少上下文长度或者降低草稿模型大小,而不是硬开。

从 14 到 159,数据摆在这里,但我更想说的是:调优不是一次性工作。模型版本换了、量化方案改了、上下文需求变了,投机解码的最佳参数都会跟着变。我现在会在每次调整后跑一遍固定测试脚本,用同样的提示词对比接受率和平均 tok/s。只有把基线数据存下来,每次改动才能有据可依。

这次折腾给我最大的感受是,本地大模型并非跑不动,而是要用对症下药的方式去解决问题。如果你遇到类似的慢速困扰,不妨先找到基线数据,再试试投机解码,按 16 起步、对齐采样参数、观察接受率这三步走,大概率能收获远超预期的效果。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进