vocab_size = 154856
parameters = 1,309,190,144
约 1309.19M

这里有一个必须写进博客的数字:

154856 × 2048 ≈ 3.17e8
词表 Embedding 约占 24% 参数

也就是说,V5 的“1B”有将近四分之一花在 Embedding 上。
这不是浪费。大词表对中文更友好,后面接 GLM 生态也更省事。但它会让你对“我这个模型到底有多少层在干活”产生错觉。后面 proxy 那节会再碰到一次。
训练默认:

seq_len = 1024
batch_size = 1
gradient_accumulation = 32
AdamW
lr = 3e-4
warmup = 2000
max_steps = 20000

这些是正式预训练的参数。今天没有跑满。

4. 第一个决定:不要再训 32k 运维 BPE

机器刚起来的时候,手里其实已经有一份 V5 的 32k tokenizer,语料是大约 89MB 技术文档。
测下来很典型:

kubectl 可能被切成 3 个 token
中文大约 1.14 字符 / token

领域 BPE 的问题我在 V2 就已经见过:它对文档站里的英文术语很亲,对真正的中文问答并不亲。
更致命的是 V1 到 V3 反复踩过的坑:

Tokenizer 一旦在训练开始后改掉,前面的权重全部作废。
所以今天不再训词表。
直接从 ZGCM-1-7B 只拿走 tokenizer,不拿 7B 权重:

tokenizer/glm51
HuggingFace AutoTokenizer
eos_token_id 需要能放进 Embedding

这里有一个容易写错的细节。
tokenizer.vocab_size 打印出来是:

154820

但 eos_token_id 也是:

154820

如果 Embedding 建成 nn.Embedding(154820, d_model),EOS 会直接越界。
V5 里实际使用:

vocab_size = max(vocab_size, len(tokenizer), eos_id + 1)
         = 154856

以后如果有人复现,不要只抄 tokenizer.vocab_size。

5. 数据从哪来:不是 txt,是 parquet

V2 的数据是我自己爬的文档站和 GitHub Markdown。
V5 改用 ZGCM-1 预训练 stage1 的 scout 分片。这次实际落到磁盘上的是两个 parquet:

zgcm-1-pretrain-stage1
revision 20a6cf27f80d53a2c1fcc8f0dbb9bef7cdd144c3
part-00689.parquet ≈ 1.45GB
part-00882.parquet ≈ 1.45GB

很多人第一次看 HuggingFace Dataset,会以为里面是一堆 txt。
不是。
常见形态就是:

一个 repo / dataset
    ↓
若干 parquet shard
    ↓
每一行一个 sample
    ↓
其中一列叫 text

parquet 是列式存储,这次用的是 ZSTD 压缩。对训练脚本来说,它只是“能按列流式读的二进制表”,不是给人类翻的小说。
抽查过这些 scout 行之后,观感非常直接:

预训练语料很乱。大量 FineWeb 风格英文网页,中文占比不高,质量也谈不上“运维教材”。
这不是 bug。
这就是现在大规模预训练的常态。模型先在很杂的文本上学会“下一个 token 更可能是什么”,而不是先学会“Kubernetes Pending 应该怎么排障”。

6. 预训练到底在学什么

V2、V3 我已经写过很多次:val loss 下降不等于会回答问题。
今天对着 parquet 里那些乱七八糟的网页,这件事变得更具体。
预训练的目标仍然只是:

P(下一个 token | 前面所有 token)

不是:

检索文档
回答问题
执行 runbook

所以如果有人问:

这些网页这么乱,模型以后怎么当 SRE?

诚实的答案是:

这一步不负责当 SRE。
这一步只负责把语言和世界的统计结构先装进权重。
SRE 怎么说话、怎么排障,是后面 SFT 的事。

这也是我后来决定今晚不去砸满 20000 step 的原因之一。
1B 全量预训练要的是时间、电和数据覆盖。租卡倒计时里硬跑,得到的只是一个没训完的 checkpoint,以及一张更贵的账单。

7. 分词怎么切 train / val

V5 的 memmap 不再用“整条 token 河最后 1% 当 val”。
那种切法看起来方便,但文档中间切开以后,val 可能只是同一篇文章的后半段。V2 的验证集可信度问题,有一部分就来自这种泄漏。
这次按文档做 hash:

MD5(text 前 256 字符)
val_percent = 1

每个文档完整进 train 或完整进 val,然后追加 EOS,写成:

data/v5/binary/glm51/part-00689_plus_tech/train.bin
data/v5/binary/glm51/part-00689_plus_tech/val.bin

dtype 是 uint32,用 memmap 读,15GB 内存撑得住。
689 分片加上原来的 tech_docs,大约 20:42 分完:

docs_train ≈ 949420
docs_val   ≈ 9586
train_tok  ≈ 847,508,995
val_tok    ≈ 8,808,550

也就是说,单这一个 scout 分片加技术文档,独立 token 已经比 V2 的 2300 万高一个数量级以上。
882 分片在 CPU 上继续跑。GPU 不必等它。

8. 一个很丢人的 shape 错误

Smoke 不是一上来就过的。
第一版 SwiGLU 的 down_proj 写成了:

nn.Linear(d_model, d_model)

正确的是:

nn.Linear(d_ff, d_model)

于是前向直接炸:

RuntimeError: mat1 and mat2 shapes cannot be multiplied
256x5632 vs 2048x2048

256 是当时的 seq_len,5632 是 d_ff,2048 是 d_model。
这种错误和“模型会不会回答 Pending Pod”无关。
它只说明:

1B 配置写到纸上之前,必须先用随机 token 做一次前向和反向。

9. Smoke:随机 token 也能说明很多事

修好以后,先跑 seq=256:

python scripts/smoke_v5_1b.py \
  --scale 1b \
  --seq-len 256 \
  --batch-size 1 \
  --tokenizer tokenizer/glm51

输出:

GPU: NVIDIA A100-SXM4-40GB
scale: 1b
vocab_size: 154856
parameters: 1,309,190,144 (1309.19M)
loss=12.4402
peak_allocated=11.03GB
SMOKE PASS

再跑训练长度:

python scripts/smoke_v5_1b.py \
  --scale 1b \
  --seq-len 1024 \
  --batch-size 1 \
  --tokenizer tokenizer/glm51
loss=12.3669
peak_allocated=11.25GB
SMOKE PASS

两件事值得单独说。
第一,随机初始化的 loss 应该接近:

ln(154856) ≈ 11.95

12.4 左右是对的。如果这里出现 0.00,那一定是又把 label 写坏了。
第二,256 和 1024 的峰值显存几乎一样。
这就是 activation checkpointing 在干活。没有它,1B / 1024 在 40GB 上会紧张得多。
Smoke 用的是 torch.randint,不读真实语料。
所以它只证明:

模型能放进 A100
BF16 前向反向能走通
词表尺寸没写错
checkpointing 有效

它不证明模型会运维。

10. 真正的训练环:20 步 proxy

V1 到 V3 还有一个存盘 bug,我这次专门改了:

以前 latest.pt / final.pt 里的 val_loss 会被写成 0.0
看起来像训练出了神迹
其实只是没 eval 的时候填了默认值

V5 改成记住最后一次真正的 eval。没 eval 过就是 None,不许再写 0。
数据环用已经分好的 689+tech,模型先用 proxy,不直接上 1B 全量:

python scripts/train_v5.py \
  --scale proxy \
  --tokens data/v5/binary/glm51/part-00689_plus_tech \
  --tokenizer tokenizer/glm51 \
  --seq-len 1024 \
  --batch-size 4 \
  --gradient-accumulation 8 \
  --max-steps 20 \
  --eval-every 10 \
  --save-every 20 \
  --log-every 1 \