尧图精选

从零搭建AI工程能力:推理服务、显存管理与性能优化实战

🕒 发布时间:2026/10/1 5:48:04 📁 来源:尧图网络
1. 从零搭建AI工程能力为什么“会调包”远远不够很多人对AI工程的理解停留在“会调API、能跑通一个demo”的层面。拿一个预训练模型套上几行推理代码输出看起来像模像样就觉得AI工程不过如此。但真正进入生产环境之后问题会一个接一个冒出来模型加载慢、显存不够用、推理延迟高、批量请求吞吐上不去、版本管理混乱、部署之后效果和本地测试不一致……这些问题的根源往往不在于模型本身而在于工程能力没有跟上。“ai-engineering-from-scratch”这个方向核心思路就是不依赖现成的高级封装从底层把AI工程的关键环节一个一个搭起来。它的价值不在于“重复造轮子”而在于通过亲手实现真正理解每个环节的工作原理、性能瓶颈和设计取舍。适合已经具备一定Python基础、了解机器学习基本概念但在工程落地方面缺乏系统训练的开发者。不管你是算法转工程还是后端想切入AI方向从零搭建这套能力都会让你在后续面对真实项目时更有底气。我自己的经历是早期做模型部署时直接用一个开源推理框架跑起来就完事了结果线上QPS一上来就崩排查了半天才发现是预处理环节成了瓶颈。后来痛定思痛把整个推理链路拆开从数据加载、预处理、模型前向、后处理到结果返回每一段都自己实现一遍才真正搞清楚问题出在哪里。这篇文章就把这套从零搭建的思路和实操细节完整梳理出来。2. 推理服务的骨架从单次调用到批量并发2.1 先搞清楚一次推理到底经历了什么很多人写推理代码就是一行model(input)但这一行背后其实包含了一长串操作。以常见的深度学习推理为例完整链路大致是原始输入数据文本、图像、音频等→ 预处理分词、归一化、resize等→ 张量转换numpy到tensor、设备搬运→ 模型前向计算→ 后处理解码、阈值过滤、格式转换→ 返回结果。从零搭建的第一步就是把这个链路显式地拆开每一段都写成独立的函数或类。这样做的好处是你能清楚地知道时间花在了哪里显存占用在哪个环节飙升哪个步骤可以并行化。我习惯用time.perf_counter()在每个环节打点跑个几十次取平均一张简单的耗时分布表就出来了。import time class InferencePipeline: def __init__(self, model, preprocess_fn, postprocess_fn): self.model model self.preprocess_fn preprocess_fn self.postprocess_fn postprocess_fn def __call__(self, raw_input): t0 time.perf_counter() processed self.preprocess_fn(raw_input) t1 time.perf_counter() tensor self._to_tensor(processed) t2 time.perf_counter() with torch.no_grad(): output self.model(tensor) t3 time.perf_counter() result self.postprocess_fn(output) t4 time.perf_counter() self._log_timing(t0, t1, t2, t3, t4) return result实测下来很多场景下预处理和后处理加起来的时间能占到总耗时的30%到50%尤其是文本分词和图像resize这类操作。如果你只盯着模型前向的耗时做优化方向就偏了。2.2 批处理不是简单地把数据堆在一起单次推理跑通之后下一步自然是批处理。但批处理远不是把多条数据拼成一个batch丢进去那么简单。你需要考虑几个关键问题动态batch还是固定batch、batch内数据长度不一致怎么处理、超时和延迟怎么权衡。动态batch的思路是维护一个请求队列当队列长度达到预设阈值或者等待时间超过设定上限时取出当前队列中的所有请求组成一个batch。这样做的好处是吞吐量高但单条请求的延迟会增加。固定batch则是每次严格凑够N条才推理延迟更可控但低峰期资源利用率低。我一般会用一个带超时机制的动态batch策略核心逻辑如下import threading import queue class BatchScheduler: def __init__(self, max_batch_size32, max_wait_ms50): self.max_batch_size max_batch_size self.max_wait_ms max_wait_ms self.request_queue queue.Queue() self.lock threading.Lock() def add_request(self, data, callback): self.request_queue.put((data, callback)) with self.lock: if self.request_queue.qsize() self.max_batch_size: self._flush() def _flush(self): batch [] while not self.request_queue.empty() and len(batch) self.max_batch_size: batch.append(self.request_queue.get_nowait()) if batch: self._process_batch(batch)这里有个容易踩的坑batch内不同样本的预处理结果长度可能不一致。比如文本分类任务有的句子10个token有的50个token。直接拼在一起会报错你需要做padding对齐。padding策略也有讲究一般用最短补齐到batch内最大长度即可不需要补齐到全局最大长度否则浪费计算资源。2.3 并发模型的选择线程、进程还是异步Python的GIL限制让很多人对多线程望而却步但在AI推理场景下情况有些特殊。如果你的推理后端是PyTorch或TensorFlow这类会释放GIL的库多线程是可以实现并发的。但如果预处理是纯Python的CPU密集操作多线程就基本没用得考虑多进程。我的经验是IO密集的环节用异步或多线程CPU密集的环节用多进程GPU推理环节单独用一个线程或进程管理。一个典型的架构是主进程负责接收请求和调度预处理放在进程池里跑GPU推理在一个独立线程中串行执行因为GPU本身是串行处理kernel的后处理再丢回进程池。这种架构听起来复杂但拆开实现之后每个部分都很清晰。关键是要做好进程间通信我一般用multiprocessing.Queue或者concurrent.futures来管理。注意多进程方案下模型参数在每个进程里都会有一份拷贝显存占用会成倍增加。如果显存紧张考虑用共享内存或者把模型放在单独的服务进程里。3. 模型加载与显存管理那些文档里不会写的事3.1 模型加载慢先看看瓶颈在哪从零搭建AI工程能力模型加载是绕不过去的一环。很多人遇到的问题是服务启动要等好几分钟模型才加载完。这背后的原因可能有很多模型文件太大、磁盘IO慢、初始化时做了不必要的计算、或者框架本身的加载逻辑有开销。我一般的排查顺序是先用time打点看加载耗时主要花在哪个阶段。如果是磁盘IO瓶颈考虑把模型文件放到更快的存储上或者用内存映射的方式加载。如果是框架初始化开销可以尝试延迟加载也就是服务启动时只加载必要的部分其余部分在第一次请求时再加载。另一个实用技巧是模型预热。服务启动后先用几条假数据跑一遍完整推理链路让CUDA kernel完成编译、内存池完成分配。这样第一个真实请求的延迟就不会特别高。预热的数据最好覆盖不同的输入长度避免运行时因为遇到新长度而触发重新编译。def warmup(pipeline, sample_inputs, n_rounds3): for _ in range(n_rounds): for inp in sample_inputs: pipeline(inp) torch.cuda.synchronize()3.2 显存不够用几个立竿见影的手段显存管理是AI工程中最容易出问题的环节之一。常见的手段有梯度检查点推理场景用不上、混合精度FP16代替FP32显存直接减半、模型量化INT8量化显存降到四分之一、动态显存分配PyTorch的torch.cuda.empty_cache()。混合精度是我最推荐的手段改动小、收益大。在PyTorch里推理时用torch.autocast上下文管理器就能开启with torch.no_grad(), torch.autocast(device_typecuda, dtypetorch.float16): output model(input_tensor)实测下来FP16推理相比FP32显存占用减少约40%到50%速度提升20%到30%精度损失在大多数任务上可以忽略不计。但要注意有些操作在FP16下会溢出比如softmax之前的logits如果数值很大FP16可能表示不了。这时候需要对特定层做特殊处理或者用torch.autocast的enabledFalse临时关闭。模型量化则是更激进的手段。INT8量化能把显存降到FP32的四分之一但精度损失需要评估。我一般会先在验证集上跑一遍量化后的模型对比指标下降幅度如果下降在可接受范围内比如1%以内就放心用。3.3 多模型共存时的显存调度实际项目中往往不是只有一个模型在跑。你可能同时需要加载一个文本分类模型、一个实体识别模型、一个向量化模型。如果每个模型都独占一份显存很快就会OOM。我的做法是按需加载、用完即卸。维护一个模型池记录每个模型的使用频率和最近使用时间。当显存不足时优先卸载最久未使用的模型。加载新模型之前先估算它需要的显存如果当前空闲显存不够就触发卸载逻辑。class ModelPool: def __init__(self, max_memory_mb): self.max_memory_mb max_memory_mb self.models {} self.last_used {} def get_model(self, name, loader_fn, required_mb): if name in self.models: self.last_used[name] time.time() return self.models[name] self._ensure_memory(required_mb) model loader_fn() self.models[name] model self.last_used[name] time.time() return model def _ensure_memory(self, required_mb): while self._used_memory() required_mb self.max_memory_mb: oldest min(self.last_used, keyself.last_used.get) del self.models[oldest] del self.last_used[oldest] torch.cuda.empty_cache()这套逻辑实现起来不复杂但能显著提升显存利用率。需要注意的是卸载模型后一定要调用torch.cuda.empty_cache()否则PyTorch的缓存分配器不会立刻把显存还给系统。4. 数据预处理与后处理的工程化4.1 预处理为什么容易成为性能瓶颈预处理环节看起来简单无非是分词、归一化、resize这些操作但实际跑起来往往比模型前向还慢。原因在于这些操作大多是CPU密集的而且很多库的实现并没有针对性能做优化。以文本分词为例Python的split()方法在短文本上很快但面对长文本或者大批量数据时纯Python循环的开销就上来了。我一般会优先用C扩展实现的分词库或者把分词逻辑用Cython重写。如果实在没有现成的库可以考虑用multiprocessing把分词任务分发到多个进程。图像预处理也是类似。PIL的resize方法在单张图上很快但批量处理时用OpenCV的cv2.resize通常更快因为OpenCV底层是C实现而且支持多线程。另外如果resize的目标尺寸是固定的可以提前算好插值权重避免每次重复计算。import cv2 import numpy as np def batch_resize(images, target_size): resized [] for img in images: resized.append(cv2.resize(img, target_size, interpolationcv2.INTER_LINEAR)) return np.stack(resized)4.2 后处理的坑解码、阈值、格式转换后处理环节的坑一点也不比预处理少。以目标检测为例模型输出的是大量的候选框和对应的置信度你需要做非极大值抑制NMS来过滤重叠框。NMS本身不难但实现不好会非常慢。我一般用torchvision.ops.nms它是CUDA加速的比纯Python实现快几十倍。文本生成任务的后处理则是解码策略的选择。贪心解码最简单但生成质量一般束搜索beam search质量好但计算量大采样解码多样性好但结果不稳定。实际项目中我一般会根据任务类型来选翻译、摘要这类任务用束搜索对话、创意写作这类任务用采样。还有一个容易被忽略的点是输出格式的标准化。模型输出的往往是logits或者概率值但下游系统需要的是结构化的JSON。这个转换过程如果放在Python里做很容易成为瓶颈。我的做法是尽量用向量化操作避免逐条循环。def postprocess_batch(logits, id2label, top_k1): probs torch.softmax(logits, dim-1) top_probs, top_ids torch.topk(probs, top_k, dim-1) results [] for i in range(logits.size(0)): item [] for j in range(top_k): item.append({ label: id2label[top_ids[i][j].item()], score: round(top_probs[i][j].item(), 4) }) results.append(item) return results4.3 预处理和后处理的缓存策略有些预处理操作的结果是可以缓存的。比如如果你的系统会对同一个用户的相同输入反复推理那么预处理结果完全可以缓存起来。我一般用LRU缓存设置一个合理的大小上限避免内存无限增长。from functools import lru_cache lru_cache(maxsize10000) def cached_preprocess(text): return tokenizer(text, return_tensorspt)后处理的结果也可以缓存但要注意缓存的key要包含所有影响输出的因素比如模型版本、参数配置等。否则模型更新后缓存里的旧结果会导致不一致。提示缓存虽然好用但在调试阶段建议先关掉否则很容易被“为什么改了代码没生效”这类问题困扰。5. 服务化与性能压测从能跑到跑得好5.1 用什么框架把推理服务暴露出去模型推理逻辑写好了下一步是把它变成一个服务。可选方案有很多Flask、FastAPI、gRPC、Triton Inference Server等。我的选择逻辑是如果追求开发效率和生态丰富用FastAPI如果追求极致性能用gRPC如果模型种类多、需要统一管理用Triton。FastAPI的优势在于异步支持好、自动生成文档、上手快。一个最简单的推理服务大概长这样from fastapi import FastAPI from pydantic import BaseModel app FastAPI() pipeline None class Request(BaseModel): text: str class Response(BaseModel): label: str score: float app.on_event(startup) def load_model(): global pipeline pipeline build_pipeline() app.post(/predict, response_modelResponse) async def predict(req: Request): result pipeline(req.text) return Response(**result)但FastAPI默认是单进程的要发挥多核性能需要配合uvicorn的--workers参数或者用gunicorn管理多个worker。这里有个坑每个worker都会加载一份模型显存占用会成倍增加。如果显存不够就只能用单worker加异步的方式。5.2 压测怎么做才有参考价值压测不是简单地用ab或wrk打一波请求就完事了。有参考价值的压测需要控制好几个变量并发数、请求分布、输入长度分布、持续时间。我一般会先用低并发跑一轮确认服务稳定然后逐步提高并发观察QPS和延迟的变化曲线。当QPS不再上升而延迟急剧增加时就找到了系统的拐点。这个拐点对应的并发数就是服务的最佳工作点。输入长度分布也很关键。如果压测时全用短输入得到的QPS会虚高。实际生产中输入长度往往是长尾分布少数超长输入会拖慢整体性能。所以压测数据要尽量模拟真实分布至少包含短、中、长三档。# 用wrk做压测的示例 wrk -t4 -c100 -d60s --latency -s post.lua http://localhost:8000/predict压测过程中要同时监控CPU、内存、显存、GPU利用率。如果GPU利用率很低但QPS上不去说明瓶颈在CPU侧的预处理或后处理。如果GPU利用率很高但QPS还是低说明模型本身计算量大需要考虑模型压缩或换更小的模型。5.3 线上出问题了怎么快速定位线上服务出问题第一反应不应该是重启而是保留现场。我一般会先看几个关键指标请求量是否突增、延迟是否突增、错误率是否突增、资源利用率是否异常。如果延迟突增但请求量没变可能是某个依赖服务变慢了或者模型推理本身变慢了。这时候需要看推理链路的耗时分布定位到具体环节。如果错误率突增先看错误日志区分是输入数据问题、模型问题还是系统问题。我习惯在服务里内置一个/debug接口返回当前的队列长度、平均延迟、显存占用等信息。这样出问题时不用登录机器就能快速了解服务状态。app.get(/debug) def debug_info(): return { queue_size: scheduler.queue_size(), avg_latency_ms: metrics.avg_latency(), gpu_memory_mb: torch.cuda.memory_allocated() / 1024 / 1024, model_loaded: pipeline is not None }6. 版本管理与持续迭代让每次更新都可控6.1 模型版本怎么管才不乱模型版本管理是AI工程中容易被忽视但极其重要的一环。我见过太多团队用model_final_v2_new.pt这种命名方式过两个月谁也不知道哪个文件对应哪个版本。我的做法是模型文件用哈希命名版本信息存在数据库或配置文件中。每次训练产出一个模型计算其SHA256哈希值用哈希值作为文件名。同时记录这个模型的元信息训练数据版本、超参数、评估指标、产出时间、负责人。import hashlib def save_model_with_hash(model, base_dir): tmp_path f{base_dir}/tmp_model.pt torch.save(model.state_dict(), tmp_path) with open(tmp_path, rb) as f: file_hash hashlib.sha256(f.read()).hexdigest()[:16] final_path f{base_dir}/model_{file_hash}.pt os.rename(tmp_path, final_path) return file_hash这样做的好处是模型文件不会重复相同内容的模型只会存一份版本回滚时只需要改配置里的哈希值模型文件可以安全地共享和分发。6.2 配置与代码分离模型版本管理好了接下来是配置管理。很多团队把模型路径、阈值、batch大小这些参数硬编码在代码里每次调整都要改代码、重新部署。正确的做法是把这些参数抽到配置文件或环境变量里。我一般用YAML文件管理配置结构大致如下model: name: text_classifier version: a1b2c3d4 path: /models/model_a1b2c3d4.pt device: cuda:0 inference: max_batch_size: 32 max_wait_ms: 50 use_fp16: true preprocess: max_length: 128 truncation: true配置文件和代码一起做版本管理但部署时可以通过环境变量覆盖部分配置。这样同一份代码可以在不同环境开发、测试、生产用不同配置运行。6.3 灰度发布与回滚模型更新不能一刀切必须支持灰度发布。我的做法是新模型上线后先切一小部分流量比如5%到新模型观察一段时间。如果各项指标正常逐步扩大流量比例如果指标异常立即切回旧模型。实现灰度发布的关键是请求路由。可以在服务入口处根据请求ID或用户ID做哈希决定走新模型还是旧模型。这样同一个用户的请求会稳定地路由到同一个模型避免体验不一致。def route_model(request_id, new_model_ratio0.05): hash_val int(hashlib.md5(request_id.encode()).hexdigest(), 16) if (hash_val % 100) (new_model_ratio * 100): return new return old回滚则要保证足够快。我的经验是旧模型不要立刻卸载保留至少一个版本的热备。这样回滚时只需要改路由配置不需要重新加载模型秒级生效。7. 一些踩坑之后的个人体会从零搭建AI工程能力这件事我最大的体会是工程能力的提升靠的不是学了多少框架而是踩了多少坑之后还能爬起来。框架会更新API会变化但底层的原理和解决问题的思路是相对稳定的。另一个体会是不要过早追求完美。我见过一些团队一开始就想着搭一套大而全的推理平台结果几个月过去了连一个能用的服务都没上线。正确的做法是先跑通最小闭环然后根据实际遇到的问题逐步优化。性能不够就优化性能显存不够就优化显存版本乱了就加版本管理。每一步优化都有明确的动机而不是为了“架构好看”。最后分享一个实用小技巧在服务里加一个/health接口返回模型是否加载、显存是否正常、队列是否拥堵。这个接口看起来简单但在排查线上问题时能省下大量时间。配合监控系统做定时探测还能在问题影响用户之前就发现异常。这套从零搭建的思路我在多个项目中反复验证过不管是文本、图像还是多模态场景核心逻辑都是相通的。希望这些经验能帮你在AI工程的路上少走一些弯路。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →