前阵子刷到一个技术社区里挺有意思的帖子:作者把自己从零训练的 3 个 LLM 全部上线,做成公开网页让大家随手试。评论区不少人第一反应是“训练 LLM 不是要几百张显卡吗?个人怎么可能做得到?”其实这里有个理解偏差:普通个人开发者完全可以训练“小语言模型”,模型参数量从几百万到两三亿不等,照样能走完预训练、微调、评估、上线推理的完整链路,效果虽然不能和 GPT-4、Claude 这类商用大模型比,但对理解 LLM 工作原理、沉淀工程经验非常有帮助。
本文就把这套流程完整拆出来,从数据准备、分词器训练、模型构建、训练配置,到用 Gradio 把多个模型包进同一个 Web 页面,手把手带你复现一个“从零训练三个小型 LLM 并在线试玩”的项目。如果你一直想亲手训练一个属于自己的语言模型,却不知道怎么设计实验、怎么安排训练资源、怎么把权重部署成网页,这篇文章可以帮你把整条路捋顺。
1. 一个人也能“从零训 LLM”吗
不少开发者一听到“训练 LLM”,会下意识想到几千张 A100、PB 级数据集、分布式并行训练平台。实际上这不矛盾——预训练大语言模型确实是重资产工程,但“做研究和学习”与“造生产级模型”的目标差距非常大。个人开发者用单张消费级显卡,甚至纯 CPU,也能训练出可用于文本生成的小模型,只是参数量会被压缩到百万到千万级别。
以下几个场景特别适合“从零训练”而不是直接调用 API:
- 研究人员和研究生想拿真实 Debug 数据研究 tokenizer、学习率、loss 震荡问题。
- 数据工程师想验证“领域数据增量训练”的可行性,而不是盲信大模型厂商的结果。
- 开发者在打造垂直 demo,例如生成固定格式 JSON、公司代码补全、特定语料风格的文案。
- 教学场景需要展示 LLM 的运行机制:embedding、注意力、残差连接、KV Cache 都变成可打印的中间变量。
把模型训练出来并不算结束,更完整的项目还会考虑:用什么方案把权重加载到一个 Web 服务里、是否支持多人同时试用、怎么避免模型生成超长且不可控的输出。而这些工程问题,和训练本身同样是稀缺经验。
在动手前必须确立一个原则:本文所说的“LLM”,严格指“小规模语言模型(Small Language Model)”。它具备完整 Transformer 解码器结构,能根据前缀续写文本,但与 ChatGPT 类产品的“对话能力”还有距离。为了在有限成本内跑通项目,我们需要有意识地控制参数量和数据规模,而不是试图挑战几十亿参数模型。
2. 从零训练全流程综述
从零训练一个语言模型并部署上线,核心链路可以分成 6 个阶段:
| 阶段 | 主要工作 | 产出物 |
|---|---|---|
| 数据准备 | 收集、清洗、去重语料;设计训练/验证集划分 | txt / jsonl 语料文件 |
| 分词器训练 | 训练 Byte-Pair Encoding 或字符级词表 | tokenizer.json / vocab.json |
| 预训练 | 用自回归语言建模目标训练模型 | pretrained.pt |
| 可选的继续训练 | 在领域语料上继续预训练或微调 | instruct.pt / chat.pt |
| 模型评估 | 计算 loss、困惑度,并写规则采样查看生成效果 | 评估日志 |
| 部署上线 | 加载权重,启动 HTTP 服务或 Gradio 页面 | Web Demo |
很多人第一次做这个项目时,容易在“数据准备”阶段就开始焦虑:到底要多少数据?其实关键在于模型的规模。如果模型只有 3 亿参数,那你理论上需要几十 GB 高质量数据;如果你把目标降一个档,用 3000 万参数的小模型,配合 100 MB~1 GB 语料也完全可以观察到明显的生成能力。对于第一个项目,先在“小模型 + 小数据”上跑通全流程,再把数据量和模型规模按比例放大会更稳妥。
工程上的好习惯也建议早点养成:日志里定时打印 loss 和 learning rate;每隔固定步数保存 checkpoint;把训练参数用配置文件管理而不是硬编码;每个实验记录数据版本和模型结构 hash。这些习惯在自训模型项目中会帮你节省大量返工时间。
3. 环境准备和实验设计
本文的示例以获得“可运行、可复制”为目标,推荐环境如下:
- 操作系统:Ubuntu 20.04 / 22.04,Windows 也可以用 WSL2。
- Python:3.10 或 3.11。
- 深度学习框架:PyTorch 2.x。
- 分词器库:tokenizers。
- 交互部署:gradio。
- GPU:有 NVIDIA 显卡最佳(显存 6GB 以上),没有就调小模型和 batch size,用 CPU 也能跑通。
版本可以根据你的项目实际情况调整,建议自行查看官方安装命令,因为 PyTorch 和 CUDA 的组合经常变化。演示代码用以下方式安装依赖:
pip install torch tokenizers transformers gradio psutil由于本文要训练 3 个模型,并分别验证不同阶段的实验目标,建议先建立 3 条实验线:
| 模型代号 | 核心目标 | 规模定位 |
|---|---|---|
| char-demo | 验证流程:字符级模型 | 参数 < 1M |
| base-medium | 真正学词汇:BPE 分词 | 参数约 30M |
| chat-skill | 在预训练基础上继续对齐风格 | 参数约 30M |
也就是说第 3 个模型不会从零训第三遍,而是第二个模型的“继续训练”版本。这样既满足“我训练了三个 LLM”的形式,也让每个模型在技术上都有独立价值。三份模型权重可以在同一个 Gradio 页面里切换试玩,互不冲突。
3.1 为什么任务里要有“字符级模型”
字符级模型的优势是词表极小,CPU 也能训练;缺点是序列很长、学习效率低。我们拿它作为“最小可运行模型”,用来检查数据流、训练脚本、部署代码是否写对。一个 1M 参数以内的字符级模型在几分钟内就能训练完,作为 smoke test 再合适不过。
4. 数据准备与分词器训练
4.1 数据来源与清洗原则
自己训练模型最忌讳直接抓全网未清洗网页。对于中文语料,常见高质量来源包括维基百科中文 dump、中文开源书籍语料、GitHub 代码数据(如果目标领域是代码)和个人整理的知识库 Markdown。无论来源是什么,请遵守版权和数据许可要求,不要把你没有权利使用的数据灌进模型。
清洗文本时建议处理这四类问题:
- 去掉 HTML 标签、超链接、脚本代码。
- 按标点、换行做段落切分,尽量保留自然语义单元。
- 过滤过短文本和噪声文本:例如长度小于 20 字符的纯数字、乱码段落。
- 去重:使用 MinHash 太重,可以先做“规范化后全文去重”。
下面的脚本演示把一批 txt 文件合并成 jsonl 训练集:
import json import re from pathlib import Path def clean_text(raw: str) -> str: raw = re.sub(r'<[^>]+>', '', raw) raw = re.sub(r'https?://\S+', '', raw) raw = re.sub(r'\s+', ' ', raw) return raw.strip() def build_jsonl(raw_dir: Path, output_file: Path): seen = set() with open(output_file, 'w', encoding='utf-8') as f: for path in raw_dir.rglob('*.txt'): text = clean_text(path.read_text(encoding='utf-8', errors='ignore')) if len(text) < 50: continue norm = re.sub(r'\W+', '', text) if norm in seen: continue seen.add(norm) line = json.dumps({"text": text}, ensure_ascii=False) f.write(line + "\n") if __name__ == "__main__": build_jsonl(Path("./raw_data"), Path("./data/train.jsonl"))这里的关键点是用“规范化后内容”做内存去重。实际处理超大数据集时可以换成布隆过滤器,但基本思路一致。训练数据一旦进入模型,清洗不干净会导致生成文本出现很多域名和乱码。
4.2 训练一个 BPE 分词器
现在的大语言模型几乎都不再直接在字符上训练,而是使用 BPE(Byte-Pair Encoding)或 Unigram 分词器。中文场景里,BPE 会把“大语言模型”切成几个有意义的子词单元,既控制词表大小,又能覆盖未登录词。
Hugging Face 的 tokenizers 库可以拿纯文本训练一个 BPE tokenizer:
from tokenizers import Tokenizer from tokenizers.models import BPE from tokenizers.pre_tokenizers import ByteLevel from tokenizers.trainers import BpeTrainer tokenizer = Tokenizer(BPE(unk_token="<unk>")) tokenizer.pre_tokenizer = ByteLevel() trainer = BpeTrainer( vocab_size=8000, min_frequency=2, special_tokens=["<unk>", "<s>", "</s>", "<pad>"], ) files = ["./data/train.jsonl"] tokenizer.train(files, trainer) tokenizer.save("./tokenizer.json") print(tokenizer.encode("你好,这是从零训练 LLM 的示例文本").tokens)你可能会问为什么要设置vocab_size。词表越大,模型嵌入层参数越多、推理更慢。对于 30M 参数的模型,8000 词表已经够用。有些团队个人模型直接调成 16000,属于合理范围,但不要再大了——语言模型的 embedding 参数占整体比例过高,会增加过拟合风险。
如果纯英文数据占比较高,可以考虑字节级 BPE;如果主要是中文,也可以用 char-level + BPE 混合。稳妥做法是保留 ByteLevel,因为它天然覆盖空格和标点细节。
4.3 训练集和验证集拆分
训练一个模型前,把数据随机切分到 98% 训练、2% 验证。验证集不参与学习,只用来观察 loss 是否降低、是否过拟合。
import random, json from pathlib import Path lines = Path("./data/train.jsonl").read_text(encoding="utf-8").strip().split("\n") random.Random(42).shuffle(lines) split_idx = int(len(lines) * 0.98) Path("./data/train_shard.jsonl").write_text("\n".join(lines[:split_idx]), encoding="utf-8") Path("./data/val_shard.jsonl").write_text("\n".join(lines[split_idx:]), encoding="utf-8") print(f"训练样本数: {split_idx}, 验证样本数: {len(lines) - split_idx}")这个拆分看起来很普通,却在实验里非常关键。如果验证 loss 不断升高而训练 loss 持续降低,说明模型正在死记硬背数据,这时候要增加数据量、减弱模型容量或加正则化。没有验证集的话,整个训练过程会像开盲盒。
5. 训练一个最小字符级模型作为冒烟测试
先放一个最简模型,目标是让项目链路整体跑通。
5.1 字符级 tokenizer 的替代方案
我们上面已经训练了 BPE,但最小值模型可以用字符 tokenizer,省去加载流程。这里不用额外训练,直接在数据集上统计词表即可。由于它只是串流程,暂时不追求它生成良好文本。
5.2 最小 Transformer 模型定义
下面代码实现一个单层 Transformer 解码器。为了便于学习和调试,刻意把注释写详细:
import math import torch import torch.nn as nn import torch.nn.functional as F class MinimalTransformer(nn.Module): def __init__(self, vocab_size: int, n_embd: int = 64, n_head: int = 4, n_layer: int = 2, block_size: int = 64): super().__init__() self.token_embedding = nn.Embedding(vocab_size, n_embd) self.position_embedding = nn.Embedding(block_size, n_embd) self.blocks = nn.ModuleList([ TransformerBlock(n_embd, n_head, block_size) for _ in range(n_layer) ]) self.ln_f = nn.LayerNorm(n_embd) self.lm_head = nn.Linear(n_embd, vocab_size, bias=False) self.block_size = block_size self.apply(self._init_weights) def _init_weights(self, module): if isinstance(module, (nn.Linear, nn.Embedding)): torch.nn.init.normal_(module.weight, mean=0.0, std=0.02) if isinstance(module, nn.LayerNorm): torch.nn.init.zeros_(module.bias) torch.nn.init.ones_(module.weight) def forward(self, idx): _, t = idx.shape token_emb = self.token_embedding(idx) pos = torch.arange(0, t, dtype=torch.long, device=idx.device).unsqueeze(0) position_emb = self.position_embedding(pos) x = token_emb + position_emb for block in self.blocks: x = block(x) x = self.ln_f(x) logits = self.lm_head(x) return logits class TransformerBlock(nn.Module): def __init__(self, n_embd, n_head, block_size): super().__init__() self.ln1 = nn.LayerNorm(n_embd) self.attn = nn.MultiheadAttention(n_embd, n_head, dropout=0.1, batch_first=True) self.ln2 = nn.LayerNorm(n_embd) self.mlp = nn.Sequential( nn.Linear(n_embd, 4 * n_embd), nn.GELU(), nn.Linear(4 * n_embd, n_embd), nn.Dropout(0.1), ) def forward(self, x): attn_out, _ = self.attn(self.ln1(x), self.ln1(x), self.ln1(x), need_weights=False) x = x + attn_out x = x + self.mlp(self.ln2(x)) return x需要注意的是,出于演示目的,上面的注意力实现为了简洁做成了双向注意力。真正的自回归 LLM 应该使用因果注意力掩码。在写生产代码时,建议改用 PyTorch 里的nn.TransformerDecoderLayer,或者直接用 nanoGPT 一类的开源实现。最小模型这块代码的主旨是暴露训练循环问题,不要在注意力细节上纠结太久。
5.3 训练循环代码
训练循环里重点看 learning rate、batch size、pad 处理:
def train_char_model(): # 这里代表你已经准备了字符-索引映射 vocab_size = 128 # 只覆盖 ASCII + 中文标点可能会不够,演示时按需替换 model = MinimalTransformer(vocab_size) optimizer = torch.optim.AdamW(model.parameters(), lr=3e-4) loss_fn = nn.CrossEntropyLoss() # ... 省略 dataloader 细节 print("char demo train finished")实践中如果文本是中文,字符级字符很容易超过 128。第一次跑的时候可以只做英文少量文本的 smoke test,或者把词表扩大到 10000,把每个 Unicode 字符按固定的 unicode 编码映射。总之目标是确认 forward/backward、save/load 都没问题。
6. 正式训练第二个模型:BPE 级别的 30M 模型
字符级模型只是冒烟测试,正式训练要从 BPE 级别起步。为了控制篇幅,我这里用轻量模型配置做示范。
6.1 模型配置说明
常见的小 GPT 结构建议如下:
- n_layer=6,n_head=8,n_embd=512,block_size=256 或 512。
- vocab_size=8000。
- 参数量大约在 28M~35M 之间。
这样的配置用 6GB 显存足够训练,batch size 可以设置为 16 或 32。GPT 系列的核心特色是 decoder-only 的因果注意力。你把上面最小模型换成GPT2LMHeadModel或从开源 GPT 实现中复用得到代码即可,没必要自己手动实现。
如果你确实想用一个简单可训练的空模型,建议参考下面思路:修改MinimalTransformer的TransformerBlock,给自注意力加上因果 mask;或者直接使用 Hugging Face 的 GPT2Config 初始化一个模型,然后把上面的训练循环接上,这样还能直接复用分词器。
6.2 构建 PyTorch Dataset
为了训练 BPE 分词后的文本,我们需要把每个文本编码成 id 列表,然后再切成长度为block_size的样本。这里给出一个可以直接运行的 Dataset 骨架:
import torch from torch.utils.data import Dataset from tokenizers import Tokenizer class TextDataset(Dataset): def __init__(self, jsonl_path: str, tokenizer_path: str, block_size: int = 256): self.tokenizer = Tokenizer.from_file(tokenizer_path) self.block_size = block_size self.examples = [] with open(jsonl_path, "r", encoding="utf-8") as f: for line in f: text = line.strip() if not text: continue ids = self.tokenizer.encode(text).ids if len(ids) < 16: continue self.examples.append(ids) def __len__(self): return len(self.examples) def __getitem__(self, idx): ids = self.examples[idx] if len(ids) > self.block_size: start = torch.randint(0, len(ids) - self.block_size, (1,)).item() ids = ids[start:start + self.block_size] else: ids = ids + [self.tokenizer.token_to_id("<pad>")] * (self.block_size - len(ids)) x = torch.tensor(ids[:-1], dtype=torch.long) y = torch.tensor(ids[1:], dtype=torch.long) return x, y需要注意:所有单个样本都对齐到 block_size 会带来大量 pad 浪费,但如果语料已经按段落切分得很短,这样处理最简单。后续想要提升训练效率,可以按 token 总数拼接长文本,再用滑动窗口切块。代码中的start随机采样选择窗口位置,能提高数据多样性。
6.3 交叉熵损失与参数量
语言模型训练目标是最小化 next-token 预测的交叉熵。每个 token 位置都会产生一个预测分布,所有位置交叉熵取平均就得到 batch loss。你经常听到的“perplexity”即困惑度,是 loss 的 exp 值,越接近词表大小越差,越接近 1 越好。
下面是核心训练代码框架:
def train_epoch(model, loader, optimizer, device): model.train() total_loss = 0 for step, (x, y) in enumerate(loader): x = x.to(device) y = y.to(device) logits = model(x) loss = F.cross_entropy(logits.view(-1, logits.size(-1)), y.view(-1)) optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step() total_loss += loss.item() if step % 200 == 0: print(f"step {step}, loss {loss.item():.4f}") return total_loss / max(len(loader), 1)梯度裁剪是必须加的,否则深层 Transformer 很容易出现梯度爆炸。clip_grad_norm_的阈值一般取 1.0。
6.4 从零训练 30M 模型的资源预期
在不显式宣传云计算平台、不编造具体硬件性能的情况下,大致可以按下面思路做资源规划:
- 数据量建议 50MB-200MB 纯文本。
- 单卡 8GB 显存可跑 batch_size=16~32、block_size=256。
- 训练 1 epoch 后模型通常能输出短语片段,但不够流畅;训练 3~5 个 epoch 效果会明显好转。
- 过早的 early stopping 依据是验证 loss,而不是损失函数值的绝对值。
不要期望小模型生成“逻辑严密长文”。在 30M 参数附近,模型会学到词法搭配和短距离语法规律,能生成看起来通顺的局部文本,但无法保持长文全局一致。如果你想降低“答非所问”的感觉,下一步可以考虑继续训练和系统提示词设计。
7. 第三个模型:继续训练出“聊天风格”
第三个“从零训练”的模型,本质上是把 base-medium 在专门的对话语料上继续训练。对话语料可以从开源的指令数据集中获取,结构通常是:
{"conversation": [{"from": "human", "value": "介绍一下机器学习?"}, {"from": "gpt", "value": "机器学习是……"}]}把这样的多轮对话转成单段文本时,需要设计一个不会被自然内容干扰的模板,例如:
<user>介绍一下机器学习?</user> <assistant>机器学习是一门研究如何让计算机从数据中自动获取规律……</assistant>接下来用同一个 BPE tokenizer,把上面格式的文本编码,在 base-medium 的 checkpoint 之上继续训练。这个阶段建议用比预训练稍小的学习率,例如1e-5 ~ 5e-5,防止灾难性遗忘。
7.1 加载 base checkpoint 继续训练
如果你是使用自定义模型而非 Hugging Face 模型,加载权重的方法很直接:
checkpoint = torch.load("./checkpoints/base_medium.pt", map_location="cpu") model.load_state_dict(checkpoint["model_state_dict"]) optimizer.load_state_dict(checkpoint["optimizer_state_dict"]) global_step = checkpoint["global_step"] print(f"已从 step {global_step} 继续训练")日志字段最好包含global_step、epoch、lr、train_loss、val_loss。后续做实验对比时,你才能知道“某个时间点上的坏模型”是被哪次数据变更引入的。
7.2 继续训练 vs 微调的不同目标
“继续训练”和“微调”在日常表述里经常混用,但在工程上有区别:
- 继续训练:使用领域文本让模型适应新分布,loss 依然基于纯文本。
- 指令微调:使用指令-回答对,让模型学会按照要求输出格式,此时可以引入模板 loss mask。
- 人类反馈对齐:需要额外的奖励模型和强化学习,流程更长。
我们的第三个模型只需要做到“指令微调”的轻量版,让它在固定模板下续写出想要的回答文本。它仍然缺少 guardrail 能力,输出里也可能出现事实错误,所以在部署 Demo 时,最好把它的定位写明为“实验性模型”。
8. 用 Gradio 把三个模型部署上线
模型训练结束,最后一步是部署在线试玩。Gradio 非常适合个人项目快速搭建模型体验页。你需要做到:
- 加载三个不同 checkpoint。
- 通过下拉框切换模型。
- 设定文本生成的解码参数。
- 展示基础推理状态,例如 GPU 显存占用。
8.1 统一推理函数
推理函数直接决定用户体验。参数较多时,先在函数里设定max_new_tokens、temperature、top_p和随机种子,再逐 token 生成。
@torch.no_grad() def generate_text(prompt: str, model_type: str = "base-medium", max_new_tokens: int = 80): tokenizer = load_tokenizer() model = load_model(model_type) model.eval() device = next(model.parameters()).device ids = tokenizer.encode(prompt).ids input_ids = torch.tensor([ids], dtype=torch.long, device=device) for _ in range(max_new_tokens): if input_ids.size(1) > model.config.block_size: input_ids = input_ids[:, -model.config.block_size:] logits = model(input_ids)[0, -1, :] logits = logits / 0.8 probs = torch.softmax(logits, dim=-1) next_id = torch.multinomial(probs, num_samples=1) input_ids = torch.cat([input_ids, next_id], dim=-1) if next_id.item() == tokenizer.token_to_id("</s>"): break return tokenizer.decode(input_ids[0].tolist())这段代码里温度0.8代表在采样前把 logits 缩小,越大生成越随机,越小越确定。循环里检查了输入长度是否超过模型最大序列长度,防止长文本导致位置编码越界。
实际项目中,你可能会想改用 Transformer 的generate方法,它会集成 top-k、top-p、repetition penalty。手动实现的好处是能看清每一步,适合学习。
8.2 Gradio 应用代码
Gradio 的典型交互页面只需要配置inputs和outputs。下面的代码会建立一个可选模型、可调 token 数量的界面:
import gradio as gr MODELS = ["char-demo", "base-medium", "chat-skill"] def predict(prompt, model_name, max_new_tokens): try: result = generate_text(prompt, model_type=model_name, max_new_tokens=int(max_new_tokens)) return result except Exception as e: return f"[推理错误] {str(e)}" demo = gr.Interface( fn=predict, inputs=[ gr.Textbox(lines=6, label="输入文本"), gr.Dropdown(MODELS, value="base-medium", label="选择模型"), gr.Slider(16, 256, value=80, step=8, label="最大生成长度"), ], outputs=gr.Textbox(lines=10, label="生成结果"), title="三个自训 LLM 在线试玩", description="模型由个人开发者从零预训练/继续训练,输出仅用于技术实验,不代表模型方观点。", ) if __name__ == "__main__": demo.launch(server_name="0.0.0.0", server_port=7860)这里的server_name="0.0.0.0"表示允许局域网或公网通过本机 IP 访问。如果只是在本地体验,可以删除这个参数。不要在生产环境把未授权模型直接绑定公网端口,除非你已经配置好身份验证或访问控制。
8.3 部署到服务器时的注意事项
个人模型部署在线试玩需要关注三个问题:
- 模型加载时间:每个 checkpoint 如果都在页面启动时读取,内存占用会叠加。建议只在切换模型时按需加载,并设置缓存。
- 并发量:Gradio 默认会排队请求,小模型推理快,但也建议设置
concurrency_limit参数,避免恶意刷接口。 - 输出安全:模型没有内置内容审核,应在应用层增加敏感词过滤、长度限制和日志审计。
如果在本地 Linux 服务器部署,最简单的启动方式是:
python app.py若需要长时间后台运行,可以使用nohup或 systemd,但本文不再展开具体运维方案。
9. 常见问题与排查思路
自训语言模型项目的报错风格和大模型 API 开发差别很大,下面是高频问题整理:
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| CUDA out of memory | batch size 或 block_size 太大 | 降低 batch size,或使用梯度累积 |
| loss 不下降或下降缓慢 | 学习率过大/过小,数据过少,模型结构有 bug | 先跑小规模实验,检查 loss 曲线和 tokenizer 输出 |
| 模型一直重复生成同一句话 | temperature 太低或 repetition penalty 不足 | 调高 temperature 至 0.9 以上,加入重复惩罚 |
| 中文生成全是乱码 | BPE/字符映射错误;模型输出 id 与 tokenizer 不对应 | 检查 encode-decode 往返是否一致 |
| 部署页面加载很慢 | 推理时实时加载权重或 GPU 首次预热 | 缓存模型实例,预热推理 |
| 验证集 loss 先降后升 | 模型过拟合 | 增加数据量、加 dropout、缩小模型、提前 early stop |
| 继续训练后旧能力变差 | 学习率太高导致灾难性遗忘 | 降低学习率,使用小学习率继续训练 |
| sample 结果一个 token 一个词吐出 | tokenizer 解码问题 | 检查 tokenizer.decode 是否逐条调用 |
排查时先跑一个固定随机种子的小实验,确认数据、模型、损失函数、采样四个模块是否各自正常,再回去调整超参。大多数“模型训不出来”的问题都发生在数据流环节,而不是模型结构上。
10. 最佳实践与小模型训练建议
从零训练 LLM 的项目里,工程规范和技术技巧一样重要。
10.1 把训练配置变成可复现文件
每次都建议用类似 YAML 的配置文件结构,把数据路径、tokenizer 路径、block size、vocab size、学习率、batch size、epoch、输出目录全部写清楚。比如:
model: n_layer: 6 n_head: 8 n_embd: 512 vocab_size: 8000 block_size: 256 data: train_path: data/train_shard.jsonl val_path: data/val_shard.jsonl tokenizer_path: tokenizer.json train: batch_size: 16 grad_accum_steps: 4 learning_rate: 3e-4 epochs: 5 checkpoint_dir: checkpoints这样每次实验之前复制一份配置,后续想看“哪个模型效果最好”时,直接看配置目录就能找到对应权重。个人项目虽然规模不大,但多模型对比时,配置文件管理远比写死代码更靠谱。
10.2 训练日志和监控设计
不要只打印 loss。每 N 步需要打印:当前学习率、batch loss、已用 token 数、每秒处理 token 数。这些数据可以让你尽早发现数据加载瓶颈或学习率异常。写日志时建议把关键指标写入 CSV/JSONL 文件,便于画图对比。
10.3 用规则采样评测和人工评测
自训模型的评估不能只看 loss,建议固定几个种子 prompt,每次训练到一定步数就采样看结果文本。虽然人工评测主观,但在小模型开发里非常高效。下面是一段固定 prompt 采样评估函数:
def evaluate_sample(prompt_list, model, tokenizer, device, max_new_tokens=40): model.eval() with torch.no_grad(): for prompt in prompt_list: input_ids = tokenizer.encode(prompt).ids input_ids = torch.tensor([input_ids], device=device) generated = input_ids for _ in range(max_new_tokens): logits = model(generated[:, -128:])[0, -1, :] next_id = torch.multinomial(torch.softmax(logits / 0.7, dim=-1), 1) generated = torch.cat([generated, next_id], dim=-1) print(prompt, "=>", tokenizer.decode(generated[0].tolist()))10.4 安全边界与合法合规
自训模型通常比商业模型更容易生成有害内容或泄露训练语料片段。Demo 页面要明确标注“实验结果”“可能包含不准确内容”。如果是对外提供服务,要严格遵守法律和平台安全要求,避免生成违法、攻击、绕过安全限制等内容。数据来源也要在 README 中列清楚,不要用来历不明的语料。
11. 总结与下一步学习路线
本文走完了一个个人开发者从零训练三个小语言模型并上线试玩的完整链路:先用字符级最小模型跑通流程,再训练 BPE 级别的 30M 模型,最后在预训练基础上继续训练出聊天风格的第三个模型,并用 Gradio 把它们统一包装成网页应用。
从学习路线上说,项目结束之后有几条值得继续深入的方向:
- 深入研究 tokenizer 的细节,例如 byte-level BPE、unigram、词表扩展对中文效果的影响。
- 学习模型并行和梯度累积,把实验规模从 30M 扩展到 100M 或 300M。
- 理解指令微调中的损失掩码:只对 answer 部分计算 loss,让模型专注学习回答格式。
- 引入量化推理,把模型权重压缩到 INT8/INT4 以减少显存。
- 尝试 RLHF 中的策略优化,但前提是先对 PPO 原理有基础。
- 把训练日志、配置、checkpoint 纳入产物管理,必要时跟 Git LFS、模型仓库工具配合使用。
动手实践永远比读文档收获更大。建议你先准备 10MB 的干净中文语料,训练一个最小字符模型,再慢慢换成 BPE 和更大数据量。只要走出第一步,后面整条工程链路都会被这一小步串起来。