☰
从零构建AI工程:手写Transformer到RAG落地全攻略
2026/9/30 12:19:31 网站建设 项目流程

很多人把“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: - python=3.10 - pytorch=2.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_files=data_path, split="train", cache_dir=cache_dir, ) # 打印统计信息,每次跑实验都能看到数据规模 print(f"Loaded {len(dataset)} samples") return dataset

这样做最大的好处是:每次实验的输入都是同一个对象,你可以通过dataset._fingerprint校验当前数据和上次是不是同一份。我有一次调了半天模型效果没变化,最后发现是数据缓存没更新,这个指纹帮我定位了问题。

对于训练类任务,我还会在采样器里做一步“洗牌 + 种子固定”,保证实验可复现:

from torch.utils.data import DataLoader dataloader = DataLoader( dataset, batch_size=16, shuffle=True, generator=torch.Generator().manual_seed(42), )

如果发现换了一台机器结果差异很大,多半是数据加载顺序不同,优先检查这里。

2.3 项目目录结构与配置管理

工程化意识要从项目开场就养起。我最终稳定下来的目录结构是:

project/ ├── configs/ # 所有实验配置(yaml) ├── data/ # 原始数据与中间产物 ├── src/ │ ├── data/ # 数据加载与预处理 │ ├── models/ # 模型定义 │ ├── training/ # 训练逻辑 │ ├── inference/ # 推理逻辑 │ └── evaluation/ # 评测逻辑 ├── scripts/ # 一键训练/推理脚本 ├── tests/ # 单元测试与回归测试 └── experiments/ # 实验记录

配置管理我推荐用yaml+dataclass的方式,不推荐在代码里到处写硬编码参数。一段典型的配置:

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, mask=None): 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(dim=2) # 缩放点积注意力 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_tokens=128): inputs = tokenizer(prompt, return_tensors="pt", truncation=True, max_length=512) with torch.no_grad(): outputs = model.generate( inputs.input_ids.to(device), max_new_tokens=max_new_tokens, do_sample=True, temperature=0.7, top_p=0.9, ) return tokenizer.decode(outputs[0], skip_special_tokens=True)

do_sample=True时,temperature=0.7是比较通用的初始值;如果发现生成内容太散,就往低调到0.3~0.5;如果太死板、像复读机,就往高调到0.9左右。注意,这几个参数直接影响用户体验,上线前值得做一轮小范围评测再定。

这里额外提示自己踩过的坑:中文模型分词后skip_special_tokens很关键,否则你会在回答里看到一堆[CLS]、[SEP]这种噪音。另外,如果输入特别长,truncation=True会直接截断,但截断可能导致语义丢失,所以还要在日志里记录这个样本被截断了,用于后期数据复盘。

4. 落地一个真正的 AI 工程:以 RAG 问答系统为例

4.1 选型与整体架构

“从零”阶段走完后,我建议立刻用一个完整的真实应用把各模块串起来。当前最典型、最容易入门又不失深度的工程是RAG(检索增强生成)问答系统。

选 RAG 而不是直接微调模型,原因很实际:知识更新快、领域适配灵活、可解释性强、开发成本相对低。尤其在垂直领域(比如产品手册、公司内部文档),RAG 几乎是标配方案。

整体架构我会画成四个模块:文档解析与切分、向量化与检索、提示词组装与生成、反馈与评测。没有复杂的依赖,我用的是最朴素的组合:SentenceTransformer做 embedding,FAISS做向量检索,Transformers或 OpenAI 兼容接口做生成。这套组合在中小规模文档场景下,稳定性和可控性都很好。

4.2 Embedding、向量库与检索链路

我见过很多 RAG 项目死在“embedding 随便选”这件事上。如果你用的是通用中文 embedding 模型,而你的文档是法律条文或医疗记录,检索效果大概率不理想。选型的原则是:先在自己的语料上跑到一个可对比的评测集,再定模型,不要看榜单就下单。

切分策略是另一个关键点。用定长切分(比如 500 个字符)简单粗暴,但会把语义单元切碎;用结构切分(按标题、段落)更贴近文档原意,但实现成本更高。我的折中方案:

from langchain.text_splitter import RecursiveCharacterTextSplitter splitter = RecursiveCharacterTextSplitter( chunk_size=500, chunk_overlap=80, 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_size=8时吞吐能提升好几倍,延迟却不一定是 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_size=1还报显存不足,那多半是序列长度撑爆了。可以先用max_length=128跑通,再逐步调大。如果真要在有限显存里跑长文本,可以换成梯度累积,或者用 Flash Attention 这类算子级优化,但那个要单独花时间配环境。

6.3 工程化问题

上线前最容易忽略的是“输入校验”。用户可能传一个 10 万字的文本进来,也可能传一个空字符串。我在服务入口会做三层检查:

  1. 长度检查:超出限制的输入直接截断或返回提示。
  2. 空值检查:空 query 不需要走模型,直接返回默认提示。
  3. 内容类型检查:防止传非文本内容。

另外,建议给模型服务加一个统一的超时控制。生成式模型如果不设超时,最坏情况会挂起整个服务。用asyncio.wait_for把单次请求控制在 10 秒以内,如果超时就返回一个兜底回答,同时把这条样本记录下来用于分析。

写在最后

从零做 AI 工程,最深的体会是:真正的工程能力来自“亲手踩坑、再亲手填坑”。手写一遍 Transformer,你才能真正明白显存为什么被吃;搭建一次 RAG,你才知道检索结果差是因为 chunk 策略,还是 embedding 选型;上线一版推理服务,你才理解延迟、吞吐、稳定性不是 PPT 上的名词,而是真实存在的约束。

如果你刚开始走这条路,我的建议是不要贪多。先把“一个小模型 + 一条数据管线 + 一个推理服务”完整打通,再往里面加复杂度。做一个闭环,比开十个头重要得多。后续还可以在这个基础上扩展:给 RAG 加一层重排序,把 Embedding 换成领域微调版本,或者把推理服务改成异步批处理。方向很多,但地基就是这些,希望我这篇分享能帮你少走几步弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询