尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

从零手搓AI工程流水线:推理服务性能调优与生产级部署实战

从零手搓AI工程流水线:推理服务性能调优与生产级部署实战 1. 为什么我要从零手搓一套AI工程流水线第一次看到ai-engineering-from-scratch这个项目名的时候我正被一堆“调包侠”式的教程搞得有点烦。满屏都是pip install之后三行代码跑通一个模型跑完就完事了可一旦要把这东西放到真实环境里跑起来各种问题就全冒出来了显存炸了、推理延迟忽高忽低、批量请求把服务打挂、模型版本对不上、日志里全是看不懂的报错。我相信很多做AI应用的朋友都有同感——会用框架和会做工程中间隔着一整条鸿沟。ai-engineering-from-scratch这个标题核心关键词就是AI工程和从零构建。它不是一个教你调API的速成课而是一套完整的、从底层开始搭建AI工程能力的实践路径。说白了它要解决的是“模型能跑”到“服务能扛”之间的所有脏活累活。这套内容适合谁适合那些已经会用PyTorch或TensorFlow跑通demo但一上生产就抓瞎的算法工程师也适合后端转AI方向、想搞清楚推理服务到底怎么回事的开发者还适合技术负责人需要判断团队AI工程化到底该从哪些环节入手。我自己的背景是后端出身后来转做AI平台踩过的坑可以说能写一本书。这次借着拆解这个项目标题的机会我把从零搭建AI工程体系的核心思路、关键细节和实操经验完整梳理一遍。文章会比较长因为AI工程本身就是个系统工程每个环节都有太多值得展开的细节。我会尽量用大白话把原理讲清楚同时给出可以直接抄作业的配置和代码片段。你不需要全部看完再动手可以挑自己最薄弱的环节先读边看边试。2. 整体架构设计与技术选型思路2.1 从“能跑”到“能扛”的核心差距在哪很多人觉得AI工程就是写个Flask接口把模型包起来这个认知偏差是后面所有痛苦的根源。我见过太多团队模型在notebook里准确率95%一上线QPS过10就崩。差距到底在哪我总结下来是四个维度的缺失资源管理、请求调度、状态监控和版本治理。资源管理这块notebook里你一个人用一张卡想怎么跑怎么跑。但线上服务是多个请求共享同一张卡显存怎么分配、batch怎么拼、什么时候该排队什么时候该拒绝这些都需要显式设计。请求调度更复杂同步请求和异步请求混在一起长请求把短请求堵死没有超时控制的话一个慢查询能拖垮整个服务。状态监控是很多小团队完全忽略的服务跑起来就不管了直到用户投诉才发现延迟已经涨了十倍。版本治理则是模型迭代的刚需A/B测试、灰度发布、回滚机制这些在传统后端早就成熟的东西在AI服务里往往一片空白。ai-engineering-from-scratch这个项目的价值就在于它把这四个维度都覆盖到了而且是从最基础的原理开始讲不是直接甩一个Kubernetes配置让你抄。我特别认同这种思路因为只有理解了为什么需要这些组件你才能在面对具体问题时做出正确的取舍。2.2 技术栈选型的取舍逻辑从零搭建AI工程体系技术选型是第一个岔路口。我的建议是推理框架用ONNX Runtime或TensorRT服务框架用FastAPI任务队列用Redis加Celery监控用Prometheus加Grafana。这套组合不是唯一解但它是经过大量生产验证的、学习曲线相对平缓的方案。为什么推理不用原生PyTorch因为原生PyTorch的Python GIL锁和动态图机制在高并发场景下性能损失很大。ONNX Runtime把模型转成静态图推理时没有Python解释器开销实测下来同样的模型QPS能提升3到5倍。TensorRT在NVIDIA卡上还能进一步优化但它的模型转换更麻烦适合对延迟极度敏感的场景。我一般建议先用ONNX Runtime跑通有极致性能需求再上TensorRT。服务框架选FastAPI而不是Flask核心原因是异步支持。AI推理是典型的IO密集加计算密集混合场景FastAPI的async/await能让请求在等待推理结果时释放事件循环处理其他请求的预处理逻辑。Flask的同步模型在这种场景下线程池很容易被打满。当然FastAPI也不是银弹如果你的推理是纯CPU计算且没有IO等待那同步框架反而更简单直接。任务队列这块Celery虽然老但生态最成熟Redis作为broker足够轻量。如果你的场景是实时推理其实不需要Celery直接在FastAPI里用run_in_executor把推理放到线程池就行。但如果你有批量离线推理、模型预热、定时评估这些需求Celery的定时任务和任务链能力就很有价值了。监控用Prometheus加Grafana是行业标配但我要强调一点AI服务的监控指标和普通Web服务不一样。除了QPS、延迟、错误率这些常规指标你还需要监控GPU利用率、显存占用、batch size分布、推理队列长度。这些指标直接决定了你的服务能不能扛住流量高峰。2.3 目录结构设计与模块划分一个清晰的目录结构能让后续开发少走很多弯路。我推荐的结构是这样的ai-service/ ├── app/ │ ├── main.py # FastAPI入口 │ ├── config.py # 配置管理 │ ├── models/ # 模型定义与加载 │ │ ├── loader.py │ │ └── registry.py │ ├── inference/ # 推理逻辑 │ │ ├── engine.py │ │ └── preprocess.py │ ├── api/ # 路由层 │ │ ├── routes.py │ │ └── schemas.py │ ├── tasks/ # 异步任务 │ │ └── batch_jobs.py │ └── utils/ # 工具函数 │ ├── metrics.py │ └── logger.py ├── tests/ ├── docker/ ├── requirements.txt └── README.md这个结构的关键在于关注点分离。models目录只管模型加载和版本管理inference目录只管推理逻辑api目录只管请求解析和响应组装。这样当你要换推理框架或者加新模型时改动范围是可控的。我见过太多项目把所有逻辑塞在一个main.py里到后面加个新模型要改十几个地方维护成本极高。配置管理我单独放在config.py里用Pydantic的BaseSettings从环境变量读取。这样做的好处是本地开发和线上部署用同一套代码只是环境变量不同。千万不要把配置硬编码在代码里也不要搞多个配置文件分支环境变量是最干净的方案。3. 核心模块的深度拆解与实操要点3.1 模型加载与版本管理的正确姿势模型加载看起来简单其实坑很多。最常见的错误是在FastAPI的启动事件里直接torch.load然后全局变量持有模型对象。这样做在单进程下没问题但一旦你用Gunicorn起多个worker每个worker都会加载一份模型显存直接翻倍。更严重的是如果模型文件很大启动时间会非常长健康检查可能直接超时。我的做法是延迟加载加单例模式。服务启动时不加载模型第一个请求进来时才加载加载完成后缓存在进程内。同时用文件锁或者Redis锁保证多个worker不会同时加载。代码大概长这样import threading from functools import lru_cache _model_lock threading.Lock() _model_cache {} def get_model(model_name: str, version: str): cache_key f{model_name}:{version} if cache_key in _model_cache: return _model_cache[cache_key] with _model_lock: if cache_key in _model_cache: return _model_cache[cache_key] model load_model_from_disk(model_name, version) _model_cache[cache_key] model return model版本管理这块我强烈建议模型文件按版本号命名并且保留至少三个历史版本。线上出问题时回滚是最快的止血手段。模型注册表可以用一个简单的JSON文件维护记录每个版本的路径、加载时间、指标表现。不需要上MLflow这种重型工具除非你的模型数量超过50个。注意模型加载完成后一定要做一次预热推理用真实形状的输入跑一遍。我遇到过模型加载成功但第一次推理报错的情况原因是某些算子需要初始化CUDA上下文不预热的话第一个真实请求会超时。3.2 推理引擎的性能调优关键参数推理引擎的性能调优是AI工程的核心技能。以ONNX Runtime为例有几个参数直接决定了性能表现参数推荐值作用踩坑经验intra_op_num_threadsCPU核数的一半单算子并行线程数设太大反而因上下文切换变慢inter_op_num_threads2到4算子间并行线程数设太大在GPU场景下无意义execution_modeORT_SEQUENTIAL执行模式并行模式在GPU上容易显存竞争graph_optimization_levelORT_ENABLE_ALL图优化级别首次加载慢但推理快enable_mem_patternTrue内存复用动态shape场景要关掉这些参数不是拍脑袋定的我做过对比测试。在一台16核CPU、V100 GPU的机器上用ResNet50做基准intra_op_num_threads设为8时QPS是120设为16时反而降到95。原因是线程太多导致CPU缓存命中率下降线程切换开销增加。inter_op_num_threads在GPU场景下影响很小因为计算主要在GPU上CPU只负责调度。动态batch是另一个关键优化点。固定batch size为1时GPU利用率可能只有20%把batch拼到8或16能显著提升吞吐。但batch太大会导致延迟增加需要根据业务场景找平衡点。我的经验是实时接口batch size控制在4到8离线任务可以到32甚至64。实现上可以用一个简单的队列加超时机制请求进来先入队队列满或者等待超过10毫秒就触发一次推理。import asyncio from collections import deque class BatchScheduler: def __init__(self, max_batch8, max_wait_ms10): self.queue deque() self.max_batch max_batch self.max_wait max_wait_ms / 1000 async def add_request(self, input_data): future asyncio.Future() self.queue.append((input_data, future)) if len(self.queue) self.max_batch: await self._process_batch() else: asyncio.get_event_loop().call_later( self.max_wait, lambda: asyncio.ensure_future(self._process_batch()) ) return await future这段代码的核心思想是用时间换吞吐。单个请求等最多10毫秒但换来的是GPU利用率从20%提升到70%以上。对于大多数业务场景10毫秒的额外延迟用户完全无感但服务能扛的QPS翻了好几倍。3.3 请求预处理与后处理的工程化封装预处理和后处理是最容易被低估的环节。很多人觉得不就是把图片resize一下、把输出取个argmax吗但在生产环境里这两个环节的代码量往往比模型推理本身还多。预处理要处理的问题包括输入格式校验、异常值处理、尺寸归一化、数据类型转换、设备搬运。我见过因为输入图片是CMYK格式导致推理结果完全错误的案例也见过因为输入文本包含特殊字符导致tokenizer崩溃的情况。所以预处理的第一原则是防御性编程对所有输入做严格校验。def validate_and_preprocess(raw_input): if raw_input is None: raise ValueError(input is None) if isinstance(raw_input, str): if len(raw_input) 0: raise ValueError(empty string input) if len(raw_input) MAX_TEXT_LENGTH: raw_input raw_input[:MAX_TEXT_LENGTH] elif isinstance(raw_input, bytes): if len(raw_input) MAX_IMAGE_SIZE: raise ValueError(image too large) raw_input decode_image(raw_input) else: raise TypeError(funsupported input type: {type(raw_input)}) return normalize(raw_input)后处理的核心是输出格式的稳定性。模型输出的logits、概率、边界框这些都需要转换成业务方约定的格式。我建议后处理逻辑单独抽成模块并且写完整的单元测试。因为后处理的bug往往很隐蔽比如softmax的数值稳定性问题、边界框坐标的归一化问题这些在测试集上可能看不出来但线上会偶发。还有一个容易被忽略的点是后处理的性能。如果后处理里有Python循环处理大数组那它可能比推理还慢。能用numpy向量化操作的就不要用for循环能用numba加速的就加个jit装饰器。我实测过一个NMS后处理纯Python实现要200毫秒改成numpy向量化后只要5毫秒。4. 完整实操流程从零搭建一个可用的推理服务4.1 环境准备与依赖安装的避坑指南环境准备这一步我踩过的坑比后面所有环节加起来都多。最大的教训是一定要用Docker并且锁定所有依赖的版本号。Python的依赖地狱在AI领域尤其严重PyTorch、CUDA、cuDNN、ONNX Runtime之间的版本兼容性极其脆弱。我的Dockerfile基础镜像选择逻辑是这样的如果只用CPU推理用python:3.10-slim就够了镜像小启动快。如果需要GPU用nvidia/cuda:11.8-runtime-ubuntu22.04作为基础然后手动装Python。不要用nvidia/cuda:11.8-devel那个镜像有好几个G里面全是编译工具运行时根本用不到。依赖安装的顺序也很重要。先装CUDA相关的系统包再装PyTorch最后装ONNX Runtime和其他库。因为PyTorch的pip包会自带CUDA运行时如果顺序反了可能会冲突。requirements.txt里所有包都要写死版本号比如onnxruntime-gpu1.16.3不要写onnxruntime-gpu1.16。我吃过这个亏某次自动升级到1.17后API变了服务直接起不来。FROM nvidia/cuda:11.8-runtime-ubuntu22.04 RUN apt-get update apt-get install -y \ python3.10 python3-pip libgl1 libglib2.0-0 \ rm -rf /var/lib/apt/lists/* WORKDIR /app COPY requirements.txt . RUN pip3 install --no-cache-dir -r requirements.txt COPY . . CMD [uvicorn, app.main:app, --host, 0.0.0.0, --port, 8000]注意libgl1和libglib2.0-0这两个系统包是OpenCV的依赖不装的话import cv2会报错。这个错误信息很隐晦新手很容易卡在这里。4.2 推理服务的核心代码实现服务入口我用FastAPI核心路由只有两个一个健康检查一个推理接口。健康检查要返回模型加载状态和GPU可用性这样Kubernetes的liveness probe才能正确判断服务是否就绪。from fastapi import FastAPI, HTTPException from pydantic import BaseModel import time app FastAPI() class InferenceRequest(BaseModel): model_name: str version: str latest inputs: list class InferenceResponse(BaseModel): request_id: str outputs: list latency_ms: float app.get(/health) async def health(): gpu_available check_gpu() models_loaded list_loaded_models() return { status: ok, gpu_available: gpu_available, models_loaded: models_loaded } app.post(/predict, response_modelInferenceResponse) async def predict(req: InferenceRequest): start time.perf_counter() try: model get_model(req.model_name, req.version) processed validate_and_preprocess(req.inputs) outputs await run_inference_async(model, processed) result postprocess(outputs) except ValueError as e: raise HTTPException(status_code400, detailstr(e)) except Exception as e: logger.exception(inference failed) raise HTTPException(status_code500, detailinternal error) latency (time.perf_counter() - start) * 1000 metrics.observe_latency(req.model_name, latency) return InferenceResponse( request_idgenerate_request_id(), outputsresult, latency_mslatency )这段代码有几个设计决策值得说明。第一异常处理分层输入校验失败返回400内部错误返回500不要把堆栈信息暴露给客户端。第二延迟统计用perf_counter而不是time.time前者精度更高且不受系统时钟调整影响。第三推理用async包装通过run_in_executor把阻塞的推理调用放到线程池避免阻塞事件循环。推理引擎的封装我单独放在inference/engine.py里核心是一个InferenceEngine类持有ONNX Runtime的session对象。这个类要处理输入输出的名称映射、设备选择、batch维度处理。ONNX模型的输入输出名称可以通过session.get_inputs()获取不要硬编码因为不同模型导出的名称可能不一样。class InferenceEngine: def __init__(self, model_path, devicecuda): providers [CUDAExecutionProvider, CPUExecutionProvider] \ if device cuda else [CPUExecutionProvider] self.session ort.InferenceSession(model_path, providersproviders) self.input_names [i.name for i in self.session.get_inputs()] self.output_names [o.name for o in self.session.get_outputs()] def infer(self, inputs: dict): feed {name: inputs[name] for name in self.input_names} return self.session.run(self.output_names, feed)4.3 监控指标埋点与日志规范监控埋点这件事我的原则是先埋后优。不要一开始就追求完美的指标体系先把最核心的几个指标埋上跑起来之后再根据实际排查问题的需要补充。核心指标我建议至少包含这几类请求量按模型、按状态码、延迟分布P50、P95、P99、GPU利用率、显存占用、队列长度。Prometheus的Python客户端用起来很简单定义一个Counter和一个Histogram就行。from prometheus_client import Counter, Histogram, Gauge REQUEST_COUNT Counter( inference_requests_total, Total inference requests, [model_name, status] ) REQUEST_LATENCY Histogram( inference_latency_seconds, Inference latency, [model_name], buckets[0.01, 0.05, 0.1, 0.5, 1.0, 5.0] ) GPU_MEMORY Gauge(gpu_memory_used_bytes, GPU memory used) QUEUE_LENGTH Gauge(inference_queue_length, Pending requests in queue)日志规范这块我强烈建议用结构化日志不要用print或者简单的字符串拼接。结构化日志的每条记录都是JSON格式包含timestamp、level、request_id、model_name、latency_ms等字段。这样在ELK或者Loki里查询的时候可以直接按字段过滤排查效率提升十倍不止。import structlog logger structlog.get_logger() logger.info( inference_completed, request_idreq_id, model_namemodel_name, latency_mslatency, batch_sizelen(inputs), gpu_memory_mbget_gpu_memory() )注意日志里千万不要打印完整的输入数据尤其是用户上传的图片或文本。一是隐私合规问题二是大日志会拖慢IO。只记录数据的形状、类型和哈希值就够了。5. 常见问题与排查技巧实录5.1 显存泄漏与OOM的排查路径显存泄漏是AI服务最头疼的问题之一。表现是服务跑一段时间后显存逐渐涨满最后OOM崩溃。排查思路是先确认是PyTorch的缓存机制还是真正的泄漏。PyTorch的CUDA缓存分配器会缓存已分配的显存nvidia-smi看到的显存占用比实际使用量高是正常的。用torch.cuda.memory_allocated()看实际分配量用torch.cuda.memory_reserved()看缓存量。如果allocated持续增长那就是真泄漏。真泄漏的常见原因有三个第一在推理循环里不断创建新的tensor而没有释放第二把tensor存到了全局列表或字典里第三异常路径下没有释放中间变量。排查方法是用tracemalloc或者objgraph追踪对象引用找到持续增长的对象类型。ONNX Runtime的显存管理相对简单它没有PyTorch那种缓存机制显存占用基本等于模型大小加batch数据大小。如果ONNX Runtime也出现显存增长那大概率是输入数据没有正确释放检查一下numpy数组的引用计数。5.2 推理延迟波动的根因分析延迟波动比延迟高更可怕因为它让容量规划变得不可能。我遇到过P50延迟20毫秒但P99延迟2秒的情况用户体验极差。根因分析下来主要有这么几类现象可能原因排查方法解决方案周期性延迟尖刺其他进程抢占GPUnvidia-smi查看GPU进程隔离GPU或限制其他进程随机长尾延迟batch拼凑等待日志记录batch等待时间减小max_wait_ms逐渐变慢内存碎片或缓存失效监控内存和缓存命中率定期重启或优化内存分配特定输入变慢动态shape导致重新编译记录输入shape分布固定shape或预编译常用shape动态shape是延迟波动的隐形杀手。ONNX Runtime和TensorRT在遇到新的输入shape时会重新做图优化和内存规划这个过程可能耗时几百毫秒。如果你的服务接收变长文本或不同尺寸图片一定要监控shape分布对高频shape做预热。我自己的做法是在服务启动时用一组典型shape做预热覆盖80%以上的请求场景。对于超出预热范围的shape要么拒绝要么走慢速路径。这样虽然牺牲了一点灵活性但换来了延迟的稳定性。5.3 模型更新时的平滑发布策略模型更新是另一个容易出事的环节。直接替换模型文件然后重启服务会导致重启期间服务不可用而且如果新模型有问题回滚也需要时间。我的策略是双缓冲加灰度流量。同时加载新旧两个模型通过配置中心控制流量比例。新模型先接1%的流量观察核心指标延迟、错误率、业务指标没有异常后逐步增加到10%、50%、100%。整个过程不需要重启服务用户完全无感。实现上可以用一个简单的权重路由import random class ModelRouter: def __init__(self): self.models {} # version - engine self.weights {} # version - weight def route(self): versions list(self.weights.keys()) weights list(self.weights.values()) chosen random.choices(versions, weightsweights, k1)[0] return self.models[chosen]灰度期间要重点监控业务指标而不只是技术指标。我见过新模型延迟正常、错误率正常但业务转化率下降了5%的情况。原因是新模型的输出分布有细微变化虽然技术指标看不出来但影响了下游业务逻辑。所以灰度发布一定要拉上业务方一起看数据。注意回滚策略要提前演练。不要等到出事了才第一次执行回滚脚本那时候手忙脚乱很容易出错。我建议每次发布前都跑一遍回滚流程确保回滚时间在1分钟以内。5.4 高频踩坑速查表最后整理一份我这些年踩过的坑按出现频率排序坑症状原因解决CUDA版本不匹配import torch报错PyTorch和系统CUDA版本不一致用PyTorch自带的CUDA运行时多worker显存翻倍显存占用是预期的N倍每个worker独立加载模型延迟加载加共享内存首次推理超时第一个请求特别慢CUDA上下文初始化启动时预热推理中文路径报错模型加载失败ONNX Runtime不支持中文路径模型路径用英文日志爆盘磁盘很快写满打印了完整输入数据只记录元信息健康检查误判服务被反复重启健康检查太简单加入模型加载状态检查批量请求OOM大batch时崩溃没有限制最大batch设置max_batch并排队版本回滚失败旧模型加载不了旧模型文件被覆盖保留至少三个历史版本这些坑每一个我都实际遇到过有些还反复踩了好几次。写在这里是希望你能跳过这些阶段直接进入稳定运行的环节。AI工程这件事技术深度是一方面但更多时候是细节的堆砌。一个参数没设对一个异常没捕获都可能导致线上事故。6. 性能压测与容量规划的实际操作6.1 压测工具选择与脚本编写性能压测是上线前的必修课。工具选择上wrk和locust是我用得最多的两个。wrk适合纯HTTP接口的极限压测性能极高单机就能打出几万QPS。locust适合模拟真实用户行为支持Python脚本定义复杂的请求序列。对于AI推理服务我推荐用locust因为你需要模拟真实的输入数据分布。用wrk的话只能发固定请求测出来的QPS偏乐观。locust的脚本大概长这样from locust import HttpUser, task, between import numpy as np class InferenceUser(HttpUser): wait_time between(0.01, 0.05) task def predict(self): input_data np.random.randn(1, 3, 224, 224).tolist() self.client.post(/predict, json{ model_name: resnet50, version: v1, inputs: input_data })压测的时候要逐步增加并发数不要一上来就拉满。从10并发开始每稳定运行2分钟增加10并发同时记录QPS、延迟、GPU利用率。当延迟开始非线性增长或者错误率上升时那个并发数就是当前配置的容量上限。6.2 容量估算与资源规划容量估算的核心是找到吞吐量和延迟的平衡点。假设你的业务要求P99延迟不超过200毫秒那压测时就要找到满足这个延迟的最大QPS。然后根据业务峰值QPS留出1.5到2倍的余量来规划资源。GPU资源规划有个经验公式所需GPU数 峰值QPS / 单卡QPS × 安全系数。安全系数取1.5到2取决于业务对延迟的敏感程度。比如单卡能扛100 QPS峰值QPS是500那至少需要8到10张卡。CPU和内存的规划相对简单但要注意预处理和后处理的开销。如果预处理很重比如大图片解码CPU可能成为瓶颈。压测时要同时监控CPU利用率和GPU利用率如果CPU先到100%而GPU只有50%那瓶颈就在CPU侧需要优化预处理或者增加CPU资源。注意容量规划不是一次性的工作。模型更新、业务增长、数据分布变化都会影响容量。我建议每季度重新做一次压测至少每半年更新一次容量规划。6.3 自动扩缩容的触发策略Kubernetes的HPA默认基于CPU和内存扩缩容但对AI服务来说GPU利用率和推理队列长度是更好的指标。GPU利用率高说明计算资源紧张队列长度长说明请求积压这两个指标比CPU更能反映AI服务的真实负载。配置HPA用自定义指标需要装Prometheus Adapter配置稍微麻烦一点但值得。我的扩缩容策略是这样的队列长度超过10持续1分钟就扩容队列长度低于2持续5分钟就缩容。扩容要快缩容要慢因为扩容不及时会导致请求超时缩容太快会导致频繁抖动。apiVersion: autoscaling/v2 kind: HorizontalPodAutoscaler metadata: name: inference-hpa spec: scaleTargetRef: apiVersion: apps/v1 kind: Deployment name: inference-service minReplicas: 2 maxReplicas: 20 metrics: - type: Pods pods: metric: name: inference_queue_length target: type: AverageValue averageValue: 10缩容的时候要小心冷启动问题。新Pod启动后需要加载模型和预热这段时间它不能处理请求。所以缩容后如果流量突然回升新Pod还没准备好就会导致请求失败。我的做法是设置minReplicas为2保证任何时候都有热备实例同时把缩容的稳定窗口设长一点比如5分钟。7. 我在这套流程里踩过的真实坑7.1 一次线上OOM的完整复盘去年有一次线上服务突然大面积OOM监控显示显存从正常的4G在10分钟内涨到了16G然后崩溃。当时第一反应是显存泄漏但排查下来发现是输入数据分布变化导致的。具体来说我们的服务接收用户上传的图片平时都是几百KB的小图那天有个用户上传了一批超高分辨率的大图8000x6000。预处理时我们把图片resize到224x224但resize之前先把原图加载到了GPU上做归一化。大图加载到GPU后单张就占了几个G显存并发几个请求直接把卡打爆了。修复方案很简单预处理全部在CPU上做只把最终的小tensor传到GPU。这个改动让显存占用直接降了一半而且因为CPU和GPU可以并行工作整体吞吐还提升了。这个坑让我深刻理解了一个原则GPU只做它最擅长的事数据搬运和格式转换交给CPU。7.2 模型版本不一致导致的诡异bug还有一次更诡异的线上服务返回的结果和测试环境不一致但代码和模型文件明明是一样的。排查了一整天最后发现是ONNX Runtime的版本差异。测试环境用的是1.15线上用的是1.16两个版本对某个算子的实现有细微差别导致输出有微小差异。这个差异在单测里看不出来但在业务逻辑里被放大了。从那以后我定了个规矩测试环境和生产环境的所有依赖版本必须完全一致用同一个Docker镜像。镜像构建一次测试通过后直接推到生产不要在生产环境重新构建。这个规矩看起来简单但执行起来需要CI/CD流程的配合很多团队做不到。7.3 批量推理的batch size选择经验batch size的选择我做过大量实验结论是没有万能值必须根据业务场景实测。实时接口我一般从4开始试逐步增加到8、16观察P99延迟的变化。如果P99延迟增加不超过20%那就用更大的batch。离线任务我直接拉到32或64因为延迟不敏感吞吐优先。还有一个细节是batch内的数据要尽量同质。如果batch里混了不同shape的输入ONNX Runtime会按最大的shape做padding浪费计算资源。我的做法是在调度队列里按shape分组相同shape的请求拼成一个batch。这样虽然增加了调度复杂度但GPU利用率能提升30%以上。8. 后续可以继续深挖的方向这套从零搭建的AI工程体系跑通之后还有几个方向值得继续投入。模型量化是性价比最高的优化把FP32模型转成INT8推理速度能提升2到4倍精度损失通常在1%以内。ONNX Runtime和TensorRT都支持量化操作也不复杂建议优先尝试。多模型共享GPU是另一个实用方向。通过NVIDIA MPS或者时间片轮转让多个小模型共享一张卡能显著降低资源成本。但要注意隔离性一个模型OOM不能影响其他模型。推理缓存在特定场景下效果惊人。如果请求有重复性比如热门商品推荐把推理结果缓存起来能减少大量计算。缓存key可以用输入的哈希值缓存过期时间根据业务特点设置。最后A/B测试框架是模型迭代的基础设施。没有A/B测试模型更新就是盲人摸象。建议在服务里内置流量分割能力配合业务指标监控让每次模型更新都有数据支撑。这些方向我后续会逐个展开每个都值得单独写一篇。AI工程这条路从零开始确实辛苦但每一步踩实了后面就会越来越顺。希望这篇长文能帮你少走一些弯路把精力花在真正创造价值的地方。
返回列表