1. 项目概述:从零炼钢,一个中文大模型的诞生记
去年年初,我决定做一件在很多人看来有点“疯狂”的事:用个人能调动的有限资源,从零开始预训练一个中文大语言模型。这个想法源于一个很朴素的念头——市面上优秀的开源模型很多,但真正从数据清洗、模型设计到训练框架都完全开源,并且详细记录每一步“踩坑”经验的中文项目,似乎并不多见。我们想证明,即使没有大厂动辄成千上万张卡的计算集群,凭借对技术的热爱和“土法炼钢”般的执着,也能炼出一块好“钢”。于是,Steel-LLM项目诞生了。
这个名字的灵感,确实来自万能青年旅店乐队。他们用有限的设备做出了惊艳的专辑,我们则希望在有限的算力下,训练出一个在中文理解与生成上表现扎实的模型。整个项目历时八个月,从数据爬取、清洗、到模型结构设计、训练框架魔改,再到最终的微调与评测,每一步都充满了挑战与收获。最终,我们得到了一个约11.2亿参数的模型,在仅使用约1T Token的中文为主数据预训练后,其在C-Eval和CMMLU这两个权威中文评测基准上,分别达到了41.9和36.08的分数。这个成绩,已经超越了早期一些参数量更大的知名模型,证明了“小模型+高质量数据+精心设计”路线的可行性。
这篇文章,我会以一个全程参与者的身份,为你彻底拆解 Steel-LLM 的构建全过程。这不是一篇冷冰冰的技术报告,而是一份热气腾腾的“炼丹”手记。我会重点分享那些在论文和官方文档里不会写的细节:为什么选这些数据?清洗时到底删掉了什么?模型结构改动背后有什么权衡?训练脚本里那几个关键的参数是怎么调出来的?以及,当你在8张H800上跑一个月却看到损失曲线纹丝不动时,该如何排查问题。无论你是对LLM训练充满好奇的初学者,还是正在筹划自己小规模训练项目的实践者,希望这份长达数万字的“流水账”,能给你带来实实在在的参考和启发。
2. 核心思路与设计哲学:为什么是“小而精”?
在启动项目前,我们面临第一个也是最重要的抉择:做大模型还是小模型?当时的背景是,千亿参数模型层出不穷,但训练成本对个人和中小团队来说是天文数字。我们的核心思路非常明确:放弃对参数规模的盲目追求,转向对数据质量和训练效率的极致优化。目标是打造一个在1B参数级别上,中文能力尽可能强的“精品”模型。
2.1 目标定位与关键决策
这个定位决定了后续一系列技术选型:
- 模型规模锚定1B左右:这个参数量在8张80G显存的GPU上(如H800/A100),刚好可以放下较大的批量大小(batch size)进行高效训练,同时模型容量又足以学习复杂的语言规律。它是性价比的甜点区。
- 数据以中文为核心:既然目标是中文模型,就必须确保训练语料的中文质量和占比。我们设定了80%以上为中文数据的目标,剩余部分用高质量的英文代码和文本作为补充,以提升模型的逻辑和代码能力,避免成为“中文偏科生”。
- 训练效率优先:在有限算力下,时间就是金钱。因此,我们在模型结构(如引入MoE)和训练框架(如优化数据加载)上,所有改动都围绕一个核心:在相同计算成本下,让模型“吃”进更多有效数据,学得更快更好。
- 全过程开源与可复现:这不是一个黑箱项目。我们从第一天起就决定开源所有代码、详细记录数据处理pipeline、甚至分享中间checkpoint。目的是降低门槛,让后来者能站在我们的肩膀上,甚至复现整个流程,这比单纯追求榜单分数更有长期价值。
2.2 与同类方案的对比思考
当时,社区已有一些优秀的1B-3B级别开源模型,如TinyLlama、Qwen1.5-1.8B等。我们并非要简单重复它们的工作。
- 与TinyLlama对比:TinyLlama用3T Token训练了1.1B模型,数据量极大,但其中文数据占比和针对性处理不足。我们的策略是,用更少但更精的中文Token,达到可比甚至更优的中文性能。事实证明,在C-Eval上,Steel-LLM显著优于TinyLlama。
- 与Qwen1.5对比:Qwen是优秀的双语模型,但其1.8B版本是在大规模多语料上训练的。我们想探索的是,如果集中火力在中文上,模型的中文潜力到底有多大。这是一种差异化的竞争思路。
- 与从头训练对比:完全从随机初始化开始训练一个LLM,周期长、风险高。我们选择了基于成熟架构(Qwen1.5)进行修改,这相当于站在了巨人的肩膀上,避免了在基础架构上踩不必要的坑,能把精力集中在数据和质量提升上。
这个“小而精”的设计哲学,像一根红线,贯穿了数据、模型、训练的所有环节。
3. 数据工程:高质量的“钢”源于优质的“矿石”
大模型训练圈有句名言:“Garbage in, garbage out.”(垃圾进,垃圾出)。数据是模型的“粮食”,其质量直接决定了模型的上限。我们的数据工作占据了项目前期绝大部分时间,可以概括为“广开源、精处理”。
3.1 数据收集:构建多元化的中文语料库
我们收集了多种类型的数据,旨在让模型获得广泛的知识和技能。下表概括了我们的数据构成:
| 数据类型 | 代表数据集 | 主要作用 | 数据量级(估算) |
|---|---|---|---|
| 通用网页文本 | SkyPile-150B, WanJuan1.0 | 提供海量、多样化的中文语言建模素材,覆盖新闻、百科、论坛等。 | ~600B Tokens |
| 高质量百科 | 中文维基过滤版、百度百科 | 提供结构化、事实性知识,是模型“常识”和“事实”的重要来源。 | ~20B Tokens |
| 问答与对话 | 百度百科问答、知乎问答、BELLE、MOSS、Firefly | 训练模型的理解、推理和对话能力,学习人类交互模式。 | ~150B Tokens |
| 代码 | StarCoderData | 提升模型的逻辑思维、结构化输出和代码生成能力。 | ~50B Tokens |
| 其他混合数据 | 各数据集的剩余部分 | 补充长文本、多领域内容,增加数据多样性。 | ~180B Tokens |
选择这些数据的理由:
- SkyPile & WanJuan:它们是当前开源社区规模最大、经过一定清洗的中文网页文本集合,是语言模型训练的“主食”。
- 百科数据:维基和百度百科的准确性相对较高,能有效注入知识。我们特别使用了过滤后的中文维基,移除了大量非中文或低质量条目。
- 问答/对话数据:这是让模型“会说话”的关键。我们混合了多种来源,既有百度百科的QA对(知识性强),也有知乎的讨论(逻辑性强),还有BELLE、MOSS等人工构造的指令数据(指令跟随能力强)。
- 代码数据:即使目标是中文模型,代码数据的引入也至关重要。它能显著提升模型的逻辑严密性和格式规范性。
实操心得:数据源的“坑”与应对
- 重复与噪音:像SkyPile这类大型爬取数据集,内部重复和低质量文本很多。绝对不能直接使用,必须经过后续严格的清洗流水线。
- 格式混乱:不同数据集格式千差万别,有纯文本、JSON、XML、Markdown等。第一步必须做格式统一,我们将其全部转化为
{"text": "..."}的JSONL格式,这是后续处理的基础。- 版权与合规:我们只使用明确开源许可的数据集,并在项目中注明出处。对于个人项目,这是避免法律风险的红线。
3.2 数据处理流水线:从原始数据到训练样本
这是最繁琐但也最见功力的部分。我们构建了一个三步流水线,核心工具是Data-Juicer。
第一步:格式统一与初步合并我们编写了step1_data_process.py脚本,针对上述四类数据(简单文本、对话、代码、其他)分别写解析器。例如:
- 对于百度百科(每个条目是独立的JSON),我们需要把
title和多个paragraph字段拼接成一个连贯的文档。 - 对于多轮对话数据(如BELLE),我们将多轮对话按特定模板拼接成一段长文本,例如:
“人类:...\n\n助手:...\n\n人类:...”。这里的关键是模板要与后续微调阶段使用的对话模板保持一致,否则会导致训练目标不一致。
第二步:Data-Juicer 强力清洗这是提升数据质量的核心步骤。我们配置了一个包含多种“算子”的流水线,每个算子像一道工序,过滤或清洗数据。我们主要的算子包括:
- 语言识别过滤:只保留中文占主导的文本,过滤掉纯英文或其他语言的段落。
- 重复文本删除:包括文档内重复、跨文档重复。我们使用了模糊去重,能识别出高度相似的段落。
- 特殊字符/乱码过滤:清除包含过多乱码、无意义字符的文档。
- 文本长度过滤:删除过短(如少于100字符)的文档,它们信息量不足;也截断或删除极长的文档,以适应模型上下文长度。
- 关键词过滤:根据一个自定义的负面词列表(如涉及暴力、色情等),过滤掉相关内容。
- 文本质量评估:使用一些启发式规则,如标点符号比例、句子平均长度等,过滤掉过于混乱的文本。
运行命令很简单:sh run_step2.sh,但背后是数天的参数调优和效果验证。一个重要的经验是:清洗不宜过度。一开始我们设置非常严格的过滤条件,结果发现数据量损失了40%,训练出来的模型语言变得非常“干瘪”且缺乏多样性。后来我们适当放宽了规则,在“干净”和“丰富”之间找到了平衡点。
第三步:生成二进制训练文件清洗后的文本数据(JSONL格式)需要被转换成模型训练时直接读取的二进制格式(.bin文件),以加速数据加载。我们修改了TinyLlama项目中的prepare_steel_llm_data.py脚本。这一步的核心是Tokenization(分词)。
我们没有自己训练分词器,而是直接使用了Qwen1.5-MoE-A2.7B-Chat 的分词器。理由如下:
- 质量有保障:Qwen的分词器对中文非常友好,分词粒度合理,能有效处理中英文混合。
- 生态兼容:使用相同的分词器,方便我们直接加载Qwen的模型权重进行修改,也方便后续与Qwen系列模型进行对比和迁移。
- 节省成本:训练一个高质量的分词器也需要大量的数据和计算,这对于我们的小项目来说性价比不高。
脚本会读取所有JSONL文件,使用Qwen分词器将文本转换成Token ID序列,然后按照固定的上下文长度(我们设置为2048)进行切分和打包,最后保存为二进制文件。这里有一个细节:务必在打包前打乱所有数据!确保每个二进制文件内包含的数据是随机的,这能防止模型在训练初期过拟合到某个特定数据源。
4. 模型架构设计:在Qwen1.5基础上做“微创手术”
我们并没有从头设计一个全新的模型架构,那需要极大的理论功底和实验验证。相反,我们选择了Qwen1.5-1.8B这个优秀的架构作为基础,并对其进行了两处关键“手术”,旨在不显著增加参数量的前提下提升模型容量和训练效率。
4.1 基础架构:为什么是Qwen1.5?
Qwen1.5 系列模型采用了标准的Decoder-Only的Transformer架构,但其在位置编码(RoPE)、激活函数(SwiGLU)、归一化层(RMSNorm)等方面都采用了当前主流且有效的选择。更重要的是,它在1.8B这个尺度上已经表现出了良好的性能,证明了其架构的有效性。从它出发,风险更低,迭代更快。
4.2 核心改造一:Softmax MoE FFN 层
这是我们对模型最大的改动。MoE(Mixture of Experts,混合专家)是一种稀疏化技术,它用多个“专家”网络(Expert)替换掉原本Transformer中每个FFN层,并通过一个门控网络(Gating Network)为每个输入Token动态选择少数几个专家进行计算。
- 传统MoE的问题:通常使用Top-K门控,即只激活K个专家(如K=2)。但这在训练中会带来两个问题:1)专家负载不均衡,有些专家总是被选中,有些则永远“躺平”;2)训练不稳定。
- 我们的选择:Softmax MoE:我们采用了Softmax MoE的门控机制。简单来说,它对所有专家的输出进行加权求和,权重由Softmax函数产生。虽然理论上每个Token都会用到所有专家,但Softmax的性质会让权重集中在少数几个专家上,实现“软”稀疏性。
- 带来的好处:
- 更高的模型容量:我们设置了8个专家。虽然前向计算时每个专家都参与,但由于权重稀疏,计算量的增加是可控的。但模型的可学习参数大大增加了,这意味着模型具有更强的表达能力。
- 更稳定的训练:Softmax门控避免了“赢家通吃”,所有专家都能得到训练,缓解了负载不均衡问题。
- 更快的训练速度:在相同参数量下,MoE模型由于稀疏激活,其训练速度往往比同等参数量的稠密模型更快。这对于我们计算资源有限的场景至关重要。
具体实现细节:我们替换了Qwen1.5模型中所有的FFN层为MoE FFN层。每个专家本身的结构与原FFN相同(即两层全连接+SwiGLU激活)。门控网络是一个简单的线性层,其输出维度等于专家数量,再接Softmax。
4.3 核心改造二:双层SwiGLU
SwiGLU(Swish-Gated Linear Unit)是当前大模型主流的激活函数,其形式为Swish(xW) * (xV),其中Swish是x * sigmoid(x)。在原Qwen1.5中,FFN层是SwiGLU(xW) * (xV)后再经过一个输出投影层。
我们的改动是,在每个专家的内部,使用两层SwiGLU。即:SwiGLU(SwiGLU(xW1) * (xV1)) * (xV2)。这相当于增加了FFN的深度。
- 设计动机:有研究表明,增加FFN的深度比增加宽度更能有效提升模型性能,尤其是在中等参数量下。双层SwiGLU可以引入更复杂的非线性变换,让模型具备更强的特征组合与抽象能力。
- 参数与计算权衡:这确实会增加一些参数和计算量。但在MoE的稀疏结构下,这部分增加是我们可以接受的。我们通过调整中间层的维度,确保最终模型的总参数量控制在1.1B左右。
注意事项:模型修改的“蝴蝶效应”
- 初始化至关重要:修改了模型结构,尤其是MoE层,必须谨慎初始化权重。我们采用了与Qwen原FFN层相似的初始化方法,并对门控网络进行了小幅度的初始化,避免一开始就出现极端权重。
- 梯度检查:改完模型后,第一件事不是直接训练,而是写一个简单的脚本,输入随机数据,检查前向传播是否通畅,反向传播的梯度是否存在NaN或Inf。这能提前发现很多结构性问题。
- 与小规模实验验证:在投入大规模预训练前,我们用极小的数据集(比如1GB文本)和极小的步数(1000步)跑一个“冒烟测试”,观察损失曲线是否能正常下降。这是验证模型改动有效性的最快方法。
5. 训练框架与实战:让8张卡跑出效率
模型和数据准备好了,接下来就是漫长的训练过程。我们基于TinyLlama的训练代码进行了深度定制,因为它设计简洁、效率高,且原生支持FSDP(完全分片数据并行),非常适合多卡训练。
5.1 对训练框架的关键改进
原始的TinyLlama代码主要针对其自身模型设计。为了适配我们的项目和工业级训练需求,我们做了四处关键改进:
- 兼容Hugging Face格式:这是为了生态。我们修改了模型加载逻辑,使其能直接读取和保存成标准的Hugging Face
model.safetensors和config.json格式。这意味着训练出的任何中间checkpoint,都可以直接用from_pretrained加载,方便后续的微调和评测。 - 完善的数据断点续训:训练过程可能因为各种原因中断(机器故障、维护等)。简单的续训可能导致数据epoch错乱。我们实现了精确的数据状态恢复。不仅保存优化器、学习率调度器的状态,还精确保存了数据加载器的随机数种子和当前处理到的文件位置。确保重启后,模型接着上次中断的地方继续学习,不会重复或跳过数据。
- 数据一致性校验:在将处理好的.bin文件投入训练前,我们增加了一个校验脚本。它会随机采样一些数据,解码回文本,人工检查是否有乱码、错位等问题。同时,在训练脚本中,每个epoch开始时也会打印几条样本的原文,作为双重保障。
- 动态数据追加:在训练中期,我们可能获得了新的高质量数据。传统做法是重新预处理所有数据,然后从头训练或合并数据集,非常麻烦。我们修改了数据加载模块,使其支持在不影响已训练数据顺序的情况下,将新的.bin文件追加到现有数据集末尾。新数据会在下一个epoch中自动加入训练循环。
5.2 超参数配置:我们的“炼丹”配方
超参数设置是训练的灵魂。以下是我们经过多次实验后确定的最终配置,适用于8张H800/A100(80GB)的环境:
# 模型相关 model_name: "qwen1.5-1.8b-moe" # 我们的自定义架构 hidden_size: 2048 intermediate_size: 5504 # 对应专家内部维度 num_attention_heads: 16 num_hidden_layers: 24 num_experts: 8 # MoE专家数 vocab_size: 151936 # Qwen分词器大小 # 训练相关 batch_size: 4 # 每张GPU的微批次大小 gradient_accumulation_steps: 16 # 梯度累积步数 # 全局批次大小 = batch_size * gradient_accumulation_steps * GPU数量 = 4 * 16 * 8 = 512 total_batch_size: 512 max_lr: 3e-4 # 峰值学习率 min_lr: 3e-5 # 最低学习率 weight_decay: 0.1 max_grad_norm: 1.0 # 梯度裁剪 # 优化器与调度器 optimizer: AdamW betas: (0.9, 0.95) lr_scheduler: cosine_with_warmup warmup_steps: 2000 # 学习率预热步数 # 训练周期 max_train_steps: 1,060,000 # 总训练步数 context_length: 2048 # 上下文长度 # 日志与保存 logging_steps: 10 save_steps: 20000 # 每2万步保存一个checkpoint关键参数解读与调优经验:
- 全局批次大小(512):这是影响训练稳定性和最终性能的核心参数之一。太大容易不稳定,太小则效率低下且可能收敛不佳。512是一个在1B模型上被广泛验证的稳健值。我们通过
梯度累积来在有限的GPU内存下实现这个大批次。 - 学习率与预热:3e-4对于1B模型是较高的学习率,因此预热(Warmup)至关重要。我们用2000步将学习率从0线性提升到3e-4,这能防止训练初期梯度爆炸。之后采用余弦退火下降到3e-5。
- 梯度裁剪(max_grad_norm=1.0):这是训练稳定性的“安全带”。当梯度的L2范数超过1时,将其缩放回1。能有效防止因个别样本导致的梯度爆炸。
- 训练步数(106万步):根据总数据量(约1T Token)和全局批次大小(512)计算得来。大致对应模型看了约1T Token(512 * 2048 * 1,060,000 ≈ 1.1T)。我们观察到损失曲线在约100万步后基本平稳,因此选择了这个值。
5.3 训练过程实录与监控
训练命令如下:
torchrun --nproc_per_node=8 \ --nnodes=1 \ --node_rank=0 \ --master_addr=localhost \ --master_port=6000 \ pretrain_steel_llm.py \ --config configs/steel_llm_config.yaml我们使用Weights & Biases (WandB)进行实时监控。下图展示了训练损失(Loss)和学习率(Learning Rate)的变化曲线(此处为文字描述):
- 0-2000步:损失快速下降,学习率从0上升至峰值。这是模型的“热身”阶段。
- 2000-50万步:损失平稳、缓慢地下降,是模型学习语言规律的主要阶段。学习率按余弦曲线缓慢下降。
- 50万-100万步:损失下降速度进一步放缓,进入“平台期”。模型开始学习更细微的语义和知识关联。
- 100万步之后:损失基本稳定,小幅波动。此时继续训练收益很小,我们选择在106万步停止。
整个训练在8张H800上持续了约30天。如果使用A100,时间大约会翻倍(60天)。这凸显了硬件对个人研究者的重要性。
6. 监督微调与评测:从“通才”到“专才”
预训练得到的模型是一个“通才”,它掌握了语言的统计规律和大量知识,但还不擅长遵循指令、进行对话或回答特定问题。这就需要监督微调(Supervised Fine-Tuning, SFT)。
6.1 SFT数据与策略
我们使用了高质量的指令微调数据集,包括Alpaca格式的中文翻译数据、Belle的多轮对话数据、以及我们自己构造的一部分高质量QA对。关键策略如下:
- 保持中英文比例:为了不破坏预训练阶段建立的语言分布,我们确保SFT数据的中英文比例与预训练数据大致相同(约8:2)。
- 对话模板统一:所有SFT数据都统一转化为与Qwen-Chat模型相同的对话格式:
<|im_start|>system\n...<|im_end|>\n<|im_start|>user\n...<|im_end|>\n<|im_start|>assistant\n...<|im_end|>。这与我们在数据预处理阶段拼接多轮对话时使用的模板一致,确保了训练连续性。 - 两阶段微调:
- v1版本:仅使用中文指令数据微调。在C-Eval上达到38分,CMMLU达到33分。
- v2版本:加入了与预训练比例匹配的英文指令数据。效果提升显著:C-Eval从38分提升至41.9分,CMMLU从33分提升至36.08分。这证明了即使在SFT阶段,保持与预训练一致的多语言分布,也能激发模型的潜力。
6.2 评测结果深度分析
我们主要关注C-Eval(中文学科知识评测)和CMMLU(中文综合知识评测)这两个权威的中文基准。我们的评测目标是:在1B参数级别,做到中文能力的最优。
从我们项目正文中的对比表格可以清晰看出:
- 对比同量级模型:Steel-LLM(41.9/36.1)显著优于TinyLlama-1.1B(25.0/24.0)、Gemma-2B(32.3/33.1)、Phi-2(23.4/24.2)等。甚至超过了参数量更大的DeepSeek-Coder-1.3B(28.3/27.8)。
- 挑战更大模型:我们的分数接近或超过了部分早期的7B模型,如ChatGLM-6B(38.9/-)、OLMo-7B(35.2/35.6)。与同为中文优化的MAP-NEO-7B(57.0/55.0)和Qwen-7B(59.0/60.4)相比仍有差距,但这符合参数量的客观规律。
- 与顶尖小模型对比:与同样优秀的CT-LLM-SFT-2B(41.5/41.5)和MiniCPM-2B(49.1/51.0)相比,我们在1.1B参数下能达到这个水平,证明了我们“小而精”路线的有效性。特别是考虑到CT-LLM和MiniCPM都采用了更复杂的训练技术或更大的数据量。
结论:Steel-LLM在1B参数规模上,实现了极具竞争力的中文能力。其成功主要归因于:高质量、高比例的中文预训练数据、针对训练效率优化的MoE架构以及与预训练分布保持一致的微调策略。
7. 实战指南:如何快速使用与复现
如果你对Steel-LLM感兴趣,无论是想直接使用,还是想复现我们的训练过程,这里提供最直接的指南。
7.1 快速体验模型
最简单的方式是通过ModelScope或Hugging Face加载模型。以ModelScope为例:
from modelscope import AutoModelForCausalLM, AutoTokenizer model_name = "zhanshijin/Steel-LLM" # 自动推断设备(GPU/CPU)和数据类型(fp16/bf16) model = AutoModelForCausalLM.from_pretrained( model_name, torch_dtype="auto", device_map="auto" ) tokenizer = AutoTokenizer.from_pretrained(model_name) # 构建对话 prompt = "请用Python写一个快速排序函数。" messages = [ {"role": "system", "content": "你是一个乐于助人的AI助手。"}, {"role": "user", "content": prompt} ] # 应用Qwen格式的对话模板 text = tokenizer.apply_chat_template( messages, tokenize=False, add_generation_prompt=True ) model_inputs = tokenizer([text], return_tensors="pt").to(model.device) # 生成 generated_ids = model.generate( **model_inputs, max_new_tokens=512, do_sample=True, # 启用采样,使输出更多样 temperature=0.8, # 温度参数,控制随机性 top_p=0.9 # 核采样,控制输出质量 ) generated_ids = [ output_ids[len(input_ids):] for input_ids, output_ids in zip(model_inputs.input_ids, generated_ids) ] response = tokenizer.batch_decode(generated_ids, skip_special_tokens=True)[0] print(response)7.2 复现训练环境与步骤
如果你想在本地或自己的服务器上复现整个训练流程,请遵循以下步骤:
硬件要求:
- 最低配置:8张显存 >= 40GB 的GPU(如A100 40G, RTX 4090D 24G*2 通过梯度累积模拟)。推荐使用8张H800/A100 80G以获得最佳体验。
- 存储:至少4TB的高速硬盘(NVMe SSD最佳),用于存放原始数据、中间文件和checkpoint。
- 内存:系统内存建议 >= 512GB,用于大数据处理。
软件环境:
- 安装Python 3.10+。
- 安装PyTorch 2.0+(需与CUDA版本匹配)。
- 克隆我们的仓库:
git clone https://github.com/zhanshijinwat/Steel-LLM.git - 安装项目依赖:
pip install -r requirements.txt。核心依赖包括:transformers,datasets,accelerate,deepspeed(可选),wandb,>