☰
从0.4到42 QPS:开源GPT私有部署推理性能优化实战
2026/10/22 8:47:22 网站建设 项目流程

年初团队接了个任务:把开源的 GPT 系大模型做私有化部署,跑成内部服务给几个业务系统调用。模型本身两天就起来了,HTTP 接口也能正常出结果,但性能惨到没法看——单条请求要等二十多秒,十个并发一起来就开始排队,压力再大一点直接 OOM。就是在那段时间,我们把“让 GPT OSS 私有部署的推理性能拉满”当成硬目标,前后折腾了大半个月,最终把系统吞吐从每秒 0.4 个请求干到了每秒 42 个请求,一百多倍。这篇先讲上篇:为什么你的推理服务那么慢、怎么量化自己的瓶颈、怎么通过权重精度、KV Cache 和推理引擎三板斧把性能拉起来。

这里的“GPT”是生成式预训练 Transformer 架构的通用说法,OSS 就是 Open Source Software(开源软件)的缩写。市面上已经有很多开源 GPT 架构大模型,我下面统一叫“某开源GPT模型”,避免具体名称干扰正文。这个教程适合已经把模型跑起来、但觉得响应速度不可接受的人,不适合完全没部署过模型的新手。硬件方面,我建议至少有一块显存 24GB 以上的企业级 GPU,普通开发机上做性能对比会很容易被 IO 或散热干扰。

1. 为什么私有部署的推理性能会差到“想砸键盘”

1.1 先搞懂推理的计时到底花在哪

大模型推理不是“算一次就完事”,它分成两个阶段。第一个阶段叫预填充,用户输入的一整段 prompt 会一次性喂给模型,模型逐层计算,产出一个带注意力缓存的中间状态,这个阶段是典型的计算密集。第二个阶段叫解码,模型一个 token 一个 token 地往外蹦,每一步都依赖前面所有 token,并且每生成一个 token 都要把全部权重从显存搬运到计算单元。

这就是关键:解码阶段是访存密集,不是计算密集。拿 7B 参数量、FP16 精度的模型来说,权重就有约 14GB。每次生成一个 token,都要把这 14GB 数据读一遍。主流显卡的显存带宽大概在 1~2TB/s,所以单个 token 的下限延迟基本就是十几毫秒。换句话说,模型越大、精度越高,单 token 延迟的物理地板就越低,你再怎么改算法也突破不了这条带宽天花板。

可以这么理解:计算单元是大厨,显存是山脚下的仓库,权重是食材。大厨炒一个菜可能只要一秒,但每炒一个菜都要把食材从山脚运上来,运货速度才是真正的瓶颈。我们想做性能优化,核心思路就是减少“搬运量”。

1.2 并发一多就崩,问题出在调度和显存

如果你只是用模型加载工具写个简单的 HTTP 服务,通常没有批处理,也没有并发调度。一个请求来了,独占 GPU 跑解码;它跑完,下一个请求才接着上。这就像只有一条流水线,一个工件加工完才轮到下一个,GPU 的算力在大部分时间里其实是空转的,因为计算单元一直在等显存搬运。

就算你加了静态批处理,把几个请求攒成一批一起跑,也会遇到另一个坑:只要这批里有一个请求特别长,整批都要等它结束才能腾出资源。GPU 吞吐上去了,但单个请求的延迟会被拖得很高,用户体验反而变差。

显存碎片则是第三个隐患。每个请求在解码时会动态申请 KV Cache 空间,申请释放、释放申请,时间一长,显存里到处都是小碎片,明明总剩余容量够,新请求却找不到连续大块空间,于是 OOM。很多团队把“并发 6 个请求就崩”归结为显存太小,实际上多半是调度和分配策略的问题。

1.3 100 倍不是数学幻觉,把优化项拆开算账

先给结论:我们最终实现的“100 倍”,指的是系统吞吐,也就是单位时间内能完成的请求数量。单请求延迟没那么夸张,只有 4 到 5 倍左右的提升。面向服务的场景,单请求体验和系统吞吐要分开看,下面这张表是我优化前做的大致拆解:

优化手段理论提升量级说明
权重精度从 FP16 降到 INT42~4 倍权重复制量减少,解码带宽瓶颈松绑
引入连续批处理5~20 倍从串行排队变成 GPU 流水线满载
KV Cache 页式管理2~3 倍主要是提高显存利用率和并发上限
推理引擎算子融合与图优化1.5~2 倍减少 Python 层调度和中间数据写回
多实例/多卡并行2~4 倍下篇会讲,是进一步扩展的手段

这些优化不是简单的加法,而是乘法关系。但也要注意,瓶颈会在不同阶段之间转移:先解决 CPU 调度,再解决显存带宽,再解决显存容量。每一步做到位后,下一步的瓶颈才会暴露出来。所以不要指望单一手段能带来 100 倍,它是组合拳的结果。

2. 优化前必须做好的三件套:基线、压测和对照实验

2.1 先把三个指标念明白

动手优化之前,必须把“慢”量化成数字。我建议你至少盯住三个指标。

首 token 延迟(TTFT)指的是用户发出请求到模型吐出第一个 token 的时间。它影响“首字响应”的体感,主要由预填充阶段决定。单 token 延迟(TPOT)指的是生成阶段每个 token 的平均耗时,它乘以生成长度就是你等待完整回复的时间。吞吐量则是一个时间段内完成的请求数或 token 数,用来衡量系统扛并发的能力。

这三个指标常常是矛盾的:提高了吞吐,单个请求的延迟可能会上升;降低了精度,延迟下去了但质量可能变差。优化前先确定你的业务到底看重哪个指标。如果做内部聊天助手,TTFT 和 TPOT 更重要;如果做离线批量分析,吞吐量更重要。

2.2 写一个简单的压测脚本,把基线数据打出来

不要用浏览器手动点接口,也不要只看一条请求的时间,那样得不出有效结论。我建议压测脚本固定 prompt 长度和生成长度。固定 prompt 长度是因为输入越长,预填充阶段越耗时;固定生成长度则避免模型自行决定结束位置导致指标不可比。

下面这个脚本用 Python 标准库实现并发压测,逻辑很简单:开 N 个线程,每个线程反复请求接口,记录首 token 延迟和总持续时间,最后汇总平均、最大、最小值和吞吐。你可以根据自己的请求格式改一发。

import json import time import urllib.request from concurrent.futures import ThreadPoolExecutor API_URL = "http://127.0.0.1:8000/v1/completions" PROMPT = "请用200字以内的篇幅,介绍一下大语言模型推理优化的基本思路。" MAX_TOKENS = 256 TOTAL_REQUESTS = 40 CONCURRENCY = 8 def single_request(idx): payload = json.dumps({ "prompt": PROMPT, "max_tokens": MAX_TOKENS, "temperature": 0.1 }).encode("utf-8") req = urllib.request.Request(API_URL, data=payload, headers={"Content-Type": "application/json"}) start = time.time() with urllib.request.urlopen(req, timeout=120) as resp: body = json.loads(resp.read().decode("utf-8")) total = time.time() - start # 如果有返回 token 数或首 token 时间戳,这里可以详细统计 text = body.get("choices", [{}])[0].get("text", "") return total, len(text) def run(): t0 = time.time() with ThreadPoolExecutor(max_workers=CONCURRENCY) as pool: results = list(pool.map(single_request, range(TOTAL_REQUESTS))) wall = time.time() - t0 completed = len(results) print(f"完成请求数: {completed}") print(f"总耗时: {wall:.2f}s") print(f"平均单请求耗时: {sum(r[0] for r in results)/completed:.2f}s") print(f"总吞吐QPS: {completed/wall:.3f}") if __name__ == "__main__": run()

如果你用的接口是流式输出,最好在客户端按事件解析首 token 时间;否则你测到的 TTFT 会和网络缓冲混在一起。压测的时候记得把模型侧日志关掉,日志输出是写磁盘,一条两条不明显,并发一高就会严重拖慢整体速度。

2.3 对照实验的三条铁律,少一条结果都不可信

第一条铁律:一次只改一个变量。很多同学喜欢同时升级引擎、改量化、调并发,最后性能确实涨了,但说不清是谁的功劳。后续某个参数回滚时也会非常痛苦。第二条铁律:测试集必须固定且跑足时间。并发测试至少要跑 60 秒以上,短时间压测很容易遇到缓存热效应,数字虚高。每轮测试最好重复三到五次取中位数,别用平均值,平均值会被个别极慢请求带偏。第三条铁律:记录环境和模型的状态。GPU 温度直接影响频率,热的卡和冷的卡性能差很多。每次跑压测前,先让一张空卡“预热”几分钟,等温度稳定了再测。我们最初没注意这点,前后两天同样的配置 QPS 差了 20%,排查好久才发现是机房温度变了。

3. 第一个提速开关:把权重精度降到“刚刚好”

3.1 精度到底影响了什么,不是你想象中那么简单

先看一张表,明确不同精度下 7B 模型的权重占多大空间:

精度每个权重占字节数7B 模型权重显存解码时每步理论搬运量
FP324约 28GB高
BF16/FP162约 14GB中
INT81约 7GB低
INT40.5约 3.5GB更低

解码阶段每生成一个 token 都要把所有权重读一遍,所以权重体积直接决定了解码速度的物理上限。把 FP16 换成 INT4,理论上搬运量减到约四分之一,单 token 延迟会显著下降。同时,权重腾出来的显存可以给 KV Cache 和批处理用,并发能力也跟着上来。

但它不是免费的。低精度带来的误差会在层与层之间传播,尤其在长文本生成时误差会累积,可能出现内容空洞、重复啰嗦甚至乱码。另外,低精度推理对 GPU 有要求,部分消费级显卡对 INT4 算子的加速效果一般,降精度后可能只快了一点点,甚至因为反量化开销不降反升。这也是我强调“刚刚好”的原因,不是越低越好,得拿你的业务数据来验证。

3.2 量化方案怎么选,校准比工具本身更关键

业界的量化方案大体分两类:训练后量化和量化感知训练。后者要在训练阶段就把量化误差考虑进去,成本太高,不适合大多数私有部署场景。前者就不动权重本身的训练,只做后处理,是私有部署的主流做法。

训练后量化里又要区分只量化权重、还是权重和激活一起量化。只量化权重,计算时再反量化回较高精度做乘加,实现简单、质量损失小,是第一次做性能优化时的首选。权重和激活都量化,速度上限更高,但容易遇到数值溢出,需要大量调参和校准。

校准环节最容易翻车。量化的本质是给每个权重段找合适的缩放因子,缩放因子怎么定?用真实业务数据来定。实际操作时,我收集了业务系统里真实出现的几百条 prompt,覆盖各种提问风格和长度,喂给量化工具做校准。千万不要用随机文本,也别用新闻语料,随机文本的分布和真实业务相差太大,校准出来的缩放因子在真实数据上效果很差。

整个流程大概是:导出原始 FP16 权重 → 加载到量化工具 → 用校准数据集计算缩放因子 → 导出低精度权重 → 做质量回归测试。质量回归这一步不能省,我会拿同一组问题分别让 FP16 和量化后的模型回答,对比内容完整性、明显错误、答案长度分布。

3.3 四种精度的实测对照

我在一块 40GB 显存的企业级 GPU 上跑同一个 7B 模型,固定同一份压测脚本,得到的数据大概是这样的:

精度方案权重显存单token延迟首token延迟质量表现
FP1614GB80ms2.8s基准
INT87GB48ms1.9s几乎无损
INT43.7GB25ms1.1s可感知但可接受

注意这个数据是“只改精度、其他条件不变”的情况下得到的。实际部署时还要配合后面讲的引擎,INT4 的收益会更明显。我们最后选了 INT4 权重方案,原因很简单:内部场景允许一定程度的灵活表达,但对速度敏感。如果你的业务是法律文书摘要、财务报告审核这类对严谨度要求极高的场景,我会建议保守一点,停在 INT8。

3.4 量化翻车的三个常见现场

现场一:模型输出一堆乱码或者根本无法自洽。这通常是量化配置里的分组大小、反量化精度参数和原始模型结构对不上。排查方法是先量化一个单层,对比输出差值,逐步缩小范围。

现场二:短问题回答正常,长文本越写越弱。这是量化误差在长序列自回归过程中的累积效应。可以考虑提高量化分组粒度,或者对关键层(通常是前几层和最后几层)保留 FP16。

现场三:精度降了,速度没有任何变化。遇到这种情况,第一反应不是量化工具坏了,而是你的模型还在用 Python 逐层调度的老路子执行,根本没有走到低精度内核。这个问题我们换推理引擎后彻底解决了,这就进入下一节。

4. 第二个提速开关:把 KV Cache 管起来

4.1 KV Cache 到底吃了多少显存

Transformer 模型做注意力计算时,每个 token 都要生成对应的 Key 和 Value 矩阵,后续 token 做注意力时又需要复用前面所有 token 的 Key 和 Value。为了不重复计算,模型会把它们缓存下来,这就是 KV Cache。它随序列长度线性增长,是解码阶段显存开销的大头。

可以这样估算单个请求占用的 KV Cache 空间:层数 × 注意力头数 × 头维度 × 序列长度 × 2(Key 和 Value 共两套)× 数据类型字节数。拿 32 层、32 头、每头 128 维、FP16 的 7B 模型为例,2048 个 token 的上下文算下来约 1GB。8 个并发请求就是 8GB,再加上权重和计算中间激活,24GB 的卡很容易被压垮。

很多我觉得“显存不够用”的部署问题,真实原因其实是 KV Cache 没有被好好管理。有的框架会为每个请求预留整块连续显存,按最大序列长度算,哪怕这个请求实际只生成了 100 个 token,也按 2048 的长度预分配。这是巨大的浪费。

4.2 两个突破口:页式 KV Cache 和连续批处理

第一个突破口是页式 KV Cache,思路和操作系统虚拟内存很像。显存被切成固定大小的块,哪个请求需要就分配给它,不需要就回收。序列更新时不再要求一整段连续空间,而是像翻书一样一页页往后加。这样碎片化问题大幅缓解,显存利用率显著提高。这也是高性能引擎与我原来用的朴素加载工具最大的区别之一。

第二个突破口是连续批处理。传统静态批处理的问题是等最慢的一个请求;连续批处理则不同,GPU 在运行时只要有一个请求生成了最后一个 token,就立刻释放它的资源,把排队的下一个请求补进去。GPU 流水线上永远有活干,吞吐自然就上来了。说穿了,这是一个调度策略的变革,但带来的收益是数量级的。

还有一个容易忽略的小优化叫前缀缓存。如果业务系统提示词是固定的,比如每次请求都带一大段系统指令,这部分前缀算出来的 KV Cache 完全可以跨请求复用。长系统提示词的场景下,TTFT 能从中受益很多。

4.3 显存怎么分配,参数怎么调

推理引擎一般会提供显存利用率参数,让你控制“总显存里有多少可以用来分配”。我的经验是把权重占用、激活中间量、显存碎片余量先预留出来,剩下的全给 KV Cache。比如 40GB 显存,INT4 权重只占约 4GB,我给 KV Cache 预留 32GB 左右,剩下的留给激活和备用。

块大小是一个需要调的重要参数。块太大,一个请求哪怕只用了一小块空间,也可能占据整块,浪费;块太小,索引和管理的开销迅速增大。理论上,大多数模型从 16 到 64 token 之间选择比较合适。我实践下来,32 token 是多数场景的稳妥起点。

优化前后并发表现对比如下:

配置可稳定承载并发数现象
优化前:固定大块预分配46 并发开始频繁 OOM
页式 KV Cache1616 并发稳定,显存无碎片崩溃
再加连续批处理24GPU 利用率显著提升,吞吐上涨明显

4.4 这一段的避坑经验

如果你看到有资料说“显存不够就把 KV Cache 关掉”,请你直接划走。关闭 KV Cache 意味着每个 token 生成时都要重算一遍之前所有 token 的 Key 和 Value,速度可能慢一个数量级,这不是优化,是灾难。

另一个常见问题是把 max_model_len 设得非常大,以为上下文越长越好。模型支持的上下文长度是能力上限,不代表部署时要把显存按上限预分配。设成 32768,KV Cache 的预分配压力会翻好几倍,实际多数请求可能只用了几千 token,纯属浪费。我建议按业务真实需求来设,比如内部客服场景,4096 或 8192 就够用了,没必要追高。

5. 第三个提速开关:换掉“裸”的推理执行方式

5.1 原生脚本为什么慢,引擎到底做了什么事

如果你目前是用模型加载工具加 Flask 之类的框架写了个 HTTP 接口,每次请求进来都走 Python 层逐层执行计算图,那性能天花板非常低。原因在于动态 shape 让算子无法充分融合,Python 调度开销巨大,GPU 内核频繁启停。简单说,每一步都要在 CPU 和 GPU 之间往返,时间全耗在路上。

专业的推理引擎做的事情可以拆成三块。第一,计算图静态化,把模型结构固定下来,减少动态分支开销。第二,算子融合,把多个连续操作合并成一个内核,中间结果不再写回显存,省下大量搬运。第三,实现连续批处理和页式 KV Cache,把调度和显存管理做到极致。这三块叠加起来,比单纯换精度带来的提升还要明显。

我习惯把这个东西叫“引擎X”,因为市面这类框架的原则是相通的。你不需要自己造轮子,选一个主流的、支持你所需量化格式的引擎即可。

5.2 替换过程怎么做,关键参数怎么定

下面是一个简化的配置示意,不同引擎的参数名略有差异,但核心项是相通的。记得把每次修改后的配置存档,方便回滚和对照。

# 示意配置,具体参数以所用引擎文档为准 model_path = "/data/models/your-gpt-oss-model-int4" dtype = "auto" # 读取量化权重时自动选择计算类型 max_model_len = 8192 # 模型最大上下文长度,按业务实际设 gpu_memory_utilization = 0.9 # 显存利用率,预留10%给激活和系统 kv_cache_block_size = 32 # KV Cache块大小,32 token为稳妥起点 max_num_seqs = 24 # 最大并发序列数,根据压测逐步调大

替换流程一般就是:把量化后的权重目录指给引擎,设置上面的参数,启动服务,把原来的接口地址切过来。最难的不是启动,而是参数到底取多少。max_num_seqs 不是越大越好。它过大时,并发序列抢占 KV Cache,每个序列分到的空间变少,可能出现上下文被迫截断或反复抢占,反而吞吐下降,日志里全是排队等待。

第一次启动时,建议把 max_num_seqs 从 8 开始,压测一轮,观察显存水位,再逐步往上加。每加一档跑一轮压测,直到 QPS 不再明显增长或出现 OOM 为止。这个“试探式调参”的过程比任何经验公式都靠谱。

5.3 换完引擎后的第一眼数据

同样的 7B 模型、同样的压测脚本、同样的并发工具,只把执行方式从原生加载换到推理引擎,数据是这个样子的:

指标原生加载推理引擎X
TTFT2.8s0.6s
单token延迟80ms28ms
10并发下QPS0.415.0
显存分配方式静态预分配动态池化

可以看到,单请求的 TTFT 和 TPOT 都有提升,但更猛的是并发吞吐,直接提升了三十多倍。这里面的主力是连续批处理,它让 GPU 不再闲着,每个 token 的时间都被压到极限。

换完引擎后发现一个问题也很有意思:日志里显示批大小很大,但吞吐反而轻微下降。后来排查下来是 max_num_seqs 设置过大,导致 KV Cache 块被抢来抢去,每个请求都要频繁等待缓存块。把它从 32 调回 24,QPS 立刻恢复。这验证了前面说的,调参是个精细活。

5.4 对推理引擎说几句实话

不是所有场景都适合上推理引擎。小模型比如参数量几个亿的模型,引擎的调度开销反而可能比模型计算还大,性能提升不明显。7B 及以上的模型,引擎的优势才会充分体现。引擎版本和量化格式的匹配也很重要。有些量化格式在原生工具里生成得很好,但引擎不支持,或者加载时要反量化回 FP16 再计算,那前面降精度带来的带宽红利就全白费了。选型前先看引擎支持哪些量化格式,再决定量化方案。

6. 上篇收尾:组合是乘积,不是加法

6.1 上篇四步做完后的成绩单

到这里,我们完成了四件事:建立了基线、量化了权重、管好了 KV Cache、换上了推理引擎。把它们拼在一起,最终的对照数据是:

指标优化前优化后提升倍数
TTFT2.8s0.4s7 倍
单token延迟80ms18ms4.4 倍
单请求完整回复耗时(256 token)约 23s约 5s4.6 倍
20并发下系统吞吐 QPS0.442105 倍
可稳定承载并发数4246 倍

所以准确说:上篇做完后,单请求体验提升了 4 到 7 倍,系统总吞吐提升了约 100 倍。如果你只有一个用户在那里慢慢点,你感受到的是“快了几倍”;如果你有几十个人同时使用,你感受到的是“从卡死到流畅”。

6.2 还没讲完的事:下篇还会讲什么

很多朋友可能已经注意到,上面所有优化都假设只有一张 GPU、一个模型副本。要再往上走,下篇会涉及多卡和多实例部署:张量并行如何把一个大模型切到多张卡上、多副本加负载均衡如何把单点服务的瓶颈摊开、投机解码和草稿模型怎么在不牺牲质量的前提下进一步压低单 token 延迟。还有监控和容量规划:GPU 利用率、显存水位、KV Cache 命中率这些指标怎么接,怎么在业务量上来之前发现问题。

6.3 几句话说给正在踩坑的人

性能优化做到最后,本质是取舍。质量、延迟、吞吐、硬件成本,四件事不可能全部做到最优。我的建议是:别一上来就想着把它优化到极限,先把业务负载摸清楚,再针对真正的瓶颈动手。还有,每一版配置和压测结果都留档,随时准备回滚。最后提醒一句,量化后的模型质量一定要让业务方参与验收,让真正常用系统的人做盲测,不要只盯着延迟曲线。我个人最深的体会是:100 倍从来不是某一个大招带来的,而是一层一层优化累积出来的结果,每一层都在解决上一个瓶颈。

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

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

立即咨询