# 面试:全量微调(Full SFT)深度
> 本仓库 `src/trainers/lm/full_sft.py`,对应 config:`configs/lm/lm_full_sft.yaml`
## 0. 整体流程
```
数据集 (sft_t2t_mini.jsonl)
│ 60,000 条多轮对话
▼
SFTDataset
├── 解析 JSON 对话格式
├── 拼接多轮对话(含 system user assistant 标记)
├── 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'{speaker}\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. **如何处理超长对话?** → 截断(丢失信息)、滑动窗口(窗口训练)、压缩(使用长上下文模型)