☰
从零训练1亿参数小模型:单卡走通预训练到DPO全流程
2026/9/26 18:49:25 网站建设 项目流程

1. 项目起点:为什么给一个小模型起名 Xihe,还要从零开始训

去年冬天,我给自己定了一个看起来很疯狂的KPI:用一张消费级显卡,把一条完整的语言模型训练链路全部走一遍——预训练、CPT、SFT、PEFT、蒸馏、DPO,一个环节都不跳过。最终产物是一个我自己命名的参数量只有1亿出头的小模型,代号就叫 Xihe。这个名字没有特殊含义,纯粹是项目目录里的一个随手代号,但跑完整个流程之后,我才意识到它更像一条“西合”之路:把大模型工业界常用的训练手段,全部压缩到一个可以靠个人算力完成的规模上,逐一验证、逐一踩坑、逐一复盘。

先说明一下,我做这件事不是想和开源社区已有的中文底座模型竞争。说实话,直接下载一个成熟的中文预训练模型,再跑SFT和DPO,两三天就能交差,效果还比我这种从零开始的方案好得多。但这么做的代价是:预训练、CPT这些偏“地基”的阶段对你来说永远是黑盒。我看到太多人拿着现成预训练权重做微调,遇到中文领域效果不好,第一反应是改Prompt、堆SFT数据,却很少有人想过继续预训练这条路;遇到对话风格不对,只知道RLHF,却连DPO和传统PPO的差别都讲不清。所以我坚持从最底层开始,把全流程打通一遍。

Xihe 的技术路线是:先做通用语料预训练得到 base 模型,再做中文继续预训练(CPT),然后用 LoRA 做指令微调(SFT),接着用更大的教师模型蒸馏压缩,最后用 DPO 做偏好对齐。整条链路里 PEFT 不是独立的第五步,而是贯穿 SFT 和 DPO 的“省钱”策略,后面会详细展开。

这篇文章适合两类人:一是想在有限算力下完整见识大模型训练全流程的开发者,二是有一定SFT/微调经验、但想补上预训练和偏好对齐知识盲区的研究者。我把自己的超参设置、数据配比、踩坑过程都写了进去,你可以把它当一份“单卡从零训语言模型”的工程手记来读。

2. 预训练的地基:语料、分词器与超参清单

2.1 语料选择与清洗管道

预训练的质量七分靠数据。Xihe 的目标是中文通用能力,但我并不想完全丢掉英文语料,因为小模型的跨语言迁移能力很差,如果只喂中文,后续做代码、技术文档类任务时会非常吃力。

我最初收集了大约 20GB 原始文本,来源是几个公开可用的网页语料、百科语料和开源代码快照。这里必须强调,语料清洗不是洁癖,而是刚需。我见过太多新手把网上爬下来的文本直接丢进 tokenizer,结果模型学会了一堆网站导航、重复弹窗、乱码和“阅读全文”。我的清洗管道是:

  1. 按行去重和文档级去重。用MinHash处理近似重复文档,这一步能砍掉约30%数据。
  2. 过滤过短文档、纯标点文档、乱码比例高的文档。
  3. 去除明显的导航文案、版权声明、Cookie提示等噪声模板。
  4. 统一全角半角,规范标点,但保留代码缩进和换行结构。
  5. 按语言粗分类,中文和英文语料分开统计,方便后面配比。

清洗后最终留下约 15GB 有效文本。字符数大约 48 亿,用 32K 词表的 BBPE 分词器切完后,有效训练 token 大约 2.1B。这个规模对 0.12B 参数的模型来说不算少,但也没到很充裕的程度,所以我用 2 个 epoch 来做预训练。

2.2 训练分词器:词表大小与特殊Token

分词器我选择训练一个 32K 词表的 BBPE(Byte-level BPE),而不是直接用现成的中英文分词器。原因是 Xihe 要自己控制词表与模型规模的匹配度。0.1B 级小模型的 embedding 占比很大,词表从 50K 降到 32K,embedding 参数直接少了约 1800 万个,这对显存和收敛速度都是实打实的收益。

训练词表时要记得预留特殊 token 位置。除了[PAD],[UNK],[BOS],[EOS],我额外安插了 20 个保留 token 槽位,后面做 SFT 的 system/user/assistant 标记时不用重新扩展词表、不用改 embedding 维度。这个经验是从同事那学来的,先别扭五分钟,后面省五小时。

2.3 模型架构与超参配置

Xihe 的架构参考了 GPT-2/LLaMA 系列的做法,decoder-only,12 层 Transformer,隐藏维度 768,12 个注意力头,中间 FFN 维度 3072,最大序列长度 1024。总参数量约 0.12B,其中 embedding 占大头的现象非常明显。Loss 用标准交叉熵,计算时把 padding 部分 mask 掉。

训练超参我根据自己的经验做了如下设定:

参数设定值说明
序列长度1024小于GPT常用的2048,降低显存
Batch Size64 条 × 1024 token等效 batch 约 64K token
学习率3e-4采用 cosine decay
Warmup Steps2000前2000步线性上升到峰值
AdamW beta(0.9, 0.95)参考LLM常用配置
权重衰减0.1只作用于非 bias 参数
Gradient Clip1.0防止早期 spike
Dropout0.1小模型可适当提高
训练轮数2token 量不够,适度重复

我在单张 24GB 显存的卡上跑,显存占用峰值约 18GB。如果显存更低,可以用更大的梯度累积步数把有效 batch 撑上去。预训练阶段的 loss 曲线从初始的 9.5 左右,一路降到 2.4 左右。这里有个很重要的判断:小模型的 loss 降得越快,不代表数据质量越好,很可能只是语料太简单。所以我会定期抽样生成一些句子,看模型是否在语法正确的前提下产生有语义的延续,而不是只会背语料。

3. CPT 继续预训练:让通用模型“回炉”中文与垂直语境

3.1 为什么 base 模型之后还要 CPT

按标题的顺序,预训练之后是 CPT。可能有人觉得,预训练不是已经训过中文了吗?为什么还要单独加一个继续预训练?如果你用的是纯中文语料从零预训练,CPT 确实可以跳过。但我的 base 语料里中英混训,模型对中文的理解只能说“够用”,一旦进入技术文档、客服对话这种行文风格更固定的场景,生成内容会明显变得泛泛而谈。

CPT(Continue Pre-Training)本质是在已训练好的 base 模型上,用特定领域的高质量数据继续做自回归训练。它的位置很微妙:太靠前,会被后续 SFT 冲掉;太靠后,英语基础又不够牢靠。我选择把 CPT 放在预训练和 SFT 之间,专用语料占比在 30% 左右。

3.2 数据配比与训练策略

CPT 的数据配比是:60% 中文通用语料、20% 代码与技术文档、20% 英文通用语料。注意最后那 20% 英文非常关键,它是用来“防遗忘”的。如果整个 CPT 阶段完全换成中文语料,模型对英文的理解会很快退化,预训练阶段积累的跨语言能力就亏了。这种混合比例的做法在领域继续预训练里很常见,效果也比单纯堆领域数据稳。

训练时我用了更保守的策略:学习率降到 2e-5,warmup 只设 200 步,训练 1 个 epoch。为什么学习率要降这么多?因为 base 模型已经收敛到比较好的状态,过大的学习率等价于把模型从当前最优位置粗暴地拽向新领域,容易造成灾难性遗忘。实测下来,CPT 后中文困惑度从 8.2 降到 5.6,英文困惑度只反弹了约 8%,属于可接受范围。

3.3 判断 CPT 是否生效

CPT 结束后的评估不能只看 loss。我当时专门准备了一组“领域探测”问题,例如让模型补全一段技术文档、续写一份客服工单,再看生成结果是否明显更贴合领域术语。如果 base 模型生成的是“你的问题我们已经收到,现在为您转接”,CPT 后的模型应该能生成包含具体处理流程的回复。这种感受只有人工检查才可靠,loss 下降并不等于真的学到了领域语义。

我在这里踩过最大的坑是:CPT 阶段数据里插入了大量“QA对”,但模型还没学会指令跟随,它只会把问题也当作正文的一部分继续复述,导致后面 SFT 时还要花额外精力清洗掉这种掺杂格式。CPT 语料尽量保持自然文本或半结构化文本,不要提前混入指令数据,各阶段的数据边界要清晰。

4. SFT 指令微调:把“会续写”变成“会听话”

4.1 指令数据集如何构造

SFT 是整个流水线里最能“人工干预”质量的环节。Xihe 的指令数据来源主要有三块:公开的 Alpaca 风格中文指令集、我自己人工写的大约 500 条高质量问答、以及从技术文档中自动生成的摘要与改写任务。总量 3 万条左右,对 0.12B 模型来说足够触发明显的格式变化。

数据格式我统一转成system / user / assistant三段式。system 里写明角色约束,例如“你是技术助手,请用简洁准确的中文回答”。user 是用户输入,assistant 是标准答案。训练时最重要的一个细节是:user 部分和 system 部分的 token 在计算 loss 时要 mask 掉,只让模型学习 assistant 部分的生成。否则模型会学到“复述用户问题”这种坏习惯,而不是真正回答问题。

4.2 SFT 训练细节与超参

SFT 阶段我直接用了 LoRA 来做参数高效微调,这是标题里 PEFT 在 SFT 中的首次落地。LoRA 只更新注入的低秩矩阵,训练参数量只有全参微调的 1.2%,显存峰值降到 12GB 以内。LoRA 的具体内容下一节展开,这里先给 SFT 的关键超参:

  • 学习率:2e-4,比全参微调高一个量级,因为 LoRA 可训练的参数量少,需要更大步长。
  • LoRA rank=16,alpha=32,作用于 attention 的 q、v、k、o 四个投影矩阵。
  • epoch=3,按 loss 和人工样例综合判断,跑到第 3 个 epoch 时已经开始轻微过拟合,但生成质量反而最好。
  • 使用 packing 方式把短样本拼接成 1024 长度,训练效率更高,但必须在样本边界插入 eos 并保持 attention mask 正确。

4.3 SFT 的效果怎么验收

SFT 的验收标准不是 train loss,而是三个硬指标:格式正确率、首 token 延迟、回复长度分布。我构造了 50 条固定测试题,覆盖问答、翻译、摘要、代码解释四类。格式正确率指回复是否以“assistant”的格式自然开始、是否有明显重复或截断。Xihe 在 SFT 前完全不会“回答问题”,它会像续写小说一样把用户的话接下去;SFT 后已经能输出像样的三段式回复,但内容深度依然有限,知识密度也不高,这是小模型的体能边界,不是训练方法的问题。

这里想提醒一句:SFT 不是数据越多越好,3 万条对 0.12B 模型已经很够用,堆到 10 万以上反而容易让预训练学到的通用知识被指令格式淹没。小模型的容量有限,指令数据要精细,不要贪多。

5. PEFT 参数高效微调:预算有限时,LoRA 是性价比主线

5.1 为什么不用全参微调

标题把 PEFT 单列在 SFT 之后,但我的实际操作里,SFT 和后续 DPO 都用的是 PEFT,所以这一章专门把方法论说透。

全参微调一个 0.12B 模型虽然也能跑,但在这个项目里我刻意不用:一方面,全参微调会把预训练学到的通用分布冲掉,小模型尤其明显;另一方面,后面 DPO 阶段通常要跑多个实验,如果每次都全参微调,存储和显存成本很夸张。PEFT 让我可以在同一个 base 模型上叠加不同任务的 LoRA 适配器,切换成本几乎为零。

5.2 LoRA 的原理与直觉理解

LoRA 的思路用一句话说:全量参数更新矩阵往往是低秩的,不需要直接学习一个完整矩阵,只要学两个小矩阵的乘积。假设原始权重矩阵 W 是 768×768,LoRA 注入一个 r=16 的低秩路径:A 矩阵是 768×16,B 矩阵是 16×768,训练时只更新 A 和 B,最后 W' 相当于原权重加了一个 AB 的乘积。

这样参数量从 589824 降到 24576,少了 96%。推理时可以把 AB 合并回 W,不增加额外显存。初始化时 B 通常置为 0,这样训练开始的一瞬间模型输出和原始模型完全一致,不会有突然的扰动。这个设计是 LoRA 能稳定训练的关键,自己在实现时可别把 A、B 都随机初始化。

5.3 LoRA 超参与调优经验

参数SFT阶段取值DPO阶段取值说明
rank168DPO用更低秩防止破坏SFT
alpha3216控制缩放幅度
target_modulesq,v,k,oq,v按需缩窄范围
lora_dropout0.050.0推理稳定性优先
biasnonenoneLoRA项目惯例

一个很反直觉的结论是:LoRA 的 rank 不是越大越好。rank 从 16 提到 64,SFT 的 loss 可以降得更快,但生成质量没有提升,反而更容易过拟合训练集的“机械答法”。DPO 阶段我甚至故意把 rank 降到 8,目的就是限制模型在偏好对齐时不要太大幅度偏离 SFT 的策略。

如果你显存特别紧张,可以进一步用 QLoRA,把 base 模型用 4bit NF4 量化后加载,再插入 LoRA 训练。但要做好训练速度下降约 20% 的预期。Xihe 的显存是够的,所以 QLoRA 我只做了验证实验没有正式使用。

6. 蒸馏:用大模型的“软答案”喂出一个能上生产的小模型

6.1 为什么要蒸馏,以及蒸馏谁的输出

SFT 和 DPO 之后,Xihe 对话质量已经能看,但 0.12B 模型的知识上限就摆在那。如果要把能力再提一档,最直接的方法是换更大的底座,但推理成本急剧上升。于是我用蒸馏做一个“能力移植”:让一个更大的教师模型(我这边用的是开源 7B 级模型)生成软化的输出分布,让 Xihe 去拟合这个分布。

蒸馏的本质是把教师模型在软概率里携带的“类间关系”教给学生。常规训练只给“正确答案”一个 one-hot 标签,学生不知道“苹果”和“水果”在语义上更接近;而教师模型的 logits 分布会把这种相似性体现出来,学生学到的信息密度大得多。

6.2 在线蒸馏还是离线蒸馏

我建议在个人项目里用离线蒸馏。在线蒸馏需要每个 step 都跑一遍教师模型推理,7B 模型对学生来说代价太大。离线流程是:

  1. 用一批高质量指令/对话,让教师模型逐条生成回复,保存回复文本或 logits。
  2. 学生对同样的输入做前向,和教师输出计算 KL 散度损失。
  3. 同时混入少量真实标注的交叉熵损失,防止学生只学“模仿”而没有标准答案约束。

蒸馏的 loss 公式我用的经典版:L = alpha * CE(student, hard_label) + (1-alpha) * T^2 * KL(student_logits/T, teacher_logits/T)。温度 T 设为 3,alpha 设为 0.3。T 越大,分布越平滑,学生能学到更多暗知识;但太大也会让学生输出变得过于保守,像在“念标准答案”。

6.3 蒸馏实操中的坑

首先要把学生的温度缩放同步处理。KL 项里教师和学生都要除以 T,计算完再乘回 T^2,这个缩放系数如果写错,loss 会明显波动。其次,教师模型的输出并不总是安全合理的,我用了一条规则:蒸馏数据里教师生成的每条回复都过一遍长度、重复度、敏感词过滤,不合格的直接丢弃。第三,蒸馏后小模型会有回复同质化倾向——五条不同提问可能得到结构高度相似的回答,这是 KL 散度把分布拉得过平的副作用,可以在 DPO 阶段用偏好对拉回多样性。

7. DPO 直接偏好优化:不用奖励模型也能拒绝坏输出

7.1 DPO 与 RLHF 的差异

最后到 DPO。传统 RLHF 要训练一个奖励模型,再通过 PPO 让策略模型在奖励模型的指导下更新,流程长、超参多、很容易崩。DPO 直接利用偏好对数据,从 Bradley-Terry 偏好模型中推出隐式奖励,一步到位优化策略。

DPO 的直觉可以用一个对比来说明:给定同一个提示,你有两个回答,一个是用户更喜欢的 chosen,一个是相对较差的 rejected。DPO 要做的事是——让策略模型在 chosen 上的概率相对参考模型更高,在 rejected 上的概率相对参考模型更低,同时用 KL 约束防止模型跑到参考分布太远的地方。这样就不需要显式的奖励模型了。

7.2 偏好数据怎么造

Xihe 的偏好对主要来自两个渠道:一是用 SFT 后模型对同一批指令做高温采样,生成多个候选回复,请标注者排序得到 chosen/rejected;二是用外部评分器对齐排序,评分标准包括准确性、格式、冗余度。最后保留 1600 个偏好对。数量不多,但对偏好对齐来说够用,DPO 数据质量比数量更重要。

构造偏好对时要特别注意三个问题:chosen 和 rejected 不能只是措辞略有差异,否则模型学不到实质偏好;同一指令下要保证两个回复长度不完全失衡,否则模型会投机取巧学“长回复更好”;不能把安全边界内的正常回答和安全拒绝回答混成一对,这会让模型在安全边界上表现错乱。

7.3 DPO 训练细节与效果

DPO 的标准 loss 形式是这样的:

loss = -log(sigmoid(beta * (log p_theta(y_w|x) - log p_ref(y_w|x) - log p_theta(y_l|x) + log p_ref(y_l|x))))

其中 beta 控制 KL 约束强度,我取 0.1。beta 越大,模型越不敢偏离参考模型;beta 越小,模型越激进地拉大 chosen 和 rejected 的概率差。0.1 是一个比较稳的起点。

DPO 训练时我用 SFT 后的模型既当策略模型初始值,也当参考模型。注意:参考模型的 logprob 要在训练前一次性算好缓存,而不是每个 step 重新算,否则成本高且不稳定。学习率设 1e-6,只训 1.5 个 epoch。多了会直接看到 chosen 和 rejected 的 margin 疯狂上升,但生成质量断崖式下跌。

实测效果:DPO 后最明显的变化是回复不再说车轱辘话,格式稳定性提高,碰壁问题会直接承认不知道而不是编造。比如在 SFT 阶段模型遇到超出知识范围的问题会硬编一个答案,DPO 后更倾向于给出“我不确定”或“建议查阅文档”的回复。这种行为不是 SFT 能简单教出来的,必须靠偏好数据对齐。

8. 全程最值得记下的坑:loss、遗忘、OOV 与重复生成

8.1 预训练 loss 不降,先怀疑数据而不是模型

很多人遇到预训练 loss 降不下去,第一反应是改模型结构,频繁加层、加头、调学习率。我的建议是:先做 100 步小规模测试,采样训练数据看 tokenizer 切分是否合理。Xihe 第一次跑的时候,我用一个没清洗干净的语料,里面大量“阅读全文”的重复模板,loss 在 6.0 附近卡了很久。后来把清洗管道加强,同样步数下 loss 就恢复正常下降趋势。模型结构在百万级数据量上很少是瓶颈,脏数据才是。

8.2 CPT 时的灾难性遗忘怎么控制

CPT 阶段最怕模型学会新领域、忘掉旧能力。我在训练日志里同时监控英文 loss 和中文 loss,发现中文 loss 下降的同时英文 loss 在 4000 步后开始抬头。解决方法是把英文通用语料从 20% 提到 35%,并降低学习率。这里有一个通用的教训:继续预训练不是“只喂领域语料”,而是在通用语料与领域语料之间找平衡。

8.3 OOV 问题与 tokenizer 的边界

BBPE 理论上不会出现严格意义的 OOV,但我发现如果对生僻中文字符做 byte 级切分,会切成一串无意义的字节碎片,生成时偶尔出现“锟斤拷”风格碎字。原因是我的词表训练语料里生僻字覆盖率低。解决方案是:训练词表时保证全字表覆盖,尤其是 GB2312 和 GBK 常用字范围全部收录。虽然 32K 词表对中文来说偏小,但通过 byte fallback 机制可以兜底,只是生僻字会以更多 token 表示,拖慢收敛。这个问题在 SFT 阶段不易察觉,但在知识类问答中一踩一个准。

8.4 生成循环“好的,好的,好的”怎么查

我排查过一个最经典的重复生成问题:模型在 50% 以上的测试样本里都会陷入“好的,好的,好的……”死循环。既然模型训练时 loss 已经降得比较好了,我怀疑是采样参数的问题。把温度从 0.8 调到 0.6,重复惩罚从 1.0 调到 1.2,循环明显减少但语义变得干瘪;最后发现根因是训练数据里有一段客服语料大量出现“好的,好的”作为回复开头,模型把这个高概率路径学得太牢了。清洗数据并重新补一轮 SFT 后,问题消失。这个坑说明,生成阶段的 post-processing 只能缓解问题,根源大概率在训练数据。

8.5 显存不够时的三板斧

总共 15GB 左右的数据量,加上 0.12B 模型,单卡训练是可以的,但 SFT 和 DPO 阶段如果序列长度设到 2048,显存还是会告急。我的处理顺序是:先降 batch size 并加大梯度累积步数,接着打开 gradient checkpointing,最后再用 LoRA 把训练参数量砍掉。不要一上来就降低序列长度,小模型长上下文能力本身弱,输入长度砍半会让效果雪上加霜。

9. 结算:Xihe 的最终形态与我的实操体会

整套流程跑完后,Xihe 变成了两个版本:一个是 0.12B 的“完整版”,保留全部能力,适合本地研究和二次微调;另一个是经过蒸馏的 0.05B 小号版,专门给低资源环境推理用。完整版在单张 4090 上大约 280ms 生成 128 token,吞吐量对我个人使用绰绰有余;小号版显存占用只要 1.6GB,速度还要再快一倍。能力上当然不能和主流的开源中文大模型比,但作为一条从零打通全链路训练pipeline的验证,我愿意给它打80分。

个人最真实的体会是:从零训练小语言模型,最大的价值不在模型本身,而在建立一套可迭代的训练手感。你会慢慢知道“数据比例调 5%”和“学习率调一倍”分别会带来什么反应,知道什么阶段看 loss、什么阶段必须看生成样例。别人给你一个 SFT 流水线,你不再是机械地跑通,而是能判断哪一步少了、哪一步数据不对。

最后再分享一个小技巧:如果要复现这套流程,建议先用一个很小的语料子集(比如 200MB 文本)打通全流程,几个小时内把预训练、CPT、SFT、LoRA、蒸馏、DPO 的代码全部跑通,再去冲完整数据。很多新手一上来就上完整 15GB 语料,结果卡在某个阶段好几天,连问题出在代码还是数据上都分不清。小规模试跑把风险前置,后面就是加数据和调参的事。Xihe 项目最大的收获,就是这个“先小后大”的节奏。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询