☰
大模型推理优化实战:量化、连续批处理与KV Cache如何让7B模型扛住线上流量
2026/9/29 22:47:42 网站建设 项目流程

Model-Optimizer是我给自己这个优化项目起的代号,起因特别朴素:一个7B对话模型在我手里从“跑得通”到“扛得住线上流量”,中间隔着的不是一段神奇代码,而是一整套有条理的排查、选型和验证流程。这篇文章就是把这套流程完整记录下来,包括我为什么做这些优化、具体每一步怎么测、最后拿到了什么收益,以及好几件让指标不升反降的蠢事。如果你也要把一个大模型从实验环境搬到生产环境,这篇文章应该能帮你少走不少弯路。

1. 起源:一次部署翻车让我写了Model-Optimizer

1.1 实验室里“能跑”,线上却直接OOM

项目最初的需求很简单:把一个基于7B参数的开源对话模型接到内部工具里,承担一部分自动化回复的工作。在开发环境里一切都很完美——单卡A10 24GB显存,FP16精度加载模型,随便问几个问题,响应流畅,内容也基本靠谱。我当时心想,这模型开源得真好,部署起来毫无压力。

结果到了压测阶段就翻车了。

模拟真实使用场景,我打了16路并发请求,每路输入大概500到700个token,期望模型输出100到200个token。模型一开始还能勉强响应,紧接着显存占用蹭蹭往上涨,两分钟后进程直接OOM,监控面板上那些请求全部超时。更尴尬的是,前几个请求虽然成功了,但首token延迟已经到了几百毫秒,后面排队的请求越等越久,完全没法用。

复盘的时候我说服自己接受一个事实:模型“能跑”和模型“能扛业务”是两码事。单机单卡能跑通,只说明权重放得下、前向计算没问题;但真实服务要考虑并发、显存峰值、排队延迟,这些在交互式Demo里根本看不出来。这次失败的直接原因就是KV Cache增长速度远超我预期,加上PyTorch默认的显存管理把缓存撑得很大,16路并发时来自前几个请求的中间状态已经把卡吃满了。

1.2 给项目定的三条边界

失败之后我没有急着找优化方案,而是先给Model-Optimizer定了几条规矩,防止自己越做越偏。

第一,不改模型结构,不做重新训练。我手里没有充足的高质量数据做微调,也没有GPU预算反复跑训练实验,所以项目范围锁定在推理部署阶段的优化。

第二,所有优化必须可量化。每次改动之前先记指标,改动之后重新测同一组指标,用数字判断收益,而不是凭感觉。

第三,一次只动一个优化点。量化、批处理、缓存优化这些手段拆开做、逐项验证,避免多个变量叠在一起出了问题根本不知道是谁干的。

这三条边界在后面的实际推进中帮了我大忙。尤其是最后一条,好几次把我从“优化上瘾”的状态里拉了回来——有些手段单独看是好的,组合起来却不一定是加法。

2. 先别动手优化,把基线测准再说

2.1 为什么TTFT和TPOT必须分开测

接手过一个模型服务的人大概都知道,推理不是一个整体过程,而是两个阶段:预填充阶段(Prefill)和逐字生成阶段(Decode)。

Prefill阶段是模型一次性处理你的输入内容,算出每一步的中间状态;这个阶段的时间主要消耗在计算上,是典型的“计算密集型”。Decode阶段则是模型一个字一个字往外吐,每吐一个字都要重新读取一遍全部权重,所以这个阶段的时间消耗在“读取权重”上,属于“访存密集型”。

这两个阶段的瓶颈完全不同,如果只用一个总延迟指标去衡量优化效果,很容易被误导。比如我做INT4量化后,Prefill提速不多,但Decode快了很多。如果只看总响应时间,可能觉得提升不大就放弃了,实际收益全在Decode阶段。

所以我从第一天就坚持把两个指标分开记:TTFT(Time to First Token,从请求发出到收到第一个token的时间)和TPOT(Time Per Output Token,每生成一个输出token平均耗时)。再加上吞吐量(每秒生成token总数),这三个指标基本能描述清楚一个推理服务的性能。

2.2 我用的测试场景与基线数据

为了避免压测失真,我固定了一套测试场景:输入长度512个token,输出长度128个token,并发数从4路起步,逐步加到16路。这个组合贴近我们内部工具的真实用法——用户抛进来一段长文本,模型给出中等长度的回答。

基线环境是这样的:

项目配置
模型7B开源对话模型,FP16权重
硬件单张NVIDIA A10,24GB显存
推理方式PyTorch原生脚本,标准HF Transformers加载
批处理静态批处理,batch size固定为4
量化无
KV CacheFP16,动态分配
测试输入/输出长度输入512 token,输出128 token

在这个配置下我跑出的基线数据如下:

指标基线数值(并发4路)备注
TTFT约420ms输入长度512时
TPOT约68ms约等于每token 14.7个
吞吐量约52 tokens/s整卡聚合
显存峰值约21.8GB已经贴近24GB上限

这个数据本身不是要证明什么,而是给之后所有优化动作提供一个参照系。没有这个参照系,后面的“优化前后对比”就无从谈起。

2.3 从基线里读出的两个瓶颈

基线数据摆出来后,问题的方向其实已经清楚了。

第一个信号是TPOT高达68ms,这说明Decode阶段每个token的生成非常慢。Decode阶段几乎完全被显存带宽卡死,因为每生成一个token都要把整个7B模型的权重从显存里读一遍,14GB的FP16权重就是14GB的读取量。A10的显存带宽大概600GB/s级别,算下来理论极限都不乐观,何况实际还有其它开销。所以第一个优化方向很明确:把权重变小,减少读取量。

第二个信号是显存峰值21.8GB,这在24GB的卡上已经是极限了。我算了一下KV Cache的消耗:7B模型在输入512token、输出128token、4路并发时,大概也需要好几GB的中间缓存,后面如果再叠加并发路数,显存立刻爆炸。所以第二个优化方向也很清晰:把KV Cache的体积打下来,并把它的存储方式改得更紧凑。

到这里,Model-Optimizer的优化大纲基本定了:先做权重量化,再做KV Cache优化和批处理改造。

3. 真正拉开效果差距的三个关键选择

3.1 量化:先动权重,因为解码卡在显存带宽

量化这件事的原理说白了很简单:把本来用16位浮点数存的权重,换成8位甚至4位整数来存,模型体积小了,读取量也就小了。对一个“每生成一个token就要读一遍全部权重”的任务来说,权重小了,Decode自然就快了。

但量化不是一个动作,而是一系列技术的集合。我当时对比了两种主流方案:GPTQ和AWQ。

GPTQ的思路是对每一层权重做基于二阶信息的量化误差补偿,量化完的权重在很多任务上损失很小。AWQ则是通过分析激活值的分布,找出哪一小部分权重更重要,对这些权重单独保留更高精度。

我最后选了GPTQ的INT4方案,原因很实际:vLLM对GPTQ的支持更成熟,加载和推理都是原生接口,不需要我自己处理反量化逻辑。AWQ在有些硬核场景里精度表现更好,但我的核心诉求是快速落地,生态成熟度比纸面精度更重要。

量化后的效果确实符合预期。TTFT只下降了大约15%,因为Prefill阶段本来就是计算密集,权重小了帮助有限;但TPOT从68ms直接降到了30ms左右,Decode提速一倍以上。这里有一个必要提醒:量化会带来精度损失,后文我会专门讲怎么评估这个损失。

3.2 连续批处理:把排队空隙填满

静态批处理的问题在于,一个批次里的所有请求必须等最慢的那个做完才能整体释放,中间大量时间都在空转。想象一下四个人坐在一张桌子前吃饭,每次必须等所有人都吃完才能换下一桌,这显然浪费。

连续批处理(Continuous Batching)的做法是:每完成一个请求就立刻从等待队列里拉一个新请求补进来,计算单元始终被占满。这个技术点在推理引擎里实现难度不低,但对吞吐量的提升是实打实的。

我在Model-Optimizer里直接把推理引擎切到了vLLM,天然支持连续批处理,不用自己造轮子。切完之后同样一批压测请求,吞吐量从52 tokens/s涨到了130多个tokens/s,翻了约2.5倍。这个提升几乎不需要修改任何模型代码,只是把“谁来调度”这件事交给了一个更聪明的引擎。

3.3 KV Cache:量化与分页,一个都不能少

KV Cache是注意力计算过程中产生的中间结果。通俗地说,模型每多看一个token,就要把前文所有token的注意力信息维护一份,方便后续token计算时直接读取。这个缓存的大小跟序列长度成正比,长上下文场景下会疯狂吃显存。

我原本以为KV Cache只是显存压力大,后来发现还有一个更隐蔽的问题:碎片化。就像硬盘用久了会留下很多不连续的小空隙,KV Cache如果按请求动态分配,也会产生大量碎片。传统做法是预先分配一大块连续空间,但如果长度估不准,预分配过头了又浪费。

vLLM的PagedAttention用了一个类似操作系统虚拟内存的思路:把KV Cache切成固定大小的块,按需分配,不需要连续空间。这样做的好处是显存利用率大幅提高,同一个物理显存能塞下更多请求。

在此基础上,我还给KV Cache做了INT8量化。这一步单独看又抠出不少显存,而且对精度影响比权重量化更小,因为KV Cache里的数值分布相对稳定,量化误差更容易被控制。

到这里,Model-Optimizer的核心技术栈已经定型:INT4权重量化 + vLLM连续批处理 + PagedAttention + KV Cache INT8量化。三个手段分别解决“权重读取太慢”“批处理空隙浪费”“缓存占用过大”这三个问题。

4. 数字说话:优化前后对比和精度代价

4.1 延迟与吞吐:从“勉强能用”到“可以上线”

优化做完之后,我重新跑了一遍完全相同的压测场景。为了公平对比,并发控制在4路,保持和基线一致;另外我又多测了16路并发,看看它在更高压力下的表现。

指标基线(FP16,静态批处理)优化后(INT4 + vLLM,并发4)优化后(INT4 + vLLM,并发16)
显存峰值约21.8GB约9.6GB约13.2GB
TTFT约420ms约360ms约410ms
TPOT约68ms约30ms约34ms
单路吞吐约14.7 tokens/s约33 tokens/s约29 tokens/s
聚合吞吐约52 tokens/s约133 tokens/s约210 tokens/s

优化后显存峰值的下降非常明显,从接近24GB的上限降到了10GB上下。这意味着同样的单卡可以支撑更高并发,或者同时跑多个模型实例,这对服务伸缩性的价值比单纯的延迟提升更大。

TTFT的改善没有TPOT那么夸张,符合前面说的Prefill阶段特性。TPOT从68ms降到30ms,意味着用户感知到的“一个字一个字蹦出来”的速度翻了一倍多。聚合吞吐从52涨到133,再到并发拉高后的210,这个数据才真正支撑起了“可以上线”的判断。

4.2 精度影响:不能只看总榜,还要分类目看

量化方案落地后,团队里有人担心精度下降太多。我一开始也觉得INT4多少会把模型“变笨”,但实测结果比预想温和。

我用固定种子和相同采样参数跑了三个不同方向的评测:通用知识类(MMLU子集)、数学推理类(GSM8K子集)、代码生成类(HumanEval子集),对比FP16基线和INT4量化后的表现:

评测集FP16基线得分INT4量化后得分偏差
MMLU子集58.357.1-1.2
GSM8K子集34.232.8-1.4
HumanEval子集22.520.1-2.4

通用知识和数学推理损失大约在1到1.5个点,尚可接受;代码生成损失接近2.4个点,明显更敏感一些。如果跑一些更依赖复杂指令跟随的场景,损失可能会更明显。

这里必须强调一个我在踩坑中学会的教训:评估量化影响时,一定要固定随机种子和采样参数,否则你根本分不清得分的波动是量化造成的,还是Sampling的随机性造成的。我最初对比时默认设置不一致,结果INT4在某些子集上“看起来”比FP16还高,白白激动了好几天。

4.3 除了指标,服务稳定性也变了

延迟指标是优化前和优化后对不上号的。基线的P99(最慢的10%请求)特别不老实,经常有一两个请求被静态批处理里的“慢请求”拖住,整个批次的延迟被拉得很长。切到连续批处理之后,长请求被单独隔离,短请求不用再陪着它一起等,P99长尾明显收窄。

我之前监控面板上做过一个简单统计:优化前一次16并发压测中,P99延迟大约是P50的几倍,长尾严重影响体验;优化后这个倍数下降到一倍多,服务整体看起来“稳定”了很多。

还有一个容易被忽略的增长点:显存下降之后,我可以在同一张卡上部署两个规格更小的模型实例,一个跑通用问答,一个跑专用代码补全,按需路由。这在以前显存接近顶格的时候是根本不敢想的。

5. 踩坑实录:三件让效果倒退的“优化”

5.1 量化后分数暴跌,根因在随机种子

量化评估刚开始的时候,我在某个内部测试集上看到INT4模型的得分比FP16低了将近5个百分点,当时差点把整个量化方案否了。

排查了两天,最后发现罪魁祸首其实是评估脚本里没有固定随机采样参数。两次评估用了不同的temperature和top_p,还用了不同随机种子,导致同一次采样结果差异很大。我重新用完全相同的随机种子、温度、采样方式跑了一遍,把每个评测集跑了三次取均值,损失立刻回落到可以接受的范围。

这是个很低级的错误,但也是一个很常见的错误。任何模型对比测试,如果不锁定随机性,测出来的差距都可能不是模型差异,而是“掷骰子”的差异。

5.2 长请求拖垮短请求,批处理不是无脑开

连续批处理上线之后,我一度以为问题全解决了。结果某天监控里出现了一个奇怪现象:短请求的延迟突然飙升,好几个推理请求排队排到了几十秒。

原因是有一个用户发了一个超长输入,生成长度也设得很大。这个长请求在连续批处理里持续占用KV Cache块,导致后到的短请求虽然计算上很快,但迟迟拿不到足够的缓存空间,全在排队等着。

解决办法分两步:一是在服务配置里限制单请求的最大输入输出长度,比如输出最多2048个token;二是合理设置模型运行时的最大序列长度(max-model-len),让KV Cache的预分配更贴近实际需求。这样长请求可以被提前拒绝或降级,短请求不被拖累。

这个坑告诉我一个道理:连续批处理优化的是“调度空隙”,但调度的前提是资源池要先规划好。如果最大序列长度设得不合理,调度器再怎么聪明也会被资源问题卡住。

5.3 剪枝量化蒸馏一起上,结果谁的问题都查不清

有一次我想尝试极限压缩,把INT4量化、结构化剪枝、知识蒸馏三个手段全部叠在一个模型上。第一版跑出来的结果让我彻底傻眼:评测分数比基线掉了十几个点,有些能力直接消失。

更糟糕的是,当时已经分不清是哪个环节造成的。剪枝可能破坏了某些关键通道,量化可能放大了剪枝引入的误差,蒸馏又因为前两者的影响没有学到位。三个因素搅在一起,根本没有办法定位。

最后我不得不推倒重来:先只做量化并验证,再单独试剪枝,最后才考虑蒸馏。每走一步都重新记录一份基线,确认这步没问题再进下一步。这次教训让我严格执行了最初定的“一次只动一个变量”原则——这条原则不是保守,而是让人保持清醒。

6. Model-Optimizer的下一步:从单机到集群前要想清楚的事

6.1 为什么我不急着上分布式

项目做到这个阶段,有同事建议下一步直接上多卡分布式推理,把模型切成好几份放到多张卡上跑。

我的看法是暂时不做。当前7B模型的INT4版本单张A10已经能支撑两三百tokens/s的聚合吞吐,对我们目前的业务流量来说,单卡余量还很充足。上分布式意味着引入通信开销、带来复杂的调度一致性问题和运维成本,如果吞吐收益并不明显,那就是为了“听起来很厉害”而付出真金白银的运维代价。

我的判断标准很简单:先把单卡的收益榨干,再考虑多卡。单卡能解决80%的场景,没必要让剩下20%的复杂度拖累全局。

6.2 第二阶段准备做的优化方向

Model-Optimizer第二阶段我列了几个方向,都还在实验阶段。

第一是长上下文处理的优化。现在模型最大支持8K上下文,业务里偶尔会有更长的文档需要分析,处理起来捉襟见肘。我在看RoPE外推和稀疏注意力这类技术,希望能把上下文长度往上推,又不让显存和延迟失控。

第二是带上下文的动态精度策略。有些请求文本较短、容错率高,可以继续用INT4拉高吞吐;有些请求是复杂的代码生成或数学推理,对精度更敏感,可以让路由层自动切换到FP16或更保守的量化配置。这个策略本质上是在“速度”和“质量”之间做一个动态调价,而不是用一套配置打天下。

第三是配方文件化。现在这套优化经验散落在笔记、配置和代码里,我想把它固化成一个可复用的配置文件体系——一个仓库里既有模型配置、又有推理引擎参数、又有量化策略说明。新模型接入时,只需要跑一遍配方脚本就能输出一套经过验证的优化参数,而不是每次重新踩一遍坑。

6.3 给想“抄作业”的人一份最小行动清单

如果你也想把自己的模型服务做一次类似优化,我的建议是这么几条:

以我自己的经历来说,Model-Optimizer最让我意外的收获,不是那些具体的技术方案,而是它逼着我养成了一套做性能优化的习惯。现在接到任何新的模型部署任务,我都会条件反射一样先问三个问题:瓶颈在哪个阶段?指标怎么测?一次改几个变量?想清楚这三件事再动手,基本不会跑偏。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询