| # 面试:全量微调(Full SFT)深度 |
|
|
| > 本仓库 `src/trainers/lm/full_sft.py`,对应 config:`configs/lm/lm_full_sft.yaml` |
| |
| ## 0. 整体流程 |
| |
| ``` |
| 数据集 (sft_t2t_mini.jsonl) |
| │ 60,000 条多轮对话 |
| ▼ |
| SFTDataset |
| ├── 解析 JSON 对话格式 |
| ├── 拼接多轮对话(含 <s>system</s> <s>user</s> <s>assistant</s> 标记) |
| ├── input_ids:全部 token 的 id |
| ├── labels:prompt 段 → -100,assistant 段 → input_ids |
| └── 截断 / 填充到 max_seq_len |
| │ |
| ▼ |
| DataLoader → model(input_ids, labels) |
| │ |
| ├── 🔥 加载预训练权重(from_weight=pretrain) |
| │ └── model_dir 指定权重源目录 |
| │ |
| ├── 全量参数更新(所有参数参与训练) |
| │ |
| └── Loss = CE(shift_logits, shift_labels) |
| 只计算 labels != -100 的位置 |
| │ |
| ▼ |
| AdamW + Cosine LR → 保存 checkpoint |
| ``` |
| |
| --- |
|
|
| ## Q1. SFTDataset 如何处理多轮对话? |
|
|
| ### 输入格式 |
|
|
| ```json |
| { |
| "conversations": [ |
| {"from": "system", "value": "你是一个 AI 助手"}, |
| {"from": "user", "value": "你好"}, |
| {"from": "assistant", "value": "你好!有什么可以帮你的?"}, |
| {"from": "user", "value": "什么是 LLM?"}, |
| {"from": "assistant", "value": "LLM 是大型语言模型..."} |
| ] |
| } |
| ``` |
|
|
| ### 编码拼接(`src/dataset/sft.py`) |
|
|
| ```python |
| def _build_conversation(self, conversation): |
| input_ids, labels = [], [] |
| for turn in conversation: |
| speaker = turn['from'] |
| text = turn['value'] |
| # 用特殊标记包裹每条发言 |
| tokens = self.tokenizer.encode(f'<s>{speaker}</s>\n{text}\n') |
| if speaker == 'assistant': |
| input_ids += tokens |
| labels += tokens # 回复段参与损失 |
| else: |
| input_ids += tokens |
| labels += [-100] * len(tokens) # prompt 段忽略损失 |
| return input_ids[:self.max_length], labels[:self.max_length] |
| ``` |
|
|
| ### 为什么 prompt 段 label 置 -100? |
|
|
| - `nn.CrossEntropyLoss(ignore_index=-100)` 自动忽略这些位置的损失 |
| - **只学习回复内容**:模型只需要学会生成 assistant 的回答,不需要拟合用户的输入 |
| - **保持 prompt 长度灵活性**:不需要 mask attention,只是不算损失 |
|
|
| ### 极端情况 |
|
|
| 如果整条对话 `max_seq_len` 截断后只包含 prompt 段(user 说的话),那么所有的 labels 都是 -100,loss = 0。这种情况虽然罕见但需要注意——数据预处理时应该过滤掉这种样本。 |
|
|
| > 面试点:如果一条对话全部被截断为 prompt 端怎么办?→ loss=0,该样本对训练无贡献;需要在数据预处理时过滤或截断时尽量保留 assistant 段 |
|
|
| --- |
|
|
| ## Q2. 加载预训练权重机制 |
|
|
| ### 权重加载流程 |
|
|
| ``` |
| 1. from_weight=pretrain |
| │ |
| 2. 确定权重路径: |
| model_dir / from_weight_hidden_size.pth |
| → checkpoint/lm_pretrain/pretrain_512.pth |
| │ |
| 3. torch.load(..., map_location=device) |
| │ |
| 4. model.load_state_dict(weights, strict=False) |
| │ |
| 5. 选择不加载 lm_head.weight(可选) |
| ``` |
|
|
| ### 代码实现 |
|
|
| ```python |
| # src/utils/training.py:145-168 |
| def init_model(config, save_dir, weight_type='pretrain', model_dir=None): |
| if weight_type != 'none': |
| weight_dir = model_dir or save_dir # model_dir 优先 |
| weight_path = f'{weight_dir}/{weight_type}_{config.hidden_size}.pth' |
| weights = torch.load(weight_path, map_location=device) |
| # 可选择跳过 lm_head(如新增词表时) |
| # weights = {k: v for k, v in weights.items() if 'lm_head' not in k} |
| model.load_state_dict(weights, strict=False) |
| ``` |
|
|
| ### model_dir vs save_dir |
|
|
| | 参数 | 作用 | 默认值 | |
| |---|---|---| |
| | `save_dir` | checkpoint 写入目录 | 配置中的 `paths.save_dir` | |
| | `model_dir` | checkpoint 读取目录 | 未设置时 = save_dir | |
| |
| 为什么要分开? |
| - pretrain 权重存在 `checkpoint/lm_pretrain/` |
| - SFT 权重存在 `checkpoint/lm/` |
| - SFT 需要从 pretrain 目录**加载**,但**保存**到自己目录 |
| - 没有 `model_dir` 时会在 `checkpoint/lm/` 下找 `pretrain_512.pth`,找不到 |
|
|
| ### 不同 from_weight 的语义 |
| |
| | from_weight | 用途 | 加载的文件 | |
| |---|---|---| |
| | `none` | 从头训练 | 不加载(随机初始化) | |
| | `pretrain` | pretrain → SFT 持续训练 | `pretrain_{h}.pth` | |
| | `full_sft` | SFT 继续训练/增量 SFT | `full_sft_{h}.pth` | |
|
|
| ### strict=False 的注意事项 |
|
|
| - 允许权重文件和模型结构**不完全一致** |
| - 常见的 mismatch 来源: |
| 1. embedding 和 lm_head 的 weight tying(两个 key 映射到同一个参数字典) |
| 2. 词表大小变化(新增 special token 后加载旧权重) |
| 3. 模型架构微调(如增减层数) |
| - `strict=False` 会静默忽略多出的键(缺少的键会随机初始化) |
| |
| > 面试点:strict=False 实际可能导致模型部分随机初始化而不报错,如何确保没有遗漏?→ 加载后比较 `model.state_dict().keys()` 和 `weights.keys()`,打印 missing_keys 和 unexpected_keys |
|
|
| --- |
|
|
| ## Q3. SFT 训练流程详解 |
|
|
| ### 训练循环(`src/trainers/lm/full_sft.py`) |
| |
| ```python |
| def train_epoch(epoch, model, loader, optimizer, scheduler, scaler, args): |
| model.train() |
| total_loss = 0 |
| for step, (input_ids, labels) in enumerate(loader): |
| input_ids = input_ids.cuda() |
| labels = labels.cuda() |
| |
| with autocast_ctx: |
| logits = model(input_ids) |
| loss = cross_entropy(logits[..., :-1, :].contiguous(), |
| labels[..., 1:].contiguous(), |
| ignore_index=-100) |
| loss = loss / args.accumulation_steps |
| |
| scaler.scale(loss).backward() |
| |
| if (step + 1) % args.accumulation_steps == 0: |
| scaler.unscale_(optimizer) |
| clip_grad_norm_(model.parameters(), args.grad_clip) |
| scaler.step(optimizer) |
| scaler.update() |
| optimizer.zero_grad(set_to_none=True) |
| scheduler.step() |
| |
| total_loss += loss.item() |
| ``` |
| |
| ### 与 Pretrain 训练循环的区别 |
|
|
| | | Pretrain | Full SFT | |
| |---|---|---| |
| | 是否加载预训练权重 | 否(from_weight=none) | 是(from_weight=pretrain) | |
| | 学习率 | 5e-4(从零学习) | 1e-5(微调,小 lr) | |
| | 训练轮数 | 2 | 2 | |
| | 数据集 | 纯文本 1.27M 条 | 指令对话 60K 条 | |
| | loss 计算 | 全 token | 仅 assistant 段 | |
| | weight_decay | 有 | 有(默认 0.1) | |
| |
| ### 为什么 SFT 学习率要小? |
| |
| - 预训练权重已经学到很好的语言表示 |
| - 大学习率会破坏/覆盖预训练知识(灾难性遗忘) |
| - 目标是在已有知识基础上"微调"指令跟随能力 |
| - 一般 pretrain LR : SFT LR ≈ 10:1 ~ 50:1 |
| |
| --- |
| |
| ## Q4. 灾难性遗忘(Catastrophic Forgetting) |
| |
| ### 什么灾难性遗忘? |
| |
| 模型在 SFT 阶段学会生成对话回复的同时,**丢失**了在预训练阶段学到的通用知识(如常识推理、知识问答能力)。 |
| |
| ### 为什么 SFT 会导致遗忘? |
| |
| ``` |
| 预训练: 语言 P(x₁...xₙ) ← 通用分布 |
| SFT: 条件 P(回复|指令) ← 狭窄分布 |
| ──────────────────────→ |
| 训练分布偏移 → 覆盖预训练权重 |
| ``` |
| |
| ### 缓解策略 |
| |
| 1. **小学习率**:1e-5 比 5e-4 小 50 倍,梯度更新量小 |
| 2. **少轮数**:1-2 轮足够,更多轮次会导致过拟合和遗忘 |
| 3. **保留预训练数据**:混合 SFT 数据 + 10-20% 预训练数据(本仓库未实现) |
| 4. **EWC / LwF**:正则化方法,限制重要参数的大幅更新(本仓库未实现) |
| 5. **LoRA**:增量微调,冻结原权重(本仓库另有 `src/trainers/lm/lora_sft.py`) |
|
|
| > 面试点:什么情况下灾难性遗忘最严重?→ 大量 SFT 数据 + 高学习率 + 多轮训练 + 领域单一的数据集 |
|
|
| --- |
|
|
| ## Q5. SFT 训练问题诊断 |
|
|
| ### Loss 正常值范围 |
|
|
| | 阶段 | Loss | 说明 | |
| |---|---|---| |
| | 初始(第 1 步) | ~7.0-8.5 | 刚加载 pretrain 权重,但换数据集后分布不同 | |
| | 收敛 | ~1.5-2.5 | 模型学会生成合理回复 | |
| | 过拟合 | < 1.0 | 训练 loss 极低但生成质量差(记忆而不是泛化) | |
|
|
| ### Loss 异常分析 |
|
|
| ``` |
| Loss 行为 可能原因 建议 |
| ──────────────────────────────────────────────────────────────────── |
| 初始 loss 极低 (<2.0) pretrain 数据集和 SFT 高度重叠 check 数据分布 |
| loss 不下降 (<30 步) 学习率太小 / 模型冻结 check 梯度 |
| loss 突增 学习率太大 / 梯度爆炸 减少 lr / 加强 grad_clip |
| loss 震荡 batch 太小 / lr 太高 调大 batch / 减小 lr |
| loss 降到 0.0 所有 label 为 -100 检查数据截断 |
| val loss 上升但 train train loss 下降过拟合 early stopping / 正则化 |
| ``` |
|
|
| --- |
|
|
| ## Q6. 推理时生成差异 |
|
|
| ### 训练 vs 推理行为对比 |
|
|
| ```python |
| # 训练时 |
| model.train() |
| logits = model(input_ids) # 全部序列 |
| loss = cross_entropy(shift_logits, shift_labels) # 不采样,计算损失 |
| |
| # 推理时 |
| model.eval() |
| generated = model.generate(input_ids, max_new_tokens=256, do_sample=True, temperature=0.7) |
| ``` |
|
|
| ### 推理超参 |
|
|
| | 参数 | 作用 | SFT 推荐值 | |
| |---|---|---| |
| | `do_sample` | 是否采样(否则 greedy) | `True` | |
| | `temperature` | 采样温度,越高越随机 | 0.7-0.9 | |
| | `top_k` | 只从前 K 个 token 采样 | 40-50 | |
| | `top_p` | 核采样(累积概率 p) | 0.9 | |
| | `repetition_penalty` | 重复惩罚 | 1.05-1.15 | |
|
|
| ### 温度对比 |
|
|
| ``` |
| Temperature=0.1: "今天天气真好,我们去散步吧。" |
| Temperature=0.7: "今天天气真好,要不出去走走?" |
| Temperature=1.5: "天气不错,散步散步散步吧...哦不对不对呵呵呵" |
| ``` |
|
|
| - 温度太低 → 输出机械、重复 |
| - 温度太高 → 输出发散、语无伦次 |
| - 0.7 是创造性 + 连贯性的良好平衡点 |
|
|
| --- |
|
|
| ## Q7. YAML 配置详解 |
|
|
| ### 结构说明 |
|
|
| ```yaml |
| model: |
| hidden_size: 512 # 模型容量:影响参数量和激活值大小 |
| num_hidden_layers: 8 # Transformer 层数 |
| use_moe: 0 # MoE 开关 |
| vocab_size: 6400 # 词表大小 |
| max_seq_len: 768 # SFT 通常需要更长上下文 |
| num_attention_heads: 8 # Q 头数 |
| num_key_value_heads: 4 # K/V 头数(GQA,4:8=2x 压缩) |
| dropout: 0.0 # SFT 一般不加 dropout |
| |
| train: |
| epochs: 2 |
| batch_size: 16 # 受显存限制 |
| learning_rate: 1.0e-5 # 微调用小 lr |
| accumulation_steps: 1 |
| grad_clip: 1.0 |
| dtype: bfloat16 |
| save_interval: 1000 |
| log_interval: 100 |
| from_weight: pretrain # 加载预训练权重 |
| model_dir: checkpoint/lm_pretrain # 预训练权重来源目录 |
| from_resume: 0 |
| |
| paths: |
| save_dir: checkpoint/lm # 训练产物存放目录 |
| data_path: dataset/lm/sft.jsonl |
| ``` |
|
|
| ### max_seq_len 为什么比 pretrain 大? |
|
|
| | | Pretrain | SFT | |
| |---|---|---| |
| | max_seq_len | 340 | 768 | |
| | 原因 | 预训练数据多为短文本(如 BERT 风格片段) | 多轮对话需要更多空间 | |
|
|
| 对话拼接后 token 数≈ sum of turns,通常比单篇文本长。 |
|
|
| --- |
|
|
| ## Q8. AdamW 优化器 |
|
|
| ### 与 Adam 的区别 |
|
|
| ```python |
| # Adam: w_{t+1} = w_t - lr * m_hat / (sqrt(v_hat) + eps) # 无 weight decay |
| # AdamW: w_{t+1} = w_t - lr * (m_hat / (sqrt(v_hat) + eps) + λ*w_t) |
| # └─────────────────────────────┬──────────────┘ |
| # └ weight decay 与梯度更新解耦 |
| ``` |
|
|
| Adam 将 weight decay 和 L2 正则化混在一起(L2 = 在 loss 上加 λ/2 × ||w||²),而 AdamW 将 weight decay 从自适应学习率中解耦出来。 |
|
|
| ### 为什么 AdamW 更好? |
|
|
| | | Adam (L2) | AdamW | |
| |---|---|---| |
| | Decay 位置 | loss 函数中(对梯度贡献) | optimizer 更新时独立加 | |
| | 自适应影响 | decay 也被 m_hat/v_hat 缩放 | decay 不受影响 | |
| | 实际效果 | 大学习率下 decay 被自适应削弱 | 稳定的 decay 效果 | |
| | 业界标准 | 旧方法 | GPT/LLaMA 等现代模型标配 | |
|
|
| ### 本仓库配置 |
|
|
| ```python |
| optimizer = AdamW(model.parameters(), lr=args.learning_rate, weight_decay=0.1) |
| ``` |
|
|
| - `weight_decay=0.1` 是常见推荐值 |
| - 一般不对 bias 和 norm 参数做 weight decay(但此项目未做区分) |
| - PyTorch 的 AdamW 默认 `betas=(0.9, 0.999)`, `eps=1e-8` |
|
|
| --- |
|
|
| ## Q9. SFT 评估方法 |
|
|
| ### 评估维度 |
|
|
| | 维度 | 评估方式 | 指标 | |
| |---|---|---| |
| | 指令跟随 | 人工/模型评估 | 是否按指令完成 | |
| | 生成质量 | 人工评分 | 连贯性/有用性/安全性 | |
| | 多样性 | 统计 | distinct-1/2, ngram 重复率 | |
| | 知识正确性 | 基准测试 | MMLU, CEval, CMMLU | |
|
|
| ### 本仓库评估脚本(`scripts/eval_llm.py`) |
| |
| ```bash |
| # 原生 torch 格式 |
| python scripts/eval_llm.py --native --save_dir checkpoint/lm_full_sft_mini \ |
| --weight full_sft --hidden_size 128 |
| |
| # HuggingFace 格式 |
| python scripts/eval_llm.py --load_from checkpoint/omni/native_hf \ |
| --tokenizer_path checkpoint/omni/native_hf |
| ``` |
| |
| ``` |
| 生成评估结果(示例): |
| ──────────────────────────────────── |
| User: 讲个笑话 |
| Assistant: 为什么程序员总把万圣节和圣诞节搞混? |
| 因为 Oct 31 == Dec 25! |
| ──────────────────────────────────── |
| User: 用 Python 写一个快速排序 |
| Assistant: def quicksort(arr): |
| if len(arr) <= 1: return arr |
| pivot = arr[len(arr)//2] |
| left = [x for x in arr if x < pivot] |
| mid = [x for x in arr if x == pivot] |
| right = [x for x in arr if x > pivot] |
| return quicksort(left) + mid + quicksort(right) |
| ──────────────────────────────────── |
| ``` |
| |
| ### 常见问题 |
| |
| - **回复过短**:「是的」「好的」→ 数据集过于简单或数据量不够 |
| - **回复重复**:不断生成相同短语 → 温度太低或 repetition_penalty 太小 |
| - **偏离主题**:模型开始乱说 → 训练不足或 temperature 太高 |
| - **不能按格式输出**:要求 JSON 但输出自然语言 → 数据集中缺乏格式化示例 |
|
|
| --- |
|
|
| ## Q10. SFT vs RLHF 的关系 |
|
|
| ### SFT 的局限性 |
|
|
| 1. **模仿而非优化**:SFT 只是让模型模仿人工回复分布,不是优化最终效果 |
| 2. **暴露偏差**:训练时使用 teacher forcing(每步输入真实 token),推理时输入是自生成的 token,分布偏移 |
| 3. **缺乏偏好对齐**:所有训练样本被视为同等正确,区分不出"好回答"和"更好回答" |
|
|
| ### RLHF 如何解决? |
|
|
| ``` |
| SFT 阶段:模仿示范数据 |
| │ |
| ▼ |
| Reward 模型训练:学习偏好排序 |
| │ |
| ▼ |
| PPO 阶段:以 reward 为信号优化策略 |
| │ |
| ▼ |
| 结果:模型知道什么"更好",不仅仅是"像什么" |
| ``` |
|
|
| ### 本仓库的 RL 系列 |
|
|
| - `src/trainers/lm/dpo.py`:Direct Preference Optimization(PPO 的简化替代) |
| - `src/trainers/lm/ppo.py`:Proximal Policy Optimization(标准 RLHF) |
| - `src/trainers/lm/grpo.py`:Group Relative Policy Optimization(DeepSeek 方案) |
| - `src/trainers/lm/distill.py`:知识蒸馏 |
|
|
| > 面试点:SFT 和 RLHF 的核心区别是什么?→ SFT 是监督学习(模仿示范),RLHF 是从偏好信号中学习优化(区分好与更好) |
|
|
| --- |
|
|
| ## Q11. Teacher Forcing 与 Exposure Bias |
|
|
| ### Teacher Forcing |
|
|
| ```python |
| # 训练时:每次输入真实 token |
| for t in range(seq_len): |
| logit = model(input_ids[:, :t+1]) |
| loss = CE(logit[:, t, :], labels[:, t]) |
| |
| # 等价于一次性算全部 |
| logits = model(input_ids) |
| loss = CE(shift_logits, shift_labels) |
| ``` |
|
|
| ### 问题:Exposure Bias |
|
|
| ``` |
| 训练时: |
| input: "中国的首都是" → 模型预测 → "北京" |
| 实际输入下一时间步: "北京" (真实 token) |
| |
| 推理时: |
| input: "中国的首都是" → 模型预测 → "上海" (错误!) |
| 实际输入下一时间步: "上海" (自己的预测, 错上加错) |
| |
| 训练分布 ≠ 推理分布 → 累积误差 |
| ``` |
|
|
| ### 缓解方法 |
|
|
| 1. **Scheduled Sampling**:推理时以一定概率用模型自己的预测替换真实 token(本仓库未实现,但面试常考) |
| 2. **强化学习**(RLHF阶段):直接在自生成序列上优化 |
| 3. **Beam Search**:推理时维护候选路径,减少单步错误的累积影响 |
|
|
| --- |
|
|
| ## Q12. 实际训练资源估算 |
|
|
| ### 30M 模型 SFT 成本 |
|
|
| | 项目 | 估算 | |
| |---|---| |
| | 参数量 | ~30M(hidden_size=512, L=8) | |
| | 总步数 | `ceil(60000/16) × 2 = 7500` | |
| | 每步时间 | ~250ms (RTX 4060) | |
| | 总时间 | `7500 × 0.25 ≈ 31 分钟` | |
| | 峰值显存 | ~4-5 GB(bf16, bs=16, seq=768) | |
| | 权重大小 | ~60 MB(fp16 保存) | |
| |
| ### 大数据全量 SFT 估算(实际生产) |
| |
| | 数据量 | batch_size | 步数 | 每步时间 | 总时间 | |
| |---|---|---|---|---| |
| | 10K | 16 | 1250 | ~250ms | ~5 分钟 | |
| | 60K | 16 | 7500 | ~250ms | ~31 分钟 | |
| | 500K | 16 | 62500 | ~250ms | ~4.3 小时 | |
|
|
| > 面试点:如何加速 SFT 训练?→ 增大 batch_size(需更大显存或多卡)→ 减少步数;使用梯度累积补偿显存不足;使用 DeepSpeed ZeRO 节省显存 |
| |
| --- |
| |
| ## 面试高频题汇总 |
| |
| ### 基础 |
| |
| 1. **SFT 和 Pretrain 训练的核心区别?** → 数据格式(纯文本 vs 对话)、loss 计算(全 token vs assistant only)、学习率(5e-4 vs 1e-5)、权重初始化 |
| 2. **为什么 label 要置 -100?** → `CrossEntropyLoss(ignore_index=-100)` 忽略该位置损失,只计算 assistant 段 |
| 3. **Teacher Forcing 是什么?** → 训练时每步输入真实 token 而非模型预测 |
| 4. **灾难性遗忘怎么避免?** → 小 lr、少轮数、混合预训练数据、LoRA 增量微调 |
|
|
| ### 进阶 |
|
|
| 5. **Exposure Bias 是什么?** → 训练(teacher forcing)和推理(自回归)的输入分布不一致导致的误差累积 |
| 6. **SFT 后为什么需要 RLHF?** → SFT 只是模仿,RLHF 从偏好信号中学习"什么更好" |
| 7. **AdamW 比 Adam 好在哪?** → weight decay 与自适应学习率解耦,更有效的正则化 |
| 8. **weight tying 在微调时有用吗?** → 有用,嵌入层和输出头共享权重能提升泛化和收敛 |
| 9. **strict=False 的潜在风险?** → 部分参数随机初始化而不报错,需要手动验证加载结果 |
| 10. **如何处理超长对话?** → 截断(丢失信息)、滑动窗口(窗口训练)、压缩(使用长上下文模型) |
|
|