1. 从零搭建AI工程能力,为什么大多数人卡在第一步就放弃了
如果你最近在技术社区里频繁看到"ai-engineering-from-scratch"这个说法,不用怀疑,它不是什么新框架的名字,也不是某个大厂开源的库。它描述的是一种学习路径——从最底层开始,把AI工程化所需的每一项能力亲手搭起来,而不是上来就调API、跑现成模型。我之所以想聊这个话题,是因为过去大半年里,我带过几个刚入行的朋友做AI相关的项目,发现一个非常普遍的现象:他们能熟练地调用各种模型接口,能跑通LangChain的示例代码,但一旦遇到需要自己处理数据管道、自己设计推理服务、自己优化延迟的场景,就完全不知道从哪里下手。
这个问题的根源不在于他们不够聪明,而在于学习路径被"捷径"绑架了。现在的AI工具链太成熟了,成熟到你只需要几行代码就能得到一个看起来能用的结果。但这种"能用"和"工程化"之间的距离,比大多数人想象的要大得多。ai-engineering-from-scratch这个方向,本质上是在补那段被跳过的基本功。
这篇文章适合谁看?如果你是刚接触AI工程的学生或者转行者,它能帮你建立一条清晰的学习路线,避免在工具链的海洋里迷失方向。如果你已经有一定经验,但总觉得自己的知识体系是"拼凑"起来的,它也能帮你把散落的点串成线。我不会给你一个"21天速成"的计划,因为那不存在。我会做的是,把从零构建AI工程能力这件事拆开,告诉你每个阶段该做什么、为什么这么做、以及最容易踩的坑在哪里。
2. 先搞清楚AI工程到底在工程什么
2.1 模型不是全部,甚至不是最重要的部分
很多人对AI工程的想象是这样的:训练一个模型,然后部署上线。但真实情况是,在一个完整的AI系统里,模型本身可能只占20%的工作量。剩下的80%是什么?是数据采集和清洗、是特征处理、是推理服务的搭建、是监控和告警、是版本管理和回滚机制。我见过太多项目,模型指标在实验室里很漂亮,一上线就崩,原因往往跟模型无关,而是数据管道在真实流量下出了问题。
举个具体的例子。假设你要做一个文本分类服务。在notebook里,你加载一个预训练模型,喂几条测试数据,准确率90%,皆大欢喜。但到了生产环境,你需要考虑的事情包括:输入文本的长度分布是什么样的?有没有异常字符?并发请求量有多大?单个请求的延迟要求是多少?模型文件有多大,加载需要多久?需不需要做批处理?这些问题,没有一个能靠调API解决。
所以ai-engineering-from-scratch的第一课,不是学某个框架,而是建立"系统思维"。你要把自己当成一个造房子的人,模型只是其中一块砖,你还需要地基、框架、水电、装修。
2.2 从零开始不等于从轮子开始造
这里有一个常见的误解需要澄清。"From scratch"不意味着你要自己实现矩阵乘法、自己写反向传播、自己造一个PyTorch出来。那是研究者的工作,不是工程师的。AI工程领域的"从零",指的是从工程化的角度,把每一个环节都理解透彻,知道它是怎么工作的、为什么这么设计、什么时候会出问题。
你可以用现成的库,但你要知道这个库在背后做了什么。比如你用HuggingFace的transformers加载模型,一行代码就搞定了。但你应该知道,这行代码背后发生了这些事情:从磁盘或网络加载模型权重文件、初始化模型结构、把权重填进去、把模型移到指定设备上、设置推理模式。每一步都可能出问题——权重文件损坏、内存不够、设备不可用、版本不匹配。如果你不理解这些,出了问题就只能靠重启和祈祷。
2.3 一条被验证过的学习路线
基于我带人的经验,从零构建AI工程能力可以分成四个阶段,每个阶段有明确的目标和产出物。
| 阶段 | 核心目标 | 关键产出 | 预计投入 |
|---|---|---|---|
| 基础夯实 | 理解数据流动的全链路 | 一个完整的数据处理管道 | 4-6周 |
| 模型服务化 | 把模型变成可调用的服务 | 一个带监控的推理API | 3-4周 |
| 性能与可靠性 | 让服务在真实流量下稳定运行 | 压测报告和优化方案 | 4-6周 |
| 迭代与运维 | 建立持续改进的闭环 | 自动化评估和部署流程 | 持续进行 |
这个路线不是线性的,你可能会在某个阶段卡很久,也可能会跳着学。但大方向是这样的:先让数据能顺畅地流起来,再让模型能稳定地提供服务,然后让服务能扛住压力,最后让整个系统能持续进化。
3. 数据管道:最不起眼但最容易翻车的地方
3.1 为什么数据管道值得你花最多时间
我在实际项目里观察到一个规律:一个AI项目延期,十有八九是因为数据问题。模型选型可以很快决定,服务框架可以快速搭建,但数据管道往往是一团乱麻。原因很简单,数据是脏的、乱的、不完整的、格式不统一的。你在网上下的数据集是别人清洗过的,但真实业务里的数据,什么妖魔鬼怪都有。
从零构建数据管道,你需要处理的事情包括:数据采集(从哪里来)、数据清洗(去掉什么)、数据转换(变成什么格式)、数据存储(放在哪里)、数据版本管理(怎么追踪变化)。每一件事都有坑,而且这些坑往往在项目后期才会暴露出来。
3.2 一个最小可用的数据管道长什么样
我不打算给你一个复杂的架构图,那没有意义。我们从最简单的开始,一个能跑通的数据管道,核心就是三个环节:读取、处理、写入。
import json from pathlib import Path from typing import Iterator def read_raw_data(path: str) -> Iterator[dict]: """从JSONL文件逐行读取原始数据""" with open(path, 'r', encoding='utf-8') as f: for line in f: line = line.strip() if not line: continue try: yield json.loads(line) except json.JSONDecodeError: # 记录坏数据,但不中断整个流程 continue def clean_record(record: dict) -> dict | None: """清洗单条记录,返回None表示丢弃""" text = record.get('text', '').strip() if len(text) < 10: return None # 去掉控制字符 text = ''.join(ch for ch in text if ch.isprintable() or ch in '\n\t') return {'text': text, 'label': record.get('label', 'unknown')} def write_processed(records: Iterator[dict], output_path: str): """写入处理后的数据""" Path(output_path).parent.mkdir(parents=True, exist_ok=True) with open(output_path, 'w', encoding='utf-8') as f: for record in records: f.write(json.dumps(record, ensure_ascii=False) + '\n')这段代码看起来很简单,但里面有几个关键的设计决策值得说。第一,逐行读取而不是一次性加载,这是为了处理大文件时不会爆内存。第二,遇到坏数据时跳过而不是崩溃,这是生产环境的基本要求。第三,清洗逻辑独立成函数,方便测试和复用。
3.3 数据版本管理:一个被严重低估的环节
你可能会问,数据还需要版本管理?当然需要。想象一下这个场景:你用一份数据训练了模型A,效果不错。两周后你更新了数据,重新训练了模型B,结果效果变差了。你想回退到模型A,但发现数据已经变了,没法复现。这时候你就知道数据版本管理有多重要了。
最土但最有效的办法是:每次数据处理的结果都带一个时间戳或者哈希值,存到不同的目录里。然后在训练脚本里记录用了哪个版本的数据。不需要上什么高级工具,一个简单的命名规范就能解决80%的问题。
注意:千万不要覆盖原始数据。原始数据是你的最后一道防线,任何时候都不要直接修改它。所有的清洗和转换都应该是生成新的文件。
3.4 数据质量的检查清单
在把数据喂给模型之前,我通常会跑一遍检查清单。这个清单帮我避免了很多低级错误。
- 样本数量:够不够?太少会导致过拟合,太多可能训练时间过长。
- 类别分布:均不均衡?严重不均衡需要做采样或加权。
- 文本长度分布:有没有异常长或异常短的样本?
- 重复率:有没有大量重复样本?重复样本会让模型记住而不是学习。
- 标签一致性:同一个样本有没有被标成不同类别?
- 特殊字符:有没有乱码、控制字符、HTML标签残留?
这些检查不需要复杂的工具,用pandas加几行代码就能搞定。但就是这几行代码,能帮你省下几天甚至几周的调试时间。
4. 把模型变成服务:从notebook到API的距离
4.1 notebook里的模型和生产环境的模型是两回事
在notebook里,模型是一个对象,你调用它的predict方法,它返回结果。在生产环境里,模型是一个服务,它需要处理并发请求、需要控制延迟、需要在出错时优雅降级。这两者之间的差距,就是AI工程的核心战场。
我见过很多团队,模型在notebook里跑得好好的,一做成API就各种问题。最常见的问题包括:内存泄漏(每次请求都加载模型)、延迟波动大(没有做批处理)、并发上不去(用了同步框架)、错误处理缺失(一个坏请求搞崩整个服务)。
4.2 一个最小可用的推理服务
我们用FastAPI来搭一个最简单的推理服务。选择FastAPI的理由很直接:异步支持好、性能不错、代码量少、自带文档。
from fastapi import FastAPI, HTTPException from pydantic import BaseModel from contextlib import asynccontextmanager import torch from transformers import AutoTokenizer, AutoModelForSequenceClassification # 全局变量,在启动时加载 model = None tokenizer = None @asynccontextmanager async def lifespan(app: FastAPI): """服务启动时加载模型,关闭时释放""" global model, tokenizer model_name = "your-model-path" tokenizer = AutoTokenizer.from_pretrained(model_name) model = AutoModelForSequenceClassification.from_pretrained(model_name) model.eval() if torch.cuda.is_available(): model = model.cuda() yield # 清理 del model del tokenizer app = FastAPI(lifespan=lifespan) class PredictRequest(BaseModel): text: str class PredictResponse(BaseModel): label: str score: float @app.post("/predict", response_model=PredictResponse) async def predict(request: PredictRequest): if not request.text.strip(): raise HTTPException(status_code=400, detail="text cannot be empty") try: inputs = tokenizer( request.text, return_tensors="pt", truncation=True, max_length=512, padding=True ) if torch.cuda.is_available(): inputs = {k: v.cuda() for k, v in inputs.items()} with torch.no_grad(): outputs = model(**inputs) probs = torch.softmax(outputs.logits, dim=-1) score, pred = torch.max(probs, dim=-1) return PredictResponse( label=str(pred.item()), score=float(score.item()) ) except Exception as e: raise HTTPException(status_code=500, detail=str(e))这段代码有几个关键点。第一,模型在lifespan里加载,只加载一次,所有请求共享。这是最基本的优化,但很多人会忘记。第二,用了torch.no_grad(),推理时不需要计算梯度,能省不少内存和计算。第三,加了输入校验,空文本直接返回400。第四,异常处理,避免一个坏请求导致服务崩溃。
4.3 批处理:提升吞吐量的第一把钥匙
上面的服务能跑,但吞吐量很低。每个请求单独做一次前向传播,GPU利用率可能只有10%。解决办法是批处理:把多个请求攒在一起,一次性送给模型。
批处理的实现有两种思路。一种是客户端攒批,就是客户端自己把多个请求合并成一个。另一种是服务端攒批,服务端维护一个队列,每隔几毫秒或者攒够一定数量就做一次推理。服务端攒批对客户端透明,更实用。
import asyncio from collections import deque class BatchProcessor: def __init__(self, model, tokenizer, max_batch_size=32, max_wait_ms=10): self.model = model self.tokenizer = tokenizer self.max_batch_size = max_batch_size self.max_wait_ms = max_wait_ms self.queue = deque() self.lock = asyncio.Lock() async def predict(self, text: str) -> dict: future = asyncio.get_event_loop().create_future() async with self.lock: self.queue.append((text, future)) if len(self.queue) >= self.max_batch_size: await self._process_batch() # 等待结果 return await future async def _process_batch(self): if not self.queue: return batch = list(self.queue) self.queue.clear() texts = [item[0] for item in batch] futures = [item[1] for item in batch] try: inputs = self.tokenizer( texts, return_tensors="pt", truncation=True, max_length=512, padding=True ) if torch.cuda.is_available(): inputs = {k: v.cuda() for k, v in inputs.items()} with torch.no_grad(): outputs = self.model(**inputs) probs = torch.softmax(outputs.logits, dim=-1) scores, preds = torch.max(probs, dim=-1) for i, future in enumerate(futures): future.set_result({ 'label': str(preds[i].item()), 'score': float(scores[i].item()) }) except Exception as e: for future in futures: future.set_exception(e)这个批处理器还需要一个定时器来触发,否则请求不够一批时会一直等。实际实现中,可以用asyncio的定时任务,每隔max_wait_ms检查一次队列。批处理能把吞吐量提升5到10倍,代价是稍微增加了一点延迟。这个权衡在大多数场景下是值得的。
4.4 模型服务的监控指标
服务上线之后,你需要知道它运行得怎么样。最少要监控这几个指标:请求量(QPS)、延迟分布(P50、P95、P99)、错误率、GPU利用率、内存占用。这些指标不需要复杂的系统,用Prometheus加Grafana就能搞定。如果不想搭这套,至少要在日志里记录每个请求的处理时间和结果状态。
提示:延迟的P99比平均值重要得多。平均值会被大量快速请求拉低,掩盖掉那些慢请求。而用户体验往往由最慢的那1%决定。
5. 性能优化:当服务开始扛不住的时候
5.1 先定位瓶颈,再动手优化
性能优化最忌讳的就是凭感觉。你觉得是模型推理慢,结果发现是数据预处理占了80%的时间。所以第一步永远是测量,不是优化。
我常用的方法是:在代码的关键路径上打时间戳,记录每个阶段的耗时。比如一个请求进来,记录tokenization花了多久、模型推理花了多久、后处理花了多久。跑几百个请求,看时间分布。这样你就能清楚地知道瓶颈在哪里。
常见的瓶颈和对应的优化方向:
| 瓶颈位置 | 典型表现 | 优化方向 |
|---|---|---|
| 数据预处理 | CPU占用高,GPU空闲 | 并行化、缓存、用更快的tokenizer |
| 模型推理 | GPU利用率高,延迟大 | 量化、蒸馏、换更小的模型 |
| 后处理 | 延迟波动大 | 优化算法、异步处理 |
| 网络传输 | 延迟与请求大小相关 | 压缩、批处理、连接复用 |
| 内存 | 频繁GC,延迟抖动 | 减少对象创建、用对象池 |
5.2 模型量化:用一点精度换大量速度
量化是把模型的权重从浮点数变成低精度整数,比如从FP32变成INT8。这样模型文件变小了,推理速度也快了,代价是精度可能会下降一点点。对于大多数应用场景,这个代价是可以接受的。
用PyTorch做动态量化很简单:
import torch.quantization # 动态量化,适用于LSTM和Linear层 quantized_model = torch.quantization.quantize_dynamic( model, {torch.nn.Linear}, dtype=torch.qint8 )动态量化不需要校准数据,直接转换就行。实测下来,模型大小能减少到原来的四分之一,推理速度能提升2到3倍,精度损失通常在1%以内。如果你的场景对精度极其敏感,可以先在小批量数据上对比量化前后的输出,确认差异在可接受范围内再上线。
5.3 缓存:最简单也最有效的优化
如果你的服务有大量重复请求,缓存是最划算的优化。比如一个分类服务,热门文本可能被反复请求。用一个简单的LRU缓存就能挡住大部分流量。
from functools import lru_cache import hashlib def get_cache_key(text: str) -> str: return hashlib.md5(text.encode()).hexdigest() # 在服务层做缓存 cache = {} async def predict_with_cache(text: str): key = get_cache_key(text) if key in cache: return cache[key] result = await batch_processor.predict(text) # 限制缓存大小 if len(cache) > 10000: cache.clear() cache[key] = result return result缓存的关键是选好key。对于文本任务,直接用文本的哈希做key就行。对于更复杂的输入,需要设计合适的key生成策略。缓存也要注意失效策略,如果模型更新了,缓存必须清空。
5.4 一个真实的优化案例
我之前优化过一个文本分类服务,初始版本QPS只有20,P99延迟800毫秒。优化过程分了三步。第一步,加了批处理,QPS提升到80,P99延迟降到300毫秒。第二步,做了动态量化,QPS提升到150,P99延迟降到180毫秒。第三步,加了缓存,热门请求直接命中,整体QPS提升到400以上,P99延迟稳定在100毫秒以内。
每一步优化之前,我都先测量了瓶颈在哪里。第一步的瓶颈是GPU利用率低,所以做批处理。第二步的瓶颈是模型本身的计算量,所以做量化。第三步的瓶颈是重复计算,所以做缓存。如果顺序反了,比如先做缓存,效果就不会这么明显,因为缓存只能挡住重复请求,挡不住首次请求的慢。
6. 持续迭代:让系统自己进化
6.1 离线评估和在线评估的鸿沟
模型上线不是终点,而是起点。你需要持续监控它的表现,发现问题,然后迭代。这里有一个经典的陷阱:离线指标好不代表线上效果好。离线评估用的是历史数据,线上面对的是真实流量,两者的分布可能不一样。
我遇到过好几次这样的情况:新模型在离线测试集上准确率提升了3%,上线后线上指标反而下降了。原因通常是训练数据和线上数据的分布有偏移,或者离线测试集不能代表真实场景。解决办法是建立在线评估机制,用真实流量做A/B测试,让数据说话。
6.2 一个简单的A/B测试框架
A/B测试的核心思想是:把流量分成两组,一组用旧模型,一组用新模型,对比关键指标。实现起来不需要很复杂,一个分流逻辑加一个指标收集就够了。
import random class ABTestRouter: def __init__(self, model_a, model_b, split_ratio=0.1): self.model_a = model_a self.model_b = model_b self.split_ratio = split_ratio def route(self, request_id: str): # 用请求ID做哈希,保证同一请求始终走同一组 hash_val = int(hashlib.md5(request_id.encode()).hexdigest(), 16) if (hash_val % 100) < (self.split_ratio * 100): return self.model_b, "B" return self.model_a, "A"分流的关键是保证同一个用户或同一个请求始终走同一组,否则体验会不一致。用请求ID或者用户ID做哈希是最简单的办法。指标收集方面,至少要有准确率、延迟、错误率这三个维度,按组分别统计。
6.3 模型更新的安全流程
模型更新不能直接覆盖,要有回滚机制。我推荐的流程是这样的:新模型先在小流量上验证,确认没问题后逐步扩大流量,同时旧模型保持可用。一旦新模型出问题,立即切回旧模型。
这个流程需要版本管理来支撑。每个模型版本都要有唯一的标识,服务能根据配置加载指定版本。配置的变更要能快速生效,最好不用重启服务。
注意:模型文件要保留至少两个版本。我见过有人更新模型时直接覆盖了旧文件,结果新模型有问题想回滚,发现旧模型已经没了,只能连夜重新训练。
6.4 数据回流:让模型越用越好
线上产生的数据是宝贵的资产。用户的实际输入、模型的输出、用户的反馈,这些数据可以用来持续改进模型。建立一个数据回流管道,把线上数据收集起来,定期标注,加入训练集,重新训练模型。这个闭环一旦建立起来,模型的效果会随着时间自然提升。
数据回流要注意隐私和合规问题。敏感信息要脱敏,用户数据的使用要符合规范。这些不是技术问题,但比技术问题更重要。
7. 我踩过的那些坑和总结的经验
7.1 不要过早优化,但也不要忽视明显的瓶颈
我刚入行的时候,总想把每个环节都优化到极致。结果花了两周做模型量化,上线后发现瓶颈根本不在模型,而在数据预处理。后来我学乖了,先上线一个能跑的版本,测量真实瓶颈,再针对性优化。但反过来,如果某个瓶颈非常明显,比如每次请求都重新加载模型,那就不要犹豫,立刻修掉。
7.2 日志和监控不是可选项
我吃过最大的亏是一个服务上线后没有加监控,结果半夜挂了没人知道,第二天用户投诉才发现。从那以后,我要求自己做的每个服务都必须有基本的监控和告警。不需要很复杂,至少要有请求量、错误率、延迟这三个指标,超过阈值就发通知。
7.3 测试要覆盖边界情况
模型服务的测试不能只测正常输入。空字符串、超长文本、特殊字符、并发请求、模型加载失败,这些边界情况都要测。我习惯在服务上线前跑一轮"破坏性测试",故意发一些奇怪的请求,看服务会不会崩。这个习惯帮我避免了好几次线上事故。
7.4 文档是写给未来的自己的
我现在写代码,会强制自己写两类文档。一类是接口文档,说明每个接口的输入输出和错误码。另一类是运维文档,说明服务怎么启动、怎么配置、出问题了怎么排查。写的时候觉得麻烦,但每次回头查的时候都觉得值。特别是当你半夜被叫起来处理故障时,一份清晰的运维文档能救命。
7.5 保持学习,但不要追新
AI工程领域的新工具和新框架层出不穷,今天流行这个,明天流行那个。我的策略是:保持关注,但不轻易切换。除非新工具能解决我当前面临的具体问题,否则我不会花时间去学。把精力放在基本功上,比追新框架的回报率高得多。数据管道、服务化、性能优化、监控运维,这些核心能力不会因为框架的更替而贬值。
从零构建AI工程能力是一条长路,没有捷径。但每一步都算数,每一个坑都会让你变得更强。我到现在也不敢说自己完全掌握了,但至少我知道遇到问题时该往哪个方向找答案。这种"知道怎么找答案"的能力,可能比任何具体的知识点都重要。