“ai-engineering-from-scratch”这个仓库名乍一看像那种收藏了不会再翻第二次的学习清单。但真正把它当成一个工程目标从头到尾走一遍“AI从零构建”你获得的远不止一个能跑的模型而是一整套对数据、训练、推理、产品化的底层直觉。这篇博文就围绕这样一个最小可行项目展开不靠现成API从零设计并训练一个百MB级别的小模型再把它包装成带Prompt工程和Agent能力的完整服务。适合刚入坑深度学习、想搞懂大模型内部到底在发生什么的人也适合已经会用框架但总觉得“少了点什么”的工程师。1. 从零开始前先想清楚这四件事1.1 核心不是“写代码”而是“定义问题”很多人第一次接触AI工程第一反应是去找模型、跑代码、调参数。但我带过不少项目也见过无数半途而废的仓库发现最大的拦路虎不是显卡不够而是问题没定义清楚。你说“我想做一个AI”这句话本身没有信息量。你是想要一个会聊天的机器人还是要一个能自动读论文摘要的助手是想要一个能分析Excel报表的工具还是想要一个能根据代码注释生成单元测试的插件这些需求落到技术方案上可能是预训练、微调、检索增强、Agent编排四种完全不同的路线。所以在写任何代码之前我会把需求拆成四问输入是什么格式纯文本、文件、图片、多轮对话输出是什么期望一段话、结构化JSON、还是需要调工具的动作序列质量怎么衡量答案准不准风格像不像延迟高不高约束条件是什么显卡显存、预算、时间、团队水平这四个问题一旦回答清楚项目的70%其实已经定了。剩下的才是调模型、部署、调优这些“看起来很难但实际上有成熟套路”的部分。1.2 为什么选“from scratch”而不是直接调API市面上已经有很成熟的模型API填入Key就能用为什么要费劲从零搭一个这是我在项目开始前反复问自己的问题。直接调API的效率确实高典型场景下几个小时就能做出一版可用原型。但从工程成长的角度看那种方式叫“消费AI”不叫“构建AI”。from scratch的价值主要体现在四个维度第一是原理可见。当你亲手完成了数据清洗、BPE分词、训练循环、推理服务这一整套链路模型在你眼里就不再是一个“神秘黑盒”而是一套由清晰部件组成的系统。以后遇到问题你至少知道该去Debug哪一层。第二是可定制性。用API时模型权重不在你手里你不能针对特定领域数据做真正的定制自己训练的模型权重、Tokenizer、采样逻辑全部可控。第三是性能可控。你可以为了低延迟裁剪模型尺寸为了高吞吐上量化为了离线部署去掉外部依赖。第四是成本曲线。在持续大规模调用的前提下自建模型的边际成本要比按量计费低得多。但我也得说实话from scratch不是用来做业务MVP的最优解。如果你只是想快速验证一个产品想法直接调API是对的。这个项目的意义在于“练兵”——用一套小模型把全链路打通之后无论你用什么API、什么框架都能站在工程视角去评估方案。1.3 技术选型的整体判断我最终选定的技术栈基本代表了目前从零开始做AI工程的主流配置Python 3.11 PyTorch 2.xHuggingFace Transformers / Datasets / TokenizersvLLM FastAPI 做推理服务Weights Biases 或 TensorBoard 做训练监控DVC 或简单的Shell脚本做数据版本管理为什么是PyTorch因为它的生态最完整不管是训练脚本、模型库、推理框架还是量化工具围绕PyTorch的工具链是最好的。为什么不直接从模型库拉一个GPT权重因为还是要完整走过数据处理和训练流程这部分手写才能真正建立手感。一个关键认识是from scratch不等于不用框架。你不用自己去实现反向传播、不用手写CUDA kernel、不用从零写Attention。你要做的是理解框架封装的每层概念并且有能力在需要的时候绕开框架提供的默认行为。比如训练脚本里那个for batch in dataloader你得知道背后是怎么组batch、怎么做padding的。1.4 最小可行目标的设定一个很容易犯的错误是目标定得太大。比如想训练一个能取代GPT-4的模型先不说数据量和算力光是训练稳定性就够折腾几个月的。所以我把这个项目的目标压到足够小但又能覆盖完整工程链路从零训练一个约100M参数的Decoder-only小模型让它能生成语法正确、语义连贯的短故事然后用LoRA在特定领域数据上微调最后把它部署成HTTP接口并封装一个能调用外部工具的Agent demo。为什么是100M左右因为一张消费级显卡就能训练迭代速度快方便反复试错。为什么是短故事因为故事类文本对连贯性要求高能明显暴露出模型是否学明白了而纯代码或数学类文本对模型能力要求太高不利于初版跑通。整个项目做下来大概三天到一周时间上完全可控。2. 数据AI工程的地基2.1 数据从哪来怎么洗我把数据比喻成AI工程的地基模型结构再先进训练代码写得再漂亮喂进去的是垃圾出来的必然是垃圾。大模型训练“数据质量比数据数量重要”这句话几乎每个项目都能验证。数据来源我建议从三个方向入手。第一是公开数据集比如HuggingFace上的OpenWebText、The Pile、TinyStories这类它们的清洗程度较高适合直接用来做初版。第二是你自己的业务数据比如工单记录、客服对话、代码仓库、产品文档这类数据数量不大但价值密度极高。第三是合成数据可以先用一个通用模型生成一批符合目标分布的文本再人工抽检修正这在小项目里尤其好用。但不管数据从哪来清洗这步省不掉。我的清洗流程通常包含这几个环节# 常规清洗流水线 1. 按行读入原始文本丢弃空行和过短片段 2. 用正则过滤HTML标签、URL、乱码字符 3. 做MinHash去重减少重复段落对训练的干扰 4. 按长度过滤句子少于10个字符或超过2000字符的酌情丢弃 5. 用简单的启发式规则过滤广告、无意义内容 6. 最终人工抽检100条确认质量后再入训练集这个过程我用过很多次实测下来最大的坑是“看起来干净但实际很脏”。比如爬下来的网页文本里充满了导航栏、版权声明这类重复内容如果不去重模型会学到“每隔几句话就输出一次版权信息”的坏习惯。所以我会专门花时间做字面去重和MinHash去重并且在所有清洗步骤之后随机抽一批样本人工看一遍。2.2 分词与Tokenizer为什么它常被忽略Tokenizer是很多AI工程新手最容易忽略的环节但它直接影响模型的训练效率和生成质量。简单说Tokenizer就是把文本切成一串token编号模型看到的实际上是这些编号而不是原始字符。目前主流方案是BPEByte Pair Encoding基本思路是这样的一开始每个字符都是一个独立的token统计相邻字符对的出现频率每次把出现频率最高的一对合并成一个新token不断重复这个过程直到token数量达到你设定的词汇表大小。比如“university”这个词可能会被切成uni、versity这样的子词这种切分方式既能覆盖常见词又能合理处理生僻词。实操层面有几点可以直接抄作业词汇表大小建议32K到128K之间。小项目用32K足够太大反而增加Embedding层的参数量和训练成本。必须自行保留特殊token的位置比如|endoftext|用来标记文本边界|pad|用来对齐batch内不同长度的样本。后续微调和推理时这套token映射要原封不动地搬过去。训练Tokenzier和训练模型必须用同一份文本数据分布否则推理时模型可能频繁遇到OOV或异常分词导致生成质量崩掉。我吃过一次亏训练阶段用了A数据集训练的Tokenizer推理阶段图省事直接用了开源的GPT-2 Tokenizer。结果生成文本里频繁出现奇怪的断裂排查了很久才发现是两边Tokenizer不一致。后来我把Tokenizer的vocab.json和merges.txt直接打进模型目录每次加载都校验哈希才彻底解决这个问题。2.3 数据配比与采样策略如果你的语料来源不止一种比如有通用文本、代码、还有业务数据那么怎么按比例混合就很关键。我常用的是多项式采样给每个数据集一个权重每次组batch的时候先按权重抽数据集再从该数据集中抽文本。这样能精确控制各数据源的占比。实际经验里通用文本和代码的比例我习惯控制在7:3左右。代码样本能显著提升模型对逻辑结构的理解能力即便你的最终任务和代码无关也能感受到生成逻辑的改善。业务数据的占比通常不高一般10%~20%就够因为业务数据量小、歧义多占比太高模型容易过拟合到样本上反而丢失泛化能力。还有一个我反复强调的细节验证集必须从训练集的分布中独立抽取并且保证不和训练集有交叠。如果不做去重就粗暴划分验证集里会出现和训练集高度重复的句子导致loss虽然漂亮但模型生成质量的真实水平被严重高估。等到一上线就露馅。3. 模型训练从随机权重到V1版本3.1 架构选择与参数配置模型架构我直接选了Decoder-only的GPT式结构这基本也是当前大模型领域的主流选择。它的核心由几部分组成Token Embedding、位置编码或RoPE、多层Transformer Block每层包括多头注意力、前馈网络、残差连接、归一化、最后的输出投影层。有人会问为什么不试试BERT那种Encoder-only如果任务主要是分类、检索这类理解型场景BERT确实高效。但我要构建的是一个能自主生成文本并接入Agent的系统生成能力是刚需Decoder-only是最省力的选择。下面给出我实际跑通的100M模型配置可以直接作为起步参考# 模型配置示例 model_config { vocab_size: 32768, n_layer: 8, n_head: 8, n_embd: 768, block_size: 256, dropout: 0.0, use_bias: False, rms_norm_eps: 1e-6, }这个配置算下来参数量在100M附近。为什么block_size只设256而不是2048因为小模型的建模能力有限太长的上下文反而会稀释注意力让模型学不到稳定的语法和语义模式。先把短文本做扎实后期如果需要长文本能力再通过位置编码外推或继续预训练来扩展这样路径更稳。3.2 最小GPT训练脚本关键就那么几行很多教学代码会把训练脚本写成几百行看着吓人但核心逻辑粗到只有四步取batch、算logits、算loss、反传更新。我通常会用一个极简的复现来跑通流程再替换成正式的数据加载器。下面这个训练循环是我反复用来做基础设施验证的版本。它拿一段连续文本作为输入目标是文本的下一个token也就是用前256个token预测下一个token不断地滑动窗口完成训练。import torch from torch.utils.data import Dataset class TextDataset(Dataset): def __init__(self, tokens, block_size): self.tokens tokens self.block_size block_size def __len__(self): return len(self.tokens) - self.block_size - 1 def __getitem__(self, idx): x torch.tensor(self.tokens[idx:idx self.block_size]) y torch.tensor(self.tokens[idx 1:idx 1 self.block_size]) return x, y # 训练核心循环 for step, (x, y) in enumerate(dataloader): logits model(x) # 前向输出(batch, block_size, vocab_size) loss F.cross_entropy( logits.view(-1, logits.size(-1)), y.view(-1) ) # 把每个位置都当成一个分类任务 optimizer.zero_grad() loss.backward() optimizer.step()这个代码里值得留意的点是logits.view(-1, vocab_size)它把batch和序列长度两个维度合并了相当于把每一个token位置都独立成一个分类样本然后再做交叉熵。这样设计的直觉是语言模型的任务就是让每个token的概率分布尽量贴近真实下一个token。优化器我建议直接用AdamW学习率用小规模实验里常见的3e-4左右。为什么AdamW比普通Adam更稳因为AdamW把权重衰减和梯度更新解耦了在不失偏置修正的前提下能有效抑制过拟合。这个区别在小模型上看着不明显但规模一大训练后期会明显省心。3.3 从预训练到LoRA微调降本增效的必走之路预训练之后模型已经学会了基本的语法和语义结构可以生成通顺的文本。但如果你要它做特定任务比如按特定风格写日报、抽取工单里的关键字段就会发现指令理解能力和输出格式都不对路。这个阶段就需要微调。全参数微调在我的消费级显卡上显存很不友好所以我直接上了LoRA。LoRA的原理说起来不复杂冻结原始权重不变在每一层Transformer的权重矩阵旁边插入两个低秩小矩阵训练时只更新这两个小矩阵。低秩意味着参数量大幅压缩同时因为冻结了原权重整体显存占用也显著下降。常用的LoRA配置参数如下rank8到16之间效果和成本比较均衡alpha建议取rank的2倍控制LoRA分支的影响强度学习率1e-4到3e-4太高会覆盖原权重学到的通用表示作用模块attention层的q_proj、v_proj必加k_proj和out_proj可选FFN层也可以插我实测下来rank8、alpha16、学习率2e-4这组配置在绝大多数文本生成任务上都够用。如果微调后模型出现严重的“灾难性遗忘”可以先把学习率降到1e-4再看是不是alpha太大压过了原始知识。3.4 评估不要只盯着loss看训练日志里的loss一路下行确实很爽但这只代表模型在训练集分布上的拟合程度不代表它“会用了”。我在项目里采用了三层评估结构第一层是困惑度Perplexity可以在验证集上算用来快速判断模型是否还在正常优化。第二层是任务级评测针对你的目标场景准备一批固定问题看模型能不能给出符合要求的输出。第三层是人工评估让模型生成一段长度适中的文本肉眼判断是否连贯、是否复读、是否符合风格要求。一个小技巧是准备几组固定模板的“探针问题”比如“讲一个关于狐狸和乌鸦的短故事”“用一句话介绍树懒”每隔几百步训练就生成一次看效果。这种定性观察比数值更直观能让你在loss没有明显异常时提前发现模型退化、重复、偏离指令这些信号。另一个重要原则是评测集要和训练集严格隔离并且评测集一旦定下来就不要频繁改动。否则你其实在做“对着答案找题”评估结果也就失去参考价值了。3.5 训练中的常见失败模式我把自己在训练过程中遇到最多的三类问题列出来方便你对照排查。Loss不降或下降极慢最先怀疑学习率。学习率太高loss会在初期冲高后震荡学习率太低loss会无聊地平躺。你可以先跑一个50步的小实验把学习率从1e-5到1e-3各试一档看看哪个区间能稳定下降。其次怀疑数据问题比如文本里混入了大量重复片段或者Tokenizer异常导致id分布大面积断层。过拟合也就是训练loss持续下降但验证loss反弹多半是数据规模太小或者模型容量超出需求。对策包括增加数据多样性、加大dropout、早停。还有一种隐蔽情况是验证集和训练集没有彻底去重导致“假过拟合”。输出重复死循环。模型一直输出同一段话通常和采样设置有关。复用默认的top_k50、top_p0.95、temperature0.8是个不错的起点。如果模型本身训练不充分也会更容易陷入重复循环这时候首先应该回头检查训练步数是否足够、block_size是否太短。4. 让模型变成“工程”推理、服务化与Agent4.1 从checkpoint到推理服务vLLM是吞吐利器训练完的checkpoint只是一个权重文件要真正让别人能调用还得把它部署成一个推理服务。我第一版直接用PyTorch自带的生成接口写了个FastAPI应用结果并发一上来GPU利用率上不去排队时间却暴涨。后来切到vLLM同样的模型吞吐量能提升好几倍。为什么vLLM能快这么多核心是它用了PagedAttention把KV Cache按页管理显存碎片化问题大幅缓解同时它内部做了continuous batching一个batch里的请求可以动态进出不用等整批结束再处理下一批。这些机制对短文本生成尤其友好。我用的是一个很常规的部署结构# 1. 把checkpoint转成HuggingFace格式 # 2. 用vLLM启动离线推理服务 vllm serve ./model_output \ --host 0.0.0.0 \ --port 8000 \ --tensor-parallel-size 1 \ --gpu-memory-utilization 0.8 # 3. 再用FastAPI包一层统一入口上线前还有一个步骤容易被忽略模型预热。也就是服务启动后先用几个固定请求跑一遍把显存里的CUDA kernel和缓存激活否则第一个真实请求的响应时间会非常难看。另外生产环境建议限制单请求最大生成token数并设置合理的超时时间避免个别异常请求把GPU拖死。4.2 给模型加“大脑”Prompt Engineering基础部署完模型很多人的第一反应是直接用然后发现模型的回答“又空又飘”。这里的问题通常不在模型而在于你没有把任务描述清楚。这就进入Prompt Engineering的范畴了。模型的本质仍然是一个next token预测器它生成什么高度依赖于你给它的上下文。你要在Prompt里把任务约束、输入格式、输出格式、示例都对齐。我最常用的一个模板结构是这样你是角色任务是描述。 约束条件 1. ... 2. ... 输入内容 {输入} 请按照如下格式输出 {JSON格式} 示例 {示例}看起来机械但有效。它本质上是在降低模型的输出空间不确定性把“开放式回答”收敛到“结构化完成”。Few-shot给几个示例和CoT让模型先写推理过程再给答案是两种最有效的增强手段。前者适合格式控制后者适合复杂推理任务。一个常见的误区是“Prompt越长越好”。并不是。Prompt过长会挤占模型的有效上下文窗口也可能引入干扰信息。我的经验是在能说清需求的前提下Prompt越短越好只保留必要的约束和示例。4.3 Agent工作流让模型学会调用工具模型能生成文本但它不能查数据库、不能调API、不能打开网页这时就需要Agent层面的编排。我实现了一个极简的ReAct流程模型交替进行“Thought思考”“Action调用工具”“Observation观察结果”最终输出Answer。用“论文搜索助手”来举例你问“最近有哪些关于LoRA的论文”模型发现自己不知道答案于是决定调用搜索工具。工具返回一列论文标题和摘要模型阅读后整理出回答。如果第一轮搜索结果不够它会再次发起搜索直到信息足够。极简实现的核心逻辑可以这样理解# 伪代码ReAct Agent def run_agent(prompt, tools, max_iterations5): messages [{role: user, content: prompt}] for _ in range(max_iterations): reply llm(messages) action parse_action(reply) # 从输出中提取工具名和参数 if action is None: return parse_answer(reply) result tools[action[name]](**action[args]) messages.append({role: assistant, content: reply}) messages.append({role: tool, content: result}) return 达到最大迭代次数这个流程对整个AI工程有很重要的启发模型不再需要“什么都知道”它只需要知道如何获取信息。在你的业务场景里可以把搜索工具换成查工单、查库存、查代码等接口Agent就变成一个灵活的业务机器人。Agent的关键挑战不是模型有多聪明而是你给它的工具边界是否清晰。我会在工具定义里写明参数类型、返回格式、超时时间并且要求模型在必要时说“我不知道”避免它为了凑答案而编造工具结果。4.4 可观测性与版本管理模型上线后最常听到的一句反馈是“它好像变蠢了。”排查下来70%的情况不是模型权重变了而是输入分布变了、Prompt被改了、或者业务方换了一批数据。要让这些排查变得高效必须在工程化初始阶段就埋好可观测性。我要求所有推理请求都记录一份结构化日志至少包含请求ID、输入内容、输出内容、模型版本、Prompt版本、耗时、token数。这样任何一次线上反馈我都能精确回溯到当时的输入输出快速判断是模型问题还是Prompt问题还是纯粹的用户预期问题。模型版本管理也非常重要。模型文件必须和Tokenizer配置、GenerationConfig打包成一个不可变的版本。线上服务永远只加载固定版本新版本必须先灰度、验证、再全量切换。这个习惯养成了AI项目才真正算得上“工程化”。5. 上线后我踩过的那些坑5.1 常见问题速查表下面这张表基本是我过去几个月反复看到的问题集结给出直接可查的解决方案问题现象常见原因解决方向训练时显存OOMbatch太大或block_size太长减小batch使用梯度累积必要时降模型尺寸Loss不降或震荡学习率不合适跑一个50步的学习率扫描找到稳定区间验证集loss明显低于训练loss验证集与训练集数据泄漏重建独立验证集保证严格去重生成结果老是复读temperature/top_p设置不当调高temperature到0.8~1.0降低top_kTokenizer加载报错训练和推理用了不同tokenizer固定tokenizer文件并验证哈希一致API响应极慢没有预热或并发配置不合理部署后先发固定请求预热再调并发某些用户请求频繁超时单请求生成token上限过大限制max_output_tokens对长文本做截断策略Agent调用工具结果错乱工具参数定义不清晰写好参数类型和返回格式限制工具数量5.2 在反复折腾中沉淀的经验这个项目让我重新理解了“工程”两个字的份量。模型训练只是整个链路的一环数据质量、部署稳定性、日志可追溯性任何一环掉链子最终呈现出来的都是一个“不靠谱的AI”。我个人的体会有三条供你参考第一先小后大是铁律。我见过一上来就想训7B模型的人卡在环境配置和数据清洗上整整两周还没见到loss。不如先用100M模型把全链路跑通再逐步扩大规模这样每轮迭代都建立在已验证的基础上。第二每次实验只改一个变量。AI实验中变量太多同时改学习率和数据配比一旦结果变差你根本不知道是谁的锅。第三保存模型权重时连同训练参数、数据版本、评估结果一起存下来。你永远猜不到哪个老版本会在两周后需要被召回。这个项目做完以后整个链路里每个环节的下一步都清晰可见模型可以考虑做RLHF对齐服务可以考虑上量化加速Agent可以扩展成多工具并行和记忆机制。但比这些技术升级更重要的是你从这条链路里建立起来的“定位直觉”——在一个AI系统出问题时你知道该往哪里看。有了这种直觉后续无论玩多大的模型、接多复杂的业务都不会手足无措。