很多人把“AI工程”想成一件特别遥远的事总觉得得先啃完几个月的数学、刷完几套论文才有资格碰模型。但我个人的经验恰恰相反真正有效的路径是从零开始把一个完整的东西做出来。项目标题挂在 GitHub 上叫ai-engineering-from-scratch说白了就是“不依赖现成封装亲手把 AI 应用从地基盖到封顶”。这篇文章想讲的就是我在这条路上沉淀下来的思路、工具、步骤和坑。这套内容适合谁两类人。一类是刚入门、想系统建立 AI 工程认知的人你能在这里找到一条不绕路的学习和实践主线另一类是已经在调 API、跑模型但遇到性能、稳定性、评测问题就头疼的人这篇里很多细节就是为你们准备的。我会把“从零”拆成环境搭建、核心模型手写、RAG 应用落地、推理优化、评测维护几个阶段每一段都给出能直接复用的方法。1. 先搞清一件事AI 工程不是“调包”而是“造轮子用轮子”1.1 为什么“从零开始”才是最快的路径先泼一盆冷水如果你只会pip install加几条transformers调用那不叫 AI 工程那叫“API 搬运工”。工程和调包之间的差距在于你有没有能力在一个环节出问题时独立定位并修复它。from scratch的核心就是逼自己把黑盒拆成白盒。我见过太多人学 AI 的路径是错的今天看一篇“用 LangChain 做知识库”的教程明天看一个“微调 Llama”的视频看起来学了很多实际上遇到一个CUDA out of memory都慌了神不知道为什么max_length设大一点就报错不知道自己显存到底被谁吃了。所以我给自己的项目定了一个铁律每个核心环节都要亲手实现一遍。不是让你在工程上线时也手写反向传播而是在学习阶段“造一遍轮子”把原理吃透等真正上工程的时候再使用成熟库。这个顺序不能反。先理解了 attention 是怎么算的你才看得懂 Flash Attention 到底优化了什么先手写过数据采样器你才知道 DataLoader 的num_workers为什么会影响训练速度甚至精度。1.2 从零构建 AI 工程的知识地图结合这个项目我把“AI 工程”拆成了六个必考科目。这个地图也是后来我做项目的检查清单模块核心内容从零实践目标工程基础Python 工程化、依赖管理、配置体系不靠 Docker 也能复现环境数据管线采集、清洗、增强、采样、版本管理手写一个可追踪的数据集类模型原理手写 Transformer、训练循环不调库能训出一个小模型应用架构RAG、Agent、微调服务从零搭一个完整问答系统部署优化推理加速、量化、服务化把模型压到可接受延迟评测监控指标设计、回归测试、线上监控每次改动都能量化好坏这六个模块不是割裂的。比如你做 RAG 应用数据管线的质量直接影响检索效果做推理服务模型原理决定你能否选对优化方案。from scratch的路径本质上是一条“每层都拆开看一眼再装回去”的路径。2. 从零开始的完整技术栈搭建环境与基础设施2.1 硬件与软件环境的选择思路很多人第一步就卡在环境上。先说结论学习阶段一张 8GB 显存的显卡完全可以起步没有独立显卡用云主机按小时租也行别纠结“卡不好学不了”。在项目实践中我给本地环境定的标准配置是# 基础运行时 Python 3.10 CUDA 11.8 或 12.1按显卡驱动选 PyTorch 2.x这里有个非常容易踩的坑CUDA 版本和 PyTorch 版本必须匹配否则你会遇到“torch.cuda.is_available() 返回 False”这种让人崩溃的问题。我的习惯是先去 PyTorch 官网按 CUDA 版本查安装命令不自己去 pip 猜。另外强烈建议用 conda 管理 Python 环境不是因为它比 venv 高级而是因为 AI 项目经常要处理不同的 CUDA 依赖conda 能帮你隔离得干净些。环境也建议写成environment.yml放进仓库这样换机器能一键复现。这是我常写的模版name: ai-engineering channels: - pytorch - conda-forge dependencies: - python3.10 - pytorch2.1.0 - torchvision - torchaudio - pip - pip: - transformers - datasets - accelerate - sentencepiece注意不要什么都往requirements.txt里塞。把带版本号的精确依赖放一份到requirements-lock.txt把每天要改的库放requirements.txt逻辑更清晰。2.2 手写一个带版本追踪的数据管线AI 工程第一步不是写模型而是把数据管线做扎实。因为后续所有实验的结论都建立在“数据没变、模型变了”的假设上。数据的增删、清洗规则的调整都应该像代码一样进入版本管理。项目里我坚持一个设计所有数据集用datasets库的Dataset类封装并自定义缓存目录。表现为这样一段代码from datasets import load_dataset # 统一的数据加载入口 def load_corpus(data_path: str, cache_dir: str ./cache): dataset load_dataset( json, data_filesdata_path, splittrain, cache_dircache_dir, ) # 打印统计信息每次跑实验都能看到数据规模 print(fLoaded {len(dataset)} samples) return dataset这样做最大的好处是每次实验的输入都是同一个对象你可以通过dataset._fingerprint校验当前数据和上次是不是同一份。我有一次调了半天模型效果没变化最后发现是数据缓存没更新这个指纹帮我定位了问题。对于训练类任务我还会在采样器里做一步“洗牌 种子固定”保证实验可复现from torch.utils.data import DataLoader dataloader DataLoader( dataset, batch_size16, shuffleTrue, generatortorch.Generator().manual_seed(42), )如果发现换了一台机器结果差异很大多半是数据加载顺序不同优先检查这里。2.3 项目目录结构与配置管理工程化意识要从项目开场就养起。我最终稳定下来的目录结构是project/ ├── configs/ # 所有实验配置yaml ├── data/ # 原始数据与中间产物 ├── src/ │ ├── data/ # 数据加载与预处理 │ ├── models/ # 模型定义 │ ├── training/ # 训练逻辑 │ ├── inference/ # 推理逻辑 │ └── evaluation/ # 评测逻辑 ├── scripts/ # 一键训练/推理脚本 ├── tests/ # 单元测试与回归测试 └── experiments/ # 实验记录配置管理我推荐用yamldataclass的方式不推荐在代码里到处写硬编码参数。一段典型的配置model: name: bert-base-chinese max_length: 512 training: batch_size: 16 learning_rate: 2e-5 epochs: 3 seed: 42然后在代码里用dataclass加载配置并把配置序列化保存到实验目录。这样每个实验的“当时参数”都留了档复现或排查问题的时候才能说清楚“上一次跑的是哪一组配置”。3. 从零写一遍核心模型理解 AI 的底层原理3.1 手写一个极简 Transformer 的工程取舍很多教程会让你把 Attention 那一套公式抄一遍然后就完了。但工程角度我建议你写的是“能跑”的版本不是“好看”的版本。所谓能跑是指过完一个真实 batch 之后 loss 真的下降。从零写 Transformer 的关键代码注意力部分可以这样拆import torch import torch.nn as nn import torch.nn.functional as F class SelfAttention(nn.Module): def __init__(self, embed_dim, num_heads): super().__init__() self.num_heads num_heads self.head_dim embed_dim // num_heads self.qkv nn.Linear(embed_dim, 3 * embed_dim) self.out_proj nn.Linear(embed_dim, embed_dim) def forward(self, x, maskNone): batch_size, seq_len, _ x.shape qkv self.qkv(x).reshape( batch_size, seq_len, 3, self.num_heads, self.head_dim ) q, k, v qkv.unbind(dim2) # 缩放点积注意力 scores (q k.transpose(-2, -1)) / (self.head_dim ** 0.5) if mask is not None: scores scores.masked_fill(mask 0, float(-inf)) attn_weights F.softmax(scores, dim-1) out attn_weights v out out.transpose(1, 2).reshape(batch_size, seq_len, -1) return self.out_proj(out)写这段的重点不在于抄公式而在于你真正理解了三件事第一为什么head_dim要等于embed_dim // num_heads因为每个头只能分到一部分特征维度第二为什么 scores 要除以sqrt(d)否则点积结果太大softmax 会进入梯度饱和区第三mask 为什么要填-inf因为-inf经过 softmax 后变成 0等价于“不看这些位置”。我自己的经验是写完这个 30 行的模块比看十篇讲 Self-Attention 原理的文章都管用。因为你会在维度对齐的过程中亲手踩坑比如q k.transpose(-2, -1)这行10 个人有 5 个人第一次会写错维度。3.2 训练过程中的关键参数与踩坑手写模型的最终目的是训起来。我建议用一个小数据集、小模型跑通训练循环确认 loss 有下降趋势再谈大规模训练。一个典型的从零训练循环def train_one_epoch(model, dataloader, optimizer, device): model.train() total_loss 0.0 for batch in dataloader: input_ids batch[input_ids].to(device) labels batch[labels].to(device) optimizer.zero_grad() logits model(input_ids) loss F.cross_entropy( logits.view(-1, logits.size(-1)), labels.view(-1) ) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step() total_loss loss.item() return total_loss / len(dataloader)这里我有三条经验第一梯度裁剪是必需品。手写模型很容易在训练开始阶段梯度爆炸loss 直接变成nan。用clip_grad_norm_把梯度模长限制在 1.0 左右能挡掉大多数这类问题。第二学习率别拍脑袋。Transformer 类模型一般要从3e-4往下试RNN 类模型可以试试1e-3。如果第一次打印 loss 就是nan最优先怀疑学习率过大其次再查数据里有没有缺失值。第三log 里同时记录显存和 loss这是工程判断的依据。我常用的训练回显Epoch 1/3 | Batch 100/500 | Loss 4.32 | LR 3e-4 | Mem 6.2G如果显存占用平稳上升但 loss 不降大概率是模型容量/学习率的问题如果显存直接爆掉那就去查batch_size、序列长度和模型层数。3.3 从模型到应用打通推理链路训练完模型之后工程难度才刚开始。推理阶段与训练阶段有一个巨大差异你无法控制用户的输入长度。这个问题处理不好线上分分钟报错。我在项目里把推理封装成独立服务并在入口处做了长度控制def generate(model, tokenizer, prompt, max_new_tokens128): inputs tokenizer(prompt, return_tensorspt, truncationTrue, max_length512) with torch.no_grad(): outputs model.generate( inputs.input_ids.to(device), max_new_tokensmax_new_tokens, do_sampleTrue, temperature0.7, top_p0.9, ) return tokenizer.decode(outputs[0], skip_special_tokensTrue)do_sampleTrue时temperature0.7是比较通用的初始值如果发现生成内容太散就往低调到0.3~0.5如果太死板、像复读机就往高调到0.9左右。注意这几个参数直接影响用户体验上线前值得做一轮小范围评测再定。这里额外提示自己踩过的坑中文模型分词后skip_special_tokens很关键否则你会在回答里看到一堆[CLS]、[SEP]这种噪音。另外如果输入特别长truncationTrue会直接截断但截断可能导致语义丢失所以还要在日志里记录这个样本被截断了用于后期数据复盘。4. 落地一个真正的 AI 工程以 RAG 问答系统为例4.1 选型与整体架构“从零”阶段走完后我建议立刻用一个完整的真实应用把各模块串起来。当前最典型、最容易入门又不失深度的工程是RAG检索增强生成问答系统。选 RAG 而不是直接微调模型原因很实际知识更新快、领域适配灵活、可解释性强、开发成本相对低。尤其在垂直领域比如产品手册、公司内部文档RAG 几乎是标配方案。整体架构我会画成四个模块文档解析与切分、向量化与检索、提示词组装与生成、反馈与评测。没有复杂的依赖我用的是最朴素的组合SentenceTransformer做 embeddingFAISS做向量检索Transformers或 OpenAI 兼容接口做生成。这套组合在中小规模文档场景下稳定性和可控性都很好。4.2 Embedding、向量库与检索链路我见过很多 RAG 项目死在“embedding 随便选”这件事上。如果你用的是通用中文 embedding 模型而你的文档是法律条文或医疗记录检索效果大概率不理想。选型的原则是先在自己的语料上跑到一个可对比的评测集再定模型不要看榜单就下单。切分策略是另一个关键点。用定长切分比如 500 个字符简单粗暴但会把语义单元切碎用结构切分按标题、段落更贴近文档原意但实现成本更高。我的折中方案from langchain.text_splitter import RecursiveCharacterTextSplitter splitter RecursiveCharacterTextSplitter( chunk_size500, chunk_overlap80, separators[\n\n, \n, 。, , , , ], )chunk_overlap的作用是保证跨段信息不丢但过大又会造成信息冗余、检索噪音。如果你发现“检索到的东西和问题关联度低”先检查切分是不是太碎再检查 overlap 是否太小。向量库这里FAISS 对小规模数据是优选因为我只需要本地跑、不依赖额外服务。数据量在百万级以下完全能撑住。索引构建代码如下import faiss import numpy as np def build_index(embeddings: np.ndarray): dim embeddings.shape[1] index faiss.IndexFlatIP(dim) # 内积索引适合归一化向量 faiss.normalize_L2(embeddings) index.add(embeddings) return index用内积之前先把向量做了 L2 归一化等价于余弦相似度这是检索里最常用的做法。4.3 生成链路与工程化细节RAG 的生成端最核心的不是模型大小而是提示词的组装。同一个模型用一段结构化的提示词效果可以天差地别。我目前稳定的版本长这样你是一个严谨的问答助手。请根据以下资料回答问题。 如果资料中没有相关信息请直接回答“资料中未找到相关信息”。 资料 {context} 问题{question} 回答这里有几个工程细节第一context不是简单地拼接检索结果而是先做相关性排序只取 top-k第二如果检索结果与问题的相似度低于阈值宁可让模型说“不知道”也不要硬编第三把引用来源放在回答后面用于人工核对。这些都是我在实践中被用户反馈逼出来的改进。检索与生成的结合有很多花样但你从零起步时先做最朴素的版本再逐步加 query 改写、重排序、多路召回。一上来就堆技术最后只会变成“什么都试了什么都调不动”。5. 性能优化、评测与上线维护5.1 推理加速的实用手段模型部署上线第一个问题是延迟。同样的模型有人能压到 100ms有人却跑出 2 秒差别往往在于有没有做对优化。我个人常用的三板斧半精度推理、KV Cache、批量调度。半精度推理是最容易做也最有效的一步。如果是 PyTorch只需要model model.half()前提是你的 GPU 支持 FP16并且模型对精度损失不敏感。实测中大多数推理任务用 FP16 没问题但如果你在跑数学计算或严格逻辑推理还是要回到 FP32 做一次对比别盲调。KV Cache 其实就是“不要每次都重新算历史 token 的键值”只在增量生成时算新 token。Transformers 库在generate时默认开启但如果你手写推理循环一定要自己实现这一点否则速度会慢几倍。批量调度则是把多条用户请求合并成一个 batch。同样一次 GPU 计算batch_size8时吞吐能提升好几倍延迟却不一定是 8 倍。这个优化在服务端尤其重要。5.2 评测体系没有量化就没有优化很多人调模型靠“感觉”这是一个巨大的工程隐患。没有量化指标你根本无法判断“上一版和这一版到底谁更好”。我在项目里建立了一套最小可行的评测集包含三类样本评测类型样例数目的标准问答100验证主场景边界情况30验证拒答能力、超长输入对抗样本20验证提示词注入、无关问题跑完评测后至少统计三个指标准确率/命中率、拒答率、平均响应延迟。每次改动比如换 embedding、调 chunk 大小、换提示词都重新跑一遍把结果记录到实验表格里。这个习惯会救你很多次尤其是“改来改去感觉没变化”的时候数据会告诉你到底有没有变化。5.3 监控与迭代上线后监控的第一指标不是 CPU而是输入输出内容质量。我在项目里至少做三件小事给每个线上请求记录query和时间戳用于后续复盘。记录检索召回数量以及响应 token 数用于判断上下文拼接是否有问题。每天对一小部分用户反馈做人工抽检必要时把它加入评测集。有一回我发现线上模型开始“胡说八道”排查之后发现是底库被错误地清空检索为空导致模型乱编。因为监控了检索召回数这个异常立刻暴露了。如果不做这个日志用户可能只会觉得“最近回答变差了”但你不知道改哪里。6. 常见问题与排错速查6.1 环境与依赖问题这类问题大多是“版本错位”引起的。先检查 CUDA、PyTorch、GPU 驱动的三角匹配关系。这里有一个快速诊断命令python -c import torch; print(torch.__version__, torch.cuda.is_available(), torch.cuda.get_device_name(0))如果torch.cuda.is_available()是False不要先重装 PyTorch先看nvidia-smi的驱动版本再看 PyTorch 对应编译的 CUDA 版本是多少。版本跨度太大时PyTorch 会拒绝调用 GPU。依赖冲突也是高频问题。langchain这个库的更新很活跃经常和transformers的某些版本打架。我的策略是把 AI 相关依赖固定在一个环境里不和其他项目混用出现冲突时优先使用conda list查版本而不是盲目pip install --upgrade。6.2 训练与推理问题速查现象优先排查项Loss 为 NaN学习率过大、数据含 NaN、梯度爆炸Loss 不下降学习率过低、标签错位、数据管线和模型不匹配显存 OOMbatch_size 过大、序列过长、梯度累积没生效生成内容重复temperature 过低、未开 top_p、采样方式错误推理结果乱码tokenizer 与模型不匹配、skip_special_tokens没开OOM这个问题值得单独说。如果你确定batch_size1还报显存不足那多半是序列长度撑爆了。可以先用max_length128跑通再逐步调大。如果真要在有限显存里跑长文本可以换成梯度累积或者用 Flash Attention 这类算子级优化但那个要单独花时间配环境。6.3 工程化问题上线前最容易忽略的是“输入校验”。用户可能传一个 10 万字的文本进来也可能传一个空字符串。我在服务入口会做三层检查长度检查超出限制的输入直接截断或返回提示。空值检查空 query 不需要走模型直接返回默认提示。内容类型检查防止传非文本内容。另外建议给模型服务加一个统一的超时控制。生成式模型如果不设超时最坏情况会挂起整个服务。用asyncio.wait_for把单次请求控制在 10 秒以内如果超时就返回一个兜底回答同时把这条样本记录下来用于分析。写在最后从零做 AI 工程最深的体会是真正的工程能力来自“亲手踩坑、再亲手填坑”。手写一遍 Transformer你才能真正明白显存为什么被吃搭建一次 RAG你才知道检索结果差是因为 chunk 策略还是 embedding 选型上线一版推理服务你才理解延迟、吞吐、稳定性不是 PPT 上的名词而是真实存在的约束。如果你刚开始走这条路我的建议是不要贪多。先把“一个小模型 一条数据管线 一个推理服务”完整打通再往里面加复杂度。做一个闭环比开十个头重要得多。后续还可以在这个基础上扩展给 RAG 加一层重排序把 Embedding 换成领域微调版本或者把推理服务改成异步批处理。方向很多但地基就是这些希望我这篇分享能帮你少走几步弯路。