自训LLM完整指南:从数据清洗到网页部署的工程实践

发布时间:2026/9/4 2:30:23
自训LLM完整指南:从数据清洗到网页部署的工程实践 在 Hacker News 上看到“Show HN: I trained three LLMs from scratch and put them online to try them out”这类帖子第一眼容易把它理解成“有人炫技训练了几个模型”。但真正值得琢磨的不是“训练了三个”这个数量而是整句话里隐含的完整闭环准备语料、训练权重、写推理逻辑、部署服务、让陌生人打开页面产生真实请求。这件事的工程纵深远超过“调用一次推理 API”所能覆盖的范围也是它值得单独立项去做的原因。我见过不少开发者的技术认知停留在聊天页面或 API 层总觉得“训练大模型”只是大公司的专利。但从工程可行性看模型规模和语料规模都是可以缩放的。哪怕只用单张消费级显卡甚至在没有 GPU 的服务器上用纯 CPU也可以完整跑一遍“数据清洗 → tokenization → 预训练 → 生成 → 部署”的流程。难点不再是有没有算力而是你能不能拆解掉每一个模糊的黑盒。这篇文章不打算猜测原作者用了多少参数、多少数据而是把这则 Show HN 当作一个普通可学习的范本如果你想复刻一个类似的项目需要掌握哪些前置知识每一步会遇到哪些决策点工程代码可以如何组织跑挂之后该从哪里排查。读完你应该能回答一个问题从一组纯文本出发能不能靠自己的代码训练出一个小模型并在网页上让别人试玩。答案是可以而且并不像想象中那么遥远。1. 为什么“自训 LLM”不是只属于大厂的事情很多人一听到“自己训练大模型”马上会联想到几千张显卡和上千万美元训练账单。确实训练 GPT-4 级别的模型需要极高的工程和资源门槛但“训练一个 LLM”并不是只有这一种形态。模型的参数量可以从几亿缩小到几百万语料可以从 TB 级缩到几十 MB目标也可以从“追求通用能力”变成“验证一个想法”。只要把数据量、模型规模、训练时长按比例缩减个人开发者就能在一台普通机器上跑通完整的训练链路。搞清这一点之前先要做一个非常现实的选择判断你的目标到底是“得到能力”还是“理解机制”。如果只是想让产品快速具备对话、总结、分类能力正确路径是调用成熟 API或者基于开源权重做指令微调如果想要研究模型内部如何编码语言规律、测试新架构、处理私有语料或者希望在一个完全离线的小型环境里运行自己的权重那么从头训练就是必要动作。三条路线没有好坏之分它们解决的问题完全不同。路线核心目的资源成本适合谁调用成熟 API业务快速接入按调用付费产品开发团队微调开源模型定制风格、对齐领域格式单卡或少量算力中小团队、垂直场景从头预训练理解机制、控制全流程、离线部署视规模伸缩研究者、教学项目、隐私受限场景判断标准可以再简化一点如果你的核心指标是“任务效果”不要在训练上重复造轮子如果你的核心指标是“路径理解”不要只停在调用 API 的层次。标题里的项目显然属于后者这也是它能被许多人点进来看的原因——它把一项看起来很高门槛的技术压缩到了个人可以展示的程度。从这类项目中我最想强调的另一个点是“公开可试玩”带来的压力和价值。本地训练时模型输出什么都可以自我安慰一旦开放给网友体验你必须处理上下文长度限制、响应速度、并发请求、边界输入和失败提示。这些不会被写进论文却是真实上线时最消耗精力的部分。2. 训练 LLM 前需要建立的核心概念在写训练代码之前先把几个高频术语讲透否则后续代码里的每一行都像魔法。第一个概念是 Token。LLM 并不直接阅读文字它读取的是整数编号。把文本切成一串小块的过程叫 Tokenization每个小块就是一个 Token。中文字符可以作为 Token英文单词可以作为 Token按子词切分也可以。Token 并不一定要等于中文里的“字”在常见开源分词器里一个汉字可能对应一到多个 Token一个英文单词也可能被拆成几个子词片段。第二个概念是自回归语言模型。绝大多数主流 LLM 的训练目标非常简单给定前面一段 Token预测下一个 Token 是什么。训练时把句子分成输入和标签两份输入是第 1 到第 N-1 个 Token标签是第 2 到第 N 个 Token。模型输出每个候选 Token 的概率通过交叉熵损失与真实标签比较误差反向传播更新权重。预测下一个词的能力一旦系统化就衍生出了写代码、翻译、总结等高阶能力。第三个概念是预训练与后训练。预训练阶段用大规模通用语料训练基础模型目标是让模型“学会接话”拥有较强的语言先验后训练阶段通常数据量小很多重点教模型“按指令回答问题”。日常开发中常说的 SFTSupervised Fine-Tuning属于后训练它是把一个已经会接话的底座变成会听话的助手的过程。标题里提到的“训练了三个 LLM”如果是一个合格实验设计通常会把不同训练阶段拆开分别保存而不是只保留最后那个“会聊天”的版本。第四个概念是损失函数代码里叫 loss。训练时每次迭代都会得到一个数值代表模型预测和真实文本的差距。loss 下降通常意味着“模型对训练语料的拟合度在提升”但它不等于模型变聪明。真正判断模型生成质量还是要看采样出来的文本是否符合语法和语义。loss 只能告诉你优化器有没有工作无法告诉你模型有没有学习到可复用的规律。3. 从零训练一个 LLM 的标准流程如果你决定走一遍从零训练路线最稳妥的顺序不是先敲模型代码而是先想清楚数据和实验目标。3.1 语料准备与清洗任何语言模型训练的第一关都是语料。语料质量的影响在规模小时会被无限放大因为模型参数少记忆能力差它只能从有限样本里拼命提取频繁出现的模式。语料里如果夹杂大量重复网页文本、无意义符号、残缺 HTML模型就会学着生成同样质量的垃圾内容。个人项目通常从公开领域文本、书籍、百科、开源代码中选择语料。一个基础原则是先做去重、编码统一、段落切分再考虑是否保留特殊符号。中文语料还要注意把全角半角、简繁体策略提前定好避免分词器学出一堆低频无意义字符。清洗完的数据最好按来源拆分留一小部分做验证集不要把全部文本灌进训练脚本。3.2 选择 Tokenizer 策略很多人第一次训练模型时最容易忽略的就是分词器。如果英文和中文混排直接用字符级分词虽然简单却会让序列变长学习效率下降如果直接引入成熟 BPE 词表又可能带来大量在私有语料里根本用不到的 Token。对最小演示项目字符级 Tokenizer 完全够用代码也容易调试。训练完成、理解原理之后可以再升级到 SentencePiece 或 Hugging Face Tokenizers 库训练 BPE 词表。顺序不要反过来否则一旦模型效果不好你很难判断是分词问题、数据问题还是模型结构问题。3.3 决定模型尺寸参数并非越多越好模型结构要素主要有几个词向量维度 n_embd、Transformer 层数 n_layer、注意力头数 n_head、前馈网络维度、最大上下文长度。它们共同决定参数量和训练所需显存。小规模实验的观察规律是当数据量很小时盲目增加层数和维度只会让模型更快记住样本而不是获得更强泛化。模型的表达能力必须和语料的重复模式量级匹配。一个几百 MB 的语料配上十几亿参数模型意义通常不大。个人实验更合理的做法是从几百万参数起步先证明训练代码能跑通再逐步增加层数或上下文长度观察 loss 与生成质量是否同步提升。3.4 训练配置与稳定性控制训练过程的三个关键配置是优化器、学习率和梯度裁剪。常用优化器是 AdamW它会为每个参数维护自适应学习率训练曲线比普通 SGD 平滑。学习率通常先从一个较小值开始常见做法是设置 warmup 阶段让学习率在几百步内线性增长避免训练初期大步长把权重撞坏。梯度裁剪也很重要。Transformer 模型在训练初始阶段经常出现梯度范数异常高的情况表现为 loss 突然变为 NaN。加上一个梯度裁剪可以限制单次更新的最大步长例如设定 max_grad_norm1.0。损失函数出现 NaN 时应该马上停止训练检查数据和学习率而不是继续跑等它恢复。3.5 生成质量评估不要只看 lossloss 曲线平滑下降只能代表训练本身没有崩它衡量的是“拟合训练分布的能力”并不直接对应生成质量。实践中有两种情况很常见一种是 loss 低到不合理模型已经把训练语料背下来换一个句子就胡言乱语另一种是 loss 还在下降但生成文本已经出现大段重复。更有效的评估方式是边训练边采样。每隔固定步数让模型从某个提示继续生成一段文本直接肉眼看输出。比如给一个“从前有座山”如果模型能接出语法通顺、内容相关的中文说明它学到了语言结构如果只输出“山山山山”或乱码就说明训练设置或数据还存在问题。这种定性评估比单纯盯 loss 可靠得多。4. 为什么要把项目做成“一组模型”而不是孤立的一个模型原作者标题里明确写了“three LLMs”这在实验设计上通常不是无心之举。从可复现实验的角度看一次训练多个模型最合理的动机是做对照让不同变量之间可比从而回答某个具体问题。常见的三种设计思路如下。第一种是固定数据、改变模型规模。分别训练一个小参数模型、一个中等参数模型、一个较大参数模型观察在同样语料和训练步数下规模增大带来的 loss 变化是否值得额外算力。这种实验能直接验证缩放法则也能帮助你以后判断“这个语料规模到底需要多大模型”。第二种是固定底座、对比不同的后训练阶段。先训练一个基础底座再在底座上继续做指令微调或者干脆保留 base、SFT 两个版本比较它们在生成连贯性、指令服从性上的差异。这能解释为什么开源社区总是发布 base 版和 chat 版两个权重而不是只给最终版本。第三种是同一个底座下训练多个分支用于验证数据配比、学习率、上下文长度等超参数的影响。训练一次可能充满偶然性结果是“同一个问题换个随机种子就消失”所以一次跑出两个以上结果能有效排除运气因素。我不清楚原作者采用的是哪种设计但从技术传播的角度看“一次展示三个模型”本身就比“一个模型”更有说服力。它天然包含对比信息读者可以通过试玩不同模型获得直观体验哪些能力来自参数规模哪些能力来自训练数据哪些能力来自后训练。对浏览者来说这种对比降低了理解门槛对作者来说它证明了对实验流程有控制力。如果你的复刻项目也想这么做不必在第一次就追求三个大模型。先用同一批数据训练两个参数量不同的最小模型再训练一个经过指令微调的版本就足够完成一组漂亮的小实验。5. 从数据到训练脚本一个可以直接跑的最小骨架下面的脚本是一个字符级 LLM 的最小但完整实现。它不追求效果追赶商用模型只保证一件事你在自己的机器上能把“读取文本 → 训练 → 生成文字”的流程完整跑通。它用 PyTorch 的 TransformerEncoder 搭建了一个带因果掩码的解码器结构训练目标是预测下一个 Token。# 文件路径micro_llm_train.py import argparse import glob import torch import torch.nn as nn from torch.utils.data import DataLoader, Dataset class CharTokenizer: 最简字符级分词器把文本转成字符 id 序列。 def __init__(self, text: str): chars sorted(set(text)) self.stoi {ch: i for i, ch in enumerate(chars)} self.itos {i: ch for i, ch in enumerate(chars)} self.vocab_size len(chars) def encode(self, s: str) - list[int]: return [self.stoi[ch] for ch in s] def decode(self, ids: list[int]) - str: return .join(self.itos[i] for i in ids) class TextDataset(Dataset): def __init__(self, text: str, tokenizer: CharTokenizer, block_size: int): tokens tokenizer.encode(text) self.data torch.tensor(tokens, dtypetorch.long) self.block_size block_size def __len__(self) - int: return max(0, len(self.data) - self.block_size) def __getitem__(self, idx: int): x self.data[idx: idx self.block_size] y self.data[idx 1: idx self.block_size 1] return x, y class MicroGPT(nn.Module): def __init__(self, vocab_size: int, max_seq_len: int 256, n_layer: int 4, n_head: int 4, n_embd: int 128): super().__init__() self.config dict( vocab_sizevocab_size, max_seq_lenmax_seq_len, n_layern_layer, n_headn_head, n_embdn_embd, ) self.max_seq_len max_seq_len self.token_embed nn.Embedding(vocab_size, n_embd) self.pos_embed nn.Parameter(torch.zeros(1, max_seq_len, n_embd)) layer nn.TransformerEncoderLayer( d_modeln_embd, nheadn_head, dim_feedforwardn_embd * 4, dropout0.1, activationgelu, batch_firstTrue, norm_firstTrue, ) self.transformer nn.TransformerEncoder(layer, num_layersn_layer) self.ln_f nn.LayerNorm(n_embd) self.lm_head nn.Linear(n_embd, vocab_size, biasFalse) def forward(self, idx: torch.Tensor, targets: torch.Tensor | None None): _, T idx.shape x self.token_embed(idx) self.pos_embed[:, :T, :] # 上三角为 -inf 的掩码保证当前位置只能看到它自己及之前的 Token causal_mask torch.full((T, T), float(-inf), deviceidx.device) causal_mask torch.triu(causal_mask, diagonal1) h self.transformer(x, maskcausal_mask) logits self.lm_head(self.ln_f(h)) loss None if targets is not None: loss nn.functional.cross_entropy( logits.reshape(-1, logits.size(-1)), targets.reshape(-1), ) return logits, loss torch.no_grad() def generate(self, idx: torch.Tensor, max_new_tokens: int 100, temperature: float 1.0) - torch.Tensor: self.eval() for _ in range(max_new_tokens): ctx idx[:, -self.max_seq_len:] logits, _ self.forward(ctx) logits logits[:, -1, :] / temperature probs torch.softmax(logits, dim-1) next_id torch.multinomial(probs, num_samples1) idx torch.cat([idx, next_id], dim1) return idx def load_corpus(patterns: str) - str: texts [] for pattern in patterns.split(,): for path in glob.glob(pattern.strip()): with open(path, r, encodingutf-8) as f: texts.append(f.read()) if not texts: raise FileNotFoundError(没有匹配到任何语料文件请检查 --corpus 参数) return \n.join(texts) def main(): parser argparse.ArgumentParser() parser.add_argument(--corpus, defaultcorpus/*.txt, help语料 glob 模式多个模式用逗号分隔) parser.add_argument(--block-size, typeint, default128) parser.add_argument(--n-layer, typeint, default4) parser.add_argument(--n-head, typeint, default4) parser.add_argument(--n-embd, typeint, default128) parser.add_argument(--epochs, typeint, default10) parser.add_argument(--batch-size, typeint, default16) parser.add_argument(--lr, typefloat, default3e-4) parser.add_argument(--save, defaultmodel.pt) args parser.parse_args() print(正在加载语料...) text load_corpus(args.corpus) print(f语料字符数: {len(text)}) tokenizer CharTokenizer(text) dataset TextDataset(text, tokenizer, block_sizeargs.block_size) dataloader DataLoader(dataset, batch_sizeargs.batch_size, shuffleTrue) print(f词表大小: {tokenizer.vocab_size}样本条数: {len(dataset)}) model MicroGPT( vocab_sizetokenizer.vocab_size, max_seq_lenargs.block_size, n_layerargs.n_layer, n_headargs.n_head, n_embdargs.n_embd, ) optimizer torch.optim.AdamW(model.parameters(), lrargs.lr) print(f参数量: {sum(p.numel() for p in model.parameters()):,}) for epoch in range(1, args.epochs 1): model.train() total_loss 0.0 for step, (xb, yb) in enumerate(dataloader): logits, loss model(xb, yb) optimizer.zero_grad() loss.backward() nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() total_loss loss.item() if step % 100 0: print(fepoch {epoch}, step {step}, loss {loss.item():.4f}) avg_loss total_loss / max(1, len(dataloader)) print(fepoch {epoch} 完成平均 loss {avg_loss:.4f}) # 采样一段文本作为训练效果的定性参考 model.eval() with torch.no_grad(): seed torch.tensor([[tokenizer.stoi[从]]], dtypetorch.long) out_ids model.generate(seed, max_new_tokens64, temperature0.9) sample_text tokenizer.decode(out_ids[0].tolist()) print(f样本输出: {sample_text}) model.train() torch.save( { model_config: model.config, model_state_dict: model.state_dict(), tokenizer: tokenizer, }, args.save, ) print(f模型已保存到 {args.save}) if __name__ __main__: main()在使用这个脚本之前先准备一个corpus目录放入一段或多段 UTF-8 编码的纯文本。中英文语料都可以但是字符级 Tokenizer 会把语料中每一个不同字符都加入词表如果语料里混入大量无关特殊符号词表会被撑大且没有意义。命令行运行方式很简单# 先确认已安装 torch # pip install torch # 准备语料后开始训练 python micro_llm_train.py \ --corpus corpus/*.txt \ --block-size 128 \ --n-layer 4 \ --n-head