ARTICLE DETAIL

资讯详情

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

从零搭建AI工程能力:手写推理服务与性能优化实战

从零搭建AI工程能力:手写推理服务与性能优化实战 1. 从零搭建AI工程能力为什么我劝你别一上来就调包这两年“AI工程”这个词被说得太多了多到有点变味。招聘JD上写着“AI工程师”进去一看是调API课程广告里写着“从零到一”点开是教你pip install三个库然后跑个demo。我自己带过几茬新人也帮朋友看过不少简历发现一个特别普遍的现象很多人能说出Transformer的公式却说不清一次推理请求从输入到输出中间到底经过了哪些环节、每个环节的瓶颈在哪、显存是怎么被吃掉的。ai-engineering-from-scratch这个标题我理解的核心不是“从零学AI理论”而是从零把AI工程这套东西真正搭起来——不依赖现成的高级封装自己动手把数据、模型、推理、服务、监控这条链路走通一遍。它解决的不是“模型怎么训”的问题而是“模型训出来之后怎么让它稳定、高效、可观测地跑在真实业务里”的问题。适合谁看适合已经会写Python、懂一点深度学习基础但一到工程落地就发怵的人也适合那些天天调包、心里却没底、想补上底层认知的从业者。我下面要聊的就是我自己走过一遍、也踩过不少坑的这套路径。不讲虚的讲每一步为什么这么做、参数怎么定、哪里最容易翻车。2. 整体设计思路为什么我坚持“手写一遍”而不是直接上框架2.1 先想清楚AI工程到底在工程什么很多人把AI工程等同于“训练模型”这是最大的误解。训练只是其中一环而且往往是迭代最慢的一环。真正的AI工程我习惯把它拆成五块数据处理、模型加载与推理、服务封装、性能优化、可观测性。这五块里训练相关的只占一小部分剩下全是传统后端工程师熟悉的活儿只不过对象换成了张量和显存。为什么强调“from scratch”因为高级框架把太多东西藏起来了。你调一个pipeline()它帮你做了分词、张量搬运、batch、后处理看起来很爽但一旦线上出现延迟抖动、显存OOM、输出乱码你根本不知道从哪查。手写一遍的目的是让每个环节都暴露在你眼前你知道每一步发生了什么才有能力去优化和排查。我自己的做法是先用最原始的方式把链路跑通哪怕性能很差然后再逐步引入优化。这个顺序不能反。先上框架再回头补底层学习曲线反而更陡因为你连“正常应该是什么样”都没有参照。2.2 技术选型的几个关键取舍选型这块我踩过坑说几个我现在的默认选择以及理由。推理后端早期我用Flask直接套模型简单是简单但并发一上来就崩。后来换成FastAPI主要是因为它原生支持异步配合uvicorn能扛住更高的并发。再往后如果追求极致吞吐会考虑专门的推理服务器但对大多数中小规模场景FastAPI够用了别过度设计。模型格式训练出来的权重比如PyTorch的.pt直接拿去线上推理加载慢、占用大。我一般会转成中间格式做一次图优化和算子融合。这一步能带来明显的启动速度和显存收益具体数字后面讲。批处理策略这是AI工程和普通后端最大的区别。普通接口一次处理一个请求AI推理如果也这样GPU利用率会低得可怜。所以必须做动态批处理——把短时间内到达的多个请求攒成一个batch一起送进模型。但攒多久是个学问攒太久延迟高攒太短吞吐上不去。下面这张表是我在不同业务场景下的经验取值供参考场景类型单请求延迟要求建议最大batch攒批等待窗口备注实时对话 500ms8~1610~20ms延迟敏感窗口要小内容审核 2s32~6450~100ms吞吐优先离线批处理无硬要求128不适用直接攒满再跑搜索排序 200ms16~325~15ms极敏感慎用攒批提示攒批窗口不是拍脑袋定的要结合你的P99延迟目标和GPU单次推理耗时反推。公式后面会给。2.3 目录结构一开始就规划好别等乱了再改我见过太多项目代码全堆在一个main.py里跑到后面自己都找不到东西。from scratch搭的时候就该把结构定下来。我常用的结构是这样的ai-service/ ├── configs/ # 配置文件模型路径、超参、阈值都放这 ├── core/ # 核心逻辑模型加载、推理封装 │ ├── model.py │ ├── tokenizer.py │ └── postprocess.py ├── api/ # 接口层 │ ├── routes.py │ └── schemas.py # 请求/响应数据结构校验 ├── utils/ # 工具日志、计时、显存监控 ├── tests/ # 测试尤其是边界输入 └── app.py # 入口这么分的好处是模型相关的改动只动core/接口改动只动api/互不干扰。等你要换模型或者加新接口时就知道这个结构有多省心了。3. 核心细节拆解每个环节的坑和参数怎么定3.1 模型加载别小看这一步它能决定你服务能不能起来模型加载看着简单其实坑不少。第一个问题是加载时机。如果你在每次请求里加载模型那服务基本没法用。正确做法是服务启动时加载一次常驻内存。但这里有个细节如果用多进程部署比如gunicorn起多个worker每个worker都会加载一份模型显存直接翻倍。我早期就吃过这个亏4个worker把一张卡的显存吃爆了。解决办法有两个一是用单进程多线程配合异步二是用共享内存或者专门的模型服务把模型独立出来。小规模场景我推荐前者简单可控。第二个问题是精度选择。训练常用FP32但推理用FP16甚至INT8往往就够了而且显存占用和速度都有明显改善。我实测过一个中等规模的模型FP32转FP16后显存占用降了约40%推理速度提升约30%而输出质量在绝大多数任务上肉眼几乎看不出差异。当然涉及数值敏感的任务比如某些回归预测要谨慎得做对比验证。# 模型加载的核心逻辑示意 import torch def load_model(model_path, devicecuda, use_halfTrue): # 先加载到CPU避免直接占显存 model torch.load(model_path, map_locationcpu) model.eval() # 必须否则dropout等层行为不对 if use_half: model model.half() model model.to(device) # 关闭梯度计算省显存 for param in model.parameters(): param.requires_grad False return model注意model.eval()这行千万别漏。我有次排查一个“输出每次都不一样”的诡异问题查了半天发现是忘了切eval模式dropout还在起作用。3.2 分词与预处理最容易被忽视的性能杀手大家注意力都在模型上但分词和预处理往往是隐藏的瓶颈。尤其是Python里的纯Python分词实现单条处理可能就要几毫秒到几十毫秒batch一大这部分耗时甚至超过模型推理本身。我的优化思路是能向量化就向量化能缓存就缓存。比如分词结果如果输入重复率高可以加一层LRU缓存。再比如padding不要每条都pad到最大长度而是按batch内实际最大长度pad能省不少计算。还有一个细节是截断策略。超长输入直接截断会丢信息我一般用“头尾保留中间截断”的方式因为很多任务里开头和结尾的信息密度更高。截断长度要根据你的模型最大长度和业务实际分布来定别直接取模型上限那样浪费算力。3.3 推理执行batch、显存、延迟三者的平衡推理执行是核心。这里最关键的概念是吞吐和延迟的权衡。增大batch能提升吞吐单位时间处理的请求数但会增加单请求延迟因为要等攒批且大batch单次计算更久。我一般会做一个简单的压测来确定最优batch。方法是这样固定一批测试请求分别用batch1、2、4、8、16……跑记录吞吐和P99延迟画成曲线找那个“吞吐已经接近饱和但延迟还没爆”的点。这个点通常就是你的最优工作点。显存方面要留足余量。我习惯让峰值显存占用不超过总显存的80%剩下20%留给碎片和突发。显存不够时的表现往往是OOM直接崩而不是优雅降级所以宁可保守。# 动态批处理的简化逻辑 import asyncio from collections import deque class BatchScheduler: def __init__(self, max_batch16, wait_ms20): self.max_batch max_batch self.wait_ms wait_ms self.queue deque() async def add_request(self, item): self.queue.append(item) # 攒批逻辑要么攒满要么等够时间 if len(self.queue) self.max_batch: return await self._flush() await asyncio.sleep(self.wait_ms / 1000) return await self._flush() async def _flush(self): batch list(self.queue) self.queue.clear() # 送进模型推理 return await self._infer(batch)这段逻辑是简化版真实场景还要处理超时、异常、请求取消等情况但核心思想就是这个。3.4 后处理与输出别让最后一步毁了体验后处理包括解码、格式化、业务规则过滤等。这里最常见的坑是解码参数。比如生成类任务temperature、top_p、top_k这几个参数直接决定输出风格。温度太高输出发散太低又死板重复。我一般从temperature0.7、top_p0.9起步再根据业务调。还有一个容易忽略的点是输出长度控制。不设上限模型可能一直生成下去既浪费算力又可能产生无意义内容。设上限又要考虑业务需求太短截断太长浪费。我的经验是设一个合理上限同时配合“遇到结束符就停”的逻辑。4. 实操过程从空目录到能跑的服务4.1 环境准备与依赖管理环境这块我强烈建议用虚拟环境别在系统Python里乱装。conda或者venv都行我个人偏好venv轻量。依赖管理用requirements.txt或者pyproject.toml把版本号锁死。AI这块的库版本兼容性特别差今天能跑的代码明天可能就因为某个库升级跑不了了。python -m venv venv source venv/bin/activate # Windows用 venv\Scripts\activate pip install torch fastapi uvicorn transformers pip freeze requirements.txt提示torch的安装要对应你的CUDA版本装错了要么用不了GPU要么直接报错。装之前先nvidia-smi看驱动支持的CUDA版本。4.2 把模型跑起来最小可用版本先别管性能先让它能跑。写一个最简单的推理函数输入一条文本输出结果。这一步的目的是验证模型加载、分词、推理、解码这条链路是通的。def simple_infer(text): inputs tokenizer(text, return_tensorspt).to(device) with torch.no_grad(): outputs model(**inputs) result postprocess(outputs) return result跑通之后你会对整条链路的耗时有个直观感受。我建议在这里加个计时把分词、推理、后处理各自的耗时打出来这样你就知道瓶颈在哪了。4.3 封装成HTTP服务用FastAPI封装定义好请求和响应的数据结构。这里要注意输入校验别让非法输入把服务搞崩。from fastapi import FastAPI from pydantic import BaseModel app FastAPI() class InferRequest(BaseModel): text: str max_length: int 128 class InferResponse(BaseModel): result: str latency_ms: float app.post(/infer, response_modelInferResponse) async def infer(req: InferRequest): import time start time.time() result await scheduler.add_request(req.text) latency (time.time() - start) * 1000 return InferResponse(resultresult, latency_mslatency)启动命令uvicorn app:app --host 0.0.0.0 --port 8000 --workers 1。注意worker数前面说过多worker会多份模型。4.4 压测与调优用数据说话服务起来后用压测工具打一波。我用的是locust或者简单的ab。重点看三个指标QPS、P50延迟、P99延迟。然后调整batch大小和攒批窗口重复压测找到最优组合。我做过一组对比同一个模型batch1时QPS约15P99约80msbatch16时QPS约120P99约220ms。吞吐提升了8倍延迟增加了不到3倍。对于吞吐优先的场景这个交换非常划算。但如果你的业务要求P99必须低于150ms那batch就不能开太大。4.5 加监控出问题时你能第一时间知道监控至少要有请求量、延迟分布、错误率、显存占用、GPU利用率。我用prometheus加grafana轻量好用。显存和GPU利用率可以用pynvml采集。from pynvml import nvmlInit, nvmlDeviceGetHandleByIndex, nvmlDeviceGetMemoryInfo nvmlInit() handle nvmlDeviceGetHandleByIndex(0) def get_gpu_mem(): info nvmlDeviceGetMemoryInfo(handle) return info.used / info.total这个指标特别重要显存一旦接近上限服务随时可能崩提前告警能救命。5. 常见问题与排查技巧实录5.1 服务启动就OOM怎么办这是最常见的问题。排查顺序先看是不是多worker导致的多份模型是的话减worker再看模型精度能不能转FP16然后看是不是加载时没释放中间变量。我遇到过一次是加载模型时同时保留了原始权重和转换后的权重两份都在显存里白白浪费一倍。5.2 延迟忽高忽低怎么查延迟抖动通常有几个来源攒批窗口不稳定、GC垃圾回收停顿、显存碎片、其他进程抢占GPU。排查时先看是不是周期性的周期性的话多半是GC或者定时任务。我一般会在关键路径上打点把每个阶段耗时都记下来抖动时一看就知道是哪段。5.3 输出结果不稳定/每次不一样如果模型是生成类任务本身就有随机性这是正常的。但如果确定性任务输出不稳定先检查model.eval()有没有加再检查有没有随机种子没固定。还有一次我是因为输入预处理里有个字典遍历顺序不确定导致结果飘这种隐蔽的坑最难查。5.4 常见问题速查表现象可能原因排查方向解决思路启动OOM多worker/精度过高看worker数、显存占用减worker、转FP16延迟抖动GC/攒批不稳打点看各阶段耗时调GC参数、固定窗口输出不稳定未eval/随机种子检查模型模式加eval、固定种子吞吐上不去batch太小/GPU没吃满看GPU利用率增大batch、优化预处理请求超时攒批窗口过长看窗口配置缩短窗口、加超时5.5 几个我踩过的坑第一个坑是日志打太多。有次我在推理循环里打了详细日志结果IO成了瓶颈QPS直接掉一半。后来改成采样打日志只在异常时打全量。第二个坑是异常没兜住。模型推理偶尔会因为输入异常抛错如果没try-except整个服务可能挂掉。我现在的做法是每个请求都包一层异常处理出错返回兜底结果同时记录日志。第三个坑是版本没锁。有次线上更新某个依赖库自动升级了接口行为变了排查了一整天才发现。从那以后所有依赖版本全部锁死。6. 性能优化的进阶思路6.1 算子融合与图优化模型里的很多小算子可以合并成一个大算子减少kernel启动开销和内存搬运。这一步通常通过模型转换工具完成转换后能带来可观的加速。我实测过一个模型转换后推理速度提升约25%显存降低约15%。6.2 量化用精度换速度INT8量化能把模型大小压到原来的四分之一速度提升也很明显。但量化有精度损失必须做充分的对比验证。我的做法是准备一批代表性测试数据量化前后跑一遍看关键指标差异是否在可接受范围内。6.3 缓存能不算就不算如果业务里重复输入多加一层结果缓存能省大量算力。缓存key用输入的哈希注意要处理缓存失效和内存上限。我用的是LRU策略简单有效。6.4 异步与流水线把预处理、推理、后处理拆成流水线用异步并行起来能进一步提升吞吐。这个复杂度较高建议在单阶段优化到位后再考虑。7. 我个人的一些体会这套东西我从头走了一遍最大的感受是AI工程的难点不在AI在工程。模型本身有现成的但怎么让它稳定高效地跑起来全是工程活。很多人卡住不是因为不懂模型是因为不懂服务、不懂性能、不懂排查。另外就是别追求一步到位。先跑通再优化这个顺序不能乱。我见过太多人一上来就想搞动态批处理、量化、流水线结果基础链路都没通debug都无从下手。最后分享一个小技巧每次改动只动一个变量然后压测对比。同时改好几个地方出了问题你根本不知道是哪个改动导致的。这个习惯帮我省了无数时间。这套路径后续还能往几个方向扩展一是多模型管理二是A/B测试框架三是自动扩缩容。等基础打牢了这些都不难加。
返回列表