ChatGPT大模型:decoder-only、指令微调与RLHF工程取舍

发布时间:2026/11/3 20:17:46
ChatGPT大模型:decoder-only、指令微调与RLHF工程取舍

简介:哈尔滨工业大学自然语言处理研究所于2023年3月6日发布的ChatGPT调研报告,全文94页,面向自然语言处理学习者、算法与产品从业者以及关注大模型技术演进的研究人员,帮助读者在较短时间内建立对ChatGPT技术脉络、应用边界与产业影响的系统认知。整份资料以单个PDF文件打包,压缩包约3.54MB,篇章按主题顺序编排,便于从原理到落地逐层阅读。内容共分七章:先梳理自然语言处理发展史与ChatGPT技术脉络,继而详解Transformer三类预训练语言模型、提示学习与指令精调、思维链及基于人类反馈的强化学习等核心算法,再延伸到大模型并行训练、部署与模型压缩、相关数据集等工程议题,并讨论客服问答、智能搜索、自动化办公等应用场景及数据隐私、算法偏见、就业冲击等风险挑战。已有1512人学习,适合作为入门梳理与内部研讨的参考材料。

1. 知识的三次搬家:ChatGPT 凭什么用一句话就能调用知识

很多人把 ChatGPT 的突破归因于“参数大”,但更反直觉的一点是:它的知识表示精度其实下降了。早期知识存在关系型数据库里,字段严格、精确度极高,代价是人必须学会 SQL 这类机器语言才能取用;后来知识散落在互联网的文本、图片、视频里,人用关键词去搜索,精确度掉了一档,调用自然度升了一档;到 2018 年之后,知识被打包进模型参数,精确度再掉一档——模型会说错、会编——但调用方式变成了人类最自然的那一种:直接说人话。这条“精确度换自然度”的交换曲线,才是理解 ChatGPT 底层设计动机的起点,也是后面所有架构选择、对齐训练、评估指标的根。搞懂它,你才知道哪些问题是这套范式天生的短板,而不是某个模型的 bug。

2. 架构分岔口:为什么对话场景最终只剩 decoder-only

预训练语言模型在 2018 年前后分成三条技术路线,三条路线的差别不在参数量,而在每个 token 能看到多少上下文。这个差别决定了它们后来各自能长出什么能力,也解释了为什么最后是 decoder-only 这一支跑出了对话式通用系统。

2.1 三种骨架与它们各自擅长的任务

编码器架构用双向注意力,每个位置同时看到左右两侧,天生适合做“理解”——文本分类、序列标注、语义匹配这类把一句话压成一个判断的任务,它性价比极高。解码器架构用单向注意力,只能看左边,天生适合做“生成”——给定前文预测下一个 token。编解码架构则是前者的编码加后者的解码,中间用交叉注意力把源端信息喂给目标端,机器翻译、摘要这类“输入一段输出一段”的任务是它的主场。

三者的能力边界由训练目标锁定。双向模型在预训练时做的是“挖空填词”,它学到的是上下文约束下的词分布,没有“继续往下写”的训练信号,所以它很难自然地生成连贯长文;单向模型做的是“下一个词预测”,它必须把整段历史压进隐状态才能续写,于是被迫学会了长程依赖建模。对话的本质是“给定历史,生成下一句”,这正好是单向目标的形状。报告里提到的参数增长曲线——从 1.2 亿到 15 亿再到 1750 亿——是沿着 decoder-only 这条路走的,规模只是放大器,方向早就定了。

2.2 因果掩码:一个矩阵决定模型能看见什么

单向性在实现上不是靠循环,而是靠一个掩码矩阵。注意力计算是softmax(QK^T/√d)V,如果不加干预,每个位置都会和所有位置算相似度,那就退化成双向了。做法是把上三角区域填成负无穷,softmax 之后这些位置的权重精确等于 0。

import numpy as np def causal_mask(n): """构造 n×n 的因果掩码:位置 i 只能看到 j <= i""" upper = np.triu(np.ones((n, n)), k=1) # 严格上三角为 1 return np.where(upper == 1, -np.inf, 0.0) # 上三角置 -inf def masked_attention_weights(scores, mask): """scores: (batch, heads, n, n),mask 广播到同样形状""" scores = scores + mask # 未来位置变成 -inf scores = scores - scores.max(axis=-1, keepdims=True) # 数值稳定 e = np.exp(scores) return e / e.sum(axis=-1, keepdims=True) # 未来位置权重精确为 0

n是序列长度,k=1保证对角线保留——第 i 个位置可以看自己,这在训练时是必须的,因为要预测的正是第 i 个位置的下一词。减最大值那一步是血泪经验:exp(-inf)本身是 0 没问题,但混合了大数值后容易出现inf - inf得到 NaN,先减最大值再取指数可以规避。

这个矩阵带来的一个副作用值得注意:它把训练变成了“一次前向、n 个监督信号”。一段长度 n 的文本,双向模型只能构造一个填词目标,单向模型能构造 n 个下一词预测目标,每个位置都是一个训练样本。这就是为什么 decoder-only 在同样语料上能吃进更多梯度信号,也是它能靠海量网络文本堆出通用能力的工程前提。

2.3 从 1.2 亿到 1750 亿:规模放大的是同一种能力

参数涨上去之后出现了一个不在原计划里的现象:语境学习。模型在 1750 亿参数规模上,只要在输入里给一个任务描述、再给一两个示例,不做任何权重更新就能完成这个任务——比如输入“这句话的情感是 __”,它能直接补出“褒义”。这在中小模型上几乎观察不到,属于典型的涌现行为:能力不是线性增长的,跨过某个规模阈值才突然出现。

但这套机制也有明确的天花板。报告里对 GPT-3 的评估结论很直接:它并没有真正解决深度学习的鲁棒性差、可解释性弱、推理能力缺失,在深层语义理解和生成上离人类认知还很远。换句话说,decoder-only 加规模解决的是“知识装得下、话说得顺”,没解决“说得对、想得清”。这两件事分别要靠后面两章的两套机制去补——一套管“听话”,一套管“说人话且别乱说”。

提示:判断一个模型是不是 decoder-only,最省事的办法不是看论文,而是看它的注意力掩码是不是下三角。很多号称“自回归”的实现,在推理时开了双向上下文窗口,训练时却是单向的,这种不一致会在长文本任务上暴露出来。

3. 提示与指令:同一套参数如何被“唤醒”成不同任务

参数冻结、行为可变,这件事在传统监督学习里是不可想象的。要让一个模型同时会翻译、会写代码、会做摘要,常规做法是给每个任务训一个模型,或者至少训一个多任务头的模型。而提示学习和指令微调走的是另一条路:不改一个权重,只改输入的形状。

3.1 语境学习:不改权重也能切换任务

语境学习的机制可以这么理解:预训练阶段模型见过海量文本,其中天然包含了大量“任务描述 + 输入输出对”的结构——问答页、教程、代码注释、论坛问答都是这种形状。模型在预测下一个 token 时,被迫学会了“识别当前文本处于哪种模式,并延续这个模式”。所以当你给出一段格式正确的示例,模型实际上是在做模式续写,而不是在执行一个被显式编程的任务。

这解释了它的两个特征。第一,格式比语义更重要:示例的分隔符、标签写法、顺序一致性,对结果的影响往往大于示例内容本身;这也是为什么同一个任务换一种模板写法,效果能差出一大截。第二,示例数量和顺序敏感:给三个示例通常好过给一个,但如果第三个示例是噪声,反而会拉低整体表现——模型学到的是“这段上下文里的统计规律”,它会忠实地延续你给的规律,包括你无意中引入的偏差。

从工程角度看,语境学习把“任务适配”从训练时搬到了推理时,代价是每次调用都要携带示例,token 成本上升,且行为不够稳定。要稳定,就得动权重,这就轮到指令微调。

3.2 指令微调把“续写”掰成“应答”

预训练模型的原始行为是“续写”:你给它一个问题,它可能补出另一个问题,因为在语料里问题后面跟着问题的情况并不少见。指令微调要做的,是把这个分布往“问题后面跟答案”的方向掰。做法是用大量“指令 + 输入 + 期望输出”的三元组做监督微调,数据规模不需要很大,但覆盖面要广、指令表述要多样。

实现上有个容易翻车的细节:loss 只能算在答案部分,指令和输入部分要屏蔽掉。否则模型会分心去学“怎么把指令复述一遍”,而不是“怎么回答”。

IGNORE_INDEX = -100 def build_labels(prompt_ids, answer_ids): """prompt 部分不参与损失,只监督 answer 部分""" return [IGNORE_INDEX] * len(prompt_ids) + list(answer_ids) def shift_for_causal_lm(labels): """因果语言模型:位置 i 的输出预测位置 i+1 的标签""" return labels[1:], labels[:-1] # (labels, inputs) # 交叉熵默认会忽略 IGNORE_INDEX,只对答案 token 求平均

IGNORE_INDEX是约定俗成的哨兵值,交叉熵损失函数遇到它会跳过该位置,不计入分母。shift_for_causal_lm那一步是自回归训练的固定动作:模型在第 i 个位置输出的分布,对应的是第 i+1 个 token 的标签,忘了 shift 会得到“模型在学复读自己”的诡异曲线——训练 loss 一路下降,推理时却只会重复输入。这个坑我在早期项目里踩过一次,loss 曲线漂亮得像教科书,上线效果惨不忍睹。

指令微调的另一个作用是激发泛化。报告里给了一个很有说服力的例子:指令集里 96% 以上是英语,其余只覆盖二十来种语言,但拿一个指令集中完全没出现过的语种做翻译,模型仍然能给出正确结果。这在“一任务一模型”的微调范式下几乎不可能——小模型没见过这个语种的任务数据,就是不会。大模型因为底座里已经装了跨语言的对应关系,指令微调只是把“翻译”这个行为模式激活了。

3.3 思维链:为什么代码语料能喂出逐步推理

思维链是这套体系里最不像“训练”出来的能力。它的做法极其简单:在示例里把推理过程一步步写出来,模型就会模仿着一步步输出,而不是直接给结论。真正值得追问的是,这种“分步”能力是从哪来的。

报告给出的解释是代码预训练。ChatGPT 的底座在文本预训练之后,又用约 159G 的代码语料做了继续预训练。代码有一个文本不具备的性质:它天然是分步骤、分模块、按顺序执行的结构,一行做完一件事,函数调用有明确的输入输出契约。模型在海量代码上学“下一行该写什么”,实际上是在学“下一步该做什么”。当这种能力迁移到自然语言任务上,就表现为把一个大问题拆成若干子步骤再逐步求解。

这个迁移带来了一个可观测的异常:模型表现不再随规模平滑增长,而是在某个点上出现激增,打破了原先的缩放规律。原因不难猜——分步推理把一个需要“一步到位”的高难度预测,拆成了若干“每步都不算太难”的预测,单步正确率的乘积自然高于整体一次命中的概率。这也是为什么思维链对数学、多跳问答这类任务收益最大,对简单的信息抽取反而几乎没有增益,甚至会因为啰嗦而拖慢响应。

但思维链不是万能药。它生成的推理步骤本身也可能是错的,而且错得很流畅——模型会一本正经地推出一条看起来严密的错误链路。这一点在后面对齐阶段也没能彻底解决,只是被部分缓解。

4. RLHF:奖励模型学的不是“对错”,是排序偏好

指令微调解决“听话”,但解决不了“说得让人满意”。同样一个正确回答,可以写得像说明书,也可以写得像人话;同样一个不该回答的问题,模型可能老老实实答了。这类目标没法用交叉熵表达,因为不存在唯一正确的标签。基于人类反馈的强化学习就是为这类“没有标准答案但有优劣之分”的目标设计的。

4.1 三段式流水线:SFT → 奖励模型 → PPO

整条流水线分三步,每步的产物都是下一步的输入。第一步是有监督微调,用人工写的高质量回答把底座掰成会应答的形状,得到一个初始策略。第二步是训练奖励模型:让标注人员对同一个问题的多个回答做排序,用这些排序数据训一个打分器,它的任务是“看一眼问题和回答,给一个分”。第三步是用强化学习优化策略,让策略生成的回答在奖励模型那里拿高分,同时不能偏离初始策略太远。

这套思路不是 2022 年才有的。早在 2008 年就有人提出在强化学习框架里引入人类评价作为额外奖励信号,动机很朴素:纯环境奖励收敛慢、方向容易跑偏,人工评价可以加快收敛、压低训练成本、修正优化方向。后来有人把人工反馈信号接进演员-评论家算法去调节优势函数,再往后才有人把它和语言模型结合。这条技术线沉默了十年,直到模型规模足够大、生成质量足够高,人工排序才变得有意义——你没法对一个只会输出乱码的模型做“哪个回答更好”的排序。

4.2 排序损失与 Bradley-Terry 假设

奖励模型的训练目标不是回归一个绝对分数,而是让“被选中的回答”分数高于“被拒绝的回答”。这背后是一个成对比较的概率假设:两个回答的分数差,经过 sigmoid 之后,等于人类选择前者的概率。

import torch import torch.nn.functional as F def pairwise_rank_loss(r_chosen, r_rejected, beta=1.0): """ r_chosen / r_rejected: 奖励模型对两个回答的打分,形状 (batch,) beta: 温度系数,控制分数差的敏感度 """ diff = beta * (r_chosen - r_rejected) # -log(sigmoid(diff)) = softplus(-diff),数值上更稳 return F.softplus(-diff).mean()

用softplus而不是直接写-logsigmoid,是因为后者在diff为很大的负数时会下溢到-inf,梯度直接炸掉。beta控制的是“分数差要多明显才算赢”:调大它,模型会更激进地拉开差距,容易过拟合到标注噪声;调小它,区分度不够,强化学习阶段拿到的奖励信号太弱。实践中这个值要根据标注一致性来定,标注人员之间分歧大的任务,beta就该小一些。

这里有个容易被忽略的点:奖励模型学的是标注人员的偏好分布,不是客观正确性。标注人员倾向于给更长的回答打高分,因为长回答看起来更全面、更用心。这个偏差会被奖励模型原样学走,再在强化学习阶段被放大,最终表现为模型普遍啰嗦、爱重复、喜欢加免责声明。这不是模型“变笨了”,而是优化目标就是这么定的。

4.3 KL 惩罚:为什么不能让策略跑太远

强化学习阶段的目标函数里,除了奖励,还有一个 KL 散度项,用来约束当前策略不要偏离初始策略太远。原因很实际:奖励模型只是人类偏好的一个粗糙近似,它有盲区、有漏洞。如果放任策略去最大化奖励,它很快会找到奖励模型的漏洞——生成一堆在打分器看来得分极高、在人看来一塌糊涂的文本。这在强化学习里叫奖励劫持,是必然会发生的,不是概率事件。

KL 项的作用相当于给策略拴了根绳:你可以往高分方向走,但每一步都要付出“偏离原模型多远”的代价。系数调大,模型保守、稳定,但学不到多少新东西;系数调小,模型敢创新,但容易跑飞、语言质量崩坏。这个权衡没有普适最优解,只能针对任务调。

RLHF 之后还有一条延伸路线值得关注:把排序环节的人工标注换成模型标注。做法是先训一个模型学习人类对“无害性”的偏好打分模式,再让它代替人对结果排序。这样能大幅降低标注成本、加快迭代速度,代价是把人类的偏见换成了模型的偏见——而这个模型本身的偏见又来自它的人类训练数据。绕了一圈,问题只是被搬了个位置,没有被消掉。理解这一点很重要:对齐不是一个可以“完成”的状态,而是一个持续校准的过程。

5. 排查清单:显存、延迟和效果对不上时该看哪一层

大模型的问题很少是单点的。同一个“效果不对”,可能出在数据、训练、压缩、部署四个层面的任何一层。这一章把工程侧的账本和最常见的几类翻车场景拆开讲,方便对照排查。

5.1 并行与压缩的账本

单卡装不下模型是常态,所以训练侧有四类并行。数据并行最简单:每张卡放一份完整模型,喂不同数据,梯度做全归约。它的瓶颈是模型必须能塞进单卡,参数量一大就失效。流水并行把模型按层切成几段放到不同卡上,前一段算完把激活值传给后一段,代价是流水线气泡——卡与卡之间要等。张量并行把单层内部的矩阵乘切块分到多卡,通信量最大但延迟低,适合放在同一个节点内。专家并行则是把前馈层拆成多个专家,每个 token 只路由到其中少数几个,参数量能涨但计算量不涨。

推理侧更关心的是压缩。量化把参数精度从 32 位浮点降到 16 位甚至 8 位整数,显存直接减半甚至更多,代价是精度损失,通常需要校准集来补偿。剪枝删掉不重要的权重,粒度从单个权重到整行整列再到整个通道,粒度越粗越容易真正加速,粒度越细越难在通用硬件上兑现收益。蒸馏让一个小模型去学大模型的输出分布,学的是“软标签”而不只是答案,适合把能力搬到端侧。稀疏化则是把大量冗余连接置零,保留最重要的结构。

显存估算有个粗略公式,先用它判断方案可行性,比跑一遍再 OOM 划算得多:

def train_mem_gb(n_params, bytes_per_param=2, use_adam=True): """ n_params: 参数量(个) bytes_per_param: 权重精度,fp16 为 2,fp32 为 4 返回:训练所需显存的粗略下限(GB) """ weights = n_params * bytes_per_param grads = n_params * bytes_per_param # 梯度通常与权重同精度 if use_adam: # fp32 主权重 + 一阶动量 + 二阶动量,常按 12 字节/参数估 optim = n_params * 12 else: optim = 0 total = weights + grads + optim return total / (1024 ** 3) print(train_mem_gb(175e9)) # 约 2.9 TB 量级,单机不可能,必须切分

这个数字只算了权重、梯度和优化器状态,还没算激活值。激活值随批大小和序列长度线性增长,长上下文场景下它经常才是真正的显存杀手。所以我一般会把这个结果乘个 1.5 到 2 的系数,再拿去和集群容量对账。反过来,如果按这个公式算出来单卡能装下,但实际跑起来 OOM,那问题几乎一定在激活值或碎片上,而不是参数本身。

5.2 部署侧的三条常见误判

第一条是关于缓存的。自回归解码每生成一个 token 都要重算前面所有位置的注意力,为了避免重复计算,工程上会把历史的键值缓存下来。这个缓存的大小与批大小、序列长度、层数、隐藏维度都成正比。很多人按“参数量除以精度”估算显存,结果部署上去发现差了一大截,差额基本都在这里。序列越长、并发越高,这块开销越不能忽略。

第二条是关于批处理的。把多个请求拼成一批能显著提升吞吐,但会让首字延迟变差,因为短请求要等长请求算完。线上系统通常要做连续批处理,请求随到随进、算完即出,而不是攒够一批再发。这个细节直接决定用户体验,但在离线评测里完全看不出来。

第三条是关于精度的。量化后的模型在通用对话上几乎无损,但在数值推理、长链计算、代码生成这类任务上退化明显。判断方法不是看平均分,而是分任务看:如果只有某一类任务掉了,大概率是量化误差在某些层被放大,换个量化策略或对敏感层保留高精度就能救回来。

5.3 五条踩坑记录

现象一:同一个问题换个说法,答案从错变对。原因是对输入措辞敏感,模型在两种表述下激活了不同的模式,而采样又引入了随机性。解决办法不是反复重试碰运气,而是固定一组改写做稳定性评测,把“换个说法就翻车”的比例当成一个正式指标来跟踪,高于阈值就说明这个场景还不适合直接上线。

现象二:回答语句通顺、条理清晰,但事实是错的。原因是模型的知识以参数形式存储,没有可追溯的证据链,置信度和正确性不挂钩。解决办法是在生成之后加一层校验,或者直接改成检索增强:把事实性内容交给外部语料,模型只负责组织和表达。什么时候必须上检索,判断标准很简单——错了会不会造成实际损失。

现象三:专业术语翻译质量明显低于通用句子。原因是预训练语料以通用文本为主,专业领域数据占比极低,而且非英语语种在语料中的比例更小。解决办法是补领域数据做继续预训练,或者用术语表做约束解码。指望靠提示词解决专业术语问题,效果有限而且不稳定。

现象四:回答普遍偏长、爱重复、结尾喜欢加一句自我说明。原因是标注人员偏好更长的回答,奖励模型学走了这个偏好,强化学习阶段又把它放大了。解决办法是在奖励模型阶段就引入长度惩罚或长度归一化,从源头校正,而不是在解码阶段硬截断。

现象五:离线评测分数很好,上线后延迟高、并发上不去。原因是离线评测通常是单条串行、短输入短输出,而线上是长上下文、高并发、流式输出。解决办法是把压测条件对齐到真实分布,重点看首字延迟和长尾延迟,而不是平均吞吐。

6. 取舍:把知识放进参数,还是放进检索

这套范式最根本的取舍,其实在第 1 章那条曲线上就已经埋下了。知识放进参数,调用自然、泛化强,但更新成本极高,一次知识变更意味着重新训练或至少重新微调,而且无法给出证据。知识放进检索库,更新即时、可溯源、可审计,但调用方式退回到关键词或向量匹配,模型只负责组织语言,理解和推理的深度受限于召回的片段。

维度参数化知识检索式知识
更新成本高,需重训或微调低,改库即可
调用自然度高,直接说人话中,依赖召回质量
可溯源弱,无证据链强,可给出来源
时效性受预训练数据截止时间限制实时
长尾覆盖靠规模,稀疏知识易错靠索引,覆盖可控

实际系统里很少有纯粹的二选一。更常见的做法是分层:稳定的常识和语言能力放参数里,易变的、需要担责的事实放检索里,模型做路由和整合。判断边界的一条经验是——如果这个知识点一年内会变,或者错了要有人负责,就别放进参数。

另一组取舍在训练侧。通用大模型加提示,适合任务边界模糊、数据稀缺、需要跨任务泛化的场景;微调小模型,适合任务定义清晰、有充足标注数据、对延迟和成本敏感的场景,尤其是序列标注这类不需要生成能力的任务,硬上大模型是纯浪费。报告中提到的一个判断我很认同:在机器阅读理解这类“答案已经在上下文里”的任务上,大模型的额外知识不但没用,反而可能干扰它对上下文的忠实度。

最后一组是成本与能力的取舍。千亿参数模型的训练成本、部署难度、单次调用开销和延迟,决定了它不可能在所有场景里都划算。量化、剪枝、蒸馏、稀疏化这四条压缩路线,本质上都是在用一点精度换大量的成本,选哪条取决于你的瓶颈是显存、是延迟还是吞吐。

我自己的习惯是:任何一个要上线的生成式功能,先做一个“最小对照实验”——同一批输入,分别用大模型加提示、小模型微调、检索加模板三条路各跑一遍,把准确率、延迟、单次成本三个数字并排放。做完这张表,方案基本就定了,剩下的争论都是情绪。这个习惯是从一次翻车里学来的:当时凭直觉选了大模型,上线三个月后成本超预算四倍,而任务本身其实一个微调过的小模型就能做到九成效果。从那以后我每次做技术选型,都强制走一遍这张对照表,哪怕结论看起来显而易见。希望帮到你。

本文还有配套的精品资源,点击获取