说实话,市面上教AI工程的文章、课程已经多到看不完了,但大部分是教你"怎么调用一个现成的服务",或者"五分钟用框架搭好一个Demo"。等你真上了生产环境,模型一挂、内存一涨、请求一慢,你会发现自己对脚下这套系统其实一无所知。我去年决定把"ai-engineering from scratch"完整走一遍——不是从空文件夹开始重写所有框架,而是把所有容易被黑盒化的环节亲手实现、亲手调试、亲手踩坑。这篇文章就是这次"从零到一"过程的全记录,适合那些不甘心只会调包、想真正理解AI系统内部运转的工程师。我会直接讲我在数据管道、推理服务、监控评估和团队协作这几个层面的完整决策过程,以及那些只有踩进去才会知道的细节。
1. "从零开始"的真正含义:先想清楚你要徒手造什么
1.1 不是什么都造,而是把黑盒变白
我在项目启动前先把目标定义清楚:所谓from scratch,重点不在"不依赖任何现成库"这种形式主义,而在于"每一个环节我都知道它内部发生了什么"。比如我不需要自己写神经网络的反向传播,但我必须理解模型加载到内存后,前向推理到底消耗哪些资源;我不需要自己实现对象存储,但我必须清楚模型文件的版本校验、回滚机制是怎么设计的。
这个理解上的差异非常关键。很多人做系统设计喜欢一上来就铺并发、铺集群、铺监控告警,但如果你没有亲手从单机、单进程、串行请求开始把完整链路跑通,后面遇到任何异常你都不知道是该查代码、查资源还是查数据。我把这次项目的范围定死在三个核心词:数据可追溯、推理可解释、发布可回滚。
1.2 AI工程全景图:四个环节一个都不能少
我把整个AI工程拆成四个大环,这也是全文的结构基础:
- 数据层:采集、清洗、版本化、质量校验
- 模型层:训练产物管理、模型加载、推理计算
- 服务层:接口封装、并发控制、缓存策略
- 反馈层:离线评测、线上预测日志、漂移检测
这四个环节环环相扣。数据层出了问题,模型层再稳都白搭;服务层没有做并发控制,模型推理再快也会被请求打挂;反馈层不做线上日志回放,你根本不知道自己部署的东西在真实场景里表现如何。我强烈建议你无论做什么项目,都先画一画自己的环节图,哪怕只有一页纸,也比盲目开写强十倍。
1.3 自研与选用现成工具的边界判断
我自己的判断标准是:能否用现成工具解决,取决于它是否影响你对核心链路的理解。
我做了个简单的对照表,给你参考我的取舍逻辑:
| 环节 | 我的选择 | 理由 |
|---|---|---|
| 数据版本管理 | 自研 manifest + 哈希校验 | 我需要精确控制回滚逻辑,而不是依赖固定用法 |
| 特征工程 | 部分自研 | 理解每一维特征怎么来的,后续排查才快 |
| 模型推理框架 | 自研轻量服务 | 便于加动态批处理和缓存,调优空间大 |
| 基础日志收集 | 直接用成熟日志库 | 这不是核心价值,没必要重造 |
| 监控面板 | 直接用现成工具 | 我只需要清晰展示,不需要造个新的 |
一句话总结:核心链路上的关键机制值得自研一次,周边工具能用现成绝不动手。
2. 数据管道的手写实践:版本化、质量校验与血缘记录
2.1 数据版本的"Git化"思路
数据管道是我最早动手的部分,因为它最无聊也最容易出错。项目刚开始时我从网上下了一批公开数据集,后来迭代了两版清洗规则,结果第三周就出现一个问题:我不知道当前模型是在哪一版数据上训练的,以前的“数据文件复制一份加个日期后缀”的做法彻底失效。
于是我设计了最简单可行的manifest方案:每次数据变更生成一个清单文件,记录数据目录下每个文件的SHA256哈希、文件大小、变更时间和变更说明。模型训练时直接把这个manifest也一并记录到训练元数据里,相当于给数据也做了Git化的提交记录。核心代码非常简单:
import hashlib import json from pathlib import Path def sha256_file(path: Path) -> str: h = hashlib.sha256() with open(path, "rb") as f: for chunk in iter(lambda: f.read(8192), b""): h.update(chunk) return h.hexdigest() def build_manifest(data_dir: Path, note: str) -> dict: manifest = {"note": note, "files": {}} for p in sorted(data_dir.rglob("*")): if p.is_file(): manifest["files"][str(p.relative_to(data_dir))] = { "sha256": sha256_file(p), "size": p.stat().st_size, } return manifest # 每次数据变更后执行 manifest = build_manifest( data_dir=Path("data/raw_v3"), note="修正年龄字段空值填充逻辑,删除重复ID记录" ) with open("data/manifests/v3.json", "w") as f: json.dump(manifest, f, ensure_ascii=False, indent=2)这个代码看着简单,但它直接解决了我后面无数次排查问题的过程。每次模型效果异常,我第一件事就是回到训练元数据里看它对应的manifest版本,然后对比当前数据和当时的差异。哈希校验的价值在于:就算文件名被改得面目全非,只要内容变了就能发现。
2.2 数据质量检查:把断言写进管道
第二个关键机制是数据质量检查。很多入门项目会在训练前做一次性的数据探索,比如画个分布图、看几个缺失值就完事了。但真实生产里,数据的分布会随着时间漂移,今天接收到的数据很可能跟昨天的格式不完全一样。我养成了一个习惯:把数据质量检查写成可重复执行的断言脚本,嵌到管道里,每次跑数据都必须通过。
我实现的质量检查包括这样几类:
- 字段完整性:必填列是否存在,非空比例是否在允许范围内
- 类型检查:每一列的类型是否稳定,比如数值列是否混入了字符串
- 值域检查:连续特征是否落在合理范围,分类特征的取值集合是否有新增
- 分布变化检测:新批次数据与历史数据在同一特征上的分布差异是否过大
其中分布检测最简单有效的指标是PSI(Population Stability Index),可以直接看特征分布的稳定性:
import numpy as np def compute_psi(expected, actual, bins=10): """expected和actual是同特征的数值数组""" expected = np.asarray(expected, dtype=np.float64) actual = np.asarray(actual, dtype=np.float64) percentiles = np.percentile(expected, np.linspace(0, 100, bins + 1)) percentiles[-1] += 1e-6 # 避免边界重叠 expected_bins = np.histogram(expected, bins=percentiles)[0] / len(expected) actual_bins = np.histogram(actual, bins=percentiles)[0] / len(actual) psi = 0.0 for e, a in zip(expected_bins, actual_bins): e = max(e, 1e-6) a = max(a, 1e-6) psi += (a - e) * np.log(a / e) return psi # 经验阈值:小于0.1表示分布稳定,0.1~0.25需要关注,大于0.25说明分布显著变化 psi_value = compute_psi(train_feature, today_feature)我第一次把PSI阈值设为0.25,第二天线上就报警了——一位业务同事改了埋点逻辑,导致某个特征的值域整体偏移,而模型在不知不觉中已经用了一批"语义变化"的数据跑了两天。没有这套检查,这个坑可能会被当成"玄学效果波动"处理很久。
2.3 血缘记录:一张表把数据、训练、模型串起来
数据管道的最后一环是血缘记录。我用一张简单的记录表,把每个模型产物的来源信息完整保存下来:训练数据manifest路径、预处理脚本版本、训练参数、代码提交哈希。这不是什么高深技术,但它在事故排查时的价值是决定性的。我见过太多团队,模型出问题后连"这个模型是用什么数据训练的"都回答不上来,只能靠猜。血缘表就是为了杜绝这种"靠猜排查"的原始状态。
我在实践里踩过一个具体教训:有一版模型效果特别好,全组都很兴奋,但后来发现它的训练脚本里有一个bug——把验证集数据混进了训练集。因为当时没有完整记录血缘信息,我花了整整两天审计才发现问题。从那以后,训练脚本必须自动把代码提交哈希写进模型元数据,否则禁止发布。
3. 模型服务化:从训练产物到在线推理的完整链路
3.1 模型加载的冷启动优化
数据层稳定后,我开始处理推理服务。很多人觉得部署推理服务就是写个FastAPI接口然后把模型load进来,实测下来第一个坑就是冷启动时间。我用的模型加载后占内存比较大,冷启动要跑好几秒。这在开发环境无所谓,但生产环境每次发布都要经历一次"服务启动后不可用"的窗口期,监控里全是超时告警。
我采用的方案分两步。第一步是加载预热:服务启动时不等待请求到来才加载模型,而是进程内部主动加载并跑一次假推理,确保模型真正就绪后再报告"健康",这才让负载均衡器放心把流量打进来。第二步是启动探针配合排查:健康检查接口返回的内容不只是"200 OK",而是明确的就绪状态码。
import time import numpy as np from fastapi import FastAPI from pydantic import BaseModel app = FastAPI() model = None ready = False def load_model(): global model # 模拟模型加载 model = {"weight": np.random.rand(128, 128)} # 预热:跑一次假推理,触发所有初始化逻辑 dummy_input = np.random.rand(1, 128).astype(np.float32) _infer(dummy_input) return True def _infer(x): # 实际推理时这里是模型predict调用 return np.dot(x, model["weight"]) @app.on_event("startup") def startup(): global ready ready = load_model() @app.get("/health") def health(): if not ready: return {"status": "not_ready"}, 503 return {"status": "ready"}这个过程本身不难,但很多人会忽略。一次发布,多等几秒无所谓,但如果是凌晨紧急发布,每一秒不可用都在损失线上流量。冷启动优化应该是AI工程的基本功,而不是可选项。
3.2 动态批处理:把小请求攒成大请求
推理服务最常见的性能杀手是:每来一个请求就做一次模型前向推理,完全没有充分利用硬件算力。特别是GPU服务,小batch的推理利用率低得可怜。我实现了最简单的动态批处理机制:请求进来先不立即推理,而是放到一个缓冲队列里,等待一个时间窗口或攒够一定数量后,统一打包成一个大batch再送进模型。
这个机制的参数调整很有讲究。我一开始把时间窗口设成50毫秒,结果单请求延迟涨了50毫秒,换来的是整体吞吐提升3倍,这在大部分推荐、风控场景里是值得的。但对于那些对单次延迟极其敏感的交互式场景,50毫秒可能就不合适了。核心权衡是:你愿意拿多少延迟换吞吐。
批处理的实现思路大致是这样:用标准库Queue + 一个后台线程,请求到达后放入队列并等待结果;后台线程每秒或每N毫秒收集队列里的所有请求,一次性推理后分发结果。
import queue import threading import numpy as np import time request_queue = queue.Queue() def batch_worker(): while True: batch = [] # 等待第一个请求到达 req, seq = request_queue.get() batch.append((req, seq)) # 再等待最多50ms,或攒到32个请求 deadline = time.time() + 0.05 while len(batch) < 32 and time.time() < deadline: try: req, seq = request_queue.get(timeout=0.005) batch.append((req, seq)) except queue.Empty: continue inputs = np.stack([item[0] for item in batch]) outputs = _infer(inputs) for (_, seq), out in zip(batch, outputs): # 通过seq找到原始请求并返回结果 pass动态批处理真正实现后性能提升非常明显,但有个隐蔽的坑:如果服务是多个worker进程并行,每个进程都维护自己的批处理队列,请求量会被随机分散到不同队列,批处理的效果会大打折扣。所以最佳实践是:让批处理队列成为全局唯一入口,推理worker才多进程并行,这个我后面还会细讲。
3.3 LRU缓存:重复请求的隐形杀手
在推理服务中,缓存绝对是被低估的技术。我发现实际线上的请求有相当比例是重复或高度相似的,尤其是同一个用户在短时间内多次请求,或者热门内容被频繁查询。给模型服务加一层LRU缓存,小到几千条,就能显著降低背后的推理压力。
我实现LRU缓存用得最简单直白的方式——用一个有序字典(Python的dict天然保持插入顺序):
from collections import OrderedDict class LRUCache: def __init__(self, capacity: int = 4096): self.capacity = capacity self.cache = OrderedDict() def get(self, key): if key not in self.cache: return None self.cache.move_to_end(key) return self.cache[key] def put(self, key, value): if key in self.cache: self.cache.move_to_end(key) self.cache[key] = value if len(self.cache) > self.capacity: self.cache.popitem(last=False)有个容易忽略的细节:缓存键的设计要非常小心。如果你把原始输入参数拼接成字符串当键,可能因为参数顺序不同产生大量本应命中的miss,也可能因为某些随机特征(比如时间戳、随机种子)导致缓存完全失效。我后来把键定义为一个规范化后的元组,保证顺序稳定、排除无关字段。
3.4 并发模型的选择:为什么我押注多进程
并发是推理服务里最容易犯错的环节。很多人因为Python的克制,知道GIL存在,但没想到它对推理服务的影响这么大。CPU密集型任务(比如模型前向计算)用多线程不仅不会加速,反而会因为线程切换和锁竞争拖慢速度。我做了个简单压测:同样的模型,1个进程可以做到每秒钟100次推理,开4个线程后反而掉到了每秒40次——这个反直觉的结果让我立刻转向多进程方案。
多进程在Python里最简单的方式是concurrent.futures.ProcessPoolExecutor,配合上面的全局批处理队列。我实际做成了这样一个架构:
- 主进程接收HTTP请求,把输入放到全局队列
- 批处理线程负责收集请求、合并成batch
- 多个推理worker进程真正执行模型推理
- 结果通过future机制返回给对应请求
from concurrent.futures import ProcessPoolExecutor import multiprocessing as mp # 全局队列(multiprocessing.Queue) g_queue = mp.Queue() executor = ProcessPoolExecutor(max_workers=4) def handle_request(input_data): future_map = {} for i in range(4): future_map[executor.submit(worker_infer, input_data[i])] = i # 收集结果...这个架构的实际搭建过程比听起来复杂得多:多进程之间如何共享模型参数、如何传递结果不丢、进程崩溃后如何恢复。每一步都是对着日志和监控慢慢调出来的。但最终的效果非常值得——单机吞吐提升了近8倍,延迟没有明显上升。
4. 效果评估与线上反馈闭环
4.1 离线评测集:不可信的评估比没有评估更可怕
我见过很多团队把离线评估当成一个形式化流程:随机切一部分数据,跑一下准确率,完事。但这样的评估结果往往和生产环境的表现差异巨大,核心原因是:离线测试集和线上真实数据的分布不一致。如果你的训练数据来自3月份,你的评估集也来自3月份,那评估结果无法反映模型在6月份线上数据上的表现。
我的做法是构建一个带有"时间穿透"意识的评测集:训练集用3月前的数据,验证集用3月的数据,测试集用4月的数据。这样能更真实地模拟模型上线后在"未来数据"上的表现。评测指标的选择也很重要,不要只盯着准确率——比如在类别不平衡严重的场景,准确率可能是99%,但这个数字毫无意义;要同时看精确率、召回率、F1,甚至按用户群体分层看指标差异。
评测的另一个细节是评审基线。我习惯每次评估都同时跑一个"规则基线"模型,哪怕它只是一个简单的阈值判断。原因很简单:如果新模型连简单规则都跑不赢,再复杂也没有价值。这个习惯帮我们避免过很多次"看起来涨了点个点,实际上只是随机波动"的假信号。
4.2 线上预测日志:回放是评估的终极武器
真正能反映线上表现的评估,永远是线上的预测结果。我建的线上预测日志系统其实很朴素,就是每次推理后在日志里记录四样东西:请求的输入数据、模型输出、模型版本号、推理耗时。这四样东西每一条都不可废弃:
- 输入数据和输出结果,用于离线回放和效果复核
- 模型版本号,用于定位线上效果变化是哪个版本引起的
- 推理耗时,用于性能趋势预警
我后来遇到过一次线上事故,排查的时候全靠版本号定位:线上从v2切到v3后,某一类请求的预测分布突然变化,但当时线上日志里忘了记录版本号,导致我无法判断是模型切换引起的,还是数据漂移引起的。那次之后我定了死规矩,版本号必须写进每条预测日志,写不完不许发布。
回放系统的价值在离线评测之外提供了一条"输送真实战场数据"的通道。我每周会把上一周的线上预测日志拉下来,与真实结果做一次对比评估,得到的指标才是模型真实的健康度。这些真实样本还会沉淀到下一轮训练数据中,形成正向迭代闭环。
4.3 简易数据漂移检测:用PSI给线上数据体检
有了线上日志,就可以做持续的数据漂移监控。我在第2节讲过PSI指标,线上同样可以用。实现思路是:以训练阶段的特征分布为基准,每天统计当天的特征分布,计算PSI值,超过阈值就告警。
需要注意的是,特征分布漂移不等于模型效果一定变差,但它是需要人工介入的信号。我在实际使用中见过两类典型漂移:第一类是特征本身物理意义未变但分布缓慢变化,这类通常可以通过特征标准化解决;第二类是特征语义变了,比如埋点逻辑改动导致特征值口径变化,这类必须回到数据源头去修,模型层面无法弥补。区分这两类的关键步骤就是看漂移出现的时机和范围——如果是全量特征同时漂移,多半是数据采集链路的问题;如果是单个特征漂移,需要深挖业务侧变化。
5. 真实踩坑记录:我们怎么把一个自研AI工程跑挂的
5.1 内存里的隐形杀手:推理进程的"缓慢膨胀"
项目上线第二周,监控面板显示内存使用率每天都在涨一个百分点,我一开始没当回事,觉得可能只是缓存。直到连续涨到第五天,服务OOM重启了,我才意识到问题的严重性。最终定位到两个原因:一是我的LRU缓存虽然是定长,但每个value的尺寸并不恒定,输入变长内容时缓存总占用持续增长;二是模型推理过程中某些中间计算结果没有被充分释放,Python的引用计数明明应该帮我们回收,但因为循环引用没有及时清理,GC又没被触发,就变成了缓慢的内存泄漏。
排查内存泄漏最有效的方式是用tracemalloc:
import tracemalloc tracemalloc.start() # 连续快照对比 snap1 = tracemalloc.take_snapshot() # 执行一轮推理 for _ in range(1000): _infer(...) snap2 = tracemalloc.take_snapshot() top_stats = snap2.compare_to(snap1, 'lineno') for stat in top_stats[:10]: print(stat)通过对比快照,我能精确找到是哪一行代码一直在累计分配内存。这个工具救过我很多次,建议每个做AI服务的工程师都学会它。
修复方案也很简单:对缓存中的内容大小做限制,超过阈值直接淘汰;同时把推理中的中间结果改成显式del,或调整垃圾回收阈值。但我不建议过度追求内存零增长——只要增长曲线可控、有明确上限,停机发布前能自动清理,就算合格。
5.2 GIL对多线程推理的暴击:实测数据才是最有力的说服工具
我前面提到过GIL问题,这里展开讲一下我的实测。我当时写了一个多线程推理脚本,预期4个线程能带来2~3倍加速,结果压测数据让我很意外:4线程吞吐不仅没涨,还倒退了40%。我画了张简单的时间对比图,发现线程切换开销严重到超过了并行收益。这个测试结论让我彻底放弃"Python多线程搞CPU推理"的幻想。
从那以后我所有的推理worker都使用多进程。但多进程也有自己的坑:每个进程都要独立加载模型,内存翻倍;进程间通信有额外耗时。我最终的折中是每台服务器固定开2个推理进程,并在每个进程里用多线程做IO(因为IO密集型场景多线程还是有效的),算下来资源利用率最优。
我把我的实测数据直接放出来给你参考:
| 方案 | 4 worker吞吐(req/s) | 内存占用 | 结论 |
|---|---|---|---|
| 单进程 | 120 | 1.2G | 基线 |
| 4线程(单进程) | 72 | 1.2G | 吞吐下降,不推荐 |
| 2进程+2线程 | 290 | 2.4G | 折中,适合内存有限 |
| 4进程 | 510 | 4.8G | 吞吐最高,但内存翻倍 |
对一个真实项目来说,"最优"完全取决于你的资源和延迟约束。我的建议是:一定先做一轮实际压测,别只看框架文档的结论。
5.3 模型文件的版本管理:一次手滑回滚了两天
最后一个大坑是模型版本的发布管理。我的模型文件是直接打包上传到服务器的,某次新版本上线后发现效果异常,准备回滚到上一个版本。结果发现服务端目录里只有一个"latest"软链接,指向的是当前版本的文件,上一个版本已经被覆盖了。最终我只能去别的地方找到旧的模型文件,重新上传——整个回滚过程花了两天。
这件事教育了我:模型文件和代码一样,必须做版本化管理。我后来建立了三个基本规则:
- 模型文件名必须包含版本号和创建时间,禁止用"latest"这种单一命名
- 文件必须计算SHA256并入库,发布时校验哈希一致
- 服务端至少保留最近3个版本的模型文件,并留出秒级切换的回滚接口
规则很简单,但很多人就是不上心。我敢打赌,如果你的项目也用过"最新模型一坨覆盖",你早晚会碰到和我们一样的回滚惨剧。
5.4 日志系统不落盘:故障发生时才知道的记录价值
另一个踩坑点是我一开始为了图省事,把日志直接print到标准输出,认为容器平台会自动采集。结果线上故障发生时,查询日志才发现输出丢失了很大一部分,因为容器重启导致缓冲区的日志被冲掉。这是非常低级但又极其常见的错误。
我最终的日志方案是:文件日志落盘 + 关键字段结构化输出(JSON格式)。异步写入,但要有独立的刷盘机制保证服务崩溃时最多丢失几秒钟日志,不影响事故排查。每次写日志时强制带上模型版本号、请求ID、时间戳三个字段,这是我上面验证过的排障刚需字段。
6. 从单机折腾到团队协作的最小化工程化
6.1 最小的CI/CD:先解决"人人都能复现构建"的问题
项目从单人扩展到小团队协作时,我做的第一件事不是搭什么Kubernetes,而是先保证"任何人都能干净地重跑一次构建和部署"。很多合作冲突的根源是:一个人环境里有隐藏依赖,另一个人跑不起来;一个人改了代码不写说明,另一个人不知情。
我做的第一版CI流程很简单:任何代码合并到主干之前,必须通过一组自动化检查,包括代码格式、单元测试、数据管道冒烟测试、模型推理冒烟测试。这个流程一开始被团队成员当成"额外负担",但经历了一次"本地跑得好好的,到服务器上全崩"之后,所有人都自觉接受了。工程化的价值不在于流程多复杂,而在于把你个人反复手动执行的步骤固化下来,变成别人也可以信任的自动路径。
6.2 模型的灰度发布与观察期
模型发布的工程化比代码发布更难,因为模型不像代码有确定的正确性。我采用的灰度策略是:新模型先接10%的线上流量,观察预测结果的分布是否符合预期,持续2个小时以上再做全量切换。灰度期间的判断标准不是单一的准确率,而是观测分布变化、延迟变化、失败率变化三个维度。
我在灰度阶段遇到过几次"新模型离线评估更好,但线上分布怪异"的情况。比如离线F1涨了5个点,但线上某个用户群体的预测结果集中偏移,说明模型学到了某个与业务目标无关的"捷径"。这种问题只有灰度观察能发现,直接全量上线后极难回退。
6.3 容量规划:用一张表避免半夜扩容
推理服务的容量规划很多人会忽视,直到线上流量把服务打爆。我建了一张家常表格,记录了每个模型版本的单请求推理耗时、单worker可承载的QPS、显存占用等数据。每次上线前,按照预估流量的峰值做一次简单计算:需要多少个worker、多少台机器、内存和显存是否够用。
计算公式很简单:所需worker数 = 峰值QPS / 单worker可承载QPS,再乘上1.5的安全冗余系数。不要嫌这个公式土,我在第3节压测时顺手记录了不同版本各指标的表格,后来的每一次扩容决策都直接查表,半小时内就能出方案,完全没有半夜手忙脚乱。
7. 写到最后:我用这次"从头造轮子"换来的能力
如果问我这次从零实践最大的收获是什么,我会说:它不是一套可以到处复用的模板,而是一种"遇到问题敢拆开看"的信心。以前遇到线上故障,我的第一反应是搜框架的issue、问同事有没有遇到过;现在遇到故障,我习惯性地按数据、代码、资源、版本的维度去排查,一步一步缩小范围,很多问题最后发现并不是什么深不可测的玄学,而是某个具体的细节没有闭环。
我个人强烈建议你也走一遍这条路,但你不需要把所有东西都重写一遍。你只需要挑一个你最依赖、但又最不清楚内部原理的环节,从最底层开始,亲手实现一次它该有的逻辑。一次就够了。这件事如果让我再选一次,我还是会从数据管道的manifest和哈希校验写起,因为那是整个系统所有"信任"的起点。