ARTICLE DETAIL

资讯详情

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

从零搭建AI工程能力:先跑通最小闭环,再逐层加复杂度

从零搭建AI工程能力:先跑通最小闭环,再逐层加复杂度 1. 从零搭建AI工程能力为什么我劝你别一上来就啃论文ai-engineering-from-scratch这个标题第一次看到的时候我愣了一下。不是因为它有多高深恰恰相反——它戳中了一个特别尴尬的现实现在想入门AI工程的人十个里有八个卡在同一个地方不是数学不够好也不是代码写不来而是不知道该从哪一层开始动手。我自己带过几个刚转方向的朋友也见过不少工作两三年想往AI方向靠的开发者。最常见的路径是这样的先花两个月刷完吴恩达的机器学习课然后开始看Transformer论文看到注意力机制那堆矩阵运算就卡住了接着去GitHub上clone一个LLM项目发现环境装不上装上了又跑不动跑动了又看不懂代码在干嘛。三个月过去热情耗尽文件夹里躺着一堆没跑通的notebook。问题出在哪出在顺序错了。AI工程本质上是一门工程学科不是理论研究。它的核心能力不是推导公式而是把一个模型从能跑变成能用——这中间涉及数据处理、训练调优、推理部署、性能压测、成本控制每一环都是实打实的工程活。你让一个还没写过几行训练代码的人去啃Attention Is All You Need就像让一个没摸过方向盘的人去研究发动机热效率方向反了。所以这篇内容我想聊的就是从零开始构建AI工程能力的一条实操路径。不是课程大纲不是学习路线图那种虚的东西而是我自己踩过坑之后总结出来的、能让你真正动手跑起来的一套方法。适合谁看如果你是后端开发想转AI方向、是学生想找个能写进简历的项目、是产品经理想搞懂模型到底怎么落地那这篇内容应该能帮你省下不少瞎折腾的时间。核心思路就一句话先跑通最小闭环再逐层加复杂度。下面我拆开讲。2. 整体设计思路为什么最小闭环比系统学习更靠谱2.1 先搞清楚AI工程到底在工程什么很多人对AI工程的想象是训练一个大模型但真实工作里训练只占很小一部分。我粗略统计过自己过去几个项目的工时分布大概是这样的环节工时占比核心工作数据准备与清洗35%采集、去重、格式化、质量过滤训练与调优20%写训练脚本、调参、监控loss推理与部署25%模型导出、服务封装、并发处理评估与迭代15%构建评测集、badcase分析、迭代其他5%文档、协作、环境维护你看数据加部署占了六成。这意味着一个合格的AI工程师写数据处理脚本的能力比推导反向传播更重要。这不是贬低理论而是说工程能力的重心在别处。所以from scratch的scratch我理解不是从数学公式开始而是从一条能跑通的数据流开始。你要先有一个完整的、哪怕很简陋的闭环数据进来、模型训练、结果输出、效果评估。这个闭环跑通了你才有资格谈优化。2.2 为什么我反对先系统学完再动手我见过太多人陷入准备陷阱先把线性代数复习一遍再把概率论补一补然后学Python再学PyTorch最后发现学完这些已经过去半年而且忘得差不多了。这里有个认知误区AI工程的知识是网状结构不是线性结构。你不需要先学完所有前置知识才能动手很多概念是在动手过程中自然理解的。比如梯度消失这个词你看十遍定义可能还是懵的但当你自己写了一个深层网络发现loss死活不降去查资料发现是梯度消失这时候的理解是刻在脑子里的。我的建议是以项目为驱动缺什么补什么。先定一个具体的小目标比如训练一个能识别手写数字的模型并部署成API然后倒推需要学什么。这个过程中你会自然接触到数据加载、模型定义、训练循环、模型保存、服务封装每个环节遇到问题再深入。这样学下来的知识是带场景的不容易忘。2.3 最小闭环的四个组成部分一个完整的AI工程最小闭环我把它拆成四块数据层能读取数据、做基本清洗、转成模型能吃的格式训练层能定义模型、跑训练循环、保存checkpoint推理层能加载模型、接收输入、返回预测结果评估层能算指标、能看badcase、能对比不同版本这四块缺一不可。很多人只做训练层结果模型训完了不知道怎么用或者用了不知道效果好不好这就是闭环没打通。提示最小闭环的目标不是效果好而是能跑通。第一版模型准确率60%完全没关系重要的是整条链路是通的后面优化才有抓手。3. 核心细节解析每个环节的实操要点与避坑指南3.1 数据层90%的人在这里偷懒然后后悔数据层是最枯燥但最关键的环节。我见过太多项目模型结构调了半天最后发现是数据里有大量脏样本导致的。数据没做好后面全是白费。第一步是数据格式统一。不管你原始数据是CSV、JSON还是数据库第一步都转成统一的中间格式。我习惯用JSONL每行一个JSON对象因为它是流式的大文件也能逐行读不会爆内存。import json def convert_to_jsonl(raw_data, output_path): with open(output_path, w, encodingutf-8) as f: for item in raw_data: record { id: item.get(id), text: item.get(content, ).strip(), label: item.get(category) } # 过滤空样本 if not record[text]: continue f.write(json.dumps(record, ensure_asciiFalse) \n)这段代码看着简单但有两个细节值得说。一是ensure_asciiFalse不加这个中文会变成unicode转义后面调试看着难受。二是空样本过滤别小看这一步真实数据里空样本比例可能到5%以上留着就是噪声。第二步是去重。重复样本对训练的伤害比想象中大它会让模型过拟合到这些重复模式上。去重方法看数据规模小数据用集合去重就行大数据可以用MinHash或者SimHash做近似去重。def dedup_by_hash(records): seen set() unique [] for r in records: # 用文本的hash做去重键 key hash(r[text]) if key not in seen: seen.add(key) unique.append(r) return unique第三步是质量过滤。这一步最考验经验。我的做法是定几条硬规则先过一遍文本长度太短的比如少于5个字去掉全是特殊符号的去掉重复字符超过一定比例的去掉。这些规则不完美但能过滤掉大部分明显垃圾。注意数据清洗的规则一定要记录下来不要凭感觉。我吃过亏第二次跑的时候忘了上次过滤了什么结果两次数据不一致模型效果对不上排查了半天。3.2 训练层别急着上大模型先把小模型跑明白训练层的核心不是模型多大而是训练循环写对。我建议从最简单的模型开始比如一个文本分类任务用个两三层的网络就够了。目的是把训练流程跑通而不是追求SOTA。一个标准的训练循环包含这几个部分import torch import torch.nn as nn from torch.utils.data import DataLoader def train_one_epoch(model, dataloader, optimizer, criterion, device): model.train() total_loss 0 for batch in dataloader: inputs batch[input_ids].to(device) labels batch[label].to(device) optimizer.zero_grad() outputs model(inputs) loss criterion(outputs, labels) loss.backward() optimizer.step() total_loss loss.item() return total_loss / len(dataloader)这段代码里optimizer.zero_grad()的位置很关键。它必须在loss.backward()之前否则梯度会累加。我见过新手把它写在backward后面结果训练完全不收敛查了半天才发现。学习率的选择是另一个坑。太大不收敛太小训练慢。我的经验是从1e-3开始试如果loss震荡就降到1e-4如果loss几乎不动就升到1e-2。对于Transformer类模型通常用warmup加衰减的策略前10%的step线性升温后面余弦衰减。checkpoint保存别偷懒。我习惯每个epoch存一次同时保存optimizer的状态。这样中断了能续训不用从头再来。保存的时候带上epoch数和当前指标方便回溯。def save_checkpoint(model, optimizer, epoch, loss, path): torch.save({ epoch: epoch, model_state: model.state_dict(), optimizer_state: optimizer.state_dict(), loss: loss }, path)3.3 推理层模型训完只是开始能服务化才算落地推理层是很多人忽略的环节。模型在notebook里跑通了不代表能对外提供服务。这里有几个关键点。模型导出。训练用的是PyTorch部署可能用ONNX或者TensorRT。导出的时候要注意输入输出的shape要固定动态shape虽然支持但会复杂很多。导出后一定要验证输出和原模型一致我遇到过导出后精度掉一截的情况原因是某些算子不支持。# 导出ONNX示例 dummy_input torch.randn(1, 128).to(device) torch.onnx.export( model, dummy_input, model.onnx, input_names[input], output_names[output], dynamic_axes{input: {0: batch_size}} )服务封装。最简单的方式是用FastAPI包一层from fastapi import FastAPI import torch app FastAPI() model load_model(model.onnx) app.post(/predict) def predict(text: str): inputs tokenize(text) with torch.no_grad(): output model(inputs) return {label: output.argmax().item()}这里有个细节torch.no_grad()一定要加否则会构建计算图显存爆炸。另外模型加载要在服务启动时做一次不要每次请求都加载。并发处理。单线程推理吞吐很低实际服务要考虑批处理。简单做法是攒一批请求一起推理复杂点可以用Triton这类推理框架。批处理能显著提升吞吐但会增加延迟需要根据业务权衡。3.4 评估层没有评估就没有优化方向评估层是最容易被跳过但最重要的环节。模型训完了准确率85%然后呢哪些样本错了错在哪不知道这些就没法优化。构建评测集。不要用训练集评估那是自欺欺人。从原始数据里切一部分出来做测试集最好再人工标注一批难样本。评测集要固定每次迭代都用同一套否则指标没法对比。指标选择。准确率不是万能的。类别不均衡的时候准确率会骗人。比如99%的样本是A类模型全预测A也有99%准确率但毫无意义。这时候要看F1、AUC这些指标。指标适用场景注意点准确率类别均衡不均衡时会虚高F1不均衡分类综合精确率和召回率AUC排序任务不受阈值影响困惑度语言模型越低越好但要看任务badcase分析。把预测错的样本捞出来人工看一遍归类。常见的问题类型有标注错误、边界模糊、模型能力不足。标注错误要回去修数据边界模糊要考虑是否合并类别模型能力不足才考虑换模型或加数据。这个分析过程比调参有价值得多。4. 完整实操流程从零到一跑通一个文本分类项目4.1 环境准备与依赖安装先说环境。我强烈建议用conda建独立环境别在系统Python里装版本冲突能让你怀疑人生。conda create -n ai-scratch python3.10 conda activate ai-scratch pip install torch transformers scikit-learn fastapi uvicorn版本选择上PyTorch选2.xtransformers选4.x这两个组合比较稳。GPU的话装对应CUDA版本的torchCPU也能跑就是慢。提示如果公司网络装包慢可以配个国内镜像源这个不展开说懂的都懂。4.2 数据准备实操假设我们做一个情感分类任务数据是电商评论。原始数据可能是CSV两列评论内容和情感标签。第一步转JSONL并清洗import pandas as pd import json import re def clean_text(text): # 去掉多余空白 text re.sub(r\s, , text) # 去掉特殊符号但保留中文英文数字 text re.sub(r[^\u4e00-\u9fa5a-zA-Z0-9。], , text) return text.strip() df pd.read_csv(raw_comments.csv) records [] for _, row in df.iterrows(): text clean_text(str(row[content])) if len(text) 5: continue records.append({ text: text, label: int(row[sentiment]) }) # 去重 seen set() unique_records [] for r in records: if r[text] not in seen: seen.add(r[text]) unique_records.append(r) with open(clean_data.jsonl, w, encodingutf-8) as f: for r in unique_records: f.write(json.dumps(r, ensure_asciiFalse) \n) print(f原始{len(records)}条去重后{len(unique_records)}条)跑完这一步你会得到清洗后的数据。我实测下来电商评论数据去重率通常在10%到20%之间清洗掉的比例看数据源质量。4.3 训练脚本编写与运行数据有了开始写训练脚本。我用一个简单的TextCNN做演示结构简单训练快适合入门。import torch import torch.nn as nn import json from torch.utils.data import Dataset, DataLoader from collections import Counter class TextDataset(Dataset): def __init__(self, path, vocabNone, max_len128): self.data [] with open(path, encodingutf-8) as f: for line in f: self.data.append(json.loads(line)) self.max_len max_len if vocab is None: self.vocab self.build_vocab() else: self.vocab vocab def build_vocab(self): counter Counter() for item in self.data: counter.update(item[text]) # 保留频率前5000的字 vocab {char: idx2 for idx, (char, _) in enumerate(counter.most_common(5000))} vocab[pad] 0 vocab[unk] 1 return vocab def __len__(self): return len(self.data) def __getitem__(self, idx): item self.data[idx] ids [self.vocab.get(c, 1) for c in item[text]][:self.max_len] ids ids [0] * (self.max_len - len(ids)) return { input_ids: torch.tensor(ids), label: torch.tensor(item[label]) }这里有个细节词表用字符级而不是词级。中文分词麻烦字符级简单且效果不差入门阶段够用。词表大小控制在5000太小覆盖不够太大embedding参数多。模型定义class TextCNN(nn.Module): def __init__(self, vocab_size, embed_dim128, num_classes2): super().__init__() self.embedding nn.Embedding(vocab_size, embed_dim, padding_idx0) self.convs nn.ModuleList([ nn.Conv1d(embed_dim, 128, kernel_sizek) for k in [2, 3, 4] ]) self.fc nn.Linear(128 * 3, num_classes) self.dropout nn.Dropout(0.3) def forward(self, x): emb self.embedding(x).transpose(1, 2) conv_outs [] for conv in self.convs: c torch.relu(conv(emb)) c torch.max_pool1d(c, c.size(2)).squeeze(2) conv_outs.append(c) out torch.cat(conv_outs, dim1) out self.dropout(out) return self.fc(out)三个不同大小的卷积核分别捕捉2字、3字、4字的局部模式这是TextCNN的经典设计。max pooling把每个卷积核的输出压成一个值最后拼接分类。训练主循环def train(): device torch.device(cuda if torch.cuda.is_available() else cpu) train_dataset TextDataset(clean_data.jsonl) # 简单切分实际项目要分层采样 val_size int(len(train_dataset) * 0.1) train_size len(train_dataset) - val_size train_set, val_set torch.utils.data.random_split( train_dataset, [train_size, val_size] ) train_loader DataLoader(train_set, batch_size32, shuffleTrue) val_loader DataLoader(val_set, batch_size32) model TextCNN(len(train_dataset.vocab)).to(device) optimizer torch.optim.Adam(model.parameters(), lr1e-3) criterion nn.CrossEntropyLoss() best_acc 0 for epoch in range(10): model.train() total_loss 0 for batch in train_loader: inputs batch[input_ids].to(device) labels batch[label].to(device) optimizer.zero_grad() outputs model(inputs) loss criterion(outputs, labels) loss.backward() optimizer.step() total_loss loss.item() # 验证 model.eval() correct 0 total 0 with torch.no_grad(): for batch in val_loader: inputs batch[input_ids].to(device) labels batch[label].to(device) outputs model(inputs) preds outputs.argmax(dim1) correct (preds labels).sum().item() total labels.size(0) acc correct / total print(fEpoch {epoch1}, Loss: {total_loss/len(train_loader):.4f}, Acc: {acc:.4f}) if acc best_acc: best_acc acc torch.save(model.state_dict(), best_model.pt) print(fBest accuracy: {best_acc:.4f}) if __name__ __main__: train()跑起来之后你会看到每个epoch的loss和准确率。正常情况下loss应该持续下降准确率持续上升。如果loss不降先检查学习率再检查数据标签有没有问题。4.4 推理服务搭建与测试模型训好了搭个服务。用FastAPI最简单from fastapi import FastAPI from pydantic import BaseModel import torch import json app FastAPI() # 启动时加载 class Predictor: def __init__(self, model_path, vocab_path): self.device torch.device(cpu) with open(vocab_path) as f: self.vocab json.load(f) self.model TextCNN(len(self.vocab)) self.model.load_state_dict(torch.load(model_path, map_locationcpu)) self.model.eval() def predict(self, text): ids [self.vocab.get(c, 1) for c in text][:128] ids ids [0] * (128 - len(ids)) tensor torch.tensor([ids]) with torch.no_grad(): output self.model(tensor) prob torch.softmax(output, dim1) pred output.argmax(dim1).item() return {label: pred, confidence: prob[0][pred].item()} predictor Predictor(best_model.pt, vocab.json) class Request(BaseModel): text: str app.post(/predict) def predict(req: Request): return predictor.predict(req.text)启动服务uvicorn main:app --host 0.0.0.0 --port 8000测试curl -X POST http://localhost:8000/predict \ -H Content-Type: application/json \ -d {text: 这个产品质量很好物流也快}返回类似{label: 1, confidence: 0.95}就说明通了。4.5 评估与badcase分析实操服务通了最后一步是评估。写个脚本把验证集跑一遍把错的样本存下来def analyze_badcase(model, dataset, device): model.eval() badcases [] with torch.no_grad(): for i in range(len(dataset)): item dataset[i] inputs item[input_ids].unsqueeze(0).to(device) label item[label].item() output model(inputs) pred output.argmax(dim1).item() if pred ! label: badcases.append({ text: dataset.data[i][text], true: label, pred: pred }) return badcases把badcases导出来人工看你会发现一些规律。比如否定句容易错不是很好被预测成正面长文本容易错信息太多模型抓不住重点。这些发现直接指导下一步优化方向。5. 常见问题与排查技巧实录5.1 训练不收敛的排查顺序这是最高频的问题。我的排查顺序是这样的先看数据。标签有没有错有没有全是一个类别我遇到过标签列读错的情况把ID当成了标签模型当然学不会。再看学习率。太大loss震荡太小loss不动。试试1e-4到1e-2之间扫一遍。然后看模型。输出层维度对不对激活函数用对没有分类任务最后要接softmax如果用CrossEntropyLoss则不需要它内部包含了。最后看初始化。默认初始化一般没问题但如果自己写了初始化逻辑检查一下有没有把权重初始化成全0。5.2 显存不够用的几种解法显存不够是另一个高频问题。解法按优先级排方法效果代价减小batch size立竿见影训练变慢可能影响效果梯度累积等效大batch实现稍复杂混合精度训练省一半显存需要GPU支持梯度检查点省显存训练变慢换小模型彻底解决效果可能下降我一般先用混合精度torch.cuda.amp几行代码就能加上性价比最高。5.3 推理速度慢的优化方向服务上线后如果QPS上不去按这个顺序优化批处理把多个请求攒一起推理吞吐能提升几倍到几十倍模型量化FP32转INT8速度提升明显精度损失通常可接受ONNX Runtime比原生PyTorch推理快尤其是CPU场景模型蒸馏用大模型教小模型小模型推理快提示优化前先压测找到瓶颈在哪。别盲目优化有时候瓶颈在网络IO不在模型。5.4 效果上不去的排查思路模型训完了准确率卡在某个值上不去。这时候别急着换模型先做这几件事看badcase错误样本有没有共性有共性就有优化方向加数据很多问题是数据不够尤其是难样本调参学习率、batch size、正则化系数小范围扫一遍换模型前面都试过了再考虑换模型成本最高我个人的经验是数据质量提升带来的收益远大于模型结构调优。与其花一周调模型不如花一周清洗数据、补充难样本。5.5 常见问题速查表现象可能原因排查方法loss不降学习率问题/数据问题先查数据标签再扫学习率loss震荡学习率太大/batch太小降学习率增大batch过拟合数据少/模型大加正则加数据减小模型显存溢出batch太大/模型太大减batch混合精度推理慢没批处理/没量化加批处理转ONNX服务崩溃内存泄漏/并发问题查日志加限流6. 进阶方向闭环跑通之后往哪走6.1 从单模型到多模型协作最小闭环跑通后你会自然遇到单模型解决不了的问题。比如一个评论既有情感又有主题单模型搞不定这时候就需要多任务学习或者多模型串联。多任务学习是一个模型多个输出头共享底层表示多模型串联是流水线前一个模型的输出喂给后一个。两种方式各有适用场景多任务适合任务相关的场景串联适合任务独立的场景。6.2 从离线到在线持续迭代的工程化真实业务里模型不是训一次就完事需要持续迭代。这就涉及数据回流、自动训练、AB测试这一套。数据回流是把线上预测的数据收集回来人工标注后加入训练集自动训练是定时触发训练流程AB测试是把新模型和旧模型同时上线对比效果。这套工程化能力是区分会训模型和能做AI工程的分水岭。6.3 成本控制别让GPU账单吓到老板最后聊个实在的成本。训练和推理都是烧钱的尤其是大模型。控制成本的手段有用spot实例训练便宜但可能被中断需要checkpoint机制推理用CPU小模型CPU够用模型量化压缩请求批处理。我见过一个项目因为没做批处理推理成本是优化后的十倍老板看到账单脸都绿了。我个人在实际操作中的体会是AI工程这件事动手比什么都重要。你看再多教程不如自己跑通一个完整项目。跑通之后你会发现那些曾经觉得高深的概念其实都是在解决具体问题时自然理解的。从最小闭环开始一步步加复杂度这条路我走过虽然慢但扎实。
返回列表