很多人都问过我一个问题:AI engineering from scratch,到底是什么套路?是像读《Build a Large Language Model from Scratch》那样,把每一行代码都手敲一遍,还是说只要会用几个现成框架搭一条流水线就算入门?
我的答案可能和你想的不太一样。真正值得“从零开始”的,不是重新发明矩阵乘法,而是亲手把一个模型从数据集处理开始,经过架构设计、训练、评估、部署,完整地走通一遍。这个过程帮你建立的不是“能跑通”的幻觉,而是“出问题时知道去哪里排查”的掌控感。
这篇文章就是我个人的实战记录。我会用一条主线把从零到一的 AI 工程路径串起来:先讲我为什么决定手写一个迷你模型,再带你搭一个最小可行的 Transformer 语言模型,然后聊聊真正项目里最花时间、也最容易被忽略的“模型驾驭工程”(harness engineering),接着延伸到提示工程和 AI Agent 的工作流,最后给出一份可以直接抄作业的八周学习路线。无论你是刚入门的技术爱好者,还是已经调了一段时间 API 但觉得心里没底的开发者,这篇文章都值得你花二十分钟读完。
1. 从零开始不是重复造轮子,而是建立掌控感
1.1 我为什么决定从零手写一个迷你模型
两年前我第一次接触大语言模型时,和大部分人一样,直接调 Hugging Face 的接口,跑通了文本生成、对话问答,当时觉得自己已经“会了”。直到有一次线上服务里模型输出突然开始重复同一个词,我盯着日志完全懵了——不知道是数据问题、训练参数问题,还是模型结构本身的问题。因为对我来说,模型就是一个黑盒,我只能换个更大的模型试试,或者干脆重启。
那次之后我决定,必须自己动手把一个小模型从头到尾实现一遍。当时正好看到社区里越来越多人在讨论build a reasoning model from scratch,还有人专门复现《Build a Large Language Model from Scratch》这本书的代码。我想,与其看别人造轮子,不如自己造一个小的。
于是我给自己定了一个目标:不借助任何现成的 Transformer 库,只用 PyTorch 的张量运算,写一个约 10M 参数的小语言模型,用莎士比亚的文本从头训练,最后让模型能生成有点模样的英文句子。这个目标听起来不大,但它恰好把数据、模型、训练、推理这条主链路全部覆盖了。
1.2 从零构建的边界:哪些要自己写,哪些可以用库
这里要先说清楚“从零”的边界。我见过有些人一刀切,觉得连 PyTorch 都不能用,必须从用 C 语言写张量库开始。我不赞成这种极端做法,因为“从零”的核心目的是理解关键机制,而不是重复实现底层数学库。
我给自己画了一条线:可以用的包括 PyTorch 的张量计算、自动求导、Adam 优化器;必须自己写的是数据分词器、token 嵌入、多头注意力、前馈网络、LayerNorm、残差连接、训练循环和采样函数。也就是说,直击 Transformer 和语言模型训练的本质,但不在矩阵乘法的底层上浪费时间。
这样做的原因很简单:项目里的绝大多数问题都出在架构设计、数据质量和训练配置上,而不是出在基本算子上。你需要能够把一个 bug 定位到是注意力掩码算错了,还是学习率太大导致 loss 炸了,而不是去关心内存对齐之类的问题。这条边界能让你在有限的时间里获得最大的掌控力。
1.3 从零开始的收益与代价
收益是实实在在的。手写过一个模型之后,再看那些大模型的论文,很多地方都变得好懂了。比如当我看到“RoPE 位置编码”这个概念时,因为我之前用 OpenAI 的接口永远看不到位置编码的代码,但手写模型时我做过最简单的位置编码表,所以我能迅速理解它是在解决相对位置的问题,而不是又一个黑魔法。
代价也必须要说:时间成本真的很高。我那时用一周的业余时间才把模型跑通,中间踩了无数坑,包括损失不下降、生成结果全是重复的“[UNK]”符号、显存不足导致崩溃。如果我只是想快速做一个产品原型,这种从零开始的成本完全不划算。所以我给所有人的建议是:在时间和精力允许的情况下,从零走一遍这条路线,但不要在工作项目里从零写模型。学习归学习,生产归生产。
2. 搭建最小可行模型:数据、架构、训练一条线
2.1 数据集:从莎士比亚到指令对
我选择的数据集是著名的小型文本集TinyShakespeare,大约 1MB,包含了莎士比亚戏剧中常用的字符集。用它的好处是:数据量小,训练快,不需要联网下载大文件,而且字符集比较稳定,适合初学者观察模型是否真的学到了语言模式。
处理流程是这样的:
- 读取原始文本,统计所有出现的字符,构建一个
char → id的映射表。 - 把整段文本转换为一个长整型数组。
- 设定
block_size = 128(即每个训练样本的上下文长度),每次从数组中随机切一段长度为 128 的序列作为输入,再把序列右移一位作为目标输出。 - 创建一个批量大小为 64 的 DataLoader,自动打乱并分批。
这里有一个容易踩的坑:如果你直接拿整段文本去切块,而没有做随机偏移采样,那么每个 batch 里的样本会高度相关,训练会非常不稳定。我当时就是因为贪图简单,每个 epoch 都用同样的切法,结果损失下降得很诡异。后来改成每次随机取起始位置,训练效果立刻正常了。
2.2 模型架构:一个极简 Transformer 的核心模块
我实现的 MiniTransformer 只有四个核心组件:TokenEmbedding、PositionalEncoding、一个单层的DecoderBlock(内含多头注意力与前馈网络)、以及输出层Linear。
这里给出最核心的注意力计算代码(我用的是单头,便于调试):
import torch.nn as nn class SelfAttention(nn.Module): def __init__(self, embed_dim, head_size): super().__init__() self.key = nn.Linear(embed_dim, head_size, bias=False) self.query = nn.Linear(embed_dim, head_size, bias=False) self.value = nn.Linear(embed_dim, head_size, bias=False) self.register_buffer('tril', torch.tril(torch.ones(block_size, block_size))) def forward(self, x): B, T, C = x.shape k = self.key(x) # (B,T,head_size) q = self.query(x) # (B,T,head_size) v = self.value(x) # (B,T,head_size) wei = q @ k.transpose(-2, -1) * C ** -0.5 wei = wei.masked_fill(self.tril[:T, :T] == 0, float('-inf')) wei = torch.softmax(wei, dim=-1) out = wei @ v return out很多人不理解为什么要有masked_fill这一步。因为在语言模型里,我们不能让模型在预测第 t 个词时看到第 t+1 个词的信息,否则就变成了“作弊”。这个下三角矩阵就是未来的遮罩,确保注意力只被允许看到当前位置和之前的位置。
前馈网络我用了一个简单两层 MLP:先把嵌入维度从embed_dim映射到4 * embed_dim,再用 GELU 激活,最后映射回原维度。真正训练的时候,这个 MLP 和注意力层之间要用 LayerNorm 和残差连接包起来,训练才能稳定。
2.3 训练循环:损失函数、优化器、学习率调度
训练一个语言模型,本质上就是最大化训练集上每个 token 出现的对数概率。我使用的是交叉熵损失,优化器选择了 AdamW,并且加了 warmup + cosine 的学习率调度。
简单解释一下为什么必须加 warmup:模型刚初始化时,梯度方向非常不稳定,如果用很大步长去更新参数,很容易把损失推到爆炸区。先让学习率从一个很小的值线性增长到预设最大值,模型就能在前期保持稳定;后面再用余弦退火逐步降到一个很小的值,类似于“先大步探索,再小步精调”。
训练循环的核心代码看起来就是这样:
optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3) for step in range(max_steps): x, y = get_batch('train') logits = model(x) loss = F.cross_entropy(logits.view(-1, vocab_size), y.view(-1)) optimizer.zero_grad() loss.backward() norm = torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step()那个clip_grad_norm_是我加的第二道保险。它会把梯度矩阵的总体范数限制在 1.0,防止出现梯度爆炸。我一开始没加这一行,训练到第 2000 步时 loss 突然从 1.8 跳到 500 多,就是梯度爆炸了。
2.4 验证与生成:如何确认模型真的学会了
训练结束后,我干的第一件事不是看 loss 曲线,而是让模型自己生成一段话。生成的方法很简单:输入一个种子文本,逐 token 预测,把预测出的 token 追加到输入后面,再继续预测下一个,这就是自回归生成。
但这里有个新手特别容易搞错的点:生成时不能直接用logits.argmax()取概率最大的 token,否则模型会陷入重复循环。更好的做法是引入温度参数和 top-k 采样。温度大于 1 时概率分布更平滑,输出更多样;温度小于 1 时更保守。我当时设置温度为 0.8,top-k 取 50,生成出来的效果才接近像样的英文句子。
我还在训练过程中定期算一遍验证集上的困惑度(perplexity)。困惑度越低,说明模型对验证集的预测能力越好。我还打印出几组注意力权重,发现模型确实学会了一种基于近邻位置的注意力模式,而不仅仅是把前面的 token 平均一下。那一刻的成就感,远比我后来用几千亿参数的大模型调 API 来得强烈。
3. 工程化不只是训练:harness engineering 在真实项目中的分量
3.1 什么是 harness engineering,为什么它比训练更耗时
模型训练出来之后,事情远没有结束。在真实项目里,真正消耗团队精力最多的是围绕模型建立一整套“驾驭系统”,也就是英文里常说的harness engineering。你可以把它理解为:给模型戴上缰绳,设计测试、评估、监控和兜底机制,让它在生产环境中稳定输出。
我在好几个项目里统计过时间分配:真正做模型训练和微调的,大概只占总时长的 25%;剩下的时间都在写评测集、调提示词、处理失败样本、做回归测试、设计降级方案。你训练出一个“聪明”模型只是起点,让它在各种奇葩输入面前都不“翻车”才是工程。
为什么这步这么重要?因为大模型本质上是概率系统,同样的提示词换几个词,输出可能就完全不一样。如果没有一套标准化的评估体系,你根本没法判断一次迭代到底是变好了还是变坏了。我见过有团队把某个样例调好了,结果其他案例全挂了,就是因为缺少一个能同时跑几百条用例的回归测试框架。
3.2 用 CodeBuddy 搭建一套模型评估与测试框架的案例
最近我尝试用 CodeBuddy 这个 AI 辅助编程工具来加速评估框架搭建。当时的需求是:给一个多轮对话模型做回归测试,覆盖回答准确性、相关性、拒绝回答(不该答的时候要明确拒绝)安全性这四类指标。
我以前的做法是手写一堆 if-else 去模板化生成测试用例,效率很低。这次我换了个方式:先让 CodeBuddy 根据我给定的种子意图自动生成一批难度递增的测试问题。比如种子意图是“查询天气”,它会生成“今天北京天气怎么样”“明天上海会下雨吗”“过去一年的气温统计”等几十个变体。我再把这些用例按类别组织成 JSON 文件,作为评估集的输入。
CodeBuddy 的提示指令模板我写得很简单,核心就是这样一段话:
你是一个测试用例生成器。给定一个用户意图,生成 20 个测试问题,必须覆盖: 1. 简单直接表达,2. 带多余信息,3. 带否定词,4. 跨话题混合,5. 恶意诱导。 输出为 JSON 数组。然后用 Python 写了一个轻量评估循环,对每个测试问题调用模型接口,再让 CodeBuddy 帮我把模型回答与预期行为做比对,输出一个四档分类结果。这个流程帮我节省了大量体力活,评估覆盖度从之前的手工 30 条提升到了 300 多条。
这里有个关键心得:评估框架的指标不仅要看“正确率”,还要看“错误分布”。我发现模型在面对带否定词的问句时,错误率特别高。如果不做这种分类统计,我永远只会得到“整体还行”这样的模糊结论,而不会知道具体薄弱点在哪里。
3.3 让模型在可控范围内输出:提示工程 + 结构化输出
评估框架架好之后,下一步就是约束模型输出。真实业务中我们通常希望模型返回结构化格式,比如 JSON 对象,方便下游程序解析。但模型经常输出多余的解释文字,甚至私自改变字段名。
我常用的方案有两层。第一层是提示工程,在系统提示里明确要求输出 JSON,并且给出一个 few-shot 示例,示例里写清字段名和类型。第二层是后处理兜底,用正则或一个小的修复函数从模型输出中提取 JSON 片段,再用json.loads解析,解析失败就报错重试。
后来我发现一个更稳的做法,是让模型输出一个受约束的格式,比如直接在提示里给定模板,让模型“填空”。这比让模型自由发挥然后靠解析器去猜要可靠得多。你甚至可以结合工具,比如用 CodeBuddy 帮你自动生成一个 Pydantic 数据模型,模型输出直接丢进 Pydantic 校验,校验失败就再次请求模型修复。
这些工作看着不起眼,但生产环境的稳定性往往就靠这一层层“护栏”。没有护栏,模型一上线就会因为一个边缘 case 把下游系统搞崩。
4. 提示工程与AI智能体:从手写模型到真实产品
4.1 提示工程的核心原则:上下文、格式、示例
当你开始把模型接入真实产品时,提示工程几乎成了日常操作。很多人以为提示工程就是“把问题说得清楚一点”,但实际远不止如此。我总结下来有三条核心原则,几乎可以应用到所有场景。
第一条,明确角色与上下文。如果不告诉模型“你是一个客服助手”,它默认会用百科全书的语气回复你。给一段精确定义角色的系统提示,比你在问题里写十句“请用礼貌语气”有效得多。
第二条,用 few-shot 示例规范格式。不要只告诉模型“输出 JSON”,给它两个具体例子,一好一坏,模型就能迅速学会你期望的格式。示例的质量直接决定输出的质量。
第三条,拆分步骤,降低复杂度。把复杂问题拆成多个互相独立的子问题,比一次性让模型“一步到位”更可靠。这样不仅错误率低,还能分别定位哪个子问题的输出有问题。
我在多个模型上做过实验:同一个问题,有上下文定义、有示例、有步骤的提示,比简单直接的提示成功率高出 30% 以上。这不是玄学,是因为模型本质上是模式匹配器,你给出的模式越清晰,它匹配到正确路径的概率越高。
4.2 一个完整的AI Agent 工作流:从规划到执行
提示工程再往上走一步,就是 AI Agent。所谓 Agent,核心是一个循环:模型接收任务,判断需要调用哪些工具,执行工具调用,观察结果,再决定下一步。我写过一个非常简单的 ReAct 风格的 Agent,骨架代码现在还能派上用场。
class SimpleAgent: def __init__(self, llm, tools): self.llm = llm self.tools = tools def run(self, task, max_steps=5): messages = [{"role": "system", "content": "你是助手,可以调用工具。每次回复先给计划,再执行。"}] messages.append({"role": "user", "content": task}) for _ in range(max_steps): resp = self.llm(messages) action = parse_action(resp) # 例如: {"tool": "search", "input": "xxx"} if action is None: return resp result = self.tools[action["tool"]](action["input"]) messages.extend([resp, role_result(result)]) return "达到最大步数"这个 Agent 能跑通,关键在于每次调用工具后,要把工具返回的结果拼进messages,作为下一次模型推理的上下文。这样才能形成“观察-思考-行动-再观察”的闭环。
真实产品里,Agent 的难点往往不是逻辑循环,而是模型在循环里多次犯错。比如它可能在调用工具时生成了不存在的工具名,或者在应该停止时继续回复。我通常会在每一轮加上校验和重试机制,而不是把大模型的输出当作可信的指令直接执行。
4.3 避坑经验:模型幻觉、上下文长度、成本控制
必须坦白说,Agent 的幻觉问题比单轮问答严重得多。因为 Agent 在循环中积累了多轮工具输出,模型很可能会“编造”一个不存在的工具结果来迎合用户的假设。我的应对方法是:在系统提示里反复强调“工具返回值是唯一事实来源,不要自己补充”,并且在工具调用环节强制校验工具名和参数格式,任何校验失败都直接中止,不让模型继续编。
上下文长度是另一个高频坑。Agent 每轮都会把历史消息拼进去,几轮之后 token 数就会爆炸。我的做法是做一个简单的上下文裁剪:只保留最近的 N 轮,并且把工具调用的结果压缩成摘要。对于长文档,用检索而不是全量塞入上下文。
最后是成本。很多人低估了 Agent 的 token 消耗,一个只有五步的 Agent 可能消耗 5000-8000 token。我建议上线前先用一个粗粒度 token 统计器,估算单次任务成本,再根据预算倒推最大步数和模型选择。我吃过一次亏,一个功能上线后 API 账单比预估贵了四倍,就是因为循环内每一步都加入了大量历史消息。
5. 从零到一的学习路径与资源推荐
5.1 按周规划的实战路线
如果说这篇文章有价值,那么下面这部分我不想让你看完就忘。我根据自己和身边朋友的经验,整理了一份八周从零入门 AI 工程的路线,每个阶段都有明确产出。
第 1 周:Python 基本功 + 线性代数复习。不需要学得多深,能写清楚循环和类,能理解矩阵乘法和点积的含义即可。产出:用 Python 手写一个二维矩阵乘法函数。
第 2 周:手写一个两层神经网络,只允许用 NumPy,不允许用深度学习框架。用交叉熵损失和反向传播在 MNIST 上分类。产出:模型在验证集上达到 90% 以上准确率。
第 3-4 周:阅读《Build a Large Language Model from Scratch》前七章,同时复现代码。这本书从一开始的注意力机制讲到 GPT 结构,非常适合作为从零构建语言模型的导引。产出:在个人电脑上训练一个能生成连贯句子的 GPT 风格小模型。
第 5-6 周:自己选一个感兴趣的数据集(中文小说、代码、专利文本都可以),完成数据清洗、分词、训练一个 30M 参数级别的语言模型,并评估模型困惑度。产出:一个可以在命令行交互的简单对话模型雏形。
第 7 周:给模型做指令微调。准备几百条高质量的指令问答对,用标准监督微调流程把通用语言模型变成能回答具体问题的助手。产出:一个能稳定回复固定指令集的模型。
第 8 周:搭一个最小评估框架,为你的模型生成 50 条测试用例,统计正确率,然后写一个两页纸的总结报告,包含你遇到的三个问题及解决方案。产出:你已经不是一个只会调 API 的人,而是一个具备工程思维的人。
5.2 必须关注的开源项目与工具
除了上面这本书,我还强烈建议你把一些核心工具加入日常武器库。Hugging Face 的transformers和datasets是目前最主流的生态,不要只是 pip install 然后调用,而是遇到问题就去读它的源码,看模型前向传播每一步在做什么。
tokenizers库也值得研究,你想真正理解 BPE 分词做了什么,光看文档是不够的,必须自己跑几个例子观察切分结果。PyTorch 不必多说了,写作本文时它已经是很多 AI 工程的基础。实验日志我用 W&B,虽然你可以换任何一款,但关键是养成记录每个超参数及其对应结果的习惯,这比记忆力可靠得多。
Agent 开发方面,LangChain 或 LLaMAIndex 可以看看,但我不建议在刚入门时沉迷,因为这些框架往往帮你隐藏了底层的消息处理逻辑。你最好先学会手写循环,再去考虑要不要用框架。
5.3 我的几点体会
走到这里,你已经从理论看到实践,从手写模型看到生产化的评估和 Agent。我把这几年里最重要的几点体会放在最后。
首先,不要被“大模型”三个字吓住。把一个 10M 参数的模型完整地训练、评估、部署,和训练一个大模型的本质流程几乎完全一样。你从小的入手,才能真正体会到每一层的作用,然后迁移到更大的模型时,才能理解为什么有人要调学习率,为什么要做数据清洗,为什么评估集比训练集更投入精力。
其次,能定位 bug 的工程师,永远比能训练大模型但调试不了模型的人更稀缺。我见过太多只会把模型代码跑通就沾沾自喜的人,结果线上输出出问题时,完全不知道是从哪里开始排查。从零手写模型最大的价值,就是把你训练成一个遇到问题能快速缩小范围、定位根因的人。
最后,给自己留一本实验笔记。把每一次训练的数据、参数、loss 曲线、生成样例都记录下来。一个月后你会感谢这个习惯,因为 AI 工程里最珍贵的不是代码,而是你的判断力——而判断力正是从无数次记录下来的成功与失败中长出来的。