这篇能做出什么
读完并跟着做完,你会得到一条完整的自训练链路:从一堆原始网页/书籍/代码文本出发,经过清洗、去重、配比,训练出自己的 tokenizer,预训练出一个约 1B(10 亿)参数量的基础模型,最后做退火、跑评测、导出成能直接加载推理的权重。
产出物具体是这些:
- 一份去重后的训练语料,规模在 20B~100B token 量级(可按算力裁剪)
- 一个词表 32k~64k 的自训练 tokenizer
- 若干
.bin分片 + 一个可复现的 dataloader - 一个 1B 参数的 dense decoder-only 模型,支持 2k~4k 上下文
- 训练日志、loss 曲线、中期 checkpoint
- 一个退火后的最终模型,以及一套可复现的评测脚本
需要说明的是:这条路线的意义在于"不依赖任何外部模型的输出"。你不用去蒸馏一个大模型的 logits,也不用别人的指令数据,模型的能力全部来自你自己筛出来的语料。代价是慢,但对数据有完全的控制权,也更容易定位问题。
算力预算的估算用这个通用公式就够了:
```
训练总 FLOPs ≈ 6 × 参数量 × 训练 token 数
训练时长 ≈ 训练总 FLOPs ÷ (卡数 × 单卡有效算力 × 并行效率)
```
1B 模型训 20B token,大约是 1.2×10²⁰ FLOPs 量级。把你自己机器的有效算力代进去,就能估出时长。具体卡型、租用价格以云厂商官方页面为准。
前置条件清单
硬件
- 至少 8 张同型号加速卡(显存 40GB 以上比较舒服)。1B 模型单卡也能训,但吞吐太低,等于浪费生命。
- 卡间有高速互联(NVLink / RDMA)。没有的话通信会成为瓶颈。
- 本地或网络存储 2TB 以上。tokenize 后的数据比原始文本大 2~4 倍。
软件
- PyTorch 较新版本(具体版本以官方文档为准),带
torch.distributed和torch.amp transformers、tokenizers、datasets、safetensors- 分布式训练框架二选一:FSDP(PyTorch 原生)或 DeepSpeed ZeRO
- 评测工具:
lm-evaluation-harness(以官方文档为准)
数据
- 原始文本至少几千亿字符,清洗后剩下 10%~30% 是正常的
- 来源建议覆盖:通用网页、书籍、百科、代码、少量高质量中文问答/论坛
人的准备
- 一次预训练通常要跑几天到几周。你需要习惯"看曲线、等结果、中途不折腾"。
步骤一:数据清洗与去重
这是整条链路里最值钱的一步。模型能力上限几乎由它决定。
1.1 统一的文档格式
先把所有来源统一成 JSONL,一行一个文档:
```json
{"text": "文档正文……", "source": "web", "lang": "zh", "url_hash": "a1b2c3"}
```
注意不要保留原始 URL(隐私和合规问题),只留哈希。
1.2 语言识别与路由
```python
伪代码,具体 API 以官方文档为准
from fasttext import load_model
lid = load_model("lid.176.bin") # 语言识别模型,以官方文档为准
def detect(text: str) -> tuple[str, float]:
labels, scores = lid.predict(text.replace("\n", " ")[:1000], k=1)
return labels[0].replace("__label__", ""), float(scores[0])
def route(doc):
lang, score = detect(doc["text"])
if score < 0.7:
return None # 语言不确定,丢掉
doc["lang"] = lang
return doc
```
中文和英文建议分开放,后面配比的时候按语言分别采样。
1.3 启发式质量过滤
这一步不需要模型,纯规则就能干掉一大半垃圾:
```python
import re
def quality_filter(text: str) -> bool:
if len(text) < 200 or len(text) > 200_000:
return False
标点占比过低,大概率是乱码或代码碎片
punct = len(re.findall(r"[,。!?;:,.!?;:]", text))
if punct / len(text) < 0.005:
return False
行平均长度过短,可能是导航栏、菜单
lines = [l for l in text.split("\n") if l.strip()]
if lines and sum(len(l) for l in lines) / len(lines) < 15:
return False
重复行占比过高
if len(set(lines)) / max(len(lines), 1) < 0.6:
return False
连续重复字符("哈哈哈哈哈哈")
if re.search(r"(.)\1{30,}", text):
return False
敏感词表命中
if hit_blocklist(text):
return False
return True
```
1.4 去重:MinHash + LSH
重复数据会让模型直接背诵,必须在训练前干掉。文档级用 MinHash LSH,段落级用精确后缀数组或 SimHash。
```python
需要 datasketch 库,API 以官方文档为准
from datasketch import MinHash, MinHashLSH
def shingles(text, n=5):
text = normalize_whitespace(text)
return {text[i:i+n] for i in range(len(text) - n + 1)}
lsh = MinHashLSH(threshold=0.8, num_perm=128)
for doc_id, text in stream_documents():
m = MinHash(num_perm=128)
for s in shingles(text[:20000]): # 只取前 2 万字符,控制成本
m.update(s.encode("utf-8"))
if lsh.query(m): # 与已有文档高度相似,丢弃
continue
lsh.insert(doc_id, m)
yield doc_id, text
```
阈值 0.8 是比较常用的起点。调低会误杀,调高会漏删。
1.5 数据配比
一个可用的起点(按 token 数占比):
| 来源 | 占比 |
|---|---|
| 中文通用网页 | 35% |
| 英文通用网页 | 25% |
| 中文书籍/百科 | 15% |
| 英文书籍/百科 | 10% |
| 代码 | 10% |
| 高质量问答/论坛 | 5% |
这个比例没有标准答案,但你必须在训练中期做小规模消融实验来验证。
步骤二:训练 Tokenizer
2.1 采样训练语料
不要用全量数据训 tokenizer,采 5GB~20GB 有代表性的文本就够。注意保持和最终训练数据的语言配比一致——这个坑很多人踩:如果 tokenizer 只用中文训,那训练时英文会被切得很碎,英文能力直接废掉。
```python
import random, json
def sample_for_tokenizer(paths, target_bytes=10 * 1024**3, out="tok_sample.txt"):
written = 0
with open(out, "w", encoding="utf-8") as fout:
while written < target_bytes:
p = random.choice(paths)
with open(p, encoding="utf-8") as fin:
for line in fin:
doc = json.loads(line)
fout.write(doc["text"].replace("\n", " ") + "\n")
written += len(doc["text"].encode("utf-8"))
if written >= target_bytes:
break
```
2.2 训 BPE tokenizer
1B 规模的模型,词表 32k~64k 是比较常见的区间。中文场景建议往 48k 以上走,能显著减少序列长度。
```python
from tokenizers import Tokenizer, models, trainers, pre_tokenizers, decoders, normalizers
tokenizer = Tokenizer(models.BPE(unk_token=None)) # byte-level,不需要 unk
tokenizer.normalizer = normalizers.Sequence([
normalizers.NFKC(), # 中文全角/半角统一,很重要
])
tokenizer.pre_tokenizer = pre_tokenizers.ByteLevel(add_prefix_space=False)
trainer = trainers.BpeTrainer(
vocab_size=65536,
special_tokens=["<|endoftext|>", "<|pad|>"],
initial_alphabet=pre_tokenizers.ByteLevel.alphabet(), # 保证 256 个字节都在表里
min_frequency=2,
show_progress=True,
)
tokenizer.train(["tok_sample.txt"], trainer)
tokenizer.decoder = decoders.ByteLevel()
tokenizer.save("tokenizer.json")
```
2.3 验收 tokenizer
```python
tok = Tokenizer.from_file("tokenizer.json")
for s in ["人工智能正在改变世界", "def forward(self, x):", "3.14159"]:
ids = tok.encode(s).ids
print(s, "->", len(ids), tok.decode(ids))
关键指标:压缩率
中文大致 1 token ≈ 1.2~1.8 个汉字比较好
英文大致 1 token ≈ 4 个字符
```
还要测一件事:随机取一段真实训练文本,encode 再 decode,看能不能完全还原。还原不了说明 normalizer 或 decoder 配错了。
步骤三:数据打包
3.1 Tokenize 到二进制分片
```python
import numpy as np
from tokenizers import Tokenizer
tok = Tokenizer.from_file("tokenizer.json")
EOT = tok.token_to_id("<|endoftext|>")
def tokenize_to_shards(jsonl_paths, out_prefix, shard_tokens=100_000_000):
buf = []
shard_id = 0
for path in jsonl_paths:
with open(path, encoding="utf-8") as fin:
for line in fin:
doc = json.loads(line)
ids = tok.encode(doc["text"]).ids
buf.extend(ids)
buf.append(EOT) # 文档之间用 EOT 分隔
while len(buf) >= shard_tokens:
arr = np.array(buf[:shard_tokens], dtype=np.uint16)
词表 < 65536,uint16 足够,能省一半磁盘
arr.tofile(f"{out_prefix}_{shard_id:05d}.bin")
buf = buf[shard_tokens:]
shard_id += 1
if buf:
np.array(buf, dtype=np.uint16).tofile(f"{out_prefix}_{shard_id:05d}.bin")
```
3.2 Dataloader
```python
import numpy as np, torch, glob, random
class TokenShardDataset(torch.utils.data.IterableDataset):
def __init__(self, shard_glob, seq_len, seed=42):
self.shards = sorted(glob.glob(shard_glob))
self.seq_len = seq_len
self.rng = random.Random(seed)
def __iter__(self):
shards = self.shards[:] # 每个 rank 拿到全部;如需分片按 rank 切
self.rng.shuffle(shards)
for shard in shards:
data = np.memmap(shard, dtype=np.uint16, mode="r")
n = (len(data) - 1) // self.seq_len
order = list(range(n))
self.rng.shuffle(order)
for i in order:
start = i * self.seq_len
chunk = data[start:start + self.seq_len + 1].astype(np.int64)
x = torch.from_numpy(chunk[:-1])
y = torch.from_numpy(chunk[1:]) # 标准的下一个 token 预测
yield x, y
```
务必留出验证集:从每个来源抽 0.1% 的文档,单独打包,训练全程不碰。
步骤四:定义 1B 模型结构
一个常见的 1B 配置(示意,按自己的算力调整):
```python
from dataclasses import dataclass
@dataclass
class Config:
vocab_size: int = 65536
d_model: int = 2048
n_layers: int = 24
n_heads: int = 16
n_kv_heads: int = 4 # GQA,减少 KV cache
head_dim: int = 128
ffn_hidden: int = 5504 # SwiGLU,约为 8/3 * d_model 再对齐到 128 的倍数
max_seq_len: int = 4096
rope_theta: float = 10000.0
tie_embeddings: bool = True
```
参数量粗算:24 × (4 × 2048² + 3 × 2048 × 5504) + 65536 × 2048 ≈ 1.0B。
结构选型都用目前的主流件:
- RMSNorm,pre-norm 结构,比 LayerNorm 更省
- RoPE 位置编码,训练长度可以外推
- SwiGLU 前馈网络
- GQA,
n_kv_heads < n_heads - 无 bias 的线性层
- 权重初始化:
std = 0.02,残差输出层额外除以sqrt(2 * n_layers)(GPT-2 的做法,对深层模型稳定性帮助明显)
```python
import torch, torch.nn as nn, torch.nn.functional as F
class RMSNorm(nn.Module):
def __init__(self, dim, eps=1e-6):
super().__init__()
self.weight = nn.Parameter(torch.ones(dim))
self.eps = eps
def forward(self, x):
dtype = x.dtype
x = x.float()
x = x * torch.rsqrt(x.pow(2).mean(-1, keepdim=True) + self.eps)
return (x.to(dtype)) * self.weight
def precompute_rope(head_dim, max_len, theta, device):
inv_freq = 1.0 / (theta ** (torch.arange(0, head_dim, 2, device=device).float() / head_dim))
t = torch.arange(max_len, device=device).float()
freqs = torch.outer(t, inv_freq) # [T, head_dim/2]
return torch.cos(freqs), torch.sin(freqs)
def apply_rope(x, cos, sin):
x: [B, H, T, D]
x1, x2 = x[..., ::2], x[..., 1::2]
cos = cos[None, None, :, :]
sin = sin[None, None, :, :]
return torch.stack([x1 * cos - x2 * sin, x1 * sin + x2 * cos], dim=-1).flatten(-2)
class Attention(nn.Module):
def __init__(self, cfg):
super().__init__()
self.n_heads = cfg.n_heads
self.n_kv_heads = cfg.n_kv_heads
self.head_dim = cfg.head_dim
self.q_proj = nn.Linear(cfg.d_model, cfg.n_heads * cfg.head_dim, bias=False)
self.k_proj = nn.Linear(cfg.d_model, cfg.n_kv_heads * cfg.head_dim, bias=False)
self.v_proj = nn.Linear(cfg.d_model, cfg.n_kv_heads * cfg.head_dim, bias=False)
self.o_proj = nn.Linear(cfg.n_heads * cfg.head_dim, cfg.d_model, bias=False)
self.q_norm = RMSNorm(cfg.head_dim) # QK-Norm,训练不稳定时打开
self.k_norm = RMSNorm(cfg.head_dim)
def forward(self, x, cos, sin):
B, T, _ = x.shape
q = self.q_proj(x).view(B, T, self.n_heads, self.head_dim).transpose(1, 2)
k = self.k_proj(x).view(B, T, self.n_kv_heads, self.head_dim).transpose(1, 2)
v = self.v_proj(x).view(B, T, self.n_kv_heads, self.head_dim).transpose(1, 2)
q, k = self.q_norm(q), self.k_norm(k)
q = apply_rope(q, cos, sin)
k = apply_rope(k, cos, sin)
repeat_kv 把 KV 头复制到和 Q 头数一致(推理时用 KV cache 就不必展开)
rep = self.n_heads // self.n_kv_heads
k = k.repeat_interleave(rep, dim=1)
v = v.repeat_interleave(rep, dim=1)
out = F.scaled_dot_product_attention(q, k, v, is_causal=True)
out = out.transpose(1, 2).reshape(B, T, -1)
return self.o_proj(out)
class MLP(nn.Module):
def __init__(self, cfg):
super().__init__()
self.gate = nn.Linear(cfg.d_model, cfg.ffn_hidden, bias=False)
self.up = nn.Linear(cfg.d_model, cfg.ffn_hidden, bias=False)
self.down = nn.Linear(cfg.ffn_hidden, cfg.d_model, bias=False)
def forward(self, x):
return self.down(F.silu(self.gate(x)) * self.up(x))
class Block(nn.Module):
def __init__(self, cfg):
super().__init__()
self.attn_norm = RMSNorm(cfg.d_model)
self.attn = Attention(cfg)
self.mlp_norm = RMSNorm(cfg.d_model)
self.mlp = MLP(cfg)
def forward(self, x, cos, sin):
x = x + self.attn(self.attn_norm(x), cos, sin)
x = x + self.mlp(self.mlp_norm(x))
return x
class LM(nn.Module):
def __init__(self, cfg):
super().__init__()
self.cfg = cfg
self.embed = nn.Embedding(cfg.vocab_size, cfg.d_model)
self.blocks = nn.ModuleList([Block(cfg) for _ in range(cfg.n_layers)])
self.norm = RMSNorm(cfg.d_model)
self.lm_head = nn.Linear(cfg.d_model, cfg.vocab_size, bias=False)
if cfg.tie_embeddings:
self.lm_head.weight = self.embed.weight # 1B 规模建议绑定,省显存
cos, sin = precompute_rope(cfg.head_dim, cfg.max_seq_len, cfg.rope_theta, "cpu")
self.register_buffer("cos", cos, persistent=False)
self.register_buffer("sin", sin, persistent=False)
self.apply(self._init_weights)
残差输出缩放,稳定深层训练
for name, p in self.named_parameters():
if name.endswith("o_proj.weight") or name.endswith("down.weight"):
nn.init.normal_(p, std=0.02 / (2 * cfg.n_layers) ** 0.5)
@staticmethod
def _init_weights(m):
if isinstance(m, nn.Linear):
nn.init.normal_(m.weight, std=0.02)
elif isinstance(m, nn.Embedding):
nn.init.normal_(m.weight, std=0.02)
def forward(self, idx, targets=None):
x = self.embed(idx)
cos, sin = self.cos[:idx.size(1)], self.sin[:idx.size(1)]
for blk in self.blocks:
x = blk(x, cos, sin)
logits = self.lm_head(self.norm(x))
if targets is None:
return logits, None
loss = F.cross_entropy(
logits.view(-1, logits.size(-1)).float(),
targets.view(-1),
ignore_index=-100,
)
z-loss,抑制 logits 范数爆炸
lz = torch.logsumexp(logits.float(), dim=-1).pow(2).mean()
return logits, loss + 1e-4 * lz
```
步骤五:预训练主循环
5.1 超参起点
| 超参 | 建议值 |
|---|---|
| 学习率(峰值) | 3e-4(1B 规模) |
| warmup | 前 2000 步线性升到峰值 |
| 衰减 | cosine 到峰值的 10% |
| 权重衰减 | 0.1(只作用于 2 维以上的参数) |
| Adam β | (0.9, 0.95) |
| 梯度裁剪 | 1.0 |
| 全局 batch | 2M~4M token |
| 精度 | bf16(不用 fp16,1B 规模也容易溢出) |
| 序列长度 | 前期 2048,后期 4096 |
学习率和 batch size 的关系:batch 越大,峰值学习率可以适当调大,但要相应加长 warmup,否则早期容易炸。
5.2 训练脚本骨架
```python
import os, math, time, torch
import torch.distributed as dist
from torch.distributed.fsdp import FullyShardedDataParallel as FSDP
具体 FSDP API 与 auto_wrap_policy 以官方文档为准
def build_optimizer(model, lr, wd):
decay, no_decay = [], []
for n, p in model.named_parameters():
if not p.requires_grad:
continue
(decay if p.dim() >= 2 else no_decay).append(p)
return torch.optim.AdamW(
[{"params": decay, "weight_decay": wd},
{"params": no_decay, "weight_decay": 0.0}],
lr=lr, betas=(0.9, 0.95), eps=1e-8, fused=True,
)
def lr_at(step, total, peak, warmup, min_ratio=0.1):
if step < warmup:
return peak * step / max(1, warmup)
p = (step - warmup) / max(1, total - warmup)
return peak * (min_ratio + (1 - min_ratio) * 0.5 * (1 + math.cos(math.pi * p)))
def train(cfg, model, dataset, total_steps, device):
dist.init_process_group("nccl")
rank = dist.get_rank()
model = FSDP(model.to(device)) # 简化写法,实际需配 sharding 策略
opt = build_optimizer(model, 3e-4, 0.1)
loader = torch.utils.data.DataLoader(
dataset, batch_size=1, num_workers=2, pin_memory=True,
)
accum = 16 # 梯度累积步数,凑够全局 batch
step = 0
micro = 0
t0 = time.time()
opt.zero_grad(set_to_none=True)
for x, y in loader:
x, y = x.to(device), y.to(device)
with torch.autocast("cuda", dtype=torch.bfloat16):
_, loss = model(x, y)
(loss / accum).backward()
micro += 1
if micro < accum:
continue
micro = 0
lr = lr_at(step, total_steps, 3e-4, 2000)
for g in opt.param_groups:
g["lr"] = lr
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
opt.step()
opt.zero_grad(set_to_none=True)
if rank == 0 and step % 20 == 0:
print(f"step {step} loss {loss.item():.4f} lr {lr:.2e} "
f"{(time.time()-t0):.0f}s", flush=True)
if step > 0 and step % 2000 == 0:
save_checkpoint(model, opt, step, f"ckpt/step_{step}")
step += 1
if step >= total_steps:
break
```
5.3 跑起来
```bash
单机 8 卡,torchrun 的用法以官方文档为准
torchrun --nproc_per_node=8 train.py \
--data "shards/shard_*.bin" \
--seq_len 4096 \
--total_tokens 20_000_000_000 \
--out_dir ckpt/
```
20B token、全局 batch 2M token,大约是 10000 步。
5.4 训练中要盯的指标
- 训练 loss:应该平滑下降。出现尖刺(spike)先看是不是某个 shard 数据有问题。
- 验证 loss:如果验证 loss 开始上升而训练 loss 还在降,说明过拟合或者数据重复,立刻查去重。
- 梯度范数:持续大于 1 说明学习率偏高。
- 吞吐:tokens/秒。如果突然掉一半,通常是某个 rank 的 IO 拖慢了。
步骤六:退火与中期调优
退火(annealing)是自训练里性价比极高的一步:在训练的最后 5%~10% 步数里,把数据换成高质量子集,同时把学习率快速降到接近 0。
为什么有效:高质量数据在低学习率下被"刻"进权重,而前期的大规模低质数据负责打底。这套做法在多个开源模型的技术路线里都能看到。
```python
def build_anneal_dataset(high_quality_jsonl, seq_len):
高质量子集:书籍、百科、代码、精挑的问答
规模通常是主训练集的 1%~5%,比如 1B token
return TokenShardDataset(high_quality_jsonl, seq_len)
训练计划:
step 0 ~ 9000 : 主数据,cosine 从 3e-4 降到 3e-5
step 9000 ~ 10000 : 高质量数据,LR 从 3e-5 线性降到 0
```
退火阶段的几个注意点:
1. 学习率必须真的降到接近 0,不能用 cosine 的 min_ratio 兜底。最后几百步的 LR 应该在 1e-6 以下。
2. 上下文长度在这里扩到最终值(比如 2048 → 4096)。用一个短窗口训练好的模型,只在退火阶段见长文本,也能学到长依赖。
3. 退火数据不要和验证集重叠。
步骤七:评测
评测要分三层,缺一层都不放心。
7.1 困惑度(PPL)
最基础的指标,判断模型有没有真的学到东西:
```python
@torch.no_grad()
def evaluate_ppl(model, loader, device, max_batches=200):
model.eval()
total_loss, total_tok = 0.0, 0
for i, (x, y) in enumerate(loader):
if i >= max_batches:
break
x, y = x.to(device), y.to(device)
with torch.autocast("cuda", dtype=torch.bfloat16):
_, loss = model(x, y)
total_loss += loss.item() * y.numel()
total_tok += y.numel()
model.train()
import math
return math.exp(total_loss / total_tok)
```
分语言分别算:中文 PPL、英文 PPL、代码 PPL。如果英文 PPL 比中文高一倍以上,说明配比失衡。
7.2 下游任务 few-shot
用 lm-evaluation-harness 跑标准任务,具体任务名和参数以官方文档为准。中文建议加一套中文基准(如 C-Eval、CMMLU 这类,具体以官方页面为准)。
重点是绝对值的纵向对比:你的 20B token 模型 vs 50B token 模型 vs 退火前 vs 退火后。横向和别人比意义不大,因为数据完全不同。
7.3 生成质量的人工检查
准备固定的 prompt 集合,每次出 checkpoint 都跑一遍:
```
- 中文续写:"夏天的傍晚,蝉鸣声从窗外传来,"
- 事实问答:"水的沸点在标准大气压下是多少度?"
- 代码补全:"def fibonacci(n):"
- 分步推理:"一个班有 40 人,其中 60% 是女生,男生有多少人?"
- 拒答测试:涉及隐私、违法内容的提问
```
基础模型不会拒答,它只会续写。所以"拒答测试"这里看的是它会不会一本正经地生成有害内容——这个信息决定你后面 SFT 阶段要花多大力气。
常见坑与排错
loss 突然变成 NaN 或出现尖刺
按这个顺序查:① 是不是某个 shard 里有超长重复串(比如一个字符重复上万次);② 学习率是不是太高,先降到 1e-4 观察;③ 打开 QK-Norm 和 z-loss;④ 确认 bf16 而不是 fp16。
验证 loss 远高于训练 loss
大概率是数据泄漏或重复。检查去重流程,尤其是跨 shard 的重复。也可能是验证集和训练集来自不同分布,这时候要分别在验证集子集上看。
模型会大段背诵训练数据
去重不够。把 MinHash 阈值从 0.8 降到 0.7 重跑一遍,或者加一层段落级的精确去重。
训练吞吐比预期低很多
常见原因:① dataloader 的 num_workers 太小,GPU 在等数据;② memmap 每次都从磁盘读,把热数据放本地 NVMe;③ batch 太小导致通信占比过高;④ 没开 pin_memory。
tokenizer 压缩率很低
检查 normalizer 是不是漏了 NFKC,以及训练 tokenizer 的语料是不是和主训练集语言分布不一致。UTF-8 中文一个汉字 3 个字节,byte-level BPE 需要足够的合并次数才能把常见汉字合成单 token。
checkpoint 保存时训练卡住
FSDP/ZeRO 下保存要分 rank 收集再写盘,如果所有 rank 同时往同一块盘写会拖死。用 torch.distributed.barrier() 加 rank 0 单写,或者用支持分片保存的格式(safetensors 分片,以官方文档为准)。
恢复训练后 loss 跳变
优化器状态没存,或者 dataloader 的随机种子没恢复,导致数据顺序完全不同。checkpoint 里必须同时存:模型权重、优化器状态、当前步数、数据迭代器的随机状态。
评测分数虚高
先确认评测数据没进训练集。用 n-gram 重叠检测一遍:评测集的每个样本,在训练集里查 13-gram 是否出现过,出现过就标记为污染。
下一步建议
跑通这条链路之后,按价值排序的动作是:
1. 做数据消融。用 1/10 的算力训几个小模型(比如 200M 参数、2B token),只改数据配比,看哪个组合的验证 loss 最低。数据上的每一点改进都会被放大到最终模型上。
2. 做 SFT。基础模型只会续写,要变成能对话、能听指令的模型,需要监督微调。这一阶段数据量小得多(几万到几十万条),但质量要求极高。
3. 继续预训练(continued pretraining)。想扩到 8k/32k 上下文,或者想注入某个垂直领域的知识,从现有 checkpoint 出发继续训是最省的路径,不用从头再来。
4. 对齐阶段。SFT 之后可以做偏好优化(DPO 一类的方法),让模型学会区分好坏回答。同样,具体实现以官方文档为准。
5. 推理侧优化。导出成 HuggingFace 格式,做量化,接到 vLLM 或 llama.cpp 上跑。自训练的模型经常在导出这步翻车——务必在训练早期就用最小 checkpoint 把"训练 → 导出 → 推理"这条链路完整走通一遍。
最后提醒一句:第一次不要追求训到最好。先定一个能在两周内跑完的计划(比如 1B 参数、10B token),把全流程跑通,把工具链踩平,再上规模。预训练最贵的成本不是算力,是你花在排错上的时间。
