ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

GPT生成式预训练全解析:从架构原理到微调实践

GPT生成式预训练全解析:从架构原理到微调实践

1. 生成式预训练到底解决了什么问题

先把话说在前面:GPT这几年几乎成了大模型的代名词,但很多人其实没搞明白,它最初提出的"生成式预训练"到底解决了什么问题。如果你把时间拨回2018年,那时候做NLP的主流思路是:每个任务都要单独标注数据,单独训练模型。情感分类要标几万条评论,命名实体识别要标几万条句子,问答要标几万组问答对。数据标注贵、周期长,而且模型学到的知识无法迁移——你在情感分类上学到的语义理解,换到下一个任务上一点都用不上。

GPT的创始人思路其实很朴素:与其每次都为具体任务找标注数据,不如先找一大堆随便什么文本,让模型自己从里面学语言规律。这个过程不需要人工标注,只需要互联网上源源不断的文字。等模型把"语言是什么"学明白了,再拿少量标注数据让它适配具体任务。这就是生成式预训练的核心逻辑:先用无监督方式从大规模文本中学习通用语言表征,再通过有监督微调迁移到下游任务。

这个思路解决了两层问题。第一层是数据问题:高质量标注数据永远是稀缺的,而原始文本几乎取之不尽。第二层是泛化问题:预训练阶段模型看到的是海量多样化的语言现象,所以学到的表征比只在单一任务上训练要通用得多。我的理解是,这就像培养一个医生之前,先让他系统学完基础医学,再针对某一专科做临床实习;比起一上来就只学某一个病的诊疗方法,基础扎实得多。

文章后面会从Transformer解码器架构、两阶段训练目标、规模化的演进路径,一直讲到我自己实践微调GPT类模型时的参数选择和踩坑记录。不管你是准备入门LLM方向的开发者,还是已经在做NLP想补全底层原理的人,这篇应该都能给你一个有体系的答案。

2. GPT的核心架构拆解

2.1 Transformer Decoder是GPT的唯一骨架

GPT的架构本质上就是Transformer的Decoder部分,但去掉了编码器-解码器之间的交叉注意力。Transformer最开始是做机器翻译的,结构是Encoder-Decoder对称形态:Encoder把源语言编码成中间表示,Decoder再基于这个表示生成目标语言。GPT的思路是:我只需要Decoder自己,让它看一段文本,预测下一个词,反复训练就能学会语言。

一个GPT Block内部包含两个核心子层:先是掩码多头自注意力(Masked Multi-Head Self-Attention),然后是前馈神经网络(Feed-Forward Network),每个子层外面都包着残差连接和层归一化。GPT-1的具体配置是12层、隐层维度768、12个注意力头、参数量1.17亿,最大序列长度512,词表大小约4万(用BPE切分)。这套配置放到今天看很小,但它把Transformer解码器这个架构验证通了。

一个容易被忽略的细节是层归一化的位置。最早的Transformer用的是Post-Norm(先算子层,再做归一化),但后来在大规模模型上,大家更倾向于Pre-Norm(先归一化,再算子层),因为它在深层的梯度传播上更稳定,训练更平滑。GPT系列在实际实现中逐渐转向了Pre-Norm的变体。这类工程细节直接决定了万卡训练能不能稳定跑下来。

2.2 掩码自注意力:为什么模型只能看左边

自注意力机制本身很简单:每个位置的向量会生成Query、Key、Value三组向量,然后计算每个位置和其他所有位置的相关性,再用相关性加权聚合信息。问题来了:如果让模型在看"今天天气真好,我打算去公园______"这句话时,同时能看到空格后面实际出现的词,那预测就没有意义了——答案已经在输入里了,模型只需要抄一遍就行。

所以GPT使用因果掩码:在计算注意力分数时,把未来位置的分数设置为负无穷,经过Softmax之后权重变成0。这样每个位置只能看到自己和它左边的所有位置,右边一概不可见。这个设计的深层意义是强制模型学会真正的预测能力。为了填对下一个词,模型必须理解句子的语法结构、上下文的语义约束、甚至常识和世界知识。

用代码展示一下这个掩码长什么样,只需要几行就能构造出来:

import torch seq_len = 4 mask = torch.triu(torch.ones(seq_len, seq_len) * float('-inf'), diagonal=1) # tensor([[0., -inf, -inf, -inf], # [0., 0., -inf, -inf], # [0., 0., 0., -inf], # [0., 0., 0., 0.]])

在注意力计算里,这个掩码直接加到分数矩阵上:

scores = torch.matmul(Q, K.transpose(-2, -1)) / math.sqrt(d_k) scores = scores + mask # 未来位置变成 -inf weights = torch.softmax(scores, dim=-1) out = torch.matmul(weights, V)

很多人会问:为什么BERT能看到双向上下文,效果也很好,GPT偏要限制自己只看左边?答案是两者目标不同。BERT做的是完形填空(掩码语言建模),模型需要同时参考左右两侧的上下文,这适合理解类任务;而GPT要生成文本,生成过程天然就是从左往右逐词展开的。如果训练时让模型看双向信息、生成时又不能看未来,就产生了训练和推断的不一致。GPT把目标和过程统一起来,所以生成起来特别顺。

2.3 位置编码与BPE分词:两个最不起眼的关键模块

Transformer本身对词序是不敏感的——你把"猫追老鼠"和"老鼠追猫"拆成同样的词向量输入,模型看到的注意力矩阵是一样的(不考虑位置信息时)。所以必须显式地把位置信息注入进去。GPT-1用了可学习的位置嵌入,也就是给每个位置分配一个可训练的向量,跟着整个网络一起更新。在小规模模型上这个方法简单直接,但在长文本场景下外推能力有限,所以后来的模型纷纷改用RoPE(旋转位置编码)这类相对位置方案,能更好地处理训练时没见过的超长序列。

分词方面,GPT用BPE(Byte Pair Encoding)把文本切成子词单元。BPE的核心思想是:数据里出现次数最多的相邻字符对不断合并,直到达到预定的词表大小。比如"learning"可能被切成["learn", "ing"],"unbelievable"可能被切成["un", "believ", "able"]。这样有几个好处:一是不用担心遇到没见过的词(OOV问题),二是把高频常用词保留为完整词元、低频词拆成更细的子词,词表大小和序列长度之间能取得平衡。GPT-2用的字节级BPE词表是50257,这个数字今天在好多开源模型里都能看到。

2.4 为什么GPT抛弃Encoder,只留Decoder

这个问题我经常被问到。既然BERT的Encoder双向注意力在理解任务上表现更好,为什么后来所有主流大模型都走Decoder-only路线?

第一个原因前面说过,生成任务和因果语言模型天生匹配。第二个原因是接口统一:文本生成是这个模型的唯一出口,无论下游要做翻译、写作、摘要还是代码生成,本质上都是"给定上下文,续写文本"。你不需要为每个任务设计不同的模型头,Prompt就是任务描述本身。这也是GPT系列从GPT-1一路扩展到GPT-4之后,各项能力不断涌现的架构基础:一个统一的生成接口,配一个足够大的Decoder,就足以容纳几乎所有的语言能力。

第三个原因是规模化的工程便利。Decoder-only结构没有Encoder和Decoder之间的复杂交互,流水线并行、张量并行、数据并行都好做得多。到了几千亿参数的规模,每多一个组件都是巨大的工程负担。后来像T5这样的Encoder-Decoder模型虽然也取得过不错的效果,但在最终的大模型竞赛里,Decoder-only用更简单的结构、更稳定的训练过程笑到了最后。

3. 两阶段训练法:预训练与微调

3.1 第一阶段:用"预测下一个词"完成无监督预训练

GPT的第一阶段目标是标准的语言模型损失:给定前t-1个词,最大化第t个词的预测概率。把所有位置的概率加起来取平均,就是整个句子的损失。用人话解释:模型读完一句话的前半段,必须尽可能准确地预测后半段接下来出现的每一个词。

听起来简单,但这个目标的信息量其实极大。为了把下一个词预测准,模型不得不学会语法规则、词的搭配习惯、句子的主题一致性、甚至推理链条。举个例子,预测"因为下雨,所以我带了______"时,模型必须知道"雨"和"伞"之间的常识关联。这些知识不需要任何人标注,全部从文本中涌现出来。

GPT-1预训练用的数据是BooksCorpus,大约5GB的图书文本。选图书而不是随意网页,是因为长篇连续文本能给模型提供更长的上下文依赖,更有利于学习连贯的语义结构。训练配置是最大序列长度512、batch size按Token数折算,初始学习率2e-4左右,配合warmup和余弦衰减,在8块GPU上训练了一个月。训练损失函数本质上就是交叉熵,代码里的实现方式值得看一下:

from torch import nn # logits: [batch, seq_len, vocab_size] # labels: [batch, seq_len] shift_logits = logits[:, :-1, :].contiguous() shift_labels = labels[:, 1:].contiguous() loss_fct = nn.CrossEntropyLoss() loss = loss_fct( shift_logits.view(-1, shift_logits.size(-1)), shift_labels.view(-1) )

注意labels要右移一位,这样每个位置都在预测"下一个词"。这段逻辑是所有生成式语言模型的损失计算基础。

3.2 第二阶段:把通用模型搬到具体任务上

预训练完成后,模型已经是一个"语言通才",但它不会做任何具体任务。这时需要一个轻量的适配过程——有监督微调。微调的思路不是从零训练,而是在预训练好的参数基础上,用少量标注数据继续更新。

GPT-1对下游任务的适配方式很有代表性。它没有为每个任务重新设计复杂的网络结构,而是改变输入的拼接方式,让任务本身变成文本序列:

  • 分类任务:[Start] 输入文本 [Extract],输出标签
  • 蕴含任务:[Start] 前提 [Delim] 假设 [Extract],输出判断
  • 相似度任务:把两个文本以两种顺序分别拼一次,各自过模型再合并特征
  • 多选题:每个选项单独和题干拼起来过模型,对多个选项的结果做Softmax

微调阶段的损失函数是带权重的混合:L = L_task + λ * L_lm,其中L_task是有监督任务损失,L_lm是保留的语言模型损失,λ在GPT-1里取0.5。很多人不理解为什么要保留语言模型损失,我的理解是:预训练学到的语言能力是微调任务的地基,如果在微调时完全不约束生成方向,模型容易在适配小任务时把通用的语言能力丢掉。加上L_lm相当于时刻提醒模型"别忘了你还会说人话",这对提升收敛稳定性和泛化性都有帮助。

3.3 数据与算力:预训练的成本到底有多高

从GPT-1到GPT-3,数据规模的变化非常夸张。GPT-1用了5GB图书,GPT-2用Reddit外链搜集的WebText约40GB文本,GPT-3则是在过滤后的Common Crawl、WebText、图书和百科混合语料上训练的,原始数据量45TB,去重过滤后大约570GB,约3000亿Token。简单感受一下:如果一个人一秒读100个Token,不吃不喝读完这些语料需要上千年。

算力需求同样夸张。GPT-3的训练成本在当时估计是数百万美元的算力消耗。对普通团队来说,从零预训练大模型基本不现实,但理解这个成本结构有实际意义:它解释了为什么开源社区更倾向于在已有基座模型上做继续预训练或微调,而不是自己重造轮子。即便只是领域继续预训练,通常也需要至少几十亿Token的高质量数据才能看到明显效果,这是很多团队一上来就踩的坑。

4. 从GPT-1到GPT-4:规模化的关键路径

4.1 代际演进:参数和能力的跃迁

把GPT系列各代模型的核心参数整理成一张表,一眼就能看出规模化的趋势:

模型参数量层数隐层维度最大序列长度关键能力变化
GPT-11.17亿12768512验证两阶段训练可行
GPT-215亿4816001024零样本生成能力
GPT-31750亿96122882048少样本上下文学习
GPT-3.5未完整公开--4096+对齐人类偏好
GPT-4未完整公开--8K/32K多模态、复杂推理

GPT-1到GPT-2是第一个跃迁。参数量增加13倍,数据从5GB变成40GB,模型突然具备了令人惊讶的文本生成连贯性。GPT-2最著名的例子是输入一段关于"美国内战后建造火车隧道"的文字,模型能续写出合理的历史叙事——没有人教过它历史,它只是从海量网页里"读过"。

GPT-2到GPT-3是第二个跃迁。参数量暴涨115倍,训练数据扩大到3000亿Token,这时出现了一个预训练阶段从未专门训练过的能力——上下文学习(In-Context Learning)。给GPT-3几个翻译示例,它不用更新任何参数,就能照着示例的模式继续翻译。这在监督学习框架里几乎无法解释,只能归结为规模带来的涌现能力。

4.2 Scaling Law:为什么变大就是有效

OpenAI团队在GPT-2和GPT-3之间做了一系列消融实验,结论后来被总结为Scaling Law:模型损失随着参数量、数据量、计算量的增大按幂律下降。这意味着,要想把损失降下来,最稳妥的方式就是同时扩大模型和数据规模,而不是绞尽脑汁改架构。

但这里有个容易误读的点:很多人以为只要把模型做大就行,实际上Scaling Law的三个维度(模型大小、数据大小、计算量)需要同步增长,缺一不可。只扩大模型不增加数据,模型很快就把数据"背完"了,开始过拟合;只增加数据不扩大模型,模型容量不够,损失下降也会变缓。正确的姿势是在三者之间保持大约等比例的投入。现在很多团队训练大模型失败的常见原因之一,就是数据规模没有跟上模型规模。

4.3 与BERT的路线之争:单向还是双向

2018年GPT和BERT几乎同时出现,当时BERT在GLUE榜单上全面压过GPT,学术圈普遍更看好双向Encoder路线。为什么后来剧情反转了?

核心在于两者优化的目标不同。BERT用掩码语言建模,善于理解但天生不擅长生成——它的训练目标里没有"从零续写一句话"这个能力要求。GPT用因果语言模型,不仅理解,而且能生成。当大家发现"能生成"这件事在统一任务接口上价值巨大时,Decoder-only路线的优势就显现了:你可以让模型把所有任务都转化成"给定提示词,续写答案",包括翻译、总结、问答、写代码,全部一个套路搞定。

这个趋势到GPT-3的上下文学习能力出现后更加明显。如今几乎所有的开源大模型,包括各种规模的中文模型,清一色都是Decoder-only架构。回头看,BERT的momentum在理解类任务上保持了几年优势,但生成能力最终成了决定通用人工智能路线走向的关键变量。

4.4 RLHF:让模型从"会说"到"会做人"

GPT-3能生成流畅的文本,但它生成的文本不一定符合人类期望:可能语句通顺但内容有害,可能看似专业实则胡说八道。这是因为预训练的目标是"预测下一个词",不是"说出用户想听的有用答案"。到了ChatGPT这一代,OpenAI引入了RLHF(基于人类反馈的强化学习),让模型对齐人类偏好。

RLHF分三步:第一步,收集人工撰写的理想回答,做有监督微调(SFT),让模型学会问答的基本形态;第二步,让模型生成多个回答,由人类标注好坏,训练一个奖励模型(RM)来预测人类偏好;第三步,用强化学习算法(主要用PPO)以奖励模型为信号进一步优化生成策略。这个过程成本很高,但效果立竿见影:模型不再只是"文字概率预测器",而是变成"能理解用户意图、遵循指令、拒绝不合理要求"的对话助手。

我个人的理解是,预训练给模型装上了"语言能力",RLHF给模型装上了"行为规范"。少了任何一环,要么能力不行,要么行为不可控。这也是为什么现在很多开源模型即便基座能力很强,如果不对齐,用起来依然会感觉"不太聪明"。

5. 实操:自己微调一个GPT类模型

5.1 工具选型与环境准备

如果你想亲自上手跑一个GPT类模型,现在生态已经非常成熟了。首选是Hugging Face的Transformers库加PyTorch,配合peft库做参数高效微调,再搭配DeepSpeed或Accelerate处理分布式训练。硬件方面,如果是微调7B级别模型,用LoRA在24GB显存的消费级显卡上就能跑;全参数微调至少需要A100/H100这样的80GB级别显存,或者多卡并行。

我建议新手起步的方式是:先用1B-3B的小模型在单卡上跑通整个流程,理解了数据格式、损失计算、生成推理之后,再上大模型。直接上大模型很容易被各种环境问题淹没,反而不利于建立正确的直觉判断。个人经验是本地先拿一个小模型反复实验两三天,比盲目租卡跑大模型收获大得多。

5.2 数据准备:决定微调效果的上限

微调效果的上限在数据,而不在模型本身。数据准备有几个关键点:

  • 数量上,指令微调通常万条级别起步,10万条已经算不错;领域继续预训练则需要亿级Token起步
  • 质量优先于数量,混入垃圾数据会让模型变"笨"
  • 需要去重和过滤,重复样本会放大模型对特定句式的偏好
  • 指令数据要覆盖任务多样性,最好包含:问答、写作、摘要、翻译、代码、角色扮演等

数据格式上,对话式微调通常组织成每轮对话的序列,每个角色前后拼接special token来区分。以最基础的指令微调为例,一个样本就是"指令-回答"对,转化后在训练时通过损失掩码只计算回答部分的损失,避免模型去死记硬背指令的措辞。这一步很多人会忽略,导致模型训练损失一直在降,但生成质量很差——因为它把输入指令也当成需要预测的内容了。

5.3 一个可运行的微调流程

下面给出一套基于LoRA的微调最小可运行流程。LoRA的思路是冻结原始权重,只训练注入的低秩分解矩阵,把可训练参数量降到原来的1%左右,适合在有限的硬件条件下微调。

from transformers import AutoModelForCausalLM, AutoTokenizer, Trainer, TrainingArguments from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training model = AutoModelForCausalLM.from_pretrained("你的基座模型路径") tokenizer = AutoTokenizer.from_pretrained("你的基座模型路径") lora_config = LoraConfig( r=8, # 低秩矩阵的秩 lora_alpha=16, # 缩放系数,通常设为r的2倍 target_modules=["q_proj", "v_proj"], # 只对注意力层注入LoRA lora_dropout=0.05, bias="none" ) model = get_peft_model(model, lora_config) training_args = TrainingArguments( output_dir="./output", per_device_train_batch_size=4, gradient_accumulation_steps=8, # 等效batch size = 4 * 8 = 32 learning_rate=2e-5, num_train_epochs=3, lr_scheduler_type="cosine", warmup_ratio=0.1, fp16=True, logging_steps=10, save_steps=500, ) trainer = Trainer(model=model, args=training_args, train_dataset=dataset) trainer.train()

几个参数的取值逻辑说明一下:学习率是微调中最敏感的超参数。全参数微调一般用1e-5到3e-5,LoRA微调可以用2e-5到1e-4,因为只更新少量参数所以可以稍微激进一点。batch size的选择要结合显存,显存不够就调大梯度累积步数。warmup比例0.1的含义是前10%的训练步数里学习率从0线性升到峰值,防止模型在开始时被大步长甩到不好的区域。

5.4 推理与生成:解码参数怎么调

微调完成后进入生成阶段,生成策略对效果的影响同样不可忽视。GPT模型每次输出一个Token,然后在已生成内容上继续预测,这个过程叫自回归解码。如果每次都选概率最大的Token(贪心解码),生成结果往往很单调;如果完全按概率随机采样,结果又可能混乱。实际中一般用折中方案:

output = model.generate( input_ids, max_new_tokens=512, temperature=0.7, # 控制随机性,越低越保守 top_p=0.9, # 只在累积概率前90%的Token里采样 repetition_penalty=1.05, # 对重复Token施加惩罚 do_sample=True )

温度参数是最直观的:temp=1是原始概率分布,越小越高概率Token被选中,生成越稳定;越大越随机,更有发散性。top_p是核采样,动态截断概率分布里累积概率达到p的那部分,避免采样到极端低概率但无意义的Token。repetition_penalty解决生成死循环问题,数值大于1会抑制重复出现的Token。实际使用中没有一个万能组合,需要根据任务类型调:代码生成更偏向低温度,创意写作可以适当提高温度。

6. 常见问题与排查心得

6.1 训练阶段的典型问题排查

训练中遇到的情况比较集中,先把高频问题整理成速查表:

现象可能原因排查方向
损失不下降学习率过大/过小先试2e-5-5e-5区间,观察前200步曲线
损失震荡剧烈batch size太小、数据混入噪音增大batch、清洗数据
微调后通用能力下降灾难性遗忘降低学习率、减少epoch、混合通用数据
显存溢出序列过长、batch过大梯度累积、混合精度、模型并行
生成重复内容采样参数不当、训练数据单一调repetition_penalty、检查数据多样性

损失不下降是最常见的起步问题。我一般先排除最蠢的错误:数据有没有正确配对、labels有没有右移、tokenizer有没有对齐。如果都没问题,再看学习率:学习率过大时损失前期下降很快但后面震荡甚至发散,学习率过小则损失慢吞吞降不动。还有一个容易被忽略的点:不同基座模型的词表大小不同,如果把别的模型的tokenizer硬套过来,embedding矩阵对不上,训练必然出问题。

显存溢出(OOM)基本是每个训练过模型的人都会遇到的。优先手段是梯度累积:减小per_device_train_batch_size,同时用gradient_accumulation_steps把等效batch补回来。然后是混合精度:fp16训练能省一半显存(新硬件上用bf16更稳),配合DeepSpeed的ZeRO Stage 2可以把优化器状态分散到多卡上。如果单卡还是爆,还有一个选项是开启CPU offload,不过速度代价较大,建议优先考虑减小序列长度。

6.2 微调后效果不对的排查思路

很多人微调完发现一个诡异现象:训练损失很低,但生成出来的内容质量很差。这个情况往往不是模型的问题,而是数据或评测方式的问题。

第一种情况是损失掩码配错了。前面说过,指令微调通常只计算回答部分的损失,如果掩码没加,模型会把指令部分当成也需预测的文本,训练时同步死背指令文本,表现就是训练损失低、推理效果差。检查方法很简单:打印出一个batch的labels,把-100的位置标出来,看看是不是只有回答部分是有效数字。

第二种情况是数据泄漏。训练集和验证集不做去重,模型可能在"记忆答案"而不是"学会答题"。尤其指令数据来自同一个来源时,相似的问法反复出现,模型很容易表现出虚高的准确率。应对方式是在数据处理阶段按文本相似度做去重,并且确保验证集和训练集来源隔离。

第三种情况是评估方式和生成配置不匹配。比如评测时用了temperature=1的随机采样,结果每次跑出来的指标波动极大;或者用了过强的repetition_penalty,导致模型在需要重复关键词的任务上被误伤。评测生成模型建议固定随机种子,多次采样取平均值,并且把生成参数显式记录在实验日志里。

6.3 影响范围:GPT类模型改变了什么

作为收尾前的一个讨论,我们可以聊一下GPT这条技术路线的实际影响范围。它改变的绝不仅仅是NLP领域。代码生成工具让程序员写样板代码的效率提升了一个档次;写作辅助工具让内容生产的门槛大幅降低;对话式搜索改变了人机交互方式;多模态扩展让模型能"看图说话"、理解图表、甚至辅助代码调试。更深远的影响在于,它把AI从"专才"变成了"通才"——以前一个模型只能做一个任务,现在一个模型可以处理几乎所有语言相关的任务。

对个人开发者来说,最实际的启示是:你不需要从零造大模型,但你可以基于开源的基座模型,用领域数据微调出自己的专用模型。这个流程的成本已经降到了个人可以承担的范围,而理解GPT的底层原理——从架构到训练方法——是做好这件事的前提。

最后说点我的实际体会

这几年跟大模型打交道,我最深的体会是:动手实践永远比看论文重要。很多人把GPT的原理背得很熟,但真正跑起来才发现,损失函数右移一位这种细节,在实践中能卡你一整天。所以如果你看完这篇文章想动手,我建议直接从Hugging Face上挑一个小一点的模型,先跑通微调再理解每一步,犯错的过程本身就是最好的学习。

实际操作中还有一个心得:无论模型多大,先把数据质量抓起来,再谈调参。我见过太多团队在超参数上反复较劲,最后发现是数据集里有几千条重复样本或者指令格式不统一。把数据可视化地检查一遍,比多试十组参数都管用。

最后再分享一个小技巧:在做任何微调实验之前,先固定一套评测集和评测指标,甚至先记录好基座模型在评测集上的表现分数。这样你每次改动都能看到明确的涨跌,而不是凭感觉觉得"好像变好了"。这个习惯在后续所有模型实验里都会帮你省下大量时间。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进