Skip to content

在 AIStudio 训练任务中使用 LLaMA Factory 微调 Qwen3-8B

本教程使用一个 AIStudio Worker 和一张 A100 GPU,通过 LLaMA Factory 对 Qwen3-8B 进行 BF16 LoRA SFT。训练数据来自 BANKING77:模型读取一条客户请求,并学习返回对应的 JSON 意图。

本文创建一个训练任务。启动脚本先检查模型、配置、输出路径、CUDA 和 GPU,然后运行 100 个训练步骤,并在新进程中重新加载 adapter。

实战目标

完成本教程后,可以确认:

  • LLaMA Factory 能从 /infini-data 加载 Qwen3-8B,并从共享存储读取训练数据;
  • Qwen3 非思考模式能够完成 LoRA 训练并保存 checkpoint;
  • 任务结束后,adapter、训练结果和 checkpoint 仍可从共享存储读取;
  • 同一任务中的新 Python 进程能够重新加载基础模型和 adapter,并生成响应。

完成本文后,您会得到可重新加载的 adapter 和训练记录。上线前,按照完成训练后的下一步使用独立测试集评估效果。

场景信息

本文使用以下配置:

  • 软件与镜像

    • LLaMA Factory:0.9.5
    • 上游镜像:docker.io/hiyouga/llamafactory:0.9.5
    • 本次实测的 linux/amd64 镜像摘要:sha256:7885b7c590e3d5e78b001623f0371fdfc059f13aa64f4832826576add06b2a7e
    • 镜像运行时:PyTorch 2.6.0、CUDA 12.4.1
  • 模型与数据

    • 基础模型:宁夏 B 公共数据中的 /infini-data/Qwen3-8B,约 16 GiB
    • 数据集:PolyAI-LDN/task-specific-datasets 的固定提交 9d081458ff52e53cf7e848f414e6e9344e4e6696
    • 数据量:1540 条训练数据,231 条验证数据
  • 训练配置

    • 训练方式:BF16 LoRA,rank 8,lora_target: all
    • 资源:单机、1 个 Worker、1 张 A100-80G、RDMA 关闭
    • 训练长度:100 步

如果 /infini-data/Qwen3-8B 不存在,需要提前把约 16 GiB 模型权重放入当地共享存储,并修改训练配置和 run-qwen3-lora.sh 中的模型路径。不要在训练任务启动后再下载模型。

数据准备只需要 CPU,建议在 AICoder 中完成,不必启动占用 GPU 的开发机。

本场景在 AIStudio 中怎样运行

AICoder 只负责准备数据、配置和脚本,以及在任务结束后检查共享存储中的产物,不占用 GPU。实际训练由一个 AIStudio 训练任务完成。

训练任务创建 1 个 Worker,使用 1 张 GPU,并在 Worker 中直接运行 LLaMA Factory,不使用 Ray、RDMA 或多 Worker 分布式训练:

language-text
AICoder(CPU)
├── 准备 BANKING77、训练配置和启动脚本
└── 检查共享存储中的训练产物
          ↕ 同一块共享存储
AIStudio 训练任务 / Worker 0 / GPU 0
├── 检查模型、配置、CUDA 和 GPU
├── 运行 100 步 BF16 LoRA SFT
└── 重新加载 adapter → runs/training-001

训练任务从 /infini-data/Qwen3-8B 读取基础模型。

同一块共享存储在 AICoder 和训练任务中均挂载为 /mnt/llamafactory-reproduction。任务结束后,Worker 和 GPU 可以释放,数据与训练产物仍保留在共享存储中。

训练任务会在新的 Python 进程中重新加载 adapter。需要提供 API 或比较效果时,将 adapter 部署到推理服务,再使用同一评测集对基础模型和 adapter 做 A/B。

开始前准备

在目标可用区准备容器镜像

本场景固定使用 docker.io/hiyouga/llamafactory:0.9.5,使后续命令和训练配置始终使用同一组 LLaMA Factory、PyTorch 和 CUDA 依赖。

信息

为什么选择 LLaMA Factory 0.9.5

LLaMA Factory 0.9.5 镜像内置 PyTorch 2.6.0 和 CUDA 12.4.1。按照 NVIDIA 的 CUDA 兼容性说明,相比 CUDA 13.x,CUDA 12.x 对节点驱动的版本要求更低,更容易适配不同的 AIStudio 资源池。因此,本文不直接使用基于 CUDA 13.x 的更新镜像。

重要

先复制到目标可用区的租户 Registry

创建 AIStudio 训练任务前,先把上游镜像复制或导入所选可用区的租户 Registry,并使用自己的 <tenant-id>

language-text
cr.infini-ai.com/<tenant-id>/llamafactory:0.9.5

需要从远程 Registry 复制镜像时,在申请 GPU 前按照使用 regctl 将远程镜像复制到目标可用区 Registry完成操作;其它导入方式见准备并验证容器镜像

在镜像中心确认 LLaMA Factory 0.9.5 镜像位于训练任务所在可用区,并且 可用服务 包含任务。

准备 BANKING77 数据

ModelScope 的 modelscope/banking77 是为 SPACE 意图识别模型重新整理的版本,并非上游原始文件的镜像。它使用 train、valid、test 三个划分和嵌套的对话字段;本文的转换脚本则读取上游 train、test 两个 CSV 文件中的 textcategory 字段。因此,本文直接固定上游提交。ModelScope 版本也可以使用,但需要另写转换脚本,不能直接套用本文命令。

本文假设同一块共享存储在 AICoder 和训练任务中均挂载为 /mnt/llamafactory-reproduction

在 AICoder 中检查共享存储和公共模型:

language-shell
set -e

export SHARED_ROOT=/mnt/llamafactory-reproduction
export WORK_ROOT="$SHARED_ROOT/qwen3-lora"
export MODEL_ROOT=/infini-data/Qwen3-8B

mkdir -p "$WORK_ROOT"
test -w "$WORK_ROOT"
test -r "$MODEL_ROOT/config.json"
test -r "$MODEL_ROOT/tokenizer_config.json"
test -r "$MODEL_ROOT/model.safetensors.index.json"
df -hT "$SHARED_ROOT"

至少预留 25 GiB 可用空间。然后下载 BANKING77 的固定版本:

language-shell
set -e

export WORK_ROOT=/mnt/llamafactory-reproduction/qwen3-lora
export DATASET_COMMIT=9d081458ff52e53cf7e848f414e6e9344e4e6696
export SOURCE_ROOT="$WORK_ROOT/source/task-specific-datasets"

test ! -e "$SOURCE_ROOT"
git clone https://github.com/PolyAI-LDN/task-specific-datasets.git "$SOURCE_ROOT"
git -C "$SOURCE_ROOT" checkout --detach "$DATASET_COMMIT"
test "$(git -C "$SOURCE_ROOT" rev-parse HEAD)" = "$DATASET_COMMIT"
(
  cd "$SOURCE_ROOT"
  printf '%s  %s\n' \
    b06e26ac675513959a63135f11b94ea7786ed02da65db93a5650d8838cbc664b \
    banking_data/train.csv \
    d12d6e3bc4c3103966ae786dc435913c0c563dfa328f5a3646d0e62cfeeb474d \
    banking_data/test.csv | sha256sum -c -
)

附录中的 prepare_banking77.py 保存为 /mnt/llamafactory-reproduction/qwen3-lora/prepare_banking77.py。该脚本从每个意图中选取 20 条训练数据和 3 条验证数据,并生成 LLaMA Factory 使用的 ShareGPT 文件。

运行转换脚本:

language-shell
set -e

export WORK_ROOT=/mnt/llamafactory-reproduction/qwen3-lora
python3 -m py_compile "$WORK_ROOT/prepare_banking77.py"
python3 "$WORK_ROOT/prepare_banking77.py" \
  --source "$WORK_ROOT/source/task-specific-datasets" \
  --output "$WORK_ROOT/data/banking77"
python3 -m json.tool "$WORK_ROOT/data/banking77/dataset_info.json" >/dev/null

最后一行输出应为:

language-json
{"train": 1540, "validation": 231, "intents": 77}

如果需要更改抽样数量或 prompt,请使用新的输出目录,不要直接修改已经用于训练的数据。

保存训练配置和启动脚本

将下面的配置保存为 /mnt/llamafactory-reproduction/qwen3-lora/qwen3-lora.yaml

language-yaml
model_name_or_path: /infini-data/Qwen3-8B
trust_remote_code: true
flash_attn: disabled

stage: sft
do_train: true
do_eval: true
finetuning_type: lora
lora_rank: 8
lora_alpha: 16
lora_dropout: 0.0
lora_target: all

dataset_dir: /mnt/llamafactory-reproduction/qwen3-lora/data/banking77
dataset: banking77_sft_train
eval_dataset: banking77_sft_validation
template: qwen3
enable_thinking: false
preserve_thinking: false
cutoff_len: 2048
packing: false
train_on_prompt: false
mask_history: false
preprocessing_num_workers: 4
dataloader_num_workers: 2

output_dir: /mnt/llamafactory-reproduction/qwen3-lora/runs/training-001
logging_strategy: steps
logging_steps: 1
logging_nan_inf_filter: false
save_strategy: steps
save_steps: 100
save_total_limit: 1
plot_loss: false
overwrite_output_dir: false
save_only_model: false
report_to: none

per_device_train_batch_size: 1
per_device_eval_batch_size: 1
gradient_accumulation_steps: 8
learning_rate: 1.0e-4
optim: adamw_torch
weight_decay: 0.0
max_steps: 100
lr_scheduler_type: cosine
warmup_ratio: 0.1
bf16: true
gradient_checkpointing: true
max_grad_norm: 1.0
eval_strategy: "steps"
eval_steps: 100
seed: 42
ddp_timeout: 180000000
resume_from_checkpoint: null

max_steps: 100 用于完成一次耗时可控的训练。换成业务数据后,根据独立验证集指标选择 checkpoint 和实际训练步数。

再从附录保存以下两个文件:

  • /mnt/llamafactory-reproduction/qwen3-lora/check_adapter.py:训练结束后,在新进程中重新加载 adapter;
  • /mnt/llamafactory-reproduction/qwen3-lora/run-qwen3-lora.sh:检查运行环境,执行 100 步训练,并在训练后调用加载检查。

在分配 GPU 前检查这些文件:

language-shell
set -e

export WORK_ROOT=/mnt/llamafactory-reproduction/qwen3-lora
python3 -m py_compile "$WORK_ROOT/prepare_banking77.py" "$WORK_ROOT/check_adapter.py"
bash -n "$WORK_ROOT/run-qwen3-lora.sh"
chmod 555 "$WORK_ROOT/run-qwen3-lora.sh"

运行这个场景

Step 1 创建并运行训练任务

在 AIStudio 中选择 训练任务,然后设置:

  1. 资源类型:选择 spot
  2. 可用区:选择能够访问模型、镜像和共享存储的可用区;本文使用宁夏 B。
  3. 镜像:选择前面准备的 LLaMA Factory 0.9.5 租户镜像。
  4. Worker 规格:选择包含 1 张 A100-80G 的规格。
  5. Worker 数量:填写 1
  6. 分布式框架:选择 单机
  7. RDMA 配置:保持关闭。
  8. 挂载公共数据,确认容器能够读取 /infini-data/Qwen3-8B
  9. 挂载准备数据时使用的共享存储,把“容器内访问地址”设为 /mnt/llamafactory-reproduction
  10. 任务可视化:保持关闭。

本文训练配置使用 report_to: none,因此保持任务可视化关闭。使用 TensorBoard 时,准备包含 TensorBoard 的镜像,修改 report_to,并使用新的输出目录运行训练。

启动命令 中填写:

language-shell
exec /bin/bash /mnt/llamafactory-reproduction/qwen3-lora/run-qwen3-lora.sh

提交前,在配置摘要中核对镜像、1 个 Worker、1 张 GPU、共享存储路径和启动命令。启动脚本会先检查模型、配置和输出路径,再确认 CUDA 以及一张 GPU,然后开始训练。任一检查失败时,任务会在训练前退出。

任务正常结束后,在 AICoder 中检查输出:

language-shell
set -e

export RUN_ROOT=/mnt/llamafactory-reproduction/qwen3-lora/runs/training-001
test -r "$RUN_ROOT/adapter_config.json"
test -r "$RUN_ROOT/adapter_model.safetensors"
test -r "$RUN_ROOT/checkpoint-100/optimizer.pt"
test -r "$RUN_ROOT/eval_results.json"
test -r "$RUN_ROOT/reload-result.json"
python3 -c 'import json,sys; value=json.load(open(sys.argv[1])); assert value["global_step"] == 100; print("global_step=100")' \
  "$RUN_ROOT/trainer_state.json"
python3 -m json.tool "$RUN_ROOT/train_results.json"
python3 -m json.tool "$RUN_ROOT/eval_results.json"
python3 -m json.tool "$RUN_ROOT/reload-result.json"

任务状态正常、上述文件都存在且 adapter 重新加载成功后,训练流程已经完成。adapter 位于共享存储中,不依赖原来的训练进程。最后在任务详情中确认 Worker 和 GPU 已释放。

Step 2 对照参考结果

使用本文的模型、数据、镜像和 A100-80G 配置完成 100 步训练后,可以用以下结果检查运行是否存在明显偏差:

  • 1771 条样本的 token 数:最少 448,p50 458,p95 479,最多 529;
  • 训练 loss:约 0.35;
  • 验证 loss:约 0.12;
  • 训练耗时:约 4 分 24 秒;
  • 峰值 GPU 显存:约 22 GiB;
  • 最终 checkpoint:checkpoint-100
  • adapter 能够在新的 Python 进程中重新加载。

这些数值用于排查明显异常,不是必须精确匹配的验收阈值。更换 GPU、镜像、模型、数据或训练参数后,重新记录 loss、训练耗时和显存峰值。

Step 3 获取训练产物

100 步训练的输出位于:

language-text
/mnt/llamafactory-reproduction/qwen3-lora/runs/training-001/

用于后续推理或评测时,至少保留:

  • adapter_config.jsonadapter_model.safetensors
  • trainer_state.jsontrain_results.jsoneval_results.json
  • 如果需要续训,保留 checkpoint-100/

通过获取训练结果下载或复制这些文件。不再需要续训时,可以删除 checkpoint 以节省空间;删除前确认团队没有依赖该目录的评测或审计任务。

排查常见问题

  • 更换了镜像、GPU、模型或训练配置:需要短跑排障时,复制训练配置,换用新的 output_dir,并把 max_stepssave_stepseval_steps 改为 2。短跑通过后,再使用 100 步配置创建正式任务。
  • 镜像无法启动或 CUDA 不可用:确认任务选择了 LLaMA Factory 0.9.5 镜像和 A100 规格。CUDA 仍不可用时,改用兼容的镜像或资源池,再执行上面的短跑排障。
  • 找不到脚本或数据:检查共享存储的“容器内访问地址”是否为 /mnt/llamafactory-reproduction。路径不一致时,任务会看不到 AICoder 中准备的文件。
  • 模型加载后、训练开始前报错:检查 dataset_info.jsontemplate: qwen3enable_thinking: false 和模型文件是否完整。
  • 显存不足:先确认任务只看到一张 GPU,并且使用 A100-80G。在更小的 GPU 上使用短跑配置测量显存;显存仍然不足时,停止任务并改用更大显存的 GPU,或准备经过独立验证的低显存配置。
  • 任务显示成功但缺少 adapter:检查配置中的 output_dir 和共享存储挂载。任务状态不能代替产物检查。
  • adapter 重新加载失败:确认 adapter 来自同一个 Qwen3-8B 基础模型,并检查两个 adapter 文件是否完整。

完成训练后的下一步

  • 评估业务效果:使用固定的独立测试集比较基础模型和 adapter,同时检查通用能力是否回退。
  • 确定训练长度:根据验证集指标选择 checkpoint 和训练步数,不直接把 100 步用于业务训练。
  • 更换运行环境:为新的 GPU、镜像、模型、序列长度或 batch 组合使用独立输出目录,并重新记录显存和耗时基线;需要时先使用排障短跑。
  • 扩展到多 Worker:按照使用 LLaMA Factory 启动多 Worker 训练把 AIStudio 的 Worker 变量映射到 LLaMA Factory 启动器,并先用少量 step 验证 rank、通信和共享存储输出。
  • 更换训练方式:为 QLoRA、全参数训练、DeepSpeed、FSDP 或 RDMA 准备并验证独立配置。
  • 部署推理服务:加载训练得到的 adapter,并测试正确率、延迟、吞吐和并发。

复制辅助脚本

下面三个文件用于准备数据、启动训练和检查 adapter。正文只保留调用方式;需要创建文件时再查看对应内容。

保存 prepare_banking77.py

language-python
#!/usr/bin/env python3
import argparse
import csv
import json
from collections import defaultdict
from pathlib import Path


def read_rows(path: Path) -> list[dict[str, str]]:
    with path.open(encoding="utf-8", newline="") as stream:
        rows = list(csv.DictReader(stream))
    if not rows or set(rows[0]) != {"text", "category"}:
        raise RuntimeError(f"Unexpected CSV columns: {path}")
    return rows


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--source", type=Path, required=True)
    parser.add_argument("--output", type=Path, required=True)
    args = parser.parse_args()

    train_rows = read_rows(args.source / "banking_data/train.csv")
    validation_rows = read_rows(args.source / "banking_data/test.csv")
    if len(train_rows) != 10003 or len(validation_rows) != 3080:
        raise RuntimeError("Unexpected BANKING77 row count")

    def group(rows: list[dict[str, str]]) -> dict[str, list[str]]:
        grouped: dict[str, list[str]] = defaultdict(list)
        for row in rows:
            grouped[row["category"]].append(row["text"])
        return grouped

    train_groups = group(train_rows)
    validation_groups = group(validation_rows)
    labels = sorted(train_groups)
    if len(labels) != 77 or set(labels) != set(validation_groups):
        raise RuntimeError("Expected the same 77 intents in both splits")

    system = (
        "Route the customer request to exactly one BANKING77 intent. "
        f"Allowed intents: {', '.join(labels)}. "
        'Return exactly one JSON object with one key named "intent" and one '
        "allowed intent string value. Do not include Markdown, explanation, or any other key."
    )

    def convert(groups: dict[str, list[str]], count: int) -> list[dict[str, object]]:
        return [
            {
                "messages": [
                    {"role": "system", "content": system},
                    {"role": "user", "content": text},
                    {
                        "role": "assistant",
                        "content": json.dumps({"intent": label}, separators=(",", ":")),
                    },
                ]
            }
            for label in labels
            for text in groups[label][:count]
        ]

    train = convert(train_groups, 20)
    validation = convert(validation_groups, 3)
    if len(train) != 1540 or len(validation) != 231:
        raise RuntimeError("Unexpected processed row count")

    dataset_info = {
        "banking77_sft_train": {
            "file_name": "train-sharegpt.json",
            "formatting": "sharegpt",
            "columns": {"messages": "messages"},
            "tags": {
                "role_tag": "role",
                "content_tag": "content",
                "user_tag": "user",
                "assistant_tag": "assistant",
                "system_tag": "system",
            },
        },
        "banking77_sft_validation": {
            "file_name": "validation-sharegpt.json",
            "formatting": "sharegpt",
            "columns": {"messages": "messages"},
            "tags": {
                "role_tag": "role",
                "content_tag": "content",
                "user_tag": "user",
                "assistant_tag": "assistant",
                "system_tag": "system",
            },
        },
    }

    args.output.mkdir(parents=True, exist_ok=False)
    for name, value in {
        "train-sharegpt.json": train,
        "validation-sharegpt.json": validation,
        "dataset_info.json": dataset_info,
    }.items():
        (args.output / name).write_text(
            json.dumps(value, ensure_ascii=False, separators=(",", ":")) + "\n",
            encoding="utf-8",
        )
    print(json.dumps({"train": len(train), "validation": len(validation), "intents": len(labels)}))


if __name__ == "__main__":
    main()

保存 check_adapter.py

language-python
#!/usr/bin/env python3
import argparse
import json
from pathlib import Path

import torch
from peft import PeftModel
from transformers import AutoModelForCausalLM, AutoTokenizer


parser = argparse.ArgumentParser()
parser.add_argument("--model", required=True)
parser.add_argument("--adapter", required=True)
parser.add_argument("--sample", type=Path, required=True)
args = parser.parse_args()

tokenizer = AutoTokenizer.from_pretrained(args.model, local_files_only=True, trust_remote_code=True)
base = AutoModelForCausalLM.from_pretrained(
    args.model,
    local_files_only=True,
    trust_remote_code=True,
    torch_dtype=torch.bfloat16,
    device_map={"": 0},
)
model = PeftModel.from_pretrained(base, args.adapter, is_trainable=False)
model.eval()

sample = json.loads(args.sample.read_text(encoding="utf-8"))[0]
encoded = tokenizer.apply_chat_template(
    sample["messages"][:2],
    tokenize=True,
    add_generation_prompt=True,
    enable_thinking=False,
    return_tensors="pt",
)
if isinstance(encoded, torch.Tensor):
    input_ids = encoded.to("cuda:0")
    attention_mask = torch.ones_like(input_ids)
else:
    encoded = encoded.to("cuda:0")
    input_ids = encoded["input_ids"]
    attention_mask = encoded.get("attention_mask")
    if attention_mask is None:
        attention_mask = torch.ones_like(input_ids)

with torch.inference_mode():
    output = model.generate(
        input_ids=input_ids,
        attention_mask=attention_mask,
        max_new_tokens=32,
        do_sample=False,
        pad_token_id=tokenizer.eos_token_id,
    )
response = tokenizer.decode(output[0, input_ids.shape[1] :], skip_special_tokens=True).strip()
lower_response = response.lower()
if not response or "<think>" in lower_response or "</think>" in lower_response:
    raise RuntimeError("Adapter reload did not return a usable Non-Thinking response")
print(json.dumps({"adapter_loaded": True, "response": response}, ensure_ascii=False))

保存 run-qwen3-lora.sh

language-bash
#!/usr/bin/env bash
set -Eeuo pipefail

ROOT=/mnt/llamafactory-reproduction/qwen3-lora
MODEL=/infini-data/Qwen3-8B
CONFIG="$ROOT/qwen3-lora.yaml"
OUTPUT="$ROOT/runs/training-001"

test -r "$MODEL/config.json"
test -r "$CONFIG"
test -r "$ROOT/data/banking77/dataset_info.json"
test -r "$ROOT/data/banking77/train-sharegpt.json"
test -r "$ROOT/data/banking77/validation-sharegpt.json"
test -w "$ROOT"
test ! -e "$OUTPUT"
nvidia-smi --query-gpu=driver_version,name --format=csv,noheader
llamafactory-cli version
python3 -c 'import torch; assert torch.cuda.is_available() and torch.cuda.device_count() == 1; print(f"torch={torch.__version__} cuda={torch.version.cuda} gpu={torch.cuda.get_device_name(0)}")'
llamafactory-cli train "$CONFIG"
python3 "$ROOT/check_adapter.py" \
  --model "$MODEL" \
  --adapter "$OUTPUT" \
  --sample "$ROOT/data/banking77/validation-sharegpt.json" \
  > "$OUTPUT/reload-result.json"