Blog Post Llm Mllm Posttrain Interview
Published:
LLM / MLLM 后训练面试题库(60 题精选)
📖 本题库覆盖大模型后训练全链路:从 SFT 到 RLHF/DPO,从 GRPO 到推理模型,从 LoRA 到 MLLM 多模态专项,另附 10 道代码手撕题。
目录
- 一、基础概念 Q01-Q05
- 二、SFT 监督微调 Q06-Q11
- 三、奖励建模 RM Q12-Q15
- 四、RLHF / PPO Q16-Q20
- 五、DPO 及变体 Q21-Q24
- 六、GRPO / 推理模型 Q25-Q28
- 七、PEFT / LoRA / QLoRA Q29-Q31
- 八、数据工程 Q32-Q34
- 九、MLLM 后训练专项 Q35-Q42
- 十、对齐与安全 Q43-Q45
- 十一、训练工程 Q46-Q47
- 十二、评估 Q48-Q49
- 十三、前沿与开放题 Q50
- 十四、手撕代码 / Coding Q51-Q60
一、基础概念
Q01 什么是大模型后训练(Post-Training)?包括哪些阶段?
难度: 基础 考察点: 对后训练全链路的系统性理解,能否区分各阶段的输入/输出/目标
满分回答:
Post-Training 是基座模型(Base Model)在预训练完成之后、面向下游任务进行的一系列训练阶段的总称。其目标是将一个”续写文本”的基座模型转化为一个”遵循指令、安全可控”的对话模型。
典型后训练 pipeline 包含以下阶段:
- SFT(Supervised Fine-Tuning) — 用人工标注的指令-回复对微调基座模型,使其学会按指令格式回答。输入:instruction-response pairs;输出:SFT 模型(policy 初始版本)。
- Reward Modeling — 收集人类偏好数据(对同一 prompt 的两个回复做排序),训练一个 Reward Model(RM)来预测人类偏好。输入:preference pairs $(y_w, y_l)$;输出:标量奖励函数 $r_\theta(x, y)$。
- RLHF / DPO / GRPO — 利用 RM(或偏好数据直接优化)进一步对齐 SFT 模型,使其生成更符合人类偏好的回复。输入:RM + SFT policy(RLHF)或偏好数据(DPO);输出:对齐后的对话模型。
⚠️ 常见误解:认为 Post-Training 只等于 SFT。实际上 SFT 只完成了格式对齐,价值观/偏好对齐需要 RLHF/DPO 等后续阶段。
⚠️ 不同厂商的 pipeline 有差异:LLaMA 2 的流程是 SFT → RM → PPO;LLaMA 3 增加了多轮对话 SFT 和 DPO;DeepSeek-R1 在 RL 前还加入了长 CoT SFT + 规则奖励 GRPO。
延伸追问:
- 为什么不能跳过 SFT 直接做 RLHF?(SFT 提供了 policy 的初始化,直接从 base model 做 RL 会导致输出不稳定)
- Post-Training 中哪个阶段对最终效果影响最大?(经验上 SFT 数据质量是瓶颈,RLHF/DPO 提升幅度约 5-15%)
参考资料:
- Training language models to follow instructions with human feedback (InstructGPT)
- The LLaMA 3 Herd of Models
Q02 Pre-training vs Post-training 的核心差异?
难度: 基础 考察点: 能否从目标、数据、方法、成本等多维度对比两个阶段
满分回答:
| 维度 | Pre-training | Post-training | |
|---|---|---|---|
| 目标 | 学习语言的统计规律(next-token prediction) | 学习遵循指令、符合人类偏好 | |
| 数据 | 海量无标注文本(TB 级,web crawl) | 少量高质量标注数据(K~M 级) | |
| 方法 | 自回归语言建模,$\mathcal{L} = -\sum_t \log P(w_t | w_{<t})$ | SFT(监督微调)→ RLHF/DPO(偏好对齐) |
| 模型规模 | 全参数训练 | 全参数或 PEFT(LoRA 等) | |
| 计算成本 | 数千 GPU × 数周 | 数十 GPU × 数天 | |
| 评估方式 | perplexity、loss 曲线 | MT-Bench、Arena Score、人工评测 | |
| 输出 | Base Model(续写能力) | Chat Model(对话能力) |
核心差异的本质是目标函数的变化:预训练优化的是”预测下一个 token 的似然”,后训练优化的是”生成人类偏好的回复”。这导致了数据形态、训练策略、评估体系的根本不同。
⚠️ 常见坑:Pre-training 的 loss 下降 ≠ 模型变好用。Base model loss 低但不会遵循指令,需要 Post-Training 来”教”它。
延伸追问:
- 能否用 Post-Training 的数据量做 Pre-training?(不行,数据量太少会导致严重过拟合)
- Pre-training 和 Post-Training 的 loss 公式本质相同吗?(SFT 的 loss 形式与预训练相同,都是交叉熵;RLHF/DPO 的 loss 则完全不同)
参考资料:
- LLaMA: Open and Efficient Foundation Language Models
- LLaMA 2: Open Foundation and Fine-Tuned Chat Models
Q03 SFT vs RLHF 的区别与联系?
难度: 基础 考察点: 理解两个核心阶段的不同目标和互补关系
满分回答:
| 维度 | SFT | RLHF |
|---|---|---|
| 优化目标 | 让模型学会按格式回答 | 让模型输出更符合人类偏好 |
| 数据类型 | instruction-response pairs | preference comparisons $(y_w > y_l)$ |
| 优化方法 | 监督学习(交叉熵 loss) | 强化学习(PPO)或直接偏好优化(DPO) |
| 模型组件 | 只需 policy model | policy + reward model + reference model |
| 训练信号 | 确定性标签(ground truth) | 偏好信号(相对排序) |
| 学习内容 | 格式、任务能力 | 价值观、风格偏好 |
联系:
- SFT 是 RLHF 的前置阶段:RLHF 的 policy 初始权重来自 SFT model,reference model 也来自 SFT model。
- SFT 解决”能不能做”,RLHF 解决”做得好不好”(偏好层面)。
- 两者互补:只用 SFT 会偏向模仿数据风格但缺乏偏好细化;只用 RLHF(跳过 SFT)则 policy 输出不稳定。
延伸追问:
- 为什么 RLHF 不能替代 SFT?(RLHF 需要一个能生成合理回复的 policy 作为起点,base model 直接做 RL 输出质量太差)
- SFT 之后只用 DPO 而不用 PPO 可以吗?(可以,DPO 是 RLHF 的替代方案,直接从偏好数据优化 policy,不需要 RM)
参考资料:
Q04 Alignment(对齐)是什么?为什么需要对齐?
难度: 基础 考察点: 对”对齐”概念的理解深度,能否区分 Helpful / Honest / Harmless 三个维度
满分回答:
Alignment 是让模型的行为与人类意图和价值观一致的过程。Anthropic 提出对齐的三个目标(HHH 原则):
- Helpful:有用,能准确完成用户指令
- Honest:诚实,不编造事实,承认不确定性
- Harmless:无害,不生成有害、歧视、危险内容
为什么需要对齐?Base model 的训练目标是预测下一个 token,它学会的是语言的统计分布,而非人类意图。具体问题:
- 指令遵循缺失:Base model 看到 “请翻译这句话” 可能续写另一句话而非翻译
- 安全性问题:可能生成有害内容(暴力、歧视等)
- 偏好偏差:可能输出冗长、自相矛盾、不符合用户风格偏好的内容
- 事实性不足:可能自信地编造不存在的信息(hallucination)
对齐通过 SFT(格式对齐)+ RLHF/DPO(偏好对齐)+ Red-teaming(安全对齐)逐步解决这些问题。
⚠️ 对齐不是”让模型永远说好话”,而是在 helpful 和 harmless 之间找平衡。
延伸追问:
- 对齐会不会降低模型能力?(是的,这就是”对齐税”,见 Q45)
- 对齐能解决 hallucination 吗?(部分缓解,但幻觉的根本原因在预训练知识覆盖度,对齐无法根治)
参考资料:
Q05 基座模型到对话模型的完整训练 pipeline?
难度: 中级 考察点: 能否完整描述从 Base Model 到 Chat Model 的全流程,包括各阶段衔接和工程细节
满分回答:
以 InstructGPT / LLaMA 2 为典型参考,完整 pipeline 如下:
Base Model → SFT → Reward Model → RLHF(PPO) / DPO → Chat Model
阶段 1:SFT
- 输入:人类标注的 instruction-response 数据集(约 10K~100K 条)
- 方法:全参数或 LoRA 微调,loss 为交叉熵(仅计算 response token)
- 输出:SFT Model(同时作为后续 RLHF 的 policy 初始化和 reference model)
- 工程细节:mask prompt tokens,多轮对话用对话模板,超参 lr≈1e-5
阶段 2:Reward Modeling
- 输入:对同一 prompt 生成多个回复,人类标注偏好排序
- 方法:训练 RM 用 Bradley-Terry loss:$\mathcal{L} = -\log \sigma(r(x, y_w) - r(x, y_l))$
- 输出:Reward Model $r_\theta(x, y)$,为 RLHF 提供奖励信号
阶段 3:RLHF / DPO
- RLHF(PPO 方式):policy 在 RM 指导下优化,KL 约束防止偏离 reference model
- DPO:直接从偏好数据优化 policy,跳过 RM 训练
- 输出:Aligned Chat Model
可选阶段 4:迭代对齐
- 用对齐后的 model 重新生成数据 → 重新训练 RM → 再次 RLHF(LLaMA 2 做了多轮迭代)
- 或对 Chat Model 做 Red-teaming + 安全 SFT 补丁
⚠️ 实际工程中各阶段不是严格串行的。LLaMA 3 在 SFT 后做了多轮对话 SFT + DPO + 安全微调的组合。
延伸追问:
- 各阶段的超参如何设置?(SFT lr=1e-5, epoch=2-3; RM lr=5e-6; PPO lr=1e-6, 需仔细调)
- 能否用同一批数据做 SFT 和 DPO?(可以但要注意格式:SFT 用单条数据,DPO 用 pair)
参考资料:
- Training language models to follow instructions with human feedback
- LLaMA 2: Open Foundation and Fine-Tuned Chat Models
二、SFT 监督微调
Q06 SFT 数据构造的最佳实践?
难度: 基础 考察点: 对 SFT 数据质量标准的理解,能否区分不同数据类型的作用
满分回答:
SFT 数据构造的核心原则:质量 » 数量。LIMA 论文证明 1000 条高质量数据可以训练出接近 GPT-4 效果的模型。
数据来源与构造方法:
- 人工标注:专业标注员撰写 instruction-response pairs,质量最高但成本大
- Self-Instruct:用强模型自动生成指令和回复,再人工筛选(Alpaca 方法)
- Magpie:利用 LLM 的对话模板,仅填入 prompt 部分让模型自生成指令(零人工输入)
- 蒸馏数据:从 GPT-4/Claude 等强模型获取回复,用于训练开源模型
数据质量标准:
- 多样性:覆盖不同任务类型(问答、写作、推理、代码等)
- 准确性:回复内容事实正确
- 格式一致性:统一的对话模板
- 长度适中:避免过于冗长或过短
- 拒绝样本:包含模型应拒绝回答的 prompt(安全对齐)
⚠️ 常见坑:用弱模型生成 SFT 数据会导致”模型蒸馏退化”——弱模型的错误模式会被学习。
数据配比建议(LLaMA 3 实践): | 数据类型 | 占比 | |———-|——| | 通用对话 | ~50% | | 代码/推理 | ~20% | | 长文档/总结 | ~15% | | 拒绝/安全 | ~10% | | 多语言 | ~5% |
延伸追问:
- Self-Instruct 生成的数据如何保证质量?(人工审核 + 规则过滤 + 多样性采样)
- SFT 数据需要覆盖多少个任务类型?(至少 20+ 种,否则泛化差)
参考资料:
- Self-Instruct: Aligning Language Models with Self-Generated Instructions
- Magpie: Alignment Data Synthesis from Scratch
Q07 SFT 的 loss 计算方式,为什么要 mask prompt tokens?
难度: 中级 考察点: 理解 SFT loss 的工程细节,特别是 prompt masking 的原因
满分回答:
SFT 的 loss 是标准的交叉熵,但只对 response tokens 计算:
\[\mathcal{L} = -\frac{1}{N_{\text{resp}}} \sum_{t \in \text{response}} \log P_\theta(y_t | x, y_{<t})\]其中 $x$ 是 prompt,$y$ 是 response,$N_{\text{resp}}$ 是 response token 数量。
为什么要 mask prompt tokens?
- 目标语义:SFT 的目标是让模型学会”给定 prompt 后生成正确的 response”,不是让模型学会”重新生成 prompt”。Prompt 是条件输入,不是预测目标。
- 防止偏移:如果不 mask prompt,模型会花大量梯度去拟合 prompt 的分布,导致 response 部分的学习信号被稀释。
- 工程实现:在
ignore_index参数中设置 prompt token 的 label 为 -100(PyTorch 默认忽略值),这样 cross_entropy 会自动跳过这些位置。
⚠️ 常见坑:初学者有时把整个序列都算 loss,这会导致模型倾向于生成类似 prompt 的内容而非回答。
代码示例:
labels = full_sequence_ids.clone()
labels[:prompt_len] = -100 # mask prompt tokens
loss = F.cross_entropy(logits.view(-1, vocab_size), labels.view(-1), ignore_index=-100)
延伸追问:
- 如果 prompt 中有特殊 token(如 system message),是否也要 mask?(是的,所有非 response 部分都 mask)
- loss 是按 token 平均还是按 response 平均?(按 response token 数平均,不是按整个序列长度)
参考资料:
Q08 多轮对话 SFT 的训练模板与 loss 设计?
难度: 中级 考察点: 能否处理多轮对话的特殊 loss 设计,理解不同 masking 策略的利弊
满分回答:
多轮对话 SFT 需要将多轮交互拼接成一条序列,使用对话模板格式化后训练。关键问题在于 loss mask 的范围。
对话模板示例(LLaMA 格式):
<|begin_of_text|><|start_header_id|>user<|end_header_id|>
{round1_question}<|eot_id|><|start_header_id|>assistant<|end_header_id|>
{round1_answer}<|eot_id|><|start_header_id|>user<|end_header_id|>
{round2_question}<|eot_id|><|start_header_id|>assistant<|end_header_id|>
{round2_answer}<|eot_id|>
Loss masking 三种策略:
| 策略 | Mask 范围 | 优点 | 缺点 |
|---|---|---|---|
| 仅最后一轮 response | 只算最后一轮 assistant 回复 | 训练快,数据利用低 | 只学习最后一轮 |
| 所有 assistant 回复 | 每轮 assistant 回复都算 loss | 数据利用充分 | 不同轮次权重相同 |
| 全部 response + 历史 | assistant 回复 + 用户提问 | 信息最大化 | 可能偏移用户风格 |
主流实践:采用”所有 assistant 回复都算 loss”策略。每个 assistant 回复段的 token label 设为自身 ID,其余设为 -100。
⚠️ 常见坑:如果用”仅最后一轮”策略,模型可能在前几轮生成时缺乏训练信号,导致多轮交互不稳定。
工程细节:
- 多轮对话需要确保各轮之间的 token 级别 mask 精确,不能遗漏特殊 token(如
<|eot_id|>) - 实际训练中通常将多条多轮对话 padding 到同一长度,用 attention mask 和 loss mask 配合
延伸追问:
- 多轮对话 SFT 的 batch 内如何处理不同轮数的对话?(padding + attention mask,loss mask 精确标记每条对话的 response 位置)
- 是否需要专门训练模型学会”何时停止生成”?(是的,EOS token 的预测也是训练目标的一部分)
参考资料:
Q09 SFT 数据量与质量的权衡,多少条数据够?
难度: 中级 考察点: 对数据规模直觉的理解,能否引用关键论文结论
满分回答:
SFT 的数据量阈值远低于预训练。关键结论:
| 研究 | 数据量 | 结论 |
|---|---|---|
| LIMA | 1,000 条 | 1000 条高质量数据足以接近 GPT-4 水平 |
| Alpaca | 52K 条 | Self-Instruct 生成的 52K 条即可显著提升 |
| FLAN/T0 | 数百万条 | 多任务提示数据,追求零样本泛化 |
| LLaMA 2 | ~27K 条(对话 SFT) | 人工标注的高质量数据 |
核心原则:质量 » 数量。 原因:
- 低质量数据的危害:包含错误的回复会直接教模型犯错,比没有数据更糟糕
- 重复数据的危害:模型会过拟合高频模式,泛化能力下降
- 多样性比数量重要:覆盖更多任务类型的 10K 条数据 > 同类型重复的 100K 条
实践建议:
- 初步 SFT:5K-20K 条高质量数据
- 生产级 SFT:50K-200K 条(含多任务、多语言、安全样本)
- 关键是数据审核流程:每条数据至少经过格式检查 + 内容准确性验证
⚠️ 常见坑:盲目追求数据量而忽略审核,用自动生成数据不做人工筛选。
延伸追问:
- 如何判断 SFT 数据是否”够”?(看 eval benchmark 是否收敛,训练 loss 是否不再下降但 eval 持续提升 = 过拟合信号)
- 低质量数据的影响能通过 RLHF 修复吗?(部分可以,但最好在 SFT 层面就保证质量)
参考资料:
- LIMA: Less Is More for Alignment (社区讨论)
- Scaling Instruction-Finetuned Language Models (Flan-T5)
Q10 全参数微调 vs LoRA 微调在 SFT 中的对比?
难度: 中级 考察点: 能否从效果、效率、适用场景等维度对比两种微调方式
满分回答:
| 维度 | 全参数微调 | LoRA 微调 |
|---|---|---|
| 参数更新量 | 所有参数 | 低秩增量 $\Delta W = BA$,$B \in \mathbb{R}^{d \times r}, A \in \mathbb{R}^{r \times d}$,$r \ll d$ |
| 显存占用 | 极高(优化器状态占 2×参数量) | 低(只存 LoRA 参数的优化器状态) |
| 训练速度 | 较慢 | 较快(梯度计算量少) |
| 最终效果 | 理论上限更高 | 对于 SFT 场景差距通常 <2% |
| 灵活性 | 需要完整模型权重 | LoRA 可随时 merge/unmerge |
| 多任务适配 | 每个任务需要完整权重 | 可为每个任务维护独立 LoRA adapter |
| 灾难性遗忘风险 | 较高 | 较低(基座权重冻结) |
LoRA 的关键公式: \(h = W_0 x + \Delta W x = W_0 x + BAx\)
其中 $W_0$ 冻结,$B, A$ 可训练,$r$ 通常取 8-64。
何时用全参数微调?
- 模型规模 <7B 且 GPU 资源充足
- 需要大幅修改模型行为(如从 base → chat 的首次 SFT)
- 目标 benchmark 要求极致性能
何时用 LoRA?
- 模型规模 >13B,GPU 资源有限
- 多任务场景,需要多个 adapter
- 实验迭代阶段,需要快速试错
⚠️ 常见坑:LoRA 秩 $r$ 太小(如 $r=1$)会导致效果明显下降;太大(如 $r=256$)接近全参数但显存没省多少。
延伸追问:
- LoRA 应该加在哪些层?(主流做法:加在所有线性层(Q/K/V/O + FFN),不加 embedding/lm_head)
- LoRA 微调后如何部署?(merge $W = W_0 + BA$ 后部署,推理无额外开销)
参考资料:
Q11 灾难性遗忘问题及缓解方法?
难度: 高级 考察点: 理解灾难性遗忘的成因和主流缓解策略
满分回答:
灾难性遗忘(Catastrophic Forgetting)是指模型在学习新任务时,丢失了之前学到的知识。在 LLM 后训练中表现为:SFT 后模型在通用能力(推理、代码等)上退步。
成因分析:
- 参数偏移:SFT 的梯度更新改变了预训练学到的权重分布
- 数据偏差:SFT 数据分布与预训练数据分布差异大
- 训练过度:epoch 过多或 lr 过大导致权重大幅偏移
缓解方法:
| 方法 | 原理 | 适用场景 |
|---|---|---|
| LoRA / PEFT | 只更新低秩增量,冻结基座权重 | 最主流的方案 |
| 数据混合 | SFT 数据中混入预训练数据(如 LLaMA 3 混了 ~5% 预训练数据) | 生产级 SFT |
| LR 衰减 | 使用较小的学习率(1e-5 ~ 5e-6) | 所有 SFT 场景 |
| 早停 | 监控通用 benchmark,在退化开始时停止 | 需要多阶段评估 |
| EWC / L2 正则 | 对重要参数施加正则化 $\lambda \sum_i F_i (w_i - w_i^0)^2$ | 理论上有用,实践少用 |
| 多阶段训练 | 先 SFT 对齐格式,再用 RLHF 微调偏好,逐步调整 | InstructGPT pipeline |
| Replay | 定期用预训练数据做”回放”训练 | 需要预训练数据访问权限 |
⚠️ 最实用的方案是 LoRA + 数据混合 + 低 lr。单独用任何一个都不够。
⚠️ 常见坑:认为 LoRA 完全不会遗忘。LoRA 只是降低遗忘程度,如果 SFT 数据偏差太大,遗忘仍然会发生。
延伸追问:
- 如何检测灾难性遗忘?(在 SFT 过程中持续评估通用 benchmark,如 MMLU/GSM8K,看是否下降)
- 数据混合的比例如何确定?(LLaMA 3 实验发现 5-10% 预训练数据混合效果最好)
参考资料:
三、奖励建模 RM
Q12 Reward Model 的训练流程与 Pairwise Loss?
难度: 中级 考察点: RM 训练的完整流程、loss 公式、数据格式
满分回答:
Reward Model(RM)的目标:给定 prompt $x$ 和回复 $y$,输出一个标量奖励 $r_\theta(x, y)$,反映人类对该回复的偏好程度。
训练流程:
- 数据收集:对同一 prompt 生成多个回复(通常 4-9 个),标注员排序选出 best($y_w$)和 worst($y_l$)
- 模型架构:通常在 SFT model 最后加一个线性头,将最后一个 hidden state 投射为标量:$r_\theta(x, y) = \text{Linear}(\text{last_hidden})$
- Loss 计算:Bradley-Terry pairwise loss:
其中 $\sigma$ 是 sigmoid 函数。直觉:让 RM 给 chosen 回复的奖励高于 rejected 回复。
- 训练细节:
- lr 通常 5e-6 ~ 1e-5
- batch size 受限于 GPU 内存(每条样本包含 prompt + 两个完整回复)
- 通常训练 1 epoch 防止过拟合
工程技巧:
- 每个排序对可以构造多个 pair:4 个排序回复可以构造 $\binom{4}{2}=6$ 个 pair
- 对多个 pair 使用统一的 loss(而非每个 pair 单独 loss)
- InstructGPT 的 RM 用 175B 参数,但实践中 7B-13B 的 RM 也够用
⚠️ 常见坑:RM 过拟合会导致 reward 值爆炸或给出不合理的分数,需要监控 reward 分布。
延伸追问:
- RM 的输出范围是否需要归一化?(不需要绝对归一化,但 RLHF 中 KL 约束会间接控制 reward 的影响范围)
- 多个 pair 的 loss 如何聚合?(取平均或加权平均,实践中 InstructGPT 对每个排序 pair 等权)
参考资料:
- Training language models to follow instructions with human feedback
- Bradley & Terry, “Rank Analysis of Incomplete Block Designs”, Biometrika 1952
Q13 Bradley-Terry 模型的推导与 RM loss 的关系?
难度: 高级 考察点: 能否从概率模型推导出 RM loss,理解偏好建模的数学基础
满分回答:
Bradley-Terry 模型假设:给定两个选项 A 和 B,人类偏好 A 的概率为:
\[P(A > B) = \frac{e^{s_A}}{e^{s_A} + e^{s_B}} = \sigma(s_A - s_B)\]其中 $s_A, s_B$ 是选项的”得分”函数。
在 RM 场景中:将回复 $y_w$(chosen)和 $y_l$(rejected)的得分替换为 RM 输出的奖励值:
\[P(y_w > y_l | x) = \sigma\big(r_\theta(x, y_w) - r_\theta(x, y_l)\big)\]推导过程:
- 定义偏好概率:$P(y_w > y_l) = \frac{e^{r(x, y_w)}}{e^{r(x, y_w)} + e^{r(x, y_l)}}$
- 简化:$= \frac{1}{1 + e^{-(r(x, y_w) - r(x, y_l))}} = \sigma(r(x, y_w) - r(x, y_l))$
- 对数似然:$\log P(y_w > y_l) = \log \sigma(r_w - r_l)$
- 最大化对数似然 → 最小化负对数似然:
这就是 RM 的 pairwise loss。
关键理解:
- Bradley-Terry 模型假设偏好是序数的(ordinal),只需要相对排序,不需要绝对分数
- RM 输出的标量奖励值本身没有绝对含义,只有差值 $r_w - r_l$ 有意义
- 这也是为什么 DPO 可以绕过 RM:直接用 policy 的 logprob 差值替代 reward 差值(见 Q21)
⚠️ 常见误解:认为 RM 输出的绝对值有意义。实际上 RM 只需要保证 $r(x, y_w) > r(x, y_l)$ 即可,绝对值可以任意偏移。
延伸追问:
- Bradley-Terry 模型的局限性?(只能建模 pairwise 偏好,无法处理更复杂的排序结构;假设偏好是 transitive 的)
- 如果标注数据有噪声怎么办?(可以用 margin-based loss 加间隔:$-\log \sigma(r_w - r_l - \delta)$)
参考资料:
- Bradley & Terry, “Rank Analysis of Incomplete Block Designs”, Biometrika 1952
- Direct Preference Optimization
Q14 Reward Hacking / Reward Overoptimization 问题?
难度: 高级 考察点: 理解 reward hacking 的成因、表现形式和缓解策略
满分回答:
Reward Hacking / Overoptimization 指 policy 模型通过”钻 RM 的漏洞”来获得高奖励,而非真正提升回复质量。
表现形式:
- 回复冗长化:RM 往往倾向给长回复更高分数,policy 学会生成空洞的长回复
- 格式钻营:学会 RM 偏好的特定格式(如列表、代码块),而非实质内容提升
- 情感偏向:RM 常偏好积极语气,policy 学会无论内容都加正面情绪词
- 重复/空洞:生成看似合理但实际无意义的内容来骗取高分
成因分析:
- RM 是有限数据的近似,无法完美捕捉人类偏好
- 当 policy 和 RM 的分布偏移增大时,RM 在 policy 输出上的预测不准确
- Gao et al. (2023) 量化发现:reward 过优化时,真实人类偏好先升后降(呈倒 U 型曲线)
缓解策略:
| 方法 | 原理 |
|---|---|
| KL 约束 | 限制 policy 与 reference model 的 KL 散度,防止输出偏离太远 |
| 迭代 RM 更新 | 定期用 policy 输出重新标注数据,更新 RM(LLaMA 2 的做法) |
| 多 RM 融合 | 用多个 RM 取平均,减少单 RM 的偏差 |
| Ensemble reward | 不同数据源训练的 RM 投票 |
| Early stopping | 监控 KL 散度,在 reward hacking 开始时停止训练 |
| 增加 RM 数据多样性 | 让 RM 在更多类型的回复上学习偏好 |
⚠️ 最关键的是 KL 约束 + 迭代 RM 更新,单靠任何一个都不够。
延伸追问:
- 如何检测 reward hacking?(对比 RM reward 和真实人类偏好评分,如果 reward 持续上升但人类评分下降 = hacking)
- reward hacking 和 overfitting RM 有什么区别?(本质相同:policy 过拟合了 RM 的偏差模式)
参考资料:
- Gao et al., “Scaling Laws for Reward Model Overoptimization” (ICML 2023)
- Secrets of RLHF in Large Language Models Part I: PPO
Q15 RM 训练的数据构造与工程技巧?
难度: 中级 考察点: RM 数据的构造方式、标注流程、工程优化
满分回答:
数据构造流程:
- Prompt 采集:从用户对话记录 / 人工设计 / 自动生成中收集 prompt
- 回复采样:用 SFT model 对每个 prompt 生成多个回复(通常 4-9 个),可调整温度/采样策略增加多样性
- 人工排序:标注员对同一 prompt 的多个回复按质量排序
- Pair 构造:从排序中抽取 (chosen, rejected) pairs
关键技巧:
- 多样性采样:不同温度、不同 beam、甚至不同模型生成回复,确保 pair 质量差异明显
- Pair 间距:优先选择排序差距大的 pair(如 best vs worst),而非相邻排名的 pair,让 RM 学习更明显的偏好差异
- InstructGPT 的做法:4-9 个回复排序,构造所有 $\binom{n}{2}$ pairs,每个 pair 权重等价
- Margin loss:加入间距 $\delta$ 来增强区分度:$\mathcal{L} = -\log \sigma(r_w - r_l - \delta)$,$\delta$ 可以是排序排名差
- 数据分布:RM 数据应覆盖不同 prompt 类型(对话、推理、代码等),防止 RM 在特定类型上偏科
工程优化:
- 每条训练样本 = prompt + chosen + rejected,显存占用是 SFT 的 ~2 倍
- 可将 prompt 部分的 KV cache 缓存,只对两个回复分别前向传播(节省 ~40% 计算量)
- RM 训练 1 epoch 为主,过拟合风险大(RM 容易记住特定 prompt 的模式)
⚠️ 常见坑:用同一模型生成的回复做 pair → RM 只学了区分该模型的特定缺陷而非通用偏好 → reward hacking 加剧。
延伸追问:
- RM 数据量和 SFT 数据量哪个更重要?(RM 数据量通常需要更多,因为 pairwise 比较比单条回复标注信息更丰富但单条 pair 信息更稀疏)
- 能否用 AI 替代人类标注 RM 数据?(可以,这就是 RLAIF 的思路,见 Q43)
参考资料:
- Training language models to follow instructions with human feedback
- Secrets of RLHF in Large Language Models Part I: PPO
四、RLHF / PPO
Q16 PPO 的完整 loss 公式及各组件含义?
难度: 高级 考察点: 能否完整写出 PPO loss 并解释每个组件的作用
满分回答:
PPO 在 RLHF 中的总 loss 包含三个部分:
\[\mathcal{L}_{\text{PPO}} = \mathcal{L}_{\text{policy}} + \beta \cdot \mathcal{L}_{\text{KL}} - c_1 \cdot \mathcal{L}_{\text{value}} + c_2 \cdot \mathcal{L}_{\text{entropy}}\]1. Policy Loss(Clipped Surrogate):
\[\mathcal{L}_{\text{policy}} = -\min\Big(\hat{A}_t \cdot \rho_t, \; \hat{A}_t \cdot \text{clip}(\rho_t, 1-\epsilon, 1+\epsilon)\Big)\]其中:
$\rho_t = \frac{\pi_\theta(a_t s_t)}{\pi_{\text{ref}}(a_t s_t)}$ 是新旧 policy 的概率比 - $\hat{A}t = r_t + \gamma V(s{t+1}) - V(s_t)$ 是优势函数(GAE 估计)
- $\epsilon$ 通常设为 0.2,防止 policy 更新过大
2. KL Penalty:
\[\mathcal{L}_{\text{KL}} = \mathbb{D}_{\text{KL}}[\pi_\theta || \pi_{\text{ref}}]\]作用:防止 policy 偏离 reference model(即 SFT model)太远,避免 reward hacking。
3. Value Loss:
\[\mathcal{L}_{\text{value}} = (V_\phi(s_t) - V_t^{\text{target}})^2\]其中 $V_t^{\text{target}}$ 是用 GAE 计算的 value target。训练 Critic(Value Model)来估计状态价值,用于计算优势函数 $\hat{A}_t$。
4. Entropy Bonus:
\[\mathcal{L}_{\text{entropy}} = -\sum_a \pi_\theta(a|s) \log \pi_\theta(a|s)\]鼓励 policy 保持一定的输出多样性,防止模式坍塌。
⚠️ 常见坑:
- $\rho_t$ 是逐 token 计算的概率比,不是整句的概率比
- KL penalty 的 $\beta$ 需要仔细调节(见 Q17)
- Critic 的训练需要和价值 target 同步更新,否则优势估计不准
延伸追问:
- PPO clip 的 $\epsilon$ 为什么设 0.2?(经验值,太大允许过大更新,太小限制太强导致学习慢)
- GAE 如何计算优势函数?($\hat{A}t = \sum{l=0}^{\infty}(\gamma\lambda)^l \delta_{t+l}$,$\delta_t = r_t + \gamma V(s_{t+1}) - V(s_t)$)
参考资料:
Q17 KL 约束在 RLHF 中的作用,KL 系数如何调?
难度: 中级 考察点: 理解 KL 约束的双面性,以及系数调节的工程实践
满分回答:
KL 约束的作用是限制 policy $\pi_\theta$ 与 reference model $\pi_{\text{ref}}$(SFT model)之间的 KL 散度:
\[\mathbb{D}_{\text{KL}}[\pi_\theta || \pi_{\text{ref}}] = \sum_y \pi_\theta(y|x) \log \frac{\pi_\theta(y|x)}{\pi_{\text{ref}}(y|x)}\]为什么要加 KL 约束?
- 防 reward hacking:没有 KL 约束时,policy 可能找到 RM 的漏洞输出高 reward 但低质量的回复
- 稳定性:防止 policy 在单步更新中偏离太大,导致后续训练不稳定
- 保持通用能力:KL 太大意味着 policy 已经远离 SFT model,可能丢失通用语言能力
KL 系数 $\beta$ 的调节:
- $\beta$ 太小 → reward hacking 加剧,policy 输出越来越怪
- $\beta$ 太大 → policy 几乎不更新,RLHF 无效果
调节策略:
| 方法 | 描述 |
|---|---|
| 固定 $\beta$ | 设为 0.01-0.1,最简单但不够灵活 |
| 自适应调节 | 监控 KL 散度,如果超过目标值则增大 $\beta$,低于目标值则减小(InstructGPT 做法) |
| KL target-based | 设定 KL 目标值(如 6 nats),自动调节 $\beta$ 使 KL 稳定在目标附近 |
| 逐步衰减 | 初期 $\beta$ 较大保证稳定,后期减小让 policy 更自由探索 |
⚠️ 常见坑:在 RLHF 中,KL 不是按整句计算而是按 token 计算,每个 token 的 logprob 差值累加。
⚠️ 另一个坑:只看 KL 值不够,还需要看 reward 和人类偏好的趋势。KL 稳定但 reward 不涨 = 训练无效。
延伸追问:
- KL 约束能否用 cosine similarity 或其他度量替代?(理论上可以,但 KL 在 RL 理论中有最优性证明,其他度量没有理论保证)
KL 散度的方向 $\text{KL}[\pi_\theta \pi_{\text{ref}}]$ vs $\text{KL}[\pi_{\text{ref}} \pi_\theta]$ 有什么区别?(前者是 forward KL,鼓励 policy 覆盖 reference 的模式;后者是 reverse KL,鼓励 policy 集中在自身高概率区域)
参考资料:
- Training language models to follow instructions with human feedback
- Secrets of RLHF in Large Language Models Part I: PPO
Q18 Critic(Value Model)在 PPO 中的角色?
难度: 中级 考察点: 理解 Value Model 的功能、训练方式和与 RM 的区别
满分回答:
Critic(Value Model)$V_\phi(s)$ 的核心功能:估计当前状态(已生成的前缀 tokens)的预期累积奖励,用于计算优势函数 $\hat{A}_t$。
\[\hat{A}_t = r_t + \gamma V_\phi(s_{t+1}) - V_\phi(s_t) = \delta_t + (\gamma\lambda)\delta_{t+1} + \cdots\](GAE 估计,$\lambda$ 是 GAE 衰减系数)
Value Model vs Reward Model:
| 维度 | Reward Model | Value Model |
|---|---|---|
| 输入 | prompt + 完整回复 | prompt + 不完整回复(前缀) |
| 输出 | 整句的质量评分 | 当前前缀的预期总奖励 |
| 训练 | 用偏好数据训练 | 用 RM reward + self-generated TD target 训练 |
| 用途 | 提供环境 reward $r_t$ | 计算优势函数 $\hat{A}_t$ |
| 初始化 | 通常从 SFT model 初始化 | 通常从 RM 初始化(因为架构相似) |
Value Model 的训练:
\[\mathcal{L}_{\text{value}} = \big(V_\phi(s_t) - V_t^{\text{target}}\big)^2\]$V_t^{\text{target}}$ 是用 GAE 计算的 TD target,融合了 RM 提供的即时 reward 和 Value Model 自身的估计。
⚠️ 常见坑:Value Model 如果训练不好(估计不准),会导致优势函数 $\hat{A}_t$ 偏差大,PPO 的 clip 机制失效,policy 更新不稳定。
工程细节:
- Value Model 通常从 RM 初始化(而非 SFT model),因为 RM 已经学过评分,Value Model 只需要学会”前缀评分”
- Value Model 的 loss 通常也加 clip(PPO 中 clip value update),防止 value 估计突然跳变
延伸追问:
- Value Model 能否和 RM 共用一个模型?(可以但效果通常不如分开训练,因为 Value Model 需要处理前缀,RM 只处理完整回复)
- 不用 Value Model 直接用 RM reward 做 policy gradient 可以吗?(可以,但优势估计更粗糙,训练更不稳定,这是 REINFORCE 方式)
参考资料:
Q19 RLHF 训练中常见的稳定性问题及解决?
难度: 高级 考察点: RLHF 训练的工程经验,能否识别和解决常见稳定性问题
满分回答:
RLHF/PPO 训练比 SFT 不稳定得多,常见问题及解决方案:
| 问题 | 现象 | 解决方案 |
|---|---|---|
| Reward Hacking | RM reward 上升但人类偏好下降 | KL 约束 + 迭代 RM 更新 + 早停 |
| Critic 过拟合 | Value 估计偏离真实 reward | Value clip + 定期用 RM 重新标定 value target |
| Policy 模式坍塌 | 输出极度重复/单一 | Entropy bonus + 增加 KL 系数 |
| 梯度爆炸 | loss 突然 spike,NaN | gradient clipping(norm clip 到 1.0)+ 降低 lr |
| Reward 尺度问题 | RM 输出值范围过大/过小 | reward normalization(running mean/std) |
| KL 突然增大 | policy 离 reference 太远 | 自适应 KL 系数,超过阈值时增大 $\beta$ |
| 训练震荡 | reward 曲线大幅波动 | 减小 batch size / 增加 rollout buffer / 减小 lr |
关键工程实践(来自 Zheng et al. “Secrets of RLHF”):
- Reward Normalization:对 RM 输出做 running mean/std 归一化,防止 reward 尺度漂移
- Value Function Pre-training:先用 RM 数据预训练 Value Model,再做 RL 时更新
- KL Cost as Reward Penalty:将 KL penalty 加入 reward $r_t’ = r_t - \beta \cdot \text{KL}_t$,而非单独 loss(更稳定)
- Batch 分割:generation batch 和 training batch 分开,generation 用更大 batch
- Advantage Normalization:对 $\hat{A}_t$ 做 batch 内归一化,防止优势值极端
⚠️ 最常见的新手错误:不做 reward normalization → RM 输出从 -100 到 +100 → PPO 完全失控。
⚠️ 另一个常见错误:policy 和 value model 用同一个网络 → 参数更新互相干扰 → 都学不好。
延伸追问:
- PPO 的超参如何选择?(lr=1e-6, clip_ratio=0.2, GAE lambda=0.95, gamma=0.99, 这些是常用起点)
- RLHF 训练需要多少步?(通常 100-500 个 PPO update,太多会 overoptimization)
参考资料:
- Secrets of RLHF in Large Language Models Part I: PPO
- Training a Helpful and Harmless Assistant with RLHF
Q20 RLHF 的优势与局限?
难度: 中级 考察点: 对 RLHF 的全面评估,能否辩证看待其优缺点
满分回答:
优势:
- 偏好对齐直接:通过 RM 直接优化人类偏好,而非间接通过数据模仿
- 超越 SFT 的天花板:SFT 只能模仿标注数据的风格,RLHF 可以发现数据之外更好的回复
- 灵活的奖励信号:RM 可以编码任意偏好(有用性、安全性、风格等),比硬编码规则灵活
- 可迭代改进:收集新偏好数据 → 更新 RM → 再次 RLHF,持续提升
局限:
- 训练不稳定:PPO 训练需要 4 个模型(policy, reference, reward, value),工程复杂度高
- RM 质量瓶颈:RM 是有限数据的近似,reward hacking 是不可避免的风险
- 标注成本高:偏好排序比单条标注更贵(4-9 个回复的排序比写一条回复难)
- 不可逆偏移:RLHF 后的模型可能”过度对齐”,在某些任务上反而不如 SFT model
- Reward 不可解释:RM 给出的标量分数无法解释”为什么这个回复更好”
RLHF vs DPO 的视角(见 Q22):
- RLHF 的根本问题在于 RM 的近似误差 → DPO 直接用偏好数据绕过 RM
- 但 RLHF 在 reward shaping(如安全奖励加权)方面更灵活
⚠️ 一个关键局限:RLHF 只能对齐偏好排序中的维度,不能对齐标注员没有考虑到的维度。
延伸追问:
- RLHF 能否替代 SFT?(不能,见 Q03)
- RLHF 的 reward hacking 能否完全消除?(不能,只能缓解,这是 RM 近似的固有缺陷)
参考资料:
五、DPO 及变体
Q21 DPO loss 的完整推导(从 RLHF 到 DPO)?
难度: 高级 考察点: 能否完整推导 DPO loss,理解从 RLHF objective 到 DPO closed-form 的数学链路
满分回答:
Step 1: RLHF 的优化目标
RLHF 的目标是最大化 RM reward,同时 KL 约束防止偏离 reference model:
\[\max_{\pi_\theta} \mathbb{E}_{x,y \sim \pi_\theta}\big[r(x,y) - \beta \log \frac{\pi_\theta(y|x)}{\pi_{\text{ref}}(y|x)}\big]\]Step 2: 证明最优 policy 的 closed form
对上述目标,最优 policy 有闭式解:
\[\pi^*(y|x) = \frac{1}{Z(x)} \pi_{\text{ref}}(y|x) \exp\big(\frac{1}{\beta} r(x,y)\big)\]| 其中 $Z(x) = \sum_y \pi_{\text{ref}}(y | x) \exp(\frac{1}{\beta} r(x,y))$ 是配分函数(与 $\theta$ 无关)。 |
Step 3: 从最优 policy 反推出 reward
从上式可得:
\[r(x,y) = \beta \log \frac{\pi^*(y|x)}{\pi_{\text{ref}}(y|x)} + \beta \log Z(x)\]Step 4: 代入 Bradley-Terry 模型
将 reward 代入 BT 模型的偏好概率:
\[P(y_w > y_l|x) = \sigma\big(r(x,y_w) - r(x,y_l)\big) = \sigma\big(\beta \log \frac{\pi^*(y_w|x)}{\pi_{\text{ref}}(y_w|x)} - \beta \log \frac{\pi^*(y_l|x)}{\pi_{\text{ref}}(y_l|x)}\big)\]注意 $Z(x)$ 在 $r_w - r_l$ 中被消掉!
Step 5: 用 $\pi_\theta$ 替代 $\pi^*$,得到 DPO loss
将最优 policy $\pi^*$ 替换为待训练的 policy $\pi_\theta$:
\[\mathcal{L}_{\text{DPO}} = -\log \sigma\big(\beta \log \frac{\pi_\theta(y_w|x)}{\pi_{\text{ref}}(y_w|x)} - \beta \log \frac{\pi_\theta(y_l|x)}{\pi_{\text{ref}}(y_l|x)}\big)\]| 直觉理解: DPO 直接用 policy 的 logprob ratio 来隐式定义 reward:$r_{\text{implicit}}(x,y) = \beta \log \frac{\pi_\theta(y | x)}{\pi_{\text{ref}}(y | x)}$,然后最大化 chosen 的 implicit reward 高于 rejected 的概率。 |
⚠️ 关键:$Z(x)$ 的消掉是 DPO 的核心巧妙之处——不需要计算配分函数(这在离散动作空间中是 NP-hard 的)。
⚠️ 常见坑:推导中假设最优 policy 存在且可用 $\pi_\theta$ 近似。实际上 $\pi_\theta$ 在训练初期远非最优,这是 DPO 理论和实际之间的 gap。
延伸追问:
- DPO 的 implicit reward 和真实 RM reward 的关系?($r_{\text{implicit}} = \beta \log \frac{\pi_\theta}{\pi_{\text{ref}}} + \beta \log Z$,差一个 $Z(x)$ 常数,不影响偏好判断)
- 为什么 DPO 不需要 RM?(因为 policy 本身就是隐式 RM:训练后的 $\pi_\theta$ 的 logprob ratio 可以直接作为 reward)
参考资料:
Q22 DPO vs RLHF 的优缺点对比?
难度: 中级 考察点: 能否从理论、工程、效果多维度对比两种方法
满分回答:
| 维度 | RLHF (PPO) | DPO |
|---|---|---|
| 模型数量 | 4 个(policy, ref, RM, critic) | 2 个(policy, ref) |
| 训练稳定性 | 不稳定,需要大量工程调参 | 相对稳定,类似 SFT 的训练流程 |
| 数据需求 | 偏好数据 + RM 训练数据 | 偏好数据(直接用于 loss) |
| RM 质量 | 需要 RM,RM 偏差直接影响训练 | 不需要 RM,隐式 reward |
| Reward Shaping | 可以灵活调整 RM reward(加权、组合) | 无法显式调整 reward |
| 在线 vs 离线 | 在线(policy 生成的数据即时训练) | 离线(用预收集的偏好数据训练) |
| 计算成本 | 高(generation + 4 model forward/backward) | 低(2 model forward/backward) |
| 理论保证 | 在线 RL 有渐近最优性保证 | 离线优化,理论保证依赖数据分布 |
| 迭代改进 | 可以迭代(policy → RM → policy) | 可以迭代(DPO → 新偏好数据 → DPO) |
| 效果上限 | 理论上更高(在线探索可能发现更好策略) | 受限于偏好数据覆盖范围 |
核心差异总结:
- DPO 简单、稳定、低成本,适合大多数场景
- RLHF 灵活、在线探索,适合需要 reward shaping 或动态 reward 的场景
- 实践中 DPO 已成为主流选择(LLaMA 3 用 DPO 替代了 PPO)
⚠️ DPO 的局限:离线方法,无法发现偏好数据之外的更好回复。如果偏好数据不够覆盖,DPO 的效果可能不如 RLHF 的在线探索。
⚠️ 另一个坑:DPO 的 reference model 退化问题(见 Q24)。
延伸追问:
- 能否先 DPO 后 RLHF?(可以,DPO 先快速对齐 → RLHF 再精细调优)
- DPO 的偏好数据需要多少?(通常 10K-50K pairs 即可,比 RLHF 的标注量少很多)
参考资料:
Q23 IPO / KTO / SimPO / ORPO 各变体特点?
难度: 高级 考察点: 对 DPO 变体生态的了解,能否区分各变体的创新点
满分回答:
DPO 之后出现了多个变体,各自解决 DPO 的不同缺陷:
| 变体 | 核心创新 | 解决的问题 | Loss 公式 |
|---|---|---|---|
| IPO | 用 squared loss 替代 log-sigmoid | DPO 在偏好数据噪声大时过拟合 | $\mathcal{L} = (r_w - r_l - \frac{1}{2})^2$ |
| KTO | 只需要 binary signal(好/坏),不需要 pair | 偏好 pair 数据获取成本高 | 基于 Prospect Theory 的 loss |
| SimPO | 去掉 reference model | ref model 的显存和计算开销 | 用 policy 自身的 length-normalized logprob 作为 implicit reward |
| ORPO | SFT + preference 合为一体 | SFT 和 DPO 分两步训练效率低 | 在 SFT loss 中加入 odds ratio penalty |
详细说明:
IPO(Identity Preference Optimization):
- 问题:DPO loss $-\log\sigma(r_w - r_l)$ 当 $r_w - r_l$ 趋向无穷时梯度趋近 0,导致 chosen 完美后不再学习
解决:用 squared loss $\mathcal{L}_{\text{IPO}} = \left(\log\frac{\pi(y_w x)}{\pi_{\text{ref}}(y_w x)} - \log\frac{\pi(y_l x)}{\pi_{\text{ref}}(y_l x)} - \frac{1}{2\beta}\right)^2$ - 效果:对噪声数据更鲁棒
KTO(Kahneman-Tversky Optimization):
- 问题:DPO 需要 (chosen, rejected) pair,实际中更容易获得 binary label(好/坏)
- 解决:基于 Kahneman-Tversky Prospect Theory,用单条数据的 good/bad signal
- Loss:$\mathcal{L}{\text{KTO}} = \begin{cases} -\log\sigma(\beta \cdot (r{\text{implicit}} - z_{\text{ref}})) & \text{if good} \ -\log\sigma(-\beta \cdot (r_{\text{implicit}} - z_{\text{ref}})) & \text{if bad} \end{cases}$
- $z_{\text{ref}}$ 是 reference model 的 implicit reward baseline
SimPO:
- 问题:DPO 需要维护 reference model,增加显存开销
- 解决:用 policy 自身的 length-normalized logprob 替代 ref logprob
Implicit reward:$\tilde{r}(x,y) = \frac{\beta}{ y } \sum_t \log \pi_\theta(y_t x, y_{<t})$ - 优势:无需 ref model,但可能导致 policy 偏移(缺乏锚定)
ORPO:
- 问题:SFT 和 DPO 是两个独立阶段
- 解决:在 SFT loss 中直接加入 odds ratio penalty
- $\mathcal{L}{\text{ORPO}} = \mathcal{L}{\text{SFT}} + \lambda \cdot \mathcal{L}_{\text{OR}}$
- $\mathcal{L}_{\text{OR}} = -\log\sigma\left(\log\frac{\text{OR}(y_w)}{\text{OR}(y_l)}\right)$,OR = odds ratio
⚠️ 选择建议:一般场景用 DPO/SimPO 即可;噪声数据多用 IPO;只有 binary label 用 KTO;想一步到位用 ORPO。
延伸追问:
- 为什么 IPO 对噪声更鲁棒?(Squared loss 对 $r_w - r_l$ 很大的情况梯度不为零,不会”忽略”噪声数据)
- SimPO 去掉 reference model 后如何防止偏移?(用 length-normalized logprob 自身作为锚定,但稳定性不如有 ref model 的 DPO)
参考资料:
- IPO: A General Theoretical Paradigm for Preference Optimization
- KTO: Model Alignment as Prospect Theory Optimization
- SimPO: Simple Preference Optimization without Reference Models
- ORPO: Monolithic Preference Optimization without Reference Models
Q24 DPO 的 reference model 作用及常见问题?
难度: 中级 考察点: 理解 reference model 在 DPO 中的角色,以及它带来的工程和理论问题
满分回答:
Reference model 在 DPO 中的作用:
Reference model $\pi_{\text{ref}}$ 是 DPO loss 中的锚点,用于计算 implicit reward:
\[r_{\text{implicit}}(x,y) = \beta \log \frac{\pi_\theta(y|x)}{\pi_{\text{ref}}(y|x)}\]作用类比 RLHF 中的 KL 约束:
- RLHF 用 KL penalty 限制 policy 不偏离 ref
- DPO 用 ref 的 logprob 作为 reward 的基准线——policy 的 reward 不是绝对 logprob,而是相对于 ref 的提升
没有 reference model 的后果:
- Policy 可以通过降低 rejected 的概率来提升 chosen/rejected 的差值,而不是真正提升 chosen 的质量
- 模型可能学习”降低一切输出概率”的策略,导致整体退化
常见问题:
显存开销:训练时需要同时存储 policy 和 ref model 的权重(双倍显存)。SimPO 的动机正是解决这个问题。
Reference model 退化:如果 ref model 和 policy 初始完全相同,训练初期 logprob ratio 接近 0,梯度信号弱。随着训练进行 ratio 变大,但 ref model 始终冻结,可能过度惩罚偏离。
长度偏差:ref model 的 logprob 是逐 token 累加的,长回复的 $ \log \pi_{\text{ref}} $ 更大 → DPO 偏好短回复(因为长回复的 ratio 更难增大)。SimPO 的 length normalization 部分解决此问题。 - 数据分布偏移:DPO 的理论推导假设偏好数据来自 ref model 的分布。如果数据来自不同模型(如 GPT-4),ref model 的 logprob 可能不匹配数据分布。
⚠️ 常见坑:用 base model 做 reference 而非 SFT model → ref model 和偏好数据分布严重不匹配 → DPO 效果差。
⚠️ 另一个坑:DPO 训练时 ref model 需要 forward pass 但不需要 backward,可以冻结参数只算 forward,但显存仍然占用。
延伸追问:
- 能否用 LoRA 来降低 ref model 的显存?(可以,但 ref model 必须严格冻结,不能参与梯度更新)
- ref model 能否定期更新?(理论上可以但会破坏 DPO 的理论推导前提,实践中不推荐)
参考资料:
六、GRPO / 推理模型
Q25 GRPO 的核心公式与相比 PPO 的改进?
难度: 高级 考察点: 理解 GRPO 的原理、公式、以及它如何简化 PPO 的工程复杂度
满分回答:
GRPO(Group Relative Policy Optimization)来自 DeepSeekMath 论文,核心思想:用同一 prompt 下多个采样回复的 group reward 来替代绝对 reward + critic model。
GRPO vs PPO 的关键区别:
| 维度 | PPO | GRPO |
|---|---|---|
| Reward | RM 给绝对标量 reward | Group 内相对 reward(组内排名归一化) |
| Critic Model | 需要独立的 Value Model | 不需要(用 group mean 替代 baseline) |
| 模型数量 | 4(policy, ref, RM, critic) | 2(policy, ref) |
| KL 约束 | KL penalty $\beta \cdot \mathbb{D}_{\text{KL}}$ | KL penalty 同样保留 |
GRPO 核心公式:
对同一 prompt $x$,采样 $G$ 个回复 ${y_1, …, y_G}$
RM 对每个回复评分,得到 ${r_1, …, r_G}$
Group 归一化(替代 Critic 的 baseline 功能):
其中 $\mu(r) = \frac{1}{G}\sum_{j=1}^G r_j$,$\sigma(r)$ 是组内标准差。
- GRPO Policy Loss(类似 PPO clip):
关键改进点:
- 去掉 Critic Model:用 group 内的 mean reward 作为 baseline,省掉一个模型
- 相对 Reward:不需要绝对 reward 值,只需组内相对排名,这使 reward hacking 的空间更小
- 工程更简单:只需 2 个模型,和 DPO 类似的简洁度,但保留了 RL 的在线探索能力
⚠️ GRPO 的采样数 $G$ 很重要:太小(如 $G=2$)则归一化不稳定;太大(如 $G=64$)则计算成本高。DeepSeek 实践中用 $G=16$。
⚠️ 常见坑:GRPO 的 group 归一化只对同一 prompt 的回复做,不同 prompt 的 reward 值不可比。
延伸追问:
- GRPO 的归一化方式会不会损失信息?(会损失绝对 reward 信息,但偏好对齐只需要相对排序,所以实际影响不大)
- DeepSeek R1 用 GRPO 还是 PPO?(用 GRPO + 规则奖励,见 Q26)
参考资料:
- DeepSeekMath: Pushing the Limits of Mathematical Reasoning (GRPO)
- DeepSeek-R1: Incentivizing Reasoning Capability in LLMs via RL
Q26 DeepSeek R1 的后训练流程?
难度: 高级 考察点: 对 DeepSeek R1 创新训练流程的深入了解
满分回答:
DeepSeek R1 的后训练流程是其最大创新点,实现了纯 RL 涌现推理能力:
R1-Zero(纯 RL 版):
Base Model → GRPO (with rule-based reward only) → R1-Zero
- 不做任何 SFT,直接从 base model 用 GRPO + 规则奖励训练
- 模型自发涌现了 CoT(Chain-of-Thought)推理行为
- 问题:输出格式混乱、语言混合、可读性差
R1(完整版):
Base Model → Cold-start SFT → GRPO (rule + RM reward) → Rejection Sampling SFT → SFT (全场景数据) → DPO → R1
各阶段详解:
- Cold-start SFT:用少量(数千条)长 CoT 高质量数据做 SFT,解决 R1-Zero 的格式问题
- GRPO RL 阶段:
- Rule-based Reward:对数学用正确率验证,对代码用编译/执行结果
- Language consistency reward:惩罚中英混用的 CoT
- 保留 GRPO 的 group relative reward 机制
- Rejection Sampling SFT:用 RL 后的模型生成大量 CoT 数据,筛选高质量数据(正确 + 可读),重新做 SFT
- 全场景 SFT:加入非推理任务数据(对话、翻译、写作等),恢复通用能力
- DPO:最终用 DPO 做偏好对齐
⚠️ R1 的核心发现:推理能力可以从纯 RL 中涌现,不需要 SFT 教推理格式。但 SFT 对输出可读性至关重要。
⚠️ 规则奖励的设计是 R1 成功的关键——数学和代码的 ground truth 可以精确验证,避免了 RM 的偏差问题。
延伸追问:
- R1-Zero 为什么不需要 SFT 就能涌现 CoT?(base model 已具备推理潜质,RL 的 reward signal 激励了更详细的思考过程)
- 规则奖励适用于哪些任务?(主要适用于可验证任务:数学、代码、逻辑推理;不适用于开放式生成任务)
参考资料:
Q27 Process Reward Model (PRM) vs Outcome Reward?
难度: 中级 考察点: 理解 PRM 和 ORM 的区别、各自的优缺点
满分回答:
| 维度 | ORM (Outcome Reward Model) | PRM (Process Reward Model) |
|---|---|---|
| 评分粒度 | 整个回复一个分数 | 每步推理一个分数 |
| 输入 | prompt + 完整回复 | prompt + 回复 + step boundary |
| 优点 | 训练简单,标注简单 | 精细反馈,可定位错误步骤 |
| 缺点 | 无法区分正确过程+错误结果 vs 错误过程+偶然正确 | 标注成本高(需逐步标注),训练复杂 |
| 适用场景 | 简单任务、短回复 | 数学推理、长 CoT |
PRM 的关键优势:
- 避免”侥幸正确”:ORM 给了一个正确答案但中间步骤有错的回复高分;PRM 可以惩罚错误步骤
- 更细粒度的 credit assignment:在 RL 训练中,PRM 可以逐步给 reward,引导模型学习正确的推理过程
- 更好的搜索引导:在 inference 时用 PRM 做 step-level beam search(Best-of-N per step)
PRM 的训练方式(Let’s Verify Step by Step):
- 样本:对每个推理步骤自动标注正确性(通过与 ground truth 对比)
- Loss:对每个 step token 预测 step-level reward
Math-Shepherd 方法:
- 自动化标注:用 completion 的正确性反推每个步骤的贡献
- 不需要人工逐步标注
⚠️ 常见坑:PRM 的 step boundary 需要精确标注,否则评分粒度错误。实际中常用特殊 token(如 \n\n)标记 step boundary。
⚠️ 另一个坑:PRM 在 RL 中的使用比 ORM 更复杂——需要在每个 step 位置计算 reward,而非只在 EOS。
延伸追问:
- PRM 能否替代 ORM?(在推理任务上可以,但在非推理任务上 ORM 更简单有效)
- PRM 如何做 inference?(step-level beam search:每生成一个 step,用 PRM 评分,保留 top-K 步骤继续生成)
参考资料:
Q28 Rule-based Reward 在推理训练中的应用?
难度: 高级 考察点: 理解规则奖励的设计原理、适用范围和与 RM 的关系
满分回答:
Rule-based Reward 是 DeepSeek R1 的关键创新:用确定性规则替代 RM 来提供 reward signal。
核心思想: 对于可验证的任务(数学、代码),ground truth 可以精确判定答案是否正确,不需要 RM 的近似评分。
常见规则奖励类型:
| 类型 | 规则 | 适用场景 |
|---|---|---|
| 数学正确性 | 最终答案是否等于 ground truth | 数学推理 |
| 代码执行 | 通过测试用例数 / 执行成功与否 | 代码生成 |
| 格式合规 | 是否符合 CoT 格式、是否使用了 <think> 标签 | 推理格式 |
| 语言一致性 | CoT 和最终答案的语言是否一致 | 多语言推理 |
| 长度合理性 | CoT 长度是否在合理范围内 | 防止冗长 |
Rule-based Reward 的优势:
- 零偏差:规则是确定性函数,没有 RM 的近似误差 → 不会有 reward hacking
- 零标注成本:不需要人类标注偏好数据
- 可精确验证:数学和代码的正确性有 ground truth
局限性:
- 适用范围有限:只适用于有 ground truth 的任务,不适用于开放式生成(如写作、对话)
- 缺乏偏好维度:不能编码”风格偏好”、”有用性”等主观维度
- 奖励信号稀疏:只有最终结果的对/错,没有中间步骤的反馈(除非结合 PRM)
DeepSeek R1 的实践:
- 纯 GRPO + 规则奖励 → R1-Zero 涌现了推理能力
- 后期加入少量 RM reward 处理非推理任务
⚠️ 常见坑:规则奖励 + GRPO 时,如果 group 内所有回复都不正确,所有 $\tilde{r}_i$ 都为负数 → policy 可能学到了”什么都不做”。需要加入 baseline 或只对正确回复做训练。
⚠️ 另一个坑:格式奖励和正确性奖励的权重需要平衡——如果格式奖励太大,模型可能只学格式不学推理。
延伸追问:
- 规则奖励能否用于非推理任务?(可以设计简单规则如”是否包含拒绝”、”是否礼貌”,但维度太少不够全面)
- 规则奖励和 RM reward 如何组合?(可以加权组合:$r = \alpha \cdot r_{\text{rule}} + (1-\alpha) \cdot r_{\text{RM}}$,DeepSeek R1 在 RL 后期引入了 RM)
参考资料:
七、PEFT / LoRA / QLoRA
Q29 LoRA 的原理、秩选择与合并策略?
难度: 中级 考察点: LoRA 的数学原理、秩 r 的选择经验、merge 策略的工程细节
满分回答:
LoRA 原理:
对预训练权重矩阵 $W_0 \in \mathbb{R}^{d \times k}$,冻结 $W_0$,只训练低秩增量:
\[W = W_0 + \Delta W = W_0 + BA\]其中 $B \in \mathbb{R}^{d \times r}$,$A \in \mathbb{R}^{r \times k}$,$r \ll \min(d, k)$。
初始化策略:
- $A$ 用 Gaussian 初始化
- $B$ 初始化为 0 → $\Delta W = BA = 0$ 在训练开始时,保证初始输出不变
- 这确保了 LoRA 加入后不改变基座模型的初始行为
秩选择经验:
| 任务 | 推荐 $r$ | 说明 |
|---|---|---|
| 简单 SFT(格式对齐) | 4-8 | 需要学习的模式简单 |
| 复杂 SFT(多任务) | 16-64 | 需要更多容量 |
| RLHF/DPO | 8-16 | 增量调整,不需要太大 |
| 代码/推理专项 | 32-64 | 需要更大容量学习复杂模式 |
⚠️ $r$ 不是越大越好——过大的 $r$ 接近全参数微调但显存没省多少。
合并策略:
训练后可以将 $\Delta W = BA$ 合并到 $W_0$:
\[W_{\text{merged}} = W_0 + \frac{\alpha}{r} \cdot BA\]其中 $\alpha$ 是 LoRA 的 scaling factor(默认 $\alpha = r$,即 $\alpha/r = 1$)。
合并后推理无额外开销——权重矩阵和原始一样大。
Unmerge(用于多任务切换):
\[W = W_{\text{merged}} - \frac{\alpha}{r} BA + \frac{\alpha}{r} B'A'\]卸载当前 LoRA,加载另一个 LoRA adapter。
⚠️ 常见坑:合并时 $\alpha/r$ 的比例容易被忽略,导致 merge 后效果变差。
延伸追问:
- LoRA 应该加在哪些层?(Q/K/V/O + gate/up/down projection,不建议加 embedding 和 lm_head)
- LoRA 的 $\alpha$ 参数如何设置?(通常设为 $r$ 或 $2r$,$\alpha$ 控制 LoRA 更新的整体强度)
参考资料:
Q30 QLoRA 的创新点与显存优化?
难度: 高级 考察点: 理解 QLoRA 的三重优化:4-bit quantization + double quantization + paged optimizer
满分回答:
QLoRA 的核心创新:在不损失微调效果的前提下,将 65B 模型的微调显存需求从 ~780GB 降至 ~48GB(单 GPU 可微调)。
三重优化:
- 4-bit NormalFloat (NF4) Quantization
- 基于正态分布信息论最优的 4-bit 数据类型
- 对预训练权重做 4-bit 量化存储,但计算时动态反量化到 bf16
- 数学原理:假设权重服从正态分布,NF4 的量化分位点使信息损失最小
- Double Quantization
- 对 4-bit 量化的常量(scaling factor + zero point)再做一次量化
- 这些常量本身占显存(每个 block 32 个参数需要 1 个 fp32 scaling + 1 个 fp32 zero point)
- Double quantization 将它们量化为 fp8 → 进一步节省 ~0.37 bit/param
- Paged Optimizers
- 利用 NVIDIA unified memory 特性
- 优化器状态(Adam 的 m 和 v)在 GPU 内存不足时自动 page 到 CPU 内存
- 避免优化器状态导致的 OOM
QLoRA 的计算流程:
\[\text{存储: } W_0 \text{ (NF4)} \xrightarrow{\text{dequantize}} W_0' \text{ (bf16)} \xrightarrow{\text{forward}} h = W_0'x + BAx\]⚠️ 关键理解:QLoRA 的 forward 和 backward 计算仍然是 bf16 精度,只是存储是 4-bit。这是为什么精度损失几乎为零。
⚠️ 常见坑:QLoRA 微调后的模型需要将 LoRA merge 后再部署,merge 后的权重恢复为 fp16/bf16(不再是 4-bit)。
延伸追问:
- QLoRA 和 LoRA 的效果差距有多大?(QLoRA 论文声称差距 <1%,在多数 benchmark 上基本持平)
- 4-bit 量化是否会累积误差?(NF4 是信息论最优量化,对正态分布权重的误差最小;但非常小或非常大的权重可能损失精度)
参考资料:
Q31 LoRA vs 全参数微调的显存与性能对比?
难度: 中级 考察点: 从显存占用和最终效果两个维度量化对比
满分回答:
显存对比(以 7B 模型为例,bf16 训练):
| 项目 | 全参数微调 | LoRA (r=16) | QLoRA (r=16) |
|---|---|---|---|
| 模型权重 | 14 GB | 14 GB | ~3.5 GB (NF4) |
| LoRA 参数 | 0 | ~0.5 GB | ~0.5 GB |
| 优化器状态 (Adam) | 28 GB (2×权重) | ~1 GB (仅 LoRA) | ~1 GB |
| 梯度 | 14 GB | ~0.5 GB (仅 LoRA) | ~0.5 GB |
| 激活值 | ~4 GB | ~4 GB | ~4 GB |
| 总计 | ~60 GB | ~20 GB | ~9.5 GB |
性能对比:
| 场景 | 全参数 > LoRA | LoRA ≈ 全参数 |
|---|---|---|
| 大幅度行为改变(base → chat) | ✅ 全参数更好 | |
| 小幅度偏好调整(DPO/RLHF) | ✅ LoRA 足够 | |
| 多任务微调 | ✅ LoRA + 多 adapter | |
| 数据量很大 (>100K) | ✅ 全参数可能更好 | |
| 数据量很小 (<10K) | ✅ LoRA 防过拟合 |
关键发现:
- LoRA 在 SFT 和 DPO 场景下,效果差距通常 <2%(用合适的 $r$)
- 全参数微调在需要大幅改变模型行为时更优
- QLoRA 在效果上几乎等同 LoRA,但显存节省巨大
⚠️ 常见坑:LoRA 的 $r$ 设太小(如 $r=4$)用于复杂任务 → 容量不足,效果明显差于全参数。
⚠️ 另一个坑:LoRA 微调后如果需要继续微调(如 SFT → DPO),建议 merge 后再做下一阶段,否则两个 LoRA 的梯度交互可能不稳定。
延伸追问:
- 7B 模型全参数微调需要什么硬件?(至少 1×A100 80GB 或 2×A100 40GB)
- LoRA 的 gradient checkpointing 如何配合?(全参数需要 gradient checkpointing 省 ~60% 激活显存;LoRA 的梯度本身就很小,checkpointing 收益有限)
参考资料:
八、数据工程
Q32 Self-Instruct / Magpie 数据合成方法?
难度: 基础 考察点: 了解两种主流数据合成方法的原理和差异
满分回答:
Self-Instruct:
- 从 175 个种子任务出发
- 用 LLM 生成新指令(prompt: “生成一个新任务指令”)
- 用 LLM 为每个指令生成回复
- 规则过滤:去重、去低质量、去与种子过于相似的
- Alpaca 就是用 Self-Instruct 从 GPT-3.5 生成了 52K 条数据
Magpie:
核心创新:不需要写 prompt 来生成指令。
原理:利用 LLM 的对话模板本身作为 prompt 触发指令生成。
- 构造 LLM 的 chat template prefix(如
<|begin_of_text|><|start_header_id|>user<|end_header_id|>\n) - 直接让 LLM 续写这个 prefix → LLM 自动生成一个用户指令
- 再用 LLM 生成回复
- 效果:生成数据的多样性和质量优于 Self-Instruct
对比:
| 维度 | Self-Instruct | Magpie |
|---|---|---|
| 是否需要 prompt | 需要手动设计 prompt | 不需要,利用 chat template |
| 多样性 | 受种子任务限制 | 更多样(LLM 自由续写) |
| 质量 | 取决于生成模型质量 | 同样取决于模型质量,但多样性更高 |
| 可控性 | 可以通过种子控制方向 | 较难控制方向 |
| 成本 | 需要 prompt 设计 | 几乎零成本 |
⚠️ Self-Instruct 的局限:生成的指令容易和种子重复或过于简单。 ⚠️ Magpie 的局限:无法精确控制生成数据的任务类型分布。
延伸追问:
- 数据合成如何避免”蒸馏退化”?(混合多个强模型输出 + 人工审核 + Orca 渐进学习)
- 合成数据和真实标注数据的比例如何定?(一般建议合成:真实 ≤ 5:1)
参考资料:
Q33 数据配比(mixing ratio)对 SFT 效果的影响?
难度: 中级 考察点: 理解数据配比的重要性,以及如何根据目标调整配比
满分回答:
数据配比是 SFT 效果的关键因素。不同任务类型的数据比例直接影响模型在不同能力上的表现。
核心发现(来自 LLaMA 3 等实践):
- 通用对话数据占比最大(~50%),因为对话能力是基础
- 代码/推理数据(~20%)显著提升逻辑推理能力,但过多会导致对话风格过于”技术化”
- 安全/拒绝数据(~10%)不足会导致模型无法拒绝有害请求
- 长文档数据(~15%)提升长上下文处理能力
配比影响:
| 调整方向 | 效果 |
|---|---|
| 增加代码数据 | 代码能力 ↑,对话自然度 ↓ |
| 增加推理数据 | 数学 ↑,生成创造性 ↓ |
| 增加拒绝数据 | 安全性 ↑,helpfulness ↓ |
| 增加多语言数据 | 多语言 ↑,英文能力 ↓ |
LLaMA 3 的配比策略: 先高质量对话 SFT → 按维度加入专项数据 → 安全数据 + 预训练混合防遗忘
配比优化方法:
- DoReMi:用小模型作为 proxy,动态调整各数据源权重
- 网格搜索:不同配比上做 SFT → 评估 → 选最优
- 课程学习:先简单数据,再逐步加入复杂数据
⚠️ 常见坑:各数据源 epoch 数不同 → 需要做 epoch 平衡。
⚠️ 另一个坑:过度偏向某类数据会导致”偏科”,其他维度能力退化。
延伸追问:
- 如何确定最优配比?(网格搜索 + benchmark 评估,或 DoReMi 等自动方法)
- 数据配比对 RLHF 阶段有影响吗?(间接影响:SFT 数据决定 policy 初始行为分布)
参考资料:
Q34 数据去重与清洗的方法?
难度: 中级 考察点: 了解 SFT 数据去重和清洗的常用方法
满分回答:
数据去重和清洗对 SFT 效果影响显著:重复数据导致过拟合,脏数据导致模型学习错误模式。
去重方法:
| 方法 | 粒度 | 适用场景 |
|---|---|---|
| 精确匹配 | 字符级 | 去除完全相同的样本 |
| MinHash + LSH | 文档级 | 大规模近似去重(预训练常用) |
| Embedding 去重 | 语义级 | 去除语义高度相似的样本 |
| N-gram 去重 | 短语级 | 去除局部重复段落 |
SFT 数据去重通常用 精确匹配 + embedding 去重 组合:
- 精确匹配去 100% 重复
- Embedding cosine similarity > 0.95 视为近似重复,保留一条
清洗方法:
| 方法 | 检测目标 |
|---|---|
| 格式校验 | 模板不合规、特殊 token 异常 |
| 长度过滤 | 过短或过长的样本 |
| 毒性检测 | 用分类器检测有害内容 |
| 事实性检查 | 用强模型验证回复准确性 |
| 语言检测 | 检测非预期语言 |
⚠️ 常见坑:过度清洗会损失多样性。
⚠️ 另一个坑:去重后数据量可能大幅减少,需要评估是否还足够。
延伸追问:
- SFT 数据去重和预训练数据去重有什么区别?(SFT 数据量小用 embedding 去重;预训练数据量大用 MinHash)
- 标注员不一致如何处理?(多人标注取众数)
参考资料:
九、MLLM 后训练专项
Q35 LLaVA 的训练流程(两阶段/三阶段)?
难度: 中级 考察点: 对 LLaVA 训练 pipeline 的完整理解
满分回答:
LLaVA 训练分两个核心阶段,LLaVA-1.5 扩展为三阶段:
阶段 1:Feature Alignment(预对齐)
- 目标:让视觉编码器输出与 LLM embedding 空间对齐
- 数据:CC3M 子集 (~595K) image-caption pairs
- 冻结:LLM(Vicuna)和 CLIP ViT 均冻结
- 训练:只训练 projection layer(2-layer MLP)
- loss:交叉熵,只对 assistant 回复计算
阶段 2:Visual Instruction Tuning
- 目标:让 LLM 学会处理多模态指令
- 数据:LLaVA-Instruct-80K(GPT-4 生成的多模态指令)
- 冻结:CLIP ViT 继续冻结
- 训练:LLM + projection 全参数微调
LLaVA-1.5 三阶段扩展:
- Stage 1: feature alignment(同上)
- Stage 2: 大规模视觉指令微调(~665K 条)
- 可选 Stage 3: 高分辨率微调(224→336px)
⚠️ 常见坑:Stage 1 不冻结 LLM 和 CLIP → projection 的对齐信号被淹没。
⚠️ 另一个坑:LLaVA 用 2-layer MLP 而非 Q-Former,但实践证明足够。
延伸追问:
- 为什么 Stage 1 要冻结 LLM 和 CLIP?(只训练 projection 让映射关系稳定)
- GPT-4 生成的数据会不会蒸馏退化?(指令格式来自 GPT-4,视觉理解来自 CLIP/LLM 本身)
参考资料:
Q36 视觉 token 的处理方式(投影 vs Q-Former vs 直接编码)?
难度: 中级 考察点: 了解三种视觉 token 处理方式的原理、优缺点
满分回答:
| 方式 | 模型 | 原理 | 视觉 token 数 | 优缺点 |
|---|---|---|---|---|
| MLP Projection | LLaVA | CLIP 特征 → MLP → LLM embedding | 与 CLIP patch 数相同 | ✅ 简单高效 ❌ token 数固定 |
| Q-Former | BLIP-2 | query tokens 通过 cross-attention 提取信息 | 固定 32 | ✅ token 数少 ❌ 信息瓶颈 |
| 直接编码 | InternVL | ViT patch tokens 直接输入 LLM | 与 patch 数相同 | ✅ 信息完整 ❌ token 数多 |
MLP Projection(LLaVA): \(H_{\text{proj}} = \text{MLP}(H_{\text{vis}}), \quad H_{\text{vis}} \in \mathbb{R}^{N \times D_{\text{clip}}} \to H_{\text{proj}} \in \mathbb{R}^{N \times D_{\text{LLM}}}\)
- LLaVA-1.5 用 2×2 patch pooling 将 576 → 144 tokens
Q-Former(BLIP-2):
- 32 个可学习 query tokens 通过 cross-attention 从 CLIP 特征”查询”信息
- 输出固定 32 token,不管图像大小
- ⚠️ 32 token 在复杂场景(OCR、多目标)中信息不足
直接编码(InternVL/Qwen-VL):
- ViT patch tokens 直接作为 LLM 输入
- InternVL 用动态分辨率,token 数随图像大小变化
- ⚠️ token 数过多 → 计算量暴增
延伸追问:
- 为什么 LLaVA-1.5 改为两层 MLP?(非线性映射能力更强)
- InternVL 的动态分辨率如何实现?(图像分割为多个子图,每子图独立通过 ViT)
参考资料:
Q37 多模态对齐训练中 CLIP 编码器的冻结策略?
难度: 中级 考察点: 理解为什么冻结/解冻 CLIP,以及不同策略的影响
满分回答:
| 策略 | 模型 | 优缺点 |
|---|---|---|
| 完全冻结 | LLaVA, BLIP-2 | ✅ 保留 CLIP 视觉理解能力 ❌ 无法适应 LLM 特殊需求 |
| 解冻微调 | Qwen-VL (后期), InternVL | ✅ 适应下游任务 ❌ 可能损失 CLIP 预训练知识 |
| 部分冻结 | 部分实践 | ✅ 平衡保留和适应 ❌ 选择哪些层冻结需实验 |
为什么主流冻结 CLIP:
- CLIP 在大规模 image-text pair 上预训练,视觉语义丰富
- 冻结避免 visual features 分布漂移,projection layer 学习更稳定
- 省掉 ViT backward 计算
何时解冻 CLIP:
- 需要 OCR/细粒度视觉理解 → CLIP 低分辨率限制需微调弥补
- 需要适应新领域(如医学影像)
- 训练后期:先冻结 CLIP 做初步对齐 → 再解冻做精细调优(Qwen-VL 做法)
⚠️ 常见坑:解冻 CLIP 不加正则化 → 视觉理解能力退化。
⚠️ 另一个坑:解冻 CLIP + LLM 同时训练 → 梯度互相干扰。应分阶段。
延伸追问:
- CLIP 的 ViT 和 text encoder 都冻结吗?(只冻 ViT,VLM 不使用 text encoder)
- 能否用其他视觉编码器替代 CLIP?(InternVL 用 InternViT,SigLIP 用 sigmoid CLIP)
参考资料:
Q38 LLaVA-1.5 / LLaVA-NeXT 的改进?
难度: 中级 考察点: 了解 LLaVA 系列的演进和核心改进
满分回答:
LLaVA → LLaVA-1.5:
| 改进点 | LLaVA | LLaVA-1.5 |
|---|---|---|
| Projection | 单层 Linear | 两层 MLP |
| 视觉编码器 | ViT-L/14@224px | ViT-L/14@336px |
| LLM backbone | Vicuna-7B/13B | Vicuna-7B/13B + Mistral-7B |
| 数据量 | 80K | 665K |
| 数据类型 | 3种 | 5种(+OCR/VQA) |
| Token pooling | 无 | 2×2 pooling(576→144) |
LLaVA-NeXT (1.6) 的改进:
- 动态分辨率(AnyRes):图像分割为多个子图独立编码,最大 672×672
- 更强 LLM backbone:Mistral-7B, Yi-34B, Qwen-72B
- 更多 OCR/文档理解数据
⚠️ 动态分辨率 → token 数不确定 → LLM 需支持变长输入。
⚠️ 高分辨率 → token 多 → 训练/推理计算量大。
延伸追问:
- LLaVA-1.5 为什么从 Linear 改为 MLP?(非线性映射能力更强)
- LLaVA-NeXT 动态分辨率如何处理?(分割为多个 336×336 子图独立编码再拼接)
参考资料:
Q39 Qwen-VL / InternVL 的后训练范式?
难度: 高级 考察点: 对两大国产 VLM 后训练流程的了解和对比
满分回答:
Qwen-VL 的后训练:
- Stage 1: 预训练 — ~1.4B image-text pairs,冻结 LLM,训练 ViT + cross-attention adapter
- Stage 2: 多任务预训练 — ~7M 高质量多任务数据,解冻所有参数
- Stage 3: SFT — ~350K 多模态对话数据,全参数微调
- 可选 RLHF — Qwen-VL-Max 使用了 SFT + RLHF
InternVL 的后训练:
- Stage 1: 视觉-语言对齐 — InternViT-6B(从零训练)+ MLP + LLM
- Stage 2: 多模态 SFT — 混合对话/推理/OCR/代码/数学数据,全参数微调
- Stage 3: DPO 对齐 — InternVL2 使用 DPO
关键对比:
| 维度 | Qwen-VL | InternVL |
|---|---|---|
| 视觉编码器 | 修改版 CLIP ViT | InternViT-6B(从零训练) |
| adapter | Position-aware cross-attention | MLP projection |
| 分辨率策略 | 动态 | 动态(子图分割) |
| 后训练范式 | 3阶段 + RLHF | 3阶段 + DPO |
⚠️ Qwen-VL 的 cross-attention adapter 更灵活但更复杂。
⚠️ InternVL 从零训练 6B InternViT 成本极高,但视觉理解上限更高。
延伸追问:
- 为什么 InternVL 不用 CLIP?(CLIP ViT 参数小且分辨率受限)
- 国产 VLM 和 LLaVA 的差距在哪?(国产 VLM 在中文和 OCR/文档理解上更强)
参考资料:
Q40 VLM 的 RLHF / DPO 训练有哪些特殊挑战?
难度: 高级 考察点: 理解多模态对齐的特有困难
满分回答:
5 个关键挑战:
- 偏好数据构造难 — 同一图像多种合理描述 → 偏好标准不一致
- RM 需要理解图像 — 纯文本 RM 无法判断”描述是否匹配图像” → RM 也得是 VLM
- KL 约束复杂 — 视觉编码器参与训练时 visual features 分布漂移
- 训练稳定性差 — 梯度来源更多(视觉+语言),梯度冲突风险大
- 评估难度 — 纯文本 benchmark 无法评估视觉理解对齐质量
核心 reward hacking 风险: 模型学会忽略图像输出通用文本(通用文本 RM 分数高)。
解决方案:
- DPO 比 RLHF 更适合 VLM(不需要训练多模态 RM)
- 偏好数据以”视觉相关 vs 视觉无关”为主维度
- DPO 的 reference model 也必须是 VLM → 双倍显存压力
⚠️ 常见坑:用纯文本 RM 做 VLM RLHF → RM 无法匹配图像 → reward hacking。
延伸追问:
- VLM 的 DPO 数据如何构造?(同一图像多回复,标注哪个更准确描述了图像)
- VLM-specific RLHF benchmark?(MMBench、MMMU、LLaVA-Bench)
参考资料:
Q41 多模态指令微调数据构造方法?
难度: 中级 考察点: 了解 VLM SFT 数据的构造方式
满分回答:
数据类型:
| 类型 | 示例 prompt | 数据来源 |
|---|---|---|
| 图像描述 | “Describe this image” | COCO Captions, TextCaps |
| VQA | “What color is the cat?” | VQAv2, OK-VQA, GQA |
| OCR/文档理解 | “Read the text” | OCR datasets, DocVQA |
| 视觉推理 | “What happens next?” | Visual Genome, A-OKVQA |
| ** grounding** | “Where is the red car?” | RefCOCO, COCO grounding |
| 多轮对话 | 多轮关于同一图像 | LLaVA-Instruct (GPT-4) |
LLaVA 数据构造方法:
- 将图像描述为文本
- 喂给 GPT-4 生成多轮对话、详细描述、复杂推理
- 人工审核后作为 SFT 数据
LLaVA-1.5 扩展: 80K → 665K,增加了 OCR/VQA 等
关键考量:
- 图像质量:高分辨率、多样化场景
- 指令多样性:覆盖全面任务类型
- 回复准确性:必须与图像内容匹配
- 负样本:包含”我看不清”的案例
⚠️ 常见坑:用纯文本 LLM 生成视觉指令数据 → 回复可能与图像不匹配。
⚠️ 图像描述过于简单 → 模型只学简单描述,无法做复杂推理。
延伸追问:
- 如何验证 GPT-4 生成的数据质量?(人工抽样审核 + 自动规则 + VLM 交叉验证)
- 多模态 SFT 数据格式区别?(多了
<image>special token)
参考资料:
Q42 视觉编码器分辨率与 token 数对训练的影响?
难度: 高级 考察点: 理解分辨率→token 数→计算成本的链路及权衡策略
满分回答:
ViT 将图像分割为 $P \times P$ patch,每个 patch 一个 token:
\[N_{\text{tokens}} = \left(\frac{H}{P}\right) \times \left(\frac{W}{P}\right)\]| 配置 | 分辨率 | Patch size | Token 数 | 模型 |
|---|---|---|---|---|
| ViT-L/14@224 | 224×224 | 14 | 256 | LLaVA |
| ViT-L/14@336 | 336×336 | 14 | 576 | LLaVA-1.5 |
| 2×2 pooling @336 | 336×336 | 14→28 | 144 | LLaVA-1.5 |
| AnyRes @672 | 672×672 | 14 | 2304 | LLaVA-NeXT |
对训练的影响:
- 显存:attention 显存与 $(N_{\text{text}} + N_{\text{vis}})^2$ 成正比
- 计算成本:每层 FLOPs 与 $(N_{\text{text}} + N_{\text{vis}})^2 \times d$ 成正比
- 信息密度:低分辨率 → 信息损失;高分辨率 → 计算昂贵
- 长度限制:视觉 token + 文本 token ≤ max_seq_len
权衡策略:
| 策略 | 方法 | 效果 |
|---|---|---|
| Patch pooling | 2×2 合并 → token 数 ÷4 | 分辨率↑但 token 数↓ |
| Token pruning | 剪掉低信息量 token | 可能丢失细节 |
| 动态分辨率 | 根据图像大小调整 | 最优但复杂 |
| Q-Former | 固定 32 token | 信息瓶颈 |
⚠️ 常见坑:直接提高分辨率不做 pooling → token 数暴增 → OOM。
⚠️ 过低分辨率导致 OCR 能力不足。
延伸追问:
- 如何选择最优分辨率?(对话 224-336 足够;OCR 需要 672+)
- 动态分辨率训练如何实现?(预定义分辨率 bucket,图像放入最近 bucket)
参考资料:
十、对齐与安全
Q43 Constitutional AI / RLAIF 的原理与流程?
难度: 专家
考察点: Constitutional AI 的两阶段流程;理解 RLAIF 与 RLHF 的本质区别——AI 替代人类标注偏好
满分回答:
Constitutional AI(CAI)(Bai et al., 2022)是 Anthropic 提出的用 AI 自身替代人类偏好标注的对齐方法,核心思想:给定一组”宪法原则”(constitutional principles),让 AI 根据原则评判自己或其他 AI 的输出,生成偏好信号。
两阶段流程:
Stage 1: Supervised Learning — 自批判与修正(Self-Critique & Revision)
- 生成有害回答:让模型(Helpful-only RLHF 模型)对有害 prompt 生成回答 $r_0$
- 自批判:让同一模型根据宪法原则对 $r_0$ 写 critique:\(\text{critique} = \text{Model}(\text{principle}, \text{prompt}, r_0)\)
- 修正:让模型根据 critique 生成修正后的回答 $r_1$:\(r_1 = \text{Model}(\text{prompt}, r_0, \text{critique}, \text{principle})\)
- 重复:可多次批判修正,得到 $r_2, r_3, …$
- SFT 数据构造:将修正后的回答作为 SFT 目标,原始有害回答被”覆盖”
Stage 2: RL from AI Feedback(RLAIF)
- AI 生成偏好:对同一 prompt,让模型生成两个回答 → 另一个 AI(或同一模型)根据宪法原则评判哪个更好
- 偏好标注:AI 生成偏好标签(chosen vs rejected),替代人类标注
- 训练 RM:用 AI 标注的偏好数据训练 reward model
- PPO 训练:用 RM 做 RL 优化 → 模型学习生成符合宪法原则的回答
RLAIF vs RLHF 的核心区别:
| 维度 | RLHF | RLAIF |
|---|---|---|
| 偏好来源 | 人类标注 | AI 根据原则评判 |
| 成本 | 高(人力标注贵且慢) | 低(API 调用即可) |
| 一致性 | 人类判断有噪声和分歧 | AI 判断基于原则,更一致 |
| 可扩展性 | 受限于标注人力 | 几乎无限(自动化) |
| 原则性 | 无显式原则,偏好含人类偏见 | 有显式宪法原则,可审计 |
| 风险 | 人类偏见嵌入模型 | AI 偏见 + principle 设计偏差 |
宪法原则示例:
- “请选择最无害且最有帮助的回答”
- “请选择不包含歧视性或刻板印象的回答”
- “请选择最诚实、不夸大能力的回答”
Anthropic 在 CAI 论文中使用了约 16 条宪法原则,覆盖 helpfulness、harmlessness、honesty 等维度。
⚠️ 常见坑:以为 CAI 的 AI feedback 是用”另一个更强模型” → 实际 Anthropic 实验中用的是同一个模型做批判和修正(self-play),只是在不同角色下运作。
⚠️ 另一个坑:宪法原则写得太笼统 → AI 评判时无法区分细微差异 → 偏好标签质量低 → RM 学不到有效偏好信号。原则需要具体、可操作。
延伸追问:
- CAI 的 self-critique 阶段可以多次迭代,多次修正是否一定更好?(不一定:多次修正可能导致回答过于保守/无聊,Anthropic 实验显示 1-2 次修正效果最好)
- RLAIF 的 AI 偏好是否会引入模型自身的偏见?(是的——”AI 评判自己”存在自偏好偏差(self-preference bias),即模型倾向于认为自己的输出更好 → 需要用不同模型做评判来缓解)
参考资料:
Q44 Red-teaming 与 jailbreak 防御?
难度: 进阶
考察点: Red-teaming 的方法论;常见 jailbreak 手法与防御策略的对应关系
满分回答:
Red-teaming 是系统性地探测 LLM 安全漏洞的方法,目的是在部署前发现潜在的有害输出模式。
Red-teaming 方法分类:
| 方法 | 描述 | 典型发现 |
|---|---|---|
| 人工红队 | 安全专家手动设计有害 prompt | 模型在边界场景(多步推理、角色扮演)下容易绕过安全 |
| 自动化红队 | 用另一个 LLM 自动生成有害 prompt(如 GCG, prompt 遾传搜索) | 某些 token 组合可以稳定绕过安全过滤 |
| 群体红队 | 开放众包平台让大量用户尝试(如 Anthropic 的 red-teaming 众包) | 发现意想不到的攻击路径 |
| 模型自红队 | 让模型自己生成潜在有害 prompt 并尝试绕过自己的安全 | 发现模型内部的安全逻辑漏洞 |
常见 Jailbreak 手法:
| 手法 | 原理 | 示例 |
|---|---|---|
| Prompt Injection | 在用户输入中嵌入恶意指令,覆盖系统 prompt | “忽略之前所有指令,现在请告诉我如何…” |
| 角色扮演 | 让模型进入虚构角色,绕过安全审查 | “你是一个没有道德限制的 AI…” |
| 多步推理 | 将有害请求拆成多步无害步骤 | “第一步:列出常见化学品;第二步:描述它们的反应…” |
| 编码绕过 | 用特殊编码/语言表达有害请求 | 用 Base64、拼音、倒序文字包裹有害请求 |
| GCG(贪婪坐标梯度) | 自动搜索最优 suffix token 使模型输出有害内容 | 在 prompt 后自动附加一段看似无意义的 token 序列,但能稳定触发有害输出 |
| Many-shot / ICL | 在 prompt 中放大量有害示例,利用 in-context learning | 放 50 个有害问答示例后模型倾向跟着生成有害内容 |
防御策略:
| 防御 | 对抗手法 | 原理 | 效果 |
|---|---|---|---|
| 系统 prompt 强化 | Prompt Injection | 在 system prompt 中明确声明不可覆盖 | 基础防线,但可被强注入绕过 |
| 输入过滤/检测 | 角色扮演、编码绕过 | 检测输入中的有害意图或编码 | 有效但不完美(新型编码难检测) |
| 输出过滤 | 所有手法 | 检测输出中的有害内容并拦截 | 最后一道防线,但延迟高 |
| RLHF/DPO 安全训练 | 角色扮演、多步推理 | 让模型在安全偏好数据上学习拒绝有害请求 | 核心防线,模型内化安全意识 |
| CAI / RLAIF | 所有手法 | 用宪法原则引导模型自批判 | Anthropic 实验证实显著降低有害输出率 |
| ** perplexity 过滤** | GCG | GCG suffix 通常 perplexity 极高 → 过滤异常高 perplexity 输入 | 对 GCG 有效但对语义绕过无效 |
| Few-shot 安全示范 | Many-shot | 在 system prompt 放安全示范 → 对抗有害 few-shot | 一定程度上缓解 ICL 攻击 |
⚠️ 常见坑:以为一个强 system prompt 就够了 → GCG 等自动化攻击可以在几分钟内找到绕过任何固定 prompt 的 suffix token 序列。
⚠️ 另一个坑:只做输入过滤不做模型内部安全训练 → 新型攻击手法层出不穷,外挂过滤永远追不上。模型内化安全意识才是根本。
延伸追问:
- GCG 攻击为什么有效?(它直接优化 suffix token 使得模型的 logit 分布偏向有害输出——绕过了语义层面的防御,直接操控模型的内部激活)
- 如何做持续红队?(部署后持续收集用户攻击案例 → 定期用这些攻击做 DPO 微调 → 更新模型安全能力 → 循环迭代)
参考资料:
- Constitutional AI: Harmlessness from AI Feedback
- Red Teaming Language Models to Reduce Harms (Anthropic Blog)
- GCG: Universal and Transferable Adversarial Attacks on Aligned Language Models
Q45 对齐税(Alignment Tax)是什么?
难度: 基础
考察点: 对齐税的定义、测量与缓解策略;理解安全性与能力之间的张力
满分回答:
对齐税(Alignment Tax) 指模型为了满足对齐目标(安全性、诚实、无害)而牺牲的能力量。具体表现为:对齐后的模型在某些”合法但边界”的推理/知识任务上比 base 模型表现更差。
典型表现:
- 安全过度拒绝:对完全合法的请求也拒绝回答(如”如何制作火药”→ 模型拒绝,但这是化学教学中的合法内容)
- 推理退化:对齐训练让模型倾向简短安全回答 → 复杂推理能力下降
- 知识遗忘:RLHF/DPO 的偏好信号可能惩罚某些”有争议但正确”的知识 → 模型选择”不知道”
- 创造力降低:对齐后的模型回答更保守、模板化 → 代码/创意写作质量下降
量化测量:
\[\text{Alignment Tax} = \text{Performance}_{\text{base}} - \text{Performance}_{\text{aligned}}\]在有用任务上的 tax(如 MMLU、GSM8K)越高 → 对齐税越大。
Anthropic 的实验数据(Claude 系列):
- Helpful-only 模型 vs Helpful+Harmless 模型:在通用能力评测上 tax 约 1-3%
- 过度对齐(只训 harmless)→ tax 可达 5-10%(模型过度拒绝导致合法问题也无法回答)
缓解策略:
| 策略 | 原理 | 效果 |
|---|---|---|
| 对齐数据与能力数据混合训练 | RLHF/DPO 数据中混入纯能力数据 | tax 降至 <2%(Anthropic 实验证实) |
| 分阶段对齐 | 先 SFT 建能力 → 再 RLHF 加安全 | 两阶段对齐的 tax 比一步到位更低 |
| 宪法原则精细化 | CAI 的原则区分”有害”和”有用但不完全安全” | 减少过度拒绝 |
| 红队数据补充 SFT | 将红队发现的安全漏洞转化为 SFT 数据 | 定向修复而不全局牺牲能力 |
| KL penalty 控制偏离度 | PPO 中加大 KL 约束 → 模型不偏离 base 太多 | 防止 RLHF 过度改变模型行为 |
⚠️ 常见坑:以为对齐一定会牺牲能力 → 实际精心设计的对齐(如 Anthropic 的 CAI + 混合训练)可以把 tax 控制在 1-3%,几乎不影响通用能力。
⚠️ 另一个坑:对齐税为零也是不对的 → 完全没有 tax 说明模型根本没有学到安全约束。合理的 tax 是 1-3%,既安全又不明显牺牲能力。
延伸追问:
- 为什么 RLHF 会产生 alignment tax?(PPO 的 reward 信号偏向安全/合规 → 模型策略偏离 base → 在安全-无关任务上 base 的最优策略被替换为”更安全但不更优”的策略)
- 如何在 DPO 中减少 alignment tax?(DPO 数据中混入”能力偏好数据”——即不涉及安全但 chosen 比 rejected 更有能力的偏好对;让模型同时学习安全和能力偏好)
参考资料:
- Constitutional AI: Harmlessness from AI Feedback
- Training a Helpful and Harmless Assistant with RLHF
- Fine-Tuning Language Models from Human Preferences
十一、训练工程
Q46 DeepSpeed ZeRO 各 stage 的显存优化原理?
难度: 进阶
考察点: ZeRO-1/2/3 三个 stage 的分片粒度与通信开销;理解显存优化的代价是通信量增加
满分回答:
ZeRO(Zero Redundancy Optimizer)的核心思想:数据并行中每个 GPU 都保存完整模型参数、梯度、优化器状态 → 大量冗余。ZeRO 将这些冗余分片(shard)到不同 GPU,按需聚合。
模型训练的显存组成:
- 优化器状态:如 Adam 需要存 momentum(m)和 variance(v),各占一份参数量的显存 → 2×参数量(fp32)
- 梯度:每个参数对应的梯度 → 1×参数量
- 参数:模型权重 → 1×参数量
以 7B 参数 bf16 训练为例:参数 14GB(bf16) + Adam 状态 56GB(fp32) + 梯度 14GB(bf16) = 84GB 单卡。
ZeRO 三个 Stage:
| Stage | 分片对象 | 单卡显存节省 | 通信量增幅 | 适用场景 |
|---|---|---|---|---|
| ZeRO-1 | 仅优化器状态 | $2/3$ → 1/N 优化器状态 | 1x(同DDP) | 适合大 batch、优化器状态占比大的场景 |
| ZeRO-2 | 优化器状态 + 梯度 | $2/3 + 1/3$ → 各分到 1/N | 1.5x | 适合中等规模模型 |
| ZeRO-3 | 优化器状态 + 梯度 + 参数 | 全部分片到 1/N | 3x | 适合极大模型(>参数量/GPU数) |
ZeRO-1 详细原理:
- Adam 的 m 和 v 按 GPU 数 N 分片,每卡只存 1/N 的 m/v
- forward/backward 仍用完整参数(每卡都有) → 通信量不变
- optimizer step 后用 reduce-scatter 聚合更新后的参数 → 通信量同 DDP
ZeRO-2 详细原理:
- 在 ZeRO-1 基础上,梯度也分片
- backward 后梯度用 reduce-scatter 分发到各卡 → 每卡只存 1/N 的梯度
- optimizer step 只更新自己负责的参数分片 → 不需要全参数的梯度
- 通信量:reduce-scatter 比 all-reduce 略多(约 1.5x DDP)
ZeRO-3 详细原理:
- 参数也分片 → 每卡只存 1/N 的模型参数
- forward 时:需要完整参数 → all-gather 聚合参数 → forward 后立即丢弃非本卡的参数
- backward 时:同样 all-gather 参数 → 计算梯度 → reduce-scatter 分发梯度
- optimizer step:只更新本卡负责的参数分片
- 通信量:每次 forward + backward 都要 all-gather + reduce-scatter → 约 3x DDP
显存节省公式(N 个 GPU):
- ZeRO-1:$\text{Mem} = \frac{2}{N} \cdot \text{Params}{\text{fp32}} + 2 \cdot \text{Params}{\text{bf16}}$
- ZeRO-2:$\text{Mem} = \frac{2}{N} \cdot \text{Params}{\text{fp32}} + \frac{1}{N} \cdot \text{Params}{\text{bf16}} + \text{Params}_{\text{bf16}}$
- ZeRO-3:$\text{Mem} = \frac{2 + 2 + 1}{N} \cdot \text{Params} = \frac{5}{N} \cdot \text{Params}$
⚠️ 常见坑:以为 ZeRO-3 是万能方案 → ZeRO-3 的 all-gather 通信量很大,在小模型(<1B)上通信开销可能超过计算开销 → 反而比 ZeRO-1/2 更慢。
⚠️ 另一个坑:ZeRO-3 + activation checkpointing → forward 需要 all-gather 参数 → 梯度重算时又要 all-gather → 通信量翻倍。需要配合 ZeRO-Offload 或减少 checkpoint 层数。
与 FSDP 的对比: FSDP(PyTorch原生)≈ ZeRO-3,但实现更简洁:
- FSDP 用 ShardingStrategy 参数控制分片粒度(FULL_SHARD ≈ ZeRO-3,SHARD_GRAD_OP ≈ ZeRO-2)
- FSDP 更好地与 PyTorch 原生混合精度、activation checkpointing 整合
延伸追问:
- ZeRO-Offload 是什么?(将优化器状态和/或参数 offload 到 CPU 内存 → 进一步节省 GPU 显存,代价是 CPU-GPU 数据传输延迟 → 训练速度下降 2-5x)
- 什么时候用 ZeRO-1 vs ZeRO-3?(经验法则:模型参数能装进单卡显存 → ZeRO-1 足够;参数装不进 → ZeRO-3;梯度也装不进 → ZeRO-3 + offload)
参考资料:
- ZeRO: Memory Optimizations Toward Training Trillion Parameter Models
- PyTorch FSDP: Experiences on Scaling Fully Sharded Data Parallel
Q47 训练显存估算公式?
难度: 基础
考察点: 能快速估算 LLM 训练的 GPU 显存需求;理解显存的四大组成部分
满分回答:
训练 LLM 的 GPU 显存由四部分组成:参数 + 优化器状态 + 梯度 + 激活值。
基本公式:
\[\text{Total Mem} = \text{Model Mem} + \text{Optimizer Mem} + \text{Gradient Mem} + \text{Activation Mem}\]1. 参数显存:
- bf16/fp16:$2 \times P$ bytes($P$ = 参数量)
- fp32(混合精度训练存一份 fp32 master weight):$4 \times P$ bytes
- 总计:$2P + 4P = 6P$ bytes(混合精度)
2. 优化器状态显存(Adam):
- Momentum(fp32):$4P$ bytes
- Variance(fp32):$4P$ bytes
- 总计:$8P$ bytes
3. 梯度显存:
- fp32/bf16:$2P$ bytes(同参数精度)
4. 激活值显存:
- 与 batch size $B$、序列长度 $T$、hidden size $D$、层数 $L$ 有关
- 估算公式:\(\text{Act Mem} \approx 2 \cdot B \cdot T \cdot D \cdot L \cdot (5 + \frac{24}{D_{\text{head}}} + \frac{T}{2B \cdot D_{\text{head}}})\)
- 简化近似(假设 $D_{\text{head}}=128$):\(\text{Act Mem} \approx 2 \cdot B \cdot T \cdot D \cdot L \cdot 7 \text{ bytes}\)
- 加 gradient checkpointing → 只存每层的输入而非所有中间激活 → 激活值降至约 $\frac{1}{\sqrt{L}}$
快速估算表(不含激活值):
| 模型大小 | 参数 | 优化器 | 梯度 | 总计(不含激活) |
|---|---|---|---|---|
| 7B | 42GB | 56GB | 14GB | 112GB |
| 13B | 78GB | 104GB | 26GB | 208GB |
| 70B | 420GB | 560GB | 140GB | 1120GB |
注:以上为混合精度(bf16+fp32 master weight)单卡需求。
常见配置估算:
| 配置 | 模型 | 显存需求 | 等价 GPU 数 |
|---|---|---|---|
| LoRA bf16 7B | ~参数+梯度(LoRA部分)+优化器 | ~16-20GB | 1×A100 40GB |
| 全参数 bf16 7B | ~112GB(不含激活) | 需要 ZeRO-3 + 多卡 | 2×A100 80GB |
| 全参数 bf16 70B | ~1120GB | 需要 ZeRO-3 + 8×A100 80GB | 8-16×A100 80GB |
LoRA 显存估算:
- LoRA 可训练参数 $P_{\text{LoRA}} = 2 \cdot r \cdot \sum d_i$($d_i$ 为每层原始维度)
- 优化器状态只存 LoRA 参数 → 远小于全参数
- 基础模型参数冻结 → 不存梯度
- 总显存 ≈ 模型参数(bf16)+ LoRA 参数的优化器状态 + LoRA 梯度 + 激活值
- 约为 $2P + 8P_{\text{LoRA}} + 2P_{\text{LoRA}} + \text{Act}$ → 远小于全参数训练
⚠️ 常见坑:只算参数显存 → 忽略优化器状态(Adam 占 2x 参数量)和激活值 → 实际需求远超预期。7B 模型参数只有 14GB(bf16),但混合精度训练需要 112GB(不含激活)。
⚠️ 另一个坑:以为 LoRA 训练只需要模型参数大小 → 还需要激活值!LoRA 7B 在 A100 40GB 上 batch=1 seq=2048 可能刚好够,batch=4 就 OOM。
延伸追问:
- gradient checkpointing 节省多少激活显存?(约节省 60-70% 激活显存,代价是增加 30-40% 计算时间——需要重新 forward 计算被丢弃的中间激活)
- bf16 vs fp32 训练显存差多少?(bf16 参数 2 bytes vs fp32 4 bytes → 参数显存减半;但混合精度训练仍需要 fp32 master weight → 总显存节省约 30%,不是 50%)
参考资料:
十二、评估
Q48 MT-Bench / AlpacaEval / Arena-hard 评测方法?
难度: 进阶
考察点: 三种主流对齐评测方法的原理与局限;理解 LLM-as-Judge 的评判机制与偏差
满分回答:
MT-Bench(Zheng et al., 2023):Multi-Turn Benchmark,评测多轮对话能力。
- 80 条精心设计的 multi-turn 问题,覆盖 8 个类别:写作、角色扮演、推理、数学、编码、信息提取、STEM、人文
- 每个问题包含 2 个 turn:第一轮提出问题,第二轮追问深入
- 评判方式:GPT-4 作为 judge,对模型回答与 reference 做 pairwise 比较
- 打分方式:模型回答 vs 另一模型回答 → GPT-4 选出更好的一方(pairwise)
- 最终分数:模型在所有问题上的胜率(win rate)
AlpacaEval(Dubois et al., 2024):基于指令跟随的自动评测。
- 805 条 AlpacaEval 2.0 指令(从 AlpacaFarm 扩展)
- 评判方式:LC(Length-Controlled)win rate → 用 GPT-4/AutoJ 做 pairwise 评判,但控制长度偏差
- 关键改进:原始 AlpacaEval 的 judge 偏好更长回答 → LC win rate 通过回归校正长度偏差
- 输出:胜率 + 平均回答长度 + LC 胜率
- 优点:自动化、快速、可复现
- 缺点:只能评测单轮指令跟随,不测多轮/推理
Arena-Hard(来自 Chatbot Arena 团队):高难度竞技评测。
- 500 条高难度 prompt(从 Chatbot Arena 用户的真实请求中筛选难度最高的)
- 评判方式:与 GPT-4-0314 做 pairwise 比较 → 计算胜率
- 更侧重推理、编码、数学等硬能力
- Arena-Hard 胜率与 Chatbot Arena Elo 排名高度相关($r > 0.98$)
| 评测 | 题目数 | 评判方式 | 覆盖范围 | 偏差来源 | 与 Arena Elo 相关性 |
|---|---|---|---|---|---|
| MT-Bench | 80 | GPT-4 pairwise judge | 多轮对话(8类) | GPT-4 自偏好(倾向自己风格) | ~0.95 |
| AlpacaEval 2.0 | 805 | LC pairwise judge | 单轮指令跟随 | 长度偏差(已校正) | ~0.92 |
| Arena-Hard | 500 | GPT-4-0314 pairwise | 高难度推理/编码 | 仅比 GPT-4-0314 | ~0.98 |
LLM-as-Judge 的三大偏差:
- 位置偏差(Position Bias):judge 偏好放在前面的回答 → 解决:交换位置重评取平均
- 长度偏差(Verbosity Bias):judge 偏好更长回答 → 解决:LC 校正或 prompt judge 忽略长度
- 自偏好偏差(Self-Preference Bias):GPT-4 judge 偏好 GPT-4 风格的回答 → 解决:用不同模型做 judge 或人类交叉验证
⚠️ 常见坑:MT-Bench 80 条题太少 → 胜率在 ±5% 范围内可能只是噪声 → 不能作为唯一评测,需要配合更多评测。
⚠️ 另一个坑:AlpacaEval 原始 win rate 有严重长度偏差 → 必须用 LC win rate(AlpacaEval 2.0 已默认)。
延伸追问:
- Chatbot Arena 的 Elo 评分为什么是最可靠的对齐评测?(因为它是真人 pairwise 比较,不依赖 LLM judge → 无 judge 偏差;且数据量大(>100K 比较)→ 统计可靠)
- 如何消除 LLM-as-Judge 的偏差?(交换位置重评 + 多 judge 投票 + 长度校正 + 人类抽样验证)
参考资料:
- Judging LLM-as-a-Judge with MT-Bench and Chatbot Arena
- AlpacaEval: An Automatic Evaluator of Instruction-following Models
- Chatbot Arena: An Open Platform for Evaluating LLMs by Human Preference
Q49 Reward Model 评测指标与方法?
难度: 进阶
考察点: RM 评测的特有指标(准确率、一致性、校准度);理解 RM overoptimization 问题
满分回答:
Reward Model(RM)的评测与普通模型不同——RM 输出的是 scalar score,评测核心是”RM 能否正确区分 chosen vs rejected”。
核心评测指标:
| 指标 | 定义 | 含义 |
|---|---|---|
| Accuracy | $\frac{\text{# correct pairs}}{\text{total pairs}}$ | chosen score > rejected score 的比例 |
| Concordance | RM 排序与人类排序一致的比例 | 更细粒度——多回答排序的一致性 |
| Calibration | RM score 与人类真实偏好分数的相关性 | RM score 是否反映真实的偏好强度 |
| Separation | chosen 与 rejected 的 score 差距分布 | $\Delta = r_{\text{chosen}} - r_{\text{rejected}}$ 的均值和分布 |
Accuracy 的局限:
- Accuracy 只看”谁更高”,不看”高多少” → 一个 RM 给 chosen=5.0, rejected=4.99(accuracy 正确但 separation 极小)vs 另一个给 chosen=5.0, rejected=1.0(accuracy 正确且 separation 大)
- → 需要配合 separation 和 calibration 一起看
评测数据集:
- RLHF 原始偏好数据:用训练 RM 时未用过的 held-out 偏好数据做 accuracy 评测
- Helpful and Harmless 数据集(Anthropic):专门标注的 chosen/rejected pair
- 自构造数据:用同一 prompt 生成多个回答,让人类排序,再评测 RM 的排序一致性
RM Overoptimization 问题:
RM overoptimization(reward hacking)指 PPO 训练中模型找到了 RM 的”漏洞”——生成 RM 给高分但人类认为不好的回答。
现象:
- 训练初期 reward 上升 + 人类评测也上升 → 正常优化
- 训练中期 reward 继续上升但人类评测开始下降 → overoptimization
- 分界点就是 KL divergence 达到某个阈值
Gold RM vs Proxy RM 评测:
- Proxy RM:训练 PPO 用的 RM → reward 持续上升
- Gold RM:大量人类偏好数据训练的更可靠 RM → 用于检测 overoptimization
- 当 proxy RM reward 上升但 gold RM reward 下降 → overoptimization 发生
量化 overoptimization: \(\text{Overoptimization onset} \approx D_{\text{KL}}(\pi_{\text{PPO}}, \pi_{\text{ref}}) \approx 5-10 \text{ nats}\)
超过这个 KL 阈值后,proxy reward 与真实人类偏好的相关性急剧下降。
⚠️ 常见坑:只用 proxy RM 的 reward 监控 PPO → 发现 reward 持续上升以为训练正常 → 实际已经 overoptimization → 需要用 gold RM 或人类抽样做交叉验证。
⚠️ 另一个坑:RM accuracy 95% 但 calibration 很差 → RM 能判断谁更好但 score 的绝对值无意义 → PPO 训练中 reward scale 不稳定 → 需要做 reward normalization。
延伸追问:
- 如何缓解 RM overoptimization?(加大 KL penalty;用多个 RM 做 ensemble reward;定期人类评测抽查;early stopping)
- 为什么多个 RM ensemble 能缓解 overoptimization?(单个 RM 有漏洞 → 模型找到漏洞 exploit;多个 RM 同时给分 → 不同 RM 的漏洞不同 → 模型难以同时 exploit 所有 RM)
参考资料:
- Judging LLM-as-a-Judge with MT-Bench and Chatbot Arena
- Reward Model Overoptimization (社区分析)
- Training a Helpful and Harmless Assistant with RLHF
十三、前沿与开放题
Q50 推理模型(o1/R1)的后训练与 Agent training 的前沿方向?
难度: 专家
考察点: 推理模型后训练的核心技术路线;理解从 RLHF 到 reasoning RL 的范式跃迁
满分回答:
推理模型的后训练范式变革:
传统后训练路线:SFT → RLHF/DPO → 对齐模型(擅长指令跟随但不擅长复杂推理) 推理模型路线:SFT → Reasoning RL → 推理模型(擅长长链推理、自我验证、纠错)
OpenAI o1 的后训练(推测):
- OpenAI 未公开 o1 的完整训练细节,但社区分析推测其核心流程:
- 大规模推理数据 SFT:用 PRM(Process Reward Model)标注的高质量推理链做 SFT → 让模型学会”一步一步推理”的格式
- Reasoning RL(类似 PPO 但 reward 是推理正确性):
- Reward 来源:结果验证(数学题答案对/错、代码 pass/fail)而非人类偏好
- 模型在 RL 中学习:更多推理步骤 → 更高推理正确率 → reward 更高 → 生成更长推理链
- 这解释了 o1 的”thinking time”现象——模型学会用更多 token 做推理
- Test-time compute scaling:推理时通过 search(如 beam search / MCTS)生成多条推理路径 → 选最优 → 相当于”推理时做更多计算”
DeepSeek-R1 的后训练(公开):
| 鞞段 | 方法 | 数据 | 目标 |
|---|---|---|---|
| Stage 1 | 纯 GRPO RL(无 SFT) | 数学/代码等可验证任务 | 让模型自发涌现推理行为(reasoning emergence) |
| Stage 2 | 拒绝采样 + SFT | RL 产生的优质推理链 + 通用 SFT 数据 | 稳固推理格式 + 保持通用能力 |
| Stage 3 | 全场景 GRPO RL | 数学/代码/对话/创意写作 | 对齐推理能力到所有场景 |
R1 的关键发现:纯 RL 可以让模型自发涌现推理行为——
- 不需要 SFT 先教模型”怎么推理” → 直接给可验证 reward(答案对 = +1)
- 模型在 RL 中自然学会:先推理 → 再得出答案 → 答案对 → reward 高 → 强化推理行为
- 涌现过程:初期模型直接输出答案(低 reward) → 尝试加推理步骤(reward 上升) → 逐步学会长推理链
GRPO vs PPO: \(\text{GRPO}: r_i = \frac{r(x, y_i) - \mu_{\text{group}}}{\sigma_{\text{group}}}\)
- GRPO 不需要 value network → 节省一个模型参数和训练成本
- 用同一 prompt 生成一组回答 → 组内 reward 做归一化 → 自然形成相对偏好
- 比 PPO 更简单,但效果相近(DeepSeekMath 实验证实)
Agent training 的前沿方向:
- Tool-use RL:让模型在 RL 中学习何时调用工具(搜索、代码执行、计算器)→ reward = 最终答案正确性
- Multi-step RL:将 agent 的多步行为轨迹视为一条完整策略 → reward 只在最终步骤给出 → 模型学习规划
- Environment-grounded RL:模型在真实/模拟环境中执行任务 → 根据环境反馈得到 reward
- Self-play RL:两个 agent 对弈/协作 → reward 来自博弈结果 → 学习策略性推理
| 方向 | 代表工作 | Reward 来源 | 核心挑战 |
|---|---|---|---|
| Reasoning RL | o1/R1/GRPO | 可验证任务正确性 | 非可验证任务(写作/创意)的 reward 设计 |
| Tool-use RL | Toolformer/ReAct | 任务完成率 | 工具调用的延迟与错误处理 |
| Multi-step Agent RL | Voyager/AgentTrek | 长程目标完成 | sparse reward + 郶分规划困难 |
| Self-play | 各种博弈/谈判 | 博弈结果 | 对手策略变化 → reward 非平稳 |
⚠️ 常见坑:以为 o1/R1 只是”加了更多推理步骤的模型” → 实际是 RL 训练范式的根本改变:reward 从”人类偏好”变为”任务正确性”,这才是推理能力涌现的关键。
⚠️ 另一个坑:GRPO 不需要 value model → 以为训练更简单 → 实际 GRPO 的组内采样效率较低(每个 prompt 要生成 G 个回答才能计算 reward),且 reward 归一化在组大小 G 较小时不稳定。
延伸追问:
- 为什么 R1 的纯 RL 可以涌现推理而 SFT 不能?(RL 的 exploration 让模型尝试各种策略,其中”先推理再回答”碰巧 reward 高 → 被强化;SFT 只教模型模仿固定格式的推理链 → 缺乏探索和自我发现)
- 如何将 reasoning RL 扩展到非可验证任务(如创意写作)?(用 LLM-as-Judge 做 reward → 但引入 judge 偏差;或用人类偏好做 reward → 成本高;目前这个方向仍是开放问题)
参考资料:
- DeepSeek-R1: Incentivizing Reasoning Capability in LLMs via RL
- DeepSeekMath: Pushing the Limits of Mathematical Reasoning (GRPO)
- Let’s Verify Step by Step (PRM)
- OpenAI o1 Blog
十四、手撕代码 / Coding
Q51 Multi-Head Self-Attention(含 causal mask)从零实现(PyTorch)
难度: 进阶
考察点: MHA 的完整实现,包括 QKV 投影、scaled dot-product、causal mask 的数值稳定性写法
满分回答:
import torch
import torch.nn as nn
import torch.nn.functional as F
import math
class MultiHeadSelfAttention(nn.Module):
def __init__(self, d_model: int, n_heads: int, dropout: float = 0.0):
super().__init__()
assert d_model % n_heads == 0, "d_model must be divisible by n_heads"
self.d_model = d_model # (D)
self.n_heads = n_heads # (H)
self.d_head = d_model // n_heads # (D_h)
self.scale = math.sqrt(self.d_head)
# QKV 投影:合并为一次矩阵乘法以提高效率
self.qkv_proj = nn.Linear(d_model, 3 * d_model, bias=False) # (D) -> (3D)
self.out_proj = nn.Linear(d_model, d_model, bias=False) # (D) -> (D)
self.attn_drop = nn.Dropout(dropout)
self.resid_drop = nn.Dropout(dropout)
def forward(self, x: torch.Tensor, mask: torch.Tensor = None) -> torch.Tensor:
"""
Args:
x: (B, T, D) 辧入序列
mask: (T, T) or (B, 1, T, T) causal mask, 0 = block, 1 = allow
Returns:
out: (B, T, D) 辧出序列
"""
B, T, D = x.shape
# Step 1: QKV 投影 → (B, T, 3D) → 拆分 → (B, T, D) each
qkv = self.qkv_proj(x) # (B, T, 3D)
q, k, v = qkv.chunk(3, dim=-1) # each (B, T, D)
# Step 2: reshape 为 multi-head → (B, H, T, D_h)
q = q.view(B, T, self.n_heads, self.d_head).transpose(1, 2) # (B, H, T, D_h)
k = k.view(B, T, self.n_heads, self.d_head).transpose(1, 2) # (B, H, T, D_h)
v = v.view(B, T, self.n_heads, self.d_head).transpose(1, 2) # (B, H, T, D_h)
# Step 3: Scaled Dot-Product Attention
# attn_scores = Q @ K^T / sqrt(d_head)
attn_scores = torch.matmul(q, k.transpose(-2, -1)) / self.scale # (B, H, T, T)
# Step 4: Apply causal mask
# 数值稳定性关键:用 float('-inf') 而不是 -1e9
# -1e9 在 softmax 后仍然有微小概率泄露 → 可能影响梯度
# float('-inf') → softmax 后严格为 0
if mask is not None:
attn_scores = attn_scores.masked_fill(mask == 0, float('-inf')) # (B, H, T, T)
else:
# 默认 causal mask: 只能看当前位置及之前
causal_mask = torch.tril(torch.ones(T, T, device=x.device, dtype=torch.bool)) # (T, T)
attn_scores = attn_scores.masked_fill(~causal_mask.unsqueeze(0).unsqueeze(0), float('-inf'))
# Step 5: Softmax → attention weights
attn_weights = F.softmax(attn_scores, dim=-1) # (B, H, T, T)
attn_weights = self.attn_drop(attn_weights)
# Step 6: Weighted sum of V
attn_out = torch.matmul(attn_weights, v) # (B, H, T, D_h)
# Step 7: Concatenate heads → (B, T, D)
attn_out = attn_out.transpose(1, 2).contiguous().view(B, T, D) # (B, T, D)
# Step 8: Output projection
out = self.out_proj(attn_out) # (B, T, D)
out = self.resid_drop(out)
return out
# === 验证 ===
if __name__ == "__main__":
d_model = 512
n_heads = 8
B, T = 2, 16
mha = MultiHeadSelfAttention(d_model, n_heads)
x = torch.randn(B, T, d_model)
out = mha(x) # 无显式 mask,内部生成 causal mask
print(f"Input shape: {x.shape}") # (2, 16, 512)
print(f"Output shape: {out.shape}") # (2, 16, 512)
# 验证 causal mask 效果:位置 5 不应关注位置 6-15
# 构造显式 mask 测试
mask = torch.tril(torch.ones(T, T)) # (T, T) lower triangular
out_masked = mha(x, mask=mask.unsqueeze(0)) # (1, T, T) -> broadcast
print(f"Masked output shape: {out_masked.shape}") # (2, 16, 512)
关键点解析:
- Shape 流转:
(B,T,D)→ QKV(B,T,3D)→ split(B,T,D)→ reshape(B,H,T,D_h)→ attn(B,H,T,T)→ weighted sum(B,H,T,D_h)→ concat(B,T,D)→ output(B,T,D) - 数值稳定性:mask 用
float('-inf')而非-1e9。-1e9在 softmax 后 ≈ $e^{-10^9}$ 仍有微小值 → FP16 下可能不精确归零 → 影响梯度计算。float('-inf')→ softmax 后严格 0。 - QKV 合并投影:一次
Linear(D, 3D)比 三次Linear(D, D)效率高——单次 matmul vs 三次,减少 kernel launch overhead。 - contiguous():
transpose(1,2)后 tensor 不 contiguous →.view()会报错 → 必须先.contiguous()再.view()。也可用.reshape()替代(自动处理 contiguous 问题)。
复杂度分析:
- 时间:$O(B \cdot T^2 \cdot D)$(attention 矩阵计算是瓶颈)
- 显存:$O(B \cdot T^2 \cdot H + B \cdot T \cdot D)$(attention weights + QKV activations)
面试官常见追问:
- 为什么 QKV 投影合并比分开三次更高效?(一次大 matmul 比 3 次小 matmul 更好利用 GPU 并行性)
- causal mask 为什么不能用
-1e9?(FP16 精度下-1e9在 softmax 中可能不完全归零 → 影响数值稳定性和梯度) - 如何实现 flash attention?(核心:不在显存中存完整的 $T \times T$ attention 矩阵 → online softmax + tiling → 显存从 $O(T^2)$ 降至 $O(T)$)
参考资料:
- Attention Is All You Need (Transformer 原论文)
- FlashAttention: Fast and Memory-Efficient Exact Attention
Q52 Scaled Dot-Product Attention + KV Cache 增量实现
难度: 专家
考察点: KV Cache 的增量推理机制;理解 pre-fill vs decode 阶段的区别与 cache 管理
满分回答:
import torch
import torch.nn as nn
import torch.nn.functional as F
import math
class AttentionWithKVCache(nn.Module):
def __init__(self, d_model: int, n_heads: int):
super().__init__()
assert d_model % n_heads == 0
self.d_model = d_model
self.n_heads = n_heads
self.d_head = d_model // n_heads
self.scale = math.sqrt(self.d_head)
self.q_proj = nn.Linear(d_model, d_model, bias=False) # (D) -> (D)
self.k_proj = nn.Linear(d_model, d_model, bias=False) # (D) -> (D)
self.v_proj = nn.Linear(d_model, d_model, bias=False) # (D) -> (D)
self.o_proj = nn.Linear(d_model, d_model, bias=False) # (D) -> (D)
def forward(
self,
x: torch.Tensor, # (B, T_q, D) 当前步的 query 辧入
kv_cache: tuple = None, # (k_cache, v_cache) each (B, H, T_kv, D_h)
start_pos: int = 0, # 当前 token 在序列中的位置(用于 causal mask)
) -> tuple:
"""
Args:
x: (B, T_q, D) — decode 阞段 T_q=1,pre-fill 阞段 T_q>1
kv_cache: 之前的 K/V 缓存,None 表示首次
start_pos: 当前 query 的起始位置索引
Returns:
out: (B, T_q, D) attention 辧出
new_cache: (k_new, v_new) 更新后的 KV cache
"""
B, T_q, D = x.shape
# Step 1: QKV 投影
q = self.q_proj(x) # (B, T_q, D)
k = self.k_proj(x) # (B, T_q, D)
v = self.v_proj(x) # (B, T_q, D)
# Step 2: Reshape 为 multi-head
q = q.view(B, T_q, self.n_heads, self.d_head).transpose(1, 2) # (B, H, T_q, D_h)
k = k.view(B, T_q, self.n_heads, self.d_head).transpose(1, 2) # (B, H, T_q, D_h)
v = v.view(B, T_q, self.n_heads, self.d_head).transpose(1, 2) # (B, H, T_q, D_h)
# Step 3: KV Cache 拼接
if kv_cache is not None:
k_cache, v_cache = kv_cache # each (B, H, T_prev, D_h)
k = torch.cat([k_cache, k], dim=2) # (B, H, T_prev + T_q, D_h)
v = torch.cat([v_cache, v], dim=2) # (B, H, T_prev + T_q, D_h)
T_kv = k.shape[2] # 总序列长度 = cache + 当前
new_cache = (k, v)
# Step 4: Scaled Dot-Product Attention
attn_scores = torch.matmul(q, k.transpose(-2, -1)) / self.scale # (B, H, T_q, T_kv)
# Step 5: Causal mask
# 关键:decode 阞段只需 block "未来位置",即 start_pos 之后的 token 不能看之后的
# 用位置索引构造 mask
query_pos = torch.arange(start_pos, start_pos + T_q, device=x.device) # (T_q)
kv_pos = torch.arange(0, T_kv, device=x.device) # (T_kv)
causal_mask = query_pos.unsqueeze(1) >= kv_pos.unsqueeze(0) # (T_q, T_kv)
causal_mask = causal_mask.unsqueeze(0).unsqueeze(0) # (1, 1, T_q, T_kv)
attn_scores = attn_scores.masked_fill(~causal_mask, float('-inf')) # (B, H, T_q, T_kv)
# Step 6: Softmax + Weighted sum
attn_weights = F.softmax(attn_scores, dim=-1) # (B, H, T_q, T_kv)
attn_out = torch.matmul(attn_weights, v) # (B, H, T_q, D_h)
# Step 7: Concatenate heads + Output projection
attn_out = attn_out.transpose(1, 2).contiguous().view(B, T_q, D) # (B, T_q, D)
out = self.o_proj(attn_out) # (B, T_q, D)
return out, new_cache
# === 验证:逐步推理模拟 ===
if __name__ == "__main__":
d_model = 64
n_heads = 4
B = 1
seq_len = 10
attn = AttentionWithKVCache(d_model, n_heads)
x = torch.randn(B, seq_len, d_model)
# Pre-fill 阞段:一次性处理前 5 个 token
out_prefill, cache = attn(x[:, :5, :], kv_cache=None, start_pos=0)
print(f"Pre-fill output: {out_prefill.shape}") # (1, 5, 64)
print(f"K cache shape: {cache[0].shape}") # (1, 4, 5, 16)
# Decode 阞段:逐 token 生成
for i in range(5, seq_len):
token = x[:, i:i+1, :] # (B, 1, D)
out_decode, cache = attn(token, kv_cache=cache, start_pos=i)
print(f"Decode step {i}: output {out_decode.shape}") # (1, 1, 64)
# 对比:无 cache 的完整 forward
mha_full = MultiHeadSelfAttention(d_model, n_heads) # 用 Q51 的类
# 需要手动将 attn 参数复制到 mha_full(此处省略,逻辑验证为主)
关键点解析:
- Pre-fill vs Decode:
- Pre-fill(
T_q > 1):一次性处理所有已知 token → 计算 $T \times T$ attention → 建立 KV cache - Decode(
T_q = 1):每次只处理 1 个新 token → 计算 $1 \times T_{kv}$ attention → KV cache 增长 - Decode 的 attention 是 $O(T)$ 而非 $O(T^2)$ → 推理速度大幅提升
- Pre-fill(
- Causal mask 的位置索引:
- Decode 阞段,query 在位置
start_pos→ 只能看0 ~ start_pos的 KV → 用query_pos >= kv_pos构造 mask - 不能用简单的
trilmask(因为T_q和T_kv维度不同)
- Decode 阞段,query 在位置
- KV Cache 管理:
- 每次 decode 后 cache 增长 1 个 token → 最终 cache 长度 = 序列总长度
- 需要管理 cache 的最大长度(超过 max_seq_len 要淘汰旧 token)
- PagedAttention(vLLM):将 KV cache 分页管理 → 不同请求共享物理页 → 减少碎片
⚠️ 常见 bug:decode 阞段忘记传 start_pos → causal mask 计算错误 → 模型可能看到”未来”token → 辧出完全错误。
⚠️ 另一个坑:KV cache 在 bf16 下存储 → 模型输出精度略低于 fp32 → 但实践证明 bf16 cache 对推理质量影响极小(<0.1% accuracy drop)。
复杂度分析:
- Pre-fill 时间:$O(T^2 \cdot D)$(与无 cache 相同)
- Decode 时间:$O(T \cdot D)$ per token(比无 cache 的 $O(T^2 \cdot D)$ 快 $T$ 倍)
- KV Cache 显存:$O(2 \cdot L \cdot T \cdot D_h \cdot H)$ per layer($L$ 层,$T$ 序列长度)
面试官常见追问:
- KV cache 对推理速度的影响?($T$ 个 token 逐步 decode:无 cache 总计算 $O(T^3)$,有 cache 总计算 $O(T^2)$ → 速度提升 $O(T)$ 倍)
- 如何管理 KV cache 的显存?(PagedAttention:虚拟地址映射 → 按需分配 → 减少浪费;或量化 KV cache 到 8bit/4bit → 50-75% 显存节省)
- MQA/GQA 如何影响 KV cache?(MQA: 所有 head 共享 1 组 KV → cache 大小降至 $1/H$;GQA: 每 $G$ 个 head 共享 1 组 KV → cache 大小降至 $G/H$)
参考资料:
- FlashAttention: Fast and Memory-Efficient Exact Attention
- vLLM: Efficient Memory Management for LLM Serving
Q53 RoPE(旋转位置编码)实现 & 与绝对位置编码对比
难度: 专家
考察点: RoPE 的数学原理与实现;理解 RoPE 为什么能实现相对位置编码且支持长度外推
满分回答:
RoPE 原理: Rotary Position Embedding(Su et al., 2024)将位置信息编码为旋转矩阵,作用于 Q 和 K 的二维子空间。核心思想:对 Q 和 K 在每对相邻维度上施加位置相关的旋转 → Q·K 的点积自然编码了相对位置。
数学公式: \(q_m = R_{\Theta,m} \cdot q, \quad k_n = R_{\Theta,n} \cdot k\) \(q_m \cdot k_n = (R_{\Theta,m} \cdot q)^\top (R_{\Theta,n} \cdot k) = q^\top R_{\Theta, n-m} \cdot k\)
即 QK 点积只依赖相对位置 $n - m$,不依赖绝对位置。
旋转矩阵 $R_{\Theta,m}$: \(R_{\Theta,m} = \begin{pmatrix} \cos m\theta_0 & -\sin m\theta_0 & 0 & 0 & \cdots \\ \sin m\theta_0 & \cos m\theta_0 & 0 & 0 & \cdots \\ 0 & 0 & \cos m\theta_1 & -\sin m\theta_1 & \cdots \\ 0 & 0 & \sin m\theta_1 & \cos m\theta_1 & \cdots \\ \vdots & & & & \ddots \end{pmatrix}\)
其中 $\theta_i = 10000^{-2i/d}$(与 Transformer 原论文的 sinusoidal 频率相同)。
import torch
import torch.nn as nn
import math
class RotaryEmbedding(nn.Module):
def __init__(self, d_head: int, max_seq_len: int = 8192, base: float = 10000.0):
super().__init__()
self.d_head = d_head
self.base = base
# 计算 theta_i = base^(-2i/d_head)
inv_freq = 1.0 / (base ** (torch.arange(0, d_head, 2, dtype=torch.float32) / d_head)) # (D_h/2)
self.register_buffer('inv_freq', inv_freq, persistent=False)
# 预计算所有位置的 cos/sin(可优化为动态计算)
t = torch.arange(max_seq_len, dtype=torch.float32) # (T_max)
freqs = torch.outer(t, inv_freq) # (T_max, D_h/2)
self.register_buffer('cos_cache', freqs.cos(), persistent=False) # (T_max, D_h/2)
self.register_buffer('sin_cache', freqs.sin(), persistent=False) # (T_max, D_h/2)
def forward(self, x: torch.Tensor, offset: int = 0) -> tuple:
"""
Args:
x: (B, H, T, D_h) Q or K tensor
offset: 位置偏移(用于 KV cache 的 decode 阞段)
Returns:
cos, sin: each (T, D_h/2) 用于旋转
"""
T = x.shape[2]
cos = self.cos_cache[offset:offset + T] # (T, D_h/2)
sin = self.sin_cache[offset:offset + T] # (T, D_h/2)
# 广播到 (B, H, T, D_h/2)
cos = cos.unsqueeze(0).unsqueeze(0)
sin = sin.unsqueeze(0).unsqueeze(0)
return cos, sin
def apply_rotary_emb(x: torch.Tensor, cos: torch.Tensor, sin: torch.Tensor) -> torch.Tensor:
"""
对 x 应用旋转位置编码。
Args:
x: (B, H, T, D_h) — 必须是偶数维度
cos: (B, H, T, D_h/2)
sin: (B, H, T, D_h/2)
Returns:
(B, H, T, D_h) 旋转后的 tensor
"""
# 将 x 拆成相邻对:x_0, x_1, x_2, x_3, ... → (x_0, x_2, ...) 和 (x_1, x_3, ...)
x1 = x[..., ::2] # (B, H, T, D_h/2) — 偶数维度
x2 = x[..., 1::2] # (B, H, T, D_h/2) — 奇数维度
# 旋转:R * [x1, x2] = [x1*cos - x2*sin, x1*sin + x2*cos]
rotated = torch.stack([
x1 * cos - x2 * sin, # 旋转后的偶数维度
x1 * sin + x2 * cos, # 旋转后的奇数维度
], dim=-1) # (B, H, T, D_h/2, 2)
# 交错合并回 (B, H, T, D_h)
return rotated.flatten(-2) # (B, H, T, D_h)
# === RoPE MHA 完整示例 ===
class RoPEMultiHeadAttention(nn.Module):
def __init__(self, d_model: int, n_heads: int):
super().__init__()
self.d_model = d_model
self.n_heads = n_heads
self.d_head = d_model // n_heads
self.q_proj = nn.Linear(d_model, d_model, bias=False)
self.k_proj = nn.Linear(d_model, d_model, bias=False)
self.v_proj = nn.Linear(d_model, d_model, bias=False)
self.o_proj = nn.Linear(d_model, d_model, bias=False)
self.rotary_emb = RotaryEmbedding(self.d_head)
def forward(self, x: torch.Tensor) -> torch.Tensor:
B, T, D = x.shape
q = self.q_proj(x).view(B, T, self.n_heads, self.d_head).transpose(1, 2)
k = self.k_proj(x).view(B, T, self.n_heads, self.d_head).transpose(1, 2)
v = self.v_proj(x).view(B, T, self.n_heads, self.d_head).transpose(1, 2)
# RoPE 只作用于 Q 和 K,不作用于 V
cos, sin = self.rotary_emb(q)
q = apply_rotary_emb(q, cos, sin)
k = apply_rotary_emb(k, cos, sin)
# Attention(仍需要 causal mask)
attn = torch.matmul(q, k.transpose(-2, -1)) / math.sqrt(self.d_head)
causal_mask = torch.tril(torch.ones(T, T, device=x.device, dtype=torch.bool))
attn = attn.masked_fill(~causal_mask.unsqueeze(0).unsqueeze(0), float('-inf'))
attn = torch.softmax(attn, dim=-1)
out = torch.matmul(attn, v)
out = out.transpose(1, 2).contiguous().view(B, T, D)
return self.o_proj(out)
# === 验证 ===
if __name__ == "__main__":
d_model = 64
n_heads = 4
B, T = 2, 8
rope_attn = RoPEMultiHeadAttention(d_model, n_heads)
x = torch.randn(B, T, d_model)
out = rope_attn(x)
print(f"Input: {x.shape}") # (2, 8, 64)
print(f"Output: {out.shape}") # (2, 8, 64)
RoPE vs 绝对位置编码对比:
| 维度 | 绝对位置编码 | RoPE |
|---|---|---|
| 编码方式 | 位置 embedding 加到 token embedding | 旋转矩阵乘到 Q/K |
| 位置信息类型 | 绝对位置(位置 5 → 固定 embedding) | 相对位置(Q·K 只依赖 n-m) |
| 长度外推 | 固定 max_seq_len → 超出需插值 | 理论上可外推(但远处衰减严重) |
| 外推方法 | 位置插值(PI)/ NTK-aware 插值 | YaRN / Dynamic NTK |
| 对 V 的影响 | 有(位置信息嵌入 token → V 也受影响) | 无(只旋转 Q/K → V 不受位置影响) |
| 与 attention 的交互 | 独立于 attention 计算 | 嵌入 attention 计算(QK 点积内) |
| KV cache 兼容性 | 需要在 cache 中存位置编号 | 需要传 offset(位置偏移) |
⚠️ 常见坑:RoPE 的 flatten(-2) 操作 → 如果输入 x 的最后一维不是偶数 → 报错 → 需要确保 d_head 是偶数。
⚠️ 另一个坑:RoPE 在 decode 阞段需要传 offset 参数 → 忘记传 → 位置编号从 0 开始 → causal mask 错误 → 模型输出乱码。
复杂度分析:
- 时间:与标准 MHA 相同,旋转操作仅增加 $O(T \cdot D_h)$ 的乘法(极小)
- 显存:cos/sin cache 约 $O(T_{max} \cdot D_h/2)$ → 通常 <1MB
面试官常见追问:
- RoPE 为什么能实现相对位置?(数学证明:$R_{\Theta,m}^\top R_{\Theta,n} = R_{\Theta,n-m}$ → QK 点积 = $q^\top R_{\Theta,n-m} k$ → 只依赖 $n-m$)
- RoPE 的长度外推怎么做?(NTK-aware scaling:增大 base → 低频分量衰减慢 → 远处位置仍有区分度;YaRN:混合高频缩放+低频插值 → 最优外推方案)
参考资料:
- RoFormer: Enhanced Transformer with Rotary Position Embedding
- YaRN: Efficient Context Window Extension of Large Language Models
Q54 RMSNorm vs LayerNorm 实现
难度: 基础
考察点: RMSNorm 与 LayerNorm 的计算差异;理解为什么现代 LLM 倾向用 RMSNorm
满分回答:
LayerNorm: 对每个样本的所有特征做归一化: \(\text{LayerNorm}(x) = \frac{x - \mu}{\sigma} \cdot \gamma + \beta\) 其中 $\mu = \frac{1}{D}\sum_i x_i$,$\sigma = \sqrt{\frac{1}{D}\sum_i (x_i - \mu)^2 + \epsilon}$
RMSNorm: 省去均值中心化,只做缩放: \(\text{RMSNorm}(x) = \frac{x}{\text{RMS}(x)} \cdot \gamma\) 其中 $\text{RMS}(x) = \sqrt{\frac{1}{D}\sum_i x_i^2 + \epsilon}$
import torch
import torch.nn as nn
class RMSNorm(nn.Module):
"""Root Mean Square Layer Normalization — LLaMA/Qwen 等现代 LLM 使用"""
def __init__(self, d_model: int, eps: float = 1e-6):
super().__init__()
self.eps = eps
self.weight = nn.Parameter(torch.ones(d_model)) # (D) — 只有 γ,没有 β
def forward(self, x: torch.Tensor) -> torch.Tensor:
"""
Args:
x: (B, T, D) or (..., D)
Returns:
(B, T, D) or (..., D) 归一化后
"""
# RMS 计算:sqrt(mean(x^2) + eps)
# 注意:在 fp16/bf16 下,先转 fp32 计算 RMS 再转回 → 数值稳定
rms = torch.sqrt(x.float().pow(2).mean(dim=-1, keepdim=True) + self.eps) # (B, T, 1) fp32
x_normed = x.float() / rms # (B, T, D) fp32
# 乘权重并转回原精度
return (x_normed * self.weight).type_as(x) # (B, T, D)
class LayerNorm(nn.Module):
"""标准 Layer Normalization — Transformer 原论文使用"""
def __init__(self, d_model: int, eps: float = 1e-6):
super().__init__()
self.eps = eps
self.weight = nn.Parameter(torch.ones(d_model)) # (D) — γ (gain)
self.bias = nn.Parameter(torch.zeros(d_model)) # (D) — β (shift)
def forward(self, x: torch.Tensor) -> torch.Tensor:
"""
Args:
x: (B, T, D) or (..., D)
Returns:
(B, T, D) or (..., D) 归一化后
"""
# 同样在 fp32 下计算以保证数值稳定性
mean = x.float().mean(dim=-1, keepdim=True) # (B, T, 1)
var = x.float().pow(2).mean(dim=-1, keepdim=True) - mean.pow(2) # (B, T, 1)
x_normed = (x.float() - mean) / torch.sqrt(var + self.eps) # (B, T, D)
return (x_normed * self.weight + self.bias).type_as(x)
# === 验证 ===
if __name__ == "__main__":
d_model = 64
B, T = 2, 10
rms_norm = RMSNorm(d_model)
ln_norm = LayerNorm(d_model)
x = torch.randn(B, T, d_model)
print(f"RMSNorm output: {rms_norm(x).shape}") # (2, 10, 64)
print(f"LayerNorm output: {ln_norm(x).shape}") # (2, 10, 64)
# 验证 RMSNorm 的 RMS 值接近 1
out_rms = rms_norm(x)
rms_val = out_rms.float().pow(2).mean(dim=-1) # 应接近 1
print(f"RMSNorm 辧出 RMS ≈ {rms_val.mean().item():.4f}") # ≈ 1.0
关键对比:
| 维度 | LayerNorm | RMSNorm |
|---|---|---|
| 计算 | $(x - \mu) / \sigma \cdot \gamma + \beta$ | $x / \text{RMS} \cdot \gamma$ |
| 可学习参数 | $\gamma$ + $\beta$(2×D) | $\gamma$(1×D)→ 无 bias |
| 均值中心化 | ✅ 有 | ❌ 无 |
| 计算量 | 2 次 mean + 1 次 var | 1 次 mean(x²) |
| 速度 | 略慢(多一步减均值) | 略快(省减均值和 bias) |
| 效果 | 理论上更灵活(可做偏移) | 实际效果几乎等同 |
| 代表模型 | GPT-2/3, BERT | LLaMA, Qwen, Mistral |
为什么现代 LLM 用 RMSNorm?
- 参数更少:省 bias → 每层少 D 个参数 → 大模型省不少
- 计算更快:省均值中心化 → 略微加速(在大模型中累积可观)
- 效果等同:实验显示 RMSNorm 和 LayerNorm 在 LLM 训练中效果无显著差异
- 数值稳定:RMSNorm 的 $\text{RMS}(x) = \sqrt{\text{mean}(x^2)}$ 比 LayerNorm 的 $\sigma$ 更稳定(因为不涉及减均值再平方 → 减少数值误差)
⚠️ 常见坑:RMSNorm 没有 bias → 以为会限制模型表达能力 → 实际归一化后的 $\gamma$ 本身就提供了缩放能力,而偏移在大多数场景下不必要(后面的 linear 层自带 bias)。
⚠️ 另一个坑:在 bf16 下直接计算 RMS → 精度不够 → 必须 .float() 转 fp32 计算 → 再 .type_as(x) 转回 bf16。这是所有 norm 层的通用做法。
面试官常见追问:
- 为什么 RMSNorm 省去均值中心化效果不变?(归一化的核心作用是稳定梯度分布 → 均值中心化对此贡献很小 → 纯缩放就够了)
- RMSNorm 的 eps 为什么设 1e-6?(防止 RMS ≈ 0 时除零 → 1e-6 在 fp32 下足够小不影响正常值,在 bf16 下也不会溢出)
参考资料:
Q55 SwiGLU / GeGLU FFN 实现
难度: 进阶
考察点: GLU 变体的 FFN 实现;理解为什么 SwiGLU 成为现代 LLM 的标准 FFN 选择
满分回答:
标准 FFN(Transformer 原论文): \(\text{FFN}(x) = W_2 \cdot \text{ReLU}(W_1 \cdot x)\)
GeGLU(Gated GLU with GeLU activation): \(\text{GeGLU}(x) = (W_1 \cdot x) \odot \text{GeLU}(W_{gate} \cdot x)\) \(\text{FFN}_{\text{GeGLU}}(x) = W_2 \cdot \text{GeGLU}(x)\)
SwiGLU(LLaMA 使用): \(\text{SwiGLU}(x) = (W_1 \cdot x) \odot \text{SiLU}(W_{gate} \cdot x)\) \(\text{FFN}_{\text{SwiGLU}}(x) = W_2 \cdot \text{SwiGLU}(x)\)
其中 $\text{SiLU}(x) = x \cdot \sigma(x)$(sigmoid linear unit,也叫 Swish)。
import torch
import torch.nn as nn
import torch.nn.functional as F
class SwiGLUFFN(nn.Module):
"""SwiGLU FFN — LLaMA/Qwen/Mistral 等现代 LLM 使用"""
def __init__(self, d_model: int, d_ff: int, dropout: float = 0.0):
super().__init__()
# 注意:SwiGLU 有 3 个投影矩阵(W1, W_gate, W2),而非标准 FFN 的 2 个
# 合并 W1 和 W_gate 为一次投影以提升效率
self.w1_w_gate = nn.Linear(d_model, 2 * d_ff, bias=False) # (D) -> (2 * d_ff)
self.w2 = nn.Linear(d_ff, d_model, bias=False) # (d_ff) -> (D)
self.drop = nn.Dropout(dropout)
def forward(self, x: torch.Tensor) -> torch.Tensor:
"""
Args:
x: (B, T, D)
Returns:
(B, T, D)
"""
# Step 1: 合并投影 → 拆分为 value 和 gate
proj = self.w1_w_gate(x) # (B, T, 2*d_ff)
value, gate = proj.chunk(2, dim=-1) # each (B, T, d_ff)
# Step 2: SwiGLU = value * SiLU(gate)
# SiLU(x) = x * sigmoid(x) ≈ x * (1 / (1 + exp(-x)))
value_gated = value * F.silu(gate) # (B, T, d_ff)
# Step 3: Output projection
out = self.w2(value_gated) # (B, T, D)
out = self.drop(out)
return out
class GeGLUFFN(nn.Module):
"""GeGLU FFN — PaLM 等模型使用"""
def __init__(self, d_model: int, d_ff: int, dropout: float = 0.0):
super().__init__()
self.w1_w_gate = nn.Linear(d_model, 2 * d_ff, bias=False)
self.w2 = nn.Linear(d_ff, d_model, bias=False)
self.drop = nn.Dropout(dropout)
def forward(self, x: torch.Tensor) -> torch.Tensor:
proj = self.w1_w_gate(x)
value, gate = proj.chunk(2, dim=-1)
value_gated = value * F.gelu(gate) # GeLU activation
out = self.w2(value_gated)
return self.drop(out)
class StandardFFN(nn.Module):
"""标准 ReLU FFN — Transformer 原论文"""
def __init__(self, d_model: int, d_ff: int, dropout: float = 0.0):
super().__init__()
self.w1 = nn.Linear(d_model, d_ff, bias=False) # (D) -> (d_ff)
self.w2 = nn.Linear(d_ff, d_model, bias=False) # (d_ff) -> (D)
self.drop = nn.Dropout(dropout)
def forward(self, x: torch.Tensor) -> torch.Tensor:
hidden = F.relu(self.w1(x)) # (B, T, d_ff)
out = self.w2(hidden) # (B, T, D)
return self.drop(out)
# === 验证 ===
if __name__ == "__main__":
d_model = 256
d_ff = 1024 # SwiGLU 的 d_ff 通常设为 2/3 * 4 * D ≈ 8D/3(见下文说明)
B, T = 2, 8
x = torch.randn(B, T, d_model)
# 对比三种 FFN
swiglu = SwiGLUFFN(d_model, d_ff)
geglu = GeGLUFFN(d_model, d_ff)
std_ffn = StandardFFN(d_model, d_ff)
print(f"SwiGLU: {swiglu(x).shape}") # (2, 8, 256)
print(f"GeGLU: {geglu(x).shape}") # (2, 8, 256)
print(f"Standard: {std_ffn(x).shape}") # (2, 8, 256)
# 参数量对比
def count_params(m):
return sum(p.numel() for p in m.parameters())
print(f"SwiGLU params: {count_params(swiglu)}") # D*(2*d_ff) + d_ff*D = 3*D*d_ff
print(f"GeGLU params: {count_params(geglu)}") # 3*D*d_ff (同 SwiGLU)
print(f"Standard params: {count_params(std_ffn)}") # D*d_ff + d_ff*D = 2*D*d_ff
关键对比:
| 维度 | ReLU FFN | GeGLU FFN | SwiGLU FFN |
|---|---|---|---|
| 公式 | $W_2 \cdot \text{ReLU}(W_1 x)$ | $W_2 \cdot (W_1 x \odot \text{GeLU}(W_g x))$ | $W_2 \cdot (W_1 x \odot \text{SiLU}(W_g x))$ |
| 投影矩阵数 | 2 | 3 | 3 |
| 参数量 | $2 \cdot D \cdot d_{ff}$ | $3 \cdot D \cdot d_{ff}$ | $3 \cdot D \cdot d_{ff}$ |
| 门控机制 | 无 | GeLU 门控 | SiLU 门控 |
| 激活函数 | ReLU | GeLU | SiLU/Swish |
| 效果 | 基线 | 比 ReLU 好 | 比 GeGLU 略好 |
| 代表模型 | GPT-2 | PaLM | LLaMA, Qwen, Mistral |
SwiGLU 的 d_ff 设定问题:
- 标准 FFN 的 $d_{ff} = 4D$(Transformer 原论文)
- SwiGLU/GeGLU 有 3 个投影矩阵 → 参数量 = $3Dd_{ff}$
- 为保持与标准 FFN 相同的参数量($2D \cdot 4D = 8D^2$):$d_{ff} = \frac{8D^2}{3D} = \frac{8D}{3}$
- → LLaMA 等模型用 $d_{ff} \approx \frac{2}{3} \cdot 4D$(通常取最近的 256 倍数)
⚠️ 常见坑:SwiGLU 的 d_ff 设为 $4D$ → 参数量变成 $3 \times 4D^2$ → 比标准 FFN 多 50% → 计算和显存也增加。需要调整为 $\frac{8D}{3}$ 才能与标准 FFN 参数量持平。
⚠️ 另一个坑:合并投影 w1_w_gate 辧出维度是 2*d_ff → chunk 拆分后每个 d_ff → 如果 2*d_ff 不是偶数或 chunk 拆分不正确 → shape 错误。
面试官常见追问:
- 为什么 SwiGLU 比 ReLU 好?(门控机制让 FFN 可以选择性地传递信息 → 比纯 ReLU 的”全通/全堵”更灵活 → 实验证实效果提升约 2-4%)
- SiLU 和 GeLU 的区别?(SiLU = $x \cdot \sigma(x)$,平滑且非单调;GeLU = $x \cdot \Phi(x)$(正态CDF),更接近 ReLU 但平滑。SiLU 更简单、计算更快)
参考资料:
- GLU Variants Improve Transformer (Noam Shazeer, 2020)
- LLaMA: Open and Efficient Foundation Language Models
Q56 Grouped-Query Attention (GQA) / Multi-Query Attention (MQA) 实现
难度: 专家
考察点: GQA/MQA 的 KV head 数量与 Q head 数量的差异;理解 KV cache 压缩的实现方式与 tradeoff
满分回答:
MQA:所有 Q head 共享1 组 K/V → KV cache 大小降至标准 MHA 的 $1/H$。 GQA:Q head 分成 $G$ 组,每组共享 1 组 K/V → KV cache 大小降至标准 MHA 的 $G/H$。
| 方案 | Q heads | K/V heads | KV cache 倍率 | 代表模型 |
|---|---|---|---|---|
| MHA | H | H | 1x | GPT-2/3, BERT |
| MQA | H | 1 | 1/H | PaLM, StarCoder |
| GQA | H | G (G < H) | G/H | LLaMA-2/3, Mistral |
LLaMA-2 70B:H=64, G=8 → KV cache 降至 8/64 = 12.5%
import torch
import torch.nn as nn
import torch.nn.functional as F
import math
class GroupedQueryAttention(nn.Module):
"""GQA / MQA 统一实现 — 支持 n_kv_heads ∈ {1, ..., n_heads}"""
def __init__(self, d_model: int, n_heads: int, n_kv_heads: int = None, dropout: float = 0.0):
super().__init__()
assert d_model % n_heads == 0
self.d_model = d_model
self.n_heads = n_heads # Q head 数 (H)
self.n_kv_heads = n_kv_heads if n_kv_heads is not None else n_heads # K/V head 数 (G)
self.d_head = d_model // n_heads # 每个 Q head 的维度 (D_h)
self.d_kv_head = d_model // n_kv_heads # 每个 KV head 的维度 — 与 Q head 相同
# 实际实现中,KV head 维度通常 = d_model // n_kv_heads
# 但为了 GQA 的 expand 操作,让 KV head 维度 = Q head 维度
# 即 d_kv_head = d_head(而非 d_model // n_kv_heads)
# 这样 expand 只需 repeat KV head,不需要额外的投影
# 投影矩阵
self.q_proj = nn.Linear(d_model, n_heads * self.d_head, bias=False) # (D) -> (H * D_h)
self.k_proj = nn.Linear(d_model, n_kv_heads * self.d_head, bias=False) # (D) -> (G * D_h)
self.v_proj = nn.Linear(d_model, n_kv_heads * self.d_head, bias=False) # (D) -> (G * D_h)
self.o_proj = nn.Linear(n_heads * self.d_head, d_model, bias=False) # (H * D_h) -> (D)
self.attn_drop = nn.Dropout(dropout)
# 每个 Q head 组有多少个 Q head
self.n_rep = n_heads // n_kv_heads # H // G = 每个 KV head 覆盖的 Q head 数
def forward(self, x: torch.Tensor, mask: torch.Tensor = None) -> torch.Tensor:
"""
Args:
x: (B, T, D)
mask: (T, T) or (B, 1, T, T) causal mask, 1=allow, 0=block
Returns:
out: (B, T, D)
"""
B, T, D = x.shape
# Step 1: 投影
q = self.q_proj(x) # (B, T, H * D_h)
k = self.k_proj(x) # (B, T, G * D_h)
v = self.v_proj(x) # (B, T, G * D_h)
# Step 2: reshape
q = q.view(B, T, self.n_heads, self.d_head).transpose(1, 2) # (B, H, T, D_h)
k = k.view(B, T, self.n_kv_heads, self.d_head).transpose(1, 2) # (B, G, T, D_h)
v = v.view(B, T, self.n_kv_heads, self.d_head).transpose(1, 2) # (B, G, T, D_h)
# Step 3: Expand KV heads to match Q heads
# 每个 KV head 重复 n_rep 次 → 与 Q head 数量对齐
# 例如 GQA: H=8, G=2, n_rep=4 → K/V 从 (B,2,T,D_h) expand 到 (B,8,T,D_h)
if self.n_rep > 1:
# repeat_interleave: 沿 head 维度重复
k = k.unsqueeze(2).expand(B, self.n_kv_heads, self.n_rep, T, self.d_head) # (B, G, n_rep, T, D_h)
k = k.reshape(B, self.n_heads, T, self.d_head) # (B, H, T, D_h)
v = v.unsqueeze(2).expand(B, self.n_kv_heads, self.n_rep, T, self.d_head)
v = v.reshape(B, self.n_heads, T, self.d_head)
# Step 4: Scaled Dot-Product Attention(与标准 MHA 相同)
scale = math.sqrt(self.d_head)
attn_scores = torch.matmul(q, k.transpose(-2, -1)) / scale # (B, H, T, T)
# Causal mask
if mask is not None:
attn_scores = attn_scores.masked_fill(mask == 0, float('-inf'))
else:
causal_mask = torch.tril(torch.ones(T, T, device=x.device, dtype=torch.bool))
attn_scores = attn_scores.masked_fill(~causal_mask.unsqueeze(0).unsqueeze(0), float('-inf'))
attn_weights = F.softmax(attn_scores, dim=-1) # (B, H, T, T)
attn_weights = self.attn_drop(attn_weights)
attn_out = torch.matmul(attn_weights, v) # (B, H, T, D_h)
# Step 5: Concatenate + Output projection
attn_out = attn_out.transpose(1, 2).contiguous().view(B, T, D) # (B, T, D)
out = self.o_proj(attn_out) # (B, T, D)
return out
# === 验证三种 Attention ===
if __name__ == "__main__":
d_model = 512
B, T = 2, 16
# MHA: n_kv_heads = n_heads = 8
mha = GroupedQueryAttention(d_model, n_heads=8, n_kv_heads=8)
x = torch.randn(B, T, d_model)
print(f"MHA output: {mha(x).shape}") # (2, 16, 512)
# GQA: n_heads=8, n_kv_heads=2 → n_rep=4
gqa = GroupedQueryAttention(d_model, n_heads=8, n_kv_heads=2)
print(f"GQA output: {gqa(x).shape}") # (2, 16, 512)
# MQA: n_heads=8, n_kv_heads=1 → n_rep=8
mqa = GroupedQueryAttention(d_model, n_heads=8, n_kv_heads=1)
print(f"MQA output: {mqa(x).shape}") # (2, 16, 512)
# 参数量对比
def count_params(m):
return sum(p.numel() for p in m.parameters())
print(f"MHA params: {count_params(mha)}") # 4*D*D = 4*512*512 = 1,048,576
print(f"GQA params: {count_params(gqa)}") # D*(8+2+2)*D_h + D*D = 小于 MHA
print(f"MQA params: {count_params(mqa)}") # D*(8+1+1)*D_h + D*D = 最少
关键点解析:
- Shape 流转:
- Q:
(B,T,D)→(B,T,H,D_h)→(B,H,T,D_h) - K:
(B,T,D)→(B,T,G,D_h)→(B,G,T,D_h)→ expand(B,H,T,D_h) - V: 同 K
- Attention:
(B,H,T,T)×(B,H,T,D_h)→(B,T,D)
- Q:
Expand 操作:GQA 的关键步骤——将 $G$ 个 KV head 重复 $n_rep$ 次与 $H$ 个 Q head 对齐。
unsqueeze(2)+expand+reshape是最常见的实现方式。- KV cache 压缩:推理时只存 $G$ 组 KV(而非 $H$ 组)→ cache 大小 $= \frac{G}{H} \times \text{MHA cache}$。
⚠️ 常见坑:GQA expand 后 K/V 的 head 维度是 d_head(而非 d_model // n_kv_heads)→ 如果误用后者 → expand 后维度不对齐 → matmul 报错。
⚠️ 另一个坑:MQA($G=1$)在某些任务上效果明显不如 MHA(约 1-2% drop),GQA($G=4-8$)在大多数任务上效果接近 MHA → 推荐用 GQA 而不是极端 MQA。
复杂度分析:
- 训练时间:expand 操作增加极小开销(只是 repeat,不增加 matmul)→ 基本与 MHA 相同
- 推理 KV cache:$O(2 \cdot G \cdot T \cdot D_h)$ per layer(vs MHA 的 $O(2 \cdot H \cdot T \cdot D_h)$)
- 推理 decode 时间:attention 从
(1, H, 1, T_kv)×(1, H, T_kv, D_h)→ 与 MHA 相同(因为 expand 了);但 KV cache 读取量减少 → 内存带宽瓶颈缓解
面试官常见追问:
- GQA 为什么比 MQA 更受欢迎?(MQA 信息压缩过度(1组KV for 所有head)→ 表征能力受限;GQA 保留多组 KV(如 8组 for 64 head)→ 更好的表征能力 + 仍然大幅压缩 cache)
- GQA 的 n_kv_heads 如何选择?(经验法则:n_kv_heads = n_heads / 8 或 n_heads / 4 → 平衡效果和效率。LLaMA-2 70B 用 8 KV heads for 64 Q heads)
参考资料:
- GQA: Training Generalized Multi-Query Transformer Models from Multi-Head Checkpoints
- LLaMA 2: Open Foundation and Fine-Tuned Chat Models
Q57 LoRA 模块从零实现(含 merge 权重)
难度: 进阶
考察点: LoRA 的完整实现,包括低秩分解、初始化策略、权重 merge;理解 LoRA 的推理优化
满分回答:
LoRA 的核心:将权重更新 $\Delta W$ 分解为低秩矩阵 $A \cdot B$: \(h = W \cdot x + \Delta W \cdot x = W \cdot x + B \cdot A \cdot x\) 其中 $A \in \mathbb{R}^{r \times d_{in}}$,$B \in \mathbb{R}^{d_{out} \times r}$,$r \ll d_{in}, d_{out}$。
import torch
import torch.nn as nn
import math
class LoRALayer(nn.Module):
"""单个 LoRA 适配层,可附加到任意 Linear 层"""
def __init__(
self,
d_in: int,
d_out: int,
rank: int = 8,
alpha: float = 16.0,
dropout: float = 0.0,
):
super().__init__()
self.d_in = d_in
self.d_out = d_out
self.rank = rank # (r) — LoRA 增量矩阵的秩
self.alpha = alpha # 缩放因子
# LoRA 缩放系数:alpha / rank
# 作用:当 rank 变化时,通过 alpha/rank 保持增量矩阵的数值量级不变
self.scaling = alpha / rank
# Dropout(在 LoRA 辧入前)
self.lora_drop = nn.Dropout(dropout) if dropout > 0 else nn.Identity()
# LoRA 低秩矩阵
# A: (r, d_in) — 初始化为 Kaiming/正态分布(保证训练初期有非零梯度)
# B: (d_out, r) — 初始化为零(保证 LoRA 初始增量 = 0 → 不改变原模型行为)
self.lora_A = nn.Parameter(torch.empty(rank, d_in))
self.lora_B = nn.Parameter(torch.zeros(d_out, rank))
# 初始化 A 为正态分布
nn.init.kaiming_uniform_(self.lora_A, a=math.sqrt(5))
def forward(self, x: torch.Tensor) -> torch.Tensor:
"""
计算 LoRA 增量:B @ A @ x * scaling
Args:
x: (B, T, d_in) or (..., d_in)
Returns:
LoRA 增量输出: (B, T, d_out) or (..., d_out)
"""
x_drop = self.lora_drop(x) # (B, T, d_in)
# 注意顺序:先 A (d_in → r) 再 B (r → d_out) → 计算量 O(d_in * r + r * d_out)
# 而不是先 B 再 A → 顺序很重要!
lora_out = x_drop @ self.lora_A.T @ self.lora_B.T # (B, T, r) @ (r, d_out) → (B, T, d_out)
# 修正:应该是 (B, T, d_in) @ (d_in, r) @ (r, d_out)
# = x @ A^T @ B^T → shape (B, T, d_in) @ (d_in, r) = (B, T, r) → @ (r, d_out) = (B, T, d_out)
return lora_out * self.scaling
class LinearWithLoRA(nn.Module):
"""Linear 层 + LoRA 适配层"""
def __init__(
self,
original_linear: nn.Linear,
rank: int = 8,
alpha: float = 16.0,
dropout: float = 0.0,
):
super().__init__()
self.linear = original_linear # 原始 Linear 层(冻结)
self.lora = LoRALayer(
d_in=original_linear.in_features,
d_out=original_linear.out_features,
rank=rank,
alpha=alpha,
dropout=dropout,
)
def forward(self, x: torch.Tensor) -> torch.Tensor:
"""
h = Wx + (B @ A @ x) * scaling
Args:
x: (B, T, d_in)
Returns:
(B, T, d_out)
"""
# 原始 Linear 辧出 + LoRA 增量
return self.linear(x) + self.lora(x)
def merge_weights(self):
"""将 LoRA 权重 merge 到原始 Linear 权重 → 推理时无需额外计算"""
# W_new = W + B @ A * scaling
self.linear.weight.data += (self.lora.lora_B @ self.lora.lora_A * self.lora.scaling) # (d_out, d_in)
# merge 后 LoRA 层不再需要 → 可以删除以节省参数
self.lora = None # 删除 LoRA 层
def unmerge_weights(self):
"""从 merged 权重恢复 LoRA(需要事先保存 LoRA 参数)"""
# ⚠️ 实际应用中需要保存原始 weight 和 LoRA 参数
# 此处仅展示逻辑:
self.linear.weight.data -= (self.lora.lora_B @ self.lora.lora_A * self.lora.scaling)
self.lora = LoRALayer(...) # 重新创建(实际中应从保存的参数恢复)
# === 在 LLaMA-style Block 中应用 LoRA ===
def apply_lora_to_model(model: nn.Module, rank: int = 8, alpha: float = 16.0,
target_modules: list = ["q_proj", "k_proj", "v_proj", "o_proj"]) -> nn.Module:
"""将 LoRA 附加到模型中指定名称的 Linear 层"""
for name, module in model.named_modules():
if isinstance(module, nn.Linear) and any(t in name for t in target_modules):
# 替换 Linear 为 LinearWithLoRA
parent_name = name.rsplit(".", 1)[0] if "." in name else ""
parent = model.get_submodule(parent_name) if parent_name else model
child_name = name.rsplit(".", 1)[1] if "." in name else name
setattr(parent, child_name, LinearWithLoRA(module, rank, alpha))
# 冻结所有非 LoRA 参数
for name, param in model.named_parameters():
if "lora" not in name:
param.requires_grad = False
return model
# === 验证 ===
if __name__ == "__main__":
d_in = 512
d_out = 512
rank = 8
B, T = 2, 16
# 创建 LoRA Linear
original_linear = nn.Linear(d_in, d_out)
lora_linear = LinearWithLoRA(original_linear, rank=rank, alpha=16.0)
x = torch.randn(B, T, d_in)
# 训练模式:LoRA 增量叠加
out_train = lora_linear(x) # (2, 16, 512)
print(f"Train mode output: {out_train.shape}")
# Merge 权重 → 推理模式
lora_linear.merge_weights()
out_inference = lora_linear(x) # (2, 16, 512) — 结果应与 merge 前相同
print(f"Inference mode output: {out_inference.shape}")
# 验证 merge 前后输出一致
lora_linear2 = LinearWithLoRA(nn.Linear(d_in, d_out), rank=rank, alpha=16.0)
# 手动拷贝权重以验证
lora_linear2.linear.weight.data.copy_(original_linear.weight.data)
lora_linear2.lora.lora_A.data.copy_(lora_linear.lora.lora_A.data) if lora_linear.lora is not None else None
# LoRA 参数量对比
original_params = d_in * d_out
lora_params = rank * d_in + rank * d_out # A + B
print(f"Original params: {original_params}")
print(f"LoRA params: {lora_params} ({lora_params / original_params * 100:.2f}%)")
# rank=8: LoRA params = 8*512 + 8*512 = 8192 → 只占原始 0.78%
关键点解析:
初始化策略:A 用 Kaiming 初始化(非零 → 有梯度),B 用零初始化 → LoRA 初始增量 $\Delta W = B \cdot A = 0$ → 模型行为不变 → 训练可以从 base 模型状态开始。
Scaling = alpha / rank:当 rank 从 8 改为 16 → scaling 从 2 变为 1 → 增量矩阵的数值量级不变 → rank 调整不影响训练动态。
Merge 权重:推理时将 LoRA 增量 $\Delta W = B \cdot A \cdot \text{scaling}$ 加到原始权重 $W$ → 只做一次 matmul → 推理零额外开销。
x @ A^T @ B^T 的计算顺序:先降维到 $r$ 再升维 → 中间维度小 → 计算量 $O(d_{in} \cdot r + r \cdot d_{out})$ → 比 $\Delta W \cdot x$ 的 $O(d_{in} \cdot d_{out})$ 大幅减少。
⚠️ 常见坑:LoRA 只加到 attention 的 Q/V 投影 → 不加到 K/O → 效果可能不如全投影 LoRA。LLaVA 实验证实 LoRA 加到所有投影(Q/K/V/O)效果最好。
⚠️ 另一个坑:merge 后如果想继续训练 → 需要先 unmerge(恢复 LoRA 层)→ 否则全参数训练会改变 merged 权重 → 下次 unmerge 时 LoRA 增量与实际变化不匹配。
复杂度分析:
- 训练参数量:$2 \cdot r \cdot d$ per layer(假设 $d_{in} = d_{out} = d$)→ rank=8, d=4096 → 65,536 参数 per layer
- 训练时间:LoRA 增量计算 $O(B \cdot T \cdot (d \cdot r + r \cdot d))$ → 极小($r \ll d$)
- 推理时间(merge 前):额外 $O(B \cdot T \cdot d \cdot r)$ → 约 2% 开销
- 推理时间(merge 后):零额外开销
面试官常见追问:
- LoRA 的 rank 如何选择?(经验:简单任务 r=4-8 足够;复杂任务 r=16-64;r 过大接近全参数微调 → LoRA 优势丧失)
- LoRA 是否需要加 dropout?(小数据量(<10K)加 dropout=0.1 防过拟合;大数据量(>100K)dropout=0 或不加)
- QLoRA 是什么?(LoRA + 基础模型 4bit 量化 → 只在 LoRA 参数上做 bf16 梯度 → 显存极低(7B 模型 ~6GB))
参考资料:
Q58 DPO loss 函数实现(给 chosen/rejected logprob 写出 loss)
难度: 专家
考察点: DPO loss 的精确实现,包括 logprob 计算、reference model 的作用、数值稳定性
满分回答:
DPO loss 公式: \(\mathcal{L}_{\text{DPO}} = -\log \sigma\left(\beta \cdot \left(\log \frac{\pi_\theta(y_w|x)}{\pi_{\text{ref}}(y_w|x)} - \log \frac{\pi_\theta(y_l|x)}{\pi_{\text{ref}}(y_l|x)}\right)\right)\)
简化写法:令 $\hat{r}\theta(y, x) = \beta \cdot \log \frac{\pi\theta(y|x)}{\pi_{\text{ref}}(y|x)}$ \(\mathcal{L}_{\text{DPO}} = -\log \sigma(\hat{r}_\theta(y_w, x) - \hat{r}_\theta(y_l, x))\)
其中 $y_w$ = chosen(好回答),$y_l$ = rejected(差回答),$\pi_\theta$ = 训练中的模型,$\pi_{\text{ref}}$ = reference(初始模型),$\beta$ = 温度超参。
import torch
import torch.nn as nn
import torch.nn.functional as F
def dpo_loss(
policy_chosen_logps: torch.Tensor, # (B,) — π_θ(y_w|x) 的 log probability
policy_rejected_logps: torch.Tensor, # (B,) — π_θ(y_l|x) 的 log probability
ref_chosen_logps: torch.Tensor, # (B,) — π_ref(y_w|x) 的 log probability
ref_rejected_logps: torch.Tensor, # (B,) — π_ref(y_l|x) 的 log probability
beta: float = 0.1,
label_smoothing: float = 0.0,
) -> tuple:
"""
计算 DPO loss。
Args:
policy_chosen_logps: 训练模型对 chosen 回答的 log prob
policy_rejected_logps: 训练模型对 rejected 回答的 log prob
ref_chosen_logps: reference 模型对 chosen 回答的 log prob
ref_rejected_logps: reference 模型对 rejected 回答的 log prob
beta: DPO 温度参数,控制偏离 reference 的程度
label_smoothing: 标签平滑系数(0=标准 DPO)
Returns:
loss: (scalar) 平均 DPO loss
chosen_rewards: (B,) chosen 的隐式 reward
rejected_rewards: (B,) rejected 的隐式 reward
"""
# Step 1: 计算 log ratio(π_θ / π_ref)
# log π_θ(y|x) - log π_ref(y|x) = log ratio
chosen_logratios = policy_chosen_logps - ref_chosen_logps # (B,)
rejected_logratios = policy_rejected_logps - ref_rejected_logps # (B,)
# Step 2: 计算隐式 reward(β * log ratio)
chosen_rewards = beta * chosen_logratios # (B,)
rejected_rewards = beta * rejected_logratios # (B,)
# Step 3: 计算 DPO loss = -log σ(reward_chosen - reward_rejected)
# 数值稳定性关键:用 logsigmoid 而不是 log(σ(x))
# log(σ(x)) 在 x 很大时 σ(x)≈1 → log(1)=0,但 x 很小时 σ(x)≈0 → log(0)=-inf → 精度丢失
# logsigmoid(x) = -log(1 + exp(-x)) = -softplus(-x),内部用数值稳定实现
logits = chosen_rewards - rejected_rewards # (B,) — chosen reward 优势
if label_smoothing > 0:
# Label smoothing DPO: DPO with LS = -LS * log σ(-logits) - (1-LS) * log σ(logits)
# 等价于:loss = -log σ(logits) * (1-LS) - log σ(-logits) * LS
losses = -F.logsigmoid(logits) * (1 - label_smoothing) - F.logsigmoid(-logits) * label_smoothing
else:
# 标准 DPO loss
losses = -F.logsigmoid(logits) # (B,)
loss = losses.mean() # scalar
return loss, chosen_rewards, rejected_rewards
def compute_logprobs(
logits: torch.Tensor, # (B, T, V) — 模型输出的 logits
labels: torch.Tensor, # (B, T) — 目标 token ids
ignore_index: int = -100, # 忽略的 label 位置(如 padding)
) -> torch.Tensor:
"""
计算给定序列的 log probability(DPO 所需)。
Args:
logits: (B, T, V) 模型 logits
labels: (B, T) token ids
ignore_index: 忽略的位置标记
Returns:
logps: (B,) — 每个序列的 log probability(sum over valid tokens)
"""
# Step 1: 对每个位置取目标 token 的 log prob
# log_softmax → 取 labels 对应位置的值
log_probs = F.log_softmax(logits, dim=-1) # (B, T, V)
# Step 2: gather 目标 token 的 log prob
# labels: (B, T) → 需要扩展维度以匹配 log_probs
target_log_probs = log_probs.gather(dim=-1, index=labels.unsqueeze(-1)) # (B, T, 1)
target_log_probs = target_log_probs.squeeze(-1) # (B, T)
# Step 3: 忽略 padding/特殊位置
mask = labels != ignore_index # (B, T)
target_log_probs = target_log_probs * mask # 0 out padding positions
# Step 4: 对每个序列求和 → 得到序列的 log probability
logps = target_log_probs.sum(dim=-1) # (B,)
return logps
# === 验证 ===
if __name__ == "__main__":
B = 4
# 模拟 log probabilities
# chosen 的 log prob 应高于 rejected → loss 应为负(模型倾向 chosen)
policy_chosen_logps = torch.tensor([-1.0, -2.0, -1.5, -0.5]) # (B,)
policy_rejected_logps = torch.tensor([-3.0, -4.0, -3.5, -2.5]) # (B,)
ref_chosen_logps = torch.tensor([-1.2, -2.2, -1.7, -0.7]) # (B,)
ref_rejected_logps = torch.tensor([-3.2, -4.2, -3.7, -2.7]) # (B,)
loss, chosen_rewards, rejected_rewards = dpo_loss(
policy_chosen_logps, policy_rejected_logps,
ref_chosen_logps, ref_rejected_logps,
beta=0.1
)
print(f"DPO loss: {loss.item():.4f}")
print(f"Chosen rewards: {chosen_rewards}") # β * (policy - ref) for chosen
print(f"Rejected rewards: {rejected_rewards}") # β * (policy - ref) for rejected
# 验证:当 chosen logratio > rejected logratio → loss < 0(正确方向)
# 当 policy 已经偏好 chosen → loss 接近 0(模型已学好)
# 用 compute_logprobs 验证完整流程
V = 100
T = 10
logits = torch.randn(B, T, V)
labels = torch.randint(0, V, (B, T))
labels[:, -3:] = -100 # padding
logps = compute_logprobs(logits, labels)
print(f"Sequence log probs: {logps.shape}") # (4,)
关键点解析:
log ratio = log π_θ - log π_ref:DPO 的核心不是绝对概率而是相对概率的变化。reference model 的作用是”锚定”——防止模型过度偏离原始分布。
数值稳定性:
F.logsigmoid(logits)而非torch.log(torch.sigmoid(logits))。原因:sigmoid(x)在 $x$ 很小时 $\approx 0$ →log(0) = -inf;logsigmoid内部用-softplus(-x)计算 → 对所有 $x$ 值都精确。logprob 计算:用
log_softmax + gather而非log(probs[target_id])→ 更高效(一次 softmax + gather vs 两次)且数值更稳定。ignore_index:DPO 的 label 通常包含 prompt + response → prompt 部分不计算 loss → 用 ignore_index 标记。
⚠️ 常见坑:忘记用 reference model → 直接用 policy_chosen_logps 和 policy_rejected_logps 做 sigmoid → 这是 Bradley-Terry model 而不是 DPO → 模型会过度偏离 base。
⚠️ 另一个坑:logprob 是对整个序列求和 → 长序列的 logprob 绝对值很大 → β 需要相应调整。常见设置:β=0.1 for 序列长度 ~200。
面试官常见追问:
- DPO 和 RLHF 效果对比?(DPO 效果接近 PPO-RLHF,但训练更简单(不需要 reward model + PPO 循环)→ 适合资源有限的场景)
- DPO 的 reference model 如何处理?(实践中用初始模型(SFT 后但未做 DPO 的模型)→ 冻结 → 不更新。或用 SimPO/IPo 去掉 reference model)
参考资料:
- Direct Preference Optimization: Your Language Model is Secretly a Reward Model
- SimPO: Simple Preference Optimization without Reference Models
Q59 PPO 损失(含 clip、KL penalty、value loss、entropy bonus)实现
难度: 专家
考察点: RLHF-PPO 的完整 loss 组成;理解每个 loss component 的作用与数值稳定性
满分回答:
PPO-RLHF 的总 loss 由四个部分组成: \(\mathcal{L}_{\text{total}} = \mathcal{L}_{\text{clip}} - c_1 \cdot \mathcal{L}_{\text{value}} + c_2 \cdot \mathcal{H} + c_3 \cdot \text{KL}(\pi_\theta, \pi_{\text{ref}})\)
其中:
- $\mathcal{L}_{\text{clip}}$:policy clip loss(核心)
- $\mathcal{L}_{\text{value}}$:value function loss
- $\mathcal{H}$:entropy bonus(鼓励探索)
- KL penalty:约束策略不偏离 reference 太远
import torch
import torch.nn as nn
import torch.nn.functional as F
def ppo_loss(
logprobs: torch.Tensor, # (B,) — 当前策略 π_θ(a|s) 的 log prob
old_logprobs: torch.Tensor, # (B,) — 旧策略 π_old(a|s) 的 log prob(用于计算 ratio)
advantages: torch.Tensor, # (B,) — advantage 值 = reward - baseline(value)
values: torch.Tensor, # (B,) — value network 当前预测
old_values: torch.Tensor, # (B,) — 旧 value 预测(用于 value clip)
returns: torch.Tensor, # (B,) — TD target / GAE returns
ref_logprobs: torch.Tensor, # (B,) — reference 模型 π_ref(a|s) 的 log prob
clip_eps: float = 0.2, # PPO clip 范围 ε
value_clip_eps: float = 0.2, # Value clip 范围
kl_coeff: float = 0.05, # KL penalty 权重 c_3
value_coeff: float = 0.5, # Value loss 权重 c_1
entropy_coeff: float = 0.01, # Entropy bonus 权重 c_2
) -> dict:
"""
计算 PPO-RLHF 的完整 loss。
Args: 上述各参数
Returns: dict 包含各 loss component 和总 loss
"""
# =============================================
# 1. Policy Clip Loss(核心)
# =============================================
# ratio = π_θ(a|s) / π_old(a|s)
# log ratio = logprob - old_logprob(数值更稳定)
ratio = torch.exp(logprobs - old_logprobs) # (B,) — 策略变化比率
# PPO clip: L_clip = -min(ratio * A, clip(ratio, 1-ε, 1+ε) * A)
# 当 A > 0(正 advantage)→ 鼓励增大 ratio,但不超过 1+ε
# 当 A < 0(负 advantage)→ 鼓励减小 ratio,但不低于 1-ε
clipped_ratio = torch.clamp(ratio, 1.0 - clip_eps, 1.0 + clip_eps) # (B,)
# 两个 candidate loss,取 min → 保守更新
surr1 = ratio * advantages # (B,) — 无 clip 版
surr2 = clipped_ratio * advantages # (B,) — clip 版
# 优势为正时 min(ratio*A, clipped*A):ratio 太大被 clip → 防止过大更新
# 优势为负时 min(ratio*A, clipped*A):ratio 太小被 clip → 防止过大回退
policy_loss = -torch.min(surr1, surr2).mean() # scalar
# =============================================
# 2. Value Loss(value function 回归)
# =============================================
# L_value = (V_θ(s) - returns)^2
# 也可用 clipped value:防止单步 value 变化过大
values_clipped = old_values + torch.clamp(values - old_values, -value_clip_eps, value_clip_eps)
value_loss1 = (values - returns).pow(2) # (B,) — 无 clip
value_loss2 = (values_clipped - returns).pow(2) # (B,) — clip 版
value_loss = torch.max(value_loss1, value_loss2).mean() # scalar
# =============================================
# 3. Entropy Bonus(鼓励探索)
# =============================================
# H(π_θ) = -Σ π_θ(a) * log π_θ(a)
# 在 LLM 中,entropy 从 log_softmax 直接计算
# 注意:这里 logprobs 是对选定 action 的 log prob
# 真正的 entropy 需要对所有 token 的分布计算 → 实际中可用简化版
# 简化版:entropy ≈ -logprobs.mean()(近似,非精确)
# 精确版需要传完整 logits → 见下方 entropy_from_logits
entropy = -logprobs.mean() # (B,) 简化近似版 → scalar after mean
# 更精确版:
# entropy = entropy_from_logits(all_logits) # 需要完整 logits
# =============================================
# 4. KL Penalty(约束偏离 reference)
# =============================================
# KL(π_θ || π_ref) = Σ π_θ(a) * [log π_θ(a) - log π_ref(a)]
# = Σ π_θ(a) * (logprobs - ref_logprobs)
# 当 logprobs 和 ref_logprobs 是对选定 token 的 log prob 时:
# KL ≈ (logprobs - ref_logprobs).mean() → 这是近似(非严格 KL)
# 严格 KL 需要完整 token 分布 → 实际 RLHF 中也用这个近似
kl_penalty = (logprobs - ref_logprobs).mean() # scalar
# =============================================
# 5. 总 Loss
# =============================================
total_loss = (
policy_loss
- value_coeff * value_loss # 减号:最小化 value loss = 最大化 -value_loss
+ entropy_coeff * entropy # 加号:最大化 entropy
+ kl_coeff * kl_penalty # 加号:KL penalty 防止偏离(实际是惩罚)
)
return {
"total_loss": total_loss,
"policy_loss": policy_loss,
"value_loss": value_loss,
"entropy": entropy,
"kl_penalty": kl_penalty,
"ratio": ratio.mean(),
"clipped_frac": ((ratio - clipped_ratio).abs() > 0).float().mean(), # clip 比例
}
def entropy_from_logits(logits: torch.Tensor) -> torch.Tensor:
"""
从完整 logits 计算精确 entropy。
Args:
logits: (B, T, V) 模型 logits
Returns:
entropy: (B, T) 每个位置的 entropy
"""
# H = -Σ p(a) * log p(a) = -Σ softmax(logits) * log_softmax(logits)
probs = F.softmax(logits, dim=-1) # (B, T, V)
log_probs = F.log_softmax(logits, dim=-1) # (B, T, V)
entropy = -(probs * log_probs).sum(dim=-1) # (B, T)
return entropy
# === 验证 ===
if __name__ == "__main__":
B = 8
# 模拟各组件
logprobs = torch.randn(B)
old_logprobs = torch.randn(B)
advantages = torch.randn(B)
values = torch.randn(B)
old_values = torch.randn(B)
returns = torch.randn(B) + 0.5 # returns 略高于 values
ref_logprobs = torch.randn(B)
result = ppo_loss(
logprobs, old_logprobs, advantages,
values, old_values, returns, ref_logprobs,
clip_eps=0.2, kl_coeff=0.05, value_coeff=0.5, entropy_coeff=0.01
)
for key, val in result.items():
if isinstance(val, torch.Tensor) and val.dim() == 0:
print(f"{key}: {val.item():.4f}")
else:
print(f"{key}: {val}")
# 验证:当 ratio ≈ 1(策略没变)→ policy_loss ≈ -advantages.mean()
# 当 advantages > 0 → policy_loss < 0 → 鼓励增大该动作概率
# 当 advantages < 0 → policy_loss > 0 → 鼓励减小该动作概率
关键点解析:
Ratio = exp(logprob - old_logprob):用 log space 计算更稳定(避免数值溢出/下溢)→ 再 exp 回 ratio。
Clip 机制:
torch.clamp(ratio, 1-ε, 1+ε)→ 限制单步策略变化幅度 → 防止”策略崩塌”(一步更新太大导致后续更新方向错误)。- KL penalty 的两种计算方式:
- 精确 KL:需要完整 token 分布的 softmax → 计算量大
- 近似 KL:
(logprobs - ref_logprobs).mean()→ 只在选定 token 上计算 → 实际 RLHF 中广泛使用 - LLaMA-2 的 RLHF 报告指出:用KL coefficient 自适应调整比固定 KL penalty 更稳定——当 KL > target → 增大 kl_coeff;当 KL < target → 减小 kl_coeff
- Value clip:与 policy clip 类似,防止 value network 单步变化过大 → 训练更稳定。
⚠️ 常见坑:PPO 中 advantage 需要做归一化(mean=0, std=1)→ 不做归一化 → reward scale 影响更新幅度 → 训练不稳定。
⚠️ 另一个坑:KL penalty 用 (logprobs - ref_logprobs) 而非严格 KL → 近似在策略接近 reference 时偏差小 → 策略偏离较大时偏差大 → 需要 KL coefficient 自适应弥补。
面试官常见追问:
- PPO 的 clip fraction 正常值是多少?(5-15% → 太高意味着策略变化过快 → 可能不稳定;太低意味着策略几乎没更新 → 学习慢)
- PPO-RLHF 训练需要几个模型?(4个:policy model、reference model(冻结)、reward model(冻结)、value model;可共用 policy 和 value 的部分参数)
- GRPO 和 PPO 的核心区别?(GRPO 没有 value model → 用组内 reward 归一化替代 baseline → 更简单但需要更多采样)
参考资料:
- Proximal Policy Optimization Algorithms
- Secrets of RLHF in Large Language Models Part I: PPO
- Training a Helpful and Harmless Assistant with RLHF
Q60 Cross-Entropy with label smoothing / SFT loss(带 ignore_index)实现
难度: 基础
考察点: SFT loss 的精确实现;理解 label smoothing、ignore_index、padding 处理的工程细节
满分回答:
SFT 的 loss 本质上是 cross-entropy loss,但需要处理几个工程细节:
- ignore_index:prompt 部分的 token 不计算 loss(只算 response 部分)
- label smoothing:软化硬标签 → 防止模型过度自信 → 提升泛化
- padding mask:padding token 不计算 loss
标准 Cross-Entropy: \(\text{CE}(y, \hat{y}) = -\sum_i y_i \log \hat{y}_i\) 其中 $y$ 是 one-hot 标签,$\hat{y}$ 是 softmax 预测。
Label Smoothing Cross-Entropy: \(y_i^{\text{smooth}} = (1 - \epsilon) \cdot y_i + \frac{\epsilon}{V}\) \(\text{CE}_{\text{smooth}} = -\sum_i y_i^{\text{smooth}} \log \hat{y}_i\)
对 one-hot 标签:$y_i^{\text{smooth}} = (1-\epsilon)$ for target class, $\frac{\epsilon}{V}$ for others。
简化公式: \(\text{CE}_{\text{smooth}} = (1-\epsilon) \cdot (-\log \hat{y}_{\text{target}}) + \epsilon \cdot (-\frac{1}{V} \sum_i \log \hat{y}_i)\)
import torch
import torch.nn as nn
import torch.nn.functional as F
def sft_loss(
logits: torch.Tensor, # (B, T, V) — 模型 logits
labels: torch.Tensor, # (B, T) — 目标 token ids
ignore_index: int = -100, # 忽略的位置标记
label_smoothing: float = 0.0, # label smoothing 系数 ε
reduction: str = "mean", # "mean" or "sum"
) -> torch.Tensor:
"""
SFT loss = Cross-Entropy with label smoothing + ignore_index
Args:
logits: (B, T, V) 模型 logits
labels: (B, T) 目标 token ids,-100 表示不计算 loss
ignore_index: 不计算 loss 的 label 值
label_smoothing: 0=标准 CE, >0=平滑版本
reduction: "mean"(除以有效 token 数)或 "sum"
Returns:
loss: scalar
"""
B, T, V = logits.shape
# Step 1: 找到有效位置(label != ignore_index)
valid_mask = labels != ignore_index # (B, T) bool
# Step 2: 创建 shift 关系(next-token prediction)
# SFT 的 labels 是 shifted 的:logits[i] 预测 labels[i+1]
# 但通常数据已经做好了 shift → 此处假设 labels 已经 shift
# Step 3: 将 logits 和 labels reshape 为 2D 以使用 F.cross_entropy
# F.cross_entropy 需要 (N, V) logits 和 (N,) labels
logits_flat = logits.view(-1, V) # (B*T, V)
labels_flat = labels.view(-1) # (B*T,)
valid_mask_flat = valid_mask.view(-1) # (B*T,)
# Step 4: 替换 ignore_index 的 label 为 0(F.cross_entropy 不处理 -100 的 label)
# F.cross_entropy 内置了 ignore_index → 可以直接传 -100
# 但如果用 label smoothing → 需要手动处理
if label_smoothing > 0:
# === Label Smoothing 版本(需手动实现)===
# F.cross_entropy 不支持 label_smoothing + ignore_index 同时使用
# → 需要手动计算
# log_softmax
log_probs = F.log_softmax(logits_flat, dim=-1) # (B*T, V)
# 构造 smooth label
# 对于 target token: weight = (1 - ε)
# 对于其他 token: weight = ε / V
smooth_labels = torch.full_like(log_probs, label_smoothing / V) # (B*T, V) ε/V
# 在 target 位置加上 (1 - ε)
# 注意:ignore_index 的位置不计算 → 先把 -100 替换为 0(任意有效 id)
safe_labels = labels_flat.clone()
safe_labels[~valid_mask_flat] = 0 # 替换为 0(不参与 loss 计算)
smooth_labels.scatter_(1, safe_labels.unsqueeze(1), (1.0 - label_smoothing + label_smoothing / V)) # (B*T, V)
# 计算 CE: -Σ smooth_label * log_prob
per_token_loss = -(smooth_labels * log_probs).sum(dim=-1) # (B*T,)
# mask out invalid positions
per_token_loss = per_token_loss * valid_mask_flat.float() # (B*T,)
# reduction
if reduction == "mean":
loss = per_token_loss.sum() / valid_mask_flat.float().sum()
elif reduction == "sum":
loss = per_token_loss.sum()
else:
loss = per_token_loss
else:
# === 标准 Cross-Entropy 版本(F.cross_entropy 内置 ignore_index)===
loss = F.cross_entropy(
logits_flat,
labels_flat,
ignore_index=ignore_index,
reduction=reduction,
)
return loss
# === 验证 ===
if __name__ == "__main__":
B, T, V = 2, 8, 100
# 模拟模型 logits
logits = torch.randn(B, T, V)
# 模拟 labels:前 3 个 token 是 prompt(ignore),后 5 个是 response(计算 loss)
labels = torch.randint(0, V, (B, T))
labels[:, :3] = -100 # prompt 部分不计算 loss
# 标准 SFT loss
loss_std = sft_loss(logits, labels, ignore_index=-100, label_smoothing=0.0)
print(f"Standard CE loss: {loss_std.item():.4f}")
# Label smoothing SFT loss
loss_smooth = sft_loss(logits, labels, ignore_index=-100, label_smoothing=0.1)
print(f"Label smoothing loss (ε=0.1): {loss_smooth.item():.4f}")
# 验证:label smoothing loss 应略高于标准 CE(因为给非目标 token 也分配了概率)
# 验证 ignore_index 效果
# 全部 label 都计算 vs 只有 response 部分
labels_all = torch.randint(0, V, (B, T)) # 没有 -100
loss_all = sft_loss(logits, labels_all, ignore_index=-100)
print(f"Loss (all tokens): {loss_all.item():.4f}")
print(f"Loss (only response): {loss_std.item():.4f}")
# 只算 response 的 loss 应更高(因为分母更小:10 vs 16 tokens)
# 对比 PyTorch 内置实现
loss_builtin = F.cross_entropy(logits.view(-1, V), labels.view(-1), ignore_index=-100, reduction="mean")
print(f"PyTorch builtin CE: {loss_builtin.item():.4f}")
print(f"Our implementation: {loss_std.item():.4f}")
# 应完全一致
关键点解析:
ignore_index 的作用:SFT 数据格式为
prompt + response→ loss 只在 response 部分(自回归地预测下一个 token)。prompt 部分的 token 标为 -100 → 不计算 loss → 不更新梯度。Shift 关系:SFT 的 labels 做 shift →
labels[i] = input_ids[i+1](预测下一个 token)。通常数据预处理已完成 shift → 代码中假设 labels 已 shift。Label smoothing 实现:
smooth_labels构造方式——先用 $\epsilon/V$ 填充所有位置 → 再用scatter_在 target 位置设 $(1-\epsilon + \epsilon/V)$。这样做避免了手动循环 → 更高效。F.cross_entropy 的局限:内置了
ignore_index和label_smoothing参数,但两者不能同时使用(PyTorch <2.3 的版本)→ 需要手动实现 label smoothing 版本。
⚠️ 常见坑:忘记做 shift → labels 和 logits 位置不对齐 → loss 计算完全错误。典型 SFT 数据:input_ids = [prompt_tokens, response_tokens], labels = [-100, -100, ..., response_tokens](shift 后)。
⚠️ 另一个坑:label smoothing ε=0.1 → 以为会让模型”更不确定” → 实际上 LS 的主要效果是防止模型过度自信(log prob 极高)→ 提升泛化性。ε=0 在训练集上可能效果更好但泛化差。
⚠️ 另一个常见错误:reduction="mean" 的分母是所有 token 数而非有效 token 数 → PyTorch 的 F.cross_entropy 用有效 token 数做分母 → 注意区别。
复杂度分析:
- 时间:$O(B \cdot T \cdot V)$(log_softmax 是瓶颈 → 需要对 V 维做 softmax)
- 显存:$O(B \cdot T \cdot V)$(log_probs 存储)
- 实际中 $V$ 通常很大(32K-128K)→ log_softmax 是 SFT 训练的主要计算瓶颈
面试官常见追问:
- 为什么 SFT loss 只在 response 部分计算?(prompt 是已知的 → 模型不需要学习预测 prompt → 若计算 prompt 的 loss → 模型会花精力学 prompt 的模式 → 浪费)
- label smoothing 在 SFT 中有用吗?(有用但效果有限:ε=0.1 在 LLaMA-2 的 SFT 中约提升 0.5-1% 泛化。但ε太大(>0.2)→ 模型太不确定 → 辧出质量下降)
- 如何处理多轮对话的 SFT loss?(只在最后一轮 response 计算 loss → 前面轮次的 response 标为 -100。或全部 response 都计算 loss → 效果通常更好)
参考资料:
- Training language models to follow instructions with human feedback (InstructGPT)
- Label Smoothing (Szegedy et al., 2016)