【Bug已解决】Understanding loss in Training LLM 解决方案一、现象长什么样训练自己的 LLM用transformers的Trainer或自己写的训练循环时遇到一类「看不懂 loss」的问题loss 数值异常大比如 10、20怎么调学习率都下不来loss 看上去在降但模型生成全是乱码、复读验证集 loss 比训练集还低或者两边都不正常切换到不同tokenizer/ 不同 padding 策略后loss 量级突然变了但模型结构没动。最典型的复现把一批长短不一的样本 pad 到同一长度送进模型直接用input_ids当labels算交叉熵发现 loss 被 padding 位置严重拉高训练目标其实是「学会预测padding token」而不是「学会预测下一个真实 token」。这类问题不报错但训练出来的模型就是「不懂人话」——因为 loss 的含义从一开始就算错了。二、背景自回归 LLM 的训练目标是「给定前 i 个 token预测第 i1 个 token」。交叉熵 loss 对每个位置算一次再平均。关键点padding 位置不该参与 loss。transformers的model(**inputs)在传入labels时会自动对labels -100的位置跳过用ignore_index。但很多人这么写# 错误写法直接把 input_ids 当 labels outputs model(input_idsbatch, labelsinput_ids) loss outputs.loss如果 batch 里有 paddinginput_ids含pad_token_id那么 padding 位置也被当成「要预测的真实标签」模型被迫去预测pad_token_id这些位置的 loss 被算进平均。后果loss 被 padding 稀释/拉高短样本多的 batchpadding 占比大loss 大部分在「学 padding」真实语义信号被淹没。训练目标错位模型花大量精力拟合 padding生成时容易吐 padding 或复读。跨 tokenizer 不可比不同 tokenizer 的 pad 比例不同loss 量级跟着变你以为换了模型其实只是 pad 多了。正确理解 loss 的前提就是「让 padding 不参与 loss」。三、根因根因一句话训练时labels没有把 padding 位置标成ignore_index-100导致交叉熵把 padding 也当成要预测的目标loss 含义错误、训练目标错位。三点展开padding 参与计算labels input_ids让 pad 位置进入 lossignore_index没生效。平均基准错loss 平均的分母包含 padding 位置数真实 token 的梯度被稀释。3.缺校验没有在送入模型前断言labels里 padding 已被-100覆盖于是错误静默存在。不是模型不会学是「学什么」被 padding 污染了。四、最小可运行复现不依赖真实大模型用一个最小交叉熵演示 padding 如何污染 lossimport torch import torch.nn.functional as F vocab, seq 10, 6 pad_id 0 logits torch.randn(1, seq, vocab) # 模型输出未归一化 targets_raw torch.tensor([[1, 2, 3, pad_id, pad_id, pad_id]]) # 含 padding # 错误直接拿含 pad 的 target 算 loss loss_with_pad F.cross_entropy( logits.view(-1, vocab), targets_raw.view(-1) ) # 正确padding 标成 ignore_index-100 targets_masked targets_raw.clone() targets_masked[targets_raw pad_id] -100 loss_no_pad F.cross_entropy( logits.view(-1, vocab), targets_masked.view(-1), ignore_index-100 ) print(含 padding 的 loss:, round(loss_with_pad.item(), 4)) print(忽略 padding 的 loss:, round(loss_no_pad.item(), 4)) print(两者是否相同:, torch.isclose(loss_with_pad, loss_no_pad))跑出来含 padding 的 loss 把 3 个 pad 位置也学进去了数值和「只看真实 3 个 token」的 loss 明显不同pad 多时差异更大。这就是「loss 算错」的精确复现。五、解决方案第一层最小直接修复最小修复构造labels时把所有pad_token_id位置替换成-100再送进模型。import torch from transformers import AutoModelForCausalLM, AutoTokenizer tokenizer AutoTokenizer.from_pretrained(your-model) model AutoModelForCausalLM.from_pretrained(your-model) def make_labels(input_ids: torch.Tensor) - torch.Tensor: labels input_ids.clone() # 关键padding 位置标成 -100交叉熵忽略它 labels[labels tokenizer.pad_token_id] -100 return labels # 训练循环 for batch_input_ids in dataloader: labels make_labels(batch_input_ids) outputs model(input_idsbatch_input_ids, labelslabels) loss outputs.loss # 现在只统计真实 token loss.backward() optimizer.step() optimizer.zero_grad()如果做「下一 token 预测」且输入已经是「输入标签移位」的格式注意自回归模型内部会自己处理移位你只需保证labels里 padding 是-100不要把labels再做一次[:, 1:]移位那会和模型内部的 shift 重复。要点labels[labels pad_token_id] -100一行解决 padding 污染。model(..., labelslabels)内部用ignore_index-100自动跳过。loss 现在只反映「真实 token 的预测质量」量级和训练目标都正确。这一步单独就让 loss 回归正确含义。六、解决方案第二层结构性改进第一层是「在循环里加一行」。但训练脚本里多个数据路径SFT、预训练、带 mask 的指令数据都构造 labels容易漏。更稳的做法把「labels 如何正确屏蔽 padding / 特殊 token」收敛成单一策略对象。from dataclasses import dataclass, field from typing import List, Optional import torch dataclass class LlmLossAuditor: LLM 训练 loss 标签屏蔽的单一策略。 # 需要忽略的 token id 集合padding、特殊 token 等 ignore_ids: List[int] field(default_factorylist) # 是否同时忽略序列左侧prompt只学回答SFT 常用 train_on_completion_only: bool False # completion 起始标记SFT 用 response_start_id: Optional[int] None def build_labels(self, input_ids: torch.Tensor) - torch.Tensor: labels input_ids.clone() for ig in self.ignore_ids: labels[labels ig] -100 if self.train_on_completion_only and self.response_start_id is not None: # 找到每个样本里 response_start 的位置其之前全标 -100 mask (input_ids self.response_start_id) # 用 cumsumstart 之前为 0之后为 1 pos mask.cumsum(dim-1) labels[pos 0] -100 return labels def check(self, labels: torch.Tensor): # 防御整行全 -100 意味着该样本无监督信号 all_ignored (labels -100).all(dim-1) if all_ignored.any(): print(f[LlmLossAuditor] 警告: {int(all_ignored.sum())} 条样本整行被忽略) # 用法 auditor LlmLossAuditor(ignore_ids[tokenizer.pad_token_id, tokenizer.bos_token_id]) for ids in dataloader: labels auditor.build_labels(ids) auditor.check(labels) loss model(input_idsids, labelslabels).loss loss.backward(); optimizer.step(); optimizer.zero_grad()结构收益单一策略padding、特殊 token、SFT「只学回答」的屏蔽都集中在一处。-可校验check抓出「整行无监督」的废样本。可扩展加新的忽略规则只改LlmLossAuditor不动训练循环。七、解决方案第三层断言 / CI 守护写 pytest 守三条(1) padding 被标-100(2) 真实 token 不被误标(3) 计算出的 loss 与「仅真实 token」一致。import torch import torch.nn.functional as F import pytest from your_lib import LlmLossAuditor pytest.fixture def auditor(): return LlmLossAuditor(ignore_ids[0]) # 假设 pad_id0 def test_pad_masked_to_neg100(auditor): ids torch.tensor([[1, 2, 0, 0]]) labels auditor.build_labels(ids) assert labels[0, 2].item() -100 assert labels[0, 3].item() -100 def test_real_tokens_kept(auditor): ids torch.tensor([[1, 2, 0, 0]]) labels auditor.build_labels(ids) assert labels[0, 0].item() 1 assert labels[0, 1].item() 2 def test_loss_ignores_pad(): vocab, seq 10, 4 pad_id 0 logits torch.randn(1, seq, vocab) raw torch.tensor([[1, 2, pad_id, pad_id]]) masked raw.clone(); masked[raw pad_id] -100 l_pad F.cross_entropy(logits.view(-1, vocab), raw.view(-1)) l_mask F.cross_entropy(logits.view(-1, vocab), masked.view(-1), ignore_index-100) assert not torch.isclose(l_pad, l_mask), 含 pad 的 loss 应与忽略 pad 的不同 def test_completion_only_mode(): a LlmLossAuditor(ignore_ids[0], train_on_completion_onlyTrue, response_start_id5) ids torch.tensor([[1, 5, 6, 7]]) # 5 之后才是回答 labels a.build_labels(ids) assert labels[0, 0].item() -100 # prompt 部分忽略 assert labels[0, 1].item() -100 # response_start 本身可忽略 assert labels[0, 2].item() 6 # 回答部分保留CI 常驻跑这四条后任何「padding 又混进 loss」「真实 token 被误标」的回归都会立刻爆红。八、排查清单训练 LLM「loss 看不懂」时按顺序查先打印labels里pad_token_id是否还在——在就说明 padding 参与了 loss。确认labels是input_ids的克隆并做了-100替换而不是直接用input_ids。确认用的loss来自model(..., labelslabels).loss而不是自己手写的、没传ignore_index的F.cross_entropy。SFT 场景确认是否「只学回答」prompt 部分应标-100否则模型在学复述问题。确认ignore_index-100与模型内部一致transformers 默认就是 -100别改成别的。换 tokenizer 后重新核对pad_token_id不同 tokenizer 的 pad id 可能不同。批量打印几个样本的labels肉眼确认除 pad/特殊位外真实 token 都保留。九、小结「训练 LLM 但 loss 看不懂 / 模型学不会」的常见根子是labels没把 padding 标成ignore_index-100交叉熵把 padding 也当成学习目标loss 含义错位、训练目标被污染。修复三层次第一层构造 labels 时labels[labelspad_token_id] -100第二层用LlmLossAuditordataclass 把 padding/特殊 token/SFT「只学回答」的屏蔽收敛为单一策略并加整行忽略校验第三层用 pytest 守「padding 被标 -100」「真实 token 保留」「含 pad 与忽略 pad 的 loss 不同」「completion-only 正确」。工程启示自回归训练里「labels 怎么构造」决定了「模型学什么」。padding 必须-100、promptSFT 时必须-100、特殊 token 通常也要-100。任何训练脚本上线前先肉眼看一眼labels再训比训完发现模型废了再回头查省事得多。