Blog Post Llm Mllm Posttrain Interview

97 minute read

Published:

LLM / MLLM 后训练面试题库(60 题精选)

📖 本题库覆盖大模型后训练全链路:从 SFT 到 RLHF/DPO,从 GRPO 到推理模型,从 LoRA 到 MLLM 多模态专项,另附 10 道代码手撕题。

目录


一、基础概念

Q01 什么是大模型后训练(Post-Training)?包括哪些阶段?

难度: 基础 考察点: 对后训练全链路的系统性理解,能否区分各阶段的输入/输出/目标

满分回答:

Post-Training 是基座模型(Base Model)在预训练完成之后、面向下游任务进行的一系列训练阶段的总称。其目标是将一个”续写文本”的基座模型转化为一个”遵循指令、安全可控”的对话模型。

典型后训练 pipeline 包含以下阶段:

  1. SFT(Supervised Fine-Tuning) — 用人工标注的指令-回复对微调基座模型,使其学会按指令格式回答。输入:instruction-response pairs;输出:SFT 模型(policy 初始版本)。
  2. Reward Modeling — 收集人类偏好数据(对同一 prompt 的两个回复做排序),训练一个 Reward Model(RM)来预测人类偏好。输入:preference pairs $(y_w, y_l)$;输出:标量奖励函数 $r_\theta(x, y)$。
  3. 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%)

参考资料:


Q02 Pre-training vs Post-training 的核心差异?

难度: 基础 考察点: 能否从目标、数据、方法、成本等多维度对比两个阶段

满分回答:

维度Pre-trainingPost-training 
目标学习语言的统计规律(next-token prediction)学习遵循指令、符合人类偏好 
数据海量无标注文本(TB 级,web crawl)少量高质量标注数据(K~M 级) 
方法自回归语言建模,$\mathcal{L} = -\sum_t \log P(w_tw_{<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 则完全不同)

参考资料:


Q03 SFT vs RLHF 的区别与联系?

难度: 基础 考察点: 理解两个核心阶段的不同目标和互补关系

满分回答:

维度SFTRLHF
优化目标让模型学会按格式回答让模型输出更符合人类偏好
数据类型instruction-response pairspreference comparisons $(y_w > y_l)$
优化方法监督学习(交叉熵 loss)强化学习(PPO)或直接偏好优化(DPO)
模型组件只需 policy modelpolicy + 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,它学会的是语言的统计分布,而非人类意图。具体问题:

  1. 指令遵循缺失:Base model 看到 “请翻译这句话” 可能续写另一句话而非翻译
  2. 安全性问题:可能生成有害内容(暴力、歧视等)
  3. 偏好偏差:可能输出冗长、自相矛盾、不符合用户风格偏好的内容
  4. 事实性不足:可能自信地编造不存在的信息(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)

参考资料:


二、SFT 监督微调

Q06 SFT 数据构造的最佳实践?

难度: 基础 考察点: 对 SFT 数据质量标准的理解,能否区分不同数据类型的作用

满分回答:

SFT 数据构造的核心原则:质量 » 数量。LIMA 论文证明 1000 条高质量数据可以训练出接近 GPT-4 效果的模型。

数据来源与构造方法:

  1. 人工标注:专业标注员撰写 instruction-response pairs,质量最高但成本大
  2. Self-Instruct:用强模型自动生成指令和回复,再人工筛选(Alpaca 方法)
  3. Magpie:利用 LLM 的对话模板,仅填入 prompt 部分让模型自生成指令(零人工输入)
  4. 蒸馏数据:从 GPT-4/Claude 等强模型获取回复,用于训练开源模型

数据质量标准:

  • 多样性:覆盖不同任务类型(问答、写作、推理、代码等)
  • 准确性:回复内容事实正确
  • 格式一致性:统一的对话模板
  • 长度适中:避免过于冗长或过短
  • 拒绝样本:包含模型应拒绝回答的 prompt(安全对齐)

⚠️ 常见坑:用弱模型生成 SFT 数据会导致”模型蒸馏退化”——弱模型的错误模式会被学习。

数据配比建议(LLaMA 3 实践): | 数据类型 | 占比 | |———-|——| | 通用对话 | ~50% | | 代码/推理 | ~20% | | 长文档/总结 | ~15% | | 拒绝/安全 | ~10% | | 多语言 | ~5% |

延伸追问:

  • Self-Instruct 生成的数据如何保证质量?(人工审核 + 规则过滤 + 多样性采样)
  • SFT 数据需要覆盖多少个任务类型?(至少 20+ 种,否则泛化差)

参考资料:


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?

  1. 目标语义:SFT 的目标是让模型学会”给定 prompt 后生成正确的 response”,不是让模型学会”重新生成 prompt”。Prompt 是条件输入,不是预测目标。
  2. 防止偏移:如果不 mask prompt,模型会花大量梯度去拟合 prompt 的分布,导致 response 部分的学习信号被稀释。
  3. 工程实现:在 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 的数据量阈值远低于预训练。关键结论:

研究数据量结论
LIMA1,000 条1000 条高质量数据足以接近 GPT-4 水平
Alpaca52K 条Self-Instruct 生成的 52K 条即可显著提升
FLAN/T0数百万条多任务提示数据,追求零样本泛化
LLaMA 2~27K 条(对话 SFT)人工标注的高质量数据

核心原则:质量 » 数量。 原因:

  1. 低质量数据的危害:包含错误的回复会直接教模型犯错,比没有数据更糟糕
  2. 重复数据的危害:模型会过拟合高频模式,泛化能力下降
  3. 多样性比数量重要:覆盖更多任务类型的 10K 条数据 > 同类型重复的 100K 条

实践建议:

  • 初步 SFT:5K-20K 条高质量数据
  • 生产级 SFT:50K-200K 条(含多任务、多语言、安全样本)
  • 关键是数据审核流程:每条数据至少经过格式检查 + 内容准确性验证

⚠️ 常见坑:盲目追求数据量而忽略审核,用自动生成数据不做人工筛选。

延伸追问:

  • 如何判断 SFT 数据是否”够”?(看 eval benchmark 是否收敛,训练 loss 是否不再下降但 eval 持续提升 = 过拟合信号)
  • 低质量数据的影响能通过 RLHF 修复吗?(部分可以,但最好在 SFT 层面就保证质量)

参考资料:


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 后模型在通用能力(推理、代码等)上退步。

成因分析:

  1. 参数偏移:SFT 的梯度更新改变了预训练学到的权重分布
  2. 数据偏差:SFT 数据分布与预训练数据分布差异大
  3. 训练过度: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)$,反映人类对该回复的偏好程度。

训练流程:

  1. 数据收集:对同一 prompt 生成多个回复(通常 4-9 个),标注员排序选出 best($y_w$)和 worst($y_l$)
  2. 模型架构:通常在 SFT model 最后加一个线性头,将最后一个 hidden state 投射为标量:$r_\theta(x, y) = \text{Linear}(\text{last_hidden})$
  3. Loss 计算:Bradley-Terry pairwise loss:
\[\mathcal{L}_{\text{RM}} = -\log \sigma\big(r_\theta(x, y_w) - r_\theta(x, y_l)\big)\]

其中 $\sigma$ 是 sigmoid 函数。直觉:让 RM 给 chosen 回复的奖励高于 rejected 回复。

  1. 训练细节
    • 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 等权)

参考资料:


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)\]

推导过程:

  1. 定义偏好概率:$P(y_w > y_l) = \frac{e^{r(x, y_w)}}{e^{r(x, y_w)} + e^{r(x, y_l)}}$
  2. 简化:$= \frac{1}{1 + e^{-(r(x, y_w) - r(x, y_l))}} = \sigma(r(x, y_w) - r(x, y_l))$
  3. 对数似然:$\log P(y_w > y_l) = \log \sigma(r_w - r_l)$
  4. 最大化对数似然 → 最小化负对数似然:
\[\mathcal{L} = -\log \sigma(r_\theta(x, y_w) - r_\theta(x, y_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)$)

参考资料:


Q14 Reward Hacking / Reward Overoptimization 问题?

难度: 高级 考察点: 理解 reward hacking 的成因、表现形式和缓解策略

满分回答:

Reward Hacking / Overoptimization 指 policy 模型通过”钻 RM 的漏洞”来获得高奖励,而非真正提升回复质量。

表现形式:

  1. 回复冗长化:RM 往往倾向给长回复更高分数,policy 学会生成空洞的长回复
  2. 格式钻营:学会 RM 偏好的特定格式(如列表、代码块),而非实质内容提升
  3. 情感偏向:RM 常偏好积极语气,policy 学会无论内容都加正面情绪词
  4. 重复/空洞:生成看似合理但实际无意义的内容来骗取高分

成因分析:

  • 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 的偏差模式)

参考资料:


Q15 RM 训练的数据构造与工程技巧?

难度: 中级 考察点: RM 数据的构造方式、标注流程、工程优化

满分回答:

数据构造流程:

  1. Prompt 采集:从用户对话记录 / 人工设计 / 自动生成中收集 prompt
  2. 回复采样:用 SFT model 对每个 prompt 生成多个回复(通常 4-9 个),可调整温度/采样策略增加多样性
  3. 人工排序:标注员对同一 prompt 的多个回复按质量排序
  4. Pair 构造:从排序中抽取 (chosen, rejected) pairs

关键技巧:

  1. 多样性采样:不同温度、不同 beam、甚至不同模型生成回复,确保 pair 质量差异明显
  2. Pair 间距:优先选择排序差距大的 pair(如 best vs worst),而非相邻排名的 pair,让 RM 学习更明显的偏好差异
  3. InstructGPT 的做法:4-9 个回复排序,构造所有 $\binom{n}{2}$ pairs,每个 pair 权重等价
  4. Margin loss:加入间距 $\delta$ 来增强区分度:$\mathcal{L} = -\log \sigma(r_w - r_l - \delta)$,$\delta$ 可以是排序排名差
  5. 数据分布: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)

参考资料:


四、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_ts_t)}{\pi_{\text{ref}}(a_ts_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 约束?

  1. 防 reward hacking:没有 KL 约束时,policy 可能找到 RM 的漏洞输出高 reward 但低质量的回复
  2. 稳定性:防止 policy 在单步更新中偏离太大,导致后续训练不稳定
  3. 保持通用能力: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 集中在自身高概率区域)

参考资料:


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 ModelValue 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 HackingRM reward 上升但人类偏好下降KL 约束 + 迭代 RM 更新 + 早停
Critic 过拟合Value 估计偏离真实 rewardValue clip + 定期用 RM 重新标定 value target
Policy 模式坍塌输出极度重复/单一Entropy bonus + 增加 KL 系数
梯度爆炸loss 突然 spike,NaNgradient 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”):

  1. Reward Normalization:对 RM 输出做 running mean/std 归一化,防止 reward 尺度漂移
  2. Value Function Pre-training:先用 RM 数据预训练 Value Model,再做 RL 时更新
  3. KL Cost as Reward Penalty:将 KL penalty 加入 reward $r_t’ = r_t - \beta \cdot \text{KL}_t$,而非单独 loss(更稳定)
  4. Batch 分割:generation batch 和 training batch 分开,generation 用更大 batch
  5. 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)

参考资料:


Q20 RLHF 的优势与局限?

难度: 中级 考察点: 对 RLHF 的全面评估,能否辩证看待其优缺点

满分回答:

优势:

  1. 偏好对齐直接:通过 RM 直接优化人类偏好,而非间接通过数据模仿
  2. 超越 SFT 的天花板:SFT 只能模仿标注数据的风格,RLHF 可以发现数据之外更好的回复
  3. 灵活的奖励信号:RM 可以编码任意偏好(有用性、安全性、风格等),比硬编码规则灵活
  4. 可迭代改进:收集新偏好数据 → 更新 RM → 再次 RLHF,持续提升

局限:

  1. 训练不稳定:PPO 训练需要 4 个模型(policy, reference, reward, value),工程复杂度高
  2. RM 质量瓶颈:RM 是有限数据的近似,reward hacking 是不可避免的风险
  3. 标注成本高:偏好排序比单条标注更贵(4-9 个回复的排序比写一条回复难)
  4. 不可逆偏移:RLHF 后的模型可能”过度对齐”,在某些任务上反而不如 SFT model
  5. 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}}(yx) \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(yx)}{\pi_{\text{ref}}(yx)}$,然后最大化 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-sigmoidDPO 在偏好数据噪声大时过拟合$\mathcal{L} = (r_w - r_l - \frac{1}{2})^2$
KTO只需要 binary signal(好/坏),不需要 pair偏好 pair 数据获取成本高基于 Prospect Theory 的 loss
SimPO去掉 reference modelref model 的显存和计算开销用 policy 自身的 length-normalized logprob 作为 implicit reward
ORPOSFT + 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_wx)}{\pi_{\text{ref}}(y_wx)} - \log\frac{\pi(y_lx)}{\pi_{\text{ref}}(y_lx)} - \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_tx, 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)

参考资料:


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 的质量
  • 模型可能学习”降低一切输出概率”的策略,导致整体退化

常见问题:

  1. 显存开销:训练时需要同时存储 policy 和 ref model 的权重(双倍显存)。SimPO 的动机正是解决这个问题。

  2. Reference model 退化:如果 ref model 和 policy 初始完全相同,训练初期 logprob ratio 接近 0,梯度信号弱。随着训练进行 ratio 变大,但 ref model 始终冻结,可能过度惩罚偏离。

  3. 长度偏差:ref model 的 logprob 是逐 token 累加的,长回复的 $\log \pi_{\text{ref}}$ 更大 → DPO 偏好短回复(因为长回复的 ratio 更难增大)。SimPO 的 length normalization 部分解决此问题。
  4. 数据分布偏移: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 的关键区别:

维度PPOGRPO
RewardRM 给绝对标量 rewardGroup 内相对 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 核心公式:

  1. 对同一 prompt $x$,采样 $G$ 个回复 ${y_1, …, y_G}$

  2. RM 对每个回复评分,得到 ${r_1, …, r_G}$

  3. Group 归一化(替代 Critic 的 baseline 功能):

\[\tilde{r}_i = \frac{r_i - \mu(r)}{\sigma(r)}\]

其中 $\mu(r) = \frac{1}{G}\sum_{j=1}^G r_j$,$\sigma(r)$ 是组内标准差。

  1. GRPO Policy Loss(类似 PPO clip):
\[\mathcal{L}_{\text{GRPO}} = -\frac{1}{G}\sum_{i=1}^G \min\Big(\tilde{r}_i \cdot \rho_i, \; \tilde{r}_i \cdot \text{clip}(\rho_i, 1-\epsilon, 1+\epsilon)\Big) + \beta \cdot \mathbb{D}_{\text{KL}}[\pi_\theta || \pi_{\text{ref}}]\]

关键改进点:

  • 去掉 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)

参考资料:


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

各阶段详解:

  1. Cold-start SFT:用少量(数千条)长 CoT 高质量数据做 SFT,解决 R1-Zero 的格式问题
  2. GRPO RL 阶段
    • Rule-based Reward:对数学用正确率验证,对代码用编译/执行结果
    • Language consistency reward:惩罚中英混用的 CoT
    • 保留 GRPO 的 group relative reward 机制
  3. Rejection Sampling SFT:用 RL 后的模型生成大量 CoT 数据,筛选高质量数据(正确 + 可读),重新做 SFT
  4. 全场景 SFT:加入非推理任务数据(对话、翻译、写作等),恢复通用能力
  5. 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 的关键优势:

  1. 避免”侥幸正确”:ORM 给了一个正确答案但中间步骤有错的回复高分;PRM 可以惩罚错误步骤
  2. 更细粒度的 credit assignment:在 RL 训练中,PRM 可以逐步给 reward,引导模型学习正确的推理过程
  3. 更好的搜索引导:在 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 的优势:

  1. 零偏差:规则是确定性函数,没有 RM 的近似误差 → 不会有 reward hacking
  2. 零标注成本:不需要人类标注偏好数据
  3. 可精确验证:数学和代码的正确性有 ground truth

局限性:

  1. 适用范围有限:只适用于有 ground truth 的任务,不适用于开放式生成(如写作、对话)
  2. 缺乏偏好维度:不能编码”风格偏好”、”有用性”等主观维度
  3. 奖励信号稀疏:只有最终结果的对/错,没有中间步骤的反馈(除非结合 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/DPO8-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 可微调)。

三重优化:

  1. 4-bit NormalFloat (NF4) Quantization
    • 基于正态分布信息论最优的 4-bit 数据类型
    • 对预训练权重做 4-bit 量化存储,但计算时动态反量化到 bf16
    • 数学原理:假设权重服从正态分布,NF4 的量化分位点使信息损失最小
  2. Double Quantization
    • 对 4-bit 量化的常量(scaling factor + zero point)再做一次量化
    • 这些常量本身占显存(每个 block 32 个参数需要 1 个 fp32 scaling + 1 个 fp32 zero point)
    • Double quantization 将它们量化为 fp8 → 进一步节省 ~0.37 bit/param
  3. 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 GB14 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

性能对比:

场景全参数 > LoRALoRA ≈ 全参数
大幅度行为改变(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:

  1. 从 175 个种子任务出发
  2. 用 LLM 生成新指令(prompt: “生成一个新任务指令”)
  3. 用 LLM 为每个指令生成回复
  4. 规则过滤:去重、去低质量、去与种子过于相似的
  5. Alpaca 就是用 Self-Instruct 从 GPT-3.5 生成了 52K 条数据

Magpie:

核心创新:不需要写 prompt 来生成指令

原理:利用 LLM 的对话模板本身作为 prompt 触发指令生成。

  1. 构造 LLM 的 chat template prefix(如 <|begin_of_text|><|start_header_id|>user<|end_header_id|>\n
  2. 直接让 LLM 续写这个 prefix → LLM 自动生成一个用户指令
  3. 再用 LLM 生成回复
  4. 效果:生成数据的多样性和质量优于 Self-Instruct

对比:

维度Self-InstructMagpie
是否需要 prompt需要手动设计 prompt不需要,利用 chat template
多样性受种子任务限制更多样(LLM 自由续写)
质量取决于生成模型质量同样取决于模型质量,但多样性更高
可控性可以通过种子控制方向较难控制方向
成本需要 prompt 设计几乎零成本

⚠️ Self-Instruct 的局限:生成的指令容易和种子重复或过于简单。 ⚠️ Magpie 的局限:无法精确控制生成数据的任务类型分布。

延伸追问:

  • 数据合成如何避免”蒸馏退化”?(混合多个强模型输出 + 人工审核 + Orca 渐进学习)
  • 合成数据和真实标注数据的比例如何定?(一般建议合成:真实 ≤ 5:1)

参考资料:


Q33 数据配比(mixing ratio)对 SFT 效果的影响?

难度: 中级 考察点: 理解数据配比的重要性,以及如何根据目标调整配比

满分回答:

数据配比是 SFT 效果的关键因素。不同任务类型的数据比例直接影响模型在不同能力上的表现。

核心发现(来自 LLaMA 3 等实践):

  1. 通用对话数据占比最大(~50%),因为对话能力是基础
  2. 代码/推理数据(~20%)显著提升逻辑推理能力,但过多会导致对话风格过于”技术化”
  3. 安全/拒绝数据(~10%)不足会导致模型无法拒绝有害请求
  4. 长文档数据(~15%)提升长上下文处理能力

配比影响:

调整方向效果
增加代码数据代码能力 ↑,对话自然度 ↓
增加推理数据数学 ↑,生成创造性 ↓
增加拒绝数据安全性 ↑,helpfulness ↓
增加多语言数据多语言 ↑,英文能力 ↓

LLaMA 3 的配比策略: 先高质量对话 SFT → 按维度加入专项数据 → 安全数据 + 预训练混合防遗忘

配比优化方法:

  1. DoReMi:用小模型作为 proxy,动态调整各数据源权重
  2. 网格搜索:不同配比上做 SFT → 评估 → 选最优
  3. 课程学习:先简单数据,再逐步加入复杂数据

⚠️ 常见坑:各数据源 epoch 数不同 → 需要做 epoch 平衡。

⚠️ 另一个坑:过度偏向某类数据会导致”偏科”,其他维度能力退化。

延伸追问:

  • 如何确定最优配比?(网格搜索 + benchmark 评估,或 DoReMi 等自动方法)
  • 数据配比对 RLHF 阶段有影响吗?(间接影响:SFT 数据决定 policy 初始行为分布)

参考资料:


Q34 数据去重与清洗的方法?

难度: 中级 考察点: 了解 SFT 数据去重和清洗的常用方法

满分回答:

数据去重和清洗对 SFT 效果影响显著:重复数据导致过拟合,脏数据导致模型学习错误模式。

去重方法:

方法粒度适用场景
精确匹配字符级去除完全相同的样本
MinHash + LSH文档级大规模近似去重(预训练常用)
Embedding 去重语义级去除语义高度相似的样本
N-gram 去重短语级去除局部重复段落

SFT 数据去重通常用 精确匹配 + embedding 去重 组合:

  1. 精确匹配去 100% 重复
  2. 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 三阶段扩展:

  1. Stage 1: feature alignment(同上)
  2. Stage 2: 大规模视觉指令微调(~665K 条)
  3. 可选 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 ProjectionLLaVACLIP 特征 → MLP → LLM embedding与 CLIP patch 数相同✅ 简单高效 ❌ token 数固定
Q-FormerBLIP-2query tokens 通过 cross-attention 提取信息固定 32✅ token 数少 ❌ 信息瓶颈
直接编码InternVLViT 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:

  1. CLIP 在大规模 image-text pair 上预训练,视觉语义丰富
  2. 冻结避免 visual features 分布漂移,projection layer 学习更稳定
  3. 省掉 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:

改进点LLaVALLaVA-1.5
Projection单层 Linear两层 MLP
视觉编码器ViT-L/14@224pxViT-L/14@336px
LLM backboneVicuna-7B/13BVicuna-7B/13B + Mistral-7B
数据量80K665K
数据类型3种5种(+OCR/VQA)
Token pooling2×2 pooling(576→144)

LLaVA-NeXT (1.6) 的改进:

  1. 动态分辨率(AnyRes):图像分割为多个子图独立编码,最大 672×672
  2. 更强 LLM backbone:Mistral-7B, Yi-34B, Qwen-72B
  3. 更多 OCR/文档理解数据

⚠️ 动态分辨率 → token 数不确定 → LLM 需支持变长输入。

⚠️ 高分辨率 → token 多 → 训练/推理计算量大。

延伸追问:

  • LLaVA-1.5 为什么从 Linear 改为 MLP?(非线性映射能力更强)
  • LLaVA-NeXT 动态分辨率如何处理?(分割为多个 336×336 子图独立编码再拼接)

参考资料:


Q39 Qwen-VL / InternVL 的后训练范式?

难度: 高级 考察点: 对两大国产 VLM 后训练流程的了解和对比

满分回答:

Qwen-VL 的后训练:

  1. Stage 1: 预训练 — ~1.4B image-text pairs,冻结 LLM,训练 ViT + cross-attention adapter
  2. Stage 2: 多任务预训练 — ~7M 高质量多任务数据,解冻所有参数
  3. Stage 3: SFT — ~350K 多模态对话数据,全参数微调
  4. 可选 RLHF — Qwen-VL-Max 使用了 SFT + RLHF

InternVL 的后训练:

  1. Stage 1: 视觉-语言对齐 — InternViT-6B(从零训练)+ MLP + LLM
  2. Stage 2: 多模态 SFT — 混合对话/推理/OCR/代码/数学数据,全参数微调
  3. Stage 3: DPO 对齐 — InternVL2 使用 DPO

关键对比:

维度Qwen-VLInternVL
视觉编码器修改版 CLIP ViTInternViT-6B(从零训练)
adapterPosition-aware cross-attentionMLP projection
分辨率策略动态动态(子图分割)
后训练范式3阶段 + RLHF3阶段 + DPO

⚠️ Qwen-VL 的 cross-attention adapter 更灵活但更复杂。

⚠️ InternVL 从零训练 6B InternViT 成本极高,但视觉理解上限更高。

延伸追问:

  • 为什么 InternVL 不用 CLIP?(CLIP ViT 参数小且分辨率受限)
  • 国产 VLM 和 LLaVA 的差距在哪?(国产 VLM 在中文和 OCR/文档理解上更强)

参考资料:


Q40 VLM 的 RLHF / DPO 训练有哪些特殊挑战?

难度: 高级 考察点: 理解多模态对齐的特有困难

满分回答:

5 个关键挑战:

  1. 偏好数据构造难 — 同一图像多种合理描述 → 偏好标准不一致
  2. RM 需要理解图像 — 纯文本 RM 无法判断”描述是否匹配图像” → RM 也得是 VLM
  3. KL 约束复杂 — 视觉编码器参与训练时 visual features 分布漂移
  4. 训练稳定性差 — 梯度来源更多(视觉+语言),梯度冲突风险大
  5. 评估难度 — 纯文本 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 数据构造方法:

  1. 将图像描述为文本
  2. 喂给 GPT-4 生成多轮对话、详细描述、复杂推理
  3. 人工审核后作为 SFT 数据

LLaVA-1.5 扩展: 80K → 665K,增加了 OCR/VQA 等

关键考量:

  1. 图像质量:高分辨率、多样化场景
  2. 指令多样性:覆盖全面任务类型
  3. 回复准确性:必须与图像内容匹配
  4. 负样本:包含”我看不清”的案例

⚠️ 常见坑:用纯文本 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 sizeToken 数模型
ViT-L/14@224224×22414256LLaVA
ViT-L/14@336336×33614576LLaVA-1.5
2×2 pooling @336336×33614→28144LLaVA-1.5
AnyRes @672672×672142304LLaVA-NeXT

对训练的影响:

  1. 显存:attention 显存与 $(N_{\text{text}} + N_{\text{vis}})^2$ 成正比
  2. 计算成本:每层 FLOPs 与 $(N_{\text{text}} + N_{\text{vis}})^2 \times d$ 成正比
  3. 信息密度:低分辨率 → 信息损失;高分辨率 → 计算昂贵
  4. 长度限制:视觉 token + 文本 token ≤ max_seq_len

权衡策略:

策略方法效果
Patch pooling2×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)

  1. 生成有害回答:让模型(Helpful-only RLHF 模型)对有害 prompt 生成回答 $r_0$
  2. 自批判:让同一模型根据宪法原则对 $r_0$ 写 critique:\(\text{critique} = \text{Model}(\text{principle}, \text{prompt}, r_0)\)
  3. 修正:让模型根据 critique 生成修正后的回答 $r_1$:\(r_1 = \text{Model}(\text{prompt}, r_0, \text{critique}, \text{principle})\)
  4. 重复:可多次批判修正,得到 $r_2, r_3, …$
  5. SFT 数据构造:将修正后的回答作为 SFT 目标,原始有害回答被”覆盖”

Stage 2: RL from AI Feedback(RLAIF)

  1. AI 生成偏好:对同一 prompt,让模型生成两个回答 → 另一个 AI(或同一模型)根据宪法原则评判哪个更好
  2. 偏好标注:AI 生成偏好标签(chosen vs rejected),替代人类标注
  3. 训练 RM:用 AI 标注的偏好数据训练 reward model
  4. PPO 训练:用 RM 做 RL 优化 → 模型学习生成符合宪法原则的回答

RLAIF vs RLHF 的核心区别:

维度RLHFRLAIF
偏好来源人类标注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 过滤**GCGGCG suffix 通常 perplexity 极高 → 过滤异常高 perplexity 输入对 GCG 有效但对语义绕过无效
Few-shot 安全示范Many-shot在 system prompt 放安全示范 → 对抗有害 few-shot一定程度上缓解 ICL 攻击

⚠️ 常见坑:以为一个强 system prompt 就够了 → GCG 等自动化攻击可以在几分钟内找到绕过任何固定 prompt 的 suffix token 序列。

⚠️ 另一个坑:只做输入过滤不做模型内部安全训练 → 新型攻击手法层出不穷,外挂过滤永远追不上。模型内化安全意识才是根本。

延伸追问:

  • GCG 攻击为什么有效?(它直接优化 suffix token 使得模型的 logit 分布偏向有害输出——绕过了语义层面的防御,直接操控模型的内部激活)
  • 如何做持续红队?(部署后持续收集用户攻击案例 → 定期用这些攻击做 DPO 微调 → 更新模型安全能力 → 循环迭代)

参考资料:


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 更有能力的偏好对;让模型同时学习安全和能力偏好)

参考资料:


十一、训练工程

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/N1.5x适合中等规模模型
ZeRO-3优化器状态 + 梯度 + 参数全部分片到 1/N3x适合极大模型(>参数量/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)

参考资料:


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}}$

快速估算表(不含激活值):

模型大小参数优化器梯度总计(不含激活)
7B42GB56GB14GB112GB
13B78GB104GB26GB208GB
70B420GB560GB140GB1120GB

注:以上为混合精度(bf16+fp32 master weight)单卡需求。

常见配置估算:

配置模型显存需求等价 GPU 数
LoRA bf16 7B~参数+梯度(LoRA部分)+优化器~16-20GB1×A100 40GB
全参数 bf16 7B~112GB(不含激活)需要 ZeRO-3 + 多卡2×A100 80GB
全参数 bf16 70B~1120GB需要 ZeRO-3 + 8×A100 80GB8-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)
\[\text{MT-Bench Score} = \frac{\text{\# wins} + 0.5 \times \text{\# ties}}{\text{total questions}}\]

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-Bench80GPT-4 pairwise judge多轮对话(8类)GPT-4 自偏好(倾向自己风格)~0.95
AlpacaEval 2.0805LC pairwise judge单轮指令跟随长度偏差(已校正)~0.92
Arena-Hard500GPT-4-0314 pairwise高难度推理/编码仅比 GPT-4-0314~0.98

LLM-as-Judge 的三大偏差:

  1. 位置偏差(Position Bias):judge 偏好放在前面的回答 → 解决:交换位置重评取平均
  2. 长度偏差(Verbosity Bias):judge 偏好更长回答 → 解决:LC 校正或 prompt judge 忽略长度
  3. 自偏好偏差(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 投票 + 长度校正 + 人类抽样验证)

参考资料:


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 的比例
ConcordanceRM 排序与人类排序一致的比例更细粒度——多回答排序的一致性
CalibrationRM score 与人类真实偏好分数的相关性RM score 是否反映真实的偏好强度
Separationchosen 与 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)

参考资料:


十三、前沿与开放题

Q50 推理模型(o1/R1)的后训练与 Agent training 的前沿方向?

难度: 专家

考察点: 推理模型后训练的核心技术路线;理解从 RLHF 到 reasoning RL 的范式跃迁

满分回答:

推理模型的后训练范式变革:

传统后训练路线:SFT → RLHF/DPO → 对齐模型(擅长指令跟随但不擅长复杂推理) 推理模型路线:SFT → Reasoning RL → 推理模型(擅长长链推理、自我验证、纠错)

OpenAI o1 的后训练(推测):

  • OpenAI 未公开 o1 的完整训练细节,但社区分析推测其核心流程:
    1. 大规模推理数据 SFT:用 PRM(Process Reward Model)标注的高质量推理链做 SFT → 让模型学会”一步一步推理”的格式
    2. Reasoning RL(类似 PPO 但 reward 是推理正确性)
    • Reward 来源:结果验证(数学题答案对/错、代码 pass/fail)而非人类偏好
    • 模型在 RL 中学习:更多推理步骤 → 更高推理正确率 → reward 更高 → 生成更长推理链
    • 这解释了 o1 的”thinking time”现象——模型学会用更多 token 做推理
      1. Test-time compute scaling:推理时通过 search(如 beam search / MCTS)生成多条推理路径 → 选最优 → 相当于”推理时做更多计算”

DeepSeek-R1 的后训练(公开):

鞞段方法数据目标
Stage 1纯 GRPO RL(无 SFT)数学/代码等可验证任务让模型自发涌现推理行为(reasoning emergence)
Stage 2拒绝采样 + SFTRL 产生的优质推理链 + 通用 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 的前沿方向:

  1. Tool-use RL:让模型在 RL 中学习何时调用工具(搜索、代码执行、计算器)→ reward = 最终答案正确性
  2. Multi-step RL:将 agent 的多步行为轨迹视为一条完整策略 → reward 只在最终步骤给出 → 模型学习规划
  3. Environment-grounded RL:模型在真实/模拟环境中执行任务 → 根据环境反馈得到 reward
  4. Self-play RL:两个 agent 对弈/协作 → reward 来自博弈结果 → 学习策略性推理
方向代表工作Reward 来源核心挑战
Reasoning RLo1/R1/GRPO可验证任务正确性非可验证任务(写作/创意)的 reward 设计
Tool-use RLToolformer/ReAct任务完成率工具调用的延迟与错误处理
Multi-step Agent RLVoyager/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 → 成本高;目前这个方向仍是开放问题)

参考资料:



十四、手撕代码 / 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)

关键点解析:

  1. 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)
  2. 数值稳定性:mask 用 float('-inf') 而非 -1e9-1e9 在 softmax 后 ≈ $e^{-10^9}$ 仍有微小值 → FP16 下可能不精确归零 → 影响梯度计算。float('-inf') → softmax 后严格 0。
  3. QKV 合并投影:一次 Linear(D, 3D) 比 三次 Linear(D, D) 效率高——单次 matmul vs 三次,减少 kernel launch overhead。
  4. 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)$)

参考资料:


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(此处省略,逻辑验证为主)

关键点解析:

  1. 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)$ → 推理速度大幅提升
  2. Causal mask 的位置索引
    • Decode 阞段,query 在位置 start_pos → 只能看 0 ~ start_pos 的 KV → 用 query_pos >= kv_pos 构造 mask
    • 不能用简单的 tril mask(因为 T_qT_kv 维度不同)
  3. 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$)

参考资料:


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:混合高频缩放+低频插值 → 最优外推方案)

参考资料:


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

关键对比:

维度LayerNormRMSNorm
计算$(x - \mu) / \sigma \cdot \gamma + \beta$$x / \text{RMS} \cdot \gamma$
可学习参数$\gamma$ + $\beta$(2×D)$\gamma$(1×D)→ 无 bias
均值中心化✅ 有❌ 无
计算量2 次 mean + 1 次 var1 次 mean(x²)
速度略慢(多一步减均值)略快(省减均值和 bias)
效果理论上更灵活(可做偏移)实际效果几乎等同
代表模型GPT-2/3, BERTLLaMA, Qwen, Mistral

为什么现代 LLM 用 RMSNorm?

  1. 参数更少:省 bias → 每层少 D 个参数 → 大模型省不少
  2. 计算更快:省均值中心化 → 略微加速(在大模型中累积可观)
  3. 效果等同:实验显示 RMSNorm 和 LayerNorm 在 LLM 训练中效果无显著差异
  4. 数值稳定: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 FFNGeGLU FFNSwiGLU 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))$
投影矩阵数233
参数量$2 \cdot D \cdot d_{ff}$$3 \cdot D \cdot d_{ff}$$3 \cdot D \cdot d_{ff}$
门控机制GeLU 门控SiLU 门控
激活函数ReLUGeLUSiLU/Swish
效果基线比 ReLU 好比 GeGLU 略好
代表模型GPT-2PaLMLLaMA, 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 更简单、计算更快)

参考资料:


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 headsK/V headsKV cache 倍率代表模型
MHAHH1xGPT-2/3, BERT
MQAH11/HPaLM, StarCoder
GQAHG (G < H)G/HLLaMA-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 = 最少

关键点解析:

  1. 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)
  2. Expand 操作:GQA 的关键步骤——将 $G$ 个 KV head 重复 $n_rep$ 次与 $H$ 个 Q head 对齐。unsqueeze(2) + expand + reshape 是最常见的实现方式。

  3. 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)

参考资料:


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%

关键点解析:

  1. 初始化策略:A 用 Kaiming 初始化(非零 → 有梯度),B 用零初始化 → LoRA 初始增量 $\Delta W = B \cdot A = 0$ → 模型行为不变 → 训练可以从 base 模型状态开始。

  2. Scaling = alpha / rank:当 rank 从 8 改为 16 → scaling 从 2 变为 1 → 增量矩阵的数值量级不变 → rank 调整不影响训练动态。

  3. Merge 权重:推理时将 LoRA 增量 $\Delta W = B \cdot A \cdot \text{scaling}$ 加到原始权重 $W$ → 只做一次 matmul → 推理零额外开销。

  4. 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,)

关键点解析:

  1. log ratio = log π_θ - log π_ref:DPO 的核心不是绝对概率而是相对概率的变化。reference model 的作用是”锚定”——防止模型过度偏离原始分布。

  2. 数值稳定性F.logsigmoid(logits) 而非 torch.log(torch.sigmoid(logits))。原因:sigmoid(x) 在 $x$ 很小时 $\approx 0$ → log(0) = -inflogsigmoid 内部用 -softplus(-x) 计算 → 对所有 $x$ 值都精确。

  3. logprob 计算:用 log_softmax + gather 而非 log(probs[target_id]) → 更高效(一次 softmax + gather vs 两次)且数值更稳定。

  4. ignore_index:DPO 的 label 通常包含 prompt + response → prompt 部分不计算 loss → 用 ignore_index 标记。

⚠️ 常见坑:忘记用 reference model → 直接用 policy_chosen_logpspolicy_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)

参考资料:


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 → 鼓励减小该动作概率

关键点解析:

  1. Ratio = exp(logprob - old_logprob):用 log space 计算更稳定(避免数值溢出/下溢)→ 再 exp 回 ratio。

  2. Clip 机制torch.clamp(ratio, 1-ε, 1+ε) → 限制单步策略变化幅度 → 防止”策略崩塌”(一步更新太大导致后续更新方向错误)。

  3. 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
  4. 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 → 更简单但需要更多采样)

参考资料:


Q60 Cross-Entropy with label smoothing / SFT loss(带 ignore_index)实现

难度: 基础

考察点: SFT loss 的精确实现;理解 label smoothing、ignore_index、padding 处理的工程细节

满分回答:

SFT 的 loss 本质上是 cross-entropy loss,但需要处理几个工程细节:

  1. ignore_index:prompt 部分的 token 不计算 loss(只算 response 部分)
  2. label smoothing:软化硬标签 → 防止模型过度自信 → 提升泛化
  3. 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}")
    # 应完全一致

关键点解析:

  1. ignore_index 的作用:SFT 数据格式为 prompt + response → loss 只在 response 部分(自回归地预测下一个 token)。prompt 部分的 token 标为 -100 → 不计算 loss → 不更新梯度。

  2. Shift 关系:SFT 的 labels 做 shift → labels[i] = input_ids[i+1](预测下一个 token)。通常数据预处理已完成 shift → 代码中假设 labels 已 shift。

  3. Label smoothing 实现smooth_labels 构造方式——先用 $\epsilon/V$ 填充所有位置 → 再用 scatter_ 在 target 位置设 $(1-\epsilon + \epsilon/V)$。这样做避免了手动循环 → 更高效。

  4. F.cross_entropy 的局限:内置了 ignore_indexlabel_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 → 效果通常更好)

参考资料: