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_size=32, min_batch_size=8, timeout_ms=5): 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和Zipkin,Python里可以用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", json=payload)这个脚本模拟了不同输入长度的推理请求,请求间隔在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 12,P99延迟3.5秒,GPU利用率25%。
第一步优化:切换到FastAPI异步框架。QPS提升到28,P99延迟降到1.8秒,GPU利用率35%。提升主要来自请求接收和IO处理的异步化。
第二步优化:引入动态批处理,批次大小32,时间窗口5毫秒。QPS提升到95,P99延迟降到0.6秒,GPU利用率72%。提升主要来自GPU计算效率的提高。
第三步优化:引入显存池化,预分配12GB显存池。QPS提升到120,P99延迟降到0.45秒,GPU利用率78%。提升主要来自显存分配开销的消除。
第四步优化:模型转换为TensorRT引擎,启用FP16精度。QPS提升到185,P99延迟降到0.28秒,GPU利用率85%。提升主要来自推理引擎的底层优化。
第五步优化:调整批处理参数,批次大小64,时间窗口3毫秒,最小批次16。QPS提升到210,P99延迟降到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工程能力,最重要的不是记住多少工具和框架,而是建立起对系统全链路的理解,知道每个环节在做什么、为什么这么做、出了问题该从哪里入手。工具会变,框架会更新,但这种系统性的工程思维是长期有效的。