ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

从零手搓AI推理服务:动态批处理与显存管理实战

从零手搓AI推理服务:动态批处理与显存管理实战 1. 从零手搓AI工程为什么我不建议你直接调包很多人一听到“AI工程”这四个字第一反应就是打开某个云平台拖几个组件调几个API然后跑通一个Demo就觉得自己已经入门了。我刚开始接触这个方向的时候也是这么想的直到有一次线上推理服务在高峰期直接雪崩日志里全是显存溢出和请求超时我才意识到——只会调包的人根本不知道系统在哪个环节出了问题更别提优化了。ai-engineering-from-scratch这个项目标题本身就说明了一件事从零开始构建AI工程能力。它不是教你如何调用某个现成的推理接口而是让你亲手把数据加载、模型推理、批处理调度、显存管理、服务暴露这一整条链路搭起来。你可能会问现在工具链这么成熟为什么还要自己造轮子我的回答很直接造轮子不是为了替代轮子而是为了在轮子爆胎的时候你知道该换哪个零件。这篇文章适合三类人看。第一类是有一定Python基础想从“调包侠”进阶到“工程实践者”的开发者。第二类是在做AI应用落地时被性能、显存、并发这些问题反复折磨的后端工程师。第三类是对AI系统底层机制好奇想搞清楚一个推理请求从进入到返回到底经历了什么的技术爱好者。我会围绕从零搭建AI推理服务这条主线把每个环节的“为什么”和“怎么做”都讲透包括我踩过的坑和实测有效的方案。需要提前说明的是这篇文章不会涉及任何特定云平台或闭源工具的深度绑定所有方案都基于开源生态和通用工程实践。你可以在本地机器、私有服务器或者任何你习惯的环境里复现。2. 推理服务的骨架从一次请求的生命周期说起2.1 一个请求进来之后系统到底做了什么很多人写推理服务代码是这样的接收请求把数据塞给模型返回结果。三行代码搞定看起来很美。但当你把并发量拉到50以上就会发现响应时间从200毫秒飙升到5秒甚至直接超时。问题出在哪里出在你没有理解一个推理请求的完整生命周期。一个典型的AI推理请求从进入系统到返回结果至少经历这几个阶段网络接收与协议解析、请求排队与批处理调度、数据预处理与张量转换、模型前向计算、后处理与结果序列化、响应回传。每个阶段都有它的性能瓶颈和工程陷阱。比如网络接收阶段如果你用的是同步阻塞的HTTP服务器每个请求占一个线程并发一高线程池直接打满。再比如批处理调度阶段如果你不做动态批处理GPU利用率可能连30%都不到大部分时间都在等单个请求的数据搬运。我在实际项目里做过一个对比测试同样的模型同样的硬件同步单请求模式下QPS只有12引入动态批处理之后QPS直接拉到85提升超过7倍。这个差距不是模型本身带来的而是工程架构决定的。所以从零构建AI工程能力第一步就是建立对请求生命周期的完整认知。2.2 为什么我选择异步框架而不是同步框架在搭建推理服务时第一个要做的技术选型就是Web框架。Flask和Django是同步框架的代表FastAPI和aiohttp是异步框架的代表。我一开始用的是Flask原因很简单——熟悉上手快。但很快我就发现Flask的WSGI协议是同步阻塞的每个请求占用一个工作线程当并发请求数超过线程池大小时后来的请求只能排队等待。对于AI推理这种计算密集型任务单个请求的处理时间本来就长同步模式下线程池会被迅速耗尽。切换到FastAPI之后情况完全不一样。FastAPI基于ASGI协议支持异步非阻塞IO可以在等待模型计算的同时处理其他请求的接收和解析。更重要的是FastAPI原生支持异步生成器和后台任务这对于实现动态批处理非常关键。你可以把多个请求的输入数据异步收集起来凑成一个批次再送给模型而不是一个一个地串行处理。这里有一个实测数据可以参考在同样的4核CPU、单张GPU的机器上Flask同步模式下50并发请求的平均延迟是3.2秒FastAPI异步模式下同样50并发的平均延迟是0.9秒。差距主要来自请求排队和IO等待的优化。当然异步不是银弹如果你的模型推理本身就是同步阻塞的异步框架也只能解决IO层面的问题计算层面的瓶颈还需要靠批处理和模型优化来解决。2.3 动态批处理把GPU从“吃不饱”变成“吃不下”GPU最怕的不是计算量大而是计算量小但次数多。每次推理都要把数据从内存搬到显存计算完再搬回来这个搬运过程的时间可能比计算本身还长。动态批处理的核心思想就是把短时间内到达的多个请求合并成一个批次一次性送给GPU计算从而摊薄数据搬运和内核启动的开销。实现动态批处理的关键是设计一个请求缓冲队列和一个批处理调度器。请求到达时先进入缓冲队列调度器每隔一个很短的时间窗口比如10毫秒检查队列如果队列里有请求就把它们打包成一个批次。批次大小可以固定也可以根据队列长度动态调整。如果队列里的请求数超过最大批次限制就分成多个批次依次处理。我在实现这个机制时踩过一个坑一开始我把时间窗口设成了50毫秒结果发现延迟明显增加因为每个请求都要等至少50毫秒才能被处理。后来我把窗口缩短到5毫秒同时引入了一个“最小批次触发”机制——如果队列里的请求数达到预设的最小批次大小比如8就立即触发批处理不再等待时间窗口结束。这样既保证了低延迟又保证了高吞吐。下面是一个简化的动态批处理调度器伪代码用Python的asyncio实现import asyncio from collections import deque class DynamicBatcher: def __init__(self, max_batch_size32, min_batch_size8, timeout_ms5): self.max_batch_size max_batch_size self.min_batch_size min_batch_size self.timeout timeout_ms / 1000.0 self.queue deque() self.lock asyncio.Lock() async def add_request(self, data): future asyncio.Future() async with self.lock: self.queue.append((data, future)) if len(self.queue) self.min_batch_size: asyncio.create_task(self._process_batch()) return await future async def _process_batch(self): await asyncio.sleep(self.timeout) async with self.lock: batch [] while self.queue and len(batch) self.max_batch_size: batch.append(self.queue.popleft()) if not batch: return inputs [item[0] for item in batch] futures [item[1] for item in batch] results await self._run_inference(inputs) for future, result in zip(futures, results): future.set_result(result)这段代码的核心逻辑是请求到达时先入队如果队列长度达到最小批次阈值立即触发批处理否则等待一个短时间窗口后再触发。批处理时从队列头部取出最多max_batch_size个请求合并后送给模型推理最后把结果分别设置回对应的Future对象。注意动态批处理的时间窗口和批次大小需要根据你的模型推理时间和硬件性能来调优。窗口太大会增加延迟窗口太小则批处理效果不明显。建议从5毫秒和8个请求开始根据实际压测结果逐步调整。3. 显存管理AI工程里最容易翻车的地方3.1 显存都去哪儿了拆解推理过程中的显存占用很多人以为模型加载到显存里就完事了实际上推理过程中的显存占用远不止模型参数本身。以一个大语言模型为例显存占用主要来自四个部分模型权重、KV缓存、激活值和临时缓冲区。模型权重是固定的加载后基本不变。KV缓存是自回归生成过程中累积的键值对随着生成序列长度的增加而线性增长。激活值是前向传播过程中产生的中间结果批次越大、序列越长激活值占用越高。临时缓冲区是CUDA内核执行时需要的临时空间通常不大但不可忽略。我在实际项目里遇到过一种情况模型权重只占了8GB显存但推理过程中显存占用峰值达到了14GB直接导致OOM。排查后发现主要问题是KV缓存没有做上限控制当并发请求数增加时每个请求都在累积KV缓存总占用迅速膨胀。解决方案是给KV缓存设置一个最大长度限制超过限制的请求要么排队等待要么直接拒绝。同时引入显存池化机制预分配一块显存区域所有请求的KV缓存都从池子里分配用完归还避免频繁的显存分配和释放。3.2 显存池化为什么预分配比动态分配更稳动态显存分配的问题是碎片化和不确定性。每次请求到来时向CUDA申请显存请求结束时释放频繁的申请和释放会导致显存碎片化最终即使总空闲显存足够也可能因为找不到连续的大块而分配失败。更糟糕的是显存分配的时间开销不稳定有时候几微秒有时候几毫秒这对于延迟敏感的服务来说是不可接受的。显存池化的思路很简单启动时一次性申请一大块显存之后所有的显存需求都从这块池子里分配不再直接和CUDA打交道。池子内部可以用简单的空闲链表或者更复杂的内存分配算法来管理。这样做的好处是分配和释放的速度极快而且不会产生外部碎片。代价是需要预先知道大概需要多少显存如果池子太小会限制并发能力太大则浪费显存资源。我通常的做法是先跑一个基准测试测出单个请求在最大序列长度下的显存占用然后乘以预期的最大并发数再加上20%的余量作为池子大小。比如单个请求峰值占用500MB预期最大并发20那么池子大小设为500MB乘以20再乘以1.2等于12GB。这个估算不一定精确但可以通过压测逐步调整。3.3 显存不足时的降级策略排队、拒绝还是卸载即使做了显存池化当请求量超过系统承载能力时仍然会面临显存不足的问题。这时候有三种策略可选排队等待、直接拒绝、或者把部分计算卸载到CPU。排队等待是最温和的策略请求进入等待队列等有显存释放后再处理。但排队会导致延迟增加如果队列太长用户体验会很差。直接拒绝是最粗暴的策略返回一个“服务繁忙”的错误让客户端稍后重试。卸载到CPU是最复杂的策略把部分层或者部分请求的计算放到CPU上执行虽然速度慢但至少能返回结果。我的建议是组合使用这三种策略。首先设置一个等待队列的最大长度比如100个请求。队列没满时新请求进入队列等待。队列满了之后新请求直接拒绝。同时监控显存池的使用率如果使用率超过90%且持续超过一定时间触发卸载机制把最老的请求或者优先级最低的请求转移到CPU上执行。这样既保证了系统的稳定性又尽可能多地处理了请求。下面是一个显存池的简化实现示例import torch class MemoryPool: def __init__(self, total_size_gb): self.total_bytes int(total_size_gb * 1024**3) self.used_bytes 0 self.free_blocks [(0, self.total_bytes)] self.lock threading.Lock() def allocate(self, size_bytes): with self.lock: for i, (start, length) in enumerate(self.free_blocks): if length size_bytes: self.free_blocks.pop(i) if length size_bytes: self.free_blocks.append((start size_bytes, length - size_bytes)) self.used_bytes size_bytes return start raise MemoryError(显存池不足) def free(self, start, size_bytes): with self.lock: self.free_blocks.append((start, size_bytes)) self.free_blocks.sort() self.used_bytes - size_bytes self._merge_blocks() def _merge_blocks(self): merged [] for block in self.free_blocks: if merged and merged[-1][0] merged[-1][1] block[0]: merged[-1] (merged[-1][0], merged[-1][1] block[1]) else: merged.append(block) self.free_blocks merged这个实现用空闲链表管理显存块分配时找到第一个足够大的块释放时合并相邻的空闲块。实际生产环境中还需要考虑对齐、线程安全、显存映射等问题但核心思路就是这样。4. 模型加载与推理优化让每一毫秒都花在刀刃上4.1 模型格式转换从训练框架到推理引擎训练好的模型通常保存为PyTorch的.pt或.pth格式或者TensorFlow的SavedModel格式。这些格式适合训练但不一定适合推理。推理场景下我们更关心的是加载速度、推理延迟和显存占用。所以第一步通常是把模型转换成推理友好的格式比如ONNX、TensorRT或者TorchScript。ONNX是一个开放的模型交换格式支持跨框架转换。你可以把PyTorch模型导出为ONNX然后用ONNX Runtime来推理。ONNX Runtime对CPU推理做了大量优化在CPU场景下通常比原生PyTorch快2到5倍。但ONNX对动态形状的支持有限如果你的模型输入序列长度变化很大可能需要导出多个固定形状的版本或者使用ONNX Runtime的动态形状功能。TensorRT是NVIDIA的推理优化引擎专门针对NVIDIA GPU做了深度优化。它支持层融合、精度校准、内核自动调优等高级特性在GPU场景下通常能比原生PyTorch快3到10倍。但TensorRT的转换过程比较复杂对模型结构有一定要求不是所有模型都能顺利转换。而且TensorRT引擎是硬件相关的换一张不同架构的GPU就需要重新构建引擎。TorchScript是PyTorch自带的模型序列化格式可以把模型转换为独立的中间表示脱离Python运行时执行。TorchScript的优点是转换简单兼容性好几乎支持所有PyTorch模型。缺点是优化程度不如TensorRT和ONNX Runtime性能提升有限。我的建议是如果部署在NVIDIA GPU上且追求极致性能优先考虑TensorRT。如果部署在CPU或者多种硬件上优先考虑ONNX Runtime。如果模型结构复杂、转换困难或者需要快速迭代先用TorchScript保证能跑起来再逐步优化。4.2 量化用精度换速度的取舍之道量化是把模型参数从高精度浮点数如FP32转换为低精度整数如INT8的过程。量化后的模型体积更小推理速度更快显存占用更低但精度会有一定损失。量化的核心挑战是在精度损失和性能提升之间找到平衡点。常见的量化方法有两种训练后量化和量化感知训练。训练后量化是在模型训练完成后直接对权重和激活值进行量化不需要重新训练。这种方法简单快捷但对于一些对精度敏感的模型量化后的精度下降可能比较明显。量化感知训练是在训练过程中模拟量化的效果让模型适应低精度计算通常能获得更好的精度保持。但量化感知训练需要重新训练模型成本较高。我在实际项目里做过一个对比测试一个BERT-base模型FP32推理延迟是45毫秒INT8量化后延迟降到18毫秒加速比2.5倍。精度方面在文本分类任务上FP32的准确率是92.3%INT8的准确率是91.8%只下降了0.5个百分点。这个 trade-off 在大多数场景下是可以接受的。但在一些对精度要求极高的场景比如医疗影像诊断0.5个百分点的下降可能就意味着漏诊率的上升这时候就需要谨慎评估。提示量化不是万能的。对于小模型或者已经很快的模型量化的收益可能不明显甚至因为量化开销导致速度变慢。建议在量化前先做基准测试确认量化确实能带来收益。4.3 算子融合与内核调优把多个小操作合并成一个大操作深度学习模型的计算图里有很多细粒度的算子比如矩阵乘法、加法、激活函数、归一化等。每个算子单独执行时都需要从显存读取数据、计算、再写回显存。如果能把多个连续的算子融合成一个就可以减少显存读写次数提升计算效率。这就是算子融合的基本思路。以Transformer模型为例最常见的融合模式是“矩阵乘法偏置加法激活函数”融合成一个算子。在原生PyTorch中这三个操作是分开执行的需要三次显存读写。融合之后只需要一次显存读写理论上能减少三分之二的显存带宽消耗。实测下来这种融合能带来15%到30%的推理加速。算子融合通常由推理引擎自动完成比如TensorRT和ONNX Runtime都有内置的融合优化。但有时候引擎的自动融合不够激进或者某些算子不支持融合这时候就需要手动干预。手动融合的方式包括修改模型结构把多个操作合并成一个自定义算子或者使用引擎提供的插件机制注册自定义融合规则。内核调优是另一个层面的优化。同一个矩阵乘法不同的CUDA内核实现比如不同的分块策略、不同的线程组织方式性能可能相差数倍。推理引擎通常会内置多个内核实现在构建引擎时自动选择最快的那个。这个过程叫做内核自动调优需要在实际硬件上运行多次基准测试所以构建引擎的时间会比较长。但一旦构建完成推理时的性能就是最优的。5. 服务化与可观测性让系统跑得稳、看得清5.1 健康检查与优雅退出别让重启变成事故推理服务上线之后难免需要重启更新模型或者修复Bug。如果重启方式不对正在处理的请求会被直接中断用户看到的就是一堆错误。优雅退出的核心思想是收到退出信号后不再接受新请求但继续处理已经接收的请求等所有进行中的请求都完成后再真正退出。实现优雅退出需要注意几个细节。首先要区分“就绪”和“存活”两个状态。存活检查只关心进程是否还在运行就绪检查关心进程是否能正常处理请求。在退出过程中进程还活着但不应该再接收新请求所以存活检查通过就绪检查不通过。负载均衡器会根据就绪检查的结果把流量从该实例上摘除。其次要设置一个合理的退出超时时间。如果某些请求处理时间特别长不能无限等待超过超时时间后强制退出。最后要确保退出过程中日志和监控数据能正常上报方便事后排查问题。我在实际项目里踩过一个坑一开始没有做优雅退出每次重启都会导致几百个请求失败。后来加上了优雅退出逻辑重启期间的请求失败率降到了零。具体做法是在FastAPI的shutdown事件里等待所有进行中的请求完成同时设置一个30秒的超时时间。如果30秒内还有请求没完成就记录日志并强制退出。5.2 指标采集延迟、吞吐、显存、错误率一个都不能少没有度量就没有优化。一个生产级的推理服务至少需要采集以下几类指标请求延迟P50、P95、P99、吞吐量QPS、显存使用率、GPU利用率、错误率、队列长度。这些指标能帮你快速定位性能瓶颈和异常情况。延迟指标要分阶段采集比如预处理时间、推理时间、后处理时间、总时间。这样当延迟升高时你能立刻知道是哪个环节出了问题。吞吐量指标要区分成功请求和失败请求失败请求不计入有效吞吐。显存使用率要同时监控池内使用率和实际GPU显存占用两者差距过大可能意味着池子大小设置不合理。错误率要按错误类型分类比如超时错误、显存不足错误、模型推理错误不同错误类型的处理方式不同。我通常用Prometheus采集指标Grafana做可视化。在Python代码里用prometheus_client库暴露指标端点然后在Prometheus配置里加上抓取任务。下面是一个简单的指标定义示例from prometheus_client import Histogram, Counter, Gauge REQUEST_LATENCY Histogram( inference_request_latency_seconds, 推理请求延迟, buckets[0.01, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0] ) REQUEST_COUNT Counter( inference_request_total, 推理请求总数, [status] ) GPU_MEMORY_USAGE Gauge( inference_gpu_memory_usage_bytes, GPU显存使用量 ) QUEUE_LENGTH Gauge( inference_queue_length, 等待队列长度 )这些指标定义好之后在请求处理的关键节点打点即可。比如请求开始时启动计时器请求结束时观察延迟并增加计数。显存使用率和队列长度可以定期采集。5.3 日志与追踪出问题时能快速定位到具体请求日志是排查问题的第一手资料。但日志不是越多越好过多的日志会拖慢系统还会淹没关键信息。我的经验是每个请求记录一条结构化日志包含请求ID、时间戳、输入长度、输出长度、各阶段耗时、状态码。不需要记录完整的输入输出内容那会占用大量存储空间还可能泄露敏感信息。结构化日志的好处是可以方便地做聚合分析。比如你想知道过去一小时P99延迟最高的请求是哪些只需要按延迟字段排序即可。如果日志是非结构化的文本就需要写正则表达式来解析效率低且容易出错。我通常用JSON格式输出日志每条日志一行方便被日志采集系统如Filebeat、Fluentd收集和转发。分布式追踪对于微服务架构下的推理服务特别有用。一个请求可能经过网关、预处理服务、推理服务、后处理服务等多个环节如果某个环节变慢追踪系统能直观地展示整个调用链的耗时分布。常用的追踪系统有Jaeger和ZipkinPython里可以用OpenTelemetry SDK来埋点。不过追踪系统的引入会增加一定的复杂度和性能开销如果服务规模不大结构化日志加请求ID关联通常就够用了。6. 压测与调优用数据说话别靠感觉6.1 压测工具选型Locust还是wrk还是自己写压测是验证系统承载能力和发现性能瓶颈的必要手段。常用的压测工具有Locust、wrk、JMeter等。Locust基于Python可以用代码定义用户行为灵活度高适合模拟复杂的请求序列。wrk是基于C的高性能压测工具单机就能产生巨大的压力适合测极限吞吐。JMeter功能全面有图形界面适合不写代码的测试人员。对于AI推理服务的压测我推荐用Locust。原因是推理请求的输入数据往往需要动态生成比如不同长度的文本、不同尺寸的图片用Locust可以方便地在压测脚本里生成这些数据。而且Locust支持分布式压测多台机器一起施压能突破单机性能限制。wrk虽然性能更强但定制请求数据比较麻烦更适合测简单的HTTP接口。下面是一个Locust压测脚本的示例from locust import HttpUser, task, between import random class InferenceUser(HttpUser): wait_time between(0.01, 0.05) task def infer(self): seq_len random.choice([16, 32, 64, 128]) payload { input_ids: [random.randint(0, 30000) for _ in range(seq_len)], max_new_tokens: 32 } self.client.post(/v1/inference, jsonpayload)这个脚本模拟了不同输入长度的推理请求请求间隔在10到50毫秒之间随机分布。运行压测时逐步增加并发用户数观察QPS和延迟的变化曲线。6.2 找到系统的拐点从线性到雪崩的临界点在哪里每个系统都有一个性能拐点。在拐点之前QPS随并发数线性增长延迟基本稳定。过了拐点之后QPS不再增长甚至下降延迟急剧上升。找到这个拐点就找到了系统的最大承载能力。寻找拐点的方法是从低并发开始逐步增加并发数每次增加后稳定运行一段时间记录QPS和P99延迟。当P99延迟开始明显上升比如超过基线延迟的2倍时说明接近拐点了。继续增加并发直到QPS不再增长这时候就是系统的极限。我在实际测试中发现拐点往往出现在GPU利用率达到80%到90%的时候。在此之前GPU还有余力处理更多请求QPS能持续增长。超过这个利用率之后请求开始排队延迟上升QPS增长放缓。所以GPU利用率是一个很好的预警指标当它持续超过85%时就应该考虑扩容或者优化了。6.3 调优实战从QPS 12到QPS 200的完整过程我拿一个实际项目的数据来复盘整个调优过程。初始版本是一个Flask同步服务单请求处理没有批处理没有显存池化。压测结果QPS 12P99延迟3.5秒GPU利用率25%。第一步优化切换到FastAPI异步框架。QPS提升到28P99延迟降到1.8秒GPU利用率35%。提升主要来自请求接收和IO处理的异步化。第二步优化引入动态批处理批次大小32时间窗口5毫秒。QPS提升到95P99延迟降到0.6秒GPU利用率72%。提升主要来自GPU计算效率的提高。第三步优化引入显存池化预分配12GB显存池。QPS提升到120P99延迟降到0.45秒GPU利用率78%。提升主要来自显存分配开销的消除。第四步优化模型转换为TensorRT引擎启用FP16精度。QPS提升到185P99延迟降到0.28秒GPU利用率85%。提升主要来自推理引擎的底层优化。第五步优化调整批处理参数批次大小64时间窗口3毫秒最小批次16。QPS提升到210P99延迟降到0.25秒GPU利用率88%。提升主要来自批处理参数的精细化调优。整个调优过程从QPS 12到QPS 210提升了17.5倍。每一步优化都有明确的收益和代价不是盲目堆砌。比如TensorRT转换虽然提升大但转换过程花了整整两天踩了不少坑。动态批处理虽然提升明显但增加了代码复杂度需要仔细处理超时和错误传播。注意调优是一个迭代过程不要指望一步到位。每次只改一个变量测出收益后再改下一个。同时要关注优化带来的副作用比如批处理增加了延迟量化损失了精度这些都需要在实际业务场景下评估是否可接受。7. 我踩过的那些坑和总结出的经验7.1 批处理导致的延迟毛刺一个容易被忽略的副作用动态批处理虽然能大幅提升吞吐但会引入延迟毛刺。原因是批处理调度器在等待凑批的过程中先到的请求需要等待后到的请求导致延迟增加。如果时间窗口设置不当这种延迟毛刺会非常明显。我遇到过一种情况P50延迟只有50毫秒但P99延迟高达800毫秒排查后发现是批处理窗口在低流量时导致请求等待过久。解决方案是引入自适应窗口机制。当队列长度较短时缩短时间窗口甚至立即触发批处理当队列长度较长时适当延长时间窗口以凑更大的批次。这样在低流量时保证低延迟在高流量时保证高吞吐。具体实现可以用一个简单的阈值判断如果队列长度小于最小批次大小时间窗口设为1毫秒否则设为5毫秒。7.2 模型热更新不重启服务怎么换模型模型迭代是常态但每次换模型都重启服务会导致请求中断。模型热更新的思路是在服务运行过程中加载新模型等新模型就绪后把流量从旧模型切换到新模型最后卸载旧模型。整个过程对用户透明不中断服务。实现模型热更新的关键是要有一个模型版本管理机制。每个模型实例有一个版本号请求可以指定使用哪个版本也可以默认使用最新版本。切换时新请求路由到新版本旧请求继续用旧版本处理等旧版本的进行中请求全部完成后再卸载旧版本。这需要服务框架支持多模型实例共存和请求路由。我在实现时用了一个简单的引用计数机制每个模型实例维护一个活跃请求计数器请求开始时计数加一结束时计数减一。卸载模型时检查计数器是否为零不为零则等待。同时设置一个最大等待时间超过时间后强制卸载并记录日志。7.3 显存泄漏排查从现象到根因的完整链路显存泄漏是AI服务最头疼的问题之一。现象是服务运行一段时间后显存占用持续上升最终OOM崩溃。排查显存泄漏需要一套系统的方法。第一步确认是否真的是泄漏。用nvidia-smi或者PyTorch的torch.cuda.memory_summary()定期记录显存占用观察是否随时间单调上升。如果显存占用在请求量稳定时也持续上升基本可以确定是泄漏。第二步定位泄漏环节。在请求处理的关键节点插入显存快照比如请求开始时、预处理后、推理后、后处理后、请求结束时。对比不同节点的显存变化找出哪个环节的显存在请求结束后没有释放。第三步分析泄漏原因。常见的显存泄漏原因包括张量没有正确释放比如被全局变量引用、CUDA流没有同步导致张量延迟释放、自定义CUDA内核没有正确管理显存、PyTorch的缓存分配器没有及时回收。我遇到过一次泄漏是因为在异常处理分支里忘记释放中间张量导致每次异常请求都会泄漏一点显存。修复方法是在finally块里统一释放资源。7.4 一些零散但有用的经验第一日志里一定要记录请求ID并且把请求ID返回给客户端。这样当用户反馈问题时你可以根据请求ID快速找到对应的日志不用大海捞针。第二压测时要用真实的数据分布不要用全零或者随机数据。真实数据的长度分布、数值分布都会影响推理性能用假数据测出来的结果可能偏差很大。第三监控告警的阈值不要设得太敏感否则会被大量误报淹没。我通常把P99延迟的告警阈值设为基线延迟的3倍显存使用率的告警阈值设为90%并且要求持续5分钟才触发告警。第四定期做故障演练比如手动杀掉一个服务实例观察负载均衡和自动恢复是否正常工作。很多问题只有在真实故障时才会暴露出来提前演练能帮你发现这些隐患。第五文档和注释要写清楚每个参数的含义和调优建议。AI工程涉及大量参数过一段时间回头看没有注释的代码连自己都看不懂。这些经验都是从实际项目中一点一点积累起来的有些是踩了坑才明白的有些是看别人踩坑学到的。从零构建AI工程能力最重要的不是记住多少工具和框架而是建立起对系统全链路的理解知道每个环节在做什么、为什么这么做、出了问题该从哪里入手。工具会变框架会更新但这种系统性的工程思维是长期有效的。
返回列表