跳到主内容
快讯直播
AI智模界
教程

从零预训练一个 1B 小模型:完全自训练全流程

这篇能做出什么

读完并跟着做完,你会得到一条完整的自训练链路:从一堆原始网页/书籍/代码文本出发,经过清洗、去重、配比,训练出自己的 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.distributedtorch.amp
  • transformerstokenizersdatasetssafetensors
  • 分布式训练框架二选一: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 前馈网络
  • GQAn_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
全局 batch2M~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),把全流程跑通,把工具链踩平,再上规模。预训练最贵的成本不是算力,是你花在排错上的时间。

AI 生成本文由 AI 基于公开信息自动生成,仅供参考。