1. 从零搭建AI工程能力:为什么“会用模型”和“会做工程”是两回事
很多人第一次接触AI项目时,都会经历一个相似的阶段:在笔记本里跑通一个模型,准确率看着还不错,于是觉得“AI也就这么回事”。可一旦要把这个东西放到真实环境里跑起来,问题就全冒出来了——推理延迟忽高忽低、显存莫名其妙爆掉、模型版本和代码版本对不上、线上效果和离线评估差了一大截。这些问题的根源,往往不是模型本身不够好,而是AI工程能力没有跟上。
ai-engineering-from-scratch这个标题,核心讲的就是这件事:从零开始,把AI从“能跑通的实验”变成“能稳定交付的工程系统”。它涉及的不是某一个具体算法,而是一整套围绕模型落地的工程实践,包括数据处理流水线、训练与推理的基础设施、模型版本管理、性能调优、监控与回滚机制等等。关键词里的“from scratch”很关键,它意味着我们不依赖现成的重型平台,而是从最基础的部分开始,一层一层把能力搭起来,理解每个环节为什么存在、解决什么问题。
这篇文章适合几类人看:一是刚入行、能把模型跑起来但不知道怎么工程化的开发者;二是有后端或运维背景、想切入AI方向的工程师;三是带团队做AI产品、需要判断技术方案合理性的技术负责人。我会尽量用从业者的视角,把每个环节的“为什么”讲清楚,而不是只丢一堆工具名字。因为工具会过时,但工程判断力不会。
需要先说明一点:AI工程没有唯一正确的架构,它高度依赖你的业务场景、团队规模和成本约束。所以下面讲的内容,更多是提供一套思考框架和可复现的实践路径,你可以根据自己的情况裁剪。我踩过的坑、试过的方案,都会尽量还原当时的判断过程,而不是只给结论。
2. 先把数据管道搭稳:AI工程里最容易被低估的地基
2.1 为什么数据管道决定了模型效果的上限
刚做AI项目的人,注意力几乎都在模型结构上,觉得换个更强的网络、调调超参就能提升效果。但真正做过几个项目之后你会发现,数据质量对最终效果的影响,往往比模型选型更大。一个中等复杂度的模型配上干净、一致、覆盖充分的数据,通常能打败一个复杂模型配脏数据。这就是为什么在AI工程里,数据管道必须被当作一等公民来对待。
所谓数据管道,指的是从原始数据采集、清洗、标注、切分,到最终喂给训练或推理的完整链路。在“from scratch”的语境下,我们不假设有现成的数据平台,而是自己把这条链路搭起来。这里最容易出问题的地方有三个:一是数据格式不统一,不同来源的数据字段对不上;二是训练和推理阶段的数据预处理逻辑不一致,导致线上线下效果偏差;三是数据版本没有管理,模型回滚时找不到对应的数据快照。
我见过一个很典型的案例:离线评估时模型准确率有92%,上线后掉到78%。排查了很久才发现,离线用的数据里某个特征做了归一化,而线上服务在拼接特征时漏掉了这一步。模型没变,数据变了,效果就崩了。这类问题在AI工程里非常常见,而且往往很难通过看代码发现,因为它藏在“数据流”里。
2.2 从零搭建数据管道的几个关键决策
搭数据管道时,第一个要做的决策是:批处理还是流处理。如果你的场景是离线训练为主,比如每天跑一次训练任务,那批处理就够了,用定时任务把数据拉下来、清洗、存成训练集即可。但如果你的场景需要实时特征,比如推荐系统要根据用户当前行为实时调整,那就得引入流处理。从零开始的话,我建议先用批处理把链路跑通,等业务真的需要实时性了再升级,不要一上来就搞复杂架构。
第二个决策是数据存储格式。很多人习惯用CSV或JSON存训练数据,简单直观,但数据量一大就撑不住。更工程化的做法是用列式存储,比如Parquet或Arrow格式,读取速度快、压缩率高,而且能保留schema。对于特征数据,可以考虑用特征存储的思路,把特征的定义、计算逻辑、版本都管理起来,这样训练和推理就能复用同一套逻辑,避免前面说的线上线下不一致问题。
第三个决策是数据版本管理。这一点经常被忽略,但非常重要。你可以把每次训练用的数据集打上版本号,记录它的来源、清洗规则、时间范围。这样当模型出问题时,你能快速定位是数据变了还是模型变了。实现方式可以很简单,比如用文件命名规范加上一份元数据记录,不一定非要上专门的数据版本工具。
下面是一个简化的数据管道示例,用Python把原始数据清洗后存成Parquet:
import pandas as pd from pathlib import Path def build_training_set(raw_path: str, output_path: str): df = pd.read_json(raw_path, lines=True) # 统一字段命名,去掉空值过多的列 df = df.rename(columns={"user_id": "uid", "item_id": "iid"}) df = df.dropna(subset=["uid", "iid", "label"]) # 标签归一化到0/1 df["label"] = (df["label"] > 0.5).astype(int) # 按时间切分,避免未来信息泄漏 df = df.sort_values("timestamp") split = int(len(df) * 0.8) train, valid = df.iloc[:split], df.iloc[split:] Path(output_path).mkdir(parents=True, exist_ok=True) train.to_parquet(f"{output_path}/train.parquet") valid.to_parquet(f"{output_path}/valid.parquet") print(f"train={len(train)}, valid={len(valid)}")这段代码看起来简单,但里面有几个工程上的讲究:按时间切分而不是随机切分,是为了模拟真实场景,避免用未来数据预测过去;标签归一化统一了不同来源的标注标准;存成Parquet方便后续高效读取。这些细节,才是数据管道真正值钱的地方。
提示:数据管道一定要有“可重放”能力。也就是说,给定同样的原始数据和同样的处理逻辑,任何时候都能得到同样的训练集。做不到这一点,模型效果就无法复现,排查问题会非常痛苦。
3. 训练与推理的基础设施:把模型跑起来只是开始
3.1 训练环境里那些“看起来能跑”的陷阱
把模型在本地跑通,和把训练任务稳定跑在服务器上,完全是两码事。本地跑的时候,数据量小、训练时间短,很多问题不会暴露。一旦上到真实规模,显存、IO、并行策略的问题就会集中爆发。
先说显存。很多人第一次遇到OOM(显存不足)时,第一反应是把batch size调小。这确实能解决问题,但会带来训练不稳定、收敛变慢的副作用。更合理的做法是先分析显存都花在哪了:模型参数、梯度、优化器状态、激活值,这四部分各占多少。对于大模型,优化器状态往往占大头,这时候可以考虑用混合精度训练、梯度累积、或者参数分片等策略。理解这些策略背后的原理,比盲目调参重要得多。
再说IO。训练时如果数据读取速度跟不上GPU计算速度,GPU就会空转,利用率上不去。解决办法包括:把数据预取到内存、用多进程加载、把数据转成更高效的格式。我见过一个项目,训练速度慢得离谱,最后发现是每个batch都在从网络存储读小文件,改成预取加本地缓存后,速度提升了三倍多。这类优化不需要改模型,但收益非常直接。
还有一个容易被忽略的点是随机种子和确定性。做实验时,如果每次训练结果都不一样,你就无法判断效果变化是来自你的改动还是随机性。所以工程上要固定随机种子,并尽量让算子具有确定性。当然,完全确定性有时会牺牲性能,这需要权衡。
3.2 推理服务的工程考量:延迟、吞吐与成本
训练完成后,模型要对外提供服务,这就是推理。推理工程的核心指标有三个:延迟、吞吐和成本。延迟是单次请求的响应时间,吞吐是单位时间能处理的请求数,成本则包括硬件和运维开销。这三个指标往往互相制约,需要根据业务场景做取舍。
比如一个在线对话场景,用户对延迟非常敏感,那就要优先保证低延迟,可能要用更小的模型、更快的硬件,甚至做模型量化。而一个离线批量打分场景,吞吐更重要,可以用大batch、异步处理来提升效率。从零搭建推理服务时,我建议先明确业务对延迟和吞吐的要求,再决定技术方案,而不是反过来。
推理服务的部署方式也有几种选择。最简单的是把模型加载到一个Web服务里,比如用FastAPI包一层。这种方式上手快,但扩展性有限。更工程化的做法是用专门的推理服务器,支持动态批处理、多模型加载、GPU共享等能力。动态批处理是个很实用的技术:把短时间内到达的多个请求合并成一个batch一起推理,能显著提升GPU利用率,但会稍微增加延迟。这个权衡点需要根据业务来定。
下面是一个用FastAPI做推理服务的最小示例,包含了请求校验和批处理的基本思路:
from fastapi import FastAPI from pydantic import BaseModel import numpy as np app = FastAPI() class PredictRequest(BaseModel): features: list[float] class PredictResponse(BaseModel): score: float # 假设model是已经加载好的模型对象 model = None @app.on_event("startup") def load_model(): global model # 实际项目中这里从模型仓库加载 model = lambda x: float(np.sum(x) / len(x)) @app.post("/predict", response_model=PredictResponse) def predict(req: PredictRequest): if len(req.features) == 0: return PredictResponse(score=0.0) score = model(req.features) return PredictResponse(score=score)这个示例很简陋,但它体现了推理服务的基本结构:启动时加载模型、请求时做校验、返回结构化结果。真实项目里还要加上超时控制、限流、日志、监控等。这些“外围”能力,恰恰是AI工程和实验代码的分水岭。
注意:推理服务和训练代码一定要解耦。训练代码可以频繁改动,但推理服务要稳定。如果两者耦合在一起,每次模型更新都可能引入服务故障。正确的做法是把模型导出成独立格式,推理服务只负责加载和调用。
4. 模型版本管理与上线流程:让每次更新都可控可回滚
4.1 为什么模型版本管理不能只靠文件名
很多团队管理模型版本的方式很原始:训练完存一个文件,名字里带上日期,比如model_20240101.pth。这种方式在模型少、更新慢的时候还能凑合,一旦模型多起来、更新频繁,就会乱套。你会遇到这些问题:不知道线上跑的是哪个版本、回滚时找不到对应的模型文件、模型和训练数据对不上、不同版本的预处理逻辑不一致。
工程化的模型版本管理,需要记录的不只是模型文件本身,还包括:训练用的数据版本、代码版本、超参数配置、评估指标、以及模型产物的校验值。这些信息合在一起,才能构成一个“可复现、可追溯”的模型版本。实现上可以用模型注册表的方式,每次训练产出一个版本,打上标签,记录元数据,上线时引用具体版本号。
这里有个实践经验:模型版本号不要用日期,用递增的整数或语义化版本。日期看起来直观,但同一天可能训练多次,而且日期不体现版本之间的先后和兼容关系。用递增版本号,配合一份变更记录,管理起来清晰得多。
4.2 上线流程中的灰度与回滚设计
模型上线不是“替换文件”这么简单。直接全量替换风险很高,一旦新模型有问题,影响面就是全部用户。更稳妥的做法是灰度发布:先让一小部分流量走新模型,观察指标正常后再逐步扩大比例。这样即使出问题,影响也可控。
灰度发布需要几个支撑能力:一是流量切分,能按比例把请求分到不同模型;二是指标对比,能同时观察新旧模型的核心指标;三是快速回滚,发现问题能立刻切回旧版本。这些能力在“from scratch”的语境下,可以用比较轻量的方式实现。比如在推理服务里维护一个模型映射表,根据请求特征或随机比例选择模型,配置变更通过配置中心或简单的接口触发。
回滚设计的关键是模型和配置都要能回滚。有时候问题不在模型本身,而在预处理配置或后处理逻辑。如果只回滚模型不回滚配置,可能还是有问题。所以上线时要把模型、配置、代码作为一个整体版本来管理,回滚时一起回滚。
下面是一个简单的灰度路由示例:
import random class ModelRouter: def __init__(self, stable_model, canary_model, canary_ratio=0.1): self.stable = stable_model self.canary = canary_model self.ratio = canary_ratio def route(self, request): if random.random() < self.ratio: return self.canary, "canary" return self.stable, "stable" router = ModelRouter(stable_model=load("model_v1"), canary_model=load("model_v2"), canary_ratio=0.05) def handle(request): model, tag = router.route(request) result = model.predict(request.features) log_metric(tag, result) return result这段代码的核心思想是:用随机比例做流量切分,同时记录每个请求走的是哪个版本,方便后续分析。真实场景中,灰度比例应该可动态调整,而且要有监控告警,一旦canary版本的错误率或延迟超标,自动降级。
提示:灰度发布时,一定要保证新旧模型的输入输出格式兼容。如果新模型需要额外的特征,而线上请求里没有,就会直接报错。所以上线前要做兼容性检查,必要时在服务层做适配。
5. 性能调优与监控:让系统在真实压力下站得住
5.1 推理性能调优的常见手段与取舍
推理性能直接关系到用户体验和成本。调优手段有很多,但每种都有适用场景和代价,不能无脑上。下面这张表整理了几种常见手段的对比:
| 调优手段 | 主要收益 | 代价 | 适用场景 |
|---|---|---|---|
| 模型量化 | 减少显存、加速推理 | 可能损失精度 | 对延迟敏感、精度要求不极端 |
| 动态批处理 | 提升吞吐、提高GPU利用率 | 增加单请求延迟 | 吞吐优先的在线服务 |
| 模型剪枝 | 减少参数量、加速 | 需要重新训练微调 | 模型过大、资源受限 |
| 算子融合 | 减少计算开销 | 依赖框架支持 | 通用优化 |
| 缓存结果 | 大幅降低重复计算 | 需要处理缓存失效 | 请求重复率高的场景 |
选择哪种手段,取决于你的瓶颈在哪。如果GPU利用率很低,说明瓶颈可能在IO或CPU预处理,这时候优化模型没用,得先解决数据供给问题。如果GPU利用率很高但延迟还是大,那可能是模型本身太重,需要考虑量化或剪枝。先定位瓶颈,再选手段,这是性能调优的基本原则。
我个人的经验是,大部分AI服务的性能问题,根源不在模型计算,而在周边环节:数据预处理慢、序列化开销大、网络传输多、锁竞争严重。所以调优时不要一上来就盯着模型,先用profiling工具把整个请求链路的时间分布搞清楚,往往能发现意想不到的瓶颈。
5.2 监控体系:没有监控的AI系统等于裸奔
AI系统上线后,必须有一套监控体系,否则出了问题你都不知道。监控要覆盖几个层面:系统层(CPU、内存、GPU、网络)、服务层(QPS、延迟、错误率)、模型层(预测分布、特征分布、业务指标)。前两层是常规运维监控,第三层是AI系统特有的,也是最容易被忽略的。
模型层监控为什么重要?因为模型的效果会随着时间漂移。线上数据分布可能和训练数据不一样,用户行为会变化,这些都会导致模型效果下降。如果你只监控系统指标,可能系统一切正常,但业务指标已经在悄悄下滑。所以要对模型的输入特征分布和输出预测分布做监控,一旦发现明显偏移,就要触发告警,考虑重新训练。
实现上,可以在推理服务里埋点,把每次请求的关键特征和预测结果采样记录下来,定期做统计分析。不需要记录全部请求,采样就够了。然后设定一些阈值,比如某个特征的均值偏离训练集均值超过一定比例就告警。这套机制不复杂,但能帮你提前发现很多问题。
注意:监控数据本身也要注意隐私和合规。记录特征时要做脱敏,避免存储敏感信息。采样比例和保留时间要根据实际需求设定,不要无限制地存。
6. 从零搭建AI工程能力的实操路线与踩坑心得
6.1 一条可落地的学习与实践路线
如果你现在想从零开始建立AI工程能力,我建议按下面的顺序推进,每一步都动手做一遍,而不是只看资料:
- 先把一个模型完整跑通:从数据加载、训练、评估到保存模型,用一个小数据集走完全流程。目的是理解每个环节的输入输出。
- 把训练脚本工程化:加上配置管理、日志、随机种子固定、检查点保存。让训练可复现、可中断恢复。
- 搭一个最小推理服务:用Web框架把模型包起来,加上请求校验和基本错误处理。体会训练和推理的差异。
- 引入数据版本和模型版本管理:哪怕只是用文件和元数据记录,也要把版本概念建立起来。
- 加上监控和灰度能力:给推理服务加指标采集,实现简单的流量切分和回滚。
- 做一次性能压测和调优:找到瓶颈,尝试至少一种调优手段,观察效果变化。
这条路线走下来,你对AI工程的理解会从“知道概念”变成“做过一遍”。很多坑只有自己踩过才有体感,比如数据不一致导致的效果偏差、显存不足的排查过程、灰度发布时的兼容性问题。
6.2 那些文档里不会写的踩坑经验
最后分享几个我在实际项目中踩过的坑,都是文档里不太会提但很要命的:
第一个坑:环境依赖不一致。训练环境用的某个库版本和推理环境不一样,导致模型加载失败或结果不一致。解决办法是用容器把环境固化下来,训练和推理用同一个基础镜像。别小看这个,我见过太多“本地好好的,线上就是不行”的问题,根源都是环境差异。
第二个坑:特征计算逻辑重复实现。训练时用Python算特征,线上用Java重写一遍,两边逻辑稍微有点差异,效果就偏了。正确做法是把特征计算逻辑统一,或者用特征存储来保证一致性。如果非要重写,一定要有对拍测试,确保两边结果一致。
第三个坑:忽略冷启动。服务刚启动时,模型还没加载完,或者缓存还是空的,这时候的延迟会特别高。如果没有做好预热和健康检查,流量打进来就会大量超时。解决办法是启动时先加载模型、预热几次推理,健康检查通过后再接入流量。
第四个坑:日志里记录了敏感数据。调试时为了方便,把请求的原始特征都打进日志,结果日志系统里存了大量用户敏感信息。这个在合规上风险很大。正确做法是日志脱敏,只记录必要的调试信息,而且要有日志保留期限。
第五个坑:没有做失败重试和降级。模型推理偶尔会失败,如果没有重试机制,用户就会看到错误。如果模型服务整体不可用,没有降级方案,业务就全挂了。工程上要考虑这些异常路径,比如重试、超时、降级到规则引擎或默认结果。
这些经验看起来琐碎,但正是它们决定了AI系统能不能真正稳定运行。模型算法决定上限,工程能力决定下限。把下限守住,才有机会去追求上限。