1. 从零搭建AI工程能力为什么我劝你别一上来就调包这两年AI应用开发的门槛肉眼可见地降低了随便拉个框架、调个API就能跑出一个能对话的Demo。但我带过不少新人也看过很多团队的项目发现一个很普遍的现象大家能把模型跑起来却说不清楚数据是怎么流的、推理延迟卡在哪、显存为什么突然爆了、上线之后怎么监控效果衰减。这就是典型的“会用工具不懂工程”。ai-engineering-from-scratch这个标题我理解它想表达的核心诉求是抛开那些封装好的高级框架从最底层的环节开始把AI工程涉及的关键能力一块一块搭起来。它不是一个具体的开源项目名而是一类学习路径和工程实践的统称。适合谁看我觉得有三类人一是刚转行做AI应用开发、只会调API的工程师二是想从算法研究转向工程落地的同学三是带团队的技术负责人需要一套可复用的工程规范来约束项目质量。这篇文章我会按照我自己实际搭建和踩坑的顺序来写从整体设计思路讲到每个环节的具体实现包括数据处理、模型推理、服务封装、性能调优和线上排查。不会只讲概念每个部分都会给出可操作的步骤和参数依据。你不需要全部照搬但至少能拿到一套可以落地的参考方案。2. 整体设计思路为什么选择从底层开始搭2.1 先想清楚“从零”到底指什么很多人看到“from scratch”第一反应是“不用任何框架纯手写”。我的理解不是这样。从零的核心是掌控关键路径而不是拒绝所有工具。你可以用PyTorch做训练、用FastAPI做服务、用Redis做缓存但你必须清楚每一层在干什么出了问题能定位到具体环节。我见过一个典型的反面案例某团队用了一个高度封装的推理服务框架上线后QPS上不去排查了两天发现是框架内部默认开了同步日志刷盘每次请求都等磁盘IO。如果他们一开始就知道请求链路里有哪些环节十分钟就能定位。所以“从零”的真正含义是你拥有对整条链路的解释权和修改权。基于这个判断我把整个工程能力拆成五个层次数据层、模型层、服务层、监控层、迭代层。每一层都有最小可用的实现方式也有进阶优化空间。下面这张表是我建议的能力拆解和对应的核心问题。层次核心能力典型问题数据层数据清洗、格式转换、版本管理训练和推理的数据预处理不一致模型层模型加载、推理执行、显存管理批处理大小怎么定、显存碎片怎么防服务层接口封装、并发处理、超时控制并发上来后延迟飙升、请求堆积监控层指标采集、日志记录、告警线上效果衰减发现太晚迭代层模型更新、A/B测试、回滚新模型上线后无法快速回退2.2 技术选型的几个关键取舍在搭建过程中有几个选型决策会直接影响后续的维护成本我逐个说下我的考量和理由。推理框架选型PyTorch原生推理、ONNX Runtime、TensorRT这三者怎么选我的经验是如果你还在频繁调整模型结构用PyTorch原生最省心如果模型结构稳定、追求跨平台部署ONNX Runtime是平衡点如果追求极致延迟且部署环境有NVIDIA GPUTensorRT值得投入。但注意TensorRT的转换和调优有学习成本小团队慎入。服务框架选型FastAPI、Flask、Tornado。FastAPI的异步支持和自动文档生成对AI服务特别友好我基本默认选它。Flask适合极简场景Tornado在高并发长连接场景有优势但生态不如前两者。这里的关键不是框架本身而是你要理解同步和异步的区别——AI推理通常是计算密集型异步框架并不能让单次推理变快但能让你在等待IO时处理其他请求。缓存策略要不要加缓存我的判断是看请求的重复率。如果相同输入占比超过20%加一层结果缓存收益很明显。缓存可以用内存字典、Redis或本地文件取决于你的服务实例数量和持久化需求。但要注意缓存失效策略模型更新后旧缓存必须清掉。注意选型没有绝对的对错但每个选择都要能回答“为什么不是另一个”。如果你说不出理由说明你还没想清楚。2.3 最小可用系统的边界定义从零搭建最容易犯的错是贪大求全一开始就想做一套完整的MLOps平台。我的建议是先定义一个最小可用系统跑通端到端流程再逐步加能力。最小可用系统应该包含一个能加载模型并对外提供HTTP接口的服务、一套基本的输入输出日志、一个能手动触发的模型更新流程。就这三样先跑起来。这个边界定义很重要因为它决定了你第一周能交付什么。很多项目死在“准备阶段”就是因为边界太模糊永远觉得还没准备好。先把最小闭环跑通后面所有优化都有附着点。3. 核心细节解析与实操要点3.1 数据预处理的一致性保障数据预处理是AI工程里最容易被低估的环节。我踩过最深的坑就是训练时的预处理和推理时的预处理不一致导致线下指标很好、线上效果崩盘。比如训练时用了某种归一化方式推理时忘了做同样的变换模型看到的输入分布完全不同。解决这个问题的核心原则是预处理逻辑必须是一份代码训练和推理共用。具体做法是把预处理封装成一个独立的模块或类训练脚本和推理服务都调用同一个模块。不要在两处各写一遍哪怕逻辑很简单。class Preprocessor: def __init__(self, config): self.mean config[mean] self.std config[std] self.max_len config[max_len] def process(self, raw_input): # 归一化 normalized (raw_input - self.mean) / self.std # 截断或填充 if len(normalized) self.max_len: normalized normalized[:self.max_len] else: normalized normalized [0] * (self.max_len - len(normalized)) return normalized这个类在训练时和推理时都实例化配置从同一个配置文件读取。这样即使后面调整了预处理逻辑两边也会同步生效。另外建议把预处理后的数据做一次校验比如检查数值范围、维度是否匹配早发现问题比线上崩了再查要好。3.2 模型加载与显存管理模型加载看起来简单但有几个细节直接影响服务稳定性。第一是加载时机我建议在服务启动时加载而不是每次请求加载。每次请求加载模型会导致首次延迟极高而且频繁的加载卸载会造成显存碎片。第二是多模型场景下的显存分配如果你需要同时加载多个模型要提前估算总显存需求。显存估算有个粗略公式模型参数量乘以4字节float32或2字节float16再加上激活值和中间结果的开销。激活值的大小和批处理大小成正比。举个例子一个1亿参数的模型float16加载需要约200MB但推理时如果批处理大小是32激活值可能额外占用几百MB到1GB不等。所以显存规划不能只看模型大小。import torch def load_model(model_path, devicecuda): # 先加载到CPU再移到GPU避免GPU内存峰值过高 model torch.load(model_path, map_locationcpu) model.eval() model model.to(device) # 如果是半精度推理 model model.half() return model注意torch.load的map_location参数很关键。如果直接加载到GPU加载过程中的临时内存峰值可能导致OOM。先加载到CPU再转移峰值会低很多。另外推理时记得用torch.no_grad()或torch.inference_mode()否则PyTorch会保留计算图显存占用会大幅增加。这个细节很多新手会忽略但效果立竿见影。3.3 服务接口的并发与超时设计AI服务的接口设计和普通Web接口有个本质区别单次请求的计算时间可能很长从几十毫秒到几秒不等。这意味着并发处理策略需要特别设计。我推荐的做法是请求队列加工作池。接口收到请求后不直接执行推理而是把请求放入队列由固定数量的工作线程或进程从队列中取出执行。这样做的好处是第一可以控制并发度避免过多请求同时推理导致显存爆掉第二可以实现请求排队和超时控制第三方便做负载均衡和优先级调度。import asyncio from concurrent.futures import ThreadPoolExecutor class InferenceService: def __init__(self, model, max_workers4, timeout10): self.model model self.executor ThreadPoolExecutor(max_workersmax_workers) self.timeout timeout async def predict(self, input_data): loop asyncio.get_event_loop() try: result await asyncio.wait_for( loop.run_in_executor(self.executor, self._infer, input_data), timeoutself.timeout ) return result except asyncio.TimeoutError: return {error: inference timeout}超时时间怎么定我的经验是取P99延迟的1.5到2倍。比如你的P99延迟是2秒超时设3到4秒比较合理。设太短会误杀正常请求设太长会让异常请求占用资源过久。3.4 日志与监控的最小实现监控不一定要上Prometheus加Grafana那套初期用日志加简单统计就能覆盖大部分需求。关键是要记录这几个指标请求量、延迟分布、错误率、输入输出的大小分布。这些数据能帮你发现大部分线上问题。我通常会在服务里加一个轻量的统计模块每处理完一个请求就更新计数器和延迟直方图然后定期输出到日志或暴露一个统计接口。import time from collections import defaultdict class Metrics: def __init__(self): self.count 0 self.errors 0 self.latencies [] def record(self, latency, is_errorFalse): self.count 1 if is_error: self.errors 1 self.latencies.append(latency) # 只保留最近1000个延迟样本 if len(self.latencies) 1000: self.latencies self.latencies[-1000:] def summary(self): if not self.latencies: return {} sorted_lat sorted(self.latencies) return { total: self.count, errors: self.errors, p50: sorted_lat[len(sorted_lat) // 2], p99: sorted_lat[int(len(sorted_lat) * 0.99)], }这个实现很粗糙但足够让你在早期发现问题。等请求量上来了再换成专业的监控系统也不迟。4. 实操过程与核心环节实现4.1 环境准备与依赖管理环境准备这块我吃过不少亏最典型的是依赖版本冲突。AI项目的依赖链很长PyTorch、CUDA、cuDNN、各种Python包之间版本兼容性很敏感。我的做法是用虚拟环境加锁定文件确保开发、测试、生产环境一致。# 创建虚拟环境 python -m venv venv source venv/bin/activate # 安装核心依赖指定版本 pip install torch2.1.0 --index-url https://download.pytorch.org/whl/cu118 pip install fastapi0.104.0 uvicorn0.24.0 pip install numpy1.24.0 # 导出锁定文件 pip freeze requirements.lock注意pip freeze导出的文件包含所有间接依赖部署时用pip install -r requirements.lock能最大程度保证环境一致。不要只记录直接依赖间接依赖的版本变化同样可能导致问题。CUDA版本的选择要和显卡驱动匹配。我一般先用nvidia-smi看驱动支持的CUDA版本然后选择不超过该版本的PyTorch CUDA构建。比如驱动支持CUDA 12.0那你可以用cu118或cu121的PyTorch但不能用cu124的。4.2 模型推理服务的完整搭建下面我把一个最小可用的推理服务完整写一遍包含模型加载、预处理、推理、后处理和接口封装。这个版本可以直接跑起来你可以基于它逐步加功能。import torch import numpy as np from fastapi import FastAPI, HTTPException from pydantic import BaseModel import uvicorn import time import logging logging.basicConfig(levellogging.INFO) logger logging.getLogger(__name__) # 请求和响应模型 class PredictRequest(BaseModel): data: list class PredictResponse(BaseModel): result: list latency_ms: float # 预处理 class Preprocessor: def __init__(self): self.mean 0.5 self.std 0.5 self.max_len 128 def process(self, raw): arr np.array(raw, dtypenp.float32) arr (arr - self.mean) / self.std if len(arr) self.max_len: arr arr[:self.max_len] else: arr np.pad(arr, (0, self.max_len - len(arr))) return arr # 服务 class InferenceService: def __init__(self, model_path): self.device cuda if torch.cuda.is_available() else cpu self.model self._load_model(model_path) self.preprocessor Preprocessor() def _load_model(self, path): model torch.load(path, map_locationcpu) model.eval() model model.to(self.device) return model torch.inference_mode() def infer(self, raw_data): processed self.preprocessor.process(raw_data) tensor torch.tensor(processed).unsqueeze(0).to(self.device) output self.model(tensor) return output.cpu().numpy().tolist() app FastAPI() service None app.on_event(startup) def startup(): global service service InferenceService(model.pt) logger.info(service started) app.post(/predict, response_modelPredictResponse) def predict(req: PredictRequest): start time.time() try: result service.infer(req.data) except Exception as e: logger.error(finference error: {e}) raise HTTPException(status_code500, detailinference failed) latency (time.time() - start) * 1000 return PredictResponse(resultresult, latency_mslatency) if __name__ __main__: uvicorn.run(app, host0.0.0.0, port8000)这个服务跑起来后你可以用curl测试curl -X POST http://localhost:8000/predict \ -H Content-Type: application/json \ -d {data: [1.0, 2.0, 3.0]}4.3 批处理推理的实现与参数选择单条推理的GPU利用率通常很低因为GPU的并行计算能力没有被充分利用。批处理推理能显著提升吞吐量但会牺牲单次延迟。怎么平衡我的经验是设置一个动态批处理窗口请求到达后等待一小段时间比如10到50毫秒把窗口内的请求合并成一个批次一起推理。import asyncio from collections import deque class BatchInferenceService: def __init__(self, model, max_batch_size32, max_wait_ms50): self.model model self.max_batch_size max_batch_size self.max_wait max_wait_ms / 1000 self.queue deque() self.lock asyncio.Lock() async def predict(self, data): future asyncio.get_event_loop().create_future() async with self.lock: self.queue.append((data, future)) if len(self.queue) self.max_batch_size: await self._process_batch() else: asyncio.get_event_loop().call_later( self.max_wait, lambda: asyncio.ensure_future(self._process_batch()) ) return await future async def _process_batch(self): async with self.lock: if not self.queue: return batch list(self.queue) self.queue.clear() inputs [item[0] for item in batch] futures [item[1] for item in batch] try: results self.model.batch_infer(inputs) for future, result in zip(futures, results): if not future.done(): future.set_result(result) except Exception as e: for future in futures: if not future.done(): future.set_exception(e)批处理大小的选择需要实测。从1开始逐步增加观察吞吐量和延迟的变化。通常存在一个拐点超过之后吞吐量增长放缓而延迟继续上升。我一般会选拐点附近的批大小作为默认值。4.4 模型热更新的实现线上服务不可能每次更新模型都重启所以需要热更新能力。实现方式有两种一是双缓冲新模型加载到另一块内存加载完成后原子切换指针二是版本化同时保留新旧模型通过路由控制流量逐步切换。class ModelManager: def __init__(self): self.current_model None self.current_version None def load_new_model(self, model_path, version): new_model torch.load(model_path, map_locationcpu) new_model.eval() new_model new_model.to(cuda) # 原子切换 self.current_model new_model self.current_version version logger.info(fmodel updated to version {version})注意热更新时旧模型占用的显存不会立即释放需要手动调用torch.cuda.empty_cache()或等待Python垃圾回收。如果显存紧张建议在低峰期做更新或者先卸载旧模型再加载新模型会有短暂的服务不可用。5. 常见问题与排查技巧实录5.1 推理延迟突然飙升的排查路径延迟飙升是最常见的线上问题我一般按这个顺序排查先看是不是请求量突增导致排队再看是不是某个请求的输入特别大导致单次推理变慢然后看GPU利用率和显存是否正常最后看是否有其他进程在争抢资源。排查步骤检查内容可能原因1请求量QPS曲线流量突增、爬虫、重试风暴2输入大小分布异常大输入、数据格式错误3GPU利用率和显存显存泄漏、其他进程占用4系统负载和IOCPU争抢、磁盘IO瓶颈5网络延迟DNS、带宽、连接池耗尽我遇到过一次延迟飙升最后发现是日志模块在每次请求时同步写磁盘磁盘IO被打满。改成异步写日志后恢复正常。这个问题的隐蔽性在于日志逻辑看起来和推理无关但它确实在请求链路里。5.2 显存泄漏的定位与解决显存泄漏的表现是服务运行一段时间后OOM重启后恢复。定位方法是定期打印显存使用量观察是否持续增长。PyTorch可以用torch.cuda.memory_allocated()和torch.cuda.memory_reserved()来查看。常见的泄漏原因有几个一是在推理循环里不断创建新的tensor而没有释放二是保留了计算图忘记用no_grad三是缓存了中间结果但没有清理策略。解决方法对应的是复用tensor、加inference_mode、给缓存设置大小上限。import torch def check_memory(): allocated torch.cuda.memory_allocated() / 1024**2 reserved torch.cuda.memory_reserved() / 1024**2 logger.info(fGPU memory allocated: {allocated:.1f}MB, reserved: {reserved:.1f}MB)建议在服务里加一个定时任务每隔几分钟打印一次显存使用情况。这样出问题时你有历史数据可以看而不是只能看到OOM的那一刻。5.3 输入数据异常的处理策略线上服务的输入数据质量往往比测试时差很多。空值、超长文本、格式错误、编码问题都会遇到。我的策略是防御性处理加明确报错。对于可以修复的异常比如超长截断、空值填充默认值自动处理并记录日志对于无法修复的异常比如格式完全不对返回明确的错误码而不是让服务崩溃。def validate_input(data): if not isinstance(data, list): raise ValueError(input must be a list) if len(data) 0: raise ValueError(input cannot be empty) if len(data) 10000: logger.warning(finput too long: {len(data)}, truncating) data data[:10000] return data注意不要静默处理所有异常。有些异常是上游系统出问题的信号如果你全部吞掉问题会被掩盖。该报错的时候要报错该告警的时候要告警。5.4 模型效果衰减的发现与应对模型上线后效果会随着时间衰减原因可能是数据分布变化、用户行为变化或模型过拟合。发现衰减的关键是建立效果监控指标。分类任务看准确率和召回率生成任务看人工评估或自动评估指标。我的做法是定期采样线上请求人工标注或自动评估和训练时的指标对比。如果发现明显下降触发模型更新流程。同时保留旧版本模型新模型上线后先小流量测试确认效果后再全量。衰减信号可能原因应对措施准确率下降数据分布变化收集新数据重新训练输出长度异常输入分布变化检查上游数据质量用户反馈变差评估标准变化重新定义评估指标特定类别效果差类别不平衡加剧针对性补充数据6. 迭代与扩展从能用走向好用6.1 性能优化的几个方向当服务稳定运行后可以考虑性能优化。方向有几个模型量化float32转float16或int8、算子融合、推理引擎替换比如用ONNX Runtime或TensorRT、批处理优化、缓存策略优化。每个方向都有收益和成本需要根据实际瓶颈来选择。我一般先用profiler找到瓶颈在哪再针对性优化。如果瓶颈在模型计算考虑量化和推理引擎如果瓶颈在数据预处理考虑并行化和缓存如果瓶颈在IO考虑异步和批量。# 使用PyTorch profiler定位瓶颈 with torch.profiler.profile( activities[torch.profiler.ProfilerActivity.CPU, torch.profiler.ProfilerActivity.CUDA], record_shapesTrue ) as prof: model(input_tensor) print(prof.key_averages().table(sort_bycuda_time_total, row_limit10))6.2 从单模型到多模型的扩展业务发展后往往需要同时服务多个模型。这时候要考虑模型隔离、资源分配和路由策略。我的建议是每个模型独立一个服务实例通过网关路由。这样模型之间互不影响也方便独立扩缩容。缺点是资源利用率可能低一些但稳定性和可维护性更好。如果资源紧张必须共享实例那要做好显存隔离和优先级控制。比如给每个模型设置显存上限超过就拒绝请求而不是OOM。优先级控制可以通过请求队列实现高优先级请求先处理。6.3 工程规范的沉淀最后说一点容易被忽略但很重要的工程规范的沉淀。从零搭建的过程中你会形成很多约定和最佳实践比如预处理代码的存放位置、模型文件的命名规则、配置项的管理方式、日志格式的统一。这些规范要写下来形成文档新成员加入时能快速上手。我自己的习惯是维护一个ENGINEERING.md记录项目里的关键约定和决策理由。每次踩坑后更新这个文档日积月累就是团队最宝贵的知识资产。这比任何教程都管用因为它是从真实项目中长出来的。我个人在实际操作中的体会是从零搭建AI工程能力最难的其实不是技术本身而是建立对整条链路的感知。你知道每个环节在干什么、为什么这么干、出问题去哪里找这比会用多少框架重要得多。框架会过时但这种工程直觉会一直跟着你。