拆解ChatGPT:从下一个词预测到Transformer与RLHF
简介:《ChatGPT研究框架.pdf》是一份面向AI产品经理、投资研究者与生成式AI入门学习者的行业专题研究文档,聚焦2023年初ChatGPT引发的技术拐点与商业化前景,帮助读者系统梳理这一波生成式AI浪潮的来龙去脉。压缩包内仅含1个PDF文件,整体约5.72MB,篇幅紧凑,便于在电脑或平板上直接阅读与检索。内容围绕市场概况、技术路径、行业进程与商业方向四条主线展开:从OpenAI的创立、微软投资与商业化授权,到AIGC从早期萌芽、沉淀积累到快速发展的阶段演进;从GPT-1、GPT-2、GPT-3到InstructGPT、ChatGPT的模型迭代,说明Transformer与人类反馈训练如何让模型输出更贴合用户意图;并讨论文本、音频、视频等跨模态应用的商用基础,以及ChatGPT Plus发布后在传媒、影视、营销等领域的落地空间。已有156人学习,适合需要快速建立ChatGPT认知框架、做行业研究或方案汇报的读者参考。
1. 反直觉的起点:一个没有"对话模块"的对话系统
把 ChatGPT 拆到最底层,你会发现一件很反直觉的事:它内部没有任何一个专门负责"回答问题"的部件。模型的全部工作,是给定前面所有的 token,输出下一个 token 的概率分布,然后采样一个出来,把它接回输入,再算下一个。所谓对话、推理、写代码,都是这个循环在足够大的参数和足够好的对齐下长出来的副产品。
搞懂这一点,后面所有的设计就都顺了:为什么需要 Transformer 这种能把整段上下文一次性摊开算的结构,为什么参数从 1.17 亿堆到 1750 亿会突然"会说话",为什么还要额外用人类反馈去微调它。这篇就沿着"预测下一个词"这条主线,把 ChatGPT 的机制一层层剥开。
2. Transformer:把"上下文"变成可并行计算的矩阵
在 Transformer 之前,处理序列的主流做法是循环网络:一个词一个词往里喂,把上一步的隐状态传给下一步。这个结构的问题不在于效果,而在于它天然是串行的——第 100 个 token 必须等第 99 个算完。序列一长,GPU 的并行度就废了,训练一个千亿级模型在那个范式下几乎不可想象。
Transformer 的核心改动是:既然我最终要的是"每个位置对所有位置的依赖关系",那为什么不一次性全算出来?
2.1 自注意力到底在算什么
把一句话看成矩阵 $X \in \mathbb{R}^{n \times d}$,n 是 token 数,d 是维度。自注意力做的是三次线性投影,得到 Q、K、V,然后用 Q 和 K 的点积衡量"第 i 个位置该关注第 j 个位置多少",归一化后作为权重,去加权求和 V。整个过程没有一步依赖上一步的结果,全都是矩阵乘法,GPU 最喜欢的形态。
import numpy as np def scaled_dot_product_attention(Q, K, V, mask=None): """ Q: (n, d_k) 查询矩阵,每一行代表一个位置"我想找什么" K: (n, d_k) 键矩阵,每一行代表一个位置"我能提供什么" V: (n, d_v) 值矩阵,每一行是真正被加权聚合的内容 """ d_k = Q.shape[-1] # 点积后除以 sqrt(d_k):防止维度变大时点积方差膨胀,把 softmax 推进饱和区 scores = Q @ K.T / np.sqrt(d_k) # (n, n) 注意力打分矩阵 if mask is not None: # 因果掩码:上三角置为 -inf,保证位置 i 只能看到 <= i 的内容 scores = scores + mask # 沿最后一维做 softmax,每一行变成一个概率分布,和为 1 weights = np.exp(scores - scores.max(axis=-1, keepdims=True)) weights = weights / weights.sum(axis=-1, keepdims=True) return weights @ V # (n, d_v) 每个位置的新表示这段代码里有两个细节值得停一下。第一个是除以 $\sqrt{d_k}$:如果 d_k 是 64,两个随机向量的点积量级大概在 8 左右,维度越大点积越容易取到大值,softmax 之后就会变成一个接近 one-hot 的分布,梯度几乎消失,训练直接卡死。这个缩放不是可选项,是让训练能跑起来的前提。
第二个是掩码。语言模型的训练目标是预测下一个词,如果让位置 i 看到位置 i+1 的内容,那它直接抄答案就行了,什么也学不到。所以注意力矩阵必须加上一个上三角为负无穷的掩码,把"未来"遮掉。这个操作叫因果掩码,是 decoder-only 结构和 encoder 结构最本质的分野之一。
多头注意力则是在这个基础上再切一刀:把 d 维拆成 h 份,每份独立算一次注意力,最后拼回来。直觉上,一个头可能在学习语法依存,另一个头在学习指代关系,还有一个头在数位置距离。这些分工不是人工指定的,是训练自己分化出来的。
2.2 并行化付出的两个代价
天下没有白拿的并行。丢掉循环结构之后,模型瞬间失去了两样东西,都得靠补丁打回来。
第一个是顺序信息。在 RNN 里,"猫追狗"和"狗追猫"天然不同,因为输入顺序不同。但在自注意力里,所有 token 是同时喂进去的,模型看到的是一堆无序的向量集合,把句子打乱它根本不知道。解决办法是位置编码:给每个位置生成一个向量,加到 token 的嵌入上。早期用的是正弦余弦函数,好处是不用训练、能外推到没见过的长度;后来的主流做法改成了可学习的位置嵌入或者旋转位置编码,代价是长度上限被钉死在训练时见过的最大长度附近。
第二个是计算量。自注意力的复杂度是 $O(n^2 d)$,n 是序列长度。上下文窗口从 2K 扩到 32K,注意力矩阵的元素数涨了 256 倍。这也是为什么长上下文一直是工程上的硬骨头——不是模型学不会,是显存和算力在按平方收费。常见的缓解思路包括滑动窗口注意力、稀疏注意力、分块计算,本质上都是在赌"远处的大部分 token 其实不需要两两交互"。
提示:看一个模型宣称支持多长上下文时,要分清是"训练时就吃了这么长"还是"推理时靠插值硬撑"。后者在超长输入上的效果衰减往往非常明显,尤其是需要精确检索中段信息时。
2.3 残差与归一化:让上百层能叠起来
单层注意力本身很浅,真正让 GPT 能堆到几十上百层的是残差连接和层归一化这套组合。每个子层(注意力、前馈网络)的输出都会和输入相加,再送进下一层。这个加法看着平淡,作用是让梯度有一条从损失函数直通底层的短路,避免在几十层反向传播后衰减到零。
层归一化的位置也有讲究。早期是"先加残差再归一化"(Post-LN),训练深层模型时非常不稳定,得靠精细的学习率预热才能收敛。后来普遍改成 Pre-LN,即归一化放在子层输入之前,训练稳定性显著改善,代价是表达能力略有损失,所以有些实现会在最后再补一次归一化。这个改动听起来琐碎,但它决定了你能不能把一个几十层的模型训到不发散——血泪经验都藏在这种地方。
另一个不显眼但影响巨大的部件是前馈网络。每个注意力层后面都跟着一个两层全连接,中间维度通常是 d 的 4 倍。整个模型里大约三分之二的参数都在这里。有一种解释是这部分充当了某种键值记忆,把训练语料里的模式存了下来,注意力负责检索,前馈负责存储。这个说法不算严格证明,但对理解"参数里到底存了什么"很有帮助。
3. 规模定律:为什么参数量堆到某个点就"突然会了"
从 GPT-1 的 1.17 亿参数,到 GPT-2 的 15 亿,再到 GPT-3 的 1750 亿,三代模型的架构差别其实没那么大,真正的变量是规模和数据。这条路线之所以能走通,是因为研究者发现了一件在深度学习里相当罕见的事:语言模型的损失随参数量、数据量、算力的增长,呈现出非常平滑的幂律下降,在对数坐标下几乎是一条直线。
这条经验规律的意义在于它可预测。你不需要真的训完一个万亿模型才知道它大概什么水平,用小模型的结果就能外推。这让"要不要继续堆规模"从赌博变成了一道算术题。
3.1 三代模型各自解决的是什么问题
GPT-1 的阶段,主流做法还是每个任务训练一个专用模型。GPT-1 的思路是先用大量无标注文本做无监督预训练,再在具体任务上做有监督微调。它证明了"预训练 + 微调"这个范式能带来泛化,但本质上还是要为每个任务准备标注数据。
GPT-2 把参数量提到 15 亿,做了一件更大胆的事:不再为下游任务微调,直接零样本去做阅读理解、摘要、翻译。效果不算完美,但它展示了一个方向——语言模型本身可能已经包含了完成任务所需的知识,只是需要一个合适的触发方式。它在生成上的表现也确实亮眼,续写、编故事、角色扮演都能来,同时也顺带把"生成假新闻、钓鱼邮件"这类风险摆到了台面上。
GPT-3 把参数推到 1750 亿,同时把交互范式从"微调"换成了"在提示里给几个例子"。这就是上下文学习:你在输入里塞两三个示例,模型不改一个参数,就能按这个格式完成新任务。这个能力的工程意义极大——它意味着部署一个新任务的边际成本从"准备数据 + 训练 + 上线"降到了"改一段提示词"。
3.2 预训练目标:一个朴素到近乎无聊的交叉熵
不管模型多大,训练目标始终是同一个:给定前 t-1 个 token,最大化第 t 个 token 的似然。把所有位置加起来,就是最小化交叉熵。
import torch import torch.nn.functional as F def next_token_loss(logits, labels, ignore_index=-100): """ logits: (batch, seq_len, vocab_size) 模型对每个位置输出的未归一化分数 labels: (batch, seq_len) 每个位置的真实下一个 token ignore_index: 填充位和提示部分的占位,不计入损失 """ # 把 batch 和 seq 两维摊平,交叉熵按 token 逐个计算 vocab_size = logits.size(-1) logits = logits.reshape(-1, vocab_size) labels = labels.reshape(-1) # reduction='mean' 时只会对非 ignore_index 的位置求平均 loss = F.cross_entropy(logits, labels, ignore_index=ignore_index) return loss这段代码朴素得让人怀疑它凭什么撑起一个千亿产业,但关键在于规模。词汇表通常在几万到十几万量级,每一个 token 位置都要在这么多候选里做一次 softmax,一次前向就要处理 batch × seq_len × vocab_size 个数值。语料是万亿 token 级别,模型要在上面反复扫。这个损失函数被优化到极致的时候,模型被迫学会了语法、事实、推理链,甚至一些世界知识的隐式表示——因为不学会这些,下一个词的预测精度就上不去。
这里有个容易踩的认知偏差:预训练阶段模型学的是"文本长什么样",不是"什么是对的"。它会把语料里的偏见、错误、过时信息一并吸收,因为它追求的是似然而不是真值。这个特点在后面的对齐阶段会变成一个必须处理的问题,但不可能被彻底解决。
3.3 少样本能力从哪来
上下文学习这件事曾经让很多人困惑:模型权重没变,只是在输入里多给了几个例子,凭什么表现就能提升一大截?
一个比较被接受的解释是,预训练语料里本身就充满了各种"格式"。问答对、代码注释和实现、教程里的示例、论坛里的追问和解答——模型见过太多"前面几个例子、后面跟一个待填空"的结构,以至于它把"按照上文给出的模式续写"这件事本身学成了一个能力。你在提示里给例子,本质上是在激活这条已经存在的路径,把它引到你要的任务上。
这也解释了为什么提示词的写法影响这么大,以及为什么它有时候表现得很玄学。模型对格式的敏感度远超直觉:换一个分隔符、把示例顺序调一下、把答案的标签换一种表述,结果可能差出十几个点。这不是模型不稳定,是它确实在依赖这些表面特征做模式匹配。
注意:上下文学习的能力有明确的容量上限。示例塞得太多会挤占上下文窗口,而且超过一定数量后收益迅速递减。实践里通常三到八个示例就够了,再多是在浪费 token 预算。
4. RLHF:把"会续写"调成"会回答"
到 GPT-3 为止,模型已经很能写了,但它是一个续写器,不是一个助手。你问它一个问题,它可能给你续出十个类似的问题,因为在训练语料里,问题后面经常跟着更多问题。它也不知道什么叫"不知道",编造起来流畅得毫无破绽。
这就是对齐要解决的问题:模型的能力已经够了,但它的行为目标和人类的使用意图错位了。单纯靠加大预训练解决不了,因为"有用、诚实、无害"这些东西不在下一个词的统计规律里。
4.1 三阶段流水线
整套流程拆成三步,每一步解决上一步留下的问题。
第一步是有监督微调。人工写一批高质量的问答对,用标准的交叉熵在上面继续训练。这一步的作用是把模型的输出分布从"任意文本"收窄到"对话格式的回答",让它学会用户提问、助手作答这个模式。数据量不需要很大,几万条量级就能显著改变输出风格,但质量必须高,标注里混进敷衍的回答,模型会照单全收。
第二步是训练奖励模型。让人对同一个问题的多个回答做排序,然后训一个模型去拟合这些偏好。这一步的产物是一个能给"回答好坏"打分的标量函数,它是后面强化学习的裁判。
第三步是用强化学习去优化主模型。生成回答、让奖励模型打分、根据分数更新参数,同时用一个 KL 惩罚项拉住模型,别让它跑得太远。
4.2 奖励模型:把偏好压成一个数
奖励模型的训练数据是成对比较:同一个输入 x,回答 A 和回答 B,人标注哪个更好。目标是让模型给更好那个打的分更高。
import torch.nn.functional as F def reward_model_loss(r_chosen, r_rejected): """ r_chosen: 模型对"人类更喜欢的回答"打的分,形状 (batch,) r_rejected: 模型对"被拒绝的回答"打的分,形状 (batch,) """ # 差值经过 sigmoid 后就是"chosen 优于 rejected"的预测概率 # 取负对数即 Bradley-Terry 偏好模型的负似然 diff = r_chosen - r_rejected loss = -F.logsigmoid(diff).mean() return loss这个损失函数的形式值得琢磨。它只看两个分数的差,不看绝对值。这意味着奖励模型的输出是一个相对量,尺度是自由的——今天整体加 10 分,所有排序结论都不变。这个性质很重要,因为它让训练对分数的绝对漂移不敏感,但也埋了个坑:如果只拿奖励模型的绝对分数当质量指标,很容易被误导。
另一个关键点是,奖励模型是从人类标注里学出来的,它必然继承了标注者的偏好、标注标准的不一致、以及标注时的疲劳。它不是一个客观的真理函数,只是人类偏好的一个有噪近似。后面的强化学习会把这个近似推到极限,包括它的缺陷。
4.3 PPO 与 KL 刹车
强化学习阶段用的是 PPO(近端策略优化)。它的核心思想是:不要一次更新太多,把新策略和旧策略的比值限制在一个小区间里,超出就截断梯度。
import torch def ppo_clipped_loss(logp_new, logp_old, advantages, eps=0.2): """ logp_new: 当前策略对生成的每个 token 的对数概率 logp_old: 采样时那版策略的对数概率(固定不动) advantages: 每个 token 的优势值,来自奖励模型的分数 eps: 裁剪范围,常用 0.1~0.2 """ # 新旧策略的概率比,等于 1 说明策略没变 ratio = torch.exp(logp_new - logp_old) surr1 = ratio * advantages # 比值超出 [1-eps, 1+eps] 就截断,防止单次更新步子太大 surr2 = torch.clamp(ratio, 1.0 - eps, 1.0 + eps) * advantages # 取两者较小值:悲观估计,只有真的变好才给奖励 return -torch.min(surr1, surr2).mean()为什么必须裁剪?因为策略梯度是on-policy的,用旧策略采的数据去评估新策略,一旦两者差距过大,估计就完全失真,训练会突然崩掉。裁剪相当于给每次更新装了个限位器。
但光有裁剪还不够。真正让这套流程能稳定跑的是 KL 惩罚:在奖励里减掉一项 $\beta \cdot \mathrm{KL}(\pi_{\text{new}} | \pi_{\text{ref}})$,其中 ref 是 SFT 之后的模型。这一项的作用是拉住模型,别为了讨好奖励模型把语言能力本身搞坏——这种现象叫奖励劫持,模型会找到奖励模型的漏洞,输出一些得分很高但人看着莫名其妙的东西。KL 项就是那个后悔药。
$\beta$ 的取值是个典型的权衡:太小,模型放飞自我,输出退化;太大,模型几乎不动,对齐等于没做。工程上一般会做自适应调整,KL 偏离目标值就自动调 β。
提示:这套流程里最脆弱的一环往往不是算法,是标注质量。奖励模型学到什么,最终模型就变成什么。偏好数据里混进"更长即更好"的偏差,最后训出来的模型一定会变得啰嗦。
5. 避坑与排查:落地阶段最容易翻车的五个点
理论讲完,真正让人头疼的是接进业务之后的各种异常。下面这五条都是在实际调试里反复出现的问题。
5.1 输出时好时坏,同一提示两次结果差异巨大
现象:同一个输入,连续调十次,偶尔给出精准答案,偶尔完全跑偏,看不出规律。
原因:解码阶段默认带随机性。模型输出的是概率分布,采样时温度参数控制分布的陡峭程度。温度接近 1 时,低概率的 token 也有机会被选中,长文本里累积几次偏航,最终结果就面目全非。
解决:需要确定性的场景把温度降到 0.1 以下,或者干脆用贪心解码。需要多样性的场景(头脑风暴、文案生成)才把温度开到 0.8 以上。同时留意 top-p 参数,它按累积概率截断候选集,和温度是叠加生效的,两个一起调容易调过头。
5.2 多轮对话到后面就"忘了"前面说的话
现象:聊了十几轮之后,模型开始重复提问,或者把早期设定的约束丢掉。
原因:模型本身是无状态的。每一轮所谓的"记忆",都是把历史消息重新拼进上下文再发一遍。上下文窗口有硬上限,超了就必须截断。大多数默认实现是保留最近若干条,早期的系统提示和关键约束最先被丢掉。
解决:不要把关键约束放在第一轮然后指望它一直在。稳妥的做法是每次请求都把系统提示和核心约束重新带上,对历史做摘要压缩而不是简单截断。如果业务对长程一致性要求高,就得在外围维护一个结构化的状态,而不是全押在上下文里。
5.3 编造事实时语气比说真话还自信
现象:问一个不存在的人物或文献,模型给出完整的生平、年份、期刊名,格式完美,内容全是假的。
原因:这是结构性的。模型的训练目标是生成"看起来合理的下一个 token",不是检索真相。它没有内建的"我不知道"分支,除非在训练中被明确教过要弃权。当输入指向一个它没见过的实体时,它会用最符合语言模式的填充方式补完,而符合模式的东西读起来就是可信的。
解决:把模型输出当草稿而不是事实源。涉及具体数字、引用、法规条款的,一律走外部检索或人工核验。在提示里显式要求"不确定时说明不确定",能降低但不能消除编造率。检索增强是更根本的办法——把事实供给它,而不是指望它记住。
5.4 稍长一点的输入处理质量断崖式下降
现象:短文问答很准,一旦输入几万字,中间段落的信息就像没被读到一样。
原因:注意力在长序列上的分布并不均匀,大量实测显示模型对开头和结尾的信息利用率明显高于中段。加上位置编码在超出训练长度后的外推能力有限,长输入的表示质量本身就在衰减。
解决:把关键信息放在输入的靠前或靠后位置。超长文档先做分段摘要再合并,而不是一次性塞进去。如果必须做精确检索,用向量检索先召回相关片段,再喂给模型。
5.5 成本随对话轮数线性甚至超线性上涨
现象:单次调用很便宜,真实业务跑起来账单远超预期。
原因:计费按 token 算,而多轮对话每次都要把历史重发一遍。第 n 轮请求的输入长度约等于前 n-1 轮的总和,累计成本是平方级增长,不是线性。
解决:给历史设硬上限,超过就做摘要替换。把固定的长系统提示做缓存(部分接口对缓存部分有折扣)。对不需要完整上下文的场景,把任务拆成无状态调用,别为了省事把所有历史一股脑带上。
6. 设计取舍:为什么这条路线能赢,以及它的边界在哪
回头看整条链路,最值得琢磨的不是某个模块有多精巧,而是几个关键取舍。
| 取舍点 | 选择了什么 | 放弃了什么 | 换来的结果 |
|---|---|---|---|
| 架构 | decoder-only + 自注意力 | 循环结构的顺序归纳偏置 | 极致并行,能喂下万亿 token |
| 训练目标 | 单一的下一个词预测 | 任务特定的监督信号 | 一个模型覆盖多任务,边际成本极低 |
| 能力获得 | 堆规模 + 上下文学习 | 参数高效、推理便宜 | 零样本泛化,部署新任务不用重训 |
| 行为对齐 | 人类偏好 + 强化学习 | 可解释性、可控性 | 输出符合意图,但决策过程成了黑匣子 |
| 知识来源 | 参数内隐记忆 | 可追溯、可更新 | 响应快,但时效性和准确性无法保证 |
这里每一行的右侧都不是免费拿到的。自注意力换来了并行,代价是平方级的算力消耗;单一目标换来了通用性,代价是模型学到的东西不可精确控制;参数化知识换来了低延迟,代价是幻觉和知识过期。
我认为最根本的一条是:这条路线的成功,建立在"预测下一个词"这个目标足够通用上。它不像传统做法那样把任务拆开分别优化,而是用一个目标把所有能力绑在一起逼出来。这在工程上是极大的简化,但在可控性上是极大的让步。你没法单独关掉模型的某个能力,也没法精确知道它为什么给出某个答案。
验证一个模型是否真的理解了你的场景,别只看它在标准问题上的表现。构造一批边界样本——输入为空、输入超长、输入里混入互相矛盾的指令、输入指向不存在的实体——看它怎么处理。这些地方的表现比正常样例更能说明问题。
从那以后,我每次接入一个模型服务,都会先强制走一遍这五类边界测试,再谈效果优化。因为绝大多数翻车都不是模型不行,是我假设了它会在某种方式下工作,而它没有。希望这篇拆解能帮你在下次遇到"它怎么又不听话了"的时候,知道该往哪一层去找原因。
本文还有配套的精品资源,点击获取