1. 为什么一个 0.4B 的对话模型值得从零训一遍
先把结论放在前面:本文说的“从零训练”,指的是不加载任何预训练权重、分词器也是自己训练的那种从头开始。模型规模控制在 0.4B 参数附近,训练数据规模在 1B tokens 左右,单张 24G 显卡,跑 40 到 60 小时,能产出一个可以日常对话、改写文本、做简单翻译和摘要的小模型。我管这个阶段性的实验项目叫 MiniMind——它不是一个现成仓库的复刻,而是我们在相似规模下重新验证整个训练流程的一套自建工程。
很多人会问:直接微调一个开源大模型不好吗?为什么还要自己从零训一个小模型?我在做这件事之前也犹豫过。但真正动手之后,才明白“从零”带来的价值不在最终效果,而在对训练流程的全链路感知。你会在数据环节发现大部分公开语料的冗余超乎想象;会在分词环节意识到词表大小对中文任务的影响比想象中更大;会在 loss 不降的时候被迫去理解学习率、梯度累积、数据顺序之间的关系。这些体验,是直接拿别人训练好的 checkpoint 微调永远无法获得的。
另外,0.4B 并不是一个“没用”的规模。它做不到复杂推理,但可以稳定完成设定类任务:它能够按固定格式输出,适合做端侧离线入口;它生成的延迟很低,在 CPU 上跑 4bit 量化后大约只需要 500MB 左右的内存;它还可以作为更大模型的前置过滤层,先筛掉明显无关的问题再转交给上层的通用模型。我的目标不是和几十B的大模型比智商,而是把它做成一个在受限环境里能用的轻量对话组件。
这篇笔记比较适合两类人:一是想做小型 LLM 但不知道从哪一步开始的学生或工程师;二是已经有微调经验、但对预训练环节比较陌生的人。我会尽量按一次实际跑通的时间线来写,数据、分词器、模型结构、训练配置、调参排查、SFT 这几个环节都会覆盖到,也不会跳过那些看起来琐碎但实际很关键的坑。
2. 数据先行:我用 1.2B tokens 语料踩出来的清洗经验
2.1 数据规模与来源构成
对于 0.4B 模型,经验上的预训练 token 量级在 20 到 50B 之间会更充分,但那样做需要至少 8 张卡跑一两周,不符合我这次“单卡快速验证”的定位。所以我压缩到了 1.2B tokens,用更少但更干净的数据去验证整个流程。这个量级对 0.4B 模型来说刚好处于“能出效果但不饱和”的区域,适合做流程验证。
数据构成我做了三块混合:中文通用文本约 84%、英文文本约 10%、代码数据约 6%。总量 1.2B tokens 的分布大概是中文 1.0B、英文 120M、代码 80M。中文部分主要来自开源的书库、百科类页面、技术博客和新闻语料;英文部分选的是结构清晰的百科与书籍;代码部分用的是一些 MIT 协议的开源仓库。这里必须强调,版权问题不是可以蒙混过关的点,宁可规模小一点,也要确保数据来源可追溯。
如果完全不放英文和代码,模型的中文流畅度不会受影响,但它在 API 名、代码片段、英文专有名词上的表现会明显变差。反过来,如果英文和代码占比太高,中文日常对话又会显得僵硬。最终我调的 0.7:0.2:0.1 这个比例是一个经过几轮小规模对比实验后的折中。
2.2 清洗管线的四个步骤
清洗流程我分成了四步,每一步都在原始文本上做严格过滤。第一步是语言过滤,用轻量语言检测库把非中英文的段落去掉,同时把中文、英文混杂但无效的噪声文本剔除。第二步是质量过滤,规则包括最小长度、标点密度、重复字符比例等。文本长度小于 50 字的段落大概率是碎片,直接删;重复字符比例大于 0.3 的内容往往是乱码;句子平均长度过长且缺少标点,可能是被抽取的半结构化文本,也不保留。
第三步是去重。我用了两层策略:先做基于 MinHash 的近似去重,再做一次基于文档前缀的精确去重。近似去重的目的是把改了几个字重复发布的新闻稿去掉,精确去重则是为了清理完全相同的文档副本。这一步非常关键,因为重复数据会以一种隐蔽的方式伤害模型:它会让模型对重复内容过拟合,同时降低它对同义表达的泛化能力。
第四步是内容安全与污染过滤。我会用一种基于关键词黑名单加分类器的方式,把敏感的、恶意的、低俗的内容直接移除,尤其是涉及特定人群攻击、暴力等内容,必须做到零容忍。同时还要处理“评测污染”的问题:如果预训练语料里混入了后续评测集的相似文本,最终指标会虚高,等到了真实场景就完全露馅。所以我把常见公开评测集的文本做了相似度过滤,凡是和评测样本相似度超过阈值的文档全部删除。
2.3 截断策略里的隐形坑
语料清洗时最容易忽略的是文档拼接和截断策略。起初我以为直接把长文档截成 1024 token 的块就可以,但跑了 5 万步之后发现,模型生成的段落经常在中间莫名其妙中断。排查后发现是因为我把大量文档暴力切块,切出来的后半段往往是不完整的句子。
正确做法是“干净切分”:先按段落边界把长文档拆成片段,再用\n\n把短片段拼接到接近 1024 token。拼接时在文档之间插入一个特殊的“分隔 token”,并在后续做 loss 计算时把分隔 token 对应的 loss 给 mask 掉。这样模型不会去学习“两个无关文档之间为什么会有这个符号”这种无意义模式,也能避免跨文档学习带来的语义串扰。
3. 分词器的选择:为什么我放弃了现成词表,自己训了一个 32K 的 BPE
3.1 照搬大模型词表的问题
最开始我图省事,直接拿了一个通用大模型用的分词词表来用。结果很快发现问题:词表大小 50K 甚至更大,和 0.4B 模型的 embedding 层一搭配,光词表参数就占了整体参数的 20% 以上。对千亿级模型来说,50K 词表不叫事,但对 0.4B 的小模型来说,这是巨大的参数浪费。更麻烦的是,这些词表大多是按英文优化的,对中文常见词覆盖并不理想,很多中文字在词表里是拆成单字或多字节碎片来表示的。
我统计了一下:用某个通用英文为主的词表处理中文文本时,平均每个中文token只能覆盖约 1.2 个字符,这意味着同样的中文内容需要更多的 token 才能表示。token 多了,训练和推理的速度都会变慢,而语义单位也被打碎,模型更难学到稳定的词级表示。
3.2 用 SentencePiece 训练分词的完整参数
所以我决定自己训练一个词表,规模定在 32K。32K 这个数字平衡了中文覆盖能力和 embedding 参数占用:对 0.4B 模型来说,32K×1024 的 embedding 大约是 32.8M 参数,占总参数的 7% 左右,合理。分词模型用的是 SentencePiece 里的 BPE,训练参数如下:
| 参数 | 取值 | 说明 |
|---|---|---|
| vocab_size | 32000 | 总词表大小 |
| model_type | bpe | 按字节对编码 |
| character_coverage | 0.9995 | 覆盖 99.95% 的字符,留少量未知符空间 |
| max_sentence_length | 8192 | 防止单条超长样本占满内存 |
| pad_id / unk_id / bos_id / eos_id | 0/1/2/3 | 固定特殊 token 位置 |
| normalization_rule_name | nmt_nfkc | 统一做 Unicode 标准化,保持版本稳定 |
词表训练语料用的是清洗后的全部中文和英文语料,从中随机采样 200 万条文本做训练。3000 步训练在 32 线程下大约花了 2 个多小时。训练完成后,我特别做了两个验证:一是随机抽 500 条中文句子重新分词,人工检查常见词是否被切开;二是统计“平均每句 token 数”在训练语料上的分布,确保没有异常的过长样本。
3.3 一些小 demo 里的分词表现
验证结果比较有意思。用自训词表分词,“深度学习是人工智能的分支”会被切成[深度学习, 是, 人工智能, 的, 分支],“MiniMind”会被切成[Mini, Mind],这是预期的;而用英文为主的词表,同样的句子会被切成[深, 度, 学, 习, 是, 人工, 智能, 的, 分支],明显碎得多。一个好的分词器能让模型在相同步数下更快地建立词义关联,这部分收益在后续训练曲线上是能看到的。
这里补一个容易出现的问题:不要在还没清洗的原始语料上直接训练词表。原始网页里的 HTML 标签、乱码符号、重复的导航文本都会混进词表,占用宝贵的词表位置。我第一版词表里就混进了大量class=,href=,http之类的碎片,后续用了几万步训练都没法把这些噪声从 embedding 里完全抹掉,最后只能回归重训词表,算是成本不小的一次教训。
4. 模型骨架拆解:6 层、1024 维、24 层的组合,参数量是怎么到 0.4B 的
4.1 网络结构设计
模型结构沿用标准的 decoder-only Transformer 设计,但做了几个适合小规模的调整。我的配置是:24 层、hidden size 1024、16 个注意力头、FFN 隐层 4096、上下文长度 1024、词表 32000。这些数字组合起来参数量约 0.43B,属于典型的 0.4B 级模型。
结构上没有用复杂的 MoE 或者长注意力变体,因为对小模型来说,标准结构稳定、易排查、生态工具多。激活函数用的是 SwiGLU,也就是把原来的 ReLU FFN 替换成门控线性单元,实际测试下来相比 GELU 在同样步数下 loss 能低 0.05 左右。位置编码用 RoPE,而不是传统的绝对正弦编码,理由有二:RoPE 对超出训练长度的情况外推效果更好,而且它天然把相对位置信息编码进注意力计算中,对短文本任务更友好。
这里想多说一句关于是否加偏置的问题。我最终在 attention 和 FFN 的线性层都不加 bias,原因是 0.4B 模型本身容量有限,bias 带来的参数增加虽小,但对训练的稳定性并没有太多帮助,去掉之后还能省一点显存。LayerNorm 用的是 pre-norm 结构,也就是先归一化再进子层。这个选择对训练稳定性非常重要,特别是在大规模 batch 下,pre-norm 的梯度传播比 post-norm 更平稳。
4.2 参数量是怎么算出来的
参数量的构成主要有三块。第一是 embedding 和 unembedding 共享参数,这部分是 32000×1024,约 32.8M。第二是 24 层 Transformer,每层包含 QKV 三个投影和输出投影,加上 FFN 的 up、gate、down 三个投影,每层参数约 16.8M,24 层合计约 403M。第三是 24 层 LayerNorm 的 scale 和 bias,每层约 4K 参数,共 96K,几乎可以忽略。
把这些加起来,总量约 436M。实际上从 0.4B 这个数字到后面的显存预估,只需要记住一部分就够了:真实可训练参数大约 0.44B,训练时的混合精度状态约需要 3.5 到 4 倍的显存开销。
4.3 推理阶段的 KV Cache 设计
训练之外,推理阶段的 KV Cache 也提前做了设计。context length 1024,hidden 1024,24 层,16 个头,每个头的维度是 64。每个 token 的 KV Cache 大小为 2×24×1024×16×64,约 6.3M 参数。以 FP16 存储,每个 token 约 12.6MB;如果生成 512 个 token,Cache 总量约 6.4GB。这个数据对一个 0.4B 模型来说不小,所以推理部署时,量化是几乎必然的选择。
我实测的是 4bit GPTQ 量化后,模型权重降到 230MB 左右,KV Cache 用 FP16 后整体运行在 1G 内存内是可行的。如果还想进一步压缩,可以把 KV Cache 也量化成 8bit,精度损失对对话任务影响不大。
5. 训练配置的取舍:单卡 24G 如何跑出一个 0.4B 模型
5.1 全局 batch size 与梯度累积的计算
训练用的是单张 24G 显存的 GPU,这是不少人手头就有的配置。0.4B 模型在这种卡上训练,核心不是“能不能”,而是“batch 该多大”。我选择的全局 batch size 是 128,每批 128 个样本,每个样本 1024 token,也就是单步处理约 131K token。这个量级对预训练来说偏低,但由于数据总量只有 1.2B,重复轮次增加,模型仍然能学到可用的语义结构。
单卡直接放 128 个 1024 token 的样本肯定放不下。实测下来 micro batch size 只能到 8,再多就 OOM。所以通过梯度累积把 128 拆成 16 个 micro step:每 16 步的梯度做一次更新,等价于一次更新 128 个样本。梯度累积的实现简单,但要注意两点:一是 mixed precision 下梯度缩放要正确;二是 optimizer state 的更新频率比普通 batch 低,学习率曲线需要相应调整。
5.2 优化器与混合精度的选择
优化器用 AdamW,权重衰减 0.1,beta 设为 0.9 和 0.95。学习率采用带 warmup 的余弦衰减曲线,峰值学习率 6e-4,warmup 500 步。这个峰值对我来说偏高,但对 1.2B 小数据量是合理的,因为数据量小,训练步数少,模型还没充分收敛就需要更大的更新步伐切入目标区域。
混合精度用 bf16 而不是 fp16。24G 卡对 bf16 有原生支持,bf16 的指数范围和 fp32 一样,省去了 fp16 容易出现的精度溢出和 scale 频繁调整问题。梯度检查点也开了,它让 activation 不缓存,而是前向时重新计算,显存大概节省了 30%,代价是训练速度下降了约 15%。在单卡场景下,这个取舍值得做。
5.3 跑通训练的实测配置表
以下是最终固定下来的训练配置,直接照抄基本能复现:
| 配置项 | 取值 |
|---|---|
| 模型参数 | 436M |
| 训练数据量 | 1.2B tokens |
| 全局 batch size | 128(16 个 micro batch × micro size 8) |
| 最大长度 | 1024 |
| 优化器 | AdamW |
| 峰值学习率 | 6e-4 |
| warmup 步数 | 500 |
| 权重衰减 | 0.1 |
| 混合精度 | bf16 |
| 梯度检查点 | 开启 |
| 总训练步数 | 约 9400 步 |
| 预计显存 | 约 20G |
实际训练时间约 48 小时,有几次因为显存波动中断过,补加了 checkpoint 保存频率,最终顺利跑完。单说训练效率的话,24G 卡跑 0.4B 完全可以接受,再往上到 0.7B 就开始吃力了。
6. 预训练 loss 不降时的排查思路:不能只盯曲线数字
6.1 一个正常的 loss 曲线长什么样
预训练初期,loss 的绝对值没有绝对标准,因为不同分词器、不同数据配比下的 loss 差异很大。我只能给出我这套配置的经验参考:随机初始化起步时 loss 在 11 左右,训练 1000 步之后降到 6 附近,5000 步后到 4.2 左右,最终在 9400 步训练结束时约 3.8。这里的 loss 是交叉熵,也即平均每个 token 的负对数似然。
比绝对数值更重要的是曲线的形状。正常的 loss 曲线应该是前 2000 步快速下降,之后缓慢平滑下降。如果出现平台期后突然反弹,往往不是模型容量不够,而更可能是数据顺序或学习率的问题。我在训练到 3800 步时遇到过一次 loss 反弹,从 4.5 涨到 4.8 再缓慢回落,回查日志发现是因为中途把一份比较大的代码数据块整块插入到训练队列,导致 batch 内数据分布突变。
6.2 数据顺序与重复带来的假象
小模型预训练很容易被数据顺序带偏。如果你的数据是“先中文后英文”的顺序喂进去,模型在中文段训练完后再进入英文段,会短暂地“忘记”一部分中文能力,表现为中文评测指标下降。解决办法是让多语种数据在每个 batch 内均匀混合,而不是按文件顺序串行输入。
另一个隐蔽问题是“重复轮次过多”。1.2B 数据让 0.4B 模型只跑不到 1 epoch,效果会不够;但我为了加速收敛,把整份数据重复了 12 个 epoch。结果模型对训练集中的常见句子产生了明显背诵倾向,生成时偶尔会整句复现训练语料中的原句。后来把重复轮次降到 8,并在每个 epoch 内对数据做随机重排,背诵现象才得到缓解。小模型不是不能多轮重复,而是必须看清楚它开始“背答案”的临界点。
6.3 学习率调整与重启训练
排查 loss 问题时,我建议先从三个维度逐个排查:数据、学习率、模型尺度,而不是同时改所有东西。当我发现 loss 在 4000 步后几乎持平,尝试把学习率从 6e-4 降到 3e-4 之后,曲线重新开始下降,这是典型的“学习率过大导致 loss 进入震荡瓶颈”。如果降低学习率后下降幅度还是不理想,再考虑增加层数或 hidden size。
如果已经跑了大量步数才发现问题,不要怕重新开始。我在 7000 步时因为数据配比调整决定回滚 checkpoint 到 3000 步重新训练,虽然多花了 8 小时,但结果比硬着头皮继续跑要好很多。因为前面学到的低层文本特征并没有浪费,从 3000 步继续是合理的。
注意:保存 checkpoint 时不要只存模型权重,一定要把 optimizer state、学习率调度器状态、数据加载器位置都存下来。我因为漏存 optimizer state,在回滚时丢失了优化器状态,导致训练前几百步的波动,白白浪费了时间。
7. SFT 阶段:数据构造、对话模板与 loss mask
7.1 指令数据的来源与结构
预训练结束后,进入了 SFT 阶段。这个阶段的目的是把模型从“续写文本”引导到“遵循指令”。我准备的数据量是 30 万条指令样本,涵盖对话、问答、翻译、摘要、改写、代码生成、分类抽取等任务。数据来源以开源指令集为主,配合一部分自己攒的出题模板,经过清洗和去重后,再让一个更强的通用模型做一轮答案改写,把过短和包含无意义回复的样本剔除。
指令样本必须统一成同一种对话模板。模板格式包含 system 提示、user 输入和 assistant 输出,特殊 token 固定为设定的[BOS]、[EOS]、[USER]、[ASSISTANT]、[SYSTEM]。在拼接时,所有字段之间用对应的特殊 token 隔开。测试集上验证表明,模板一致性直接影响微调的稳定性,如果混用多种模板,模型在生成时经常出现“回答到一半换个格式”的毛病。
7.2 只对答案部分计算 loss
SFT 的一个关键技术点是 loss mask,即损失只在 assistant 输出的 token 上计算。如果不做 mask,模型会把大量学习算力花在预测“用户问了什么”上,而对真正的答案反而权重不足。mask 实现并不复杂:在构造 batch 时,对非答案 token 的位置给一个 -100 的标签,常规 CrossEntropyLoss 会自动忽略它们。
训练时我采用全量微调,而不是 LoRA。0.4B 模型全量微调在单卡 24G 上完全可行,而且不会引入低秩适配带来的表达力损失。SFT 的峰值学习率设置为 2e-5,比预训练低一个量级,并加了 200 步 warmup。过高的学习率会让模型把预训练阶段学到的语法和语义能力快速覆盖,导致生成变得生硬。
7.3 对话样本与单轮指令的混合比例
SFT 数据不能全是单轮问答。如果模型只在单轮问答上训练,它不会记住上下文。我最终采用的单轮指令、多轮对话、系统指令类样本的混合比例约为 5:4:1。多轮对话能让模型学会在上下文中取用历史信息,系统指令类样本则让模型学会遵循固定的角色设定。这个比例经过少量对比实验验证,比单纯堆单轮指令的效果要好。
另外建议在 SFT 阶段加入少量“拒绝类”样本,例如用户提出超出能力范围的问题时,模型应该诚实说明自己不知道,而不是强行编造答案。这虽然会略微降低流畅度,但对真实使用体验的提升很明显。
8. 上线前的评估与后续扩展方向
8.1 自动评测加人工抽检的评估方案
SFT 结束后,我做了两轮评估。第一轮是自动评测,用常见的几类中文基础能力评测集做对比,覆盖常识问答、中文理解、数学计算、代码生成等。0.4B 模型的分数当然不能与主流大模型相比,但我关注的是它在同一评测集下与同等规模其他模型的相对水平,确认流程跑通后没有出现明显偏差。第二轮是人工抽检,选 200 条真实用户风格的问题,包含闲聊、专业问答、开放性写作等,按流畅度、相关性、有害性三项打分。
人工抽检暴露了一个自动评测发现不了的问题:模型喜欢在拿不准的答案后面添加大量模糊描述,表面上看着完整,实际没有信息量。后来我在 SFT 数据里加入了更多“简洁回复”的样本,让模型学会在不确定时给短答案,这个现象才有所缓解。
8.2 我在整个流程里踩过的几个典型坑
总结这次从零训练的心得,有几个坑值得单独说。分词器词表必须和模型结构、数据规模一起考虑,不能照搬现成方案;数据清洗比模型结构更重要,1.2B 数据里只要混入 1% 的垃圾内容,最终生成质量就会有可感知的下降;SFT 数据宁可少而精,也不要多而杂,劣质样本对模型回复的负面影响在 0.4B 这种小容量模型上会被放大。
关于继续优化的方向,排在第一位的是加大数据量。从 1.2B 翻到 4B tokens 并维持同样结构,预计效果会有明显提升。第二位是引入更可控的对齐方法,在 SFT 之后进行一轮偏好优化,让模型更稳定地拒绝不合适的内容。第三位是做更激进的量化部署,把模型压到 4bit 并跑通端侧推理,让 0.4B 模型不只是训练实验,而能变成实际产品中的一个可用组件。