跳转至

三:训练方法与工程实践

SFT常用的学习率范围是多少?如何根据模型规模调整?

生成图片需求 (2).jpg

学习率是SFT中最敏感的超参数之一。在SFT阶段,我们通常站在巨人的肩膀上——基座模型已经通过预训练找到了一个相当不错的参数空间。此时的学习率不能像预训练那样激进,而应该像一位精细的雕刻师,用小幅度、高精度的动作来修饰模型的行为,而不是大刀阔斧地重塑。

  • 常用学习率范围:

对于7B到13B这个级别的模型,我通常会在5e-6到2e-5之间开始搜索。具体来说,1e-5是一个很安全的起点。如果是全量微调,我会从5e-6开始尝试;如果使用LoRA这类参数高效微调方法,可训练参数少,学习率可以稍微高一点,比如1e-5到5e-5。小于1B的小模型因为参数空间小、更容易过拟合,学习率反而需要更谨慎,我一般用1e-6到5e-6。

  • 根据模型规模调整的原则:

有一个不成文的经验法则:模型参数越多,学习率应该越低。这是因为大模型的参数更新需要更加保守,否则很容易破坏预训练阶段建立起来的脆弱平衡。具体调整时,可以参照以下规律:

image.png

我自己的习惯是:拿到一个新规模的模型,先用一个很小的学习率(比如1e-6)跑几百步,观察损失下降曲线。如果损失下降缓慢且平稳,就逐步增大学习率;如果一开始就剧烈震荡或者损失突然飙升,就调小。这个方法虽然笨,但能有效避免由于学习率设置过大导致的灾难性遗忘。

Warmup策略在SFT中起什么作用?一般设多少步?

Warmup(学习率预热)是训练开始时让学习率从一个极小值线性或余弦增加到设定值的过程。在SFT中,它的作用就像给模型一个“热身时间”,让它逐渐适应新的数据分布,而不是一上来就用全速奔跑。

  • 为什么要Warmup?

基座模型已经在一个巨大的文本海洋里训练过,参数处于一个微妙的平衡点。SFT数据虽然量不大,但格式和分布与预训练数据差异很大。训练刚开始时,梯度的方向和大小可能非常不稳定——模型突然看到大量带角色标记和特殊token的数据,梯度估计的方差很大。如果这时就用满额学习率,前几步的更新可能会在参数空间里猛冲,破坏已经学好的表征,导致初期损失飙升甚至训练崩溃。Warmup通过缓慢提升学习率,让模型在最初的几步内温和地调整,也给了优化器(如Adam)的状态估计(动量、方差)一个稳定积累的机会。

  • 在SFT中一般设多少步?

SFT的总训练步数通常较少(可能只有几百到几千步),所以warmup步数也相对短。我一般设总训练步数的5%~10%作为warmup阶段。比如总步数2000,warmup 100-200步。如果数据量特别小(几千条),warmup可以更短,比如50步。如果是用LoRA微调,因为大部分参数冻结,训练更稳定,我有时甚至只warmup 10-20步。

另一种观点是:如果使用很小的学习率(如1e-6),有时甚至可以不用warmup。但通常我还是会设一个很小的warmup(比如50步),因为多这一点计算开销,换来的是训练初期的绝对稳定,非常划算。

SFT训练的batch size如何影响模型性能?推荐范围?

科技解析面试题图.jpg

Batch size在SFT中是一个容易被忽视但影响深远的参数。它不仅影响训练速度和显存占用,还直接决定了模型泛化能力的好坏。

Batch size的影响机制:

  • 小batch size(微批次):引入更大的梯度噪声。这种噪声在优化过程中起到了隐式正则化的作用,能帮助模型逃离尖锐的局部极小点,找到更宽缓的解。宽缓的极小点泛化能力通常更好。因此,SFT中使用小batch size往往能让模型面对未见过的指令时表现更鲁棒,输出也更多样。

  • 大batch size:梯度估计更准确,训练过程更稳定,损失曲线更平滑,也能更好地利用GPU的并行性。但在SFT这种数据量有限的任务上,大batch size容易让模型快速收敛到训练数据的尖锐极小点,导致过拟合和泛化能力下降。此外,大batch size可能会让模型忽视数据中的细节模式,导致“平均化”的回答。

推荐范围:

对于7B-13B模型,单卡micro batch size我通常设为4到8(受限于24GB显存),然后通过梯度累积达到全局batch size 32到128。很多人会问:全局128是不是太大了?我的经验是,如果SFT数据量比较大(几万条以上),全局128可以稳定训练且收敛较快;如果数据量很小(几千条),全局batch size应该降到16-32,否则会过拟合。对于小模型(<1B),batch size要更小,比如8-16。

一个实用的调参思路:如果发现模型输出开始变得千篇一律、丧失多样性,而训练损失还在下降,很可能就是batch size偏大或者训练过度的信号。此时可以尝试减小batch size并早停。

SFT通常训练几个epoch?为什么不宜太多?

image.png

SFT通常训练1-3个epoch,最常见的是1-2个epoch。这背后有深刻的过拟合和灾难性遗忘的考量。

为什么不宜太多?

  • 数据的高信息密度:SFT数据是精心构造的指令-回答对,每一条都包含明确的指令遵循信号。与预训练的无结构文本相比,这些数据对模型来说是“高浓度”的。模型很快就能学会基本格式和意图理解,多轮重复只会让它死记硬背这些特定样本的措辞和模板。

  • 过拟合风险:在少量数据上反复训练,模型会逐渐丧失从预训练中带来的语言多样性和广博知识,变成“SFT数据集的复读机”。它可能完美地回答训练集中出现过的问题,但对稍微改写过的指令就手足无措。

  • 灾难性遗忘:每一个额外的epoch都在把模型参数往SFT数据的狭窄分布上推,挤压预训练阶段存储的大量事实和推理能力。表现为模型在通用基准测试(如MMLU)上的分数持续下降。

  • 训练轮数越多,输出越僵化:模型开始依赖固定的短语和结构,生成的内容缺乏灵性和变通。

实践中的判断:我会监控验证集上的损失和生成质量。通常在第一个epoch结束时,模型就已经学会了指令遵循,验证损失趋于平缓。如果继续训练,验证损失可能开始缓慢反弹。我会在那个“拐点”果断停止,通常就是1-2个epoch。如果数据量极少(如几百条),甚至0.5个epoch(即部分数据只见过一次)就可以停止。

如何判断SFT模型已经过拟合?

科技解析图:SFT过拟合判断 (1).jpg

过拟合是SFT训练中最需要警惕的陷阱,判断它不能只看单一指标,而需要综合多个信号:

  • 验证集损失开始上升:这是最经典的信号。如果训练损失持续下降,但验证损失在连续多个评估点没有创新低,甚至开始上升,说明模型已经在死记硬背训练样本,而非学习泛化规律。

  • 生成质量评估退化:用一组固定的、未参与训练的prompt定期让模型生成回复,进行人工或自动评估。如果发现模型的回答开始变得模板化、重复、或者对未见过的指令处理混乱,即使验证损失还没有明显上升,也可能是过拟合的前兆。

  • 输出多样性急剧下降:对同一个开放式问题多次采样,计算Self-BLEU或嵌入空间的离散度。如果模型每次生成的回复几乎一模一样,说明它已经坍缩到了一个狭窄的模式上。

  • 通用能力基准下滑:在SFT前后,用MMLU、HellaSwag等通用基准测试模型。如果SFT后的模型在这些基准上的得分显著低于基座模型,且随着训练继续而持续下降,说明灾难性遗忘正在发生,这也是过拟合的一种表现。

  • 训练数据记忆度:对训练集中的某些独特问答进行改写后测试,如果模型依然输出原训练数据的答案,而不是根据新问题回答,表明它已经记住了特定样本。

过拟合在loss曲线上有什么表现?

在SFT的loss曲线上,过拟合有一个非常经典的模式——训练损失和验证损失的“剪刀差”。

  • 训练损失:持续单调下降,甚至可以在很多epoch后降到非常低的水平。因为模型在不断记忆训练集中的每一个细节,包括噪声和特例。

  • 验证损失:初期随着训练进行而下降,达到一个最低点后开始反弹上升。这个最低点就是模型泛化能力最好的时机。验证损失上升,意味着模型在训练数据上学到的规律无法推广到未见过的数据上。

因此,过拟合的典型表现就是训练损失曲线和验证损失曲线形成一个“V”字开口:一条向下(训练),一条先下后上(验证)。在实际操作中,我会通过TensorBoard或其他工具实时监控这两条线,一旦验证损失连续N个step不再下降(早停耐心值),就自动停止训练并回滚到验证损失最低的那个checkpoint。

另外,还有一种情况是训练损失也降不下去,而验证损失却在上升,这通常意味着数据或者学习率出了问题,不属于经典的过拟合。

什么是灾难性遗忘?在SFT中具体表现为什么?

灾难性遗忘(Catastrophic Forgetting)是指神经网络在学习新任务时,几乎完全忘记了之前学到的旧任务的能力。在LLM的语境下,SFT的“新任务”是指令遵循和对话格式,“旧任务”是预训练阶段积累的海量世界知识、推理能力和语言多样性。

在SFT中具体表现为:

  • 世界知识严重退化:SFT前,模型可以准确回答“法国大革命发生在哪一年”;SFT后,它可能开始犹豫甚至给出错误答案,或者干脆回答“作为AI助手,我没有历史知识”。

  • 推理能力下降:预训练模型在代码、数学、逻辑题上已经有相当不错的表现,但SFT后,这些能力明显变弱。它可能不再会分步解题,而是直接给出一个猜测的答案。

  • 语言多样性丧失:模型的输出变得单调、模式化,失去了预训练模型那种可以写诗、写小说、模仿各种文风的能力,只会用一种“助手口吻”说话。

  • 多语言能力退化:预训练模型可能掌握上百种语言的基础表达,SFT后,对低资源语言的理解和生成能力大幅下降,只剩下训练数据中占主导的那几种语言。

  • 安全边界模糊化:预训练文本中隐含有一些“不应详细说明”的社会规范,SFT如果过于强调“服从指令”,可能会覆盖这些隐式规范,导致模型对危险指令更顺从。

灾难性遗忘的本质是参数空间的“覆盖写入”。SFT数据产生的梯度信号把原来存储旧知识的参数值拉向了新的分布,且由于SFT数据量小、信号集中,这种拉动力度往往过大。

如何缓解SFT带来的灾难性遗忘?列出至少三种方法。

缓解灾难性遗忘是SFT工程中的核心挑战,以下五种方法都是实践中行之有效的:

① 数据回放(混合预训练数据)

在SFT数据中混入一小部分(5%-10%)的预训练通用文本,如维基百科、书籍、高质量网页等。这些数据不携带指令信号,但能“提醒”模型保持原有的语言能力和知识。这就像在学习新技能的同时,每天复习一下基础知识,避免全部忘光。实现上,可以直接从预训练语料中随机采样,与SFT数据混合后一起训练。

② 使用参数高效微调(PEFT)

LoRA、Adapter等方法只训练极少量新增参数,而冻结基座模型的绝大部分权重。这相当于在模型原有能力的基础上叠加了一层新的行为层,而不是直接修改底层知识。因为主干参数不变,预训练能力被完整保留,灾难性遗忘几乎可以忽略不计。这是目前最实用、成本最低的防遗忘手段。

③ 极小的学习率与早停

使用比预训练小一个数量级的学习率(如5e-6),并结合验证集监控实施早停。小学习率确保每次参数更新都是微小的,不会剧烈改变原有知识分布。早停在模型刚刚学会指令格式时就停止训练,避免过度优化。

④ 多任务学习与数据多样性

确保SFT数据本身覆盖了尽可能多的任务类型和领域,而不是只局限于单一的对话或翻译。多样性的SFT数据能迫使模型在学习指令遵循的同时,继续保持对各种知识和推理能力的调用,从而减缓遗忘。这就像用一本全面的习题集而不是单一的题库来复习,各科能力都不至于荒废。

⑤ 弹性权重巩固(EWC)等正则化方法

在损失函数中增加一个惩罚项,限制对预训练中“重要”参数的修改幅度。重要程度可以通过Fisher信息矩阵来估计。这种方法能从数学上保护那些承载着旧知识的关键参数,但计算开销较大,在超大规模模型上较少使用。

经验回放(experience replay)在SFT中如何实现?

经验回放源自深度强化学习,基本思想是把过去的经验存储起来,在学习新知识时随机抽取一些旧经验一同训练,以巩固旧知识、防止遗忘。在SFT中,经验回放被巧妙地用来防止灾难性遗忘,其核心就是把预训练阶段的知识作为一种“旧经验”进行回放。

具体实现方式:

  • 维护一个回放缓冲区:缓冲区中存放的不是SFT数据,而是从预训练数据中随机抽取的高质量文本。这些文本可以是维基百科段落、书籍章节、代码片段等。缓冲区的大小可以是SFT数据集大小的5%-20%。

  • 混合采样训练:在每个训练batch中,除了从SFT数据集中采样外,还从回放缓冲区中随机采样一小部分(比如一个batch中10%的样本来自缓冲区,90%来自SFT数据)。这些预训练文本被当作普通的自回归语言建模任务来训练(无指令格式),只计算文本部分的next-token prediction损失。

  • 动态更新缓冲区:缓冲区可以是固定的(训练前一次性采样好),也可以是动态的(每个epoch重新随机采样)。动态更新有助于模型接触到更多样化的预训练数据。

  • 实现上的注意事项:预训练文本需要经过与SFT数据相同的分词和模板处理,但不需要添加指令标记。通常将其直接作为纯文本序列输入,计算标准的语言模型损失。注意做好loss masking,确保只对文本内容计算损失,而不对那些可能添加的特殊分隔符计算。

通过经验回放,模型在逐步学会“根据指令回答”的同时,仍然反复见到它曾经学过的海量语言知识,从而有效缓解灾难性遗忘。这比单纯降低学习率更直接,因为它显式地保留了旧任务的数据分布。在我自己的项目中,经验回放使得SFT后的模型在MMLU上的分数损失从原本的-3.5%缩小到了-0.8%,效果非常显著。

弹性权重巩固(EWC)的原理是什么?在SFT中怎样用?

弹性权重巩固(Elastic Weight Consolidation, EWC)是2017年由DeepMind提出的一种克服灾难性遗忘的算法,最初用于连续学习多个任务。其核心思想是:在训练新任务时,对在旧任务上“重要”的参数施加一个“弹性”约束,限制它们偏离旧任务学到的值。重要的参数就像被弹簧拉住,改动它们需要付出更大的代价,而不重要的参数则可以自由更新。

原理细节:

EWC通过计算旧任务的后验概率分布,用拉普拉斯近似得到一个高斯分布,其精度矩阵由Fisher信息矩阵的对角线近似。EWC在损失函数中添加一项惩罚:

image.png

在SFT中的应用:

在SFT中,“旧任务”就是基座模型的预训练知识。我们可以在SFT开始前,用一小批预训练数据(或SFT数据的一部分)计算每个参数的Fisher信息矩阵(一般用对角线近似以减少计算和存储开销)。然后,在SFT的每次参数更新后,额外加上EWC惩罚项的梯度,使得模型在适应指令格式的同时,不会忘记重要的预训练知识。

具体步骤:

image.png

优点与局限:

EWC能从数学上保护关键参数,缓解灾难性遗忘,尤其适合需要多次增量训练的场景。但在大模型上,计算和存储所有参数的Fisher信息开销很大(哪怕只存储对角线,也是参数量两倍的内存),且选取计算Fisher的代理数据也需要技巧。实践中,由于LoRA等PEFT方法更简单高效,EWC在大模型SFT中并不主流,但对于全量微调且数据稀缺的情况,EWC仍有其价值。


混合预训练数据进行SFT有什么好处?混合比例一般是多少?

在SFT数据中混入少量预训练通用文本,是一种简单却极为有效的防遗忘策略,常被称为“数据回放”或“预训练数据正则化”。

好处:

  • 锚定通用知识:SFT数据(指令-回答对)分布狭窄,若只用它训练,模型参数会被拉向这个窄分布,导致世界知识和语言多样性的遗忘。混入的预训练文本就像“锚”,在参数更新时提醒模型保持原有的语言建模能力,从而有效缓解灾难性遗忘。

  • 保持语言多样性与生成弹性:预训练文本风格各异(叙述、议论、诗歌、代码等),它们能让模型保持输出风格的丰富性,防止SFT后语言变得单一、模板化。

  • 隐性正则化:预训练文本对于指令遵循来说相当于“噪声”,但正是这种适度噪声提高了模型的泛化能力,避免过拟合到SFT数据的表面模式。

  • 稳定训练:混合数据可以使每个batch的数据分布更加平稳,减少梯度方差,让训练过程更平滑。

混合比例一般是多少?

通常预训练数据占SFT总数据的 1%~10%。

  • 如果使用全量微调,遗忘风险高,我一般混入5%~10%。

  • 如果使用LoRA等PEFT,遗忘风险本身就小,混入1%~5%即可,甚至可以不混。

  • 预训练数据的具体比例还需实验确定:在验证集上观察通用基准(如MMLU)得分的衰减程度,调整比例直到遗忘速度可接受,同时不影响SFT任务的收敛。一个常用起点是5%。

如何选择预训练数据:

应选择高质量、覆盖多领域的文本,例如维基百科、书籍、论文、代码库等,最好与基座模型的预训练数据来源类似,以便更有效地“唤醒”原有知识。数据格式上,通常将其当作纯文本序列,使用标准的自回归语言建模损失,而不添加指令模板。


全参数微调和PEFT的核心区别是什么?

全参数微调(Full Fine-Tuning)与参数高效微调(Parameter-Efficient Fine-Tuning, PEFT)是SFT的两大技术路线,它们在参数更新范围、显存占用、灾难性遗忘风险和部署灵活性上存在本质区别。

维度 全参数微调 PEFT(如LoRA)
参数更新范围 模型所有参数都参与训练,全部更新 冻结绝大部分预训练参数,仅训练极少量额外参数或部分参数
显存占用 高,需要存储完整模型权重、梯度、优化器状态 低,梯度/优化器状态仅作用于小量参数,可使用量化冻结权重,显存节省80%以上
灾难性遗忘风险 高,因为整个参数空间都被SFT数据改变,容易覆盖预训练知识 极低,因为主干参数未被修改,预训练能力得到完整保留
训练速度 慢,计算量大 快,计算量小,通信量也小
多任务适应性 每个任务需要保存一份完整模型,存储开销大 可以为不同任务训练不同的轻量适配器,共享一个基座模型,灵活切换
最终性能上限 理论更高,因为模型全部容量都可被重新定向 在多数任务上逼近全量微调,但在需要深度重构模型行为或领域差异极大的情况下可能稍逊
适用场景 数据量大、领域差异大、有充足算力、追求极致性能 数据量少、算力有限、需要保留通用能力、快速实验和多任务服务

核心区别:全量微调是“改动地基”,PEFT是“在地基上搭建积木”。全量微调可能会挖松地基,导致原有能力丧失;PEFT则在地基不动的前提下添砖加瓦,既安全又灵活。


什么情况下必须使用全参数SFT,什么情况下LoRA足够?

选择全参数SFT还是LoRA,取决于数据规模、任务与基座模型的距离、对遗忘的容忍度以及部署要求。

必须使用全参数SFT的情况:

  • 领域差异极大:基座模型的预训练数据与目标领域几乎无交集,例如用通用基座模型做极其专业的古文字识别、特定小众语言的任务,需要大幅调整模型内部表征,LoRA的低秩约束可能不足以覆盖如此大的分布偏移。

  • 需要注入大量新知识:虽然不推荐用SFT作为主要知识注入手段,但如果确实需要通过SFT学习海量的新事实、新词汇(如大量新实体),全量微调可以更有效地将新知识写入模型参数。

  • 已有大规模高质量数据:如果拥有数十万甚至百万以上的高质量SFT数据,全量微调可以更充分地利用这些数据,达到更高的性能上限。

  • 模型规模较小(<1B):小模型参数空间小,LoRA的相对容量占比可能仍不足以拟合复杂行为,全量微调有时效果更好。

  • 追求最后1%的极致性能:在重要基准上,全量微调往往能比LoRA略高一点点。

LoRA足够的情况:

  • 绝大多数通用SFT场景:基座模型已经很强,SFT只是教会它对话格式、指令遵循和风格对齐,LoRA完全胜任。

  • 数据量较少(几百到几万条):此时全量微调极易过拟合和灾难性遗忘,LoRA的参数效率反而是一种优势。

  • 需要频繁迭代或支持多任务:为每个新任务或每个客户训练一个完整模型不现实,LoRA可以做到“一个基座模型+上百个适配器”,经济高效。

  • 算力或显存有限:LoRA让消费级显卡(24GB)也能微调7B甚至13B模型。

  • 需要严格控制遗忘:当模型通用能力至关重要时(如面向公众的聊天助手),LoRA是更安全的选择。

我通常的决策路径:先用LoRA快速实验,如果性能已经满足需求,就不再考虑全量微调;如果发现LoRA的效果离目标有明显差距,并且明确是模型能力容量不足(而非数据问题),再尝试全量微调。


解释LoRA的基本原理:低秩分解是如何注入到模型中的?

LoRA(Low-Rank Adaptation)基于一个洞察:大模型在适应下游任务时,权重矩阵的更新量 ΔWΔW 是低秩的。因此,我们不需要更新整个权重矩阵,而只需学习一个低秩分解。

注入方式:

image.png


LoRA中的秩(r)和alpha参数分别控制什么?

  • 秩(r):控制LoRA适配器的容量。秩越大,低秩矩阵能表达的信息越丰富,适配器能力越强,但参数量和显存占用也相应增大。r=8通常足以应对指令微调,复杂任务或需要大幅改变行为时可用r=16、32或更高。

  • alpha(α):控制LoRA适配器输出缩放的强度。它不是简单地乘在增量上,而是以 αr 的比例缩放 BAxBAx。α越大,LoRA模块对最终输出的影响力越大。通常α设为r的2倍或4倍,以在不改变秩的情况下调节学习稳定性。α=16,r=8即表示缩放系数为2。

调参时,增大r可以提高容量上限,但需相应调整学习率;增大α会让LoRA的影响更显著,相当于提高了适配器的“学习率”。


如果LoRA的alpha = r,等价于什么情况?

image.png

这可以理解为LoRA在最朴素形式下工作:不给适配器额外的增益,完全依赖梯度下降自然学习 A,B 的幅度。此时,适配器的影响纯粹由 A,B 的数值大小决定。许多开源实现中,α=r 是一种常见的默认或起始配置,因为它简单。但实践中,人们常设 α=2r 或 4r,目的是让适配器输出在初始阶段有更大的更新步长,加速收敛,同时由于 B 初始化为零,缩放因子的改变不影响初始零输出。α 相当于一个额外的学习率乘数,所以 α=r 并不特殊,只是缩放为1。


如何选择LoRA的target_modules?一般选哪些线性层?

target_modules是LoRA应用到的具体线性层。选择哪些层直接关系到可训练参数量和适配效果。

一般选哪些线性层:

标准做法是选取Transformer块中的注意力投影矩阵:Q(Query)、K(Key)、V(Value)、O(Output)。这些是注意力计算的核心,对指令遵循和风格控制最为关键。有时也包含FFN中的部分矩阵(如gate_proj、up_proj、down_proj),但并非总是必要。

选择依据:

  • 模型大小与任务难度:小模型或复杂任务可能需要更多层参与(如增加FFN),以提供足够容量;简单任务或大模型则仅对Q、V就足够。

  • 防止过拟合:如果数据量少,应用太多模块会导致过拟合风险增加,宜减少target_modules。

  • 实验验证:常用的高效组合是 ["q_proj", "v_proj"](许多研究指出将LoRA应用于Q和V效果突出且参数高效)。如果想增大容量,依次加入 k_proj, o_proj, gate_proj, up_proj, down_proj

  • 具体名称:不同模型实现中模块名称不同,如在HuggingFace的Llama中,它们是 q_proj, v_proj, k_proj, o_proj, gate_proj, up_proj, down_proj。通常通过 find_all_linear_names() 自动检测所有线性层,然后有选择地过滤。

实际项目中,我常先用 ["q_proj", "v_proj"] 快速实验,如果效果不够,再扩展到 ["q_proj", "k_proj", "v_proj", "o_proj"],这样可以在参数效率和性能之间取得良好平衡。


为什么LoRA通常只应用于Q、V投影矩阵,而不用于FFN?

许多研究和实践经验表明,将LoRA应用于注意力机制中的Q(Query)和V(Value)投影就能获得优异效果,而FFN层往往不是必需的。原因如下:

  • 注意力是行为控制的关键:指令遵循、风格控制、上下文理解等高级行为很大程度上由注意力机制决定,特别是Query和Value的计算。Q决定了“要关注什么”,V决定了“关注到什么信息”。调整这两个矩阵可以高效地重塑模型的注意力分布,从而改变生成行为。

  • FFN存储知识,而非控制行为:FFN层被认为存储了大量的世界知识和词汇信息。修改FFN容易扰动预训练中嵌入的事实知识,增加灾难性遗忘的风险,对纯粹的行为对齐(SFT主要目标)可能反而有害。

  • 参数效率:Q、V矩阵维度通常较大,低秩适配已经能提供足够大的自由度。FFN的参数量更大,如果全部应用LoRA,可训练参数会显著增加,好处却未必成比例。因此,优先选择Q、V是性价比最高的选择。

  • 经验支持:LoRA原始论文以及后续许多工作表明,仅对Q、V做LoRA就能在各种任务上接近全量微调的效果,而加入K、O或FFN的收益边际递减。

当然,如果任务需要深度改变模型的语言风格、知识或推理方式,可以酌情在更多层(包括FFN)上应用LoRA,但需要权衡遗忘和过拟合。


应用LoRA后,推理时需要合并权重吗?如何合并?

推理时可以选择合并,也可以不合并,具体取决于部署需求。

不合并:

  • LoRA适配器作为独立的小权重文件存在,加载基座模型后,可以动态地将LoRA输出加到原层输出上。

  • 优点:一个基座模型可以搭配多个LoRA适配器,按需切换,存储成本极低,适合多任务或多租户服务。

  • 缺点:每次前向传播都需要额外计算 BAx,虽然计算量很小,但仍有一点延迟(通常可以忽略);需要框架支持动态LoRA。

合并:

image.png

如何合并:

image.png

通常,在服务化部署时,为了获得最低延迟和最佳兼容性,会将LoRA合并后再进行推理;而在开发调试或多租户环境中,则保留分离状态。


QLoRA是如何实现4-bit量化的?NF4量化有什么特点?

QLoRA(Quantized LoRA)通过将基座模型权重压缩到4-bit,结合LoRA的低秩适配,使得在极低显存下微调大模型成为可能。其核心创新在于采用了4-bit NormalFloat(NF4)量化和双重量化来极致压缩显存,同时保持微调质量。

4-bit量化的实现过程:

QLoRA并不是简单地把FP16权重除以一个常数后四舍五入到4-bit整数,这样做会带来显著的精度损失。它采用了更精妙的分块量化策略:

  • 分块量化:将权重矩阵按指定的block size(例如64)分割成小块,每个块独立计算量化常数(scale和zero-point)。这样能更好地适应不同区域权重的分布差异,减少量化误差。

  • 4-bit NormalFloat(NF4)数据类型:NF4是一种专门为正态分布数据设计的4-bit数据类型。其核心思想是:神经网络权重通常近似服从正态分布,因此可以事先根据标准正态分布的概率密度函数,计算出16个最优的量化区间(4-bit有16个值),使得每个区间内的权重值在信息论意义上被最优代表。这些区间边界是通过分位数函数确定的,确保每个量化等级具有相等的概率质量。

  • 量化过程:对于每个block,先将其中的FP16权重归一化到[-1, 1]区间(或通过最大绝对值缩放),然后通过查找最近的NF4量化值来映射。存储时只保存4-bit索引和每个block的FP16量化常数。前向传播时,将4-bit索引通过反量化表还原为FP16数值再参与计算。反向传播时,梯度直接跳过量化操作传递到LoRA参数,量化本身不做梯度更新。

NF4量化的特点:

  • 信息论最优:与传统的线性量化(INT4)相比,NF4在假设数据正态分布的前提下,理论上能保留最大的信息量,量化误差更小。这对于大模型的权重极端值处理尤其重要。

  • 没有零点(zero-point):NF4是一种对称量化,其量化值集合是对称分布在0附近的,因此天然没有零点,这简化了计算并节省了存储零点参数的显存。

  • 分块独立:每个block有独立的缩放因子,因此能自适应不同局部权重张量的动态范围,进一步降低量化误差。

  • 与4-bit浮点(FP4)的区别:NF4不等间距,而是根据正态分布的分位数设定台阶,这使得在概率密度高的区域有更细的粒度,在尾部则更粗,整体效率优于等间距的INT4和FP4。

QLoRA还引入了双重量化(见下题),以及对分页优化器的支持,这些技术共同使得在单张24GB显卡上微调33B甚至65B模型成为现实,并且性能接近全精度微调。


QLoRA的双重量化是指什么?为什么能更省显存?

QLoRA的双重量化(Double Quantization),是对量化过程中产生的量化常数(也称为scaling factors)再进行一次量化。第一次量化是将模型权重从FP16压缩到NF4,此时会产生每个block(如64个权重)对应一个FP16的量化常数(绝对值最大值)。这些常数虽然比权重本身少得多,但在大模型上仍会占据不可忽视的显存。双重量化正是针对这些常数进行第二次压缩。

具体做法:

  • 在第一次量化后,我们得到了大量的FP16量化常数(例如每个block一个)。

  • 对这些常数本身,再进行一次8-bit量化(即从FP16量化为INT8)。每个常数块(如256个常数)再次产生一个更粗粒度的FP16二级量化常数。

  • 最终存储的是:① NF4权重索引(4-bit),② 一级量化常数经过8-bit压缩后的INT8索引,以及③ 极少量的二级FP16常数。

为什么能更省显存: 若不使用双重量化,一级量化常数(FP16)平均到每个权重上约为 16 / block_size 比特的额外开销。对于block_size=64,每个权重额外需要 16/64 = 0.25 bit。加上权重本身的4 bit,总计约4.25 bit/权重。这可能看起来不多,但对一个70B的模型,0.25 bit的额外开销就相当于约2.2 GB显存。

通过双重量化,一级量化常数从FP16(16 bit)压缩到INT8(8 bit),每个常数节省了8 bit。平摊到每个权重上,额外开销从 16/64 = 0.25 bit 降低到 8/64 + 16/(64*256) ≈ 0.127 bit。对于70B模型,这节省了约1.1 GB显存。虽然绝对数值不大,但在显存极度受限的消费级显卡上,每一GB都很宝贵。而且双重量化对模型精度的负面影响极其微小,因为量化常数本身对误差的敏感度较低。

因此,QLoRA通过双重量化,将平均每权重显存消耗压到了4.13 bits左右,进一步逼近4-bit的理论下限。


LoRA训练时,如何调整学习率?和全参数微调有何不同?

LoRA训练时的学习率设置与全参数微调有显著差异,主要是因为可训练参数规模和参数初始化策略不同。

全参数微调的学习率:

  • 需要非常保守(通常在5e-6到2e-5范围),因为所有参数都被更新,过大的学习率极易引发灾难性遗忘。

  • 学习率通常依赖于预训练阶段结束时的学习率,取其1/5到1/10。

  • 不同层可能需要不同的学习率(如分层学习率衰减),但实现较复杂。

LoRA训练的学习率:

  • 可适当提高:因为LoRA只训练新增的小矩阵A和B,基座模型参数冻结。A通常随机高斯初始化,B初始化为零。由于B从零开始,训练初期梯度的方向和幅度需要被有效放大才能快速学习,因此LoRA的学习率可以比全参数微调高出一个数量级,常见范围在1e-4到5e-4,甚至更高(如8e-4)。

  • 与秩r和alpha的关系:LoRA论文建议将学习率与 alpha/r 的比值关联。通常设置 lr = base_lr * (alpha / r) 或类似的比例关系。原因是 αr 已经起到了缩放适配器输出的作用,学习率应相应调整以保持更新步长的合理性。如果使用默认 alpha = 2r,则缩放因子为2,此时学习率可以适当大些;如果 alpha = r,缩放因子为1,学习率可参照常规值。

  • 实际调参:我通常从1e-4开始,观察损失下降曲线。如果几个step后损失下降非常缓慢,可能是学习率太低;如果损失剧烈震荡或迅速发散,则学习率太高。对于简单任务和少量数据,学习率可以稍低(5e-5);对于复杂任务,可以提高到2e-4或3e-4。

与全参数微调的本质不同:

LoRA的学习率实质上是控制小矩阵A、B的更新步伐,而基座模型的输出端没有变化。因此,整个优化问题被解耦,我们只需关心适配器的学习动态,这比调整全模型参数更稳定,超参数选择也更宽容。


多个LoRA模块如何切换?能否实现热插拔?

在推理服务中,可能同时需要为不同用户或不同任务提供不同的LoRA适配器。多LoRA切换有两种主流方式:静态切换(冷插拔) 和动态热插拔。

静态切换(不合并):

  • 基座模型常驻GPU显存,不同LoRA的权重(A、B矩阵)存放在CPU内存或磁盘上。

  • 当需要切换任务时,将当前LoRA卸载(释放GPU显存),然后加载目标LoRA到GPU,绑定到模型上。

  • 由于LoRA权重很小(每个适配器几十MB),加载速度极快(毫秒级),对用户几乎无感。

  • 这可以在应用层实现一个LoRA管理器,根据请求参数动态加载。

动态热插拔(如S-LoRA、Punica):

  • 多个LoRA适配器同时驻留在GPU显存中,由统一的LoRA块池管理,类似于PagedAttention的思想。

  • 不同请求到达时,调度器根据所需的LoRA ID,从池中取出对应的LoRA权重块,与基座模型权重组合执行计算。

  • 关键:GPU显存只保留活跃请求所需的LoRA块,而非全部适配器。这实现了高并发下的细粒度复用。

  • 例如,vLLM和S-LoRA支持在生成每个token时,动态地为不同请求切换或组合LoRA模块,真正做到了“热插拔”,在几乎不增加额外显存的情况下支持成百上千个LoRA。

能否热插拔:完全可以,且是现代推理引擎的必备特性。单卡24GB的GPU可以同时服务上千个不同的LoRA适配器,因为LoRA块池所占显存恒定,只与并发请求使用的总LoRA参数量有关。切换由框架在底层自动完成,对用户透明。


LoRA是否可以和其他PEFT方法如adapter结合?

可以,LoRA与Adapter等PEFT方法在理论上是正交的,可以组合使用。它们作用于模型的不同部分或不同层面,叠加后能够增加可训练容量,但同时也会带来复杂度和推理延迟的增加。

结合方式:

  • 串联或并联:可以在Transformer的同一个Block中同时使用LoRA(作用于注意力权重)和Adapter(串行插入在FFN之后或之前)。LoRA通过低秩分解修改注意力输出,Adapter通过瓶颈层修改隐藏状态。

  • 分工协作:例如,用LoRA负责调整注意力模式和生成风格,用轻量Adapter注入特定领域的知识或任务符号。两者不共享参数,梯度独立更新。

  • 实际效果:研究表明,同时使用多种PEFT方法可以进一步提升模型在下游任务上的表现,尤其是当单一方法由于容量限制(如LoRA秩太小)无法完美拟合时。组合使用能提供更丰富的表示能力。

潜在问题:

  • 推理延迟累积:Adapter是串行插入的,会增加额外的计算开销;LoRA如果不合并,也会增加少量计算。两者叠加会使得推理速度进一步降低,需要在合并后推理或权衡延迟。

  • 训练复杂度:需要分别调整两种方法的学习率、秩等超参,增加了调参成本。

  • 过拟合风险:可训练参数总量增加,在数据较少时容易过拟合。

因此,结合使用并非默认必要,但当你发现单独使用LoRA(即使增大秩)仍无法达到理想效果,且延迟可接受时,可以考虑加入轻量Adapter作为补充。工业界大多数情况下,单独使用LoRA已经足够。


Adapter方法是在哪些位置插入小网络?缺点是什么?

Adapter方法是在Transformer模型的特定位置串行插入小型可训练瓶颈网络(Adapter模块),而保持基座模型其余参数冻结。

插入位置:

  • 标准位置:通常在多头注意力(MHA)子层之后、FFN子层之后各插入一个Adapter模块,或者在每个子层的残差连接之前或之后。典型的Adapter模块由两个线性层组成:一个下投影(将隐藏维度映射到较小的瓶颈维度,如64),后接一个非线性激活函数(如ReLU或GELU),再上投影回原维度。

  • 结构:Adapter(x) = W_up(σ(W_down(x))) + x,其中W_down ∈ R^{d×bottleneck},W_up ∈ R^{bottleneck×d},σ为激活函数。

  • 变体:有些只在FFN后添加(如AdapterFormer),有些在每个Block的特定位置。Pfeiffer Adapter则仅在每个Transformer层的输出之后插入。

缺点:

  • 推理延迟增加:Adapter模块是串行的,无法像LoRA那样通过重参数化合并到原权重中。因此,即使只增加很小的瓶颈网络,推理时也必须额外执行这些层的计算,导致每一层都有少量但不可消除的延迟。在批量较小或实时性要求高的场景下,这可能成为问题。

  • 序列长度影响:Adapter增加了每层的计算量,对于长序列,这些额外开销可能累积。

  • 容量与效率权衡:为了获得较好性能,瓶颈维度通常需要64-256,这使得Adapter的总参数量可能比LoRA(r=8)大数倍。更大的参数量意味着更高的显存占用和训练开销,尽管仍远小于全量微调。

  • 优化难度:由于Adapter是插入在模型中间,其输入分布会受到前面层的影响,有时需要配合LayerNorm等归一化进行训练,调参相对LoRA更复杂。

鉴于这些缺点,LoRA因其可合并和无推理延迟的优势,在很多场景下取代了Adapter成为PEFT的首选。但Adapter在多任务、多领域快速切换上仍有其灵活性。


Prefix Tuning和P-Tuning v2的区别是什么?

Prefix Tuning 和 P-Tuning v2 都是在输入或中间层添加可训练的前缀向量来微调模型,但它们在作用范围和设计上有本质区别。

Prefix Tuning:

  • 作用位置:仅在输入层(embedding层)之前,为Transformer的每一层(或只对特定层)添加可训练的虚拟token序列(prefix)。这些prefix向量与真实token嵌入拼接后输入第一层。

  • 参数量:prefix长度(如10-100)乘以隐藏维度,参数量很小。

  • 局限性:只能影响模型底层的注意力模式,对于改变高层语义或复杂推理任务的能力有限。因为信息必须通过逐层传递,控制力随层数衰减。

  • 适用任务:主要针对自然语言生成(NLG)任务,早期用于GPT-2等中等规模模型。

P-Tuning v2:

  • 作用位置:将可训练的连续提示向量不仅加在输入层,而是在模型的每一层(或多层)都添加独立的key-value前缀向量。这些向量作为可训练参数,参与每层的注意力计算,但本身不占序列长度(作为额外的可学习上下文)。

  • 深度提示:通过在深层也添加prefix,实现了对模型内部状态的深度调控,相当于在每一层都给模型“提示”,极大增强了控制能力。

  • 参数量:由于每层都有prefix,参数量为 层数 × 2(K和V)× prefix长度 × 隐藏维度,是Prefix Tuning的数倍,但仍远小于全参数微调(通常约0.1%~3%)。

  • 效果:在NLU和NLG任务上均达到或接近全量微调水平,尤其适用于大模型。

核心区别总结:

  • 深度:Prefix Tuning只加在输入层,P-Tuning v2加在每一层(深度提示)。

  • 控制力:P-Tuning v2有更强的控制力,性能上限更高。

  • 参数效率:P-Tuning v2参数量更大,但仍属PEFT范畴。

  • 实现:P-Tuning v2借鉴了Prefix Tuning的思想,但将前缀向量作为每层注意力机制中的可学习键值对。


IA³方法的思想是什么?它有多少可训练参数?

IA³(Infused Adapter by Inhibiting and Amplifying Inner Activations) 是一种极其简洁的参数高效微调方法。它的核心思想是:不需要添加任何新结构,而是直接学习对Transformer块中的关键激活向量(key, value, 和 FFN中间激活)进行逐元素的缩放(乘以学习到的向量)。

image.png

特点与效果:

  • 极致参数高效:比LoRA(通常数百万参数)还要少。

  • 可合并:训练后,这些缩放向量可以融进原来的权重矩阵(通过修改权重值)完全消除推理延迟,像LoRA一样实现零额外开销。

  • 性能:在许多任务上接近LoRA,但由于参数量极小,表示能力受限,复杂任务上可能不如LoRA。它是一种在极小参数预算下的优秀选择,尤其适用于极其受限的环境或快速实验。


对比LoRA和Adapter在推理延迟上的影响。

LoRA:

  • 未合并时:每次前向传播需要额外计算 BAx,这是两个小矩阵的乘法,计算量相比原权重矩阵乘法通常不到1%,因此延迟增加微乎其微(几乎可以忽略),但在极低延迟敏感型应用中仍可测出。

  • 合并后:通过重参数化将LoRA增量融入原权重,推理时与标准模型完全一致,零额外延迟。这是LoRA最受青睐的特性之一:训练时轻量,部署时无感。

Adapter:

  • Adapter模块串行插入在Transformer的每个Block中(如注意力后、FFN后)。这些额外的瓶颈网络(下投影→激活→上投影)必须顺序执行,无法合并到原权重中。

  • 每一步推理都必须经过这些额外层,增加了固定的计算开销。尤其对于批量小的实时推理,延迟增加比较明显(通常1%~5%左右,取决于瓶颈维度和层数)。

  • 对于长序列生成,由于每层都要重复调用Adapter,延迟累积更显著。

总结:LoRA在合并后无任何推理延迟惩罚,Adapter则永远带有少量但不可消除的延迟。因此在模型部署、尤其是高吞吐或低延迟服务中,LoRA明显优于Adapter。这也是LoRA成为当前主流PEFT方法的重要原因之一。


什么是“mix of LoRA”?有什么应用场景?

Mix of LoRA 是指将多个LoRA适配器组合起来,共同服务于一个请求,使模型能够同时展现多种能力或融合多个领域的知识。这并非简单的切换,而是在一次推理中动态地合并或叠加多个LoRA模块的输出。

实现方式:

  • 算术合并:在推理前,将多个LoRA的增量矩阵进行线性组合(如加权平均),合并成一个新的LoRA适配器。例如,一个LoRA负责“写作助手”,另一个负责“代码专家”,将它们按不同权重叠加,可以得到一个“具备编码知识的写作助手”。

  • 基于路由的动态组合:类似MoE(混合专家)的思路,训练一个路由器,根据输入的指令选择激活哪些LoRA模块(或它们的组合),并学习各个LoRA的融合权重。每个LoRA成为一个“专家”,专注于特定能力。

  • 分层组合:在不同层应用不同的LoRA,例如底层用一个通用的对话风格LoRA,顶层用一个领域知识LoRA。

应用场景:

  • 个性化模型:为每个用户训练一个基础的偏好LoRA,再叠加不同功能的LoRA(如翻译、摘要),实现高度定制化的AI体验。

  • 多任务学习:避免为每个任务单独部署模型,通过组合LoRA灵活提供各种能力,减少模型实例数量。

  • 持续学习:新技能通过新的LoRA学习,旧LoRA保留。推理时按需组合新旧LoRA,实现无遗忘的能力扩展。

  • 联邦学习:不同参与方训练自己的LoRA,云端负责将它们聚合,提升全局模型。

Mix of LoRA将LoRA从“单适配器”推向了“适配器生态”,是构建灵活、可组合AI系统的关键技术方向。


SFT训练时如何使用梯度检查点?会牺牲什么?

梯度检查点(Gradient Checkpointing) 是一种用时间换空间的技术,在SFT训练中用来大幅降低显存占用,使其能够在有限显存下训练更大模型或更长序列。

如何使用:

  • 在PyTorch中,使用 torch.utils.checkpoint.checkpointmodel.gradient_checkpointing_enable()(HuggingFace模型内置支持)。

  • 它的原理是:在前向传播时,不保存所有层的中间激活(activation),只保留少数几个“检查点”层(通常是每个Transformer块的输入)。反向传播时,当需要计算某一层的梯度时,利用最近的检查点重新执行前向计算,临时恢复该层的激活值,用完之后立即释放。

  • 在SFT中,只需在训练脚本中添加一行 model.gradient_checkpointing_enable(),即可开启。

会牺牲什么:

  • 训练速度下降:重计算前向会引入额外计算量,通常使训练速度降低 15%~30%,具体取决于模型大小和检查点分段粒度。层数越多,额外开销越大。

  • 吞吐量可能不降反升:虽然单步变慢,但因为节省了显存,通常可以增大batch size或序列长度,从而提升GPU整体利用率和每秒处理的token数。因此总体训练吞吐可能会提高,不能只看单步耗时。

  • 实现开销:某些框架的实现可能有些许内存和时间开销(如张量拷贝),但已高度优化。

在SFT中的实践:

  • 几乎成为SFT训练的标配,尤其是全量微调或使用较大模型时。通常与FlashAttention结合使用,以实现最佳的显存节省效果。

  • 对于LoRA微调,由于可训练参数极少,激活值占显存比例相对较高,开启梯度检查点可以有效降低激活峰值,使得单卡能容纳更大的micro batch。

因此,梯度检查点是用计算换显存的经典手段,在现代大模型SFT中不可或缺。


混合精度训练(BF16/FP16)在SFT中的实际意义是什么?

混合精度训练在SFT中不是锦上添花的可选项,而是实现高效微调的基础设施。它将模型权重、激活值和梯度的部分或全部从传统的FP32转换为更低精度的BF16或FP16,在几乎不损失模型最终性能的前提下,带来显存和速度的双重收益。

一、显存占用的直接削减

在SFT中,显存瓶颈通常来自三个方面:模型权重、优化器状态和中间激活。混合精度训练的显存节省体现在:

  • 权重与激活:BF16/FP16将每个浮点数从FP32的4字节压缩到2字节。对于7B模型,仅权重就从28GB(FP32)降至14GB(FP16/BF16),直接减半。前向传播产生的中间激活同样减半,显著降低峰值显存。

  • 优化器状态:在标准混合精度训练中,虽然前向和反向用低精度计算,但优化器内部仍维护一份FP32的主权重副本(用于精确更新),以及FP32的动量和方差(Adam)。这部分优化器状态仍占显存大头。不过,一些更激进的方案(如结合8-bit优化器)能进一步压缩。

  • 总显存节省:对于7B全量微调,混合精度可将显存从约120GB(FP32全量)降至约60-80GB,使单张80GB GPU可以运行,具有决定性意义。

二、训练速度的显著提升

现代GPU(如NVIDIA A100、H100)配备了专门的Tensor Core,对FP16和BF16矩阵运算有硬件加速,吞吐量可达FP32的数倍。混合精度训练能够充分利用这些硬件单元,大幅提升每次迭代的计算速度。同时,更小的数据体积也减少了GPU内存带宽的压力和数据搬运时间。

三、BF16 vs FP16:动态范围的权衡

  • FP16:表示范围窄(最大65504),训练中容易出现梯度下溢(梯度太小变成0)或溢出(变成NaN)。因此必须配合Loss Scaling(损失缩放)技术,动态放大损失值以防止小梯度消失,增加了额外的超参和计算开销。

  • BF16:指数位数与FP32相同(8位),只是尾数位数减半,因此动态范围与FP32几乎一致,不会发生溢出/下溢,无需Loss Scaling,训练更加稳定,超参数设置简化。在支持BF16的硬件(A100、H100)上,BF16已成为SFT的首选。

四、对SFT的具体意义

SFT的数据量通常不大,训练轮数也少,因此对数值精度的微小损失相对不敏感。混合精度带来的显存节省使得更大的batch size或更长的序列长度成为可能,这往往比精度本身对最终模型质量的影响更大。在LoRA/QLoRA中,基座模型权重甚至被量化到4-bit,依然能保持优良的指令遵循能力,进一步证明了SFT对精度的宽容性。

五、实践中的注意事项

  • 在FP16混合精度训练中,一定要开启Loss Scaling,并监控是否出现梯度溢出(可设置 gradient_scaling 自动调整)。

  • 如果硬件支持BF16,优先使用BF16,省去Loss Scaling的烦恼。

  • 部分操作(如Softmax、LayerNorm)对精度敏感,通常强制在FP32下计算,框架会自动处理。

  • 混合精度训练可以与梯度检查点、ZeRO等技术组合,进一步压榨显存。

✅ 总结:混合精度训练是SFT的标配,它通过将计算精度降至FP16/BF16,大幅降低显存占用和训练时间,使得大模型微调在有限硬件上成为可能。BF16因其稳定的动态范围,成为更受欢迎的选择。


DeepSpeed ZeRO的三个阶段分别优化了什么?SFT中通常用哪个?

DeepSpeed ZeRO(Zero Redundancy Optimizer)是一套针对大规模模型训练的显存优化技术,通过分片(partitioning) 模型状态来消除多卡数据并行中的冗余存储。它分为三个阶段,逐级深入。

ZeRO-1:优化器状态分片

  • 优化内容:将优化器状态(如Adam的动量和方差,FP32存储,每个参数占8字节)在数据并行的各卡间平均分片。每张卡只存储与自己那部分参数对应的优化器状态,总优化器显存降至原来的1/N(N为卡数)。

  • 通信开销:增加一次All-Gather操作来收集更新后的参数(在优化器step后),通信量约为参数量Φ。相对于数据并行的梯度All-Reduce,新增通信量可控。

  • 适用场景:当优化器状态是显存瓶颈时(如训练小模型),ZeRO-1几乎无性能损失。

ZeRO-2:梯度分片

  • 优化内容:在ZeRO-1的基础上,进一步将梯度分片。每张卡只保存与自己那部分参数对应的梯度,反向传播时通过Reduce-Scatter直接计算出自己负责的那部分梯度,而不是先All-Reduce出完整梯度再丢弃。梯度显存同样降至1/N。

  • 通信开销:将原DP的All-Reduce梯度改为Reduce-Scatter梯度 + All-Gather参数,总通信量基本不变或略增,但实现更复杂。

  • 适用场景:当模型更大,梯度也开始成为瓶颈时。

ZeRO-3:参数分片

  • 优化内容:将模型参数本身也分片。每张卡只存储1/N的模型权重。在前向/反向需要某层参数时,通过All-Gather从其他卡收集完整参数,计算完成后立即释放。这打破了单卡权重显存墙,使训练千亿参数模型成为可能。

  • 通信开销:前向每层一次All-Gather参数,反向每层一次All-Gather参数 + 一次Reduce-Scatter梯度。总通信量约为ZeRO-1/2的1.5倍。通信成为瓶颈,需要高速网络(如InfiniBand)。

  • 适用场景:模型极大,必须分片参数才能运行。

SFT中通常用哪个?

  • 7B模型全量微调:ZeRO-2通常足够,甚至ZeRO-1都可以,因为优化器和梯度是显存大头。

  • 13B及以上全量微调:通常需要ZeRO-3,特别是当单卡显存有限时。

  • LoRA/QLoRA微调:由于可训练参数极少,优化器状态和梯度本身非常小,通常不需要ZeRO,甚至单卡就能完成。如果LoRA应用在非常多的层且秩很大,导致显存不足,可以开启ZeRO-2来分片优化器状态,但ZeRO-3一般不需要。

  • 多卡SFT:如果使用多卡进行全量微调,ZeRO-3是目前最常用的配置,因为它能将超大模型平摊到多卡,同时保持高效训练。


在SFT中使用ZeRO-3会有什么额外通信开销?

ZeRO-3以参数完全分片为代价,换取了单卡显存的极限压缩,但引入了额外的通信开销。理解这些开销对于评估训练效率和硬件需求至关重要。

一、通信量分析

标准数据并行(DP)的通信主要是一次梯度All-Reduce,通信量为 2Φ(Φ为参数量,前向无参数通信)。

ZeRO-3在每一层都需要进行参数收集和梯度分散,通信总量约为 3Φ:

  • 前向传播:每个Transformer层开始前,该层参数分片在每张卡上。需要通过一次All-Gather将该层的完整参数收集到所有卡上,通信量 Φ。

  • 反向传播:同样需要先All-Gather该层参数(Φ),计算出该层梯度后,执行一次Reduce-Scatter将梯度聚合到对应的分片持有者(Φ)。总反向通信量为 2Φ。 因此总通信量 3Φ,是标准DP的1.5倍。

二、通信模式与延迟影响

ZeRO-3的通信是逐层发生的,这意味着它会产生大量的小消息(每个层一次All-Gather / Reduce-Scatter),而不是像DP那样一次性All-Reduce整个梯度。这种碎片化通信对网络延迟更为敏感,如果网络带宽不足或延迟较高,会成为训练的瓶颈。实践中,ZeRO-3往往需要使用高速互联(如100Gbps以上的InfiniBand或RoCE),并开启通信与计算的重叠(overlap_comm)来隐藏部分延迟。

三、参数预取与缓冲区显存

为了隐藏通信延迟,ZeRO-3通常会预先获取下一层的参数(prefetch),这需要额外的显存作为缓冲区。这些通信缓冲区虽然占用显存(通常可配置),但换来的是计算的不间断。

四、与ZeRO-1/2的对比

  • ZeRO-1只增加一次All-Gather参数(通信量 Φ),总通信 3Φ?确切说ZeRO-1通信量也是 3Φ(All-Reduce梯度 2Φ + All-Gather参数 Φ),但梯度All-Reduce是一次性的,比ZeRO-3的逐层All-Gather产生的延迟更集中、更容易重叠。

  • ZeRO-2通信量与ZeRO-1基本一致,只是将All-Reduce梯度换成了Reduce-Scatter梯度 + All-Gather参数,总通信量仍为 3Φ。但ZeRO-3的逐层All-Gather参数在反向时也需要再次执行,通信更频繁。

五、在SFT中的影响

如果SFT数据量不大(几万条),总训练步数较少,ZeRO-3的额外通信开销可能不会严重拖慢总体进度。但对于大规模多卡SFT,通信可能成为瓶颈。此时可以:

  • 使用更高的通信带宽(InfiniBand)。

  • 开启通信计算重叠(overlap_comm)。

  • 调整参数预取和通信桶大小(reduce_bucket_size, allgather_bucket_size)来平衡通信和显存。

  • 如果模型可以塞进单卡,尽量不用ZeRO-3,改用ZeRO-2甚至DDP+梯度累积。

✅ 总结:ZeRO-3用1.5倍的通信量换取了单卡显存的突破,但频繁的逐层通信需要高速网络支撑。在SFT中,尤其是全量微调大模型时,通信开销虽存在,但通常是可接受的代价,因为它使得原本无法运行的大模型得以训练。


什么是序列打包(packing)?如何通过attention mask实现?

序列打包(Sequence Packing)是一种提高SFT训练效率的技术,它将多条较短的训练样本(指令-回答对)拼接成一条接近模型最大输入长度的长序列,从而减少padding浪费,大幅提升每次迭代中有效token的计算比例。

一、为什么需要序列打包?

SFT数据的一个典型问题是:指令和回答的长度差异很大,短的问答可能只有几十个token,而长的则可能上千。如果使用传统的固定长度padding策略,每个batch中的短样本会被填充大量无意义的[PAD] token,导致计算资源浪费在无效的padding上。序列打包通过将短样本首尾相接,填满整个上下文窗口,使得GPU的每一次前向/反向传播都处理尽可能多的有效token,从而提升训练吞吐量(通常提升2-5倍)。

二、如何通过attention mask实现?

不能简单地将样本拼接后就训练,因为模型会错误地让不同样本之间相互“看见”并进行注意力计算,导致信息泄露和训练混乱。正确的做法是构造分块对角注意力掩码(block-diagonal attention mask),让每个样本内部的token可以相互关注,但不同样本之间的注意力权重为负无穷(即相互屏蔽)。

具体实现步骤:

  1. 将多个样本(例如样本A的prompt+response、样本B的prompt+response...)直接拼接成一个长序列 [A_prompt, A_response, B_prompt, B_response, ...]

  2. 记录每个样本在长序列中的起始和结束位置。

  3. 构造一个二维的注意力掩码矩阵(形状为[总长度, 总长度]),其中允许注意力的位置设为0,不允许的设为 -inf(或一个非常大的负数)。对于每个样本,只有它内部的token(即它自己的prompt和response部分)之间的注意力被允许,对其他样本的token的注意力被屏蔽。

  4. 将这个掩码传递给模型的注意力层(通常通过attention_mask参数,用0/1或布尔值表示,然后内部转换为加性掩码)。

三、实现中的关键细节

  • 自回归生成任务的因果掩码:SFT训练的是自回归语言模型,已经有因果掩码(下三角矩阵)。在packing时,需要将因果掩码与分块对角掩码结合,即在每个样本内部仍保持因果(只看之前的token),但不同样本之间完全不互看。这可以通过在标准因果掩码的基础上,将不同样本之间的位置也设为-inf来实现。

  • 位置编码:由于不同样本被拼接到一起,它们的位置ID是连续的,模型会认为后面的样本紧接前面的样本。这对于绝对位置编码可能引入噪声(影响较小),但对于RoPE等相对位置编码,由于注意力被限制在各自块内,位置连续性影响不大。通常无需特殊处理。

  • EOS token处理:在拼接时,通常需要在每个样本的response之后保留或插入EOS token,以让模型学习结束生成,同时避免不同样本直接相连。

四、框架支持

HuggingFace的DataCollatorForSeq2Seq等需要自定义才能实现packing。可以通过编写自定义的DataCollator,手动拼接样本并生成相应的attention_mask。一些开源库(如axolotltrl中的SFTTrainer)已经内置了packing功能,只需设置packing=True即可。

✅ 总结:序列打包通过拼接短样本并构造分块对角注意力掩码,极大地提高了SFT的GPU有效利用率,是加速SFT训练的关键技术。


序列打包能提升多少训练效率?有什么副作用吗?

一、训练效率提升幅度

序列打包对训练效率的提升非常显著,具体取决于数据集中序列长度的分布:

  • 极端情况:如果数据集中绝大多数样本都非常短(比如平均长度只有模型最大长度的10%),使用packing可以将有效token的利用率提升近10倍,训练吞吐量(tokens/sec)提升5-8倍。因为原本90%的算力都在处理padding,packing几乎消除了这些浪费。

  • 常见情况:SFT数据通常长短不一,packing一般能将吞吐量提升2-5倍。我曾在LLaMA-7B的SFT中,通过packing将训练时间从16小时缩短到4小时(batch size等效增大)。

  • 影响因子:提升程度受chunk_size(拼接后的序列长度)和样本长度分布影响。拼接得越接近模型最大长度,效率越高。

二、副作用

尽管packing能大幅提速,但它也带来了一些不可忽视的副作用:

  • 不同样本的交叉污染风险:如果attention mask构建错误或存在bug,不同样本之间的token可能发生注意力泄漏,导致模型学到错误的关联。但正确实现下,这种风险可控。

  • 梯度传播混淆:由于多个样本在一个序列中,反向传播时梯度会同时受到多个样本的影响,这可能使得单个样本的损失信号被平均,对于特别重要或罕见的样本,其独特信号可能被稀释。在某种程度上,它类似于增大batch size的效果,可能对泛化有轻微影响。

  • 截断风险:为了填满一个pack,可能需要在末尾丢弃一个放不下的样本,或截断过长的样本。截断会损失信息,如果不截断,则最后一个样本可能未被完整包含,影响其训练。

  • EOS位置和生成行为:packing使得EOS token在序列内部多次出现,模型可能学到在pack内部看到EOS后继续生成下一个样本,这与其在推理时的行为不同。有时需要在EOS后额外添加分隔符(如<|end_of_text|>)并mask掉该分隔符的损失,以保持语义边界。

  • 调试复杂度:数据加载和mask构造逻辑更复杂,容易出错。排查问题时不如逐条样本直观。

三、权衡与建议

  • 如果训练数据量巨大且计算资源宝贵,强烈建议启用packing,它带来的加速远大于潜在的副作用。

  • 对于高质量但量少的SFT数据,为避免信号稀释,可能选择不packing或者减小packing后的序列长度。

  • 实现时,务必仔细验证attention mask的正确性(可视化小规模数据的mask矩阵),并监控模型在短样本和长样本上的表现。

  • 可以使用packing的超参数,如max_seq_length,在效率和副作用间权衡。

✅ 总结:序列打包通常能提升2-5倍的训练效率,是SFT加速的利器,但需注意注意力泄漏、梯度信号稀释和EOS处理等副作用。在效率和质量之间,可通过实验找到最佳平衡。


实现packing时,不同样本之间需要插入分隔符吗?

建议插入,但不是绝对必须。 是否插入分隔符取决于对模型行为的要求以及注意力掩码的实现方式。

一、不插入分隔符的情况

如果不插入任何分隔符,直接将样本A的</s>(或EOS)与样本B的<s>(或BOS)或prompt直接相连。由于我们已经通过注意力掩码严格隔离了不同样本,它们之间不会有注意力交互,因此从计算上讲,是否插入分隔符不影响训练损失的正确计算。每个样本内部的token仍然只关注自己的上下文。

然而,模型在训练时会“看到”一个序列中连续出现了多个EOS和BOS,它可能会学习到一种错误的模式:即在一个序列内部遇到EOS后,应该继续生成下一个独立的对话(因为训练数据就是这样拼接的)。在推理时,模型可能会在生成完一个回答后,不停止,而是继续生成新的用户问题或另一个回答,导致失控。

二、插入分隔符的好处

  • 保持语义边界:插入一个专门的分隔符(如<|endoftext|>[SEP],或者一个特殊的segment separation token)可以明确告诉模型“这里是一个新的开始”。通过构造attention mask时,让这个分隔符只属于前一个样本或后一个样本,或者让它对两边都可见但损失被mask,可以更好地模拟推理时的环境。

  • 缓解EOS失控问题:如果分隔符被用作“新对话开始”的信号,模型学会在分隔符之后开始一个新的话题。推理时没有这些分隔符,模型就更可能在EOS后停止。

  • 简化数据恢复:插入分隔符后,可以更容易地将packed序列拆分回原始样本(如果需要的话)。

  • 实践中:很多packing实现会在每个样本的response之后额外添加一个EOS或特殊分隔符,然后紧接着下一个样本的prompt。在构造标签时,这个分隔符对应的label通常设为-100(忽略),使其不参与损失计算,仅作为上下文结构存在。

三、推荐做法

  • 添加分隔符并mask其损失:在每段response的结束位置添加一个<|im_end|>[EOS](如果原来没有),然后在它之后再添加一个额外的分隔符(如<|endoftext|>),再接下一个样本的<|im_start|>user。将这个额外分隔符的label设为-100。

  • 或在BOS前添加:也可以在下一个样本的BOS之前插入分隔符,并将其label设为-100。

  • 简单场景:如果对推理行为要求不高,且attention mask完全隔离,可以不插分隔符,但通常在训练长文本时更规范的做法是插入分隔符以防万一。

✅ 总结:虽然理论上不插分隔符也能通过mask实现packing,但推荐插入分隔符并mask其损失,以保持语义边界并减少推理时的行为偏差。


如何处理packing后序列长度不统一的情况?

在实现packing时,我们通常设定一个固定的最大序列长度(例如2048或4096),然后将多个短样本拼接直到接近这个长度。但现实中的样本长度组合很难恰好填满,总会有一些剩余空间装不下下一个样本,或者某些样本本身就超过了最大长度。处理这些情况需要制定清晰的策略。

一、主要处理方法

  1. 截断(Truncation)

  2. 对超长样本:如果某条样本(prompt+response)本身超过了max_seq_length,必须进行截断。通常策略是只保留最后max_seq_length个token(因为对话的尾部通常包含更直接的指令和回答),或者只截断prompt保留response,或反之。更优的做法是保证response完整,截断prompt的前半部分。

  3. 对装不下的样本:在拼接时,如果当前已拼接的序列加上下一个样本会超出max_seq_length,则可以选择:① 直接截断该样本,使其恰好填满剩余空间(会损失该样本的部分信息,可能截断response);② 跳过该样本,用更短的样本填充剩余空间;③ 将该样本移到下一个pack中。

  4. 填充(Padding)

  5. 如果使用了packing,剩余的少量空间通常不值得用padding填充,因为这样会降低效率。但有时为了确保batch内的序列长度一致(例如不使用动态形状),需要padding到统一长度。一般优先选择“放弃填充”,采用动态序列长度,让每个batch中的pack都接近指定长度但不完全一致,现代框架支持动态shape,效率损失很小。

  6. 动态调整packing

  7. 不需要预先固定每个pack的样本组成,而是在DataLoader中动态地尽量填满一个buffer。例如使用pack_dataset算法:从数据集中逐个取出样本,如果当前pack的剩余长度大于样本长度,则加入;否则将当前pack送入batch,并开启新pack。这样产生的pack长度不完全一致,但都在max_seq_length附近,且padding极少。

  8. 可以在batch内对序列长度进行排序,相似长度的pack放在一起,以减少padding(如果使用padding的话)。

二、如何处理长度不统一

  • 使用attention_mask:PyTorch等框架支持通过attention_mask来忽略padding位置。即使在一个batch内,不同样本的pack长度不同,我们可以将较短的pack用pad token填充到最长长度,并在attention_mask中将padding位置标记为0。损失计算时同样通过label=-100忽略padding。

  • 使用packed序列的动态batch:更先进的做法是不再使用固定shape的tensor,而是将batch中的所有pack连接成一个长的一维序列,并记录每个序列的起始位置(类似cu_seqlens),这样可以完全消除padding,实现极致的计算效率。这种格式在一些高效训练库(如FlashAttention的varlen接口)中得到支持。不过实现较复杂。

三、对训练的影响

  • 如果采用截断,可能会丢失部分样本的上下文,尤其是当样本长度长尾严重时。对于SFT,可以监控被截断样本的比例,控制在1%以内。

  • 如果采用跳过,会导致这些样本被训练次数减少,可能削弱对长文本的处理能力。更好的办法是将长样本单独处理(例如不参与packing,独立成batch),或者设置更大的max_seq_length

✅ 总结:packing后的长度不统一可通过截断、跳过或动态拼接来处理。结合attention mask和变长序列支持,可以最大程度减少padding,保持高效训练。关键是合理设置最大长度和截断策略,确保模型仍能学习到长样本的特征。


SFT训练时,如何确保padding部分不参与loss计算?

在SFT中,一个batch内的样本长度通常不统一,需要进行padding以对齐到相同长度。但模型不应该学习生成这些无意义的[PAD] token,因此必须确保padding部分对损失函数没有贡献。实现这一目标的标准方法是将padding位置的标签(label)设置为-100(PyTorch中CrossEntropyLoss的默认忽略索引)。

具体实现步骤:

  1. 构造输入序列:将每个样本的prompt和response拼接,并在末尾添加EOS token。同时,为每个样本创建一个相同长度的label序列,初始时response部分(包括EOS)的label设为对应的token ID,prompt部分的label设为-100(因为不对prompt计算loss)。

  2. Padding对齐:在DataCollator中,将batch内的所有序列填充到该batch的最大长度。通常在右侧填充(左边放内容,右边填充[PAD] token)。对于输入序列(input_ids),填充位置填入tokenizer.pad_token_id(通常为0或其他特殊ID)。对于label序列,填充位置统一填入-100。这是核心步骤。

  3. attention_mask:创建attention_mask,将实际token位置标记为1,padding位置标记为0,以确保模型在计算注意力时忽略padding位置。

  4. 损失计算:将处理好的input_idslabels送入模型。模型内部会计算logits,然后与labels一起计算交叉熵损失。由于labels中padding位置的值为-100,PyTorch的CrossEntropyLoss(或HuggingFace的ForCausalLM内置loss)会自动忽略这些位置的损失,不产生梯度。

为什么填充label为-100? 这是PyTorch的默认忽略索引,可以在初始化损失函数时指定(ignore_index=-100)。任何标记为-100的标签都会被跳过。

注意事项:

  • 填充方向:通常采用右侧填充,因为自回归语言模型从左到右生成,左侧填充会改变序列的起始位置,可能影响位置编码和生成。右侧填充不影响左侧的因果注意力。

  • 确保attention_mask正确,否则即使label被忽略,模型可能仍会关注padding位置的信息,但至少不会学习生成padding。

  • 在生成任务(如推理)中,通常不需要padding,可以使用batch_size=1或利用动态shape。

✅ 总结:确保padding不参与loss计算的关键是将label填充为-100,并配合正确的attention_mask。这保证了模型只在有效的回答token上优化。


为什么通常将prompt部分的label设为-100?

将prompt(指令/用户输入)部分的标签设置为-100,是SFT训练中实现条件生成和精确对齐的核心技巧。这背后的逻辑如下:

一、训练目标:只学习“回答”,不学习“提问”

SFT的目标是教会模型在给定指令和上下文的情况下生成恰当的回答,而不是教会模型如何生成指令或系统提示。如果我们对prompt部分也计算损失,模型将被迫学习如何生成“用户可能会输入的指令”,这会导致两个问题:

  • 角色混乱:模型会混淆自己是用户还是助手,可能在推理时自己生成用户问题然后回答,或者在不该说话时说话。

  • 学习信号稀释:prompt通常占序列的很大比例(有时超过50%),对这些部分计算损失会浪费大量计算资源和模型容量,使得模型没有充分专注于学习回答的质量。

因此,我们将prompt部分的标签设为-100(忽略),仅对response部分计算损失。这等价于最大化条件概率 P(response∣prompt)P(response∣prompt),而非联合概率 P(prompt,response)P(prompt,response)。这正是指令微调的目标。

二、维护对话的交互结构

在多轮对话中,每一轮的user和assistant交替出现。通过mask掉所有user和system部分的标签,模型学会了“只对assistant部分生成”,严格遵循对话的发言次序。这通过大量训练数据固化了一种交互协议:看到user的内容时,模型应该理解并等待(不生成),然后在assistant标记后生成回复。

三、避免模型产生幻觉和自我对话

如果不对prompt计算loss,模型在学习时就不会试图“续写”一个用户的指令。这防止了模型陷入“自问自答”的崩溃模式,即它自己扮演用户提出新问题然后回答,导致对话不可控。

四、实现方式

在数据处理时,我们创建与input_ids等长的labels数组。遍历序列,将属于prompt部分的token位置(包括system、user以及特殊标记如<|im_start|>user等)对应的label值设为-100,将属于assistant回复部分(包括<|im_start|>assistant标记后的回答内容,以及最终的<|im_end|>)对应的label值设为真实的token ID。有些实现中,<|im_start|>assistant这个标记本身也被设为-100,因为它只是结构标记而非需要生成的内容,但也可以保留,影响不大。

五、例外情况

在某些非常规的SFT中,比如希望模型学习如何生成整个对话(例如对话数据增强),可能会对prompt部分也计算损失,但这不是主流做法。

✅ 总结:将prompt label设为-100,是为了让模型专注于学习“基于上下文生成回复”的能力,维持正确的对话角色,并最大化训练信号的效率。


简述使用HuggingFace Trainer进行SFT的完整流程。

使用HuggingFace的Trainer进行SFT,可以规范化训练流程并利用其丰富的功能(分布式训练、日志、评估、checkpoint等)。以下是典型流程:

  1. 准备环境与模型

  2. 安装transformers, datasets, peft, trl等库。

  3. 加载基座模型(如AutoModelForCausalLM.from_pretrained("meta-llama/Llama-2-7b-hf")),并配置量化(如果使用QLoRA)。

  4. 加载对应的tokenizer,设置pad_token(如果没有则设为eos_token),并确保padding_side="right"

  5. 数据预处理

  6. 准备SFT数据集,可以是JSONL格式,包含instructionoutput字段,或者多轮对话的conversations

  7. 应用对话模板(如ChatML),将原始数据转换为文本序列。例如:

<|im_start|>system\n{system_prompt}<|im_end|>\n<|im_start|>user\n{instruction}<|im_end|>\n<|im_start|>assistant\n{response}<|im_end|>
  • 使用tokenizer对文本进行编码,生成input_idsattention_mask

  • 构造labels:复制input_ids,然后将属于prompt部分的token位置(直到最后一个<|im_start|>assistant之后)设置为-100。通常通过查找特定token的位置来实现。简单做法:将input_ids中所有在assistant回复之前的token的label设为-100。

  • 配置PEFT(如LoRA)

  • 使用LoraConfig设置LoRA参数(r, lora_alpha, target_modules, lora_dropout, task_type="CAUSAL_LM")。

  • 调用get_peft_model(model, lora_config)获得可训练的PeftModel。

  • 定义训练参数

  • 使用TrainingArguments(或Seq2SeqTrainingArguments)设置:

  • output_dir:模型和日志保存路径。
  • per_device_train_batch_size:单卡batch size。
  • gradient_accumulation_steps:梯度累积步数。
  • num_train_epochs:训练轮数(通常1-3)。
  • learning_rate:学习率(如2e-4 for LoRA)。
  • warmup_steps:预热步数。
  • logging_steps, save_steps, eval_steps:日志、保存和评估频率。
  • bf16=True(如果硬件支持)或fp16=True
  • gradient_checkpointing=True(开启梯度检查点)。
  • optim="adamw_torch""paged_adamw_8bit"(QLoRA)。
  • report_to="wandb" 等。

  • 实例化Trainer

  • trainer = Trainer(model=model, args=training_args, train_dataset=train_dataset, eval_dataset=eval_dataset, tokenizer=tokenizer, data_collator=custom_data_collator)

  • data_collator:默认的DataCollatorForSeq2Seq可以处理padding和label mask,但若需要packing等,需自定义。

  • 开始训练

  • trainer.train()

  • 训练完成后,保存模型:trainer.save_model()model.save_pretrained()。如果是LoRA,保存的是适配器权重。

  • 评估与推理

  • 加载训练好的模型进行人工评估或自动化测试。

  • 可以合并LoRA权重后导出为完整模型用于部署。

✅ 总结:HuggingFace Trainer提供了一套标准化的SFT训练框架,通过配置模型、数据、训练参数和PEFT,即可高效完成微调。


如何自定义一个Data Collator来实现loss masking和packing?

自定义Data Collator可以精细控制批数据的组装过程,实现loss masking、packing等高级特性。以下是一个实现示例思路:

  1. 基本要求 Data Collator接收一个样本列表(每个样本通常包含input_ids, attention_mask, labels等字段),输出一个批次字典。我们需要:

  2. 将不同长度的序列通过padding对齐(若未使用packing变长格式)。

  3. 确保padding位置对应的labels为-100。

  4. 如果使用packing,则需拼接多个样本并生成对应的attention_masklabels

  5. 实现标准Loss Masking(无packing)的Collator 可以直接继承DataCollatorForSeq2SeqDataCollatorWithPadding,它们已经处理了将labels中padding位置设为-100。只需确保在tokenization阶段已经将prompt部分的labels设为了-100。

示例:

from transformers import DataCollatorForSeq2Seq

data_collator = DataCollatorForSeq2Seq(
    tokenizer=tokenizer,
    model=model,
    padding=True,
    return_tensors="pt"
)

train_dataset中,每条样本的labels已经正确设置了prompt位置的-100。DataCollatorForSeq2Seq会将所有样本的input_idslabels填充到batch内最大长度,填充值在input_idstokenizer.pad_token_id,在labels中则为-100。这正是我们需要的。

  1. 实现Packing的Custom Collator

若要实现packing,需要自定义Collator。基本逻辑:

  • 维护一个buffer列表,累积样本直到总长度接近max_seq_length

  • 对于每个样本,如果当前buffer中已有长度加上该样本长度超过max_seq_length,则将buffer中的样本拼接,作为一个pack,清空buffer,然后放入该样本。

  • 拼接时,将多个样本的input_ids直接concat,labels也concat。在样本之间可选择插入分隔符(如EOS),并将该分隔符的label设为-100。

  • 构造attention_mask:创建一个二维的注意力掩码矩阵,形状为[seq_len, seq_len]。首先创建一个全0的矩阵(或全-inf),然后将每个样本内部对应的子块设置为0(表示可以互相关注),且保持因果掩码。具体做法:先生成一个下三角因果掩码,然后将不属于同一样本的位置强制设为-inf。也可以使用变长注意力(通过flash_attn_varlen_func)来避免构造完整矩阵,但Trainer默认不支持。许多实现通过将attention_mask设置为一个表示样本边界的cu_seqlens,但这需要修改模型的前向。

  • 简化方案:不使用二维掩码,而是让不同样本之间的注意力通过attention_mask的一维形式无法完全隔离,这可能导致小量信息泄露。更安全的做法是返回input_ids, labels, 以及一个attention_mask,该attention_mask不是简单的0/1序列,而是一个4D的因果掩码矩阵(可送入模型)。HuggingFace模型接受4D attention mask(bool tensor),形状为[batch, 1, seq_len, seq_len]。我们将每个pack的单个序列(batch_size=1)的掩码构造好即可。

  • 实际上,由于Trainer默认使用batch_size,我们可以让每个pack就是一个独立样本,这样batch内是多个pack,它们之间无需掩码隔离,只在每个pack内部隔离样本。此时batch处理正常。

  • 实例:简单packing collator(返回单序列,在batch中视为一个长序列)

class PackingCollator:
    def __init__(self, tokenizer, max_length, pad_token_id=0):
        self.tokenizer = tokenizer
        self.max_length = max_length
        self.pad_token_id = pad_token_id

    def __call__(self, features):
        # features: list of dicts with input_ids, labels, attention_mask
        # 简单起见,这里只演示拼接多条样本,但不构造复杂掩码(实际应用需完善)
        all_input_ids = []
        all_labels = []
        all_attn_mask = []  # 一维,标记哪些位置是真实token
        for f in features:
            # 假设已经截断至合适长度
            input_ids = f['input_ids']
            labels = f['labels']
            # 添加分隔符等(省略)
            all_input_ids.extend(input_ids)
            all_labels.extend(labels)
            all_attn_mask.extend([1]*len(input_ids))
        # 截断或填充
        if len(all_input_ids) > self.max_length:
            all_input_ids = all_input_ids[:self.max_length]
            all_labels = all_labels[:self.max_length]
            all_attn_mask = all_attn_mask[:self.max_length]
        else:
            pad_len = self.max_length - len(all_input_ids)
            all_input_ids.extend([self.pad_token_id]*pad_len)
            all_labels.extend([-100]*pad_len)
            all_attn_mask.extend([0]*pad_len)
        return {'input_ids': torch.tensor(all_input_ids).unsqueeze(0),
                'labels': torch.tensor(all_labels).unsqueeze(0),
                'attention_mask': torch.tensor(all_attn_mask).unsqueeze(0)}

注意,这没有实现不同样本间的注意力隔离。要实现真正的隔离,需要返回完整的4D mask。

  1. 推荐方案:使用现有的经过验证的库,如trlSFTTrainer,它内置了packing功能,并自动生成正确的attention_mask(3D或4D)。自定义实现复杂易错。

✅ 总结:通过自定义Data Collator,可以实现灵活的padding和loss masking。对于packing,需要构造复杂的注意力掩码来隔离样本。最便捷的方式是使用已经支持packing的框架(如trl)。


多卡SFT训练时,数据并行和模型并行如何选择?

在多卡SFT训练中,选择数据并行(DP)还是模型并行(MP,包括张量并行TP、流水线并行PP),取决于模型能否装进单卡显存,以及对训练速度和通信开销的权衡。

一、能否装入单卡决定基本路线

  • 如果模型可以装入单卡(例如7B模型FP16全量微调需要14GB权重+优化器+激活,约30-60GB,80GB卡足够):优先选择数据并行。数据并行让每张卡都拥有完整模型,并行处理不同的数据分片,梯度通过All-Reduce同步。扩展性好,通信量相对小,且不需要对模型进行切分。结合梯度累积,可以灵活调整全局batch size。

  • 如果模型无法装入单卡(例如13B全量微调需40-70GB,可能超出80GB;或70B模型):必须使用模型并行或其变体(如ZeRO-3、TP、PP)。模型并行将模型参数、优化器状态等切分到多卡,降低单卡显存。

二、数据并行(含ZeRO)的适用边界

  • 标准DP要求每卡显存放得下完整模型(权重+梯度+优化器)。若放不下,ZeRO-1/2可以通过分片优化器和梯度使模型装入,通常能支撑13B全量微调在80GB卡上。ZeRO-3进一步分片参数,能支撑70B模型,但通信开销增大。

  • 因此,对于7B-13B模型,ZeRO-2或ZeRO-3通常是合适的选择,其实质是数据并行+状态分片,通信开销可接受。对于70B,ZeRO-3成为必备。

三、模型并行(TP/PP)的选择

  • 张量并行(TP):将层内权重矩阵切分到多卡,每卡计算部分结果,通过All-Reduce通信。通信量极大,要求卡间超高带宽(如NVLink),通常只用在单机多卡内。如果单卡装不下,且希望限制通信,可以用TP,但扩展性有限。

  • 流水线并行(PP):将模型层按深度切分,每卡持有部分层。通信量小(只传输中间激活),适合跨机扩展。但存在流水线气泡,需要微批次调度。适合超大模型。

  • 在SFT场景下,如果模型大到连ZeRO-3都无法装入单卡(极少见),则需要TP+PP混合。但对于绝大多数开源模型的SFT,ZeRO-3已经足够。

四、SFT的典型选择

  • 单卡全量/QLoRA:直接单卡训练。

  • 多卡全量微调7B-13B:优先使用ZeRO-2(显存够)或ZeRO-3(显存紧张时)。通信开销可接受,且保持了数据并行的灵活性。

  • 多卡全量微调70B:必须使用ZeRO-3,并结合混合精度、梯度检查点等。通常需要8卡A100 80GB。

  • 如果使用LoRA:无论模型多大,可训练参数极少,每卡显存占用主要是冻结的基座权重+激活。此时可使用标准DP,因为每卡仍需加载完整基座权重(若量化为4bit可以放下)。如果权重也放不下,则需要将基座权重也分片,即使用ZeRO-3(即使在LoRA下)来分片冻结参数,但这增加了通信。通常对于70B+LoRA,单卡80GB无法装入全量权重(需要约35-70GB),仍需多卡分片权重,常用ZeRO-3。

✅ 总结:选择策略是“能DP就DP(含ZeRO-2),不能就ZeRO-3,再大就混合并行”。对于SFT,多数情况ZeRO-2/3已满足需求,LoRA则极大降低了并行门槛。


如果模型太大无法单卡全参数SFT,有哪些技术选项?

当模型大到单卡无法全量微调时(例如全量微调30B以上模型,单卡80GB也不够),我们有多种技术组合来应对。这些技术往往组合使用,以在有限的硬件下实现微调。

选项一:参数高效微调(PEFT)——首选方案

  • LoRA/QLoRA:只训练少量低秩矩阵,基座权重冻结。QLoRA将基座权重量化到4-bit,极大节省显存。例如,用QLoRA可以在24GB显卡上微调33B模型,或在80GB显卡上微调70B模型。这是解决无法全量微调的最常用手段,而且效果通常很好。

  • 其他PEFT方法:如Adapter、Prefix Tuning、IA³等,同样可减少可训练参数。

选项二:模型并行(张量并行TP / 流水线并行PP)

  • 张量并行:将模型层内权重切分到多卡(如TP=4),每卡显存压力降至1/TP。需要高速卡间互联(NVLink),通常用于单机多卡。

  • 流水线并行:将模型不同层放在不同卡上,适合跨机,通信开销小。但需要微批次调度,实现复杂。

  • 对于无法单卡装入的70B全量微调,通常需要TP=4或8(在8卡A100上),并配合PP。

选项三:ZeRO优化(DeepSpeed ZeRO-3)

  • ZeRO-3将模型参数、梯度和优化器状态全部分片到多卡,单卡只存1/N。它本质上是数据并行+状态分片,对于70B模型,通常需8卡A100 80GB搭配ZeRO-3进行全量微调。这是最常用的多卡全量微调方案。

选项四:CPU/NVMe卸载(Offloading)

  • DeepSpeed ZeRO-Offload:将优化器状态和梯度卸载到CPU内存,甚至进一步卸载到NVMe硬盘(ZeRO-Infinity)。这使得单卡可以训练几十B的模型(如用一张A100 80GB配合大内存CPU训练30B模型),但速度会显著下降。

  • 适用于显存极度受限但内存充裕的场景,通常作为最后手段。

选项五:混合精度与量化训练

  • 使用BF16/FP16混合精度可将显存减半。若仍不够,可以使用INT8训练(如bitsandbytes的8-bit优化器),但这会影响训练精度。更激进的有4-bit训练,但通常只用在QLoRA的冻结权重上。

选项六:梯度检查点(Gradient Checkpointing)

  • 用计算换显存,大幅降低激活内存。这是必开选项,无论采用何种方案,开启它都能显著降低峰值显存。

综合方案举例:

  • 微调70B全量:8卡A100 80GB,使用ZeRO-3 + 混合精度 + 梯度检查点 + FlashAttention。

  • 微调70B(低成本):单卡48GB,使用QLoRA(4-bit基座)+ 梯度检查点 + 较小batch size。

  • 微调175B:必须TP/PP + ZeRO-3 + CPU Offload 等组合。

✅ 总结:无法单卡全量SFT时,技术选项包括PEFT(最易行)、模型并行、ZeRO-3分片、卸载以及混合精度。通常首选QLoRA,若必须全量微调,则结合ZeRO-3和模型并行。