从零手搓AI工程:资深从业者的完整复盘与踩坑实录
从零手搓AI工程一个资深从业者的完整复盘与踩坑实录这两年“AI工程”这个词被说得太多了多到有点泛滥。招聘网站上挂着“AI工程师”的岗位点进去一看有的其实是调API的有的其实是做数据标注管理的还有的干脆就是换个名字的前端。我之所以想认真聊聊“从零构建AI工程”这件事是因为我自己在这条路上走过不少弯路——最开始以为会调几个模型接口就算入门了后来发现真正难的根本不是模型本身而是模型之外那一整套让东西能跑起来、跑得稳、跑得省钱的工程体系。这篇内容适合谁看如果你已经会用Python写点脚本对机器学习有模糊的概念但一想到要把一个模型从笔记本里的demo变成能对外服务的东西就头大那这篇就是写给你的。如果你已经是有经验的工程师想看看别人是怎么组织这套东西的也能从我的踩坑记录里找到一些共鸣。我会把整个从零搭建的过程拆开讲包括整体设计思路、核心模块的实现细节、实操步骤、参数选择的依据以及那些只有真正动过手才会知道的坑。1. 整体设计思路为什么不能上来就写模型代码1.1 先想清楚“工程”二字的重量很多人一提到AI项目第一反应是打开Jupyter Notebookimport torch然后开始搭网络。我早期也这样结果就是 notebook 里跑得挺好一旦要给别人用就彻底歇菜。问题出在哪出在把“算法”和“工程”混为一谈了。算法关心的是这个模型的准确率能不能再高两个点。工程关心的是这个模型在凌晨三点崩了怎么办输入一条脏数据会不会让整个服务挂掉推理延迟从200毫秒涨到2秒用户会不会跑光GPU账单月底会不会超预算。这两件事的思维方式完全不同。从零构建AI工程第一步不是写模型而是先画清楚整个系统的边界——数据从哪来经过哪些处理模型在哪一步介入结果怎么出去中间哪些环节可能出问题。我习惯用一张“数据流图”来开头不用什么专业工具白纸上画就行。左边是数据源中间是处理链路右边是消费方。画完之后你会发现模型往往只是中间一个小方块前后有大量的事情要做。这个认知非常重要它决定了你后面把精力花在哪里。1.2 分层架构把变化的部分隔离出来AI系统和传统软件最大的区别在于它的“核心逻辑”是会变的。今天用这个模型明天可能换一个今天这个特征有用明天可能就没用了。如果把这些易变的东西和稳定的东西混在一起写改一次就要动全身。我的做法是分三层。最底层是基础设施层负责计算资源、存储、网络这些这部分尽量用成熟方案不要自己造轮子。中间是工程能力层包括数据处理管道、特征管理、模型服务框架、监控告警这部分是整个系统的骨架需要自己精心设计。最上面是业务逻辑层针对具体场景的模型、策略、规则这部分变化最快所以要写得尽量薄、尽量独立。这样分层的好处是换模型的时候只动最上层底层的服务框架不用碰扩容的时候只动基础设施层业务代码不用改。我见过太多项目把模型推理代码和业务逻辑揉在一个文件里最后想换个模型要改几百行那滋味真的不好受。1.3 技术选型的几个关键决策选型这件事没有标准答案但有几个原则我踩坑之后总结出来了。第一优先选生态成熟的不要为了追求新潮去用一个GitHub上只有几百星的框架出了问题连搜都搜不到解决方案。第二优先选团队熟悉的一个大家都会用的普通方案比一个只有你懂的“最优方案”要靠谱得多。第三优先选能平滑演进的今天的数据量小不代表明天还小选型时要留出扩展的余地。具体到AI工程几个核心组件的选型思路是这样的。模型服务框架如果追求极致性能可以考虑专门的推理服务器如果追求开发效率用通用的Web框架加模型加载也能凑合我个人的建议是先用简单的方案跑通等真的遇到性能瓶颈再换。数据处理这块小规模用pandas完全够数据量上来了再考虑分布式方案。监控告警一开始用最基础的日志加定时检查就行不要一上来就搭一套复杂的可观测性平台那是给自己找麻烦。提示选型时最容易犯的错是“过度设计”。我见过一个日请求量不到一千的项目上来就搞了一套微服务加消息队列加分布式缓存的架构结果维护成本高得吓人最后又退回到单体应用。架构要匹配当前的规模留一点余量就好不要留太多。2. 核心模块拆解数据、模型、服务三件套2.1 数据管道脏数据是万恶之源我敢说AI工程项目里百分之七十的线上问题根源都在数据。模型本身很少无缘无故出错往往是喂进去的数据出了问题。所以数据管道的健壮性怎么强调都不过分。一个基本的数据管道要包含几个环节采集、校验、清洗、转换、存储。采集环节要处理的是数据源的多样性可能是数据库、可能是文件、可能是消息队列每种来源的读取方式都不一样。校验环节是最容易被忽略但最重要的要检查字段是否缺失、类型是否正确、数值是否在合理范围内。我吃过一次亏上游传过来一个空值模型直接抛异常整个服务挂了半小时。从那以后我养成了习惯任何进入模型的数据先过一遍校验规则。清洗和转换环节是把原始数据变成模型能吃的格式。这里有个经验转换逻辑要可配置、可回溯。什么意思就是当你想知道某条预测结果是怎么来的时候能顺着转换链路倒推回去。我一般会把每一步转换的输入输出都记录下来虽然占点存储但排查问题时能救命。存储环节要考虑的是读写模式和访问频率。训练数据通常是一次写多次读适合用文件系统或者对象存储在线服务需要的特征数据是高频读写适合用键值数据库。这两类存储不要混用否则性能会很尴尬。2.2 模型管理版本、加载与热更新模型管理这块核心要解决三个问题模型从哪来、怎么加载、怎么更新。模型从哪来说的是训练产物的管理。每次训练出来的模型文件都要有唯一的标识并且记录它的元信息——用了什么数据、什么参数、什么时间训练的、在验证集上的表现如何。这些信息不记录的话过两周你自己都不记得哪个文件是哪个版本了。我一般用“时间戳加版本号”来命名再配一个清单文件记录详细信息。怎么加载涉及到服务启动时的初始化逻辑。模型文件可能很大加载需要时间如果每次请求都重新加载那肯定不行。通常的做法是服务启动时加载到内存常驻在那里。但这里有个坑如果模型特别大加载时间可能超过服务启动的健康检查超时时间导致服务还没起来就被判定为失败。解决办法是把加载过程做成异步的服务先起来模型在后台加载加载完成前请求返回一个“准备中”的状态。怎么更新是生产环境必须考虑的问题。模型不可能一成不变总会有新版本要上线。最简单的做法是重启服务但这样会有服务中断。好一点的做法是热更新新模型加载好之后原子性地切换过去旧模型等正在处理的请求结束后再释放。实现热更新需要模型服务框架支持如果用的框架不支持那就只能接受短暂的中断了。2.3 服务接口把模型包装成可用的东西模型本身只是一个函数输入张量输出张量。但用户不会直接调用这个函数他们需要一个接口。这个接口的设计要考虑几件事输入输出的格式、错误处理、性能、安全。输入输出格式最常见的是HTTP加JSON。JSON的好处是通用、易读、好调试坏处是序列化开销大、传输体积大。如果对性能要求高可以考虑用二进制协议但开发调试会麻烦一些。我的建议是先用JSON把功能跑通真的遇到性能瓶颈再优化。错误处理是区分业余和专业的地方。模型推理可能因为各种原因失败输入格式不对、数值溢出、显存不足、超时。每一种失败都应该有明确的错误码和错误信息而不是笼统地返回一个500。用户拿到错误信息后应该知道是自己输入的问题还是服务的问题能不能重试。性能方面要关注的是延迟和吞吐。延迟是单个请求的处理时间吞吐是单位时间能处理的请求数。这两个指标往往是矛盾的提高吞吐可能会增加延迟。要根据业务场景来权衡如果是实时交互场景延迟优先如果是批量处理场景吞吐优先。安全这块最基本的是输入校验和资源限制。输入校验防止恶意构造的数据导致服务崩溃资源限制防止单个请求占用过多资源影响其他请求。这些在传统Web服务里是常识但在AI服务里经常被忽略。3. 实操过程从空目录到能跑的服务3.1 环境准备与依赖管理动手之前先把环境弄干净。我强烈建议用虚拟环境不管是venv还是conda总之不要用系统自带的Python环境。原因很简单AI项目的依赖又多又杂版本冲突是家常便饭虚拟环境能帮你隔离这些麻烦。依赖管理用requirements.txt或者更现代的pyproject.toml。关键是要锁定版本不要写torch1.0这种要写torch2.0.1这种。因为AI框架的版本差异可能导致结果完全不同今天跑得好好的明天自动升级了可能就崩了。我一般会用pip freeze把实际安装的版本导出来确保环境可复现。python -m venv ai-env source ai-env/bin/activate pip install -r requirements.txt目录结构也要提前规划好。我习惯这样组织project/ data/ # 数据相关 models/ # 模型文件 src/ # 源代码 data/ # 数据处理 model/ # 模型定义与加载 service/ # 服务接口 utils/ # 工具函数 configs/ # 配置文件 tests/ # 测试代码 logs/ # 日志输出这个结构不是死的但核心思想是按职责分目录不要把所有代码堆在一个文件夹里。3.2 数据处理模块的实现数据处理模块我一般会写成一个类把各种处理逻辑封装成方法。这样做的好处是状态可以保持比如一些需要预加载的映射表可以放在实例属性里不用每次处理都重新加载。class DataProcessor: def __init__(self, config): self.config config self.vocab self._load_vocab() def _load_vocab(self): # 加载词表等静态资源 pass def validate(self, raw_data): # 校验输入数据 if not isinstance(raw_data, dict): raise ValueError(输入必须是字典) required_fields [text, id] for field in required_fields: if field not in raw_data: raise ValueError(f缺少字段: {field}) return True def clean(self, raw_data): # 清洗数据 text raw_data[text].strip() text re.sub(r\s, , text) return text def transform(self, cleaned_data): # 转换成模型输入格式 tokens self.tokenize(cleaned_data) return {input_ids: tokens}校验方法要写得严格一点宁可误报也不要漏报。清洗方法要幂等就是同一个数据清洗两次和清洗一次结果一样这样重试的时候不会出问题。转换方法要可测试给定输入应该有确定的输出。3.3 模型加载与推理封装模型加载这块关键是把加载逻辑和推理逻辑分开。加载只在服务启动时做一次推理每次请求都要做。分开之后推理路径上的代码越少越好因为每一行都可能成为性能瓶颈。class ModelWrapper: def __init__(self, model_path, devicecpu): self.device device self.model self._load_model(model_path) self.model.eval() def _load_model(self, path): # 加载模型文件 model torch.load(path, map_locationself.device) return model torch.no_grad() def predict(self, inputs): # 推理 tensor torch.tensor(inputs[input_ids]).to(self.device) output self.model(tensor) return output.cpu().numpy().tolist()torch.no_grad()这个装饰器很重要它告诉框架不要计算梯度能省不少内存和时间。eval()模式也要记得开它会关闭dropout等训练时才用的层。这些都是小细节但累积起来影响不小。推理结果的格式转换也要注意。模型输出通常是张量要转成Python原生类型才能序列化成JSON。转换过程可能成为瓶颈如果输出很大要考虑分批转换或者用更高效的序列化方式。3.4 服务接口的搭建服务接口我用FastAPI来举例因为它写起来简洁自带文档性能也还行。核心就是定义一个路由接收请求调用处理管道返回结果。from fastapi import FastAPI, HTTPException from pydantic import BaseModel app FastAPI() processor DataProcessor(config) model ModelWrapper(model_path) class PredictRequest(BaseModel): text: str id: str class PredictResponse(BaseModel): id: str result: list latency_ms: float app.post(/predict, response_modelPredictResponse) async def predict(request: PredictRequest): start time.time() try: processor.validate(request.dict()) cleaned processor.clean(request.dict()) inputs processor.transform(cleaned) result model.predict(inputs) latency (time.time() - start) * 1000 return PredictResponse(idrequest.id, resultresult, latency_mslatency) except ValueError as e: raise HTTPException(status_code400, detailstr(e)) except Exception as e: raise HTTPException(status_code500, detail内部错误)这里有几个细节值得说。请求和响应用Pydantic模型定义能自动做类型校验和文档生成。异常处理要区分客户端错误和服务端错误客户端错误返回4xx服务端错误返回5xx。响应里带上延迟信息方便排查性能问题。启动命令很简单uvicorn src.service.main:app --host 0.0.0.0 --port 8000 --workers 4--workers指定工作进程数一般设为CPU核数或者核数的两倍。但要注意如果模型很大每个进程都加载一份会占用大量内存这时候要么减少worker数要么用共享内存的方式加载模型。3.5 配置管理与环境隔离配置千万不要硬编码在代码里。数据库地址、模型路径、超时时间这些都应该放在配置文件或者环境变量里。我用的是YAML配置文件加环境变量覆盖的方式本地开发用配置文件线上部署用环境变量。# configs/default.yaml model: path: models/latest.pt device: cpu service: host: 0.0.0.0 port: 8000 timeout: 30 data: max_length: 512 batch_size: 32读取配置的代码要处理默认值和类型转换避免因为配置缺失导致服务起不来。环境隔离的意思是开发、测试、生产用不同的配置但代码是同一份。这样能保证测试过的代码直接上生产减少意外。4. 常见问题与排查技巧实录4.1 服务启动就崩依赖与路径问题最常见的问题是服务启动时报模块找不到。这通常是两个原因依赖没装全或者Python路径不对。依赖问题好办pip install -r requirements.txt再跑一遍。路径问题稍微麻烦点特别是当你的代码分散在多个目录时。我的解决办法是在项目根目录放一个conftest.py或者setup.py把项目根目录加到Python路径里。或者在启动脚本里显式设置PYTHONPATH环境变量。这样不管从哪个目录启动都能正确找到模块。export PYTHONPATH${PYTHONPATH}:/path/to/project还有一个坑是相对导入和绝对导入混用。我的建议是统一用绝对导入从项目根目录开始写比如from src.model.wrapper import ModelWrapper。这样最不容易出错。4.2 推理结果不稳定随机性与数值精度有时候你会发现同样的输入两次推理结果不一样。如果模型里有dropout或者随机采样那结果不一样是正常的。但如果模型是确定性的结果还不一样那就要查原因了。一个常见原因是数值精度。GPU和CPU的计算精度可能不同不同GPU型号之间也可能有差异。如果对结果一致性要求高可以强制用CPU推理或者设置固定的随机种子。另一个原因是输入数据的顺序如果用了多线程处理数据到达模型的顺序可能不确定导致批处理时结果有细微差异。排查这类问题我一般会先固定随机种子然后对比单条推理和批量推理的结果再对比不同设备上的结果。一步步缩小范围总能找到原因。4.3 延迟突然飙升内存与并发问题服务跑着跑着突然变慢这种情况我遇到过好几次。原因五花八门但排查思路是类似的。先看内存。如果内存持续增长不释放那可能是内存泄漏。Python里常见的内存泄漏原因是全局变量不断累积、循环引用、缓存没有淘汰策略。用memory_profiler之类的工具可以定位到具体哪行代码在吃内存。再看并发。如果请求量上来了延迟才飙升那可能是资源竞争。模型推理通常是CPU或GPU密集型的多个请求同时推理会互相抢资源。解决办法是限制并发数或者用队列把请求排起来慢慢处理。虽然这样会增加等待时间但总比所有请求都超时要好。还有一个容易被忽略的点是日志。如果每个请求都打大量日志磁盘IO会成为瓶颈。生产环境的日志级别要调高一点只记录关键信息。4.4 常见问题速查表现象可能原因排查方法解决思路服务启动失败依赖缺失、路径错误看报错信息检查import补依赖、设PYTHONPATH推理结果不一致随机性、数值精度固定种子对比关dropout、统一设备延迟逐渐升高内存泄漏、缓存膨胀监控内存曲线加淘汰策略、修泄漏请求超时并发过高、模型太大看CPU/GPU利用率限流、换小模型返回格式错误序列化问题看原始输出转换类型、处理特殊值显存不足批量太大、模型太大看显存占用减小批量、量化模型这张表是我自己踩坑之后整理的基本上覆盖了八成以上的常见问题。遇到问题先查表能省不少时间。提示排查问题时日志是第一手资料。但日志要打对地方关键路径的输入输出、耗时、异常都要记录。我习惯在请求入口和出口各打一条日志中间的关键步骤也打点这样出问题时能快速定位到是哪一步出的问题。5. 性能优化与成本控制5.1 推理加速的几种手段模型推理慢最直接的办法是换更小的模型。但很多时候模型是业务方定的不能随便换。那就只能在工程层面想办法。批处理是最有效的加速手段之一。单个请求推理一次和十个请求一起推理一次后者平均到每个请求的时间要短得多。因为模型推理的计算量大部分是矩阵运算批量越大硬件的利用率越高。但批处理会增加延迟因为要等凑够一批才能处理。所以要在延迟和吞吐之间找平衡点我一般会设一个最大等待时间比如50毫秒超过这个时间不管凑没凑够都开始处理。量化是另一个常用手段。把模型的权重从32位浮点数降到16位甚至8位能显著减少内存占用和计算量代价是精度可能略有下降。对于很多场景来说这点精度损失是可以接受的。量化有训练后量化和量化感知训练两种前者简单但精度损失大后者复杂但效果好。缓存也很重要。如果某些输入是重复的可以把结果缓存起来下次直接返回。缓存要注意失效策略不能一直缓存旧结果。我一般会给缓存设一个过期时间比如五分钟平衡新鲜度和命中率。5.2 资源使用的监控与调优不监控就谈不上优化。最基本的监控指标包括CPU利用率、内存占用、GPU利用率、显存占用、请求延迟、请求成功率。这些指标要能实时看到最好还能设告警。监控工具的选择很多简单的用psutil自己写个脚本定时采集复杂的用Prometheus加Grafana。我的建议是先用简单的等真的需要复杂功能再升级。关键是养成看监控的习惯不要等出事了才去看。调优的思路是找到瓶颈然后针对性解决。如果CPU是瓶颈看是计算密集还是IO密集计算密集考虑加CPU或者优化算法IO密集考虑加缓存或者换更快的存储。如果GPU是瓶颈看是显存不够还是计算单元不够显存不够就减小批量或者量化计算单元不够就加GPU或者优化模型结构。5.3 成本控制的几个实用技巧AI服务的成本大头通常是计算资源。控制成本的核心是让资源利用率尽可能高。闲置的GPU就是在烧钱。一个技巧是弹性伸缩。请求多的时候自动扩容请求少的时候自动缩容。云服务商一般都有这个功能配置好触发条件就行。但要注意冷启动时间如果扩容需要几分钟那高峰期可能来不及。另一个技巧是混合部署。把延迟要求高的服务和对延迟不敏感的服务部署在一起用优先级调度来分配资源。这样高峰期优先保证核心服务低峰期把资源让给批处理任务。还有一个技巧是选择合适的硬件。不是所有场景都需要GPU很多模型在CPU上跑也够快。GPU也不是越贵越好要根据模型的计算特性来选。推理场景通常更看重显存和内存带宽训练场景更看重计算能力。6. 测试与上线别让惊喜变成惊吓6.1 测试策略从单元到集成AI工程的测试比传统软件难因为很多行为是概率性的没有确定的预期输出。但难不代表可以不做反而要做得更细致。单元测试针对每个函数和方法验证输入输出符合预期。对于数据处理函数给定输入应该有确定的输出这类测试好写。对于模型推理可以验证输出的形状、范围、类型而不是具体数值。集成测试验证整个管道是否通畅。从请求入口到响应出口走一遍完整流程检查各环节是否正常衔接。这类测试能发现单元测试发现不了的问题比如接口不匹配、配置错误。回归测试在每次改动后跑一遍确保没有破坏已有功能。AI项目特别需要回归测试因为改一个参数可能影响一大片。我一般会准备一组固定的测试用例每次上线前跑一遍对比结果有没有异常变化。6.2 灰度发布与回滚上线新版本不要一下子全量先灰度一小部分流量观察一段时间没问题再扩大。灰度期间要重点监控错误率、延迟、资源占用这些指标和旧版本对比。回滚方案要提前准备好。新版本出问题时要能快速切回旧版本。最简单的回滚是保留旧版本的部署出问题直接切流量。复杂一点的是蓝绿部署两套环境同时运行切换的时候改路由就行。我踩过的一个坑是模型文件没有版本管理回滚的时候找不到旧模型了。从那以后我养成了习惯每次上线都把模型文件、配置文件、代码版本一起打包存档回滚的时候直接拿存档就行。6.3 上线检查清单上线前过一遍这个清单能避免大部分低级错误配置文件是否正确特别是数据库地址、模型路径这些依赖是否完整版本是否锁定日志级别是否合适不要打太多也不要打太少监控告警是否配置关键指标是否有覆盖回滚方案是否准备好旧版本是否可访问压力测试是否做过容量是否够异常处理是否完善错误信息是否友好这个清单看起来简单但每次上线前认真过一遍能省掉很多半夜被叫起来处理故障的麻烦。7. 一些个人体会做AI工程这几年最大的感受是工程能力比算法能力更稀缺。会调模型的人很多但能把模型稳定、高效、低成本地跑起来的人不多。这中间的差距就是工程的价值。另一个体会是简单方案往往是最好的方案。我见过太多项目一开始就追求“高大上”的架构结果维护成本高得吓人最后又退回到简单方案。架构要匹配当前的需求和团队的能力不要为了技术而技术。最后分享一个小技巧把每次故障都记录下来。什么现象、什么原因、怎么解决的、以后怎么避免。积累一段时间后你会发现大部分故障都是重复的有了这份记录下次遇到就能快速处理。这个习惯我坚持了三年现在遇到问题基本能在一小时内定位和解决靠的就是这份积累。这个项目后续还可以扩展的方向很多比如加自动扩缩容、加A/B测试框架、加模型效果监控。但我的建议是一次只加一个加完稳定了再加下一个。贪多嚼不烂稳扎稳打才是正道。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →