是时候准备面试和实习了。

不同以往的是,当前职场环境已不再是那个双向奔赴时代了。求职者在变多,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进一步节省

工程实践建议

  1. 从LoRA开始:效果好,实现简单

  2. 逐步优化:根据实际需求调整rank和alpha

  3. 监控训练:特别是混合精度训练的稳定性

  4. 多实验对比:不同方法在不同任务上效果差异很大

记住:没有银弹,只有最适合的方案。

更多推荐