1. 项目概述:从零手搓一个能翻译英-马的LLM,不是玩具,是真家伙
你有没有想过,ChatGPT、Gemini这些大模型的“心脏”到底长什么样?不是调用API,不是改改Hugging Face的config,而是真正从 import torch 开始,一行行敲出Transformer的每一个模块,亲手喂它数据、看它犯错、陪它长大——最后让它把一句“Hello, how are you?”稳稳当当地翻成“Hai, apa khabar anda?”。这正是我们今天要干的事。关键词很明确: PyTorch、LLM、Transformer、英-马翻译、从零构建 。这不是一个“Hello World”式的Demo,而是一个结构完整、可训练、可验证、可推理的生产级小型语言模型(MalayGPT)的全链路实现。它解决的核心问题,是帮你彻底撕开LLM的黑箱:你知道为什么必须用LayerNorm而不是BatchNorm?为什么Positional Encoding里sin和cos要交替塞进向量?为什么decoder的mask要“遮住未来”,而encoder的mask只管padding?这些在论文里一笔带过的细节,恰恰是模型能否收敛、效果能否上线的生死线。适合谁?如果你已经会写PyTorch的 nn.Module ,能看懂 torch.nn.functional.softmax 的输入输出维度,但对Transformer内部的梯度流向、mask的物理意义、甚至 view().transpose() 那几行代码到底在动哪块内存还存有疑虑——那你就是这个项目的理想读者。它不假设你精通矩阵微积分,但要求你愿意为每一行 attention_score = (query @ key.transpose(-2,-1)) / math.sqrt(self.d_k) 停下来,画个3x3的小矩阵,亲手算一遍。我试过,光是把 causal_mask 函数里的 torch.triu(..., diagonal=1) 和 ==0 的逻辑理清楚,就花了整整一个下午。但当你第一次看到自己写的 MultiHeadAttention 模块在验证集上把“Thank you very much”翻成“Terima kasih banyak”时,那种亲手造出“理解”的震撼,是任何现成框架都给不了的。
2. 整体架构设计与核心思路拆解:为什么是Encoder-Decoder,而不是纯Decoder?
构建一个翻译模型,第一道坎就是选型。为什么我们死磕经典的Encoder-Decoder架构,而不是直接上GPT-style的纯Decoder?这绝不是为了“复古”,而是由翻译任务的本质决定的。翻译是典型的 序列到序列(Seq2Seq) 任务:输入是一整段英文源文,输出是一整段对应的马来文译文。它需要模型先“吃透”源文的全局语义(比如主谓宾关系、时态、指代消解),再基于这个完整的理解,逐词生成目标语言。纯Decoder架构(如GPT)天生是自回归的——它只能根据前面已生成的token预测下一个,无法在生成第一个词之前就“看见”整个源句。这就像让一个没读过原文的人,凭空开始写译文,注定是盲人摸象。而Encoder-Decoder则完美匹配:Encoder像一位专注的速记员,把整段英文压缩成一个富含语义的上下文向量(Context Vector);Decoder则像一位资深译者,拿着这个向量,结合自己已生成的部分译文,精准地推导出下一个词。这个设计背后,是信息流的严格管控。Encoder的输出(Key和Value)会被送入Decoder的Cross-Attention层,成为Decoder生成每个词时的“参考依据”。没有这个跨层连接,Decoder就成了无源之水。我们选择6层Encoder+6层Decoder,完全复刻原始Transformer论文的设定。这不是教条主义,而是经过海量实验验证的甜点区:层数太少,模型容量不足,抓不住长距离依赖;层数太多,训练成本指数级上升,且极易过拟合——尤其当我们只有100万平行语料时。至于隐藏层维度 d_model=512 ,它也不是拍脑袋定的。你可以把它理解为模型的“工作记忆带宽”。512维足够让每个token的embedding承载丰富的语法、语义、甚至文化信息(比如“bank”在金融语境和地理语境下的区分),又不会让GPU显存瞬间爆掉。我实测过,把 d_model 拉到768,在我的RTX 4090上单步训练时间直接翻倍,而BLEU分数只涨了0.3,性价比极低。另一个关键决策是 分词器(Tokenizer)的选择 。我们弃用了现成的 transformers.AutoTokenizer ,坚持用 tokenizers 库从头训练BPE(Byte-Pair Encoding)。原因很简单:预训练Tokenizer是为通用语料优化的,而我们的任务极度垂直——英-马翻译。马来语有大量的外来词(尤其是英语借词)和独特的构词法(如前缀meN-、peN-)。一个为Wikipedia训练的Tokenizer,很可能把“mempercepat”(加速)切分成 mem , per , cepat 三个无意义碎片,而我们自己训的BPE,会在语料中高频出现的 mempercepat 上直接学习出一个完整token。这直接决定了模型能否理解马来语的动词变位。所以,整个项目的设计哲学就一句话: 所有组件,必须服务于“翻译”这个单一目标,拒绝任何通用性妥协。 每一行代码,都在回答一个问题:“这对提升英-马翻译的准确率,有直接贡献吗?”
3. 核心模块深度解析:从Embedding到Multi-Head Attention,每一行代码的物理意义
3.1 输入嵌入(Input Embedding)与位置编码(Positional Encoding):让模型“认字”并“知序”
模型不吃文字,只吃数字。所以第一步,是把“Hello”变成一串512维的向量。这就是 EmbeddingLayer 干的活。它的核心是 nn.Embedding(vocab_size, d_model) ,一个巨大的查找表。 vocab_size 是你的词表大小(比如英语词表约3万), d_model=512 是每个词的向量长度。当你传入一个token ID(比如 "Hello" 在词表里是第1234号),它就从表里捞出第1234行的512个数字。这里有个精妙的细节: embedding_output = self.embedding(input) * math.sqrt(self.d_model) 。为什么要乘以 sqrt(512) ?这是为了 方差归一化 。想象一下,如果embedding层的权重初始化是标准正态分布 N(0,1) ,那么输入一个ID,输出就是一个512维的随机向量,其L2范数的期望值大约是 sqrt(512) 。如果不做缩放,这个巨大的初始范数会一路传递下去,导致后续层的激活值爆炸,训练根本无法稳定。乘上 1/sqrt(512) ,就把输出的方差重新拉回1附近,为后续的LayerNorm铺平道路。这看似微小的乘法,是整个训练过程能跑起来的基石。
但光有“字”还不够,句子是有顺序的。“猫追老鼠”和“老鼠追猫”意思天壤之别。而 nn.Embedding 输出的向量,对所有位置的“猫”都一模一样。这就轮到 PositionalEncoding 登场了。它不改变词义,只给每个位置打上独一无二的“座标”。原始论文用的公式是 PE(pos, 2i) = sin(pos/10000^(2i/d_model)) 和 PE(pos, 2i+1) = cos(pos/10000^(2i/d_model)) 。为什么用sin/cos?因为它们是 周期函数 ,能天然表达“相对位置”。两个位置 pos 和 pos+k 的编码,其差值只与 k 有关,与 pos 无关。这意味着模型可以轻松学到“跳过5个词”这样的相对关系,而不需要死记硬背绝对位置100和105。在代码里, div_term = torch.exp(torch.arange(0, d_model, 2).float() * (-math.log(10000)/d_model)) 这行就是在计算 10000^(2i/d_model) 的倒数。 pe[:, 0::2] = torch.sin(pos * div_term) 则是把sin塞进所有偶数列。最关键的一句是 pe = pe.unsqueeze(0) 。这行代码把形状从 (max_seq_len, d_model) 变成了 (1, max_seq_len, d_model) 。为什么要加一个batch维度?因为我们的输入 input_embedding 是 (batch_size, seq_len, d_model) ,只有加上这个 1 ,才能利用PyTorc
428




被折叠的 条评论
为什么被折叠?



