从零训练小型LLM:字符级到指令微调与在线部署全流程

发布时间:2026/9/4 20:24:18
从零训练小型LLM:字符级到指令微调与在线部署全流程 训练自己的 LLM 并不是研究者才需要做的事。个人开发者同样可以复现一条“数据准备 - 从零预训练 - 指令微调 - 网页 Demo”的完整链路而且并不需要几十亿参数。本文要讨论的项目是训练三个相互独立但又承接递进关系的小型 LLM并把它们放到线上让人直接试用。这里的“小型”是刻意的参数控制在几百万到几千万级别训练数据控制在可下载、可检查、可重复处理的范围这样一台带 GPU 的机器甚至配置合理的 CPU 环境都能跑完。这类项目最大的价值不是得到一个能媲美商业模型的聊天助手而是把“大语言模型从零开始”这条链路彻底走通。你会看到词表怎么构建、语料怎么切块、损失为什么下降、模型为什么生成乱码、提供服务时又要处理哪些并发与安全问题。下面按一条适合个人项目和教学实验的主线展开。1. 先想清楚为什么是三个小 LLM而不是一个大模型1.1 三个模型的角色与分工一次训练三个模型听上去像浪费算力但如果三个模型分别负责链路中的不同阶段成本反而比“一个中等模型练到底”更低排错也更清晰。建模时可以这样分配角色模型定位训练方式适合验证的问题Model A字符级自回归模型从随机权重开始用原始字符做词表预训练数据加载、模型前向、损失下降、基础生成是否正常Model B子词级语言模型先训练 BPE Tokenizer再从头预训练分词质量、上下文长度、生成结果是否像自然语言Model C指令微调后的对话模型在 Model B 权重基础上做 SFT指令格式是否有效、回复是否对齐用户问题Model A 和 Model B 更像“从零训练”的必经梯度。Model C 实际上是同一个基础模型在指令数据上的延续训练这种安排比第三次重新随机初始化要经济得多。对个人项目而言用“一个基础模型 不同阶段的检查点”来理解 LLM比追求三个完全独立的新模型更有性价比。这三个模型最终分别对应一个网页测试入口体验逻辑可以做同一套 API也可以每个模型一个 API 端口。推荐后一种方式因为可以互不影响地重启单个服务也便于在一台服务器上用不同资源配额做限流。1.2 从零训练在这个项目里的真实含义“从零训练”指的是不使用 Hugging Face Hub 上已经训练好的权重不加载 GPT-2、BERT 或 Llama 的 checkpoint 作为初始化。模型参数是随机初始化的词表是自己构建的输入输出结构也由自己控制。这样做能看清楚每个环节的真实成本也能避开“下载预训练权重后只会做推理”的黑盒状态。但这种项目也要克制预期。几百万参数的小模型学不到复杂的百科知识无法可靠推理也很容易产生重复文本。它的主要目标是验证自己的数据清洗流程是否正确验证模型结构是否能通过训练持续降低损失验证推理时温度、重复惩罚、停止词这些参数对输出质量的影响验证部署链路能否承载多人同时试用。学习环境和生产环境在这里必须分开。学习时可以先做中文几千行文本或英文 WikiText 摘要先把“能训练”跑通等到真正想让朋友试用再考虑用更干净的语料、更大的词表、更长的 block_size并给服务加上鉴权与限流。2. 环境准备与项目结构先把地基铺好2.1 运行环境与依赖版本这个小项目依赖不多但版本需要互相兼容。训练使用 PyTorch分词使用 Hugging Face Tokenizers数据读取使用 Hugging Face Datasets在线服务使用 FastAPI。示例依赖文件requirements.txttorch2.1.0 transformers4.38.0 tokenizers0.15.0 datasets2.17.0 fastapi0.110.0 uvicorn[standard]0.29.0 sentencepiece0.1.99 pydantic2.6.0 tqdm4.66.0环境可以根据自己的 CUDA 版本调整。如果显卡显存较小可以安装 CPU 版 PyTorch但训练步数要相应调小。这里给出的版本不是唯一选择落地前要结合自己的驱动和 Python 版本确认。推荐使用 Python 3.10 或 3.11。之后创建虚拟环境python -m venv .venv source .venv/bin/activate pip install -U pip pip install -r requirements.txt在 Windows 上激活命令是.venv\Scripts\activate。检查是否可用 GPUpython -c import torch; print(torch.cuda.is_available())如果输出True训练会在 GPU 上进行输出False也不影响流程只是速度会慢很多。2.2 项目目录与关键文件目录结构建议一开始就固定避免训练到一半找不到 checkpointllm-from-scratch-demo/ ├── data/ # 原始语料和预处理后的文本 ├── models/ # 三个模型的 checkpoint 保存目录 ├── tokenizers/ # 训练好的 BPE tokenizer ├── core/ │ ├── __init__.py │ ├── mini_gpt.py # Decoder-only Transformer 实现 │ ├── data_utils.py # 语料加载、切块、Dataset │ └── trainer.py # 训练循环 ├── app/ │ ├── __init__.py │ ├── main.py # FastAPI 入口 │ ├── serve_gpt.py # 在线推理封装 │ └── models_cfg.py # 三个模型各自的配置 ├── scripts/ │ ├── prepare_data.py │ ├── train_model_a.py │ ├── train_model_b.py │ ├── train_model_c.py │ └── export_state.py ├── web/ │ └── index.html └── requirements.txt这份结构把训练脚本和模型实现分开。core/mini_gpt.py是核心模型三个训练脚本都复用它只是数据、词表、超参数不同。部署层放在app/训练层不应当被在线服务直接 import。2.3 数据准备语料要干净规模要克制个人项目最容易犯的错误是一开始就下载几十 GB 语料。数据量大并不是问题问题是你很难判断训练失败是模型写错还是数据太脏。首轮实验应该使用处理成本低、来源清晰的公开语料。这里以 Hugging Face 的wikitext-2-raw-v1作为例子它属于学习型英文语料文件小容易判断分词和生成质量。scripts/prepare_data.pyfrom datasets import load_dataset dataset load_dataset(wikitext, wikitext-2-raw-v1, splittrain) text \n.join(dataset[text]) # 使用小比例先跑通流程 sample_size 100_000 sample_text text[:sample_size] with open(data/corpus_a.txt, w, encodingutf-8) as f: f.write(sample_text) print(f原始字符数: {len(sample_text)})如果你有自己的一份中文语料也可以直接把 txt 文件放到data/用相同思路清洗即可。清洗时至少要做三件事去掉无关 URL、空行、重复段落统一换行符不要把 Markdown 语法和正文混在一起训练除非你明确希望模型学会 Markdown。Model A 使用字符级词表时不需要额外训练 tokenizer只需要统计全部字符。Model B 需要单独训练 BPE tokenizer。Model C 在 Model B 基础上继续训练所以它复用 Model B 的 tokenizer不需要重新训练。3. 用 PyTorch 写一个最小可训练的 Decoder-Only Transformer3.1 为什么用 Decoder-Only 结构当前主流 LLM 大多采用 Decoder-Only 结构。它只保留 Transformer 的解码器部分但内部通过因果注意力掩码保证每个位置只能看到当前位置及之前的信息。这样做的好处是训练和生成可以共用同一套参数结构训练时一次预测所有位置的下一个 token生成时逐个 token 自回归扩展。在小型从零训练项目中自己实现这个结构比直接调用现成 GPT 接口更有学习价值。核心部分包括因果注意力、前馈网络、残差连接、LayerNorm 和 token/position embedding。3.2 模型代码核心模块与生成函数下面给出一段适合学习的 PyTorch 实现在core/mini_gpt.py。它刻意精简了细节只保留能跑通训练的最小能力。import math import torch import torch.nn as nn class CausalSelfAttention(nn.Module): def __init__(self, d_model: int, n_head: int, dropout: float 0.1): super().__init__() assert d_model % n_head 0 self.n_head n_head self.head_dim d_model // n_head self.c_attn nn.Linear(d_model, 3 * d_model) self.c_proj nn.Linear(d_model, d_model) self.attn_dropout nn.Dropout(dropout) self.resid_dropout nn.Dropout(dropout) def forward(self, x): B, T, C x.size() qkv self.c_attn(x) # (B, T, 3*C) q, k, v qkv.split(C, dim2) q q.view(B, T, self.n_head, self.head_dim).transpose(1, 2) k k.view(B, T, self.n_head, self.head_dim).transpose(1, 2) v v.view(B, T, self.n_head, self.head_dim).transpose(1, 2) att (q k.transpose(-2, -1)) / math.sqrt(self.head_dim) mask torch.tril(torch.ones(T, T, devicex.device)).view(1, 1, T, T) att att.masked_fill(mask 0, float(-inf)) att torch.softmax(att, dim-1) att self.attn_dropout(att) y att v y y.transpose(1, 2).contiguous().view(B, T, C) return self.resid_dropout(self.c_proj(y)) class MLP(nn.Module): def __init__(self, d_model: int, dropout: float 0.1): super().__init__() self.fc1 nn.Linear(d_model, 4 * d_model) self.gelu nn.GELU() self.fc2 nn.Linear(4 * d_model, d_model) self.dropout nn.Dropout(dropout) self.ln nn.LayerNorm(d_model) def forward(self, x): return self.dropout(self.fc2(self.gelu(self.fc1(self.ln(x))))) class Block(nn.Module): def __init__(self, d_model: int, n_head: int, dropout: float 0.1): super().__init__() self.ln1 nn.LayerNorm(d_model) self.attn CausalSelfAttention(d_model, n_head, dropout) self.ln2 nn.LayerNorm(d_model) self.mlp MLP(d_model, dropout) def forward(self, x): x x self.attn(self.ln1(x)) x x self.mlp(self.ln2(x)) return x class MiniGPT(nn.Module): def __init__( self, vocab_size: int, block_size: int, d_model: int 128, n_head: int 4, n_layer: int 4, dropout: float 0.1, ): super().__init__() self.block_size block_size self.token_embedding nn.Embedding(vocab_size, d_model) self.pos_embedding nn.Embedding(block_size, d_model) self.drop nn.Dropout(dropout) self.blocks nn.Sequential( *[Block(d_model, n_head, dropout) for _ in range(n_layer)] ) self.ln_f nn.LayerNorm(d_model) self.head nn.Linear(d_model, vocab_size) def forward(self, idx): B, T idx.size() assert T self.block_size tok_emb self.token_embedding(idx) pos torch.arange(T, deviceidx.device).unsqueeze(0) pos_emb self.pos_embedding(pos) x self.drop(tok_emb pos_emb) x self.blocks(x) return self.head(self.ln_f(x)) torch.no_grad() def generate( self, idx, max_new_tokens: int 32, temperature: float 0.8, top_k: int 50, ): self.eval() for _ in range(max_new_tokens): idx_cond idx[:, -self.block_size:] logits self(idx_cond) last_logits logits[:, -1, :] / max(temperature, 1e-5) if top_k is not None: v, _ torch.topk(last_logits, top_k) last_logits[last_logits v[:, -1].unsqueeze(-1)] float(-inf) probs torch.softmax(last_logits, dim-1) next_id torch.multinomial(probs, num_samples1) idx torch.cat((idx, next_id), dim1) return idx这段代码里最值得注意的地方是因果注意力掩码。它使用torch.tril生成下三角矩阵让当前位置的注意力只能访问左侧信息。如果去掉这一步模型在训练时就能“看到未来 token”损失会异常低但生成阶段仍然无法正常工作写代码时很容易忽略。LayerNorm 放在每个残差分支的开始而不是像早期 Transformer 那样放在最后属于 Pre-LN 设计。这种写法训练更稳定也更容易在较小模型上调高学习率。3.3 训练方法和 loss 必须盯住训练脚本要先构造输入和标签。例如一段 token 为[a, b, c, d, e]设置block_size4后输入[a, b, c, d]的标签就是[b, c, d, e]。模型在每一步都预测“下一个 token”。下面是一个通用训练数据封装放在core/data_utils.pyimport torch from torch.utils.data import Dataset class TokenDataset(Dataset): def __init__(self, tokens, block_size): self.tokens tokens self.block_size block_size def __len__(self): return len(self.tokens) - self.block_size - 1 def __getitem__(self, idx): x torch.tensor( self.tokens[idx : idx self.block_size], dtypetorch.long ) y torch.tensor( self.tokens[idx 1 : idx self.block_size 1], dtypetorch.long ) return x, y训练循环不需要复杂但至少要做到梯度裁剪、周期性打印 loss、保存 checkpoint。代码可以统一放在core/trainer.pyimport os import time import torch import torch.nn.functional as F def train_lm( model, train_loader, optimizer, device, vocab_size, epochs3, log_interval50, save_dirmodels, model_namemodel_base, ): model.to(device) model.train() os.makedirs(save_dir, exist_okTrue) global_step 0 for epoch in range(epochs): for step, (x, y) in enumerate(train_loader): x x.to(device) y y.to(device) logits model(x) loss F.cross_entropy( logits.view(-1, vocab_size), y.view(-1), ) optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step() if step % log_interval 0: print( fepoch {epoch 1}/{epochs} fstep {step} loss {loss.item():.4f} ) global_step 1 save_path os.path.join(save_dir, f{model_name}.pt) torch.save(model.state_dict(), save_path) print(fsave model to {save_path})对于从零训练的小模型如果 loss 在前几百步内不下降通常不是“训练不够”而是模型结构或数据加载有问题。常见原因包括注意力掩码没有真正生效标签位移错误导致模型永远在预测当前位置学习率过高loss 震荡很大tokenizer 中大量 token 从未出现在训练数据中文本切块时没有使用连续 token导致每个样本之间没有上下文关联。4. 把三个模型跑起来从字符级、子词级到指令微调4.1 Model A字符级模型用来打通训练闭环字符级模型不需要训练复杂 tokenizer只需统计语料中出现的字符建立字符到数字的映射。它生成的文本经常带有奇怪的拼写但对验证训练链路非常有效。# scripts/train_model_a.py import torch from torch.utils.data import DataLoader from core.data_utils import TokenDataset from core.mini_gpt import MiniGPT from core.trainer import train_lm with open(data/corpus_a.txt, r, encodingutf-8) as f: text f.read() chars sorted(set(text)) stoi {ch: i for i, ch in enumerate(chars)} itos {i: ch for i, ch in enumerate(chars)} vocab_size len(chars) # 示例用极短 block_size先验证流程效果稳定后可以调大 block_size 64 train_tokens [stoi[ch] for ch in text] dataset TokenDataset(train_tokens, block_size) loader DataLoader(dataset, batch_size32, shuffleTrue, drop_lastTrue) model MiniGPT( vocab_sizevocab_size, block_sizeblock_size, d_model128, n_head4, n_layer4, ) optimizer torch.optim.AdamW(model.parameters(), lr3e-4) train_lm( model, loader, optimizer, devicecuda if torch.cuda.is_available() else cpu, vocab_sizevocab_size, epochs3, save_dirmodels, model_namemodel_a, )这一步跑通后可以用一个非常短的前缀做生成测试。如果只能重复单个字符优先检查 loss 是否在下降、数据和标签位移是否正确而不是急着扩大模型。4.2 Model B训练 BPE Tokenizer 并扩大上下文字符级模型的最大问题是单元太短模型需要很长上下文才能形成有意义的词。Model B 使用 ByteLevel BPE Tokenizer把英文单词和常见子词看成单个单元序列长度更短学习效率更高。训练 tokenizer 的脚本from tokenizers import ByteLevelBPETokenizer files [data/corpus_a.txt] tokenizer ByteLevelBPETokenizer() tokenizer.train( filesfiles, vocab_size8000, min_frequency2, special_tokens[|endoftext|, |pad|], ) tokenizer.save_model(tokenizers/model_b/)这里把vocab_size设为 8000。对小型教学语料来说16000 到 32000 的词表往往偏大会有一大段 embedding 层始终得不到充分训练。先从小词表开始等生成质量接近语料水平后再逐步加大。加载 tokenizer 和训练 Model B 时需要先把文本切分成 token idfrom tokenizers import ByteLevelBPETokenizer tokenizer ByteLevelBPETokenizer.from_file(tokenizers/model_b/vocab.json, tokenizers/model_b/merges.txt) with open(data/corpus_a.txt, r, encodingutf-8) as f: text f.read() enc tokenizer.encode(text) tokens enc.ids print(token count:, len(tokens)) print(tokenizer.decode(tokens[:50]))Model B 的超参数可以比 Model A 大一点例如d_model256、n_head8、n_layer6、block_size128。训练参数要根据显存和语料规模调整如果显存不足降低batch_size比降低block_size对序列语义的损失更小。实际输出看起来是否自然取决于训练步数和数据量。语料只有几万 token 时BPE 模型仍会大片重复这并不代表代码错误。只要训练 loss 稳定下降并且验证集 loss 没有在后期显著上升就可以判断流程正常。4.3 Model C在预训练模型上补指令会话能力Model C 的目标是把 Model B 变成一个能回答问题、能结束输出、而不是无限续写的模型。它使用指令数据做监督微调而不是再从头预训练。一条简单指令样本可以组织为{ instruction: 用一句话解释什么是梯度下降。, answer: 梯度下降是一种通过计算损失函数梯度并沿反方向更新参数来最小化损失的方法。 }在训练文本中把所有样本拼成如下结构|user| 用一句话解释什么是梯度下降。 |assistant| 梯度下降是一种通过计算损失函数梯度并沿反方向更新参数来最小化损失的方法。 |endoftext|下面是 Model C 训练脚本的核心片段from tokenizers import ByteLevelBPETokenizer tokenizer ByteLevelBPETokenizer.from_file(tokenizers/model_b/vocab.json, tokenizers/model_b/merges.txt) SPECIAL_SEP |endoftext| user_text 用一句话解释什么是梯度下降。 answer_text 梯度下降是一种通过计算损失函数梯度并沿反方向更新参数来最小化损失的方法。 raw f|user|{user_text}|assistant|{answer_text}|endoftext| sample_ids tokenizer.encode(raw).ids由于新增了|user|和|assistant|这样的控制 token如果你在训练 Model B 时没有把它们加入 special tokenModel C 阶段必须先扩展词表和 embedding。否则会出现 index 越界或生成的文本里缺少必要的格式标记。微调时学习率通常比预训练低推荐使用1e-5到5e-5。继续使用与 Model B 相同的语言建模目标但更建议对答案部分的 loss 加权或只对答案区域计算损失避免模型把“重复用户问题”也当作正确行为。如果只是做功能验证忽略 label mask 也能看到效果但不能算严格 SFT。生产级 SFT 必须按段落位置生成 maskdef build_sft_mask(ids, assistant_start_pos): mask list(ids) for i in range(len(mask)): mask[i] 0 if i assistant_start_pos else 1 return mask这个函数只是示意完整实现还需要考虑 token 切分后|assistant|的位置如何定位。关键思想是让模型只从回答部分学习如何组织语言而不是把指令模板本身当作需要记忆的文本。5. 用 FastAPI 把模型封装成网页 Demo再放到线上5.1 API 层要管住参数和并发在线 Demo 不能直接把 PyTorch 模型对象暴露给 HTTP 请求。需要一层 API 接口负责参数校验、请求排队、模型推理、错误处理和超时控制。app/main.py示例from fastapi import FastAPI, HTTPException from pydantic import BaseModel, Field from app.serve_gpt import load_models from app.models_cfg import MODEL_CONFIGS app FastAPI(titleLLM Demo Server) models load_models(MODEL_CONFIGS) class GenerateRequest(BaseModel): model: str Field(..., pattern^(model_a|model_b|model_c)$) prompt: str Field(, max_length512) max_new_tokens: int Field(32, ge1, le256) temperature: float Field(0.8, ge0.1, le1.5) top_k: int Field(50, ge1, le100) app.post(/api/generate) def generate(req: GenerateRequest): if req.model not in models: raise HTTPException(status_code404, detailmodel not found) try: text models[req.model].generate_text( req.prompt, max_new_tokensreq.max_new_tokens, temperaturereq.temperature, top_kreq.top_k, ) return {model: req.model, text: text} except Exception as exc: # 生产环境要记录完整堆栈response 只给简洁信息 raise HTTPException(status_code500, detailgenerate failed) from excmax_length、max_new_tokens、temperature都要限制范围避免用户通过很长的 prompt 或很大的生成长度占满内存。model字段也要白名单校验不能出现任意的模型路径。serve_gpt.py可以只做一层薄封装把模型推理细节和 tokenizer 从 FastAPI 路由中隔离出来import torch from core.mini_gpt import MiniGPT class LLMRunner: def __init__(self, model, tokenizer, device): self.model model.to(device).eval() self.tokenizer tokenizer self.device device torch.no_grad() def generate_text(self, prompt, max_new_tokens32, temperature0.8, top_k50): # 这里的 tokenizer 可能是字符串映射或 BPE if hasattr(self.tokenizer, encode): ids self.tokenizer.encode(prompt).ids else: ids [self.tokenizer.stoi.get(ch, 0) for ch in prompt] if not ids: return input_ids torch.tensor([ids], dtypetorch.long, deviceself.device) output_ids self.model.generate( input_ids, max_new_tokensmax_new_tokens, temperaturetemperature, top_ktop_k, )[0].tolist() if hasattr(self.tokenizer, decode): return self.tokenizer.decode(output_ids) return .join( self.tokenizer.itos.get(i, ) for i in output_ids )5.2 前端页面只需要一个最小的生成入口一个可用的 Demo 页面可以没有复杂样式。选择模型、输入 prompt、点击生成、展示结果就够了。web/index.html核心片段select idmodel option valuemodel_aModel A字符级/option option valuemodel_bModel BBPE 语言模型/option option valuemodel_cModel C指令模型/option /select textarea idprompt rows4The future of AI/textarea button idrun生成/button pre idoutput/pre script document.getElementById(run).addEventListener(click, async () { const resp await fetch(/api/generate, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ model: document.getElementById(model).value, prompt: document.getElementById(prompt).value, max_new_tokens: 64, temperature: 0.8 }) }); const data await resp.json(); document.getElementById(output).innerText data.text || data.detail; }); /script本地启动时可以只访问 8000 端口不需要把前端和 API 分开。上线后如果由 Nginx 统一接收 443 流量再把请求转发到 8000静态文件可以由 Nginx 直接托管。启动命令uvicorn app.main:app --host 0.0.0.0 --port 8000本地验证curl -X POST http://127.0.0.1:8000/api/generate \ -H Content-Type: application/json \ -d {model:model_c,prompt:什么是Transformer,max_new_tokens:32,temperature:0.8}5.3 部署到线上时要补的安全和稳定性设计把服务放到线上之前至少要考虑下面几件事。学习环境可以省略线上 Demo 却不能省略。第一API 必须加鉴权。最简单的方案是让前端页面请求时带一个固定 token服务端在 FastAPI 中间件或依赖函数中校验。这个方案不复杂但能过滤掉大量乱扫请求。第二要限制单次生成长度。小型模型在 CPU 上生成 256 个 token 可能需要几十秒甚至更久单个请求占满 CPU 时其他人就体验不到服务了。第三推理服务不要直接跑在 root 用户下不要开放未限制的端口。让模型读取的 checkpoint 文件放在固定目录使用环境变量配置路径避免硬编码服务器路径后迁移困难。第四要为模型服务增加一层超时。在 FastAPI 中如果模型生成阻塞可以设置同步接口的超时时间或者把推理放入线程池并限制任务队列长度。最简单的做法是让前端请求也设置AbortController避免用户等待过久。下面是一个简单的 Nginx 配置示例负责把 443 收到的请求转发到本机 8000并为静态页面提供访问server { listen 443 ssl http2; server_name llm-demo.example.com; ssl_certificate /etc/nginx/certs/demo.crt; ssl_certificate_key /etc/nginx/certs/demo.key; location /api/ { proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_http_version 1.1; proxy_read_timeout 120s; proxy_pass http://127.0.0.1:8000; } location / { root /var/www/llm-demo; index index.html; } }生产环境需要由 CA 签发证书而不是使用自签名证书。这里配置只用于说明转发思路实际目录和域名要根据自己的服务器调整。5.4 上线后的验证方式上线后不能只验证“页面能打开”。至少要做三组检查功能验证三个模型分别生成内容确认能返回正常 JSON不会出现 500。并发验证用脚本同时发 10 到 20 个请求观察服务是否被拖垮内存是否飙升。安全验证请求不带鉴权 token 时是否会被拒绝发送超长 prompt 时是否会被拒绝。如果使用 GPU 部署还要监控显存。多个请求同时推理时显存会累积增长需要控制并发数必要时用torch.inference_mode()替代torch.no_grad()并主动释放不再用的特征图。6. 运行结果验证与常见问题排查6.1 三种验证方式与预期现象阶段验证方式预期现象训练阶段观察训练 loss 是否稳定下降前几百步后 loss 应明显低于随机初始化时的数值生成阶段手动输入短文本检查输出输出长度符合max_new_tokens不崩溃且与输入词表一致部署阶段curl 请求 API返回 JSON包含model与text字段对 Model A 来说输出包含很多拼写错误甚至乱码是正常现象。Model B 的训练 loss 下降后应当能生成类似句子结构的文本。Model C 应当能对指令做简短回答并且不会无限重复|endoftext|以外的内容。需要说明的是这里不会给出“训练到某个 loss 就一定输出合理文本”的结论。不同数据规模、词表大小、模型参数配置都会导致不同结果在线 Demo 适合展示的是流程能跑通而不是产出商业质量文本。6.2 常见问题对照表问题现象常见原因检查方式处理建议loss 从一开始就居高不下标签位移错误或数据乱码打印 batch 中 x 与 y 的前几个元素确认 y 是 x 右移一位的结果生成时只重复同一个 token温度过低、模型太小、数据不足调高 temperature检查训练 loss降低 max_new_tokens观察不同 prompt训练时显存不足block_size / batch_size 过大查看 GPU 占用先降低 batch_size再考虑降低 block_sizeAPI 返回 500模型 config 与 checkpoint 不匹配查看 FastAPI 日志确认词表大小、block_size、模型参数一致在线请求很慢CPU 推理生成 max_new_tokens 太长用 time 命令统计单次请求耗时限制最大生成长度增加服务端超时Model C 生成的内容没有区分用户和助手special token 没有加入训练数据检查 tokenizer vocab 是否包含控制符在训练数据和生成时统一使用相同的模板Model C 最常见的现象是模型继续像普通语言模型一样续写而不是回答问题。这通常不是模型结构问题而是指令数据中没有足够的|user|/|assistant|边界信号或者微调步数太少。可以把指令样本重复训练几个 epoch并加长|assistant|后内容所占的比例。6.3 排错顺序按链路逐层缩小范围当三个模型都出现问题时不要直接改训练代码。先按以下顺序排查检查输入数据是否干净是否有大量空行或未知编码字符。检查 tokenizer 的 encode/decode 是否对称。编码再解码后文本应尽量接近原文。检查训练时输入和标签是否错位。检查模型 forward 里的因果注意力 mask 是否生效。检查 loss 曲线。loss 不下降就改回超小模型先跑 50 步。检查生成时模型处于 eval 模式且没有 dropout 干扰。检查部署层是否把错误吞掉FastAPI 日志是否打印了完整堆栈。如果 loss 正常下降但生成结果糟糕问题大概率在采样参数和生成策略而不是训练没跑通。可以先输出训练集里某段文本的续写结果比较模型是否记住了一部分原文。如果模型能续写出接近原文的句子说明模型容量和词表已经匹配数据只是对外展示条件需要调整。7. 最佳实践可执行清单和更远一步7.1 用 Markdown 管理实验记录这个项目有三个模型、多种数据大小、多组超参数靠脑记完全不现实。建议在本地建一个实验记录目录为每个模型建独立 Markdown 文件这也是简称 LLM wiki 之类的本地知识库方法可以落地的场景之一。每个模型页面至少要记录字段内容示例模型名model_c基础模型model_b checkpoint step 5000数据来源清洗后的中文指令集约 2000 条词表大小8000block_size128最终 loss3.12生成样例见下方文本问题记录曾出现 其他开发者拿到这份 Markdown 不一定能复现完全一致的效果但至少能知道这个实验尝试过什么、卡在哪里。个人项目里这一份记录往往比模型本身更有价值。7.2 上线前检查清单把三个模型做成在线 Demo 前使用下面的清单逐项检查而不是在收到服务器告警后才补依赖版本是否已锁定requirements.txt 是否与本地一致checkpoint 路径是否使用环境变量而不是服务器绝对路径模型加载是否只执行一次避免每个请求都加载权重API 是否限制 prompt 长度和生成长度是否校验 model 参数为白名单值是否设置鉴权 token 和限流是否限制并发数避免显存或内存被打满FastAPI 日志是否保留请求参数基础信息和异常堆栈但又不打印敏感信息是否设置反向转发后的 read timeout是否在启动后先做一次简单 curl 自检。如果目标只是内网试用可以省略部分公网安全配置。但如果让陌生人访问令牌鉴权和请求长度限制是底线。7.3 下一步扩展方向跑通三个模型后可以沿三个方向继续深入。方向一把预训练数据从几万字符扩展到几百万字符并观察 loss 和数据量之间的曲线。这有助于理解“模型记住了什么”和“模型泛化了什么”的区别。方向二在 Model C 上加入更规范的标签 mask并引入多轮对话模板。先记录指令、上下文、回答三部分的位置再让模型只学习回答部分体验会更接近真实产品。方向三把在线推理从 CPU 单进程升级成批量推理或接入 GPU 服务框架。Demo 阶段追求的是“能跑”生产阶段追求的才是“并发、延迟和成本可控”。最后要强调的是在线 Demo 的本质是让用户看到训练过程的真实产物。你不需要把三个小模型伪装成强大的 AI 产品只需要清楚地说明每个模型是什么、用什么数据训练、当前有哪些限制。这样项目反而更有说服力也更适合作为后续学习与面试展示的素材。