拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

从零搭建AI工程能力:数据管道、模型训练与推理服务实战

从零搭建AI工程能力:数据管道、模型训练与推理服务实战

1. 从零搭建AI工程能力:为什么“会调包”远远不够

很多人第一次接触AI工程,是从一行model.fit()或者一个pip install transformers开始的。跑通一个Demo,看着损失曲线往下掉,准确率往上走,就觉得自己“入门”了。但真正到了要把模型塞进一个每天要处理几十万请求的生产系统里,才发现事情完全不是那么回事——显存炸了、推理延迟飙到秒级、模型版本对不上、数据管道三天两头断流。这些问题,没有一个能靠调包解决。

ai-engineering-from-scratch这个标题,核心不在“AI”,而在“engineering”和“from scratch”。它指向的是一类非常具体的需求:不依赖现成的高级封装,从底层把AI系统的每一个环节亲手搭一遍,从而真正理解一个AI系统是怎么运转起来的。这跟“从零实现一个神经网络”还不太一样,后者偏算法原理,前者偏工程落地。你要关心的不只是反向传播怎么算,更是数据怎么流、模型怎么存、服务怎么起、监控怎么做。

这篇文章适合谁看?如果你已经会用PyTorch或TensorFlow训练模型,但一提到部署、服务化、性能优化就心里没底,那这篇内容就是为你准备的。如果你是个后端工程师,想转AI方向但被各种框架的“魔法”搞得云里雾里,那也合适。甚至你是个学生,课程项目跑通了但不知道工业界到底怎么干活,同样能从这里找到答案。我会按照一个AI工程系统从无到有的真实搭建顺序,把每个环节的“为什么”和“怎么做”都讲清楚,中间穿插我自己踩过的坑和总结出来的经验。

需要提前说明的是,AI工程是一个非常大的领域,一篇文章不可能覆盖所有细节。我会聚焦在最核心、最容易出问题、也最能体现工程思维的几个环节:数据管道的构建、模型训练的可复现性、推理服务的性能优化、以及整个系统的可观测性。每个环节我都会给出可操作的方案和代码示例,同时解释为什么这样选、不那样选。

2. 数据管道:AI工程里最容易被低估的“脏活累活”

2.1 为什么数据管道值得你花50%的时间

我见过太多项目,模型结构调了又调,超参搜了一轮又一轮,最后发现瓶颈根本不在模型,而在数据。训练时数据加载成了瓶颈,GPU利用率常年不到30%;线上推理时,特征预处理逻辑和训练时不一致,导致效果直接崩掉。这些问题归根结底,都是数据管道没做好。

数据管道在AI工程里的角色,相当于一个城市的供水系统。模型是水龙头,用户看到的是水龙头出水,但真正决定水质和水压的,是背后那套从水源到管网的复杂系统。一个健壮的数据管道需要解决几个核心问题:数据从哪里来、怎么清洗和转换、怎么高效地喂给训练、怎么保证训练和推理的一致性。

从零搭建的话,我建议不要一上来就上Spark或者Flink这种重型武器。除非你的数据量真的到了TB级别,否则用Python的multiprocessing加上合理的数据格式,就能撑起相当规模的生产系统。关键是要把管道的每个阶段解耦,让它们可以独立测试和替换。

2.2 用Dataset和DataLoader构建可复用的数据层

PyTorch的Dataset和DataLoader是两个非常值得深入理解的抽象。很多人只是照着模板写一个__getitem__就完事了,但其实这里面有很多工程细节。

import torch from torch.utils.data import Dataset, DataLoader import numpy as np class ProductionDataset(Dataset): def __init__(self, data_paths, transform=None, cache_size=1000): self.data_paths = data_paths self.transform = transform self.cache = {} self.cache_size = cache_size def __len__(self): return len(self.data_paths) def __getitem__(self, idx): if idx in self.cache: return self.cache[idx] # 从磁盘加载单条数据 raw = np.load(self.data_paths[idx]) sample = self.transform(raw) if self.transform else raw # 简单的LRU缓存 if len(self.cache) >= self.cache_size: self.cache.pop(next(iter(self.cache))) self.cache[idx] = sample return sample

这段代码看起来简单,但有几个工程上的考量。第一,缓存机制。如果你的数据在磁盘上,每次__getitem__都去读文件,IO会成为瓶颈。加一个简单的内存缓存,能显著提升吞吐。第二,transform的幂等性。数据增强类的transform每次调用应该产生不同的结果,但预处理类的transform必须保证幂等,否则训练和推理会对不上。第三,错误处理。生产环境里数据损坏是常态,__getitem__里应该加try-except,记录坏样本并返回一个安全的默认值,而不是让整个训练崩掉。

DataLoader这边,num_workers的设置是个经验活。设得太小,数据加载跟不上GPU;设得太大,CPU上下文切换开销反而拖慢速度。我的经验是,从num_workers=4开始试,观察GPU利用率和CPU负载,逐步调整。另外,pin_memory=True在GPU训练时几乎总是应该开的,它能加速CPU到GPU的数据传输。

2.3 训练与推理的一致性:特征工程的双刃剑

这是AI工程里最经典的坑之一:训练时用pandas做特征,推理时用numpy做特征,结果因为浮点数精度或者缺失值处理方式不同,导致线上线下效果不一致。解决这个问题的唯一办法是把特征处理逻辑封装成独立的、可复用的模块,训练和推理都调用同一份代码。

class FeaturePipeline: def __init__(self, config): self.config = config self.scaler = None self.encoders = {} def fit(self, df): # 拟合阶段:只在训练数据上调用 self.scaler = StandardScaler().fit(df[self.config.numeric_cols]) for col in self.config.categorical_cols: self.encoders[col] = LabelEncoder().fit(df[col]) return self def transform(self, df): # 转换阶段:训练和推理都调用 df = df.copy() df[self.config.numeric_cols] = self.scaler.transform(df[self.config.numeric_cols]) for col in self.config.categorical_cols: # 处理未见过的类别 df[col] = df[col].map( lambda x: self.encoders[col].transform([x])[0] if x in self.encoders[col].classes_ else -1 ) return df def save(self, path): # 持久化,推理服务加载同一份 joblib.dump({'scaler': self.scaler, 'encoders': self.encoders}, path)

这个FeaturePipeline的关键在于fit和transform的分离,以及save方法。训练完成后,把pipeline保存下来,推理服务启动时加载同一个文件。这样就能从根源上杜绝特征不一致的问题。我强烈建议把这个pipeline的版本号和模型版本号绑定,每次模型更新,pipeline也跟着更新,避免版本错配。

注意:分类特征编码时一定要处理“未见过的类别”。线上总会遇到训练集里没出现过的值,如果不处理,轻则报错,重则静默产生错误的编码,导致预测结果完全不可信。

3. 模型训练的可复现性:让每一次实验都有迹可循

3.1 随机种子只是起点,不是终点

“设置随机种子就能复现”是一个流传很广的误解。在PyTorch里,即使你设置了torch.manual_seed(42),如果用了CUDA、用了多线程的DataLoader、用了某些非确定性算子,结果依然可能不一样。真正的可复现性需要一套组合拳。

import random import numpy as np import torch def set_seed(seed=42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) # 以下两行会降低性能,但能保证确定性 torch.backends.cudnn.deterministic = True torch.backends.cudnn.benchmark = False # DataLoader的worker种子 def seed_worker(worker_id): worker_seed = seed + worker_id np.random.seed(worker_seed) random.seed(worker_seed) return seed_worker

cudnn.deterministic = True会让cuDNN只使用确定性的算法,代价是可能慢一些。benchmark = False则是关闭自动调优,因为自动调优会选择不同的算法,导致结果不可复现。这两个设置在生产训练脚本里我建议都打开,除非你确实需要那一点性能提升,并且能接受结果波动。

DataLoader的worker种子也经常被忽略。每个worker进程有自己的随机状态,如果不单独设置,数据增强的结果每次都会不同。上面的seed_worker函数配合DataLoader(worker_init_fn=seed_worker)使用,能解决这个问题。

3.2 实验管理:别再用文件夹名当版本号了

我见过太多人用experiment_1、experiment_2_final、experiment_2_final_really这样的文件夹来管理实验。这种方式在实验数量超过20个之后就会彻底失控。你需要一个结构化的实验管理系统。

最轻量的方案是用MLflow或者Weights & Biases,但如果想从零搭建,核心需要记录这几样东西:代码版本(git commit hash)、超参数配置、训练指标曲线、模型权重、以及环境依赖。

import json import hashlib import subprocess from pathlib import Path from datetime import datetime class ExperimentTracker: def __init__(self, base_dir='experiments'): self.base_dir = Path(base_dir) self.exp_id = datetime.now().strftime('%Y%m%d_%H%M%S') self.exp_dir = self.base_dir / self.exp_id self.exp_dir.mkdir(parents=True, exist_ok=True) self.metrics = [] def log_config(self, config): # 记录git commit try: git_hash = subprocess.check_output( ['git', 'rev-parse', 'HEAD'] ).decode().strip() except Exception: git_hash = 'unknown' config['git_hash'] = git_hash config['timestamp'] = self.exp_id with open(self.exp_dir / 'config.json', 'w') as f: json.dump(config, f, indent=2) def log_metrics(self, step, metrics_dict): self.metrics.append({'step': step, **metrics_dict}) with open(self.exp_dir / 'metrics.json', 'w') as f: json.dump(self.metrics, f) def log_model(self, model, optimizer=None): torch.save({ 'model_state': model.state_dict(), 'optimizer_state': optimizer.state_dict() if optimizer else None, }, self.exp_dir / 'checkpoint.pt')

这个ExperimentTracker虽然简单,但已经覆盖了实验管理的核心需求。git_hash让你能精确知道是哪份代码跑出来的结果;config.json记录了所有超参数;metrics.json是训练曲线;checkpoint.pt是模型权重。有了这些,任何一个实验结果都可以被完整复现。

3.3 配置管理:把超参数从代码里赶出去

超参数散落在代码各处是另一个常见的工程债。今天改个学习率,明天改个batch size,改完就忘了改过什么。正确的做法是用配置文件来管理所有超参数,代码只负责读取配置。

from dataclasses import dataclass, field from typing import List import yaml @dataclass class TrainConfig: learning_rate: float = 1e-3 batch_size: int = 32 epochs: int = 10 hidden_dims: List[int] = field(default_factory=lambda: [256, 128]) dropout: float = 0.1 seed: int = 42 device: str = 'cuda' @classmethod def from_yaml(cls, path): with open(path) as f: data = yaml.safe_load(f) return cls(**data) def to_dict(self): return {k: v for k, v in self.__dict__.items()}

用dataclass的好处是类型明确、有默认值、容易序列化。配合YAML配置文件,你可以为不同的实验准备不同的配置文件,比如configs/baseline.yaml、configs/large_model.yaml,切换实验只需要换一个文件路径。这比在代码里改来改去要清晰得多,也更容易做版本控制。

提示:配置文件里不要放敏感信息,比如数据库密码、API密钥。这些应该通过环境变量注入,配置文件只放模型和训练相关的参数。

4. 推理服务:从模型文件到高可用API

4.1 为什么Flask不够用,以及什么时候该上TorchServe

很多教程教你用Flask写一个/predict接口,把模型加载到全局变量里,然后app.run()。这在Demo阶段没问题,但生产环境会遇到几个硬伤:并发能力差、没有批处理、没有模型版本管理、没有健康检查。

Flask默认是单线程的(虽然可以开多线程,但GIL限制了CPU密集型任务的并发)。对于深度学习推理来说,单次推理可能耗时几十到几百毫秒,如果每个请求都单独跑一次模型,GPU利用率会非常低。理想的方式是动态批处理:把短时间内到达的多个请求合并成一个batch,一次性送进GPU,这样能大幅提升吞吐。

从零搭建一个支持动态批处理的推理服务,核心思路是用一个队列加一个后台批处理线程:

import threading import queue import torch import time class BatchInferenceServer: def __init__(self, model, max_batch_size=32, max_wait_ms=10): self.model = model self.model.eval() self.max_batch_size = max_batch_size self.max_wait_ms = max_wait_ms self.request_queue = queue.Queue() self.result_dict = {} self.lock = threading.Lock() self._start_worker() def _start_worker(self): thread = threading.Thread(target=self._batch_worker, daemon=True) thread.start() def _batch_worker(self): while True: batch = [] request_ids = [] deadline = time.time() + self.max_wait_ms / 1000 # 收集请求,直到达到max_batch_size或超时 while len(batch) < self.max_batch_size and time.time() < deadline: try: timeout = max(0, deadline - time.time()) req_id, data = self.request_queue.get(timeout=timeout) batch.append(data) request_ids.append(req_id) except queue.Empty: break if not batch: continue # 批处理推理 with torch.no_grad(): batch_tensor = torch.stack(batch) outputs = self.model(batch_tensor) # 分发结果 with self.lock: for req_id, output in zip(request_ids, outputs): self.result_dict[req_id] = output.cpu().numpy() def predict(self, data, timeout=5.0): req_id = id(data) self.request_queue.put((req_id, data)) start = time.time() while time.time() - start < timeout: with self.lock: if req_id in self.result_dict: return self.result_dict.pop(req_id) time.sleep(0.001) raise TimeoutError('Inference timeout')

这个实现虽然简化了很多,但核心思想是完整的:请求进队列,后台线程按batch取出,批量推理后把结果放回字典,请求线程轮询等待。max_wait_ms控制延迟上限,max_batch_size控制吞吐上限,两者需要根据实际业务场景调优。如果业务对延迟敏感,就把max_wait_ms调小;如果追求吞吐,就调大。

4.2 模型版本管理与灰度发布

生产环境的模型不是一成不变的。今天上线v1,明天可能要上v2,后天可能发现v2有问题要回滚到v1。如果没有版本管理,这个过程会非常痛苦。

一个简单的方案是用文件系统做版本管理,每个版本一个目录,包含模型权重和配置文件。推理服务启动时读取一个current软链接,指向当前生效的版本。更新时,先上传新版本到新目录,然后原子性地切换软链接。

import os from pathlib import Path class ModelRegistry: def __init__(self, base_dir='model_registry'): self.base_dir = Path(base_dir) self.base_dir.mkdir(exist_ok=True) def register(self, version, model, config): version_dir = self.base_dir / version version_dir.mkdir(exist_ok=True) torch.save(model.state_dict(), version_dir / 'model.pt') with open(version_dir / 'config.json', 'w') as f: json.dump(config, f) return version_dir def activate(self, version): version_dir = self.base_dir / version if not version_dir.exists(): raise ValueError(f'Version {version} not found') current_link = self.base_dir / 'current' if current_link.exists() or current_link.is_symlink(): current_link.unlink() current_link.symlink_to(version_dir) def get_current(self): current_link = self.base_dir / 'current' if not current_link.exists(): raise RuntimeError('No active model version') return current_link.resolve()

activate方法里的unlink加symlink_to是一个原子操作(在大多数文件系统上),能保证切换过程中不会出现读到半个模型的情况。灰度发布可以在此基础上做:让推理服务同时加载两个版本,按请求比例分流,观察新版本的指标后再全量切换。

4.3 推理性能优化的几个实用手段

除了动态批处理,还有几个手段能显著提升推理性能。量化是其中之一,把FP32的权重转成INT8,模型体积缩小4倍,推理速度提升2-3倍,精度损失通常在1%以内。PyTorch提供了torch.quantization模块,动态量化对LSTM和Linear层效果很好。

# 动态量化示例 quantized_model = torch.quantization.quantize_dynamic( model, {torch.nn.Linear}, dtype=torch.qint8 )

ONNX Runtime是另一个利器。把PyTorch模型导出成ONNX格式,然后用ONNX Runtime推理,通常能比原生PyTorch快20%-50%,尤其是在CPU上。导出时注意opset版本要匹配,输入输出的动态维度要设置正确。

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

TensorRT则是NVIDIA GPU上的终极方案,能把推理速度再提升一个档次,但配置复杂度也最高,而且和特定GPU架构绑定。我的建议是:先用动态批处理把GPU利用率提上去,如果还不够,再考虑量化和ONNX Runtime,最后才上TensorRT。

注意:量化后的模型精度一定要在验证集上重新评估。有些模型对量化很敏感,尤其是那些权重分布不均匀的模型,量化后精度可能掉得厉害。如果掉点超过可接受范围,可以考虑只量化部分层,或者用量化感知训练来恢复精度。

5. 可观测性:让系统在出问题时能自己“说话”

5.1 日志、指标、追踪:三根支柱缺一不可

AI系统的可观测性和传统后端系统既有重叠也有特殊之处。重叠的是日志、指标、追踪这三大支柱,特殊的是AI系统还需要监控数据分布漂移和模型预测分布。

日志方面,我建议用结构化的JSON日志,而不是纯文本。这样方便后续用ELK或者Loki做检索和分析。每条推理日志至少包含:请求ID、输入摘要(比如输入的shape和统计量)、输出摘要、推理耗时、模型版本。

import logging import json import time class StructuredLogger: def __init__(self, name): self.logger = logging.getLogger(name) handler = logging.StreamHandler() handler.setFormatter(logging.Formatter('%(message)s')) self.logger.addHandler(handler) self.logger.setLevel(logging.INFO) def log_inference(self, request_id, input_tensor, output, latency_ms, model_version): record = { 'event': 'inference', 'request_id': request_id, 'input_shape': list(input_tensor.shape), 'input_mean': float(input_tensor.mean()), 'input_std': float(input_tensor.std()), 'output_shape': list(output.shape), 'output_mean': float(output.mean()), 'latency_ms': latency_ms, 'model_version': model_version, 'timestamp': time.time() } self.logger.info(json.dumps(record))

指标方面,除了常规的QPS、延迟、错误率,AI系统要特别关注输入数据的统计量。如果线上输入的均值突然偏移了训练分布的均值好几个标准差,那大概率是上游数据出了问题,模型输出不可信。这个监控能在模型效果崩掉之前就发出预警。

5.2 数据漂移检测:在模型失效之前发现问题

数据漂移检测的核心是比较线上输入分布和训练集分布。最简单的方法是计算PSI(Population Stability Index),对每个特征分桶后计算分布差异。

import numpy as np def calculate_psi(expected, actual, buckets=10): def scale_range(input_arr, min_val, max_val): input_arr = input_arr + -(min_val) input_arr = input_arr / (max_val - min_val) return input_arr breakpoints = np.arange(0, buckets + 1) / buckets * 100 breakpoints = np.percentile(expected, breakpoints) expected_percents = np.histogram(expected, breakpoints)[0] / len(expected) actual_percents = np.histogram(actual, breakpoints)[0] / len(actual) # 避免除零 expected_percents = np.clip(expected_percents, 1e-6, None) actual_percents = np.clip(actual_percents, 1e-6, None) psi_value = np.sum( (expected_percents - actual_percents) * np.log(expected_percents / actual_percents) ) return psi_value

PSI小于0.1表示分布稳定,0.1到0.25表示有轻微漂移需要关注,大于0.25表示显著漂移,模型可能需要重新训练。这个计算应该定期(比如每小时)在最近的线上数据上跑一次,结果推送到监控面板。

5.3 从告警到自动恢复:把人工干预降到最低

监控的目的是发现问题,但发现问题之后如果每次都要人工介入,那系统的可维护性还是很差。理想情况下,常见的故障应该能自动恢复。

比如,当检测到某个模型版本的错误率超过阈值时,自动回滚到上一个稳定版本。当检测到GPU显存不足时,自动降低批处理大小。当检测到数据漂移超过阈值时,自动触发重新训练流程。

class AutoRecovery: def __init__(self, registry, monitor, threshold=0.05): self.registry = registry self.monitor = monitor self.threshold = threshold self.error_history = [] def check_and_recover(self): error_rate = self.monitor.get_error_rate(window='5m') self.error_history.append(error_rate) if error_rate > self.threshold: # 连续3个窗口都超阈值才触发回滚 if len(self.error_history) >= 3 and all( e > self.threshold for e in self.error_history[-3:] ): current = self.registry.get_current() previous = self._find_previous_version(current) if previous: self.registry.activate(previous) self.monitor.alert( f'Rolled back from {current} to {previous} ' f'due to error rate {error_rate}' ) self.error_history.clear() def _find_previous_version(self, current): versions = sorted([ d.name for d in self.registry.base_dir.iterdir() if d.is_dir() and d.name != 'current' ]) try: idx = versions.index(current.name) return versions[idx - 1] if idx > 0 else None except ValueError: return None

这个AutoRecovery的逻辑是:连续3个5分钟窗口的错误率都超过阈值,才触发回滚。加这个“连续3次”的条件是为了避免因为偶发波动而误回滚。回滚之后清空历史,给新版本一个干净的观察窗口。

提示:自动回滚虽然方便,但一定要有完善的日志和告警。每次自动回滚都应该通知到相关负责人,否则问题可能被掩盖,永远得不到根治。

6. 一些让我少走弯路的工程习惯

6.1 本地能跑通不代表线上能跑通

我踩过最大的坑之一,是本地开发环境用Python 3.8加PyTorch 1.9,线上环境用Python 3.9加PyTorch 1.12,结果某些算子的行为有细微差异,导致线上效果比本地差了一截。从那以后,我坚持用Docker来统一开发和生产环境。Dockerfile里明确锁定所有依赖的版本,包括CUDA和cuDNN的版本。

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 # requirements.txt里所有包都锁定版本 # torch==2.0.1 # numpy==1.24.3 # ...

requirements.txt里不要用>=或者不写版本号,全部用==锁定。虽然这样更新依赖麻烦一点,但能避免“昨天还好好的今天突然挂了”这种问题。

6.2 单元测试要覆盖数据管道和特征工程

模型本身的单元测试很难写,因为输出是概率性的。但数据管道和特征工程的测试非常好写,而且收益极高。每个transform函数都应该有对应的测试,验证输入输出shape、数值范围、边界情况。

def test_feature_pipeline_unseen_category(): pipeline = FeaturePipeline(config) train_df = pd.DataFrame({'cat': ['a', 'b', 'c'], 'num': [1.0, 2.0, 3.0]}) pipeline.fit(train_df) # 测试未见过的类别 test_df = pd.DataFrame({'cat': ['d'], 'num': [4.0]}) result = pipeline.transform(test_df) assert result['cat'].iloc[0] == -1 # 应该被编码为-1 assert not result.isnull().any().any() # 不应该有NaN

这个测试验证了未见类别被正确处理为-1,且没有产生NaN。这类测试能在早期发现很多潜在问题,比等到线上出故障再排查要划算得多。

6.3 性能优化之前先测量

“过早优化是万恶之源”这句话在AI工程里尤其正确。我见过有人花了一周时间优化数据加载,结果发现瓶颈其实在模型的前向传播。正确的做法是先做profiling,找到真正的瓶颈再动手。

PyTorch自带的profiler就很好用:

with torch.profiler.profile( activities=[ torch.profiler.ProfilerActivity.CPU, torch.profiler.ProfilerActivity.CUDA, ], schedule=torch.profiler.schedule(wait=1, warmup=1, active=3), on_trace_ready=torch.profiler.tensorboard_trace_handler('./log') ) as prof: for step, batch in enumerate(dataloader): if step >= 5: break train_step(batch) prof.step()

跑完之后在TensorBoard里看trace,哪个算子耗时最长一目了然。是数据加载慢,还是某个卷积层慢,还是CPU到GPU的拷贝慢,清清楚楚。根据profile结果来优化,才能把精力花在刀刃上。

6.4 文档和注释要写“为什么”,而不是“是什么”

代码里的注释应该解释“为什么这样做”,而不是“这行代码在做什么”。比如:

# 不好:把输入转成float32 x = x.float() # 好:模型权重是float32,输入如果是float64会导致类型不匹配报错 x = x.float()

“是什么”从代码本身就能看出来,“为什么”才是需要注释的。同样,README里应该写清楚这个模块的设计决策、已知限制、以及踩过的坑,而不是简单地罗列API。

7. 从Demo到生产:一个真实的演进路径

如果你现在手里有一个能跑通的Demo,想把它变成生产系统,我建议按这个顺序推进:

第一阶段:可复现。把随机种子固定住,把超参数抽到配置文件,把实验记录结构化。这个阶段的目标是“任何人拿到你的代码和配置,都能跑出一样的结果”。

第二阶段:可测试。给数据管道和特征工程写单元测试,给推理服务写集成测试。这个阶段的目标是“改代码之后能快速知道有没有破坏现有功能”。

第三阶段:可观测。加上结构化日志、关键指标监控、数据漂移检测。这个阶段的目标是“系统出问题时能快速定位原因”。

第四阶段:可扩展。引入动态批处理、模型版本管理、自动回滚。这个阶段的目标是“系统能承受流量增长,且运维成本不随规模线性增长”。

每个阶段都有明确的验收标准,不要跳级。我见过太多团队在可复现性都没做好的情况下就急着上自动扩缩容,结果出了问题连是哪个版本、哪份配置导致的都查不出来。

AI工程和传统软件工程最大的区别在于,它的“正确性”是概率性的、数据依赖的、且会随时间漂移的。这意味着你不能只关注代码逻辑,还要关注数据质量、分布变化、以及模型退化。ai-engineering-from-scratch这个方向的价值,就在于它强迫你直面这些复杂性,而不是躲在框架的抽象后面。当你亲手搭过一遍数据管道、处理过模型版本冲突、被数据漂移坑过之后,你对AI系统的理解会完全不一样。

返回列表