尧图精选

从零手搓AI工程化全流程:特征存储、模型注册与推理服务实战

🕒 发布时间:2026/10/1 19:45:12 📁 来源:尧图网络
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 modelresnet datacifar10就能切换不同的配置组合实验复现时特别方便。注意技术选型没有绝对的对错关键是匹配团队的技术栈和业务规模。如果你团队里没人写过 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-001versionint自增版本号statusenumstaging/production/archivedmetricsjson评估指标如{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_size32, max_wait_ms10): 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(timeout5) 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:6379Python 依赖用poetry管理锁文件提交到 Git。关键依赖的版本要精确锁定比如onnxruntime1.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(days7) 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, logTrue) 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(), lrlr) train_loader DataLoader(dataset, batch_sizebatch_size) for epoch in range(10): train_one_epoch(model, optimizer, train_loader) return evaluate(model, val_loader) study optuna.create_study(directionmaximize) study.optimize(objective, n_trials50)踩过的坑超参搜索时一定要设置早停机制。有些参数组合训练到一半就明显不行了没必要跑完。我一般设 patience3验证集指标连续 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_version14 )推理服务用 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 就能搞定模型层需要自己埋点。预测分布监控很简单每隔一段时间统计预测值的均值和方差如果和训练时差异超过阈值就告警。特征漂移用 PSIPopulation Stability Index或者 KL 散度来衡量。def compute_psi(expected, actual, buckets10): 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 psiPSI 小于 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用本地文件系统代替对象存储等规模上来了再换。先把流程跑通再逐步优化比一开始就追求完美架构要务实得多。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →