字节大模型算法岗被狠狠拷打,LoRA的数学原理被问倒了
是时候准备面试和实习了。
不同以往的是,当前职场环境已不再是那个双向奔赴时代了。求职者在变多,HC 在变少,岗位要求还更高了。
最近,我们又陆续整理了很多大厂的面试题,帮助一些球友解惑答疑,分享技术面试中的那些弯弯绕绕。
喜欢本文记得收藏、关注、点赞。

上周帮一个学员复盘字节面试,他说面试官问 LoRA 原理的时候,脑子直接一片空白。
“LoRA不就是用两个小矩阵代替一个大矩阵吗?”
面试官:“那为什么可以这样代替?低秩的数学原理是什么?和QLoRA有什么区别?”
学员:“…”
今天就来彻底搞清楚这些微调技术的原理,保证下次面试不再懵逼。
原题:请详细解释LoRA(Low-Rank Adaptation)的工作原理,为什么低秩矩阵分解能够有效地微调大模型?
说实话,刚开始听到"低秩适配"这个名字,我也是一脸懵逼。什么叫低秩?为什么要适配?
后来才发现,LoRA的核心思想特别朴素:大部分微调其实只需要调整模型的一小部分能力。
传统微调的问题
假设你有一个7B参数的模型,每个参数用FP16存储,那就需要:
-
模型参数:7B × 2 bytes = 14GB
-
梯度:7B × 2 bytes = 14GB
-
优化器状态(Adam):7B × 8 bytes = 56GB
-
总计:84GB显存!
一张A100只有80GB,连单卡都跑不动,更别说多数据微调了。
LoRA的解决思路
核心假设:预训练模型已经学会了足够的知识,微调时只需要调整一个低秩的增量。
具体来说,不直接更新权重矩阵W,而是学习一个增量ΔW:
W_new = W + ΔW
关键insight:ΔW可以分解成两个低秩矩阵的乘积:
ΔW = A × B
其中:
-
W的形状:(d, k) - 比如 (4096, 4096)
-
A的形状:(d, r) - 比如 (4096, 16)
-
B的形状:(r, k) - 比如 (16, 4096)
-
r << min(d, k),这个r就是"秩"
数学直觉理解
为什么可以这样分解?想象你要在一张白纸上画一幅复杂的画:
传统微调:你有一支4096×4096的超级画笔,每个像素都可以独立调色。
LoRA微调:你只有16支基础画笔(矩阵A的列),但你可以用不同的力度组合这些画笔(矩阵B的行)来创造出复杂的效果。
虽然自由度降低了,但对于大部分绘画任务,16支画笔的组合已经够用了。
手写LoRA实现
import torch
import torch.nn as nn
import math
class LoRALinear(nn.Module):
def __init__(self, in_features, out_features, rank=16, alpha=16, dropout=0.1):
super().__init__()
# 原始的权重矩阵,冻结不更新
self.linear = nn.Linear(in_features, out_features, bias=False)
self.linear.weight.requires_grad = False
# LoRA的参数
self.rank = rank
self.alpha = alpha
self.dropout = nn.Dropout(dropout)
# 低秩分解的两个矩阵
self.lora_A = nn.Parameter(torch.zeros(in_features, rank))
self.lora_B = nn.Parameter(torch.zeros(rank, out_features))
# 初始化策略很重要!
nn.init.kaiming_uniform_(self.lora_A, a=math.sqrt(5))
nn.init.zeros_(self.lora_B) # B初始化为0,确保开始时增量为0
# 缩放因子
self.scaling = alpha / rank
def forward(self, x):
# 原始线性变换
original_output = self.linear(x)
# LoRA的增量
lora_output = self.dropout(x) @ self.lora_A @ self.lora_B * self.scaling
return original_output + lora_output
# 吴师兄提醒:scaling = alpha/rank 这个细节很容易被问到!
为什么这样做有效?
1. 参数量大幅减少
原始矩阵:4096 × 4096 = 16M参数
LoRA矩阵:4096 × 16 + 16 × 4096 = 131K参数
参数量减少了99%!
2. 数学上的合理性
大部分权重矩阵都存在冗余,真正"有用"的信息往往集中在少数几个主要方向上。SVD分解告诉我们,一个矩阵的大部分信息可以用前r个奇异值及其对应的向量来表示。
3. 实验验证
论文作者在多个任务上验证,rank=1~64的LoRA就能达到全量微调95%以上的效果。
二、腾讯追问:LoRA和QLoRA有什么区别?
原题:QLoRA技术是如何实现模型压缩的?它和LoRA的主要区别是什么?
QLoRA = Quantized LoRA,听起来就是在LoRA基础上加了量化。但实际上,QLoRA的创新点远不止于此。
QLoRA的核心创新
1. 4bit量化的基座模型
LoRA虽然减少了可训练参数,但基座模型(比如LLaMA-7B)仍然要以FP16精度加载,占用14GB显存。
QLoRA把基座模型量化到4bit,显存占用立马降到3.5GB!
2. 双重量化(Double Quantization)
普通4bit量化需要额外存储每个量化块的缩放因子(scale)。如果缩放因子用FP16存储,64个参数需要1个FP16的scale,额外开销还是很大。
QLoRA的做法:对缩放因子再进行一次8bit量化!
# 双重量化的示例逻辑
def double_quantization(weights, block_size=64):
# 第一次量化:FP16 -> 4bit
scales_fp16 = []
quantized_4bit = []
for block in weights.split(block_size):
# 计算缩放因子
scale = block.abs().max() / 7# 4bit范围是[-7,7]
scales_fp16.append(scale)
# 量化到4bit
quantized = torch.round(block / scale).clamp(-7, 7)
quantized_4bit.append(quantized)
# 第二次量化:缩放因子 FP16 -> 8bit
scales_fp16 = torch.stack(scales_fp16)
scale_scale = scales_fp16.abs().max() / 127# 8bit范围
scales_8bit = torch.round(scales_fp16 / scale_scale).clamp(-127, 127)
return quantized_4bit, scales_8bit, scale_scale
3. 分页优化器
训练时,优化器状态(比如Adam的momentum)会在CPU和GPU之间动态分页,进一步节省GPU显存。
量化精度 vs 训练效果
很多同学担心:4bit量化会不会影响训练效果?
QLoRA的巧妙设计:
-
前向传播:4bit精度计算
-
反向传播:计算梯度时临时反量化到FP16/BF16
-
参数更新:LoRA的A、B矩阵始终保持高精度
这样既节省了显存,又保证了训练精度。
实际效果对比
我在一个7B模型上测试过:
全量微调:需要80GB显存,训练2小时
LoRA:需要20GB显存,训练2.5小时
QLoRA:需要9GB显存,训练3小时
效果对比:QLoRA达到全量微调98%的性能,而显存只用了1/9!
三、阿里深挖:Prefix Tuning和Prompt Tuning有什么区别?
原题:请解释Prefix Tuning、Prompt Tuning、P-tuning等参数高效微调方法的区别和适用场景。
这几个名字听起来都很像,很容易搞混。我刚开始也是傻傻分不清楚,后来发现它们的核心思想都是在输入上做文章,但具体实现方式不同。
Prompt Tuning:最简单直接
核心思想:在输入前面加一些可学习的"软提示词"
class PromptTuning(nn.Module):
def __init__(self, model, prompt_length=20, embed_dim=768):
super().__init__()
self.model = model
self.prompt_length = prompt_length
# 可学习的软提示,直接在embedding空间
self.soft_prompt = nn.Parameter(torch.randn(prompt_length, embed_dim))
def forward(self, input_ids):
batch_size = input_ids.shape[0]
# 获取原始embedding
inputs_embeds = self.model.get_input_embeddings()(input_ids)
# 复制软提示给每个样本
prompt_embeds = self.soft_prompt.unsqueeze(0).expand(batch_size, -1, -1)
# 拼接
inputs_embeds = torch.cat([prompt_embeds, inputs_embeds], dim=1)
return self.model(inputs_embeds=inputs_embeds)
适用场景:T5这样的encoder-decoder模型,只在encoder输入前加软提示。
Prefix Tuning:更灵活的控制
核心思想:不只是在输入层加提示,而是在每一层的Key-Value上都加上可学习的前缀。
class PrefixTuning(nn.Module):
def __init__(self, model, prefix_length=20, num_layers=12, hidden_size=768):
super().__init__()
self.model = model
self.prefix_length = prefix_length
# 为每一层生成prefix
self.prefix_encoder = nn.ModuleList([
nn.Sequential(
nn.Linear(hidden_size, hidden_size),
nn.Tanh(),
nn.Linear(hidden_size, 2 * hidden_size) # 生成K和V
) for _ in range(num_layers)
])
# 初始的prefix embedding
self.prefix_tokens = nn.Parameter(torch.randn(prefix_length, hidden_size))
def get_prefix_states(self, batch_size):
"""为每一层生成prefix的K,V状态"""
prefix_states = []
prefix_embeds = self.prefix_tokens.unsqueeze(0).expand(batch_size, -1, -1)
for layer_encoder in self.prefix_encoder:
# 生成这一层的prefix K,V
prefix_kv = layer_encoder(prefix_embeds)
prefix_k, prefix_v = prefix_kv.chunk(2, dim=-1)
prefix_states.append((prefix_k, prefix_v))
return prefix_states
def forward(self, input_ids):
batch_size = input_ids.shape[0]
prefix_states = self.get_prefix_states(batch_size)
# 修改模型的attention计算,加入prefix的K,V
# 这里需要hook到每个attention层,具体实现比较复杂
return self.model(input_ids, prefix_states=prefix_states)
适用场景:生成任务,特别是需要精细控制生成内容的场景。
P-tuning:结合了两者优势
核心思想:Prompt Tuning + 更深层的优化
class PTuning(nn.Module):
def __init__(self, model, pattern="[P0][P1][P2] {text} [P3]", hidden_size=768):
super().__init__()
self.model = model
self.pattern = pattern
# 提取可学习的prompt token数量
self.prompt_tokens = []
for token in pattern.split():
if token.startswith('[P') and token.endswith(']'):
self.prompt_tokens.append(token)
# 为每个prompt token创建embedding
self.prompt_embeddings = nn.ParameterDict({
token: nn.Parameter(torch.randn(hidden_size))
for token in self.prompt_tokens
})
# 更深的prompt encoder
self.prompt_encoder = nn.Sequential(
nn.Linear(hidden_size, hidden_size),
nn.ReLU(),
nn.Linear(hidden_size, hidden_size)
)
def forward(self, input_ids, text_span):
# 构建混合的输入序列
# [P0] [P1] [P2] actual_text [P3]
# 这里需要复杂的token替换逻辑
pass
三者对比总结

经验总结:
-
T5微调:首选Prompt Tuning
-
GPT类生成:首选Prefix Tuning
-
BERT类理解:首选P-tuning
四、快手实战题:混合精度微调具体怎么做?
原题:在混合精度微调过程中,具体是指哪几种精度的混合使用?有什么注意事项?
这个问题特别实际!混合精度训练能节省一半显存,训练速度还能提升1.5-2倍,但稍有不慎就会训练不稳定。
什么是混合精度?
传统训练:所有操作都用FP32(32位浮点数)
混合精度训练:
-
前向传播:大部分用FP16,数值敏感操作用FP32
-
反向传播:梯度计算用FP16,累积用FP32
-
参数更新:用FP32保证精度
FP16的问题和解决方案
1. 数值下溢问题
FP16的最小正数是6.1×10^-5,很多梯度小于这个值就变成0了。
解决方案:梯度缩放(Gradient Scaling)
# 手写梯度缩放示例
class GradientScaler:
def __init__(self, init_scale=2**16):
self.scale = init_scale
self.growth_factor = 2.0
self.backoff_factor = 0.5
self.growth_interval = 2000
self.steps_since_scale = 0
def scale_loss(self, loss):
"""放大loss,让梯度不会下溢"""
return loss * self.scale
def unscale_gradients(self, optimizer):
"""反向缩放梯度"""
for group in optimizer.param_groups:
for param in group['params']:
if param.grad isnotNone:
param.grad = param.grad / self.scale
def step(self, optimizer):
"""检查梯度是否有inf/nan,动态调整scale"""
# 检查梯度是否正常
inf_or_nan = False
for group in optimizer.param_groups:
for param in group['params']:
if param.grad isnotNone:
if torch.isnan(param.grad).any() or torch.isinf(param.grad).any():
inf_or_nan = True
break
if inf_or_nan:
# 发现异常,降低scale
self.scale *= self.backoff_factor
self.steps_since_scale = 0
print(f"检测到nan/inf,降低scale到 {self.scale}")
else:
# 正常更新参数
optimizer.step()
self.steps_since_scale += 1
# 定期增大scale
if self.steps_since_scale >= self.growth_interval:
self.scale *= self.growth_factor
self.steps_since_scale = 0
PyTorch的自动混合精度
正确的使用方式:
import torch
from torch.cuda.amp import autocast, GradScaler
# 初始化
model = YourModel()
optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4)
scaler = GradScaler()
for batch in dataloader:
optimizer.zero_grad()
# 自动混合精度前向传播
with autocast():
outputs = model(batch['input_ids'])
loss = criterion(outputs, batch['labels'])
# 缩放loss并反向传播
scaler.scale(loss).backward()
# 可选:梯度裁剪(在unscale之后,step之前)
scaler.unscale_(optimizer)
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
# 更新参数
scaler.step(optimizer)
scaler.update()
哪些操作不适合FP16?
自动混合精度会自动处理,但了解原理很重要:
# 这些操作会自动用FP32
loss_functions = [
nn.CrossEntropyLoss(),
nn.BCELoss(),
nn.MSELoss()
]
# 这些操作也会用FP32
numerical_operations = [
torch.norm(), # 范数计算
torch.sum(), # 求和
torch.softmax(), # 需要高精度的激活函数
torch.log_softmax()
]
# LayerNorm通常也用FP32
layer_norm = nn.LayerNorm(hidden_size) # 内部会处理精度
实际训练中的注意事项
1. 初始scale值的选择
# 不同模型建议的初始scale
model_scales = {
'bert-base': 2**15, # 较小模型,梯度相对较大
'bert-large': 2**16, # 中等模型
'gpt-7b': 2**12, # 大模型,梯度相对较小
'gpt-70b': 2**8 # 超大模型
}
2. 学习率调整
混合精度训练时,由于数值精度变化,通常需要稍微调整学习率:
# 经验法则:FP16训练的学习率可以稍微调高
fp32_lr = 1e-4
fp16_lr = fp32_lr * 1.2 # 可以尝试调高20%
3. 监控训练稳定性
class MixedPrecisionMonitor:
def __init__(self):
self.loss_scale_history = []
self.gradient_scale_history = []
def log_step(self, scaler, model):
# 记录当前的loss scale
self.loss_scale_history.append(scaler.get_scale())
# 记录梯度范数
total_norm = 0
for p in model.parameters():
if p.grad isnotNone:
param_norm = p.grad.norm()
total_norm += param_norm ** 2
total_norm = total_norm ** (1. / 2)
self.gradient_scale_history.append(total_norm.item())
def check_stability(self, window=100):
"""检查最近100步的训练稳定性"""
if len(self.loss_scale_history) < window:
returnTrue
recent_scales = self.loss_scale_history[-window:]
# 如果scale频繁下降,说明训练不稳定
scale_drops = sum(1for i in range(1, len(recent_scales))
if recent_scales[i] < recent_scales[i-1])
if scale_drops > window * 0.1: # 超过10%的步数都在降scale
print("警告:训练可能不稳定,考虑降低学习率")
returnFalse
returnTrue
总结:微调技术选择指南
根据我的实际经验,不同场景下的最佳选择:
显存充足(>40GB):
-
任务简单:Prompt Tuning
-
任务复杂:LoRA (rank=32-64)
显存有限(16-32GB):
-
首选:LoRA (rank=8-16)
-
备选:Prefix Tuning
显存很少(<16GB):
-
唯一选择:QLoRA
-
配合gradient checkpointing进一步节省
工程实践建议:
-
从LoRA开始:效果好,实现简单
-
逐步优化:根据实际需求调整rank和alpha
-
监控训练:特别是混合精度训练的稳定性
-
多实验对比:不同方法在不同任务上效果差异很大
记住:没有银弹,只有最适合的方案。
更多推荐
所有评论(0)