1. 从零搭建AI工程体系,为什么我劝你别一上来就调包
这两年AI应用开发的门槛肉眼可见地降低了,一个刚入门的开发者,花一个下午就能用现成的框架跑通一个对话机器人。但我在带团队和做技术咨询的过程中发现一个很普遍的现象:很多人能跑通Demo,却搭不出一个能上线的系统。模型一换就崩,并发一上来就超时,成本失控,日志里全是看不懂的报错。这就是我特别想聊“ai-engineering-from-scratch”这个主题的原因——不是教你怎么调用某个API,而是把AI工程当成一门正经的工程学科,从最底层的地基开始,一层一层把房子盖起来。
所谓“from scratch”,不是让你从零手写一个Transformer,那属于研究范畴,跟工程是两码事。我这里说的从零,是指从工程视角出发,把数据管道、模型服务、推理优化、评估体系、监控告警这些环节,按照一个可维护、可扩展、可观测的标准搭建起来。它解决的核心问题是:让一个AI系统在真实业务流量下稳定运行,而不是只在你的笔记本上跑得欢。这套东西适合谁?适合已经会写Python、懂一点机器学习基础,但一到工程落地就抓瞎的开发者;也适合那些被“模型效果很好但线上就是不稳定”折磨过的工程师。
我见过太多团队,模型选型讨论了两周,工程架构五分钟就拍板了,结果上线后天天救火。这篇文章我会把整个搭建过程拆开,讲清楚每一步为什么这么做、坑在哪里、怎么验证。你不需要有很深的算法背景,但需要对工程有敬畏心。下面这些内容,是我自己踩过坑、也帮别人填过坑之后总结出来的,能直接抄作业的地方我会给到具体配置和参数。
2. 整体架构设计:先想清楚数据怎么流,再谈模型怎么选
2.1 为什么我把数据管道放在模型之前
很多人的第一反应是“我要用哪个模型”,但工程视角下,第一个该问的问题是“数据从哪来、到哪去、中间经过哪些处理”。我做过一个内容审核的项目,团队一开始花大力气调模型,上线后发现80%的延迟来自数据预处理——图片解码、尺寸缩放、格式转换这些“杂活”。模型推理只占了不到20%的时间。这就是典型的工程顺序搞反了。
一个健壮的AI工程体系,数据管道应该是独立于模型存在的。它的职责包括:接收原始输入、做格式校验、清洗和标准化、特征提取、批量组装、缓存管理。我通常会把这条管道设计成可插拔的,每个环节是一个独立的处理单元,用消息队列串起来。这样做的好处是,当你要换模型时,前面的数据管道完全不用动;当数据源变化时,模型侧也感知不到。
具体到技术选型,小规模场景用Redis做队列加Celery做worker就够了,别一上来就上Kafka,运维成本不划算。我实测过一个日请求量在50万左右的系统,Redis+Celery的组合完全扛得住,延迟稳定在200毫秒以内。只有当你的日请求量到千万级,或者需要多消费者组、消息回溯这些高级特性时,再考虑Kafka或Pulsar。这个判断依据很简单:看你的峰值QPS和消息积压容忍度。
2.2 模型服务层的三种形态与选择逻辑
模型服务层我一般分成三种形态来考虑,选择哪种取决于你的延迟要求、成本预算和团队能力。
第一种是嵌入式推理,就是把模型直接加载在应用进程里。优点是延迟最低,没有网络开销,适合小模型(比如量化后的BERT、蒸馏后的小模型)。缺点是模型和业务代码耦合,升级模型要重启服务,而且一个进程只能跑一个模型。我一般用ONNX Runtime来做嵌入式推理,它跨平台、性能好,Python和C++都能调。
第二种是独立推理服务,模型跑在一个单独的进程或容器里,通过HTTP或gRPC对外提供服务。这是最主流的做法,解耦做得好,可以独立扩缩容。框架上我推荐Triton Inference Server或者TorchServe,它们支持动态批处理、多模型版本管理、GPU共享这些生产级特性。Triton的配置文件稍微复杂一点,但一旦配好,后面加模型就是改个config的事。
第三种是Serverless推理,按调用次数付费,适合流量波动大、平时没什么请求的场景。但要注意冷启动问题,一个没缓存的模型冷启动可能要好几秒,对延迟敏感的业务不适用。
我通常的建议是:先用独立推理服务把架子搭起来,等业务稳定了,再把高频调用的小模型下沉到嵌入式推理去优化延迟。这个演进路径比较稳妥,不会一开始就陷入过度设计的泥潭。
2.3 评估与监控体系:没有它,你就是在盲飞
模型上线不是终点,而是起点。我见过太多团队上线后只看业务指标,模型本身的状态完全黑盒。等业务指标掉了才去查,往往已经损失了好几天的流量。所以评估和监控体系必须和模型服务同步搭建,不能事后补。
评估体系分两层:离线评估和在线评估。离线评估用固定的测试集,每次模型更新都跑一遍,看准确率、召回率、F1这些指标有没有退化。在线评估用真实流量,通过A/B测试或者影子模式来对比新旧模型的表现。影子模式特别有用——新模型接收同样的请求但不返回结果,只记录它的输出,跟线上模型的输出做对比。这样可以在不影响用户体验的前提下验证新模型。
监控体系要覆盖三个维度:系统指标(CPU、内存、GPU利用率、延迟分布)、模型指标(输入分布、输出分布、置信度分布)、业务指标(转化率、点击率、人工审核通过率)。我习惯用Prometheus采集指标,Grafana做看板,再配一套告警规则。关键是要监控输入数据的分布变化,一旦发现输入分布偏移(比如用户突然开始发某种新类型的图片),就要警惕模型可能失效。
3. 核心细节拆解:数据管道、推理优化与版本管理
3.1 数据预处理的性能陷阱与优化手段
数据预处理是AI工程里最容易被低估的环节。我做过一个图像分类的服务,最初用Pillow做图片解码和缩放,单张图片处理要30毫秒,成了整个链路的瓶颈。后来换成OpenCV的C++接口,同样的操作降到5毫秒以内。再后来用NVIDIA的DALI库,把预处理放到GPU上做,进一步降到1毫秒以下。这个优化过程让我深刻体会到,预处理不是“随便写写”的代码,它直接决定了系统的吞吐上限。
除了库的选择,还有几个实操要点。第一是批处理,把多个请求攒成一批一起做预处理,能充分利用CPU的向量化指令。但批的大小要权衡,太大增加延迟,太小浪费算力。我一般从batch size 8开始试,根据延迟和吞吐的曲线找拐点。第二是缓存,对于重复的输入(比如热门图片、常见文本),预处理结果可以缓存起来。我用Redis做二级缓存,命中率在内容类业务里能到30%以上,效果立竿见影。第三是异步化,预处理和推理可以流水线并行,用双缓冲的方式让CPU和GPU都不闲着。
注意:预处理的一致性极其重要。训练时用的预处理逻辑和线上推理时必须完全一致,否则会出现训练推理偏差。我建议把预处理逻辑封装成一个独立的模块,训练和推理共用同一份代码,避免手抖写了两套。
3.2 推理优化的四个层次与参数计算
推理优化我一般分四个层次来做,从易到难分别是:模型量化、算子融合、动态批处理、模型蒸馏。
模型量化是最容易见效的。把FP32的权重转成INT8,模型体积缩小4倍,推理速度提升2到3倍,精度损失通常在1%以内。我用ONNX Runtime的量化工具做过一个文本分类模型,FP32下单次推理12毫秒,INT8量化后降到4毫秒,准确率从94.2%掉到93.8%,完全可接受。量化的关键是校准集的选择,要用有代表性的真实数据,不能随便拿几条样本糊弄。
算子融合是把多个连续的小算子合并成一个大的算子,减少内核启动开销和内存访问。这个一般在模型导出阶段由推理框架自动完成,比如TensorRT和ONNX Runtime都有图优化pass。你不需要手动写融合规则,但要知道它的存在,在选择推理框架时把图优化能力作为一个考量因素。
动态批处理是提升GPU利用率的关键。GPU最怕的就是batch size为1的推理,算力大量浪费。Triton的动态批处理可以在服务端把多个请求攒成一批,我实测过,在QPS 100左右的场景下,开启动态批处理后GPU利用率从15%提升到60%,单次推理成本降了一半多。配置上主要调两个参数:max_batch_size和max_queue_delay。前者根据显存来定,后者根据延迟容忍度来定。我的经验值是max_queue_delay设成你延迟预算的十分之一左右,比如延迟要求100毫秒,那就设10毫秒。
模型蒸馏是用大模型教小模型,让小模型达到接近大模型的效果。这个需要训练,周期长,但收益也大。我一般把它作为最后的优化手段,当前面三个层次都榨干了再考虑。
3.3 模型版本管理与灰度发布的实操方案
模型版本管理是个容易被忽视但极其重要的工程问题。我见过团队用文件名来区分版本,比如model_v1.pth、model_v2_final.pth、model_v2_final_fix.pth,最后谁也说不清哪个是线上跑的。正确的做法是用模型注册表(Model Registry)来管理,每个版本有唯一的ID、元数据(训练数据、超参、评估指标)、状态(staging、production、archived)。
我一般用MLflow来做模型注册,它跟训练流程集成得好,也能跟推理服务对接。每次训练完自动注册一个新版本,标记为staging。经过离线评估和影子测试后,再手动或自动提升为production。推理服务从注册表拉取指定版本的模型,而不是从文件路径加载。这样版本切换就是改一个配置的事,回滚也是秒级完成。
灰度发布我推荐用流量切分的方式。新模型上线先接1%的流量,观察一段时间(至少一个完整的业务周期,比如一天),确认各项指标正常后再逐步放大到5%、10%、50%、100%。Triton支持多模型版本同时加载,配合网关的流量路由就能实现。关键是要有自动回滚机制,一旦监控到错误率或延迟超过阈值,自动把流量切回旧版本。这个阈值我一般设成错误率超过1%或者P99延迟超过基线的1.5倍。
4. 完整实操流程:从环境搭建到上线验证
4.1 环境准备与依赖锁定
环境搭建这一步,我的原则是:能用容器就用容器,能锁版本就锁版本。AI工程的依赖特别复杂,CUDA版本、cuDNN版本、PyTorch版本、Python版本,任何一个不匹配都可能出问题。我习惯用Docker来封装整个运行环境,基础镜像选NVIDIA官方的CUDA镜像,然后在上面装Python依赖。
依赖锁定用pip-compile或者poetry,把每个包的精确版本都固定下来。我踩过一个坑:开发环境用的是PyTorch 1.12,线上装的时候自动拉了最新的1.13,结果某个算子的行为变了,模型输出对不上。从那以后我所有项目都用requirements.txt锁死版本,并且用pip install --no-deps来避免依赖解析带来的意外升级。
FROM nvidia/cuda:11.8.0-cudnn8-runtime-ubuntu22.04 RUN apt-get update && apt-get install -y python3.10 python3-pip COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY app /app WORKDIR /app CMD ["python", "server.py"]requirements.txt里我会把直接依赖和间接依赖都列出来,用pip freeze生成。虽然看起来有点冗余,但能保证环境完全可复现。
4.2 推理服务的代码骨架与关键配置
推理服务的代码骨架我一般分成四层:接入层、预处理层、推理层、后处理层。接入层负责协议解析和请求校验,预处理层做数据转换,推理层调模型,后处理层把模型输出转成业务需要的格式。每层之间用明确的接口定义,方便单独测试和替换。
下面是一个基于FastAPI和ONNX Runtime的简化骨架,我实际项目里也是类似的结构:
import onnxruntime as ort from fastapi import FastAPI, HTTPException from pydantic import BaseModel import numpy as np app = FastAPI() # 启动时加载模型,只加载一次 session = ort.InferenceSession( "model.onnx", providers=["CUDAExecutionProvider", "CPUExecutionProvider"] ) class PredictRequest(BaseModel): text: str class PredictResponse(BaseModel): label: str confidence: float @app.post("/predict", response_model=PredictResponse) async def predict(req: PredictRequest): # 预处理 tokens = preprocess(req.text) input_array = np.array([tokens], dtype=np.int64) # 推理 outputs = session.run( ["logits"], {"input_ids": input_array} ) # 后处理 logits = outputs[0][0] probs = softmax(logits) label_id = int(np.argmax(probs)) return PredictResponse( label=ID2LABEL[label_id], confidence=float(probs[label_id]) )关键配置有几个地方要注意。providers的顺序决定了优先用哪个执行提供者,CUDA在前意味着有GPU就用GPU,没有就回退到CPU。session.run的输入名称必须和模型导出时定义的名称一致,这个可以用netron工具打开ONNX文件查看。另外,onnxruntime的intra_op_num_threads和inter_op_num_threads要根据CPU核数来调,我一般设成物理核数的一半,留一些给其他进程。
4.3 压力测试与性能基线建立
服务写完了别急着上线,先做压力测试,建立性能基线。我用Locust或者wrk来做压测,重点看三个指标:吞吐量(QPS)、延迟分布(P50、P95、P99)、错误率。压测要覆盖不同的并发级别,从低到高逐步加压,找到系统的拐点——也就是延迟开始急剧上升的那个点。
我做过一个文本分类服务的压测,单卡T4 GPU,batch size 1的情况下QPS只有80,P99延迟45毫秒。开启动态批处理后,batch size 8,QPS冲到420,P99延迟反而降到38毫秒。这个数据说明批处理不仅提升了吞吐,还因为更充分的GPU利用降低了尾延迟。但继续加大batch size到16,QPS只涨到480,P99延迟却升到65毫秒,性价比就下降了。所以batch size 8是这个场景的甜点。
压测结果要记录下来,作为后续优化的对照基线。每次改代码、换模型、调参数,都重新跑一遍压测,对比基线看有没有退化。这个习惯能帮你及早发现性能问题,而不是等用户投诉了才知道。
4.4 上线检查清单与回滚预案
上线前我会过一遍检查清单,确认每个环节都到位了。这个清单是我多年踩坑总结出来的,每次上线都对照着看:
| 检查项 | 确认内容 | 不通过的后果 |
|---|---|---|
| 模型版本 | 注册表里production版本正确 | 跑错模型,结果全错 |
| 依赖版本 | 与测试环境完全一致 | 行为不一致,难排查 |
| 资源配额 | GPU显存、CPU、内存留有余量 | OOM崩溃 |
| 监控告警 | 关键指标都有告警规则 | 出问题发现不了 |
| 日志级别 | 生产环境用INFO,不开DEBUG | 日志爆炸,磁盘写满 |
| 回滚方案 | 旧版本镜像还在,配置可切换 | 出问题无法快速恢复 |
| 限流熔断 | 网关层有QPS限制和熔断 | 被打挂,雪崩 |
回滚预案我一般准备两套:快速回滚和完全回滚。快速回滚是切流量,把网关的流量切回旧版本,秒级生效。完全回滚是重新部署旧版本的镜像,分钟级生效。快速回滚用于紧急情况,完全回滚用于快速回滚也解决不了的问题。两套都要提前演练,别等真出事了才试。
5. 常见问题与排查技巧实录
5.1 推理结果不一致的排查思路
这是最让人头疼的问题之一:同一个输入,两次推理结果不一样,或者线上和离线结果对不上。我遇到过几次,排查下来原因各不相同,但有一套通用的排查思路。
第一步,确认模型本身是不是确定性的。有些模型里有Dropout层,推理时如果没设成eval模式,每次输出都会随机变化。PyTorch里要显式调用model.eval(),ONNX导出时也要注意把Dropout固定住。第二步,检查预处理是否一致。浮点数运算的顺序、归一化的参数、padding的方式,任何一个细节不同都会导致结果差异。我一般会把预处理后的张量dump出来,跟离线流程逐元素对比。第三步,检查推理框架的版本和配置。不同版本的ONNX Runtime可能对同一个算子有不同的实现,精度会有微小差异。如果对精度要求极高,要锁定推理框架的版本。
实操心得:我习惯在服务启动时跑一个自检用例,用固定的输入和期望的输出做校验。如果自检不通过,服务直接拒绝启动。这样能在上线前就发现不一致问题,而不是等用户反馈。
5.2 显存泄漏与OOM的定位方法
GPU显存泄漏比内存泄漏更难排查,因为Python的垃圾回收管不到GPU显存。我遇到过一次,服务跑几个小时就OOM,重启就好,但过几小时又OOM。排查下来是每次推理都创建了一个新的CUDA tensor,但没有释放。
定位方法是用nvidia-smi或者pynvml定期采样显存使用量,画成曲线。如果曲线是锯齿状上升,那就是泄漏。然后逐步注释代码,二分查找泄漏点。常见的泄漏点包括:在循环里创建tensor、把tensor存到全局变量、异常路径下没有释放资源。解决办法是用torch.no_grad()包裹推理代码,用del显式删除不再使用的tensor,用torch.cuda.empty_cache()清理缓存。
另一个常见的OOM原因是batch size设得太大。这个好解决,压测的时候找到显存能承受的最大batch size,然后留20%的余量。我一般会在服务启动时根据可用显存动态计算batch size,而不是写死一个值。
5.3 输入分布偏移的检测与应对
模型上线后,用户的行为可能会变化,导致输入数据的分布跟训练时不一样,模型效果下降。这个问题很隐蔽,因为系统指标都正常,只有业务指标在慢慢掉。
检测方法是监控输入数据的统计特征。对于文本,监控token长度分布、词汇覆盖率、OOV率;对于图像,监控亮度分布、尺寸分布、颜色直方图。一旦发现某个特征的分布跟基线比有明显偏移(比如用KL散度或者PSI指标来量化),就触发告警。
应对策略分短期和长期。短期可以加规则兜底,比如对低置信度的样本转人工审核。长期要收集新数据,重新训练模型。我一般会设计一个数据回流机制,把线上推理的输入和输出都存下来,定期抽样标注,作为下一轮训练的增量数据。
5.4 常见问题速查表
| 现象 | 可能原因 | 排查手段 | 解决方案 |
|---|---|---|---|
| 延迟突然升高 | GPU被其他进程占用 | nvidia-smi看GPU利用率 | 隔离GPU或限制并发 |
| 结果随机变化 | Dropout未关闭 | 检查模型eval模式 | 导出时固定Dropout |
| 显存持续增长 | tensor未释放 | 采样显存曲线 | 加no_grad和del |
| 吞吐上不去 | batch size太小 | 压测不同batch size | 开启动态批处理 |
| 错误率突增 | 输入分布偏移 | 对比输入统计特征 | 加规则兜底+重训 |
| 服务启动慢 | 模型加载耗时 | 计时各阶段 | 模型预热+懒加载 |
6. 工程化落地的几个关键决策点
6.1 自建还是用云服务
这个问题没有标准答案,取决于你的团队规模和业务阶段。我的判断逻辑是:如果你的团队没有专职的运维和基础设施工程师,或者业务量还不大,优先用云服务。云服务帮你屏蔽了GPU运维、扩缩容、监控这些脏活累活,让你专注在模型和业务上。但云服务的成本在量大之后会变得很高,而且有厂商锁定的风险。
当你的日请求量稳定在百万级以上,或者对延迟、数据隐私有特殊要求时,就该考虑自建了。自建的第一步不是买GPU,而是把容器化和编排做好。用Kubernetes管理推理服务,用GPU Operator来管理GPU资源,用Prometheus做监控。这套东西搭起来大概需要两周,但搭好之后,扩缩容、滚动更新、故障恢复都是自动的,长期看是划算的。
我自己的经验是走混合路线:核心业务自建,保证稳定性和成本可控;边缘业务和实验性功能用云服务,快速试错。这样既有灵活性,又有经济性。
6.2 团队协作与接口约定
AI工程不是一个人的活,需要算法工程师、后端工程师、运维工程师协作。协作最大的摩擦点在于接口约定。算法工程师关心的是输入输出的张量形状和数值范围,后端工程师关心的是HTTP接口的请求响应格式,运维关心的是资源配额和健康检查接口。
我的做法是在项目初期就定好三份文档:模型接口文档(定义输入输出的张量规格)、服务接口文档(定义HTTP/gRPC的请求响应格式)、运维接口文档(定义健康检查、指标暴露、配置项)。三份文档都放在代码仓库里,跟代码一起版本管理。每次接口变更都要更新文档,并且通知所有相关方。
另外,我强烈建议算法工程师也参与服务代码的编写和review。不是让他们写全部,而是让他们理解推理服务是怎么调用模型的,这样在模型设计时就会考虑工程约束,比如输入尺寸不要太大、输出不要有动态形状。这种“算法懂工程、工程懂算法”的团队,效率比各管一段的团队高很多。
6.3 成本控制的几个实操手段
AI工程的成本大头在GPU。控制成本的手段我按性价比排序:第一是提高GPU利用率,通过动态批处理、多模型共享GPU、混部推理和训练任务,把利用率从20%提到60%以上,成本直接降三分之二。第二是模型压缩,量化、剪枝、蒸馏,把大模型变小,小模型可以用更便宜的GPU甚至CPU跑。第三是弹性伸缩,根据流量自动调整实例数,低峰期缩容。第四是选择合适的GPU型号,不是所有场景都需要A100,很多推理场景用T4甚至CPU就够了。
我做过一个成本对比:同一个文本分类服务,用A100单卡跑,月成本约2000美元;量化后用T4单卡跑,月成本约300美元,性能还更好。所以别盲目追求高端GPU,先看看你的模型是不是真的需要。
7. 我踩过的那些坑与最后的经验分享
说几个我印象最深的坑。第一个是时区问题,日志里的时间戳用的是UTC,但业务方看的是本地时间,排查问题时对不上,白白浪费了半天。从那以后我所有服务的时间戳都带时区信息,日志里同时打UTC和本地时间。第二个是浮点数精度,两个看起来一样的浮点数,用==比较返回False,导致缓存命中率极低。后来所有浮点数比较都用abs(a-b) < epsilon。第三个是配置文件热加载,改了一个参数没重启服务,以为生效了,其实没有,排查了半天。现在我的服务要么不支持热加载,要么热加载后打日志明确说“配置已更新”。
最后分享一个我觉得最有用的习惯:给每个服务写一个README,里面包含这个服务的架构图、接口说明、部署步骤、常见问题、负责人。这个README不是给领导看的,是给三个月后的自己和接手的同事看的。我见过太多服务,作者离职后就没人敢动了,因为没人知道它是怎么跑起来的。一个维护良好的README,能省下无数沟通成本。
AI工程这个领域,技术更新快,但工程的基本原则是不变的:解耦、可观测、可回滚、自动化。把这些原则落实到每一个环节,你的系统就能在变化中保持稳定。至于具体的工具和框架,选那个你团队最熟悉的,别为了追新而追新。