说到大型语言模型(LLM)的训练,预训练和后续的对齐(SFT)阶段,虽然表面上都用loss函数来驱动模型学习,但背后的设计逻辑和目标其实天差地别。很多同学刚接触时容易混淆,觉得反正都是“算损失、反向传播”,但仔细拆开来看,这两个阶段的loss从数据、目标到代码实现,都有本质上的不同。下面我们就从原理到代码,把这两者的差异彻底捋清楚。
1. 预训练阶段
- 目标:让模型学会语言的通用表示——语法、语义、常识知识,甚至一些推理能力。
- 数据:海量、无标注的原始文本,比如整个互联网的网页、书籍、代码仓库。
- Loss函数:自监督学习。主流方案有两种:
- Masked Language Modeling (MLM) —— 随机遮住句子中的词,让模型猜被遮住的是什么。
- Causal Language Modeling (CLM) —— 从左到右,根据已经出现的词预测下一个词。
- Loss特点:
- 关注模型对语言结构和知识的整体理解。
- 数值通常较大,因为模型要从零开始学习海量信息。
- 随着训练进行,loss逐步下降,意味着模型对语言的建模能力在持续提升。
- 预训练Loss代码解析:以transformers库的源码为例,loss计算封装在Trainer的
compute_loss中,实际由LabelSmoother类完成。以下是核心实现:
544 @dataclass
545 class LabelSmoother:
546 """
547 Adds label-smoothing on a pre-computed output from a Transformers model.
548
549 Args:
550 epsilon (`float`, *optional*, defaults to 0.1):
551 The label smoothing factor.
552 ignore_index (`int`, *optional*, defaults to -100):
553 The index in the labels to ignore when computing the loss.
554 """
555
556 epsilon: float = 0.1
557 ignore_index: int = -100
558
559 def __call__(self, model_output, labels, shift_labels=False):
560 logits = model_output["logits"] if isinstance(model_output, dict) else model_output[0]
561 if shift_labels:
562 logits = logits[..., :-1, :].contiguous()
563 labels = labels[..., 1:].contiguous()
564
565 log_probs = -nn.functional.log_softmax(logits, dim=-1)
566 if labels.dim() == log_probs.dim() - 1:
567 labels = labels.unsqueeze(-1)
568
569 padding_mask = labels.eq(self.ignore_index)
570 # In case the ignore_index is -100, the gather will fail, so we replace labels by 0. The padding_mask
571 # will ignore them in any case.
572 labels = torch.clamp(labels, min=0)
573 nll_loss = log_probs.gather(dim=-1, index=labels)
574 # works for fp16 input tensor too, by internally upcasting it to fp32
575 smoothed_loss = log_probs.sum(dim=-1, keepdim=True, dtype=torch.float32)
576
577 nll_loss.masked_fill_(padding_mask, 0.0)
578 smoothed_loss.masked_fill_(padding_mask, 0.0)
579
580 # Take the mean over the label dimensions, then divide by the number of active elements (i.e. not-padded):
581 num_active_elements = padding_mask.numel() - padding_mask.long().sum()
582 nll_loss = nll_loss.sum() / num_active_elements
583 smoothed_loss = smoothed_loss.sum() / (num_active_elements * log_probs.shape[-1])
584 return (1 - self.epsilon) * nll_loss + self.epsilon * smoothed_loss
- shift_labels:是否需要位移。当设置
shift_labels=True时,logits取[:-1, :](第0到倒数第二个位置的预测),labels取[1:](第1到最后一个位置的真实token)。这样做的意思是,用第0到倒数第二个位置的输出去预测第1到最后一个位置的真实词,实现自回归预测。 - log_probs:先取log_softmax,将模型输出的logits转换成对数概率分布。
- padding_mask:通过
labels.eq(self.ignore_index)找到label中填充的特殊token(通常是pad_token_id),然后把这些位置在loss中忽略掉。 - nll_loss:核心负对数似然。使用
gather(dim=-1, index=labels),从log_probs中取出与真实标签对应的对数概率值。这里有个细节:log_probs的shape是(batch_size, sequence_length, vocab_size),labels的shape是(batch_size, sequence_length),gather会沿着vocab_size维度取出指定索引的值。比如log_probs[0, 2, 500]表示第一个样本第三个词预测为词汇表第500个词的对数概率,而labels[0, 2]就是该位置的真实词索引。取出来之后再做平滑处理,并去掉padding的影响。
2. 对齐阶段 (SFT: Supervised Fine-Tuning)
- 目标:将预训练模型的能力“对齐”到特定任务或场景,比如对话生成、文本摘要、机器翻译,或者把LLM落地到某个垂直业务。
- 数据:人工标注的高质量数据,例如对话记录、摘要文章、翻译句对、垂类业务问答对。
- Loss函数:监督学习。根据任务选择:
- Cross-Entropy Loss:用于分类任务(情感分析、意图识别等)。
- Mean Squared Error (MSE) Loss:用于回归任务(文本评分、质量评估等)。
- Loss特点:
- 关注模型在特定任务上的表现,而不是通用语言知识。
- 数值通常较小,因为模型已经在预训练阶段学好了基础,只需微调。
- 随着训练进行,loss下降,说明模型在目标任务上越做越好。
- 对齐SFT Loss代码解析:不同框架实现细节略有差异,但核心思路一致:先剔除prompt部分(不让模型学习如何理解指令),只对输出(answer)部分计算loss。以开源框架LLaMA-Factory为例,其SFT loss计算在
train/sft/trainer.py的prediction_step中:
81 @override
82 def prediction_step(
83 self,
84 model: "torch.nn.Module",
85 inputs: Dict[str, Union["torch.Tensor", Any]],
86 prediction_loss_only: bool,
87 ignore_keys: Optional[List[str]] = None,
88 ) -> Tuple[Optional[float], Optional["torch.Tensor"], Optional["torch.Tensor"]]:
89 r"""
90 Removes the prompt part in the generated tokens.
91
92 Subclass and override to inject custom beha vior.
93 """
94 labels = inputs["labels"] if "labels" in inputs else None
95 if self.args.predict_with_generate:
96 assert self.tokenizer.padding_side == "left", "This method only accepts left-padded tensor."
97 labels = labels.detach().clone() if labels is not None else None # backup labels
98 prompt_len, label_len = inputs["input_ids"].size(-1), inputs["labels"].size(-1)
99 if prompt_len > label_len:
100 inputs["labels"] = self._pad_tensors_to_target_len(inputs["labels"], inputs["input_ids"])
101 if label_len > prompt_len: # truncate the labels instead of padding the inputs (llama2 fp16 compatibility)
102 inputs["labels"] = inputs["labels"][:, :prompt_len]
103
104 loss, generated_tokens, _ = super().prediction_step( # ignore the returned labels (may be truncated)
105 model, inputs, prediction_loss_only=prediction_loss_only, ignore_keys=ignore_keys
106 )
107 if generated_tokens is not None and self.args.predict_with_generate:
108 generated_tokens[:, :prompt_len] = self.tokenizer.pad_token_id
109 generated_tokens = generated_tokens.contiguous()
110
111 return loss, generated_tokens, labels
112
113 def _pad_tensors_to_target_len(self, src_tensor: "torch.Tensor", tgt_tensor: "torch.Tensor") -> "torch.Tensor":
114 r"""
115 Pads the tensor to the same length as the target tensor.
116 """
117 assert self.tokenizer.pad_token_id is not None, "Pad token is required."
118 padded_tensor = self.tokenizer.pad_token_id * torch.ones_like(tgt_tensor)
119 padded_tensor[:, -src_tensor.shape[-1] :] = src_tensor # adopt left-padding
120 return padded_tensor.contiguous() # in contiguous memory
- 关键逻辑:SFT计算loss时,prompt部分(即指令或输入)不参与。做法是:将labels中对应prompt的位置用
pad_token_id掩码掉,这样后续调用预训练阶段的loss计算时,那些位置就会被忽略(因为预训练loss会通过ignore_index跳过padding)。具体来看:padding_side == "left":要求左填充,这是生成任务常用的设置。- 如果
prompt_len > label_len,说明labels长度短于输入(可能因为labels只包含输出部分),需要用_pad_tensors_to_target_len把labels向左填充到和input_ids一样长,填充值用pad_token_id,这样prompt对应的位置就被mask了。 - 如果
label_len > prompt_len,则截断labels使之与input_ids等长。 - 处理好之后,调用父类(transformers的Trainer)的
prediction_step,此时loss只计算输出部分(因为prompt部分已被标记为忽略)。
总结
| 特征 | 预训练 | 对齐 (SFT) |
|---|---|---|
| 目标 | 学习通用语言表示 | 迁移到特定任务 |
| 数据 | 海量未标注数据 | 高质量标注数据 |
| Loss函数 | 自监督学习 (MLM, CLM) | 监督学习 (Cross-Entropy, MSE) |
| Loss特点 | 数值较大,关注语言理解 | 数值较小,关注任务表现 |
当然,现实中的训练方案往往更灵活。比如某些预训练任务会混入少量标注数据,而部分对齐任务也会采用对比学习之类的自监督方法。但核心框架就是这样:预训练负责打基础,SFT负责让模型“听懂人话”,两者在loss上的设计差异,从根本上决定了模型最终的能力边界。
