把黑箱拆开:手搓一个大语言模型 书架

第 14 章 / 共 16 章

第十四章 · 真的动手:两百行搭一个迷你 LLM

上一章我们吵了半天"它到底懂不懂"。吵归吵,有个办法能一劳永逸地把神秘感捏碎:自己动手造一个。不是调 API,不是喊咒语,是从一张空白的 Python 文件开始,把前面十三章讲的东西——切词、词向量、注意力、堆层、下山训练——真的拼成一个能跑的东西,看着它从吐乱码一路吐出像样的句子。

好消息是:一个最小可用的 LLM,核心逻辑真的就两百行左右。我们要造的是一个字符级 GPT(char-level GPT)——它不预测"下一个词",而是预测"下一个字符"。为什么退到字符级?因为这样连第二章的分词器都省了:词表就是文本里出现过的那些字符,几十个而已。麻雀虽小,五脏俱全,注意力那颗心脏一个不少。

第一步:把文本变成数字流水线

找一个文本文件,比如一整本《红楼梦》或者莎士比亚全集,几 MB 就够。我们把里面所有不重复的字符拎出来排个序,这就是词表。然后给每个字符编个号——这就是全部的"分词"。

chars = sorted(set(text)) — 拿到所有出现过的字符

stoi = {c: i for i, c in enumerate(chars)} — 字符→编号

itos = {i: c for c, i in stoi.items()} — 编号→字符(生成时要用它翻译回去)

data = torch.tensor([stoi[c] for c in text]) — 整本书变成一长串数字

现在整本书是一条几百万长的数字带子。训练一个"猜下一个字符"的模型,需要的样本其实白送:带子上任意一段连续字符是输入,把它整体往右挪一格就是答案。看到"红楼梦"就该猜"梦"后面那个字——答案早就写在原文里,不用人工标注。这就是第八章说的自监督(self-supervised):数据自己给自己出题、自己对答案。

训练时我们从带子上随机剪一小段,比如剪 32 个字符当输入 x,把这 32 个字符各自往后挪一位当目标 y。一次剪一批(比如 64 段)塞进去,这叫一个 batch。就这么简单。

第二步:搭出那颗心脏

模型本体就是把前几章的零件按顺序摞起来。我用大白话过一遍数据在里面的旅程,每一站对应前面某一章:

  1. 查表变向量(第三章):每个字符编号先查一张 embedding(词嵌入表),把干巴巴的编号换成一个有几十上百个数字的向量,让"字"带上可运算的含义。同时再查一张位置嵌入(positional embedding),告诉模型"这是第几个字"——因为注意力本身不分先后,得额外喂它顺序。
  2. 互相看一眼(第五章):进入注意力层。每个字符生成自己的 Query、Key、Value,用 Q 去和前面每个字的 K 打分,决定该多听谁的,再按分数把大家的 V 加权汇总。这里有个关键动作叫因果掩码(causal mask)——把"看未来"的那部分打分强行设成负无穷。
  3. 过一遍小网络(第四章):注意力汇总完,每个位置再单独过一个两层的小神经网络(掰弯的矩阵乘法),把信息揉一揉。
  4. 摞很多层(第六章):把"注意力 + 小网络"打包成一个 Block,配上残差连接和层归一化,然后重复堆几层。迷你版堆 3 到 6 层就够看效果。
  5. 吐概率(第一章):最后一层输出,乘上一个矩阵映射回"词表大小"这么多个数字,每个数字对应"下一个字符是它"的原始分数。

为什么第 2 步那个因果掩码这么要命?因为我们的目标是预测下一个字。训练时整句话都在眼前,如果不遮住未来,模型算第 5 个字时会偷看到第 6 个字——那它直接抄答案就行,学不到任何东西,一到真实生成(没有未来可看)立刻抓瞎。掩码逼它只靠左边已有的字去猜,这才是真本事。

可以这样想:因果掩码就是考试时用一张纸盖住答案的下半部分,只让你看到题目和上面已经写好的,逼你自己往下推。不盖,就是开卷抄,考完啥也没学会。

第三步:算错了多少,然后下山

模型吐出的是每个位置上"下一个字符该是谁"的一串分数。怎么知道它猜得好不好?用第七章的交叉熵损失(cross-entropy loss):它衡量"模型给正确答案那个字符的概率有多低"。猜对了正确字符、概率给得高,损失就小;正确答案它几乎没往上押,损失就大。

PyTorch 里核心就一行:loss = F.cross_entropy(logits, targets)

然后三步下山:loss.backward() 把错误反推回每个旋钮算出梯度 → optimizer.step() 让每个旋钮朝减小损失的方向挪一小步 → optimizer.zero_grad() 清空梯度准备下一轮。

训练就是这三行在一个循环里转成千上万次。有个数字能帮你判断它有没有在学:我们的词表假设有 65 个字符,一个完全瞎猜的模型,损失约等于 ln(65) ≈ 4.17(对所有选项均匀乱押的数学期望)。所以开跑时你会看到损失在 4.1 附近晃——这就是"纯随机"的基准线。训练几分钟后它掉到 2 以下、再到 1.5 左右,你就知道:它真的在从文本里榨取规律,而不是装样子。

第四步:让它开口说话

生成比训练还简单,就是第一章那个"猜下一个词"反复做:喂一段起始文字 → 模型吐出下一个字符的概率 → 按第十章讲的采样(掷一次加权骰子,别每次都选最高分,否则死板重复)抽一个字 → 把这个字接到句子末尾 → 拿新句子再喂回去。如此循环几百次,一段文字就自己长出来了。

最动人的是看它随训练进度变化。刚开始(损失 4 左右),它吐的是纯乱码,像猫在键盘上打滚。训练一会儿(损失 2.5),它学会了单词的长度、空格和标点该出现在哪、常见字母怎么搭配——看着像语言,但没一个真词。再练下去(损失 1.5 左右),真词冒出来了,甚至有像模像样的对话格式和人名。

它从没被人教过"什么是单词""什么是标点"。它只是被逼着一遍遍猜下一个字符,为了猜得准,这些规律它不得不自己总结出来。你亲眼看着结构从纯噪声里自己浮现——这就是整本书那句暗线最好的证据:没有魔法,只有被海量重复的"猜下一个"逼出来的模式。

它和 GPT-4 差在哪

你造的这个和真正的大模型,架构上是同一个东西——同样的注意力、同样的堆层、同样的"猜下一个 + 下山"。差别只有两个词:规模数据。你的模型可能几万到几百万个参数(旋钮),跑在笔记本上几分钟;GPT-4 那一档是几千亿到万亿量级的参数,喂了大半个互联网,在成千上万张显卡上练几个月。把你这两百行的宽度、深度、数据量往上乘几个数量级,就是它们。第九章说的"涌现"就藏在这段放大里——同样的配方,量足够大时会煮出你在小锅里看不到的东西。

所以本质上,一个 LLM 就是你刚亲手拼出来的这两百行,被规模狠狠放大后的样子。你已经把黑箱从里到外摸过一遍了——它不再是黑箱。下一章,我们不再自己从零造,而是把别人已经练好的开源大模型搬到你电脑上,看看怎么用量化把它塞进一张普通显卡,再亲手微调它一次。

把黑箱拆开:手搓一个大语言模型 · 王建硕
面向对世界有好奇心的人 · 费曼式写法 · 由多个 AI 代理撰写与互相审校