#!/usr/bin/env python3 """ ═══════════════════════════════════════════════════════════ Qwen2.5-7B 全参数 SFT 训练入口 · train.py ═══════════════════════════════════════════════════════════ 签发: 铸渊 · ICE-GL-ZY001 · 国作登字-2026-A-00037559 V100 32G × 4 上的 Qwen2.5-7B 全参 SFT. 策略: DeepSpeed ZeRO-3 + 优化器 CPU offload + gradient checkpointing + fp16 启动方式 (由 start-training.sh 调用): deepspeed --num_gpus=4 train.py stdout 协议(被 watch-training-output.sh 解析): ZY_PROGRESS step=N total=M loss=X lr=Y epoch=E total_epochs=TE thr=T 环境变量: ZY_TRAIN_DATA 数据根 (默认 /data/guanghu) ZY_MODEL_DIR 模型路径 (默认 $ZY_TRAIN_DATA/models/Qwen2.5-7B) ZY_DATA_PATH SFT JSONL (默认 $ZY_TRAIN_DATA/processed/sft.jsonl) ZY_OUTPUT_DIR checkpoint 输出 (默认 $ZY_TRAIN_DATA/checkpoints/qwen2_5_7b_sft) ZY_DS_CONFIG DeepSpeed json (默认 server/training-agent/configs/ds_zero3_offload.json) ZY_NUM_EPOCHS 默认 3 ZY_LR 默认 2e-5 ZY_MAX_SEQ_LEN 默认 2048 ZY_PER_DEVICE_BSZ 默认 1 ZY_GRAD_ACCUM 默认 16 ZY_SAVE_STEPS 默认 200 ZY_LOGGING_STEPS 默认 5 ZY_REPORT_EVERY_STEPS 默认 5 (ZY_PROGRESS 协议输出节流) """ from __future__ import annotations import json import math import multiprocessing as _mp import os import sys import time from dataclasses import dataclass from pathlib import Path from typing import Any # ── multi-rank × multi-proc fork 安全护栏 ── # 必须在 import torch / transformers / datasets 之前生效: # 1. 关掉 fast tokenizer 的 Rust 线程池, 避免 fork 后子进程死锁/abort # (这是 deepspeed 多 rank 同时在 datasets.map 里 fork worker 时 # iflatmap_unordered 静默崩溃的最常见根因). # 2. 把 multiprocessing 默认启动方式从 fork 切到 spawn, 即使后续有人 # 把 ZY_MAP_NUM_PROC 调高也安全. os.environ.setdefault("TOKENIZERS_PARALLELISM", "false") try: # force=True 会强制覆盖已有的 start method; # 极少数情况下 (子解释器/已有活跃池) 仍会抛 RuntimeError, 此时降级跳过. _mp.set_start_method("spawn", force=True) except RuntimeError: pass import torch from datasets import load_dataset from transformers import ( AutoModelForCausalLM, AutoTokenizer, Trainer, TrainingArguments, TrainerCallback, set_seed, ) # ── 配置 ── DATA_ROOT = Path(os.environ.get("ZY_TRAIN_DATA", "/data/guanghu")) MODEL_DIR = Path(os.environ.get("ZY_MODEL_DIR", str(DATA_ROOT / "models" / "Qwen2.5-7B"))) DATA_PATH = Path(os.environ.get("ZY_DATA_PATH", str(DATA_ROOT / "processed" / "sft.jsonl"))) OUTPUT_DIR = Path(os.environ.get("ZY_OUTPUT_DIR", str(DATA_ROOT / "checkpoints" / "qwen2_5_7b_sft"))) DS_CONFIG = Path(os.environ.get("ZY_DS_CONFIG", str(Path(__file__).parent / "configs" / "ds_zero3_offload.json"))) NUM_EPOCHS = float(os.environ.get("ZY_NUM_EPOCHS", "3")) LR = float(os.environ.get("ZY_LR", "2e-5")) MAX_SEQ_LEN = int(os.environ.get("ZY_MAX_SEQ_LEN", "2048")) PER_DEVICE_BSZ = int(os.environ.get("ZY_PER_DEVICE_BSZ", "1")) GRAD_ACCUM = int(os.environ.get("ZY_GRAD_ACCUM", "16")) SAVE_STEPS = int(os.environ.get("ZY_SAVE_STEPS", "200")) LOGGING_STEPS = int(os.environ.get("ZY_LOGGING_STEPS", "5")) REPORT_EVERY = int(os.environ.get("ZY_REPORT_EVERY_STEPS", "5")) SEED = int(os.environ.get("ZY_SEED", "42")) # datasets.map 的 worker 数. 默认 1 — 这是有意的: # - deepspeed --num_gpus=N 起 N 个 rank, 每个 rank 都会调一次 build_dataset. # 如果这里再 fork 出 cpu//2 个 map worker, 就是 N × (cpu//2) 个 fork 子进程 # 同时加载 fast tokenizer + 同时写 datasets cache, 极易触发 # `iflatmap_unordered` 静默崩溃. # - 几万行 SFT 用 fast tokenizer 串行 tokenize 通常 < 1 分钟, 远小于一次训练代价. # - 真有大数据集需要并行, 用 ZY_MAP_NUM_PROC=N 显式开 (此时务必只让 rank 0 跑 map, # 由调用方保证, 这里不再做 multi-rank 同时并行 map 的兼容). MAP_NUM_PROC = max(1, int(os.environ.get("ZY_MAP_NUM_PROC", "1"))) IGNORE_INDEX = -100 def is_main_process() -> bool: return int(os.environ.get("LOCAL_RANK", "0")) == 0 def log(msg: str): if is_main_process(): print(msg, flush=True) # ── 数据加载 + 模板化 ── def _resolve_role_anchors(tokenizer) -> dict[str, list[int]]: """解析 ChatML 的 assistant 段定位锚点. 返回: im_start_id — <|im_start|> 的 token id im_end_id — <|im_end|> 的 token id asst_role_ids — "assistant\n" 编码后的 token id 序列 (作为内容前的角色头) 思路: Qwen2.5 的 chat_template 把每段对话包成 <|im_start|>{role}\n{content}<|im_end|>\n. <|im_start|> / <|im_end|> 是 special token, BPE 不会跨它们合并, 因此可以在 full_ids 上直接用 token-id 做线性扫描定位 assistant 段, 不依赖任何 "前缀对齐" 假设. 这是修复 "有效样本: 0" 故障的关键. """ im_start_id = tokenizer.convert_tokens_to_ids("<|im_start|>") im_end_id = tokenizer.convert_tokens_to_ids("<|im_end|>") if im_start_id is None or im_end_id is None or im_start_id == tokenizer.unk_token_id: raise RuntimeError( "[train] tokenizer 缺少 <|im_start|>/<|im_end|> special token, " "确认这是 Qwen2.5 系列模型 (而不是兼容包装)." ) # "assistant\n" 编码为 content token (不带任何 special token) asst_role_ids = tokenizer.encode("assistant\n", add_special_tokens=False) if not asst_role_ids: raise RuntimeError("[train] 无法编码 'assistant\\n' 角色头, tokenizer 异常.") return { "im_start_id": im_start_id, "im_end_id": im_end_id, "asst_role_ids": asst_role_ids, } def _mask_assistant_segments( full_ids: list[int], im_start_id: int, im_end_id: int, asst_role_ids: list[int], ) -> tuple[list[int], int]: """在 full_ids 上线性扫描, 找出所有 assistant 段并返回 labels + 标记到的 token 数. 被标记的范围 = assistant 内容 + 闭合的 <|im_end|> (让模型学会自然停止). 若 max_seq_len 截断导致最后一段没有闭合的 im_end_id, 则标到序列末尾. """ n = len(full_ids) labels = [IGNORE_INDEX] * n role_len = len(asst_role_ids) marked = 0 k = 0 while k < n: if full_ids[k] != im_start_id: k += 1 continue # 检查 <|im_start|> 后是否是 "assistant\n" if k + role_len < n and full_ids[k + 1 : k + 1 + role_len] == asst_role_ids: content_start = k + 1 + role_len # 找到内容结尾的 <|im_end|> j = content_start while j < n and full_ids[j] != im_end_id: j += 1 content_end = min(j, n - 1) # 含 im_end (若存在), 否则到序列末尾 for p in range(content_start, content_end + 1): labels[p] = full_ids[p] marked += content_end + 1 - content_start k = content_end + 1 else: k += 1 return labels, marked def build_dataset(tokenizer): if not DATA_PATH.is_file(): raise FileNotFoundError(f"训练数据不存在: {DATA_PATH} · 请先跑 preprocess-corpus.py") log(f"[train] 加载数据: {DATA_PATH}") raw = load_dataset("json", data_files=str(DATA_PATH), split="train") log(f"[train] 样本数: {len(raw)}") anchors = _resolve_role_anchors(tokenizer) im_start_id = anchors["im_start_id"] im_end_id = anchors["im_end_id"] asst_role_ids = anchors["asst_role_ids"] def encode(example: dict[str, Any], idx: int | None = None) -> dict[str, list[int]]: try: msgs = example["messages"] # apply_chat_template 一次性按 Qwen2.5 ChatML 格式化整段对话 full_ids = tokenizer.apply_chat_template( msgs, tokenize=True, add_generation_prompt=False, truncation=True, max_length=MAX_SEQ_LEN, ) labels, _marked = _mask_assistant_segments( full_ids, im_start_id, im_end_id, asst_role_ids ) return {"input_ids": full_ids, "labels": labels, "attention_mask": [1] * len(full_ids)} except Exception as e: # 让 worker 子进程在崩之前留下"那一条样本是谁"的痕迹. # iflatmap_unordered 在主进程只能看到 worker 的最终 traceback, # 这里把样本指纹打到 stderr, 便于事后定位脏数据. try: msgs = example.get("messages") preview = json.dumps(msgs, ensure_ascii=False)[:300] if msgs is not None else "" except Exception: preview = "" sys.stderr.write( f"[train.encode] 样本编码失败 idx={idx} err={type(e).__name__}: {e}\n" f"[train.encode] messages 预览: {preview}\n" ) sys.stderr.flush() raise cols = raw.column_names log(f"[train] tokenize map: num_proc={MAP_NUM_PROC} (默认 1, 用 ZY_MAP_NUM_PROC 覆盖)") ds = raw.map(encode, with_indices=True, remove_columns=cols, num_proc=MAP_NUM_PROC) # 过滤掉没有 assistant token 的样本 (理论上极少, 留作防御) ds = ds.filter(lambda ex: any(l != IGNORE_INDEX for l in ex["labels"])) log(f"[train] 有效样本: {len(ds)}") # ── "自动门" 防呆守护 ── # 若一条都没标到, 立即停, 不让 Trainer 在空数据集上裸奔. # 同时打首条样本的诊断信息, 让下一次定位时 < 30 秒. if len(ds) == 0: sample = raw[0] if len(raw) > 0 else None diag = ["[train] ❌ 有效样本为 0 — 标记环节失效, 终止训练."] if sample is not None: try: fid = tokenizer.apply_chat_template( sample["messages"], tokenize=True, add_generation_prompt=False, truncation=True, max_length=MAX_SEQ_LEN, ) preview = tokenizer.decode(fid[:200], skip_special_tokens=False) diag.append(f"[train] 诊断: im_start_id={im_start_id} im_end_id={im_end_id} " f"asst_role_ids={asst_role_ids}") diag.append(f"[train] 诊断: 首条样本 token 数={len(fid)}") diag.append(f"[train] 诊断: 首条样本前 200 token 解码=\n{preview}") except Exception as e: diag.append(f"[train] 诊断失败: {e}") for line in diag: log(line) raise RuntimeError("有效样本为 0 — 见上方诊断.") # 标注质量统计 — "门会回报它做了什么" if is_main_process(): try: sample_n = min(64, len(ds)) asst_tokens = 0 total_tokens = 0 for i in range(sample_n): row = ds[i] total_tokens += len(row["labels"]) asst_tokens += sum(1 for l in row["labels"] if l != IGNORE_INDEX) ratio = asst_tokens / total_tokens if total_tokens else 0.0 log(f"[train] 标注统计 (前 {sample_n} 条采样): " f"平均 assistant token 占比={ratio:.2%} " f"(平均 {asst_tokens // sample_n} tok/样本)") except Exception as e: log(f"[train] 标注统计失败(非致命): {e}") return ds @dataclass class PadCollator: tokenizer: Any pad_to_multiple_of: int = 8 def __call__(self, features: list[dict]) -> dict[str, torch.Tensor]: max_len = max(len(f["input_ids"]) for f in features) if self.pad_to_multiple_of > 1: max_len = math.ceil(max_len / self.pad_to_multiple_of) * self.pad_to_multiple_of pad_id = self.tokenizer.pad_token_id if pad_id is None: pad_id = self.tokenizer.eos_token_id def _pad(seq: list[int], val: int) -> list[int]: return seq + [val] * (max_len - len(seq)) input_ids = torch.tensor([_pad(f["input_ids"], pad_id) for f in features], dtype=torch.long) labels = torch.tensor([_pad(f["labels"], IGNORE_INDEX) for f in features], dtype=torch.long) attn = torch.tensor([_pad(f["attention_mask"], 0) for f in features], dtype=torch.long) return {"input_ids": input_ids, "labels": labels, "attention_mask": attn} # ── 心跳协议: Trainer Callback ── class ZYProgressCallback(TrainerCallback): """每 REPORT_EVERY 步输出 stdout 协议行,被 watch-training-output.sh 解析.""" def __init__(self): self.t0 = time.time() self.last_step = -1 def on_log(self, args, state, control, logs=None, **kwargs): # noqa: D401 if not is_main_process() or not logs: return step = state.global_step or 0 if step == self.last_step: return if step > 0 and (step - self.last_step) < REPORT_EVERY and step != state.max_steps: return self.last_step = step loss = logs.get("loss") if "loss" in logs else logs.get("train_loss") lr = logs.get("learning_rate") elapsed = max(time.time() - self.t0, 1e-6) thr = step / elapsed if step > 0 else 0.0 epoch = math.floor(state.epoch or 0) line = ( f"ZY_PROGRESS step={step} total={state.max_steps} " f"epoch={epoch} total_epochs={int(args.num_train_epochs)} " f"loss={loss if loss is not None else 'nan'} " f"lr={lr if lr is not None else 'nan'} " f"thr={thr:.4f}" ) print(line, flush=True) def main() -> int: set_seed(SEED) if not MODEL_DIR.is_dir(): log(f"❌ 模型目录不存在: {MODEL_DIR} · 请先跑 download-model.py") return 2 if not DS_CONFIG.is_file(): log(f"❌ DeepSpeed 配置不存在: {DS_CONFIG}") return 2 OUTPUT_DIR.mkdir(parents=True, exist_ok=True) log(f"[train] 模型: {MODEL_DIR}") log(f"[train] 数据: {DATA_PATH}") log(f"[train] 输出: {OUTPUT_DIR}") log(f"[train] DS: {DS_CONFIG}") tokenizer = AutoTokenizer.from_pretrained(str(MODEL_DIR), trust_remote_code=True, use_fast=True) if tokenizer.pad_token is None: tokenizer.pad_token = tokenizer.eos_token train_ds = build_dataset(tokenizer) log("[train] 加载模型 (fp16)...") model = AutoModelForCausalLM.from_pretrained( str(MODEL_DIR), dtype=torch.float16, trust_remote_code=True, use_cache=False, # 与 gradient_checkpointing 冲突 ) model.gradient_checkpointing_enable() if hasattr(model, "enable_input_require_grads"): model.enable_input_require_grads() args = TrainingArguments( output_dir=str(OUTPUT_DIR), num_train_epochs=NUM_EPOCHS, per_device_train_batch_size=PER_DEVICE_BSZ, gradient_accumulation_steps=GRAD_ACCUM, learning_rate=LR, warmup_ratio=0.03, lr_scheduler_type="cosine", weight_decay=0.0, max_grad_norm=1.0, fp16=True, bf16=False, gradient_checkpointing=True, logging_steps=LOGGING_STEPS, save_steps=SAVE_STEPS, save_total_limit=3, save_strategy="steps", report_to=["tensorboard"], deepspeed=str(DS_CONFIG), ddp_find_unused_parameters=False, dataloader_num_workers=2, dataloader_pin_memory=True, remove_unused_columns=False, seed=SEED, ) trainer = Trainer( model=model, args=args, train_dataset=train_ds, data_collator=PadCollator(tokenizer=tokenizer), callbacks=[ZYProgressCallback()], ) if is_main_process(): # 写一个供副将查询的训练 meta try: (OUTPUT_DIR / "training-meta.json").write_text( json.dumps({ "model": str(MODEL_DIR), "data": str(DATA_PATH), "max_seq_len": MAX_SEQ_LEN, "num_train_epochs": NUM_EPOCHS, "per_device_batch": PER_DEVICE_BSZ, "grad_accum": GRAD_ACCUM, "effective_batch": PER_DEVICE_BSZ * GRAD_ACCUM * max(1, torch.cuda.device_count()), "lr": LR, "fp16": True, "deepspeed_config": str(DS_CONFIG), }, indent=2, ensure_ascii=False), encoding="utf-8", ) except Exception as e: log(f"[train] meta 写入失败(非致命): {e}") log("[train] 🔥 开始训练") train_result = trainer.train() log("[train] 训练结束 · 保存最终模型...") trainer.save_model(str(OUTPUT_DIR / "final")) trainer.save_state() if is_main_process(): try: tokenizer.save_pretrained(str(OUTPUT_DIR / "final")) except Exception as e: log(f"[train] tokenizer 保存失败(非致命): {e}") log(f"[train] ✅ 完成 · global_step={trainer.state.global_step} · loss={train_result.training_loss:.4f}") return 0 if __name__ == "__main__": sys.exit(main())