在 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
- LLaMA Factory:
模型与数据
- 基础模型:宁夏 B 公共数据中的
/infini-data/Qwen3-8B,约 16 GiB - 数据集:
PolyAI-LDN/task-specific-datasets的固定提交9d081458ff52e53cf7e848f414e6e9344e4e6696 - 数据量:1540 条训练数据,231 条验证数据
- 基础模型:宁夏 B 公共数据中的
训练配置
- 训练方式:BF16 LoRA,rank 8,
lora_target: all - 资源:单机、1 个 Worker、1 张 A100-80G、RDMA 关闭
- 训练长度:100 步
- 训练方式:BF16 LoRA,rank 8,
如果 /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 分布式训练:
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>。
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 文件中的 text 和 category 字段。因此,本文直接固定上游提交。ModelScope 版本也可以使用,但需要另写转换脚本,不能直接套用本文命令。
本文假设同一块共享存储在 AICoder 和训练任务中均挂载为 /mnt/llamafactory-reproduction。
在 AICoder 中检查共享存储和公共模型:
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 的固定版本:
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 文件。
运行转换脚本:
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最后一行输出应为:
{"train": 1540, "validation": 231, "intents": 77}如果需要更改抽样数量或 prompt,请使用新的输出目录,不要直接修改已经用于训练的数据。
保存训练配置和启动脚本
将下面的配置保存为 /mnt/llamafactory-reproduction/qwen3-lora/qwen3-lora.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: nullmax_steps: 100 用于完成一次耗时可控的训练。换成业务数据后,根据独立验证集指标选择 checkpoint 和实际训练步数。
再从附录保存以下两个文件:
/mnt/llamafactory-reproduction/qwen3-lora/check_adapter.py:训练结束后,在新进程中重新加载 adapter;/mnt/llamafactory-reproduction/qwen3-lora/run-qwen3-lora.sh:检查运行环境,执行 100 步训练,并在训练后调用加载检查。
在分配 GPU 前检查这些文件:
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 中选择 训练任务,然后设置:
- 资源类型:选择 spot。
- 可用区:选择能够访问模型、镜像和共享存储的可用区;本文使用宁夏 B。
- 镜像:选择前面准备的 LLaMA Factory 0.9.5 租户镜像。
- Worker 规格:选择包含 1 张 A100-80G 的规格。
- Worker 数量:填写
1。 - 分布式框架:选择 单机。
- RDMA 配置:保持关闭。
- 挂载公共数据,确认容器能够读取
/infini-data/Qwen3-8B。 - 挂载准备数据时使用的共享存储,把“容器内访问地址”设为
/mnt/llamafactory-reproduction。 - 任务可视化:保持关闭。
本文训练配置使用 report_to: none,因此保持任务可视化关闭。使用 TensorBoard 时,准备包含 TensorBoard 的镜像,修改 report_to,并使用新的输出目录运行训练。
在 启动命令 中填写:
exec /bin/bash /mnt/llamafactory-reproduction/qwen3-lora/run-qwen3-lora.sh提交前,在配置摘要中核对镜像、1 个 Worker、1 张 GPU、共享存储路径和启动命令。启动脚本会先检查模型、配置和输出路径,再确认 CUDA 以及一张 GPU,然后开始训练。任一检查失败时,任务会在训练前退出。
任务正常结束后,在 AICoder 中检查输出:
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 步训练的输出位于:
/mnt/llamafactory-reproduction/qwen3-lora/runs/training-001/用于后续推理或评测时,至少保留:
adapter_config.json和adapter_model.safetensors;trainer_state.json、train_results.json和eval_results.json;- 如果需要续训,保留
checkpoint-100/。
通过获取训练结果下载或复制这些文件。不再需要续训时,可以删除 checkpoint 以节省空间;删除前确认团队没有依赖该目录的评测或审计任务。
排查常见问题
- 更换了镜像、GPU、模型或训练配置:需要短跑排障时,复制训练配置,换用新的
output_dir,并把max_steps、save_steps和eval_steps改为2。短跑通过后,再使用 100 步配置创建正式任务。 - 镜像无法启动或 CUDA 不可用:确认任务选择了 LLaMA Factory 0.9.5 镜像和 A100 规格。CUDA 仍不可用时,改用兼容的镜像或资源池,再执行上面的短跑排障。
- 找不到脚本或数据:检查共享存储的“容器内访问地址”是否为
/mnt/llamafactory-reproduction。路径不一致时,任务会看不到 AICoder 中准备的文件。 - 模型加载后、训练开始前报错:检查
dataset_info.json、template: qwen3、enable_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
#!/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
#!/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
#!/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"