本文基于 Xihe 项目,从随机初始化模型权重开始,介绍数据准备、预训练、领域增量预训练、指令微调、参数高效微调、知识蒸馏、偏好对齐与模型导出。
项目包含 Dense 与 MoE 两种结构,适合学习训练原理和开展小规模实验。本文中的“从零”指模型权重从零训练:分词器直接使用仓库
model/中已有的文件,不包含重新训练 tokenizer。本文命令已按当前源码接口核对;完整 GPU 训练、显存、耗时和部署兼容性仍需在实际环境验证。文中不把流程验证结果当作模型能力评测。
0. 先理解路线,再开始训练
0.1 一条主线,几条可选分支
图 1:训练路线与产物。PEFT 输出适配器;SFT、在线蒸馏和 DPO 输出完整模型权重。
离线数据蒸馏是另一条数据分支:distill_generate.py 用教师生成回答,再把生成的 JSONL 交给 full_sft.py。
这张图有三个前提:
- 七种 PEFT 是可选的对照实验,不需要依次训练七遍。
- 当前在线蒸馏脚本从学生的
Instruct权重起训;自家 Xihe 教师也读取Instruct权重。 - 当前 DPO 脚本读取完整权重,不直接挂载手写 PEFT 适配器。适配器训练后要进入 DPO,需另行实现合并或相应加载逻辑。
0.2 两种正式训练配置
| 项目 | Xihe-106M(Dense) | Xihe-147M-MoE |
|---|---|---|
| 隐藏维度 / 层数 | 768 / 16 | 512 / 8 |
| Q 头 / KV 头 | 12 / 4 | 8 / 2 |
| FFN | SwiGLU 稠密网络 | 7 个路由专家 + 1 个共享专家,top-k=2 |
| 词表大小 | 6400 | 6400 |
| 默认训练截断长度 | 512 token | 512 token |
| 位置编码配置上限 | 32768 | 32768 |
模型名称由脚本统计参数量后生成,实际以日志和输出配置文件为准。位置编码配置为 32K,不代表模型经过了 32K 长文本训练,更不代表该长度下的质量已经验证。
0.3 环境、依赖与运行约定
建议使用独立的 Python 3.10 或 3.11 环境,先按显卡与驱动安装适配的 PyTorch,再安装项目依赖。以下命令均在仓库根目录执行,使用单行格式,避免 Bash 与 PowerShell 续行符不同造成错误。
git clone https://github.com/ZGonce819/Xihe.git
cd Xihe
python -m pip install -r requirements.txt
python -c "import torch, transformers; print('torch:', torch.__version__); print('transformers:', transformers.__version__); print('CUDA:', torch.cuda.is_available())"
requirements.txt 中的版本下限不是经过验证的完整版本锁。较新的教师模型、PEFT 方法和 TRL 接口可能需要更新或匹配版本;遇到导入/API 错误应先核对依赖,而不是修改训练数据。
| 功能 | 相关依赖 |
|---|---|
| 手写模型、训练与本地对话 | torch、transformers |
| HF 数据转换 | datasets |
| HF Trainer / PEFT 路线 | peft、accelerate;DPO HF 版另需 trl |
| HF 4bit 加载 | bitsandbytes、accelerate |
| API 合成数据 | openai,另需自行配置服务与凭据 |
| PDF / Word / 图片抽取 | 按文件类型安装 pypdf、pypdfium2、python-docx、Pillow、numpy、easyocr |
| vLLM 服务 | 单独安装兼容的 vllm,建议独立环境 |
手写 model_qlora.py 使用 PyTorch 实现量化,不依赖 bitsandbytes;HF 量化路线才需要它。多卡训练脚本使用 NCCL,按 Linux/WSL2 环境准备,不应把命令直接当作 Windows 原生多卡方案。
显存取决于序列长度、batch、精度、优化器、专家数和教师规模,不能仅凭模型参数量给出“最低显存”。先用小 batch 测峰值,再逐步增加。蒸馏需要教师与学生;DPO 需要策略模型与冻结参考模型,两者的显存构成不同。
完整累积周期内,常用的全局有效 batch 计算为:
每卡 batch_size × accumulation_steps × GPU 数
相同有效 batch 不保证训练轨迹完全相同:有效 token 数、数据顺序、精度和调度都会影响结果。记录环境时可执行 python -m pip freeze > environment-freeze.txt,并同时记录仓库提交、GPU、驱动和数据版本。
0.4 最小闭环:先验证数据、更新和保存
先用一个小结构和 16 条演示样本验证流程,不承诺几分钟完成,也不期待生成正确答案。正式的 106M/MoE 训练见第 3 章。
将下面代码保存为仓库根目录的 make_smoke_data.py,再执行 python make_smoke_data.py:
import json
from pathlib import Path
root = Path('data/smoke')
root.mkdir(parents=True, exist_ok=True)
pretrain = [{'text': '水在标准大气压下的沸点约为一百摄氏度。学习需要练习和反馈。'} for _ in range(16)]
sft = [{'conversations': [
{'content': '学习时为什么需要练习?'},
{'content': '练习可以帮助理解知识,并通过反馈发现需要改进的地方。'}
]} for _ in range(16)]
for name, rows in [('pretrain', pretrain), ('sft', sft)]:
with (root / f'{name}.jsonl').open('w', encoding='utf-8') as f:
for row in rows:
f.write(json.dumps(row, ensure_ascii=False) + '\n')
演示数据刻意重复以保持示例短小,不用于正式训练或评测。使用专用输出目录,里面只保留本次小模型的配置,后续脚本便可自动发现模型名:
python pretrain.py --save_dir ./out/smoke --hidden_size 128 --num_hidden_layers 2 --num_attention_heads 2 --num_key_value_heads 1 --data_path ./data/smoke/pretrain.jsonl --epochs 1 --batch_size 2 --accumulation_steps 1 --max_seq_len 128 --num_workers 0 --log_interval 1 --save_interval 8
python full_sft.py --save_dir ./out/smoke --data_path ./data/smoke/sft.jsonl --init_from Base --save_as Instruct --epochs 1 --batch_size 2 --accumulation_steps 1 --max_seq_len 128 --num_workers 0 --log_interval 1 --save_interval 8
python chat.py --save_dir ./out/smoke --role Instruct --max_new_tokens 32
验收标准:两个训练阶段各读取 8 个 batch,loss 为有限值,输出目录出现同一模型名的 -config.json、-Base.pth、-Instruct.pth,最后能加载并生成文本。随机、重复或不通顺的输出不意外;这一步只验证工程闭环。
保存机制需要特别注意。 当前手写预训练、CPT、SFT、DPO 等脚本定期保存权重,没有训练结束时的兜底保存。间隔按每轮读取的 batch 计数,不是优化器更新次数。少于默认 100 个 batch 的小任务可能不保存任何权重。最小示例用 8 个 batch、每步更新、最后一个 batch 保存,避开这一问题。
正式任务应让每轮 batch 数能被累积步数整除,并选择落在更新边界上的保存间隔;否则当前循环没有可靠处理最后不足一个累积周期的收尾。中断后加载 .pth 只是在权重上续训,文件没有完整保存优化器、调度器和随机状态,不能称为精确断点恢复。
1. 模型结构:每个组件在解决什么问题
model.py 实现 Decoder-only Transformer,主干为:
input_ids → Embedding → N × DecoderLayer → RMSNorm → lm_head → logits
Embedding 与输出投影共享权重;每个 DecoderLayer 使用 Pre-Norm 残差结构:
h = x + Attention(RMSNorm(x))
y = h + MLP(RMSNorm(h))
Pre-Norm 提供较直接的残差梯度通路,有助于深层训练稳定,但不保证梯度永不衰减或训练不会发散。
1.1 RMSNorm 与 SwiGLU
RMSNorm 不减去均值,用均方根缩放激活:
RMSNorm(x) = x / sqrt(mean(x²) + ε) × weight
FFN(x) = down_proj(up_proj(x) × silu(gate_proj(x)))
实现先转 float32 计算归一化,再转回输入类型,减少混合精度的数值误差。SwiGLU 的门控分支决定哪些特征通过;默认中间维约为隐藏维度的 8/3,再向上对齐到 64 的倍数。
1.2 RoPE 与 GQA
RoPE 按位置旋转 Q/K,使注意力的位置关系通过相对旋转进入内积。项目采用前后半维配对的 rotate_half 形式;与相邻配对形式转换时,需要一致地处理 Q/K 的维度排列,不能直接混用权重。
cos/sin 表使用 persistent=False,不进入权重文件;增量解码通过 start_pos 接续历史位置。修改位置编码长度不会自动赋予长上下文能力,仍需要训练和评测。
GQA 让多个 Q 头共用一组 K/V。相对相同 Q 头数、每头维度和长度的 MHA,Dense 的 KV Cache 元素数为其 1/3,MoE 为其 1/4;这不等于整机显存同比下降。
expand 本身可以创建不复制数据的视图,但后续 reshape 可能物化副本。因此,不宜把整个 K/V 头展开操作描述为“保证零拷贝”。
1.3 SDPA 与增量解码的因果掩码
训练路径调用:
output = F.scaled_dot_product_attention(xq, xk, xv, is_causal=True)
SDPA 是统一接口,实际可能选择 FlashAttention、其他高效后端或数学实现。是否避免物化完整注意力矩阵,取决于实际后端与输入条件。
当 Q 长度为 1、K/V 包含历史时,左上对齐的因果掩码只允许看到最前面的 K,无法正确访问整段历史。项目对单 token 增量解码回落到手写注意力。正确原则是让掩码对应 Q 的绝对位置;使用正确掩码时,SDPA 也可以执行增量解码。若扩展为带历史缓存的多 token 输入,还需单独检查掩码,不能只检查 seq_len > 1。
1.4 MoE:总容量与激活计算分开看
图 2:单个 token 的 MoE 路由示意。示例选择两个路由专家,共享专家始终参与。
MoE 将稠密 FFN 替换为专家组。门控网络对每个 token 计算专家概率,选择 top-k,再对选中权重归一化:
scores = F.linear(hidden_states, gate_weight).softmax(dim=-1)
weights, indices = torch.topk(scores, k=2, dim=-1)
weights = weights / (weights.sum(dim=-1, keepdim=True) + 1e-20)
本项目每个 token 执行两个路由专家和一个共享专家;共享专家输出再乘 sigmoid 门控。相对于执行全部专家,这减少了激活计算,但不能说相对于稠密模型“计算量不变”:专家宽度、数量、共享分支和路由开销都要计入,而且所有专家权重仍需存储。
为抑制少数专家长期占据路由,训练额外加入负载均衡损失。常见 token 级写法是 α × E × Σ(f_i × P_i),其中 E 是专家数、f 是选择频率、P 是完整 softmax 的平均概率。本项目默认使用 sequence 级统计,具体归一化应以 MoEGate 为准。
辅助损失使用 top-k 之前的概率分布,可以为路由均衡提供训练信号,但不保证所有专家负载完全一致。训练时按专家分配 token 并保留梯度;推理时按专家分组计算、聚合结果。分组过程仍会创建中间张量,不应描述为绝对“不复制”。
2. 数据工程:先把输入变成可检查的契约
仓库忽略了 data/、raw_data/ 和大权重目录。克隆代码不会自动获得作者本地的 pretrain_hq.jsonl、sft_mixed.jsonl 或教师权重。正式训练前必须准备这些文件,或者把命令中的路径替换为自己的文件。
2.1 三种 JSONL 格式
每行必须是一个完整 JSON 对象,使用 UTF-8 编码。下列每个代码块是一条样本。
预训练与 CPT:
{"text":"梯度累积通过多次反向传播累加梯度,再执行一次参数更新。"}
SFT、手写 PEFT 和学生蒸馏数据:
{"conversations":[{"content":"什么是梯度累积?"},{"content":"连续处理多个小批次并累加梯度,再执行一次参数更新。"}]}
当前 SFTDataset 按数组下标交替指定 user/assistant,只读取 content,不是通用 ShareGPT 角色解析器。不要直接混入独立 system 轮次、工具消息或 from/value 字段;先转换成严格交替的问答。
DPO:
{"chosen":[{"role":"user","content":"梯度累积会增加模型参数量吗?"},{"role":"assistant","content":"不会,它改变参数更新的频率,不改变模型结构或参数量。"}],"rejected":[{"role":"user","content":"梯度累积会增加模型参数量吗?"},{"role":"assistant","content":"会,每次累积都会增加一层参数。"}]}
DPO 两侧必须共享相同的问题与上下文,并使用 tokenizer 支持的角色格式;不能把两个任意好坏回答拼成偏好对。
2.2 从文档生成预训练文本
把拥有使用权限的 txt/md 等资料放到 raw_data/knowledge/,执行:
python prepare_text.py --input_dir ./raw_data/knowledge --output_path ./data/pretrain_custom.jsonl --chunk_size 512 --min_len 20
PDF、Word、图片可走相应抽取/OCR 分支,需先安装对应依赖。chunk_size 的单位是字符,训练长度单位是 token,两者不等价,尤其是小词表、多语言或代码文本。切分后应使用实际 tokenizer 统计长度、截断率与有效 token 数。
正式预训练可将清洗后的通用文本整理为 data/pretrain_hq.jsonl;若只有上述领域文本,也可显式使用 pretrain_custom.jsonl 从零实验,但不能据此期待通用助手能力。
2.3 从公开指令数据集生成 SFT 文件
下面是 prepare_hf.py 的字段映射示例,运行时需能访问数据源,并核对数据集当前字段与许可:
python prepare_hf.py --dataset shibing624/alpaca-zh --split train --question_fields instruction,input --answer_field output --output_path ./data/sft_hf.jsonl
--max_samples 可以限制输出条数,但当前脚本先加载并抽取数据,再截取输出,不是流式小样本下载。gene.py 支持 API 合成,prepare_md_qa.py 支持文档转问答;生成内容都应抽样审核,不能默认教师回答必然正确。
本教程后续统一用 sft_hf.jsonl 展示命令。已有多份 SFT 数据时,可以在 full_sft.py --data_path 中用逗号分隔;ConcatDataset 仅拼接数据,不自动平衡不同来源的采样比例。
2.4 开训前检查什么
至少检查 JSON 可解析、字段正确、内容非空、角色交替、问题与答案是否被截断,以及每个样本是否还有有效的回答监督 token。没有有效监督 token 的样本应剔除或重新切分,否则可能出现零分母和 NaN。
训练/验证集应在重复和上采样之前划分;文档切片按源文档分组划分,避免相邻片段泄漏。记录数据来源、版本、条数、token 数、去重规则和各来源比例。
本地 data/ 下的格式化、打乱辅助脚本未被 Git 跟踪,本教程不把它们作为克隆后的必备工具。单卡 pretrain.py 目前 shuffle=False,正式语料应提前打乱;SFT 已在训练侧打乱。
3. 预训练:让随机权重学习预测下一个 token
3.1 目标与张量对齐
图 3:输入 X 与目标 Y 错开一个 token。每个位置的预测都与对应的下一 token 目标比较;接入 HF 模型时要避免重复移位。
数据集将同一序列错开一位:
X = input_ids[:-1]
Y = input_ids[1:]
loss_mask = (input_ids != pad_token_id)[1:]
位置 t 的 logits 预测 Y[t],即原始序列的下一个 token。max_seq_len=512 时,X/Y 实际长度为 511。当前 tokenizer 不自动添加 BOS/EOS,不能假定每条预训练文本已经带结束符。
损失是有效 token 的平均交叉熵,MoE 再叠加辅助损失:
L = Σ(mask × token_CE) / Σ(mask) + L_aux
这个移位约定延续到 SFT,但蒸馏和 DPO 会改变目标函数,不能说所有阶段“只改变 mask”。
3.2 正式配置命令
前提:已准备 data/pretrain_hq.jsonl,并通过第 2 章的数据检查。以下是起始配置,显存不足先减小 batch;完整训练前按第 0.4 节检查累积和保存边界。
python pretrain.py --hidden_size 768 --num_hidden_layers 16 --num_attention_heads 12 --num_key_value_heads 4 --data_path ./data/pretrain_hq.jsonl --epochs 1 --batch_size 8 --accumulation_steps 8 --learning_rate 5e-4 --warmup_ratio 0.01 --max_seq_len 512 --save_interval 80
python pretrain.py --hidden_size 512 --num_hidden_layers 8 --num_attention_heads 8 --num_key_value_heads 2 --use_moe --num_routed_experts 7 --num_shared_experts 1 --num_experts_per_topk 2 --data_path ./data/pretrain_hq.jsonl --epochs 1 --batch_size 4 --accumulation_steps 8 --learning_rate 5e-4 --max_seq_len 512 --save_interval 80
预期产物为 out/Xihe-106M-Base.pth、out/Xihe-147M-MoE-Base.pth,以及各自的 -config.json。后续都显式指定模型名,避免多个配置文件导致自动发现失败。默认不传 Dense 结构参数时,pretrain.py 的 512/8 配置并不是这里的 106M。
Linux 多卡示例:
torchrun --nproc_per_node=2 pretrain.py --hidden_size 768 --num_hidden_layers 16 --num_attention_heads 12 --num_key_value_heads 4 --data_path ./data/pretrain_hq.jsonl --batch_size 8 --accumulation_steps 8 --save_interval 80
3.3 训练循环中值得理解的细节
图 4:梯度累积示例。四个小批次各自计算 loss / 4 并反向传播,累积满一个周期后执行一次优化器更新;图中 g1 至 g4 表示各次缩放后累积的梯度贡献。
学习率使用预热加余弦调度。当前公式的峰值是 1.1 × learning_rate,末端趋近 0.1 × learning_rate;它按读取的 batch 调整,不是按优化器更新次数调整。若改用常见的峰值恰为 lr 的公式,应同时修改代码和实验记录。
GPU 路径使用 torch.amp.autocast('cuda')。当前若干手写训练脚本没有把 --dtype 传给 autocast,因此参数名为 bfloat16 不代表实际按 BF16 计算;CUDA autocast 默认通常为 FP16。该参数还用于控制 GradScaler 是否启用。需要固定精度时,先修正并验证实现,不能仅改命令参数。
使用梯度缩放时,顺序应是:反向传播 → unscale_ → 梯度裁剪 → step → update。项目的 .pth 保存为 FP16 权重;保存精度与训练计算精度是两回事。
验收时检查权重保存、有限损失和独立验证集的 token 平均 CE。训练 loss 下降只能说明拟合训练数据;重复文本、数据泄漏或过拟合都可能让它很好看。
4. CPT:在 Base 上继续学习领域文本
CPT 使用与预训练相同的下一 token 目标,区别是从 Base 权重起步,输入换成领域文本。当前 cpt.py 固定读取 Base,输出 CPT。
python cpt.py --model_name Xihe-106M --data_path ./data/pretrain_custom.jsonl --epochs 1 --batch_size 4 --accumulation_steps 8 --learning_rate 5e-5 --save_interval 80
产物为 out/Xihe-106M-CPT.pth。MoE 路线使用同样命令,将模型名换为 Xihe-147M-MoE,并按显存调整 batch。
较小学习率、混入通用语料和限制训练量都可以缓解灾难性遗忘,但不能保证通用能力不变。验收时同时比较领域验证集与通用验证集:领域指标改善而通用指标明显退化,通常需要调整配比或训练预算。
5. SFT:让模型学会按问题回答
5.1 回答掩码与聊天模板
图 5:预训练与 SFT 的理想监督范围对照。为便于比较,以同一段对话示意;预训练数据不必使用对话格式,图中分段也不代表真实 tokenizer 输出。
SFT 仍预测下一个 token,但主要监督 assistant 回答,避免让用户问题主导损失。理想的监督区间包括回答正文和结束符,让模型学会何时停止。
当前实现用 tokenizer 的真实 chat template 渲染文本,再匹配 <|im_start|>assistant 与 <|im_end|>。不要假定渲染结果只含 user/assistant;仓库模板还会插入默认 system 内容。
dataset.py 的 mask 边界使用 start + 1 和 end + len(eos_id) + 1,可能覆盖结束符之后的一个 token。该写法依赖模板分词,不能直接当作任意模板通用的答案掩码。修改模板时,必须逐 token 解码核对 mask;还要排除截断后回答完全消失的样本。
5.2 从 Base 或 CPT 起训
先选择一个起点,以下两条命令是替代关系,都会写入同一个 Instruct 文件:
python full_sft.py --model_name Xihe-106M --init_from Base --save_as Instruct --data_path ./data/sft_hf.jsonl --epochs 1 --batch_size 4 --accumulation_steps 8 --learning_rate 5e-4 --save_interval 80
python full_sft.py --model_name Xihe-106M --init_from CPT --save_as Instruct --data_path ./data/sft_hf.jsonl --epochs 1 --batch_size 4 --accumulation_steps 8 --learning_rate 5e-4 --save_interval 80
产物为 out/Xihe-106M-Instruct.pth。完成 MoE 的预训练后,也需用其模型名单独执行 SFT,才能进入后面的自家教师蒸馏。
python chat.py --model_name Xihe-106M --role Instruct --decode_strategy greedy --max_new_tokens 128
用训练集之外的固定问题检查:是否回答问题、是否停止、是否复读、是否出现身份污染。SFT 训练完成不等于事实正确率或推理能力已经达标。
5.3 身份纠正与多来源混合
开源指令数据可能包含数据生成模型的身份回答。项目提供身份数据生成器,可配合通用样本做低学习率续训:
python prepare_identity.py --variant dense --format sft --repeat 1 --output_path ./data/sft_identity.jsonl
python full_sft.py --model_name Xihe-106M --init_from Instruct --save_as Instruct-Identity --data_path ./data/sft_hf.jsonl,./data/sft_identity_dense.jsonl --epochs 1 --batch_size 4 --accumulation_steps 8 --learning_rate 5e-5 --save_interval 80
输出文件会自动增加 _dense 后缀。根据数据量调整身份样本比例,不要把少量身份问答重复到淹没通用数据。单独保存 Instruct-Identity,再对比身份问题与通用问题。
原项目记录中,SFT 身份续训比其 DPO 身份实验更有效;这是特定数据和配置下的经验,不是“DPO 无法修改身份”的理论结论。
6. PEFT:用同一底座做七种微调实验
6.1 手写实现的范围
| 方法 | 可训练部分 | 本项目实现说明 |
|---|---|---|
| LoRA | 低秩 A/B | 在线性投影旁加入低秩更新 |
| QLoRA | 量化底座上的 LoRA | 手写 NF4 分块编码,前向反量化;没有双重量化 |
| BitFit | 新增 bias | 原底座没有相应 bias,先注入零偏置;属于变通实现 |
| IA³ | 缩放向量 | 调整 K/V 和 FFN 内部通道 |
| Prompt Tuning | 输入软提示 | 训练虚拟 token embedding |
| P-Tuning | 软提示及编码器 | 用编码器生成输入软提示 |
| Prefix Tuning | 各层前缀 KV 相关参数 | 将可学习前缀加入各层注意力 |
QLoRA 论文中的完整工程方案与这里的教学实现不同。手写版前向会反量化出权重,不能仅用压缩后的权重大小推算峰值显存;HF bitsandbytes 路线可使用双重量化。
6.2 以 LoRA 走通训练与加载
图 6:LoRA 的冻结主干与可训练低秩旁路。适配器需要与训练时的底座版本配套。
LoRA 的前向形式为 W₀x + α/r × B(A(x))。A 随机初始化、B 零初始化,使初始旁路输出为零。
以下用第 2 章生成的 SFT 文件演示;做领域适配时替换成相同格式的领域数据。训练与推理使用同一个 Instruct 底座:
python lora_sft.py --model_name Xihe-106M --init_from Instruct --data_path ./data/sft_hf.jsonl --lora_name Tutorial --epochs 1 --batch_size 4 --accumulation_steps 8 --learning_rate 5e-4 --save_interval 80
python chat.py --model_name Xihe-106M --role Instruct --peft_method lora --adapter_name Tutorial
预期适配器为 out/lora/Xihe-106M-LoRA-Tutorial.pth,另有适配器配置。训练命名参数是 --lora_name,对话加载参数是 --adapter_name,二者不要混淆。
其他手写方法的最小启动示例:
python qlora_sft.py --model_name Xihe-106M --init_from Instruct --data_path ./data/sft_hf.jsonl --qlora_name Tutorial --epochs 1 --batch_size 2 --accumulation_steps 8 --save_interval 80
python bitfit_sft.py --model_name Xihe-106M --init_from Instruct --data_path ./data/sft_hf.jsonl --bitfit_name Tutorial --epochs 1 --batch_size 2 --accumulation_steps 8 --save_interval 80
python ia3_sft.py --model_name Xihe-106M --init_from Instruct --data_path ./data/sft_hf.jsonl --ia3_name Tutorial --epochs 1 --batch_size 2 --accumulation_steps 8 --save_interval 80
python prompt_sft.py --model_name Xihe-106M --init_from Instruct --data_path ./data/sft_hf.jsonl --prompt_name Tutorial --epochs 1 --batch_size 2 --accumulation_steps 8 --save_interval 80
python ptuning_sft.py --model_name Xihe-106M --init_from Instruct --data_path ./data/sft_hf.jsonl --ptuning_name Tutorial --epochs 1 --batch_size 2 --accumulation_steps 8 --save_interval 80
python prefix_sft.py --model_name Xihe-106M --init_from Instruct --data_path ./data/sft_hf.jsonl --prefix_name Tutorial --epochs 1 --batch_size 2 --accumulation_steps 8 --save_interval 80
这些是独立实验,不会依次叠加能力。对比方法时固定底座、数据划分、训练预算和解码设置,记录实际可训练参数量、验证指标与峰值显存,而不是只比较训练 loss。
6.3 HF PEFT 版:先转换,再训练
peft_sft.py 支持 lora / qlora / ia3 / ln_tuning / prompt / p_tuning / prefix。它没有 BitFit,LN Tuning 也不等价于 BitFit,因此不能说与手写七种方法逐一对应。
python -m pip install peft accelerate
python convert_model.py --model_name Xihe-106M --role Instruct --shell auto --transformers_path ./Xihe-106M-Instruct-HF
python peft_sft.py --method lora --model_path ./Xihe-106M-Instruct-HF --data_path ./data/sft_hf.jsonl
HF Trainer 路线有一个重要对齐问题:项目数据集已经构造下一 token 目标 Y,而 HF 模型内部还会移位 labels。接入时需要还原 labels 的位置:
targets = targets.masked_fill(loss_mask == 0, -100)
labels = torch.full_like(input_ids, -100)
labels[:, 1:] = targets[:, :-1]
这段右移只适用于“数据集已移位、模型内部还会移位”的接口组合,不是所有 Trainer 数据集都必须做。该适配会舍去原 Y 的最后一个目标,严格比较手写版与 HF 版时还要统一监督范围。
HF 适配器与手写 .pth 适配器格式不同,应使用各自的加载方式;不能把 HF 产物直接交给 chat.py 的手写适配器加载器。
7. 蒸馏:先选教师接口,再选损失
图 7:三种蒸馏路线的输入、监督信号与产物区别。CE 是硬标签交叉熵,KD 是蒸馏损失;在线路线按权重组合二者。
7.1 离线数据蒸馏
教师重新回答现有问题,生成 SFT 数据;学生训练时不需要同时加载教师。先准备完整的教师模型和 tokenizer 到 teacher/Teacher-Instruct/。这个路径表示读者自行下载的模型目录,不是仓库附带权重。
python distill_generate.py --teacher_path ./teacher/Teacher-Instruct --data_path ./data/sft_hf.jsonl --out_path ./data/sft_distilled.jsonl --num_samples 1000 --batch_size 1 --max_new_tokens 256
python full_sft.py --model_name Xihe-106M --init_from Instruct --save_as Distill-Data --data_path ./data/sft_distilled.jsonl --epochs 1 --batch_size 2 --accumulation_steps 8 --save_interval 80
需要 HF 4bit 加载时安装对应依赖并追加 --teacher_4bit。教师答案应审核事实、格式、重复和长度;教师更大不保证每条回答都更好。
decoder-only 批量生成通常采用左 padding,因为生成逻辑常从最后一个位置的 logits 选取新 token。右 padding 可能读取 pad 位置预测;不能把问题简单归因于“位置编码必然错乱”。
7.2 同词表在线蒸馏
当教师与学生的 token ID 语义一一对应时,可以对预测分布计算 KL 散度,常用温度 T 软化分布并乘 T² 补偿梯度尺度。本项目总损失为:
L = α × L_CE + (1 - α) × L_KD
这里 α 是硬标签权重。自家模型路线要求两种模型的配置和 Instruct 权重都在 out/:
python distillation.py --teacher_name Xihe-147M-MoE --student_name Xihe-106M --data_path ./data/sft_hf.jsonl --epochs 1 --batch_size 2 --accumulation_steps 8 --temperature 2 --alpha 0.5 --save_interval 80
输出为 out/Xihe-106M-Distill.pth。教师冻结,学生从 Instruct 续训;参数量更大只是候选教师条件,应先验证教师在目标任务上的表现。
7.3 跨词表在线蒸馏:ULD 风格近似
python distillation.py --teacher_path ./teacher/Teacher-Instruct --student_name Xihe-106M --data_path ./data/sft_hf.jsonl --epochs 1 --batch_size 1 --accumulation_steps 8 --temperature 2 --alpha 0.5 --save_interval 80
脚本比较词表映射,发现不一致时使用双 tokenizer 数据集,输出 Xihe-106M-Distill-ULD.pth;词表一致时走 KL 路线。教师不能直接读取学生的 token ID。
当前 uld_loss_fn 的具体操作是:两侧 softmax 后各自降序排序,回答序列截到较短长度,词表截到较小宽度,再计算逐位 L1 并平均。它约束概率形状,属于带截齐近似的 ULD 风格实现,不能直接宣称是一般情形下精确的 Wasserstein-1 距离。
两个近似尤其需要验证:两侧第 i 个回答 token 未必对应同一语义位置;被截去的词表尾部概率质量也未必可忽略。教师回答区间通过增量模板长度推断,同样依赖模板与分词边界,不能保证任意模板通用。验收除下游效果外,还应抽样检查两侧监督区间与被截去的质量。
8. DPO:在偏好对上调整回答倾向
图 8:同一偏好对经策略模型和参考模型评分,再比较两侧的偏好差值。只有策略模型接收参数更新,参考模型保持冻结。
DPO 使用策略模型与冻结参考模型,直接优化偏好对,无需在这条训练路径中额外训练奖励模型或执行 PPO 在线采样。给定同一问题 x,标准目标可写为:
L = -log sigmoid(β × [(log π(y_chosen|x) - log π(y_rejected|x))
- (log π_ref(y_chosen|x) - log π_ref(y_rejected|x))])
标准序列 log 概率对回答 token 求和。本项目 dpo.py 对回答 token 的 log 概率求平均,是长度归一化变体;它改变分数尺度及长度影响,β 与学习率不能不加区分地照搬其他实现。
先按第 2 章格式准备 data/dpo.jsonl,再运行:
python dpo.py --model_name Xihe-106M --init_from Instruct --save_as DPO --data_path ./data/dpo.jsonl --epochs 1 --batch_size 2 --accumulation_steps 8 --learning_rate 1e-6 --beta 0.1 --save_interval 80
python chat.py --model_name Xihe-106M --role DPO --decode_strategy greedy --max_new_tokens 128
产物为 out/Xihe-106M-DPO.pth。脚本默认学习率为 1e-8,上面显式使用 1e-6 作为调参起点,不是效果保证。若从身份校正版对齐,改成 --init_from Instruct-Identity,同时用新的 --save_as 保留实验版本。
仅验证数据生成流程时,也可执行:
python prepare_identity.py --variant dense --format dpo --repeat 1 --output_path ./data/dpo_identity.jsonl
生成 data/dpo_identity_dense.jsonl 后可替换训练路径;它只覆盖有限的身份/质量模板,不能代表通用偏好数据。小数据任务需重新设置保存与累积间隔。
验收应查看保留偏好对上的相对偏好准确率、回答长度、复读率和通用任务退化。NaN 先排查零 mask、数值精度和异常数据,再调学习率;不能一律归因为学习率过大。
9. 模型导出与部署
9.1 原生权重与模型壳
原生 .pth 需要同名 -config.json 与配套 tokenizer。chat.py --role 选择权重后缀,例如 Instruct、DPO、Distill;它当前统一套聊天模板,即使选择 Base 也不会自动切成原始文本续写模式。
convert_model.py 提供三种壳:
| 壳 | 用途与限制 |
|---|---|
xihe | 自定义 Transformers 模型代码,加载时需要 trust_remote_code=True |
llama | Dense 映射为官方 Llama 结构 |
qwen2moe | MoE 映射为官方 Qwen2-MoE 结构 |
auto | 按 Dense/MoE 自动选官方壳 |
python convert_model.py --model_name Xihe-106M --role DPO --shell auto --transformers_path ./Xihe-106M-DPO-HF
python convert_model.py --model_name Xihe-147M-MoE --role Instruct --shell auto --transformers_path ./Xihe-147M-MoE-Instruct-HF
每条命令都要求对应权重已存在;只有 Instruct 时把第一条的 --role 与输出目录改为 Instruct。使用不同目录保留不同阶段,避免后一次转换覆盖前一次结果。
转换成功只证明文件生成,不等于数值等价或后端兼容性已经通过。应在 eval 模式、相同 token 输入下比较原模型与转换模型的 logits、贪心输出和停止行为,并考虑保存精度带来的误差。
9.2 vLLM 服务示例
在支持该模型结构和硬件的 Linux/WSL2 vLLM 环境中,可以尝试加载官方壳导出:
python -m vllm.entrypoints.openai.api_server --model ./Xihe-106M-DPO-HF --served-model-name xihe --host 127.0.0.1 --port 8000 --dtype auto --max-model-len 512 --gpu-memory-utilization 0.5
客户端使用 requests 示例,先执行 python -m pip install requests,再运行:
import requests
response = requests.post('http://127.0.0.1:8000/v1/chat/completions', json={
'model': 'xihe',
'messages': [{'role': 'user', 'content': '什么是梯度累积?'}],
'max_tokens': 64,
'temperature': 0
}, timeout=120)
response.raise_for_status()
print(response.json()['choices'][0]['message']['content'])
输入模板与输出 token 的总长度需落在服务长度预算内。gpu-memory-utilization 用于控制显存预算,不能把参数文件大小当作服务总显存;KV Cache、工作区和运行时都会占用显存。
vLLM 对部分模型支持标准 LoRA,但本项目手写适配器不能因此直接挂载。自定义 Xihe 壳也不会仅凭 trust_remote_code 自动获得 vLLM 支持。llama.cpp 通常还需转换为 GGUF,并验证架构支持,不能直接加载这里的 HF 权重目录。
10. 如何判断这次训练是否有价值
项目当前没有统一的端到端评测入口,下面是建议补充执行的评测规范,不是已经跑出的成绩。把测试问题和验证数据固定下来,在每个阶段重复评估,才能区分能力提升、数据记忆与格式变化。
| 阶段 | 至少检查 | 对照对象 |
|---|---|---|
| 预训练 | 保留文本的 token 平均 CE/PPL | 相同 tokenizer、不同训练步数 |
| CPT | 领域指标与通用指标是否此消彼长 | Base |
| SFT | 指令遵循、事实错误、复读与停止 | 同一底座的微调前后 |
| PEFT | 下游表现、可训练参数、显存和耗时 | 同一底座、全参 SFT |
| 蒸馏 | 学生实际任务表现与教师质量 | 原学生、同数据直接 SFT |
| DPO | 保留偏好对准确率与生成质量 | 起训策略模型 |
| 转换与部署 | logits 差异、生成、停止符和长度 | 原生模型 |
CE 应按全验证集有效 token 汇总,再计算 PPL = exp(CE);不把 MoE 辅助损失计入语言建模 PPL,也不跨不同 tokenizer 直接比较 PPL。SFT 的回答区域 PPL 与全文本 PPL 应分开报告。
至少展示固定问题的 Base/SFT/DPO 或裸底座/适配器前后输出,并保留失败样例。测试题不要直接取自训练集;解码温度、最大输出长度、模板和 seed 应保持一致。
建议每次运行保存一条记录:仓库提交、完整命令、数据版本与 token 数、起训权重、软件版本、实际计算精度、GPU、峰值显存、耗时和评测结果。没有这些条件,就不要在博客里给出“8GB 必定跑通”“一小时训好”之类的保证。
11. 常见问题速查
| 现象 | 优先检查 |
|---|---|
unrecognized arguments | 对话用 --role;LoRA 训练命名用 --lora_name |
| 找不到数据文件 | 数据不随 Git 分发;先生成或下载,再检查相对路径 |
| 找到多个模型配置 | 显式设置 --model_name;最小实验使用专用目录 |
训练结束没有 .pth | 每轮 batch 数是否达到 save_interval |
| 小数据训练没有有效更新 | batch 数是否不足一个累积周期 |
| loss 为 NaN/Inf | 空监督 mask、截断、精度、异常数据和学习率 |
| 指定 BF16 但实际不符 | 检查 autocast 是否接收了 dtype |
| 适配器输出异常 | 方法、rank、命名与训练底座版本是否一致 |
| 换模板后效果骤降 | 逐 token 检查角色边界、结束符和 mask |
| 训练 loss 好看但回答差 | 验证集泄漏、重复数据、预算不足及解码方式 |
| 长文本效果差 | 32K 是配置上限,默认训练长度只有 512 |
| HF/vLLM 无法加载 | 依赖版本、导出壳、架构支持及目标目录完整性 |
建议先完成第 0.4 节的最小验证,再训练 Dense 的 Base → SFT 主线,确认数据和评测可靠后扩展到 CPT、MoE、PEFT、蒸馏与 DPO。小模型的价值在于让每个训练决策都可以观察和对照:改了什么数据、更新了哪些参数、优化了什么目标,以及最终回答是否因此改善。
代码仓库:ZGonce819/Xihe。核心文件为 model.py、dataset.py、各阶段训练脚本、chat.py 和 convert_model.py;阅读时优先核对可执行代码,不要把代码注释或本文中的经验建议当作未经验证的保证。