0.4B对话模型从零训练实战:数据清洗、分词器与训练配置全解析
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 的 BPE3.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_size32000总词表大小model_typebpe按字节对编码character_coverage0.9995覆盖 99.95% 的字符留少量未知符空间max_sentence_length8192防止单条超长样本占满内存pad_id / unk_id / bos_id / eos_id0/1/2/3固定特殊 token 位置normalization_rule_namenmt_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.8M24 层合计约 403M。第三是 24 层 LayerNorm 的 scale 和 bias每层约 4K 参数共 96K几乎可以忽略。把这些加起来总量约 436M。实际上从 0.4B 这个数字到后面的显存预估只需要记住一部分就够了真实可训练参数大约 0.44B训练时的混合精度状态约需要 3.5 到 4 倍的显存开销。4.3 推理阶段的 KV Cache 设计训练之外推理阶段的 KV Cache 也提前做了设计。context length 1024hidden 102424 层16 个头每个头的维度是 64。每个 token 的 KV Cache 大小为 2×24×1024×16×64约 6.3M 参数。以 FP16 存储每个 token 约 12.6MB如果生成 512 个 tokenCache 总量约 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.1beta 设为 0.9 和 0.95。学习率采用带 warmup 的余弦衰减曲线峰值学习率 6e-4warmup 500 步。这个峰值对我来说偏高但对 1.2B 小数据量是合理的因为数据量小训练步数少模型还没充分收敛就需要更大的更新步伐切入目标区域。混合精度用 bf16 而不是 fp16。24G 卡对 bf16 有原生支持bf16 的指数范围和 fp32 一样省去了 fp16 容易出现的精度溢出和 scale 频繁调整问题。梯度检查点也开了它让 activation 不缓存而是前向时重新计算显存大概节省了 30%代价是训练速度下降了约 15%。在单卡场景下这个取舍值得做。5.3 跑通训练的实测配置表以下是最终固定下来的训练配置直接照抄基本能复现配置项取值模型参数436M训练数据量1.2B tokens全局 batch size12816 个 micro batch × micro size 8最大长度1024优化器AdamW峰值学习率6e-4warmup 步数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 mask7.1 指令数据的来源与结构预训练结束后进入了 SFT 阶段。这个阶段的目的是把模型从“续写文本”引导到“遵循指令”。我准备的数据量是 30 万条指令样本涵盖对话、问答、翻译、摘要、改写、代码生成、分类抽取等任务。数据来源以开源指令集为主配合一部分自己攒的出题模板经过清洗和去重后再让一个更强的通用模型做一轮答案改写把过短和包含无意义回复的样本剔除。指令样本必须统一成同一种对话模板。模板格式包含 system 提示、user 输入和 assistant 输出特殊 token 固定为设定的[BOS]、[EOS]、[USER]、[ASSISTANT]、[SYSTEM]。在拼接时所有字段之间用对应的特殊 token 隔开。测试集上验证表明模板一致性直接影响微调的稳定性如果混用多种模板模型在生成时经常出现“回答到一半换个格式”的毛病。7.2 只对答案部分计算 lossSFT 的一个关键技术点是 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 模型不只是训练实验而能变成实际产品中的一个可用组件。