☰
从零手搓AI工程化全流程:特征存储、模型注册与推理服务实战
2026/10/1 19:45:04 网站建设 项目流程

1. 为什么我要从零手搓一套AI工程化流程

第一次看到ai-engineering-from-scratch这个项目名的时候,我正被公司里一堆“跑得起来但没法维护”的模型脚本折磨得够呛。数据科学家在 Jupyter Notebook 里调通了模型,准确率漂亮得不行,可一旦要上线,问题就全冒出来了:依赖版本对不上、推理服务没有健康检查、模型文件散落在各个角落、日志格式五花八门。这个项目标题里的“from scratch”一下子戳中了我——它想做的事情,就是抛开那些大而全的框架,从最底层把 AI 工程化的每个环节亲手搭一遍,搞清楚每一步到底在解决什么问题。

说白了,ai-engineering-from-scratch不是一个具体的库或者工具,而是一套从零构建 AI 工程化能力的学习路径和项目实践集合。它覆盖了从数据处理、特征管理、模型训练、实验追踪,到模型打包、服务部署、监控告警的完整链路。适合谁看?如果你已经会写 Python、懂一点机器学习基础,但每次把模型推上生产环境都觉得心里没底,那这套东西就是给你准备的。它不教你调包,而是让你亲手实现那些平时被框架隐藏起来的细节,比如为什么推理服务要做批处理、模型版本怎么管理才不会乱、A/B 测试的流量怎么切分才科学。

我花了大概两个月的时间,按照这个思路把公司的一个推荐模型从 Notebook 里“捞”出来,重新用工程化的方式搭了一遍。踩过的坑、熬过的夜、以及最后看到监控面板上 P99 延迟稳定在 50ms 以内的那种踏实感,都让我觉得有必要把这套东西系统地梳理出来。下面我就按照实际操作的顺序,把每个环节的核心思路、关键细节和避坑经验掰开揉碎了讲。

2. 整体架构设计与技术选型思路

2.1 为什么不用现成的 MLOps 平台

市面上不缺 MLOps 平台,从云厂商的一站式方案到开源的 Kubeflow、MLflow,功能都很全。但我坚持从零搭的原因有三个。第一,黑盒调试成本太高。有一次线上模型推理结果异常,排查了半天发现是平台内部的特征转换逻辑和训练时不一致,但平台把这块封装得太深,根本看不到中间过程。第二,团队规模不匹配。小团队维护一套 Kubeflow 的运维成本可能比模型本身还高,杀鸡用牛刀反而拖慢迭代速度。第三,学习价值。只有自己实现一遍,才能真正理解模型版本管理、特征一致性、推理批处理这些概念背后的权衡。

当然,从零搭不等于所有轮子都自己造。我的原则是:核心链路的控制逻辑自己写,通用的基础设施用成熟组件。比如对象存储用 MinIO 或者云厂商的 OSS,消息队列用 Redis 或 Kafka,这些没必要重复造。但模型加载、预处理、后处理的编排逻辑,必须自己掌控。

2.2 分层架构的划分逻辑

我把整个系统分成了四层,每层职责单一,层与层之间通过明确定义的接口通信。这种划分方式参考了经典的分层架构思想,但针对 AI 场景做了调整。

层级职责关键组件对外接口
数据层原始数据存储、特征计算与存储对象存储、特征库、ETL 脚本特征读取 API
训练层实验管理、模型训练、超参搜索训练脚本、实验追踪、模型注册表模型版本号
服务层模型加载、推理编排、批处理推理服务、模型缓存、请求队列HTTP/gRPC 接口
监控层指标采集、日志聚合、告警指标库、日志系统、告警规则监控面板

这样分层的核心好处是变更隔离。比如我要换一个特征存储方案,只需要改数据层的实现,训练层和服务层通过接口调用,完全感知不到底层变化。再比如推理服务要加一个预处理步骤,只动服务层就行,不会影响训练逻辑。

2.3 技术选型的几个关键决策

编程语言选 Python,但关键路径用 Rust 或 C++ 扩展。训练和数据处理用 Python 是因为生态成熟,但推理服务的预处理如果全用 Python 写,在高并发下 GIL 会成为瓶颈。我的做法是把特征转换中计算密集的部分用 Rust 写成 Python 扩展,实测 QPS 能提升 3 倍以上。

模型格式统一用 ONNX。训练时可以用 PyTorch 或 TensorFlow,但导出时必须转成 ONNX。这样做的好处是推理服务只需要一个 ONNX Runtime,不用同时装 PyTorch 和 TensorFlow 两套依赖,镜像体积从 3GB 降到 800MB,冷启动时间从 20 秒降到 3 秒。

配置管理用 Hydra + OmegaConf。AI 项目的配置项特别多,学习率、批大小、特征列表、模型结构参数,如果全用 argparse 管理会非常混乱。Hydra 支持配置组合和覆盖,比如python train.py model=resnet data=cifar10就能切换不同的配置组合,实验复现时特别方便。

注意:技术选型没有绝对的对错,关键是匹配团队的技术栈和业务规模。如果你团队里没人写过 Rust,那就别为了性能强行上,用 Cython 或者干脆接受 Python 的性能损失,先把流程跑通更重要。

3. 核心模块的细节实现与实操要点

3.1 特征存储:保证训练和推理的一致性

特征一致性是 AI 工程化里最容易翻车的地方。训练时用 Pandas 算了一版特征,推理时用 NumPy 又算了一版,结果分布对不上,模型效果直接崩掉。我的解决方案是特征计算逻辑只写一次,训练和推理共用同一份代码。

具体做法是定义一个FeatureTransformer基类,里面实现fit和transform方法。训练时先fit再transform,推理时直接加载训练好的fit结果(比如均值、方差、分桶边界)再transform。这样能保证两边用的是完全相同的转换逻辑。

class FeatureTransformer: def __init__(self): self.stats = {} def fit(self, data): self.stats['mean'] = data.mean() self.stats['std'] = data.std() return self def transform(self, data): return (data - self.stats['mean']) / self.stats['std'] def save(self, path): with open(path, 'wb') as f: pickle.dump(self.stats, f) def load(self, path): with open(path, 'rb') as f: self.stats = pickle.load(f) return self

训练脚本里fit完之后把stats存到模型注册表里,推理服务启动时从注册表拉取。这样即使特征逻辑改了,只要版本号对应,就不会出现训练推理不一致的问题。

实操心得:特征版本号一定要和模型版本号绑定。我见过太多团队模型版本管理得很好,但特征版本是散的,结果回滚模型的时候发现特征对不上。建议在模型注册表的元数据里显式记录依赖的特征版本。

3.2 实验追踪:别让好结果莫名其妙消失

没有实验追踪的团队,经常出现“上周那个准确率 0.92 的模型参数是什么来着”这种灵魂拷问。我的做法是每次训练自动记录所有超参数、指标曲线和产出文件,用 MLflow 或者自己写一个轻量级的追踪服务。

自己实现的话,核心就是三张表:experiments记录实验基本信息,runs记录每次运行的状态和指标,artifacts记录产出的模型文件和日志。每次训练开始前生成一个唯一的run_id,训练过程中通过回调函数把指标写进去。

class ExperimentTracker: def __init__(self, db_path): self.conn = sqlite3.connect(db_path) self.run_id = str(uuid.uuid4()) def log_params(self, params): for k, v in params.items(): self.conn.execute( "INSERT INTO params (run_id, key, value) VALUES (?, ?, ?)", (self.run_id, k, str(v)) ) self.conn.commit() def log_metric(self, name, value, step): self.conn.execute( "INSERT INTO metrics (run_id, name, value, step) VALUES (?, ?, ?, ?)", (self.run_id, name, value, step) ) self.conn.commit()

注意事项:指标写入不要太频繁,否则数据库压力大。我的经验是每 10 个 step 写一次,或者每 30 秒写一次,既能画出平滑的曲线,又不会拖慢训练速度。

3.3 模型注册表:版本管理的核心枢纽

模型注册表是整个系统的“账本”,记录每个模型版本的来源、指标、依赖和状态。我设计的状态机是这样的:staging(刚训练完,待验证)→production(线上在用)→archived(已下线)。每次状态变更都要记录操作人和时间戳。

模型文件本身存在对象存储里,注册表只存元数据和文件路径。元数据里必须包含:训练数据版本、特征版本、超参数、评估指标、ONNX 文件路径、依赖的 Python 包版本。这样回滚的时候才能完整复现。

字段类型说明
model_idstring唯一标识,如rec-model-20240501-001
versionint自增版本号
statusenumstaging/production/archived
metricsjson评估指标,如{"auc": 0.85, "p99_latency": 45}
feature_versionstring依赖的特征版本号
artifact_pathstringONNX 文件在对象存储中的路径
created_attimestamp创建时间

踩过的坑:一开始我没记录依赖包版本,结果有一次线上推理服务报错,排查半天发现是 ONNX Runtime 版本升级导致算子不兼容。后来在元数据里加了requirements_hash字段,每次部署前先校验依赖是否匹配,问题就再也没出现过。

3.4 推理服务:批处理与动态填充

推理服务的核心挑战是在延迟和吞吐之间找平衡。单条请求处理延迟最低,但 GPU 利用率上不去;大批量处理吞吐高,但延迟会飙升。我的方案是实现一个动态批处理队列:请求进来先入队,服务端每隔 10ms 或者队列长度达到 32 时触发一次批量推理。

class BatchInferenceServer: def __init__(self, model, max_batch_size=32, max_wait_ms=10): self.model = model self.max_batch_size = max_batch_size self.max_wait_ms = max_wait_ms self.queue = [] self.lock = threading.Lock() def predict(self, input_data): future = Future() with self.lock: self.queue.append((input_data, future)) if len(self.queue) >= self.max_batch_size: self._process_batch() return future.result(timeout=5) def _process_batch(self): batch = self.queue[:self.max_batch_size] self.queue = self.queue[self.max_batch_size:] inputs = [item[0] for item in batch] outputs = self.model(inputs) for (_, future), output in zip(batch, outputs): future.set_result(output)

参数计算过程:max_wait_ms和max_batch_size怎么定?假设你的 SLA 是 P99 延迟 100ms,模型单次推理耗时 20ms,那么留给排队的时间最多 80ms。如果 QPS 是 1000,那么 10ms 内平均到达 10 个请求,max_batch_size设 32 意味着最坏情况下等 32 个请求到齐,耗时约 32ms,加上推理 20ms,总共 52ms,满足 SLA。如果 QPS 更高,可以适当调大max_batch_size。

提示:动态批处理对延迟敏感的场景要慎用。比如实时风控,单条请求延迟要求 10ms 以内,那就别批处理了,直接单条推理,用模型量化或者蒸馏来降延迟。

4. 完整实操流程:从数据到上线的全链路

4.1 环境准备与依赖管理

第一步是把环境搭起来。我的习惯是用 Docker Compose 管理本地开发环境,把对象存储、数据库、消息队列这些依赖都跑在容器里。这样新同事入职,docker-compose up一条命令就能把环境跑起来,不用折腾半天装各种服务。

version: '3.8' services: minio: image: minio/minio command: server /data --console-address ":9001" ports: - "9000:9000" - "9001:9001" environment: MINIO_ROOT_USER: admin MINIO_ROOT_PASSWORD: password postgres: image: postgres:15 ports: - "5432:5432" environment: POSTGRES_DB: ai_engineering POSTGRES_USER: admin POSTGRES_PASSWORD: password redis: image: redis:7 ports: - "6379:6379"

Python 依赖用poetry管理,锁文件提交到 Git。关键依赖的版本要精确锁定,比如onnxruntime==1.16.3,不要用^1.16这种模糊版本,否则不同机器上装出来的版本可能不一样。

实操心得:Docker 镜像构建时,把依赖安装和代码拷贝分成两层。先拷贝pyproject.toml和poetry.lock安装依赖,再拷贝代码。这样改代码的时候不会触发依赖重装,构建速度快很多。

4.2 数据管道搭建与特征计算

数据管道的核心任务是把原始数据变成模型能吃的特征。我的流程是:原始数据落到对象存储 → ETL 脚本读取并清洗 → 特征计算 → 特征写入特征库 → 训练脚本从特征库读取。

ETL 脚本用 Prefect 或者 Airflow 调度,每天凌晨跑一次。特征计算要注意避免数据泄漏:不能用未来数据算当前特征。比如算用户过去 7 天的点击率,时间窗口的右边界必须是当前时间,不能包含之后的数据。

def compute_user_ctr(user_id, events, current_time): window_start = current_time - timedelta(days=7) window_events = events[ (events['user_id'] == user_id) & (events['timestamp'] >= window_start) & (events['timestamp'] < current_time) ] if len(window_events) == 0: return 0.0 return window_events['click'].sum() / len(window_events)

注意事项:特征计算的时间窗口一定要和训练时保持一致。我见过一个案例,训练时用 7 天窗口,推理时用了 30 天窗口,结果特征分布完全变了,模型效果掉了一半。建议把窗口大小写成配置项,训练和推理共用同一个配置。

4.3 模型训练与超参搜索

训练脚本的结构要清晰:数据加载 → 特征转换 → 模型定义 → 训练循环 → 评估 → 保存。每个环节都通过配置文件控制,不要硬编码。

超参搜索用 Optuna 或者 Ray Tune,但要注意搜索空间不要太大。我的经验是先把学习率、批大小、网络层数这几个关键参数搜一遍,其他的用默认值。搜索次数控制在 50 次以内,否则时间成本太高。

def objective(trial): lr = trial.suggest_float('lr', 1e-5, 1e-2, log=True) batch_size = trial.suggest_categorical('batch_size', [32, 64, 128]) num_layers = trial.suggest_int('num_layers', 2, 6) model = build_model(num_layers) optimizer = torch.optim.Adam(model.parameters(), lr=lr) train_loader = DataLoader(dataset, batch_size=batch_size) for epoch in range(10): train_one_epoch(model, optimizer, train_loader) return evaluate(model, val_loader) study = optuna.create_study(direction='maximize') study.optimize(objective, n_trials=50)

踩过的坑:超参搜索时一定要设置早停机制。有些参数组合训练到一半就明显不行了,没必要跑完。我一般设 patience=3,验证集指标连续 3 个 epoch 不提升就停掉。

4.4 模型导出与推理服务部署

训练完的 PyTorch 模型要导出成 ONNX。导出时注意动态轴的设置,批处理维度要设为动态,否则推理时只能处理固定批大小。

torch.onnx.export( model, dummy_input, "model.onnx", input_names=['input'], output_names=['output'], dynamic_axes={ 'input': {0: 'batch_size'}, 'output': {0: 'batch_size'} }, opset_version=14 )

推理服务用 FastAPI 写 HTTP 接口,启动时加载 ONNX 模型和特征转换器。服务要暴露/health和/metrics接口,方便监控系统采集。

app = FastAPI() model = None transformer = None @app.on_event("startup") async def load_model(): global model, transformer model = ort.InferenceSession("model.onnx") transformer = FeatureTransformer().load("transformer.pkl") @app.post("/predict") async def predict(request: PredictRequest): features = transformer.transform(request.data) inputs = {model.get_inputs()[0].name: features} outputs = model.run(None, inputs) return {"prediction": outputs[0].tolist()} @app.get("/health") async def health(): return {"status": "ok"}

实操心得:推理服务启动时加载模型可能耗时几秒,Kubernetes 的 readiness probe 要设置足够的initialDelaySeconds,否则服务还没加载完就被判定为不健康,反复重启。

4.5 监控告警与日志聚合

监控分三层:基础设施层看 CPU、内存、GPU 利用率;服务层看 QPS、延迟、错误率;模型层看预测分布、特征漂移。前两层用 Prometheus + Grafana 就能搞定,模型层需要自己埋点。

预测分布监控很简单:每隔一段时间统计预测值的均值和方差,如果和训练时差异超过阈值就告警。特征漂移用 PSI(Population Stability Index)或者 KL 散度来衡量。

def compute_psi(expected, actual, buckets=10): breakpoints = np.percentile(expected, np.linspace(0, 100, buckets + 1)) expected_counts = np.histogram(expected, breakpoints)[0] / len(expected) actual_counts = np.histogram(actual, breakpoints)[0] / len(actual) psi = 0 for e, a in zip(expected_counts, actual_counts): if e == 0: e = 0.0001 if a == 0: a = 0.0001 psi += (e - a) * np.log(e / a) return psi

PSI 小于 0.1 说明分布稳定,0.1 到 0.2 之间需要关注,大于 0.2 就要告警了。

注意:监控指标不要只看技术指标,业务指标同样重要。比如推荐模型的点击率、转化率,如果技术指标正常但业务指标掉了,很可能是模型效果衰减了。

5. 常见问题与排查技巧实录

5.1 推理结果和训练结果不一致

这是最高频的问题,没有之一。排查思路按以下顺序来:

排查项检查方法常见原因
特征转换对比训练和推理的转换后数据归一化参数不一致、缺失值填充策略不同
模型输入打印模型实际接收的 tensor维度顺序错误、数据类型不匹配
ONNX 导出用 onnxruntime 和 PyTorch 分别推理同一输入算子不兼容、动态轴设置错误
版本匹配检查模型版本和特征版本是否对应部署时拉错了版本

我的经验是先怀疑特征,再怀疑模型。90% 的不一致问题都出在特征转换上。建议在推理服务里加一个 debug 接口,输入原始数据后返回转换后的特征,和训练时的特征对比一下就能定位。

5.2 推理服务延迟毛刺

P99 延迟偶尔飙高,但平均值正常。这种问题最难查,因为复现不了。常见原因和解决方案:

  • Python GC 停顿:推理服务里避免创建大量临时对象,或者调大 GC 阈值。
  • 批处理队列积压:监控队列长度,如果持续增长说明max_batch_size太小或者模型太慢。
  • 模型加载竞争:多个 worker 同时加载模型导致 IO 争抢,改成启动时预加载。
  • 网络抖动:如果特征存储是远程调用,加本地缓存和超时重试。

我遇到过一次毛刺,查了两天才发现是 ONNX Runtime 的线程池和 FastAPI 的线程池互相抢 CPU。后来把 ONNX Runtime 的intra_op_num_threads设为 1,让 FastAPI 的 worker 来并行,问题就解决了。

5.3 模型更新后效果下降

新模型上线后业务指标反而掉了,可能的原因:

  • 训练数据分布变了:新模型在旧数据上过拟合,线上数据分布已经漂移。
  • 特征版本不匹配:新模型依赖新特征,但推理服务还在用旧特征。
  • A/B 测试流量切分有问题:实验组和对照组的用户群体本身有差异。

我的做法是新模型先跑 shadow 模式,也就是线上流量同时打到新旧两个模型,但只返回旧模型的结果,新模型的结果只记录不生效。跑一周后对比两个模型的预测分布和业务指标,确认没问题再切流量。

5.4 常见问题速查表

现象可能原因快速排查解决方案
服务启动失败模型文件路径错误检查环境变量和挂载卷修正路径或重新挂载
推理报维度错误输入 shape 不匹配打印输入 tensor shape检查预处理逻辑
内存持续增长特征缓存未清理监控内存曲线加 LRU 缓存或定期清理
GPU 利用率低批大小太小查看 GPU 监控调大 max_batch_size
日志缺失日志级别配置错误检查 logging 配置设为 INFO 级别

独家避坑技巧:每次部署新模型前,先用一小部分真实流量做金丝雀测试。具体做法是切 5% 的流量到新模型,观察 30 分钟,如果错误率和延迟都正常再逐步扩大比例。这样即使新模型有问题,影响范围也可控。

6. 我在这套流程里踩过的几个大坑

第一个坑是过度设计。一开始我想把所有东西都做成可配置的,特征转换器支持十几种变换,结果配置文件复杂到没人看得懂。后来砍掉了 80% 的功能,只保留最常用的几种,反而用起来更顺手。AI 工程化的核心是让流程可靠,不是让功能丰富。

第二个坑是忽视冷启动。推理服务第一次加载模型要 10 秒,Kubernetes 的 liveness probe 设了 5 秒超时,结果服务一直重启。后来把模型加载放到 init container 里,主容器启动时直接挂载加载好的模型文件,冷启动时间降到 1 秒以内。

第三个坑是日志打太多。每个请求都打完整日志,一天下来几百 GB,存储成本比 GPU 还高。后来改成采样打日志,正常请求只打 1%,错误请求全打,既省了存储又不影响排查。

这套东西搭完之后,最大的感受是AI 工程化没有银弹。每个团队的业务场景、技术栈、人员能力都不一样,照搬别人的方案往往水土不服。关键是理解每个环节要解决的核心问题,然后根据自己的情况做取舍。比如小团队可以先用 SQLite 代替 PostgreSQL,用本地文件系统代替对象存储,等规模上来了再换。先把流程跑通,再逐步优化,比一开始就追求完美架构要务实得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询