
Model-Optimizer是我给自己这个优化项目起的代号起因特别朴素一个7B对话模型在我手里从“跑得通”到“扛得住线上流量”中间隔着的不是一段神奇代码而是一整套有条理的排查、选型和验证流程。这篇文章就是把这套流程完整记录下来包括我为什么做这些优化、具体每一步怎么测、最后拿到了什么收益以及好几件让指标不升反降的蠢事。如果你也要把一个大模型从实验环境搬到生产环境这篇文章应该能帮你少走不少弯路。1. 起源一次部署翻车让我写了Model-Optimizer1.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阶段。所以我从第一天就坚持把两个指标分开记TTFTTime to First Token从请求发出到收到第一个token的时间和TPOTTime Per Output Token每生成一个输出token平均耗时。再加上吞吐量每秒生成token总数这三个指标基本能描述清楚一个推理服务的性能。2.2 我用的测试场景与基线数据为了避免压测失真我固定了一套测试场景输入长度512个token输出长度128个token并发数从4路起步逐步加到16路。这个组合贴近我们内部工具的真实用法——用户抛进来一段长文本模型给出中等长度的回答。基线环境是这样的项目配置模型7B开源对话模型FP16权重硬件单张NVIDIA A1024GB显存推理方式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.2GBTTFT约420ms约360ms约410msTPOT约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.2GSM8K子集34.232.8-1.4HumanEval子集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最让我意外的收获不是那些具体的技术方案而是它逼着我养成了一套做性能优化的习惯。现在接到任何新的模型部署任务我都会条件反射一样先问三个问题瓶颈在哪个阶段指标怎么测一次改几个变量想清楚这三件事再动手基本不会跑偏。