大模型推理服务性能优化:从5万到2000万日调用的生产级实践

大模型推理服务性能优化:从5万到2000万日调用的生产级实践

1. 项目概述:从概念验证到生产级服务的挑战

去年年初,我们团队接手了一个内部大模型应用项目,最初它只是一个供几十个研发同事“尝鲜”的玩具。那时的架构简单粗暴:一台搭载了A100的GPU服务器,上面跑着一个用FastAPI写的简单封装,后端挂载着一个7B参数的模型。日均调用量大概在5万次左右,P99延迟虽然不太稳定,但大家觉得“能跑就行”。然而,随着产品正式上线和用户量的指数级增长,这个“玩具”很快变成了整个系统的性能瓶颈。调用量在三个月内冲到了日均2000万次,原有的服务在高峰时段频繁超时、崩溃,用户体验直线下降。

我们面临的核心矛盾非常典型:如何在有限的硬件资源和急剧增长的用户请求之间,找到平衡点,保证服务的高可用、低延迟和高吞吐?这不是简单地加机器就能解决的问题。大模型推理本身是计算和内存密集型的,每一次生成(Generation)都伴随着巨大的计算图和显存操作。从5万到2000万,这400倍的量级跃迁,逼迫我们必须从“写一个能跑的Demo”的思维,转向“构建一个生产级的推理服务平台”的性能工程思维。这涉及到从模型加载、请求调度、批处理优化、内存管理到监控告警的全链路深度重构。

接下来的内容,就是我作为这个项目的核心负责人,在过去一年里,带领团队将这套大模型推理服务从濒临崩溃的边缘,优化到能够稳定支撑日均2000万次调用、P99延迟控制在可接受范围内的完整实践记录。我会避开那些空洞的理论,直接分享我们踩过的坑、验证过的方案以及那些真正起效的“黑科技”。

2. 性能瓶颈全景诊断:找到真正的“慢”

在动手优化之前,盲目调整参数和架构是致命的。我们做的第一件事,就是像医生一样,对系统进行了一次全面的“体检”,建立完整的性能画像(Performance Profiling)。这个过程让我们发现,问题远比表面看到的“服务器CPU/GPU跑满了”要复杂。

2.1 核心指标体系的建立

我们首先定义了衡量推理服务健康度的核心黄金指标(Golden Signals):

  1. 吞吐量(Throughput):每秒处理的请求数(RPS)或每秒生成的Token数(TPS)。这是容量规划的基础。
  2. 延迟(Latency):特别是P50、P90、P99和P999(千分位)延迟。P99延迟是衡量用户体验和系统稳定性的关键,它反映了长尾请求的糟糕程度。
  3. 错误率(Error Rate):非200状态码的请求比例,包括超时、模型加载失败、显存不足(OOM)等。
  4. 资源利用率(Resource Utilization):GPU利用率、GPU显存使用率、CPU利用率、系统内存使用率。这里有个关键认知:GPU利用率高不等于性能好,它可能意味着计算效率低下或存在阻塞。

我们使用Prometheus+Grafana搭建了监控大盘,并通过在服务代码中埋点,精细采集了每个阶段的耗时,比如:请求排队时间、预处理时间、模型计算时间(可细分为Attention、FFN等)、后处理时间、Token生成时间序列等。

2.2 初期架构的瓶颈分析

通过一周的监控数据分析和py-spyNVIDIA Nsight Systemstorch.profiler等工具进行现场 profiling,我们揪出了以下几个核心瓶颈:

瓶颈一:串行处理与“空跑”的GPU。最初的服务是单进程单线程,每个请求独占GPU进行计算。这意味着当一个请求在生成下一个Token时,GPU的算力被完全占用,但显存中的大部分数据(如KV Cache)可能只为这一个请求服务,其他请求只能在队列中干等。GPU的算力利用率曲线呈“锯齿状”,大量时间花在了等待I/O(网络收发、数据加载)和上下文切换上。

瓶颈二:动态批处理(Naive Batching)的缺失。没有批处理,就无法利用GPU的并行计算能力处理多个请求。这是初期吞吐量无法提升的根本原因。

瓶颈三:显存管理的“野蛮生长”。每个请求独立加载模型权重(尽管是同一份),并且KV Cache随着生成长度线性增长,缺乏有效的回收和共享机制。这导致在并发稍高时,极易触发OOM,服务直接崩溃。

瓶颈四:网络与序列化开销。我们的服务部署在Kubernetes集群,通过负载均衡器(如Nginx)暴露。每个请求/响应的JSON序列化/反序列化,以及网络传输,在超高QPS下成为了不可忽视的开销。特别是在生成长文本时,响应体巨大。

瓶颈五:冷启动与模型加载。每次服务发布或扩缩容,加载一个几十GB的大模型到GPU显存需要数分钟,这期间服务不可用,造成流量损失。

诊断结论是:我们的系统不是一个“慢”的系统,而是一个“低效”的系统。GPU这个最昂贵的资源,大部分时间都在“磨洋工”。优化方向很明确:想尽一切办法让GPU“忙”起来,并且是高效地“忙”。

3. 核心优化策略:让GPU“饱和”工作

基于诊断结果,我们制定并实施了分阶段的优化策略。这些策略环环相扣,共同作用才实现了质的飞跃。

3.1 策略一:拥抱动态批处理与连续批处理

这是提升吞吐量最有效的一招。我们放弃了请求独占GPU的模式,引入了动态批处理(Dynamic Batching)

原理与实现:我们部署了一个推理服务器(如vLLMTGI),它们内置了高效的调度器。当请求到达时,调度器不会立即执行,而是将其放入一个队列,等待一个很短的时间窗口(例如10-50毫秒)。在这个窗口内,到达的、具有相似参数(如相同的模型、采样参数)的请求会被动态地组合成一个批次(Batch),然后一次性送入GPU进行计算。

为什么选择vLLM?在对比了多个方案后,我们选择了vLLM。它的核心优势在于PagedAttention算法,这个我们后面会细说。但就批处理而言,vLLM的调度器非常高效,能实现接近零开销的请求排队和批次组建。我们实测,在相同硬件和模型下,从无批处理切换到vLLM的动态批处理,吞吐量直接提升了8-10倍。

连续批处理(Continuous Batching)的威力:传统的批处理要求一个批次内的所有请求同时开始、同时结束。这对于生成任务极不友好,因为每个请求的生成长度(输出Token数)差异巨大。让一个生成了10个Token的请求等待另一个生成了100个Token的请求,是巨大的浪费。 vLLM和TGI实现了连续批处理。当一个批次中某个请求生成完毕(如达到最大长度或遇到停止符),它可以立即被“释放”出批次,并将该请求占用的计算资源(特别是KV Cache空间)腾出来。同时,调度器可以立即将队列中一个新的请求“插入”到这个刚刚空出的槽位,加入当前正在进行的批次中。这样,GPU的计算流水线几乎永远不会中断,实现了极高的资源利用率。

实操心得:设置合适的max_batch_size和调度等待时间(scheduler_delay)是个平衡艺术。max_batch_size太大可能导致OOM或单个请求延迟激增;太小则无法充分利用GPU。我们的经验是从一个保守值开始(如8),根据监控的GPU利用率和P99延迟逐步调优。调度等待时间通常设置在20ms左右,在延迟和吞吐之间取得较好平衡。

3.2 策略二:实施PagedAttention与显存优化

显存是比算力更紧俏的资源。大模型推理的显存占用主要来自两部分:模型权重KV Cache。对于7B~70B的模型,在FP16精度下,权重可能占用14GB到140GB显存。而KV Cache的占用与批次大小(batch size)和序列长度(sequence length)成正比。

KV Cache的爆炸性增长:在自回归生成中,为了计算下一个Token,模型需要缓存之前所有Token的Key和Value状态。对于Batch Size为B,序列长度为L,层数为N,注意力头数为H,每个头维度为D的模型,KV Cache的显存占用大约是2 * B * L * N * H * D * sizeof(dtype)。这非常可观。例如,一个13B模型(N=40, H=40, D=128),处理批次为8、序列长度为2048的请求,FP16下的KV Cache就可能占用近10GB显存。

vLLM的PagedAttention:这是游戏规则改变者。它受操作系统虚拟内存分页机制的启发,将每个请求的KV Cache在物理显存中划分为固定大小的“块”(Block),例如16个Token一个块。这些块不需要连续存储。每个请求维护一个逻辑上的“块表”,记录它使用了哪些物理块。

  • 优势一:消除显存碎片。传统方式下,不同长度的请求会导致显存中出现大量无法利用的碎片。PagedAttention通过小块分配,实现了高效的显存复用。
  • 优势二:高效共享。在并行采样(如Beam Search)或多轮对话(将历史对话作为前缀)场景中,不同序列间可能共享大量的前缀Token。PagedAttention可以物理上只存储一份共享前缀的KV Cache,多个逻辑序列通过块表指向它,避免了重复存储,有时能节省90%以上的相关显存。

我们的实践:启用vLLM后,我们同样配置的A100服务器,能够支持的并发请求数(即有效Batch Size)提升了3-5倍,OOM错误几乎消失。这是支撑2000万日调用的基石。

3.3 策略三:量化与模型压缩

当批处理和显存优化做到极致后,模型权重本身的大小就成了瓶颈。为了在单张GPU上服务更大的模型或支撑更大的批次,我们引入了量化。

量化方案选型

  • FP16/BF16:基线,保持最佳精度,显存占用大。
  • INT8(权重量化):将模型权重从FP16转换为INT8,显存占用减半,推理速度提升明显。通常使用bitsandbytes库实现。这对大多数任务精度损失可接受。
  • GPTQ/AWQ(INT4/INT8):更激进的量化方法。GPTQ是对权重进行逐层量化,AWQ则通过分析激活分布来保护重要的权重通道。INT4量化能将显存占用降至FP16的1/4,使得在24GB显存的消费级显卡上运行70B模型成为可能。
  • SmoothQuant:一种将激活的量化难度转移到权重上的技术,使得模型在INT8下同时量化权重和激活,获得更好的端到端加速。

我们的选择路径:我们采取了渐进式策略。

  1. 首先对所有生产模型统一使用FP16,确保基线精度。
  2. 对于延迟敏感但吞吐要求不极致的在线服务,尝试并上线了W8A8(权重INT8,激活INT8)的SmoothQuant版本,在精度损失<0.5%的情况下,获得了近2倍的推理速度提升。
  3. 对于内部的一些工具类、对精度容忍度较高的场景,我们测试了AWQ INT4量化,显存节省了75%,速度也有提升,但需要仔细评估下游任务效果。

注意事项:量化不是无损的,必须进行严格的评估。我们建立了自动化的评估流水线,在量化后,在多个下游任务(如问答、摘要、代码生成)的测试集上对比量化模型与原始模型的性能差异,只有满足预设阈值(如准确率下降不超过1%)的量化模型才会被部署。

3.4 策略四:计算图优化与内核融合

GPU擅长大规模并行计算,但频繁启动大量的小型计算内核(Kernel)会导致严重的启动开销。框架默认的算子实现可能并非最优。

使用TensorRT-LLM或FasterTransformer:这些是NVIDIA推出的针对LLM推理的高度优化库。它们会对整个Transformer计算图进行编译优化,包括:

  • 算子融合(Kernel Fusion):将多个连续的小算子(如LayerNorm、GeLU、矩阵乘加)融合成一个大的CUDA内核,减少内核启动次数和全局内存访问。
  • 针对性的内核优化:使用高度优化的、手写的CUDA内核来替代框架(如PyTorch)的通用实现,尤其针对Attention和激活函数。
  • 内存布局优化:将Tensor在内存中的排列方式调整为对GPU缓存更友好的格式(如Channel Last)。

我们的实践:对于追求极致性能的固定模型,我们使用TensorRT-LLM进行编译部署。编译过程可能需要数小时,但生成的是一个高度优化的、序列化后的引擎(Engine)文件。部署时直接加载这个引擎,推理速度相比原始PyTorch模型有显著提升(在某些场景下可达50%以上)。但它的缺点是灵活性差,任何模型结构的改动都需要重新编译。

更灵活的选择:PyTorch 2.x 的torch.compilescaled_dot_product_attention:对于需要快速迭代模型的场景,我们采用PyTorch 2.x的原生优化。使用torch.compile(model, mode="max-autotune")对模型进行即时编译(JIT),可以自动进行图优化和内核融合。同时,确保使用PyTorch内置的、经过高度优化的F.scaled_dot_product_attention函数,它利用了FlashAttention等先进算法,大幅提升了Attention计算效率。

4. 系统工程与架构演进

单点优化再强,没有稳健的系统和架构支撑,也无法应对日均2000万次的洪流。我们在系统层面做了大量工作。

4.1 服务化与API网关设计

我们摒弃了直连推理服务器的模式,构建了清晰的三层架构:

  1. API网关层:使用KongApache APISIX。负责鉴权、限流、熔断、负载均衡、请求路由、监控数据采集。所有客户端请求首先到达网关。
    • 限流:我们实现了基于用户/API Key的分级限流,防止恶意请求打垮后端。
    • 熔断:当某个后端推理实例错误率超过阈值时,网关自动将其熔断,待其恢复后再逐步放量。
  2. 推理服务层:即运行vLLM/TGI的实例池。每个实例承载一个模型的一个版本。我们通过Kubernetes Deployment进行管理,并配置了HPA(水平Pod自动扩缩容),根据GPU利用率或请求队列长度自动扩缩容。
  3. 模型管理层:一个独立的服务,负责模型的版本管理、热加载、A/B测试流量分发。当需要上线新模型时,通过该服务向推理服务层下发指令,进行平滑更新。

4.2 高效的请求调度与负载均衡

网关层的负载均衡策略至关重要。简单的轮询(Round Robin)会导致不同实例负载不均,因为请求的复杂度(输入/输出长度)差异很大。 我们采用了最少连接数(Least Connections)响应时间加权相结合的策略。网关会轻微地倾向于将新请求发送给当前连接数较少且近期平均响应时间较短的实例。同时,我们在每个推理实例上暴露了健康检查接口和负载指标(如当前批次大小、队列长度),网关可以基于这些指标做出更智能的路由决策。

4.3 监控、告警与可观测性

“没有度量,就没有优化。” 我们建立了全方位的监控体系:

  • 基础设施监控:使用Node ExporterDCGM Exporter监控服务器和GPU的硬件指标。
  • 应用性能监控(APM):在网关和推理服务代码中大量埋点,追踪每个请求的生命周期,生成分布式链路追踪(使用Jaeger),可以清晰看到一个请求在网关、排队、预处理、模型计算、后处理各阶段的耗时。
  • 业务指标监控:记录总调用量、各模型调用量、Token消耗量(用于成本核算)、输出长度的分布等。
  • 告警:基于P99延迟、错误率、GPU显存使用率、实例健康状态等设置告警规则。告警通过钉钉、短信、电话多级触达,确保问题能被及时发现。

我们有一个核心仪表盘,实时展示着全局的QPS、P99延迟和错误率。这个大盘是团队所有人的“血压计”。

5. 实战问题排查与调优实录

理论很美好,但现实总是骨感的。下面分享几个我们遇到的具体问题及其解决过程。

5.1 案例一:P99延迟的周期性毛刺

现象:服务整体运行平稳,但每过大约5分钟,P99延迟就会出现一个明显的尖峰,从200ms飙升到2s以上,持续十几秒后恢复。

排查

  1. 首先排除外部因素:数据库、网络、上游服务均无异常。
  2. 检查监控,发现毛刺出现时,GPU利用率有一个短暂的下降,同时系统内存使用率有小幅上升
  3. 登录服务器,使用dstatpidstat观察,发现在毛刺时段,有一个名为journald的进程CPU占用率很高。
  4. 恍然大悟:这是Linux系统日志服务(journald)的日志轮转(rotation)和压缩。默认情况下,journald会定期清理和压缩旧的系统日志,这个操作是CPU密集型的,会与推理进程争抢CPU资源。
  5. 推理服务虽然主要用GPU,但预处理(tokenization)、后处理(detokenization)、数据搬运(CPU到GPU)以及框架本身的管理调度都需要CPU。CPU的短暂瓶颈导致GPU喂料不足,进而引发排队堆积,表现为P99延迟飙升。

解决

  • 为推理服务分配独立的CPU核心集(taskset或K8s的cpu-manager-policy),进行CPU隔离。
  • 调整journald的配置(/etc/systemd/journald.conf),降低日志压缩频率或将其设置为非实时任务(nice值调低)。
  • 将日志直接输出到文件,并采用更轻量的日志轮转工具(如logrotate)。

经验GPU服务的性能瓶颈,很可能在CPU或I/O。必须对宿主机的系统级进程和行为有清晰的了解。

5.2 案例二:长文本生成导致的OOM

现象:服务在处理一些超长文本生成请求(如生成长篇报告)时,会随机性地出现OOM,导致整个推理实例崩溃重启。

排查

  1. 检查vLLM配置,max_model_len(模型支持的最大序列长度)设置正确。
  2. 观察崩溃前的监控,发现显存是缓慢上升直至爆掉的,而非瞬间。
  3. 分析请求日志,发现出问题的请求都是开启了流式输出(Streaming)的请求。
  4. 深入代码发现,我们在流式返回时,为了拼接完整的响应以供后续审计,在内存中缓存了整个生成的文本。当生成数千甚至上万个Token时,这个缓存字符串会占用数百MB的系统内存。如果并发多个这样的长文本请求,系统内存就会被耗尽。Linux OOM Killer会选择占用内存最大的进程(通常是我们的推理进程)杀掉。

解决

  • 修改流式响应逻辑,不再在服务端内存中缓存完整响应。审计日志改为记录请求和响应的元数据(如Token数)以及抽样片段。
  • 对于必须保存完整内容的场景,将其异步写入到外部存储(如对象存储S3或数据库),而不是留在应用内存中。
  • 严格限制单个请求的最大生成Token数(max_tokens),并在网关层对超长请求进行拦截或降级。

经验显存OOM只是冰山一角,系统内存OOM同样致命。尤其是在处理流式、长上下文场景时,对内存的管理要扩展到整个应用层面。

5.3 案例三:冷启动流量雪崩

现象:每当发布新模型版本或滚动更新服务时,虽然采用了蓝绿部署,但在新实例启动接收流量的瞬间,其P99延迟极高,导致部分用户请求失败。

分析:新启动的实例,模型需要从磁盘加载到GPU显存,这需要时间(冷启动)。在此期间,如果网关将大量流量直接切过来,这些请求要么堆积在队列中超时,要么直接失败。更糟糕的是,模型加载本身是计算和I/O密集的,可能进一步拖慢第一个批次的处理。

解决:实施就绪探针(Readiness Probe)与渐进式流量切换

  1. 在Kubernetes的Deployment中,为推理容器配置一个“就绪探针”。这个探针会执行一个轻量的脚本,该脚本会检查模型是否已完全加载至GPU并可以正常执行一次前向推理。
  2. 只有就绪探针通过后,Kubernetes才会将该Pod标记为“Ready”,并将其IP地址添加到Service的Endpoint列表中,此时网关才会发现并开始向其分发流量。
  3. 在网关层面,我们配置了渐进式流量切换。当有新版本实例组上线时,网关不会立即将100%流量切过去,而是以每分钟增加10%-20%流量的速度逐步放大,同时密切监控新实例的延迟和错误率。一旦指标异常,立即回滚流量。

经验发布的平滑度是稳定性的重要组成部分。任何变更,尤其是涉及核心服务的变更,都必须有自动化的、可观察的、可回滚的发布策略。

6. 成本、效果与未来展望

经过上述一系列优化,我们的服务达到了以下状态:

  • 吞吐量:单台A100 80GB服务器,服务7B模型,从优化前的约200 RPS提升至稳定1500+ RPS
  • 延迟:P99延迟从不可控(经常>5s)稳定在800ms以下(取决于输入输出长度)。
  • 成本:通过高效的批处理和量化,在承载相同流量的情况下,所需的GPU实例数减少了约65%,直接计算成本大幅下降。
  • 稳定性:服务可用性达到99.95%,能够从容应对日常流量高峰和突发活动。

回顾这段历程,性能工程没有银弹,它是一系列权衡(Trade-off)的艺术:吞吐与延迟的权衡、精度与速度的权衡、灵活性与效率的权衡。核心思想始终是“让最贵的资源(GPU)保持高效、饱和的工作状态”

未来,我们还在探索更多方向:比如使用推测解码(Speculative Decoding)用小模型“猜测”大模型的输出,再用大模型快速验证,从而加速推理;比如更精细的混合精度计算算子编译优化;再比如基于请求特征(长度、优先级)的差异化调度策略

性能优化之路永无止境。每一次流量增长,都会带来新的挑战。但有了这套系统化的方法论和工具链,我们至少有了应对挑战的底气和武器。希望我们的这些实践和踩过的坑,能为你构建自己的大模型服务提供一些切实可行的参考。