ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

从零开始构建大模型—读书笔记

2026/9/2 16:53:01 拓冰建站 浏览量
从零开始构建大模型—读书笔记 从零开始构建大模型-读书笔记LLM的定义一个LLM是一种神经网络旨在理解、生成和回应类似人类的文本。这些模型是经过大量文本数据训练的深度神经网络有时甚至包括整个互联网上可公开获取文本的大部分内容。什么是预训练和微调。预训练中的“预”指的是初始阶段其中类似于LLM的模型在大规模、多样化的数据集上训练以发展对语言的广泛理解。这个预训练模型随后作为基础资源可以通过微调进一步优化。微调是一个在更具体于特定任务或领域的较窄数据集上对模型进行特定训练的过程。这种由预训练和微调组成的两阶段训练方法在图 1.3 中展示两个最受欢迎的微调类别是指令微调和分类微调。在指令微调中标记数据集由指令和答案对组成例如一个查询翻译文本并附带正确翻译的文本。在分类微调中标记数据集由文本及其相关类别标签组成——例如与“垃圾邮件”和“非垃圾邮件”标签相关的电子邮件对于传统的机器学习模型 和通过传统监督学习范式训练的深度神经网络通常需要标签信息。然而在LLMs的预训练阶段并非如此。在这个阶段LLMs使用自监督学习模型从输 入数据中生成自己的标签Transformer 架构大多数现代LLMs依赖于 Transformer 架构这是一种在 2017 年论文“Attention Is All You Need”https://arxiv.org/abs/1706.03762中引入的深度神经网络架构。要理解LLMs我们必须了解原始的 Transformer它是为机器翻译开发的将英语文本翻译成德语和法语。Transformer 架构的简化版本在图 1.4 中展示。Transformer 架构由两个子模块组成一个编码器和一个解码器。编码器模块处理输入文本并将其编码成一系列数值表示或向量这些向量捕捉输入的上下文信息。然后解码器模块接收这些编码向量并生成输出文本。案例补充从架构到 ChatGPT理解 Transformer 后可以顺着这条主线串起大模型的发展脉络2017 年Transformer 论文发表奠定自注意力架构基础2018 年BERT 用编码器做理解任务GPT 用解码器做生成任务2020 年至今GPT 系列不断放大解码器规模配合海量数据与指令微调最终演进为 ChatGPT 等对话式大模型。这样从“编码器-解码器”的架构原点出发就能自然理解为什么今天的 LLM 大多采用或改造Transformer 解码器作为主干。BERT基于原始 transformer 的编码子模块构建其在训练方法上与 GPT 不同。GPT 是为生成任务设计的BERT 及其变体则专注于掩码词预测其中模型预测掩码或隐藏在给定句子中的单词如图 1.5 所示。这种独特的训练策略使 BERT 在文本分类任务中具有优势包括情感预测和文档分类。作为其能力的一种应用截至本文撰写时X前身为 Twitter使用 BERT 来检测有害内容。GPT 另一方面专注于原始 Transformer 架构的解码器部分旨在处理需要生成文本的任务。这包括机器翻译、文本摘要、小说创作、编写计算机代码等。GPT 模型主要设计和训练用于执行文本补全任务在能力上也表现出显著的灵活性。这些模型擅长执行零样本和少样本学习任务。零样本学习指的是在没有任何先前特定示例的情况下泛化到完全未见过的任务的能力。另一方面少样本学习涉及从用户提供的最少数量示例中进行学习如图 1.6 所示构建大型语言模型LLM 编码的三个主要阶段是实施 LLM 架构和数据准备过程阶段 1预训练 LLM 以创建基础模型阶段2以及微调基础模型以成为个人助理或文本分类器阶段 3。理解词嵌入数据转换为向量格式的概念通常被称为嵌入。使用特定的神经网络层或另一个预训练的神经网络模型我们可以嵌入不同类型的数据——例如视频、音频和文本如图2.2 所示。然而需要注意的是不同的数据格式需要不同的嵌入模型。例如为文本设计的嵌入模型不适合嵌入音频或视频数据比如最简单的二维嵌入虽然我们可以使用预训练模型如 Word2Vec 为机器学习模型生成嵌入但LLMs通常会产生自己的嵌入这些嵌入是输入层的一部分并在训练过程中更新。将嵌入作为LLM训练的一部分进行优化的优点是嵌入被优化以适应特定任务和数据。不幸的是高维嵌入对可视化构成了挑战因为我们的感官感知和常见的图形表示本质上限于三维或更少这就是为什么图 2.3 在二维散点图中显示了二维嵌入。然而当我们处理LLMs时我们通常使用具有更高维度的嵌入。对于 GPT-2和 GPT-3嵌入大小通常被称为模型隐藏状态的维度根据特定模型变体和大小而变化。这是性能和效率之间的权衡。 最小的 GPT-2 模型117M 和 125M 参数使用768 维的嵌入大小来提供具体示例。最大的 GPT-3 模型175B 参数使用 12,288维的嵌入大小。在 Python 中实现一个完整的分词器类其中包含一个 encode 方法该方法将文本分割成标记并通过词汇表执行字符串到整数的映射以生成标记 ID。此外我们还将实现一个 decode 方法该方法执行反向的整数到字符串映射将标记 ID 转换回文本。以下列表显示了此分词器实现的代码。SimpleTokenizerV2字节对编码BPEtiktoken它通过迭代地将频繁出现的字符合并为子词将频繁出现的子词合并为单词来构建其词汇表。例如BPE 首先将所有单个字符添加到其词汇表中“a”、“b”等。在下一阶段它将经常一起出现的字符组合合并为子词。例如“d”和“e”可能合并为子词“de”这在许多英语单词中都很常见。字节对编码BPE分词器用于像 GPT-2 和 GPT-3 这样的LLMs可以有效地通过将未知单词分解为子词单元或单个字符来处理未知单词。一个简单的BPE实现demodefbpe(word,merge_rules):# 规则表变成 {符号对: 行号}行号优先级越小越早学会越先粘ranks{pair:ifori,pairinenumerate(merge_rules)}symbolslist(word)# 从单个字符起步: lowest - [l,o,w,e,s,t]whilelen(symbols)2:# 第 1 步在当前所有相邻对里挑优先级号最小的一对best_pairNonebest_rankfloat(inf)foriinrange(len(symbols)-1):pair(symbols[i],symbols[i1])rranks.get(pair)# 表里没有的对返回 NoneifrisnotNoneandrbest_rank:best_rankr best_pairpairifbest_pairisNone:# 没有可合并的对了收工break# 第 2 步从左到右扫把这对的每处出现粘成一个新碎片new_symbols[]i0whileilen(symbols):ifilen(symbols)-1and(symbols[i],symbols[i1])best_pair:new_symbols.append(symbols[i]symbols[i1])i2# 粘掉两个跳两格else:new_symbols.append(symbols[i])i1symbolsnew_symbols# 本轮结束回到第 1 步returnsymbols# ---- 演示 ----MERGES[(e,s),(l,o),(lo,w),(n,e),(ne,w)]VOCAB{l:1,o:2,w:3,e:4,s:5,t:6,n:7,x:13,es:8,lo:9,low:10,ne:11,new:12}defencode(word):return[VOCAB[p]forpinbpe(word,MERGES)]if__name____main__:forwin[lowest,newest,newsx]:print(w,-,bpe(w,MERGES),-,encode(w))我们也可以用tiktoken封装好的BPE分词器importimportlibimporttiktokenprint(tiktoken version:,importlib.metadata.version(tiktoken))# tiktoken version: 0.14.0tokenizertiktoken.get_encoding(gpt2)text(Hello, do you like tea? |endoftext| In the sunlit terracesof someunknownPlace.)integerstokenizer.encode(text,allowed_special{|endoftext|})print(integers)不用记名字tiktoken.encoding_for_model(“gpt-4o”) 会自动映射到对应编码实测 gpt-4o→o200k_basegpt-3.5-turbo→cl100k_base。2.6数据采样滑动窗口对于每个文本片段我们都需要输入和目标。由于我们希望模型预测下一个词因此目标就是将输入向右偏移一个位置后的结果。GPTDatasetV1 类基于 PyTorch Dataset 类定义了如何从数据集中获取单个行其中每行包含一个由 max_length 分配的 token ID 数组这些 ID 被分配给输入chunk 张量。target_chunk 张量包含相应的目标。看看当我们将数据集与 PyTorch DataLoader 结合时返回的数据是什么样的——这将带来额外的直观性和清晰度。把一整篇文本变成next-token 预测的训练样本——输入是前文标签是紧接着的下一个 token。模型将来要学的就是这件事这里先把教材造出来。逐块拆Cell 30读文件 全文编码with open(“the-verdict.txt”, “r”, encoding“utf-8”) as f:raw_text f.read()with … as f 是上下文管理器用完自动关文件等价于 Java 的 try-with-resources省得手写 f.close()。f.read() 把全文读成一个字符串。接着 tokenizer.encode(raw_text) 用 gpt2 BPE 把全文变成一条编号长链len 打印 5145——整篇文档 5145 个 token这就是模型的数据集。Cell 31-32错位一位造 (输入, 标签) 对enc_sample enc_text[50:] # 从第 50 个 token 截取到末尾纯为演示挑个起点context_size 4x enc_sample[:context_size] # 下标 0~3y enc_sample[1:context_size1] # 下标 1~4切片等价于循环x [enc_sample[i] for i in range(0, 4)] # 即 [290, 4920, 2241, 287]y [enc_sample[i] for i in range(1, 5)] # 即 [4920, 2241, 287, 257]对齐看就一目了然——y 就是 x 往前错一位下标: 0 1 2 3 4样本: 290 4920 2241 287 257x [290 4920 2241 287 ]y [4920 2241 287 257 ]于是逐位置配成 4 对(and→established)、(established→himself)、(himself→in)、(in→a)。每一对就是一条训练样本“给你前文猜下一个词”。Cell 33-34把同一件事换成上下文逐渐长大的视角for i in range(1, context_size1): # i 1,2,3,4context enc_sample[:i] # 前 i 个 tokendesired enc_sample[i] # 第 i1 个即答案打印出来就是 [290] ---- 4920、[290, 4920] ---- 2241……cell 34 只是把编号 decode 回英文再打印方便人读。这个循环讲的是概念故事模型先看到一个词猜下一个再看到两个词猜下下个……而上面的 (x, y) 错位对是训练时的向量化形态。两者为什么等价靠 ch03 你见过的因果掩码——训练时一个 forward 同时预测 4 个位置但掩码保证预测位置 i 时只能看到 ≤ i 的 token不许偷看未来于是并行算和逐渐长大串行算完全一致。目的层面这段代码是整本书的发动机一标签免费。任何文本只要错位一位每个位置都自动生成一条 (输入, 答案) 样本不需要人标注。5145 个 token 立刻变出 5144 条样本。这正是你全景文档里decoder-only 胜出的工程根源——CLM 的数据管线零加工互联网全文都是教材。二目标定义模型。根据前文预测下一个 token就是 GPT 系列的训练目标因果语言建模模型的全部智能都是从这个看似简单的任务里逼出来的。三窗口大小是玩具值。这里 context_size4真实 GPT-2 用 1024 的上下文窗口紧接着书里会实现一个滑动窗口 打乱 分批的 DataLoader把全文切成大量这样的 (x, y) 小块喂给训练循环——但真正的训练要等 ch03 注意力、ch04 模型搭好之后ch05才开始所以这节末尾说prediction 放到后面章节。一句话收束这节把一篇文章变成了几万道填空题后面所有章节都是在造一个会做这些填空题的模型。我们可以用PyTorch 的 Dataset来存储这些用滑动窗口把整个文本切成几千条 (输入窗口, 目标窗口)的 样本fromtorch.utils.dataimportDataset,DataLoaderclassGPTDatasetV1(Dataset):def__init__(self,txt,tokenizer,max_length,stride):self.input_ids[]self.target_ids[]# Tokenize the entire texttoken_idstokenizer.encode(txt,allowed_special{|endoftext|})assertlen(token_ids)max_length,Number of tokenized inputs must at least be equal to max_length1# Use a sliding window to chunk the book into overlapping sequences of max_lengthforiinrange(0,len(token_ids)-max_length,stride):input_chunktoken_ids[i:imax_length]target_chunktoken_ids[i1:imax_length1]self.input_ids.append(torch.tensor(input_chunk))self.target_ids.append(torch.tensor(target_chunk))def__len__(self):returnlen(self.input_ids)def__getitem__(self,idx):returnself.input_ids[idx],self.target_ids[idx]defcreate_dataloader_v1(txt,batch_size4,max_length256,stride128,shuffleTrue,drop_lastTrue,num_workers0):# Initialize the tokenizertokenizertiktoken.get_encoding(gpt2)# Create datasetdatasetGPTDatasetV1(txt,tokenizer,max_length,stride)# Create dataloaderdataloaderDataLoader(dataset,batch_sizebatch_size,shuffleshuffle,drop_lastdrop_last,num_workersnum_workers)returndataloader重叠stride max_length是数据稀缺时的增强旋钮——同一段话在多个上下文里反复当题数据充足时不重叠才是高效默认不浪费算力做重复样本2.7 创建令牌嵌入 Creating token embeddings准备工作包括对文本进行分词、将文本分词转换为分词 ID以及将分词 ID 转换为嵌入向量。在此我们考虑之前创建的分词 ID 来创建分词嵌入向量连续向量表示或嵌入是必要的因为类似于 GPT 的LLMs是使用反向传播算法训练的深度神经网络。最后我们需要通过一个嵌入层embedding layer将词元tokens转换为连续的向量表示。通常这些嵌入层本身就是大语言模型的一部分并在模型训练过程中一同更新训练。6 个词、每个词用 3 维向量表示表里先填随机数之后训练时靠梯度下降把每个词向量学出来。初始随机值毫无意义训练时 next-token 损失的梯度会不断挪动各行常出现在相似上下文里的词行向量被越拉越近。input_idstorch.tensor([2,3,5,1])vocab_size6output_dim3torch.manual_seed(123)embedding_layertorch.nn.Embedding(vocab_size,output_dim)print(embedding_layer.weight)Parameter containing:tensor([[0.3374,-0.1778,-0.1690],[0.9178,1.5810,1.3010],[1.2753,-0.2010,-0.1606],[-0.4015,0.9666,-1.1481],[-1.1589,0.3255,-0.6315],[-2.8400,-0.7849,-1.4096]],requires_gradTrue)print(embedding_layer(input_ids))tensor([[1.2753,-0.2010,-0.1606],[-0.4015,0.9666,-1.1481],[-2.8400,-0.7849,-1.4096],[0.9178,1.5810,1.3010]],grad_fnEmbeddingBackward0)input text输入文本 “fox jumps over dog” 经分词变成编号列 [2, 3, 5, 1]fox2, jumps3, over5, dog1上方大矩阵Embedding 层的权重表6 行 3 列箭头取行每个编号去表里取自己那一行。蓝箭头第 1 个 token 编号 2 → 取表第 2 行 [1.2753, −0.2010, −0.1606]放到输出的第 1 行红箭头第 3 个 token 编号 5 → 取表第 5 行 [−2.8400, −0.7849, −1.4096]放到输出的第 3 行。3 和 1 同理没画箭头。下方矩阵输出 [4, 3]——四行依次是表的第 2、3、5、1 行。注意输出行的顺序跟输入编号顺序走不跟表的顺序走表里第 1 行0.9178 那行因为 dog 排在最后被放到了输出最底下。图里能读出的两个要点一纯取行零计算。前向传播时这层不做乘法就是行选择——所以快。数学上等价于 one-hot×Wbonus notebook 03 证的但实现上直接查行。二同一个词出现几次就取几次。这句里四个词不重复所以四行各异要是输入 “dog dog”表第 1 行就会被取两遍——重复 token 共享同一行向量这正是参数共享表只有 6 行不管句子多长。换句话说嵌入层本质上是一个查找操作通过 token ID 检索嵌入层权重矩阵中的行。Embedding 查表 one-hot 向量 × 权重矩阵第一步建 Embedding 并查表。 idx [2, 3, 1]种子 123 建 4×5 的表embedding(idx) 取出第 2、3、1 行输出那三行向量。第二步造 one-hot。 torch.nn.functional.one_hot(idx) 把每个编号变成只有第 id 位是 1、其余全 0的行向量编号2 → [0, 0, 1, 0] 编号3 → [0, 0, 0, 1] 编号1 → [0, 1, 0, 0]第三步建 Linear 并对齐权重。 nn.Linear(4, 5, biasFalse) 是个纯矩阵乘层无偏置注意它自己的随机初始值和 Embedding 不同所以 notebook 用 linear.weight Parameter(embedding.weight.T) 把同一张表拷过去转置是因为两层存权重的形状约定相反Linear 存 [出, 入]Embedding 存 [入, 出]。第四步对比。 linear(onehot.float()) 的输出和 embedding(idx) 逐元素相同——跑出来的两块 tensor 数字分毫不差唯一区别是尾巴grad_fn矩阵乘路径vs 查表路径。前向结果一样只是走的计算通道不同。数学上就一行one-hot 是标准基向量 e_ie_i × W W 的第 i 行——左乘基向量就是**“行选择器”**乘法里那堆 0 什么都不贡献只有 1 所在位置的行活下来。真实模型词表 5 万维one-hot × W 每个 token 要做 5 万次乘法其中 49999 次是乘 0纯浪费Embedding 直接 memcpy 一行稀疏且快。所以 Embedding 不是新数学是 Linear 的稀疏优化特例——梯度也只流到被选中的行两边一致。嵌入层本质上是一个查找操作通过 token ID 检索嵌入层权重矩阵中的行。如果你懂线性代数嵌入就相当好理解。嵌入表 embedding_layer 的行数 词表大小列数是我们定的“衡量这些词的维度”。如果词表有 6 个词id 0~5那表是 6×3每个词存一行 3 维。4×3 是查询的输出4 个 id每个查出一行 3 维向量。第一步嵌入表6×3每行是一个词的向量、embedding_layer 是 6×3id 列表先展开成 4×6 的 one-hot 矩阵再乘出 4×3 的结果embedding_layer初始化时是随便填的随机数但它不会一直是随机数——它是可训练参数训练过程中会被梯度一点点改成有语义的样子。2.8 编码单词位置Encoding word positionsembedding 层不管 ID 在序列里的什么位置都转换成相同的向量。 就是查表的固有性质只认编号、不认位置。“dog” 在第 0 位和第 100 位取的是同一行。后果是严重的向量只带是哪个词、不带在第几位而后面要学的注意力机制本身又是顺序无关的把输入行洗牌输出跟着同样洗牌计算过程察觉不到顺序。不补救的话“dog bites man” 和 “man bites dog” 在模型眼里是同一堆向量。 补救办法再建第二张表——位置嵌入表行号 位置号0, 1, 2, …行向量同样靠训练学出来。最终喂给模型的向量 词向量 位置向量逐元素相加“combined” 在这里就是加法形状不变还是 [4, 3]。一个向量同时叠加内容和位置两种信息。位置矩阵L4,d3L4, d3L4,d3单位矩阵 位置嵌入的查询矩阵是单位阵 embedding_weight 跟id的embedding_weight 保持维度一致。权重表的行数 编号的取值个数 one-hot 向量的宽度。那位置矩阵的权重参数和编号矩阵的权重参数 是分开计算的吗存储分开、梯度分开、但通过同一个损失函数耦合上面的图是把损失加起来每一步同时更新。