尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

AI工程从零开始:手把手构建推理模型与部署实战

AI工程从零开始:手把手构建推理模型与部署实战 这些年我一直在做AI工程落地看到“从零开始”四个字感触特别深。很多人觉得现在框架这么成熟HuggingFace上一拉模型就能用何必从零造轮子但真正碰到要调推理逻辑、要控制模型行为、要部署到生产环境的时候就会发现光会“调包”远远不够。你手里的黑盒越黑出了问题就越无从下手。这篇东西就是围绕“ai-engineering-from-scratch”这个主题把我自己从零构建AI系统、甚至从零训练一个小型推理模型的全过程做一个拆解包括数据怎么准备、模型怎么搭、训练怎么跑、评估怎么设计、部署怎么优化以及一堆网上教程不会告诉你的坑。不管你是想真正理解LLM底层的工程细节还是打算在业务里落地一套自己的AI能力这篇都能给你一个相对完整的参考。1. 项目整体设计与思路拆解1.1 “从零开始”到底零到什么程度先说清楚一个误区。“从零开始”不代表让你重新发明Transformer也不代表让你把CUDA内核手写一遍。真正有价值的“从零”是你要绕过那些高度封装的API亲手搭建数据管线、模型结构、训练循环、评估体系。你可以用PyTorch可以用TensorFlow但不要一上来就from transformers import AutoModel。你自己写一个微型Transformer哪怕只有几百万参数跑通一次前向、反向、参数更新你对attention的维度变化、残差连接的位置、LayerNorm的放置就会有一个刻骨铭心的理解。这种理解是任何文档都替代不了的。从工程角度我更倾向于把“从零”定义为不依赖现成的预训练权重不依赖自动封装好的推理引擎自己控制从数据到服务的每一个环节。目标通常有两个方向。一个是构建一个能解决特定问题的模型比如领域问答、文本分类、代码生成另一个是构建一个具备基础推理能力的小模型类似build a reasoning model from scratch这种思路让模型不只是做模式匹配而是能一步步给出思考链条。这两个方向我都试过下面会分别讲。1.2 为什么选择“从零构建”而不是“微调现成模型”很多时候直接微调一个开源大模型确实更快比如拿Llama、Qwen做LoRA。但如果你追求的是低延迟、低资源占用、私有化部署或者你需要在模型内部加入特定的推理结构那么现成模型很可能并不合适。大模型动辄几十GB跑一个推理都要A100业务场景根本扛不住。这时候从零训练一个小参数模型反而是一条更务实的路。我做的这个项目核心目标就是训练一个参数量在1亿左右的自回归模型让它具备基础的数学推理和多步逻辑判断能力。选这个规模是因为单卡A100或者多卡4090就能训练推理时CPU也能跑部署成本极低。事实证明小模型只要数据构造得当在特定任务上的表现并不会让人失望反而比那种“一眼假”的通用大模型更可控。这里有个重要心得从零构建的前提是你已经清楚知道自己要什么。如果你只是想快速接一个对话机器人那直接调API即可。但如果你需要深入理解AI系统内部机制或者要针对特殊数据做高度定制那“从零”是绕不开的。而且这个项目里的很多方法论比如数据配比、训练策略、评估设计即使你以后回到微调大模型的路线也是完全通用的。2. 核心细节解析数据才是真正的“从零开始”2.1 数据从哪来公开语料、合成数据、自建标注很多人把“从零构建AI”理解成“从零写神经网络代码”这是本末倒置。真正的工程瓶颈是数据不是模型结构。训练一个推理模型数据构造直接决定模型能力上限。我这里的做法分三步第一基础语料用公开的代码和通用文本让模型先学会语言的基本规律。这部分相当于“常识底座”。我只筛选了大约5GB的高质量英文和中文语料去重、清洗、过滤掉低质量内容。清洗的细节很繁琐——移除重复段落、统一换行符、过滤超过一定长度的乱码、处理HTML标签残留但这些都是必做功课。第二针对推理能力我构造了大量“问题-思维链-答案”三元组。这里最花时间。与其手工写几千条不如写一个程序自动生成数学应用题。比如随机生成两个数的加减乘除、随机生成多步逻辑判断题然后把解题步骤按“逐步思考”的方式展开成文本。记住不要直接给答案一定要把中间步骤写清楚比如“已知x…第一步计算…得到…”这样模型才能真正学会推理的模式。第三自建一小部分高质量标注数据。我找了几个朋友一起标注了大概2000条逻辑推理题涵盖悖论分析、条件判断、计划生成等。这些数据的作用是“校准”让模型学会人类表达中的微妙之处。合成数据适合量产人工数据适合点睛两者结合效果最好。2.2 Tokenizer训练与数据配比的艺术不要直接用现成的Tokenizer。虽然直接用GPT-2的tokenizer也能跑但那对中文推理题非常不友好。我用了BPE算法在自己准备的5GB语料上训练了一个词表大小为32000的tokenizer。训练时特别注意了数字和运算符的处理——把小数点、负号、括号都当作独立token这样模型在处理数学表达式时不会把“3.14”拆得乱七八糟。你可以在HuggingFace的tokenizers库中通过train_from_iterator接口实现整个训练过程只需十来分钟但效果差异显著。数据配比是我自己摸索出来的经验基础语料60%、合成推理数据30%、人工标注数据10%。这个比例不是拍脑袋。因为如果推理数据占比太低模型很容易被基础语料淹没学不会推理如果太高模型会变得过拟合只会做训练集里的题型泛化能力差。我实验过70%推理数据结果在保留的测试集上BLEU分数很高但一旦换一种出题方式准确率直接掉了15个百分点。教训是宁可让模型见多识广别让它死记硬背。3. 实操过程从零搭建一个微型Transformer3.1 模型结构与关键参数选择我的模型是一个标准的Decoder-only Transformer参数量约1.1亿。具体配置如下层数12隐藏维度768注意力头数12前馈层维度3072序列长度1024。这些数字大家应该很熟悉——其实就是GPT-2 small的配置。选这个结构的最大好处是参考实现多遇到问题便于排查。模型代码我不会全部贴出来但有几个核心模块必须交代清楚Token Embedding 位置编码这里我用了可学习的绝对位置编码没有用RoPE。因为对于一个1亿参数的小模型绝对位置编码足够用而且实现最简单。如果你想用相对位置编码注意处理好cache里面的position_ids很容易出错。Attention Mask因果maskcausal mask必须用torch.triu生成上三角矩阵确保每个位置只能看到之前的信息。我一开始忘了把mask转成布尔类型结果训练速度慢了一倍。LayerNorm位置标准解码器用的是Pre-LN结构也就是先做归一化再进注意力层。这与原始Transformer的Post-LN不同训练更稳定收敛更快。我的建议是直接上Pre-LN别纠结。这里要特别强调“为什么”的问题。为什么用Pre-LN因为深层Transformer在小规模数据上使用Post-LN很容易出现梯度爆炸。Pre-LN等于在每个残差分支之前把激活值拉到标准范围让梯度在残差路径里更顺畅。我做过对比实验同样的数据和超参数Post-LN版本在第20个epoch出现loss NaNPre-LN稳定跑完。这就是从零带来的领悟。3.2 训练循环与损失函数设计训练循环别看简单里面细节极多。我的loss用的是交叉熵但注意把ignore_index设成pad_token_id否则padding位置的loss会被计算进去白白拉低指标。优化器我选了AdamW初始学习率3e-4配合500步warmup和余弦衰减。这里有一个关键技巧——梯度裁剪把全局梯度范数裁剪到1.0这一步必须做不然小模型在长序列场景下很容易局部loss暴增。另外要小心“损失下降但指标不变”的假象。训练过程中我每500步打印一次token级准确率而不是只看loss。比如数学题输出“235”即使loss降了如果模型输出“234”准确率照样为0说明它只是学会了语言形式没学会计算。真正靠谱的监控指标是“逐步推理正确率”我把它定义为在给定的思维链数据中模型能连续生成每一步直到最终答案正确。这个指标比单一的交叉熵能更早暴露推理能力不足。4. 训练工程化多卡分布式、日志与检查点4.1 分布式训练的正确姿势单卡训练1亿参数模型其实也行但一批数据要好几秒太折磨人。我上了4张4090用PyTorch自带的DistributedDataParallelDDP做数据并行。这里有几个容易踩坑的地方第一torch.distributed.init_process_group必须在任何张量操作之前调用。第二如果用了torch.multiprocessing.spawn启动多个进程记得在主进程里设置set_start_method(spawn)否则可能在Windows或某些Linux环境上报错。第三数据加载器要设置shuffleTrue时用分布式采样器DistributedSampler并在每个epoch开头调set_epoch保证每个epoch的数据分片不同。我把batch size设为32 * 4卡 128序列长度1024那么每个step的token数就是13万左右。这是理论计算方式batch_size * seq_len * num_gpus。这批大小对于1亿参数模型来说是合适的学习率也可以相应调大一点。如果你的是单卡且显存不够就用梯度累积——每8个step累加一次梯度再更新效果等价于batch size扩大8倍但要注意LayerNorm和batch normalization在累积时会受影响所以更推荐直接用梯度累积加较大的micro_batch_size。4.2 检查点、日志与中断恢复从零训练最怕什么最怕训练到第三天突然断电然后从头再来。所以检查点机制必须从头设计好。我每500步保存一次model_state_dict、optimizer_state_dict、scheduler_state_dict以及epoch和global_step。保存的时候不要覆盖上一次的checkpoint至少要留最近5个。你可能会问为什么因为有时某个checkpoint正好是loss异常波动前的状态回滚就靠它。日志方面我用wandb记录每个step的loss、学习率、梯度范数、GPU利用率、数据加载耗时。这里有一个容易被忽略的点数据加载耗时往往占大头。我一开始把数据放在机械硬盘上每个batch加载要2秒多而前向加反向才0.5秒训练效率惨不忍睹。后来我把数据文件全部挪到SSD并把每个样本打包成二进制文件用mmap方式随机读取加载时间直接降到0.1秒。这就是从业者经验和书本知识之间的差别。另外建议在训练脚本里加入“心跳打印”if global_step % 100 0: print(fstep {global_step} | loss {loss:.4f} | acc {acc:.4f} | lr {scheduler.get_last_lr()[0]:.2e} | mem {torch.cuda.max_memory_allocated()/1024**3:.2f}GB, flushTrue)加flushTrue是因为重定向到日志文件时Python的输出缓冲会导致你没法实时看到进度——这个坑我踩过不骗你。5. 关键环节推理模式的构建与评估设计5.1 “从零构建推理模型”的核心思维链与自洽性项目标题里有一层含义是“从零构建推理模型”。这跟单纯训练一个文本生成模型完全是两码事。推理模型需要让模型在输出最终答案之前先输出一段“思考过程”。在实践中我把训练数据中的思维链作为文本序列的一部分用分隔符|begin_of_thought|和|end_of_thought|把推理过程和最终答案隔开。这样做的目的是让模型学习到“先推理再回答”的顺序模式。这里有一个你可能没意识到的问题训练时思考过程容易过拟合测试时模型会把不该输出的思考也说出去。所以推理阶段我增加了“自洽性检查”——让模型生成多个候选答案比如5条路径然后看哪条路径的最终答案一致。如果3条路径都得到“42”那这个答案可信度就很高如果5条路径答案都不一致我会触发“重试”机制把第一次生成的输出作为上下文的一部分再让模型重新推理一遍通常能提升准确率。这其实就是一种简易的self-consistency效果立竿见影。5.2 评估集构造别让你的测试集自欺欺人评估是AI工程里最容易被糊弄的环节。你不能只用训练时见过的同构数据来测那是自欺欺人。我的做法是构建一套“三维评估集”维度A同分布任务训练集里见过的题型变体比如两位数加减法扩展到三位数验证基本学习能力。维度B跨任务迁移用另一种格式的逻辑题比如把数学题改成文字题“买三斤苹果每斤五元付二十元应找几元”检验模型是否真的理解了“数量关系”。维度C对抗样本故意在问题中插入无关条件比如“小明有3个苹果地球是圆的他又买了5个一共几个”模型必须学会忽略干扰信息。我在这里发现了一个特别有趣的现象模型在维度A上可以做到92%的准确率但在维度C上直接掉到61%。说明它更多是在记忆模式而不是在“理解”问题。后来我在训练集里加入了大约15%的干扰项样本维度C的准确率才提升到84%。这个经验也分享给大家别把“训练集刷分”当成“模型能力强”。从工程角度来看评估脚本要做成自动化每次训练完自动运行并汇报。我在评估脚本里用vllm作为推理后端支持批量推理和并发评估速度比原生的PyTorch快5倍以上。这一点对迭代模型版本特别重要因为你需要频繁跑测试来决定要不要改数据、调超参数。6. 常见问题与排查技巧实录6.1 训练不收敛loss下降但输出全是重复这个问题我遇到过多次。典型现象是loss从6降到4左右就不动了生成文本变成无限循环的同一个词。排查思路分两步先看数据和标签——用调试模式打印一个batch的input和target确认target确实没有错位再看模型结构——如果用了attention mask但没有把padding位置attend到模型的输出就容易被padding token带偏。我当时的问题出在词表太大、数据太少导致embedding参数学不充分。解决方案是减少词表大小到24000并增加数据量。如果数据没法增加试试对embedding层做权重共享tying也就是让输入embedding和输出投影层共享权重。这样不仅大幅减少参数量还能提升训练效果。我强烈建议在1亿参数这个规模直接用tied embedding别问为什么先试再说。6.2 推理阶段速度太慢CPU上跑不动的优化手段如果你的模型要部署到无GPU环境推理速度就成了硬指标。我用的是2.5GHz的CPU模型1亿参数生成200个token需要4.5秒这显然不够用。优化顺序如下模型量化用torch.quantization把线性层转为int8内存占用降低三成速度提升约20%。权重合并把LayerNorm和Linear的运算融合进一个自定义算子用torch.compile编译速度提升40%。使用量化感知训练QAT在训练的最后阶段加入伪量化节点让模型参数适应量化误差。这是终极方案收益最高但实现成本也最高。最后还可以考虑剪枝移除attention头中得分较低的那些。不过1亿参数的模型剪枝空间有限不如量化来得实在。部署到生产环境我用的是ONNX Runtime加动态轴关键是要把max_sequence_length设成动态否则固定序列长度会浪费算力。这一步做完CPU推理从4.5秒降到了1.8秒已经基本满足我的业务要求。如果你只有单核限制那建议直接从模型规模入手——把层数从12降到8推理速度几乎能翻倍而推理能力下降并不大。6.3 最容易被忽略的坑随机种子与复现性从零开始做工程时你会反复改动数据、模型和训练逻辑如果没有统一随机种子你根本分不清性能提升到底来自哪个改动。我的做法是设置一套“固定全局种子”的机制def set_seed(seed42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed)但即便如此DDP多卡训练时因为异步操作和不确定性的CUDA kernel多次运行的结果也不完全一致。为了保证可复现性我在训练脚本中额外设置了torch.backends.cudnn.deterministic True和torch.backends.cudnn.benchmark False。前者让卷积/矩阵运算走向确定性算法后者禁用自动调优。代价是速度降低10%左右但换来完全可复现的训练轨迹这对工程调试特别重要。我的建议是如果你在调参阶段开启deterministic正式大规模训练时可以关掉它来换取速度。7. 工具选型与版本管理以及未来的扩展方向7.1 工程化的代码组织做AI工程不能跟写实验代码一样全堆在一个notebook里。我的项目结构是这样的ai-engineering-from-scratch/ ├── configs/ # 所有超参数的yaml配置文件 ├── data/ # 数据下载、清洗、合成脚本 ├── models/ # 模型结构定义 ├── train/ # 训练循环、分布式启动脚本 ├── eval/ # 评估脚本、测试集构造 ├── deploy/ # 推理服务、量化脚本、ONNX转换 └── utils/ # 工具函数以yaml管理配置是必须的。你不能在代码里写死学习率、batch size。我用的配置示例model: dim: 768 layers: 12 heads: 12 data: vocab_size: 32000 seq_len: 1024 data_path: /data/pretrain.bin train: batch_size: 32 grad_accum: 4 lr: 3e-4 warmup_steps: 500改配置不用动代码实验记录也更干净。再加上git对每个实验打标签这样任何一次训练都能追溯到对应的代码、数据和配置。我从零开始强调的“从零”也包括“从零建立工程纪律”。7.2 这个项目后续还能怎么扩展做完这一轮我已经拿到了一个能完成简单数学推理和自我纠错的自研小模型。但说实话这只是第一步。接下来有几个方向值得继续做把数据规模扩大10倍训练一个3亿参数的模型推理能力会有质的飞跃。引入强化学习用“答案校验”作为奖励信号让模型在推理路径上做更长时间的探索这和我们搜索到的build a reasoning model from scratch的思路一脉相承。在部署端接上工具调用能力比如模型生成公式然后调用Python计算器再回传结果。这其实是另一种“从零构建AI系统”的方式不纯粹靠模型记忆。我个人在实际操作中的体会是从零构建AI最有价值的产出往往不是那个模型本身而是你被迫建立起来的数据嗅觉、工程直觉和调试能力。比如你亲手拼过一个数据管线后面再用什么框架都会下意识地想“这个数据的偏置在哪里”你亲手调过一次不收敛后面看到别人的loss曲线就能猜到七八分问题在哪。这种手感靠调包调参是练不出来的。如果你也在考虑走这条路建议不要一上来就挑战千亿参数大模型先拿一个小目标练手完整地走一遍“数据构造—结构实现—训练调优—评估迭代—部署上线”的闭环。走通一次你对AI工程的认知会完全不同。
返回列表