· liyu · ai · 20 min read

从预训练到指令微调:中文 LLaMA2 (204M) 原生 LoRA SFT 实战与 Kaggle GPU 避坑指南

完整记录中文 LLaMA2(204M 参数)从预训练向 SFT 指令微调演进的全流程。深入剖析 Loss Masking 数据管道、原生手写 LoRA/QLoRA 模块实现,并深度复盘 Kaggle 云端 GPU 迁移中的只读文件系统、Git LFS 假死、GPU 架构兼容与跨设备张量对齐四大经典工程踩坑。文末联动 AstrBot Agent 微信机器人实现 GPU 训练定时看护。

完整记录中文 LLaMA2(204M 参数)从预训练向 SFT 指令微调演进的全流程。深入剖析 Loss Masking 数据管道、原生手写 LoRA/QLoRA 模块实现,并深度复盘 Kaggle 云端 GPU 迁移中的只读文件系统、Git LFS 假死、GPU 架构兼容与跨设备张量对齐四大经典工程踩坑。文末联动 AstrBot Agent 微信机器人实现 GPU 训练定时看护。

一、前言:从「文本接龙」到「听懂人话」

在完成大语言模型的**预训练(Pre-training)**阶段后,模型已经通过海量无监督语料(如网页、书籍、维基百科)掌握了丰富的语言统计分布与世界知识。然而,此时的模型本质上依然只是一个「文本接龙机器」——当你向它提问 “如何用 Python 实现二分查找?” 时,它可能不会直接给出答案,而是顺着你的句子继续续写出 “?很多初学者在面试中经常会遇到这个问题……”

要让模型从无序续写转变为能够遵循人类意图、理解多轮对话结构并给出清晰回答的 AI 助手,就必须进行 SFT(Supervised Fine-Tuning,监督式指令微调)

在之前的阶段,我们成功自研复现并预训练了一个轻量级中文大模型 myllama2(基座规模 204.2M 参数:vocab=65000, dim=768, layers=12, heads=12, kv_heads=4,预训练验证集交叉熵损失 val_loss 稳定收敛至 4.30)。

本文将完整复盘 myllama2 从预训练阶段正式迈入 SFT 指令微调阶段的端到端技术实现,重点涵盖:

  1. SFT 数据管道与 Loss Masking 机制:为什么指令微调中绝不能对 Prompt 计算损失?
  2. 原生 LoRA / QLoRA 适配器手写实现:如何以仅 0.24% 的可训练参数实现高效调优?
  3. Kaggle Tesla T4 云端迁移与四大硬核避坑实录:只读系统、Git LFS 假死、算子架构不兼容与跨设备张量对齐。
  4. AI 智能运维联动:如何利用此前部署的 AstrBot 微信机器人充当 GPU 训练「云管家」,防止免费算力超时烧尽。

二、SFT 数据管道与 Loss Masking 机制

1. 标准化指令对话模板

在指令微调中,输入数据通常包含三个字段:instruction(指令任务)、input(补充上下文输入,可为空)与 output(期望的模型回答)。

为了让模型在多轮对话中形成明确的角色感知与边界意识,我们定义了如下标准对话模板:

<s>Human: {instruction}\n输入:{input}\n\nAssistant: {output}</s>

input 为空时,模板自适应简化为:

<s>Human: {instruction}\n\nAssistant: {output}</s>

其中:

  • <s>:序列起始符(BOS Token,ID=1);
  • </s>:序列结束符(EOS Token,ID=2);
  • 统一的换行符与标记能够帮助模型建立清晰的意图识别与回答终止习惯。

2. 为什么必须采用 Loss Masking?

在预训练阶段,因果语言模型(Causal LM)对序列中的每一个 Token 都会计算交叉熵损失(Next-Token Prediction)。

但在 SFT 阶段,这种做法是严重错误的

  • 如果对 Human: ... 提问部分也计算损失,模型就会花费大量参数容量去「死记硬背」用户的提问句式与提问用词;
  • 我们真正期望模型学习的是:在给定人类提问的前提下,如何推理并生成合理的 Assistant 答案

因此,必须引入 Loss Masking(损失掩码机制):将输入序列中所有属于 Prompt(包括 Human 提示词、用户提问内容、以及序列末尾的 Padding 填充区)对应的目标标签全部设为 -100

在 PyTorch 的 nn.CrossEntropyLoss(ignore_index=-100) 计算中,标签值为 -100 的位置会被底层 CUDA 算子自动忽略,梯度仅对 Assistant 输出区域进行反向传播

Token 序列:   [ <s>, Human:, 帮我写一首诗, \n\n, Assistant:, 白日依山尽, ..., </s>, <pad>, <pad> ]
Input IDs:   [  1 ,  5120 ,   12840  ,  302 ,    8492   ,   29104  , ...,  2  ,   0  ,   0   ]
Target Labels:[ -100, -100 ,   -100   , -100,    -100   ,   29104  , ...,  2  , -100 , -100  ]
                                                        |____________________|
                                                         仅对 Response 计算 Loss

3. 数据集与掩码生成核心实现

data/dataset.py 中,我们实现了支持动态截断、智能分词与自动掩码构造的 SFTDataset

import json
import torch
from torch.utils.data import Dataset

class SFTDataset(Dataset):
    """支持 Loss Masking 的指令微调数据集"""

    def __init__(self, data_path, tokenizer, max_seq_len=512):
        self.tokenizer = tokenizer
        self.max_seq_len = max_seq_len
        self.samples = []

        with open(data_path, 'r', encoding='utf-8') as f:
            for line in f:
                if line.strip():
                    self.samples.append(json.loads(line))

    def __len__(self):
        return len(self.samples)

    def __getitem__(self, idx):
        item = self.samples[idx]
        instruction = item.get("instruction", "")
        input_text = item.get("input", "")
        output_text = item.get("output", "")

        # 1. 拼接 Human Prompt 与 Assistant 响应
        if input_text:
            prompt = f"<s>Human: {instruction}\n输入:{input_text}\n\nAssistant: "
        else:
            prompt = f"<s>Human: {instruction}\n\nAssistant: "
        response = f"{output_text}</s>"

        # 2. 分别编码 Prompt 与 Response
        prompt_ids = self.tokenizer.encode(prompt, add_special_tokens=False)
        response_ids = self.tokenizer.encode(response, add_special_tokens=False)

        input_ids = prompt_ids + response_ids
        # Prompt 区域打上 -100 掩码,Response 区域保留真实 Token ID
        labels = [-100] * len(prompt_ids) + list(response_ids)

        # 3. 截断与 Padding
        if len(input_ids) > self.max_seq_len:
            input_ids = input_ids[:self.max_seq_len]
            labels = labels[:self.max_seq_len]

        pad_len = self.max_seq_len - len(input_ids)
        if pad_len > 0:
            pad_id = self.tokenizer.pad_token_id or 0
            input_ids = input_ids + [pad_id] * pad_len
            labels = labels + [-100] * pad_len  # Padding 部分同样忽略

        return {
            "input_ids": torch.tensor(input_ids, dtype=torch.long),
            "labels": torch.tensor(labels, dtype=torch.long),
        }

三、原生 LoRA / QLoRA 核心架构手写实现

如果对 2 亿参数的基座模型进行全量微调(Full Fine-Tuning),不仅需要保存完整的优化器状态(AdamW 中每个参数需要 8 字节额外开销),还极易破坏预训练学到的通用泛化特征(灾难性遗忘)。

我们选择通过原生代码手写实现 LoRA(Low-Rank Adaptation,低秩适配)

1. 数学原理与低秩分解

对于预训练模型中任意一个固定的线性层权重矩阵 W ∈ ℝ^(d × k),LoRA 冻结原始权重 W,并通过并联两个极低秩的投影矩阵 A ∈ ℝ^(r × k)B ∈ ℝ^(d × r) 来模拟权重的增量变化:

W_new = W + ΔW = W + (α / r) · (B × A)

其中:

  • r 为低秩维度(Rank,通常取 8、16),满足 r ≪ min(d, k)
  • α 为缩放因子(Scaling factor),用于调节适配器权重对原始输出的影响程度;
  • 初始化技巧
    • A 矩阵采用 Kaiming 均匀分布初始化;
    • B 矩阵采用 全零初始化(Zeros)
    • 这样可以严格保证在训练初始步(Step 0)时,ΔW = B × A = 0,模型输出与原始基座完全等价,避免微调初期产生震荡。
输入特征 x ──┬─────────────────── W (冻结不更新) ───────────────────> (+) ──> 输出 y
             │                                                        ^
             └─> A (r × k, Kaiming Init) ─> B (d × r, Zero Init) ──(α/r)─┘

2. LoRA 核心层与注入逻辑 (models/lora.py)

models/lora.py 中,我们手写了 LoRALinear 层,并实现了自动遍历模型结构、将注意力机制中的 wq, wk, wv, wo 线性层动态替换为 LoRA 层的注入工具:

import math
import torch
import torch.nn as nn

class LoRALinear(nn.Module):
    """原生 LoRA 线性层包装器"""

    def __init__(self, base_layer: nn.Linear, rank: int = 8, lora_alpha: float = 32.0, lora_dropout: float = 0.05):
        super().__init__()
        self.base_layer = base_layer
        self.rank = rank
        self.lora_alpha = lora_alpha
        self.scaling = lora_alpha / rank

        # 冻结基座权重
        self.base_layer.weight.requires_grad = False
        if self.base_layer.bias is not None:
            self.base_layer.bias.requires_grad = False

        in_features = base_layer.in_features
        out_features = base_layer.out_features

        # 定义可训练低秩矩阵
        self.lora_A = nn.Parameter(torch.empty(rank, in_features))
        self.lora_B = nn.Parameter(torch.zeros(out_features, rank))
        self.dropout = nn.Dropout(p=lora_dropout) if lora_dropout > 0.0 else nn.Identity()

        # 初始化 A 矩阵
        nn.init.kaiming_uniform_(self.lora_A, a=math.sqrt(5))

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        # 1. 原始冻结层的正向传播
        base_out = self.base_layer(x)
        # 2. LoRA 低秩旁路计算: (x @ A^T) @ B^T * scaling
        lora_out = (self.dropout(x) @ self.lora_A.T) @ self.lora_B.T * self.scaling
        return base_out + lora_out

    def merge_weights(self):
        """推理阶段:将 LoRA 权重一键合并进 Base Layer,消除推理额外延迟"""
        if not self.base_layer.weight.requires_grad:
            delta_w = (self.lora_B @ self.lora_A) * self.scaling
            self.base_layer.weight.data += delta_w.to(self.base_layer.weight.device)

通过注入函数 apply_lora(model, rank=8, alpha=32, targets=['wq', 'wk', 'wv', 'wo']),模型在注意力投影层全部挂载适配器。

3. 参数量极致压缩

在我们的 204.2M 模型上,参数量统计如下:

  • 基座总参数量204,159,744
  • LoRA 可训练参数量491,520
  • 可训练参数占比:仅 0.24%
  • 保存体积:微调后生成的 adapter_model.safetensors 仅约 3.0 MB(相较于 777 MB 的全量基座,存储开销直降 99.6%)。

四、训练器适配与轻量 Checkpoint

training/trainer.py 中,我们针对微调场景对训练器进行了专项重构:

  1. 自动混合精度适配:检测到 Tesla T4 GPU(Turing 架构不支持硬件级 bfloat16 算子)时,训练器能够自适应切换为 float16 混合精度与 GradScaler,确保算力满载且无下溢风险;
  2. 轻量 Checkpoint 覆写:覆写 FineTuningTrainer.save_checkpoint(),微调保存时仅导出 LoRA 的 adapter_model.safetensorsadapter_config.json,不再重复 dump 庞大的基座权重,将单次 Checkpoint 耗时从数秒缩短至几十毫秒。

五、Kaggle GPU 迁移与四大硬核避坑实录

本地配备的显卡显存有限,为了加速完整训练集(25 万条高质量中文指令语料)的迭代,我们将训练流水线整体迁移至 Kaggle Notebook(Tesla T4 16GB GPU)

在迁移调试过程中,我们攻克了四个具有高度通用性的工程难题:

                               Kaggle 迁移四大难关
 ┌───────────────────────┬────────────────────────┬────────────────────────┬───────────────────────┐
 │ 1. 只读文件系统报错   │ 2. Git LFS 假死卡死    │ 3. P100 架构算子缺失   │ 4. 跨设备张量不对齐   │
 │   [Errno 30] Read-    │   815MB 权重拉取假死   │   CUDA kernel error    │   mat2 is on cpu      │
 │   only file system    │   带宽打满超时         │   sm_60 被新 PyTorch   │   Base在GPU / LoRA在  │
 │                       │                        │   放弃支持             │   CPU                 │
 └───────────────────────┴────────────────────────┴────────────────────────┴───────────────────────┘
            │                       │                        │                       │
            ▼                       ▼                        ▼                       ▼
  同步至 /kaggle/working   GIT_LFS_SKIP_SMUDGE=1    切换加速器为 T4 x 2      优化 LoRALinear 并显式
  工作区后安全读写         改从本地 Input 快速拷贝  (Turing sm_75 完美兼容)  model.to(device)

关卡 1:/kaggle/input 只读文件系统报错

🔴 报错现象

直接在 Kaggle 上克隆并运行微调脚本时,程序抛出系统级错误:

OSError: [Errno 30] Read-only file system: '/kaggle/input/myllama2/models/checkpoints'

🔍 原因分析

Kaggle 的 /kaggle/input 挂载点属于只读存储卷(Read-Only Mount),用于保障数据源的纯净性。任何在其中创建目录、写临时文件或生成 checkpoint 的操作都会被操作系统内核直接拦截。

🛠️ 解决方案

在 Notebook 启动单元格中,先将项目代码与依赖文件同步到可读写的 /kaggle/working 目录:

# 复制到工作区
cp -r /kaggle/input/myllama2-code /kaggle/working/myllama2
cd /kaggle/working/myllama2

关卡 2:Git LFS 815MB 大文件拉取假死

🔴 报错现象

在 Kaggle 终端通过 git fetchgit reset --hard 同步仓库时,命令行长时间停滞无任何日志输出,最终触发超时中断。

🔍 原因分析

我们的 GitHub 仓库中托管了 777MB 的预训练基座权重文件(model.safetensors),由 Git LFS 追踪管理。Kaggle 容器在执行 git 操作时会自动触发 LFS smudge 过滤器,从海外 LFS 节点拉取大文件,因带宽限制发生假死。

🛠️ 解决方案

设置环境变量跳过 LFS 大文件拉取,改由直接从 Kaggle Dataset 挂载的 Input 目录拷贝真实权重:

# 1. 禁用 Git LFS 自动下载
export GIT_LFS_SKIP_SMUDGE=1
git reset --hard origin/main

# 2. 从 Kaggle 本地 Input 极速拷贝预训练权重
mkdir -p models/checkpoints/best
cp /kaggle/input/myllama2-pretrained-weights/model.safetensors models/checkpoints/best/

关卡 3:Tesla P100 架构算子缺失(CUDA error: no kernel image

🔴 报错现象

在选择默认的 Tesla P100 加速卡运行训练时,前向传播直接崩溃:

RuntimeError: CUDA error: no kernel image is available for execution on the device
CUDA kernel errors might be asynchronously reported at some other API call...

🔍 原因分析

Kaggle Notebook 预装了最新版 PyTorch(基于 CUDA 12.x 编译),而 Tesla P100 采用的是较老的 Pascal 架构(Compute Capability sm_60,新版 PyTorch 官方二进制包在编译时已经移除了对 sm_60 架构的预编译算子核函数。

🛠️ 解决方案

在 Kaggle 右侧控制面板的 Settings ➔ Accelerator 中,将 GPU 类型切换为 GPU T4 x 2。Tesla T4 采用 Turing 架构(sm_75),拥有完整的 Tensor Core 支持与最新的 PyTorch 算子兼容性。


关卡 4:跨设备张量不对齐(Expected all tensors to be on same device

🔴 报错现象

启动微调的第一步迭代时,矩阵乘法报错:

RuntimeError: Expected all tensors to be on the same device, but found at least two devices, cuda:0 and cpu! (when checking argument for argument mat2 in method wrapper_CUDA_mm)

🔍 原因分析

在脚本逻辑中,基座模型首先执行了 model.to(device) 转移至 GPU 显存;但在随后调用 apply_lora(model) 动态替换子模块时,新实例化的 LoRALinear 内部的 lora_Alora_B 参数默认创建在 CPU 内存中。在前向计算 (x @ A.T) 时,GPU 上的激活值遇到 CPU 上的权重,引发设备不匹配崩溃。

🛠️ 解决方案

LoRALinear 初始化中自动读取基座所在设备,并在完成全部 LoRA 模块注入后显式执行二次全局设备同步:

# 动态注入 LoRA
model = apply_lora(model, rank=8, alpha=32)
# 强制确保所有新增 LoRA 参数完整载入目标 GPU
model = model.to(device)

六、云端训练实测指标与运行日志

经过上述工程优化后,SFT 微调在 Kaggle Tesla T4 GPU 上稳定运行,关键性能表现如下:

  • GPU 计算利用率100% 满载稳定运行;
  • 显存占用:约 3.0 GB / 15.0 GB(得益于仅加载 204M 模型与轻量 LoRA 参数,显存极其充裕);
  • 单步耗时:约 6.2 ~ 6.3 秒 / 步(每步包含 16 次 micro-batch 前向反向,有效批次为 32 条长序列样本);
  • 收敛曲线:从 Step 0 的初始 Loss 4.3607 迅速下降,在 Step 80 即降至 4.1523,模型开始展现出良好的指令遵循倾向。

以下为 Kaggle 云端微调训练启动时的实测日志截图:

Kaggle GPU 微调实测运行日志


七、联动 Agent 智能助手:让 AstrBot 成为 GPU「云管家」

Kaggle 为每位开发者每周提供约 30 小时的免费 GPU 算力配额。在模型微调过程中,训练任务通常需要执行数小时,开发者往往容易在离开电脑后遗忘关闭实例,导致宝贵的每周 GPU 额度被白白空耗。

为了解决这个问题,我们联动了此前搭建的基于 AstrBot + Gemini 的微信智能运维助手(ClawBot)。

在 Kaggle 上启动训练并估算好耗时后,直接在微信聊天框中给智能助手发送自然语言指令:

19点整提醒我关闭 Kaggle 上的 GPU

🤖 ClawBot(AstrBot 智能助手)

好嘞!定个 19:00 的闹钟,到点提醒你关 Kaggle GPU ⏰
搞定!✅ 已定好 今天 19:00 提醒你关闭 Kaggle GPU(约 1 小时 41 分钟后)🛑
到点我会提醒你:去 Kaggle 的 Settings -> Accelerator 关掉 GPU,别让额度白烧 💸
放心忙别的吧,到点见!😄

AstrBot 微信智能助手定时提醒看护 GPU

这一贴心联动不仅实现了「大模型训练」与「个人智能体助手」在现实开发中的生动交汇,也真正让 AI 融入了日常工程工作流。


八、总结与代码清单

本次实践完成了中文 LLaMA2 (204M) 从预训练基座到 SFT 对话模型的关键跨越:

  1. 机制明晰:通过 Loss Masking 确保模型只聚焦 Assistant 回答生成,避免 Prompt 噪声污染;
  2. 轻量高效:手写 LoRA 仅以 0.24% 的参数增量实现指令适配,权重存储极轻量;
  3. 工程健壮:总结并攻克了 Kaggle 云端运行时的只读目录、大文件下载、GPU 兼容与张量设备同步四大关卡;
  4. 工具闭环:结合 AstrBot 智能助手看护算力资源,打造高效省心的研发体验。

核心产出代码结构

myllama2/
├── data/
│   └── dataset.py              # SFT 对话数据集与 Loss Masking 实现
├── models/
│   ├── llama.py                # LLaMA2 204M 基座模型架构
│   └── lora.py                 # 原生 LoRA / QLoRA 核心层与注入合并工具
├── training/
│   └── trainer.py              # 混合精度与 LoRA 轻量 Checkpoint 训练器
├── scripts/
│   ├── finetune.py             # SFT 微调执行主入口
│   └── chat.py                 # 终端多轮指令交互验证脚本
└── kaggle_sft_finetune.ipynb   # Kaggle 云端一键执行 Notebook

后续我们将进一步探索 DPO(直接偏好优化)与强化学习对齐技术,让微调后的小模型在推理与专业领域问答中展现更出色的表现!

Share:
Back to Blog

Related Posts

View All Posts »