简介:《ChatGPT大模型技术发展与应用》PDF是一篇面向自然语言处理学习者、大模型入门研究者及AI应用开发者的技术综述,帮助读者系统建立从语言模型到ChatGPT的演进认知。文章以深度学习、语言模型、语义表示和预训练技术为主线,梳理N-gram统计方法向神经网络语言模型过渡、机器翻译研究催生Transformer,以及TF-IDF、pLSA、LDA到Word2Vec、ELMo、BERT、GPT-2的语义表示与预训练发展路径。文章进一步分析GPT-3展现的生成能力及其输出不可控、知识谬误、逻辑推理不足等幻觉问题,并说明ChatGPT如何通过指令学习、监督微调与基于人类反馈的强化学习实现与人类期望对齐。压缩包仅含1个PDF文件,大小约2.15MB,轻量便于查阅与归档;已有526人学习。读者可从中获取大模型技术发展脉络、关键模型对比、对齐策略,以及聊天机器人、问答系统、文本生成、翻译等应用方向,适合作为课程阅读、论文写作与技术选型参考。
1. 从"接话"到"办事":ChatGPT 的能力不是堆参数堆出来的
很多人第一次被 ChatGPT 震住时,直觉归因是"参数够大"。把大模型技术发展与应用这条线完整摊开看,参数只是入场券。真正让它从"会续写"变成"能按指令办事"的,是语言模型、语义表示、预训练三条技术线各自还清了债,最后在同一个架构上合流:N-gram 的稀疏、RNN 的梯度连乘、静态词向量的多义歧义、GPT-3 的幻觉不可控,每一笔债都对应一次结构级的取舍。读懂这些取舍,才能看懂为什么自注意力能替掉循环、为什么 BERT 走双向而 GPT 走单向、为什么监督微调加人类反馈的强化学习能把一台"续写机器"掰成对话助手。这份综述把演进脉络、关键机制和实现细节串在了一条线上,值得逐节拆开看。
2. 从 N-gram 到 RNN 语言模型:马尔科夫假设欠下的三笔债怎么还
语言模型这件事,说到底只问一个问题:给定前面的词,下一个词的概率分布是什么。整条演进史,就是不断更换"怎么算这个分布"的载体。先是用计数,再用神经网络,再用带门的循环网络,每一次换代都不是为了更时髦,而是上一代在某个维度上已经无路可走。
2.1 N-gram 的马尔科夫假设与它的稀疏陷阱
统计语言模型的做法极其朴素:一句话的概率按条件概率连乘展开,P(S)=P(W₁)P(W₂|W₁)…P(W_k|W₁…W_{k-1})。这个式子本身没问题,问题是条件项太多,参数量随句子长度指数膨胀,语料再大也统计不过来。N-gram 用一个假设切断了这个膨胀:当前词只跟前 n-1 个词有关,更远的历史直接丢弃。
在这个假设下,每个条件概率用极大似然估计就能算出来,分子是 n 个连续词片断在语料里的出现次数,分母是前 n-1 个词片断的出现次数:
from collections import Counter def train_ngram(corpus, n): # 按 n 切分连续词片断统计分子,按 n-1 统计分母 ngram, context = Counter(), Counter() for sent in corpus: tokens = ["<s>"] * (n - 1) + sent + ["</s>"] for i in range(len(tokens) - n + 1): gram = tuple(tokens[i:i + n]) ngram[gram] += 1 context[gram[:-1]] += 1 # 只保留作为条件的前 n-1 个词 return ngram, context def cond_prob(ngram, context, vocab_size, hist, word, alpha=1.0): # alpha=1.0 即拉普拉斯平滑:分子分母同步加常数,避免 0 概率 num = ngram[tuple(hist) + (word,)] + alpha den = context[tuple(hist)] + alpha * vocab_size return num / denn是窗口长度,alpha是平滑强度,vocab_size决定分母要补偿多少。这段代码暴露了 N-gram 的三笔债。
第一笔是稀疏。n 越大,能看到的上下文越多,预测本该更准,但任何一个没在语料里出现过的组合,极大似然估计出来的概率就是 0,而句子概率是连乘,一个 0 就让整句概率归零。语料里没出现不等于语言里不合法,这是统计视角和语言视角的根本错位。
第二笔是参数空间。词表大小 V,n 元模型要覆盖 Vⁿ 种组合,三元、四元就已经吃满内存,再往上根本存不下。第三笔是泛化。N-gram 眼里"猫吃鱼"和"狗吃鱼"是两个毫不相干的计数事件,它没有任何 mechanism 去表达"猫和狗都是动物"这种相似性。
平滑是对第一笔债的补丁:拉普拉斯平滑无脑加一,Good-Turing 按出现次数折减低频计数,Kneser-Ney 用接续计数修正低频词的分布,Interpolation 把不同阶的模型加权混合。这些方法都很精巧,但它们修的是"计数为 0"这个症状,修不了"模型不知道词与词相似"这个病因。
提示:判断一个语言模型实现是否有隐藏 bug,先看它对未登录组合的输出是不是精确的 0。是 0 说明平滑没生效,后面的困惑度指标全部不可信。
2.2 NNLM 的投影层:词向量第一次成为副产品
2003 年有研究提出用前馈神经网络替代计数,思路是:把前 n-1 个词映射成连续向量、拼起来、过一层非线性隐层、再输出整个词表上的分布。这个模型被称作 NNLM,它解决稀疏问题的方式很聪明——不统计词组合,而是学习"如何组合词向量"。
关键在投影层。每个词的 one-hot 向量乘一个 V×m 的投影矩阵 C,得到一个 m 维稠密向量。因为所有词共用同一个 C,相似词在训练中会被推向相近的向量,从未在语料里共现过的组合也能靠向量运算得到一个合理分数,稀疏问题被绕过去了。
import torch, torch.nn as nn class NNLM(nn.Module): def __init__(self, vocab_size, embed_dim, hidden_dim, window): super().__init__() # 投影矩阵 C:V x m,等价于 Embedding,但语义上是"查表降维" self.C = nn.Embedding(vocab_size, embed_dim) self.hidden = nn.Linear((window - 1) * embed_dim, hidden_dim) self.act = nn.Tanh() self.out = nn.Linear(hidden_dim, vocab_size) def forward(self, ctx): # ctx: [B, n-1] 前 n-1 个词的 id e = self.C(ctx) # [B, n-1, m] h = self.act(self.hidden(e.flatten(1))) # [B, h],窗口内词向量拼接后进隐层 return self.out(h) # [B, V] 未归一化得分,配 softmax 用embed_dim就是论文里的 m,通常取 50 到 300;hidden_dim是隐层宽度;window是 n。参数量从 Vⁿ 降到 V·m + m·(n-1)·h + h·V,稀疏问题基本消失,词向量作为副产物第一次出现——这是整个领域的一个重要转折,词表示从此可以不依赖人工特征。
NNLM 的局限也写死在结构里:前馈网络只能吃固定窗口,n-1 个词之外的上下文一概看不见。窗口开大,参数和计算线性上涨;窗口开小,长距离依赖丢掉。要真正建模"整段历史",必须换掉这个前馈结构。
2.3 梯度连乘:爆炸与消失是同一件事的两面
RNN 语言模型的思路是把隐状态当成记忆载体,当前时刻的隐状态由上一时刻隐状态和当前输入共同决定,循环连接让历史信息可以一直往后传。理论上它能看无限长的上文,实际训练中却撞上了反向传播的数学性质。
反向传播沿时间展开后,梯度里会出现大量雅可比矩阵的连乘积。每个因子的谱范数如果略大于 1,连乘若干步就指数爆炸;略小于 1,就指数衰减到几乎为零。这跟"模型记性好不好"没关系,是连乘这个运算本身的性质:长期依赖的梯度要么大到溢出,要么小到消失。
爆炸还能救。梯度裁剪在每一步算出全局梯度范数,超过阈值就等比缩小,让参数更新不至于一脚踩空:
def clip_grad_norm(params, max_norm): total = 0.0 for p in params: if p.grad is not None: total += p.grad.data.norm(2).item() ** 2 total = total ** 0.5 # 全局 L2 范数 if total > max_norm: scale = max_norm / (total + 1e-6) for p in params: if p.grad is not None: p.grad.data.mul_(scale) # 等比缩放,方向不变 return totalmax_norm是阈值,常见取 1.0 到 5.0;返回值是裁剪前的范数,用来监控训练是否长期处于爆炸状态。裁剪是个必要但不充分的手段:它让训练不崩,但不解决"梯度传不到远处"的问题。
注意:梯度裁剪只能止血,不能治病。如果裁剪后 loss 依然不降,别继续调
max_norm,该怀疑的是循环结构本身撑不住这个序列长度。
2.4 LSTM 三门与 GRU 两门在控什么
要治消失,得在结构上给梯度开一条不被连乘反复折磨的通路。1997 年提出的 LSTM 引入了细胞状态和三个门:输入门控制新信息写入多少,遗忘门控制旧状态保留多少,输出门控制隐状态往外吐多少。
class LSTMCell(nn.Module): def __init__(self, input_dim, hidden_dim): super().__init__() self.wx = nn.Linear(input_dim, 4 * hidden_dim) # 一次算四组门,减少调用开销 self.wh = nn.Linear(hidden_dim, 4 * hidden_dim) def forward(self, x, state): h_prev, c_prev = state gates = self.wx(x) + self.wh(h_prev) # [B, 4H] i, f, o, g = gates.chunk(4, dim=-1) # 输入门/遗忘门/输出门/候选值 i, f, o = torch.sigmoid(i), torch.sigmoid(f), torch.sigmoid(o) c = f * c_prev + i * torch.tanh(g) # 加法通路:梯度回传直接乘 f h = o * torch.tanh(c) return h, (h, c)input_dim是输入特征维度,hidden_dim是隐状态宽度,四个门共享同一次线性变换再切分。真正有意思的是c = f * c_prev + i * tanh(g)这一行:细胞状态的更新是逐元素乘加,不经过 tanh 压缩,反向传播时 ∂c_t/∂c_{t-1} 恰好就是遗忘门 f 本身。f 接近 1,梯度就几乎原样传回去,连乘的衰减被门控值直接调制了。
记住一点:门控缓解梯度消失,靠的不是"记住了更多信息",而是"给梯度开了一条可控衰减的直通路"。遗忘门学出来的值是数据驱动的,模型自己决定哪些历史值得留。这也解释了为什么长期依赖不是万能药——如果任务本身要求遗忘,学出来的 f 就会偏小。
GRU 是同一思路的压缩版,把三个门减成两个:更新门决定新旧状态怎么混合,重置门决定计算候选值时要不要看历史。参数量少三分之一左右,小数据上更容易训,长序列上表达能力略逊于 LSTM。二者谁更好取决于任务,没有普适结论,这也是"玄学"说法的一个来源。
2.5 Seq2Seq 与 Attention 的第一次正面回答
有了能处理序列的 RNN 单元,机器翻译这类"序列进、序列出"的任务就有了骨架:编码器把源句压成一个语义向量,解码器从这个向量出发逐词生成目标句。这套 Encoder-Decoder 在 2014 年前后被两篇工作定型,其中一篇做到了完全端到端的训练,不再依赖规则和统计对齐组件,成了后续神经翻译模型的起点。
但骨架有个结构性瓶颈:不管源句多长,编码器只能往解码器递一个固定长度的向量。句子一长,信息压缩必然有损,解码第一步就吃亏。2015 年有工作提出在编码器和解码器之间加注意力:解码每一步都回头看一眼编码器的所有时刻输出,按当前状态算出一组权重再加权求和,而不是只吃最后一个向量。
def additive_attention(query, keys, values): # query: [B, H] 解码器当前状态;keys/values: [B, L, H] 编码器各时刻输出 q = query.unsqueeze(1) # [B, 1, H] scores = torch.tanh(q + keys) # 简化版加性打分,实际带两组线性变换 weights = torch.softmax(scores, dim=1) # [B, L, 1],各时刻的权重 return (weights * values).sum(dim=1), weights # 加权求和得到上下文向量L是源句长度,H是隐状态维度,返回的权重矩阵可以直接可视化,看到"生成这个词时模型在盯源句哪个位置",对齐关系是学出来的而非人标的。这一步的意义不止是翻译更准,它证明了"按需取用历史"比"把历史压成一个向量"更有效。
紧接着还有一项工作用卷积替掉 RNN 做 Seq2Seq,靠可扩张卷积并行捕获不同范围的上下文,在翻译任务上效果更好。这件事传递出一个信号:序列建模不必绑定循环结构。既然卷积能行,那更彻底的并行化方案也就不远了。
3. 自注意力与 Transformer:并行计算凭什么顺手解决长依赖
RNN 的根本约束不在门控设计,而在计算路径:第 t 步必须先算完第 t-1 步,序列再长也只能串行。这意味着两件事同时受制——长距离依赖的梯度路径长度随距离线性增长,训练吞吐又被序列长度卡死。要同时解掉这两个,必须放弃"按时间步推进"这个前提。
3.1 Q/K/V 与缩放点积:一次矩阵乘法换掉时间步串行
Transformer 的做法是把序列里所有位置一次性摆到矩阵里,让每个位置直接和所有位置交互。每个位置的向量被线性映射成三份:Query 用来"提问",Key 用来"被匹配",Value 承载"实际内容"。打分方式是 Query 和所有 Key 做点积,softmax 归一化成权重,再对 Value 加权求和。
$$\mathrm{Attention}(Q,K,V)=\mathrm{softmax}!\left(\frac{QK^{\top}}{\sqrt{d_k}}\right)V$$
import math, torch def scaled_dot_product_attention(Q, K, V, mask=None): # Q: [B, Lq, dk] K, V: [B, Lk, dk] scores = Q @ K.transpose(-2, -1) / math.sqrt(Q.size(-1)) # [B, Lq, Lk] if mask is not None: scores = scores.masked_fill(mask == 0, float("-inf")) # 屏蔽位置权重归零 weights = torch.softmax(scores, dim=-1) return weights @ V, weightsd_k是每个头的维度,mask是 0/1 矩阵,-inf经过 softmax 后精确变成 0。这段代码的形状账值得算清楚:QKᵀ 得到一个 Lq×Lk 的矩阵,也就是说每个位置与所有位置的相关性被显式算了出来并存下来。
为什么除以 √d_k?因为点积的方差随维度线性增长,维度一大,打分值的绝对值就大,softmax 会迅速饱和成接近 one-hot 的分布,梯度趋近于零,训练推不动。除以 √d_k 把方差拉回 1 附近,让 softmax 待在有梯度的区间。这是典型的"看起来像技巧,实际是数值稳定性刚需"。
代价也很明确:注意力矩阵是 L×L 的,时间和显存都是序列长度的平方级。这是 Transformer 用并行换来的账单,后来所有长上下文优化本质上都在还这笔账。
3.2 多头注意力:把一次注意力拆成多个子空间
单头注意力只能学一种"相关性模式",但语言里的关联是多类型的——语法依存、指代、语义搭配各算各的。多头注意力的做法是把 d_model 维切成 h 份,每份独立算一次注意力,最后拼回去再过一个输出投影。
class MultiHeadAttention(nn.Module): def __init__(self, d_model, n_head): super().__init__() assert d_model % n_head == 0 self.d_head = d_model // n_head self.n_head = n_head self.wq, self.wk = nn.Linear(d_model, d_model), nn.Linear(d_model, d_model) self.wv, self.wo = nn.Linear(d_model, d_model), nn.Linear(d_model, d_model) def forward(self, x, mask=None): B, L, _ = x.shape split = lambda t: t.view(B, L, self.n_head, self.d_head).transpose(1, 2) q, k, v = split(self.wq(x)), split(self.wk(x)), split(self.wv(x)) out, _ = scaled_dot_product_attention(q, k, v, mask) out = out.transpose(1, 2).reshape(B, L, -1) # 各头拼接回 [B, L, d_model] return self.wo(out) # 输出投影做跨头信息融合d_model是模型宽度,n_head是头数,约束是必须整除。总计算量和单头注意力基本持平,因为每头维度缩到 1/h,注意力矩阵的规模没变,但表达被拆成了多个独立子空间。这就是为什么多头几乎是免费的多样性——不是把模型变大,而是把同一份预算切成不同视角。
提示:
n_head不是越大越好。头数过多会让每个头的维度太小,打分退化成噪声。常见做法是让d_head保持在 64 左右。
3.3 位置编码:并行之后怎么补回顺序
自注意力有一个容易被忽略的性质:它是对位置的置换等变的。把输入序列打乱顺序,输出只是跟着打乱,模型完全无法区分"猫追狗"和"狗追猫"。原因是打分只依赖向量内容,跟位置索引无关。
所以位置信息必须显式注入。最经典的做法是正弦位置编码,用不同频率的正弦余弦函数为每个位置生成一个独一无二的向量,直接加到词向量上:
def sinusoidal_position(max_len, d_model): pe = torch.zeros(max_len, d_model) pos = torch.arange(0, max_len).unsqueeze(1).float() div = torch.exp(torch.arange(0, d_model, 2).float() * (-math.log(10000.0) / d_model)) pe[:, 0::2] = torch.sin(pos * div) # 偶数维用 sin pe[:, 1::2] = torch.cos(pos * div) # 奇数维用 cos return pe # [max_len, d_model],与词向量逐元素相加max_len是支持的最大长度,d_model与词向量维度一致才能相加。这种编码的好处是任何位置的编码都能由线性变换表达相对位移,长度外推有一定基础;坏处是遇到超出训练长度的位置,泛化能力有限。后来出现的可学习位置嵌入、相对位置偏置、旋转位置编码,都是在"怎么让模型知道谁在前谁在后"这个问题上换解法。
3.4 Masked Self-attention 与 Encoder/Decoder 的分工
Transformer 保留了 Encoder-Decoder 骨架,两侧的注意力用法不同。编码器是双向的,每个位置可以看整个序列;解码器必须自回归,生成第 t 个词时只能看前 t-1 个词,否则训练时就把答案抄进去了。
实现方式是在注意力打分矩阵上加一个上三角掩码,把"未来位置"的分数打成负无穷:
def causal_mask(L): # 下三角可见、上三角屏蔽,广播到 [1, 1, L, L] 供 batch 和多头共用 return torch.tril(torch.ones(L, L)).view(1, 1, L, L)这个 mask 的设计动机很直接:训练阶段整句一起喂进去并行算,靠掩码模拟自回归的因果约束;推理阶段逐词生成,两者的行为必须一致,否则训练和推理分布错位,模型立刻翻车。这种"训练并行、推理串行"的组合,是 Transformer 能用满 GPU 又保持生成能力的核心设计。
顺带说一句,这也是理解 GPT 系列为什么选 Decoder-only 的关键:单向注意力加掩码天然就是语言模型,不需要额外的预训练任务设计。
4. 语义表示与预训练:从 TF-IDF 到 BERT、GPT-2 的范式收敛
Transformer 提供了骨架,但骨架本身不会说话。模型要从语料里学到"语义",还得靠表示方法和训练范式。这条线走了三步:统计方法把词变成稀疏计数,神经网络把词变成稠密向量,预训练把整段文本变成上下文相关的表示。
4.1 TF-IDF、LSA、pLSA、LDA:统计语义的天花板在哪
最早期信息检索系统要给文档一个数学表示,才好算查询与文档的相关度。TF-IDF 的思路是:词在本文档里出现得多(词频高)、在整个语料里出现得少(逆文档频率高),就说明它有区分度。计算简单,效果不差,但本质是词袋——两个同义词在它眼里毫无关系,"苹果"这个词的多义也无从区分。
为了捕捉词背后的隐主题,LSA 把文档-词矩阵做奇异值分解,降到低维空间,同现频繁的词在这个空间里靠得近。问题在于它是个纯代数操作,分解出来的维度没有可解释的含义,也没法直接生成新文档。
pLSA 换成概率视角,假设文档以一定概率选主题、主题以一定概率生成词,把文档、主题、词连成一条生成链。解释性好了,但作为生成模型它有硬伤:新文档的主题分布没有先验可依据,只能靠间接手段凑,容易过拟合。LDA 补上最后一块——给主题分布加 Dirichlet 先验。
LDA 里α是文档主题分布的先验参数,β是主题词分布的先验参数,K是主题数(人工指定),θ是文档的主题分布,φ是主题的词分布,Z是采样出的主题,W是采样出的词。生成新文档时先从先验采样主题分布,再从主题分布采样词,整个链条是完备的,所以它避免了 pLSA 的过拟合。这几种方法的对比可以摊成一张表:
| 方法 | 核心机制 | 主要局限 |
|---|---|---|
| TF-IDF | 词频 × 逆文档频率 | 稀疏、同义多义无法处理 |
| LSA | 文档-词矩阵 SVD 降维 | 主题维度不可解释 |
| pLSA | 潜在主题的生成模型 | 新文档无法确定主题分布,易过拟合 |
| LDA | 主题分布加 Dirichlet 先验 | 主题数需人工设定,仍是词袋假设 |
这张表说明了一件事:统计语义的天花板不在数学技巧,而在"词作为离散符号"这个前提。只要词还是独立的计数单位,语义相似性就只能靠共现间接推断。
4.2 Word2Vec:CBOW 与 Skip-Gram 的取舍
把词变成连续向量,是第一次真正意义上的突破。Word2Vec 提供两种结构:CBOW 用上下文预测中心词,Skip-Gram 用中心词预测上下文。看似只是方向相反,取舍差别很大。
CBOW 把多个上下文词向量求和或平均再预测目标词,训练快,对高频词的表示更平滑。Skip-Gram 反过来,每个上下文词都是一个独立预测任务,低频词能获得更多梯度更新,语义细节保留更好。语料小选前者,要精细词义选后者,这是常见的经验判断。
class SkipGramNS(nn.Module): def __init__(self, vocab_size, dim): super().__init__() self.in_emb = nn.Embedding(vocab_size, dim) # 中心词向量 self.out_emb = nn.Embedding(vocab_size, dim) # 上下文词向量,与输入不共享 def forward(self, center, context, negatives): v = self.in_emb(center) # [B, d] u_pos = self.out_emb(context) # [B, d] u_neg = self.out_emb(negatives) # [B, K, d] pos = torch.log(torch.sigmoid((v * u_pos).sum(-1)) + 1e-9) neg = torch.log(torch.sigmoid(-(v.unsqueeze(1) * u_neg).sum(-1)) + 1e-9).sum(-1) return -(pos + neg).mean() # <p> <a href="https://download.csdn.net/download/weixin_42764105/88699521" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>