我从零训练了一个 30M 的运维小模型:OpsLM-Toy 第一阶段复盘

这不是一篇“调用现成框架训练模型”的教程,而是我真的从 Tokenizer、Embedding、Self-Attention、Multi-Head Attention、MLP、Transformer Block 一层一层写起,最后把一个随机初始化的 Decoder-only Transformer 训练到能生成运维文本。

这篇文章记录的是第一阶段实验。目标不是训练出一个真正可用的大模型,而是把“语言模型到底是怎么从文本一路变成 loss,再通过反向传播学到东西”这条链路完整走通。

代码仓库(后续会持续整理并开源):

https://github.com/noovertime7/opslm-toy

仓库开源后,想直接复现实验可以先:

git clone https://github.com/noovertime7/opslm-toy.git
cd opslm-toy

1. 为什么我要做这个实验

我平时做 SRE,也一直在接触智能体、推理服务、GPU、vLLM、Kubernetes、AI Infra 这些东西。以前我对大模型很多概念都知道一点,比如 Token、Embedding、Attention、Transformer、Loss、反向传播,但是如果真让我从一张白纸开始把一个语言模型写出来,我其实并没有完整做过。

所以这次我给自己定了一个目标:

不使用现成的 GPT 模型,不加载预训练权重,从随机参数开始,亲手写一个小型 Decoder-only Transformer,然后用我自己准备的 SRE/AI Infra 语料把它训练起来。

第一阶段我把这个模型叫做:

OpsLM-Toy-30M

它的定位很明确:

  • 不是为了生产使用;
  • 不是为了追求 Benchmark;
  • 不是为了和现成开源模型比能力;
  • 而是为了让我真正理解一个 Base Model 是怎么“出生”的。

这次实验最后是成功的,而且成功得有点过头:训练 loss 最后降到了约 0.000959,模型几乎把我那点小语料背下来了,也让我第一次非常直观地看到了什么叫 过拟合。


2. 实验环境

我租了一台 GPU 机器,主要环境如下:

OS: Ubuntu 24
GPU: NVIDIA GeForce RTX 5090
显存: 32GB
Python: 3.11
环境管理: Conda
框架: PyTorch
Tokenizer: Hugging Face tokenizers

项目目录:

/root/opslm-toy

Conda 环境:

conda create -n opslm python=3.11 -y
conda activate opslm

安装依赖:

python -m pip install torch tokenizers numpy tqdm

我后面尽量都使用:

python -m pip

而不是直接 pip,因为这样更不容易出现“包装到了别的 Python 环境”这种问题。


3. 第一版模型配置

我没有一上来就用 RoPE、RMSNorm、SwiGLU、FlashAttention、GQA 这些现代结构,而是先故意写一个最容易理解的经典版本。

第一版配置:

from dataclasses import dataclass


@dataclass
class ModelConfig:
    vocab_size: int
    max_seq_len: int = 1024
    d_model: int = 512
    n_heads: int = 8
    n_layers: int = 8
    d_ff: int = 2048
    dropout: float = 0.1

    def __post_init__(self):
        if self.d_model % self.n_heads != 0:
            raise ValueError(
                "d_model must be divisible by n_heads"
            )

    @property
    def head_dim(self):
        return self.d_model // self.n_heads

核心参数:

d_model      = 512
n_heads      = 8
head_dim     = 64
n_layers     = 8
d_ff         = 2048
max_seq_len  = 1024
目标 vocab   = 16000

这里有一个很重要的关系:

512 / 8 = 64

也就是说,每个 Attention Head 负责 64 维。


4. 项目目录

第一阶段最后整理成了大概这样的结构。第一次从空目录创建时,我执行的是:

mkdir -p opslm-toy/{data/raw,tokenizer/artifacts,src,scripts,logs,checkpoints}
cd opslm-toy

touch src/__init__.py

如果是从 GitHub 克隆,则不需要手动创建这些目录,直接:

git clone https://github.com/noovertime7/opslm-toy.git
cd opslm-toy

目录如下:

opslm-toy/
├── data/
│   ├── raw/
│   │   ├── general.txt
│   │   ├── linux.txt
│   │   ├── kubernetes.txt
│   │   ├── network.txt
│   │   ├── ai_infra.txt
│   │   └── production.txt
│   ├── merged.txt
│   └── cleaned.txt
│
├── tokenizer/
│   ├── train_tokenizer.py
│   ├── test_tokenizer.py
│   ├── analyze_tokenizer.py
│   └── artifacts/
│       ├── tokenizer.json
│       └── info.txt
│
├── src/
│   ├── __init__.py
│   ├── config.py
│   ├── embedding.py
│   ├── attention.py
│   ├── mlp.py
│   ├── block.py
│   ├── model.py
│   ├── dataset.py
│   └── prediction.py
│
├── scripts/
│   ├── clean_text.py
│   ├── test_embedding.py
│   ├── test_attention.py
│   ├── test_multihead_attention.py
│   ├── test_mlp.py
│   ├── test_block.py
│   ├── test_model.py
│   ├── test_dataset.py
│   ├── test_dataloader.py
│   ├── test_next_token_prediction.py
│   ├── train.py
│   └── generate.py
│
├── logs/
│   └── train.log
│
└── checkpoints/
    ├── step_000100.pt
    ├── ...
    └── final.pt

5. 准备第一批语料

第一版我没有追求大数据,只准备了几十 KB 的领域文本,主要是为了先把整个训练链路跑通。

内容包括:

  • Linux;
  • Kubernetes;
  • 网络;
  • AI Infra;
  • GPU;
  • 运维故障排查;
  • 日志、监控、进程、系统命令;
  • 一些通用计算机概念。

例如:

Linux 是一种广泛使用的开源操作系统。
Linux 内核负责进程调度、内存管理、文件系统、设备驱动和网络协议栈。
ps 命令可以查看系统中的进程。
top 命令可以实时观察系统负载和进程资源使用情况。

Kubernetes 部分则包含:

Pod 处于 Pending 状态时,可能是因为资源不足、调度限制或者存储问题。
Pod 出现 CrashLoopBackOff 时,通常表示容器启动后不断退出并重新启动。
kubectl logs 可以查看容器日志。
kubectl exec 可以进入容器执行命令。

AI Infra 则包括:

GPU 显存不足可能与 batch size、KV Cache、模型大小和并发有关。
Tensor Parallel 可以把一个模型切分到多张 GPU 上。
NCCL 常用于多 GPU 通信。

这批数据非常小,后面也正是因为太小,导致模型严重过拟合。

对应的原始数据文件放在:

ls -lh data/raw

我当时会先确认几个文件是否存在:

ls data/raw/general.txt \
   data/raw/linux.txt \
   data/raw/kubernetes.txt \
   data/raw/network.txt \
   data/raw/ai_infra.txt \
   data/raw/production.txt

查看语料行数:

wc -l data/raw/*.txt

6. 清洗文本

清洗脚本做的事情很简单:

  • 去空行;
  • 压缩多余空格;
  • 去掉重复行;
  • 合并 data/raw/*.txt;
  • 输出到 data/cleaned.txt。

第一阶段不追求复杂清洗策略,目标只有一个:

让语料能稳定进入 Tokenizer。

实际执行:

python scripts/clean_text.py

然后检查结果:

ls -lh data/cleaned.txt
wc -l data/cleaned.txt
head -n 20 data/cleaned.txt

如果要确认是否还有空行或异常内容,我还会直接:

sed -n '1,80p' data/cleaned.txt

7. 训练自己的 Tokenizer

这里我选的是:

Byte-level BPE

特殊 Token:

<pad> = 0
<unk> = 1
<bos> = 2
<eos> = 3

Tokenizer 的目标词表大小:

16000

但是因为第一阶段语料非常少,所以真正训练出来的词表远小于 16000。

这点很重要:

16000 是目标上限,不代表小语料一定能学出 16000 个有意义 Token。

训练命令:

python tokenizer/train_tokenizer.py

训练完成后确认文件已经落盘:

ls -lh tokenizer/artifacts/tokenizer.json
ls -lh tokenizer/artifacts/info.txt

测试 encode / decode:

python tokenizer/test_tokenizer.py

分析 Tokenizer:

python tokenizer/analyze_tokenizer.py

我当时重点测试的是:

kubectl get pods -n production

7.1 Byte-level BPE 里的 Ġ 是什么

我第一次测试:

kubectl get pods -n production

Tokenizer 输出类似:

'kubectl'
'Ġget'
'Ġpods'
'Ġ-'
'n'
...

这里的 Ġ 不是乱码,而是 ByteLevel Tokenizer 对“前面有空格”的一种内部表示。

例如:

'Ġget'

可以理解成:

' get'

真正 decode 时仍然会恢复正常文本。


7.2 我第一次真正理解 BPE 是怎么“学词”的

一开始 production 被拆得很碎:

Ġp
r
od
u
c
t
i
on

后来我专门加了一批包含 production 的训练句子:

production environment
production cluster
production namespace
kubectl get pods -n production
production workloads
production metrics
production logs

然后重新训练 Tokenizer。

这次 production 就开始被学成更完整的 Token。

我当时实际做的是:

# 修改或补充 production 相关语料后重新清洗
python scripts/clean_text.py

# Tokenizer 重新从头训练
python tokenizer/train_tokenizer.py

# 再次验证 production 是否被学成更完整的 Token
python tokenizer/test_tokenizer.py

这个实验让我第一次很直观地理解:

BPE 并不知道“production 是一个英文单词”,它只是根据频率不断合并经常一起出现的 byte/token 片段。


7.3 一个非常重要的原则

Tokenizer 可以在模型训练前反复重训。

但是一旦模型开始训练,就不能随便改 Tokenizer。

因为模型内部已经形成:

Token ID 731
↓
Embedding 第 731 行

如果重新训练 Tokenizer,731 代表的内容变了,那么以前训练出来的 Embedding 就全部对不上了。


8. Embedding:把 Token ID 变成向量

这一阶段完成代码后,我先做语法检查,再跑测试:

python -m py_compile src/embedding.py
python scripts/test_embedding.py

模型不能直接理解:

731
1074
1279

所以我们需要 Embedding。

完整核心实现:

import torch
import torch.nn as nn


class TokenAndPositionEmbedding(nn.Module):
    def __init__(
        self,
        vocab_size: int,
        d_model: int,
        max_seq_len: int,
        dropout: float = 0.1,
    ):
        super().__init__()

        self.token_embedding = nn.Embedding(
            num_embeddings=vocab_size,
            embedding_dim=d_model,
        )

        self.position_embedding = nn.Embedding(
            num_embeddings=max_seq_len,
            embedding_dim=d_model,
        )

        self.dropout = nn.Dropout(dropout)

    def forward(self, input_ids: torch.Tensor) -> torch.Tensor:
        batch_size, seq_len = input_ids.shape

        if seq_len > self.position_embedding.num_embeddings:
            raise ValueError(
                f"Sequence length {seq_len} exceeds "
                f"max_seq_len {self.position_embedding.num_embeddings}"
            )

        positions = torch.arange(
            seq_len,
            device=input_ids.device,
        )

        positions = positions.unsqueeze(0)
        positions = positions.expand(batch_size, seq_len)

        token_embeddings = self.token_embedding(input_ids)
        position_embeddings = self.position_embedding(positions)

        x = token_embeddings + position_embeddings
        x = self.dropout(x)

        return x

数据 Shape:

[B, T]
↓
[B, T, 512]

例如:

[4, 128]
↓
[4, 128, 512]

其中:

B = Batch Size
T = Sequence Length
512 = d_model

9. Self-Attention:让 Token 之间真正发生关系

对应的验证命令:

python -m py_compile src/attention.py
python scripts/test_attention.py

如果要直接观察 Causal Attention 权重矩阵:

python scripts/show_attention.py

这是我觉得最关键的一步。

输入:

[B, T, 512]

一个 Attention Head 先把每个 Token 投影成:

Q
K
V

每个都是:

[B, T, 64]

一个 Head 的核心公式:

Attention(Q,K,V)
=
softmax(QK^T / sqrt(d)) V

在我们的配置里:

d = 64
sqrt(64) = 8

所以 QK 的分数最后要除以 8。


9.1 为什么需要 Causal Mask

语言模型不能偷看未来。

比如:

A B C D

预测 C 时,只能看到:

A B

不能看到:

D

所以需要下三角 Mask:

1 0 0 0
1 1 0 0
1 1 1 0
1 1 1 1

未来位置在 softmax 前被设成:

-inf

这样 softmax 以后概率就是 0。


10. Multi-Head Attention

完成多头注意力后我直接跑:

python -m py_compile src/attention.py
python scripts/test_multihead_attention.py

还可以检查参数量和每个 Head 的输出 Shape。

单个 Head 输出:

[B, T, 64]

我们有 8 个 Head:

8 × 64 = 512

所以:

Head 1 -> 64
Head 2 -> 64
...
Head 8 -> 64
↓ concat
512
↓ out_proj
512

一个很重要的点:

我们没有规定某个 Head 一定负责语法、某个 Head 一定负责 namespace。

这些角色如果真的出现,也是训练过程中自己形成的。

第一阶段为了方便理解,我是用 8 个独立 Head 去实现的,这种写法好理解,但不高效。

真正的大模型一般会把 Q/K/V 一次性做大矩阵投影,然后 reshape 成多个 Head。


11. MLP

对应测试命令:

python -m py_compile src/mlp.py
python scripts/test_mlp.py
python scripts/test_attention_mlp.py

Transformer 不只是 Attention。

MLP 也非常重要。

我们的结构:

512
↓
2048
↓
GELU
↓
512

代码逻辑就是:

x = self.fc1(x)
x = F.gelu(x)
x = self.fc2(x)

我当时才真正意识到:

Attention 负责 Token 之间的信息交换,而 MLP 更像是每个 Token 自己内部的信息加工。

而且在这个模型里,一个 MLP 的参数量甚至比 Attention 还多。


12. Transformer Block

完成后:

python -m py_compile src/block.py
python scripts/test_block.py
python scripts/test_residual.py

第一版使用:

Pre-Norm

结构:

x
│
├──── LayerNorm -> Attention ────┐
│                                │
└────────────── + <──────────────┘
│
├──── LayerNorm -> MLP ──────────┐
│                                │
└────────────── + <──────────────┘

核心代码其实非常简单:

x = x + self.attention(self.ln1(x))
x = x + self.mlp(self.ln2(x))

这里的 + 就是 Residual Connection。

我现在对残差连接的理解是:

不是让每一层彻底推翻上一层的表示,而是在原表示基础上增加修改。


13. 组装完整 Decoder-only Model

完整模型第一次跑通时,我执行:

python -m py_compile src/model.py
python scripts/test_model.py

如果想观察显存,可以另开一个终端:

watch -n 1 nvidia-smi

最终结构:

Token IDs
↓
Token Embedding + Position Embedding
↓
Transformer Block 1
↓
Transformer Block 2
↓
...
↓
Transformer Block 8
↓
Final LayerNorm
↓
LM Head
↓
Logits

输入:

[B, T]

最后输出:

[B, T, Vocab]

也就是说,每个位置都会对词表里的所有 Token 打分。


13.1 LM Head 是什么

LM Head 本质就是:

512
↓
Linear
↓
vocab_size

比如 vocab 是 1500:

512 -> 1500

那么每个位置会得到 1500 个 logit。

这些 logit 不是概率。

训练时也不需要自己先 softmax,因为 CrossEntropyLoss 会直接处理 raw logits。


13.2 Weight Tying

我这里把:

Token Embedding

和:

LM Head

共享同一份权重。

代码:

self.lm_head.weight = self.embedding.token_embedding.weight

这样可以减少参数,也让输入 Token 表示和输出 Token 打分共享同一张参数表。


14. 参数初始化

完成初始化逻辑后:

python -m py_compile src/model.py
python scripts/test_initialization.py

如需保存“刚出生、还没有训练过”的模型:

mkdir -p checkpoints
python - <<'PY'
import torch
from tokenizers import Tokenizer
from src.config import ModelConfig
from src.model import OpsLMToy

torch.manual_seed(42)
tokenizer = Tokenizer.from_file("tokenizer/artifacts/tokenizer.json")
model = OpsLMToy(ModelConfig(vocab_size=tokenizer.get_vocab_size()))
torch.save(model.state_dict(), "checkpoints/opslm_toy_init.pt")
print("saved: checkpoints/opslm_toy_init.pt")
PY

我没有继续使用 PyTorch 默认初始化,而是自己统一:

Linear.weight   -> Normal(0, 0.02)
Linear.bias     -> 0
Embedding       -> Normal(0, 0.02)
LayerNorm.weight-> 1
LayerNorm.bias  -> 0

核心代码:

def _init_weights(module):
    if isinstance(module, nn.Linear):
        nn.init.normal_(module.weight, mean=0.0, std=0.02)
        if module.bias is not None:
            nn.init.zeros_(module.bias)

    elif isinstance(module, nn.Embedding):
        nn.init.normal_(module.weight, mean=0.0, std=0.02)

    elif isinstance(module, nn.LayerNorm):
        nn.init.ones_(module.weight)
        nn.init.zeros_(module.bias)

并且用:

torch.manual_seed(42)

固定随机种子,方便复现实验。


15. Dataset:Next Token Prediction 到底怎么构造

完成 Dataset 后先跑:

python -m py_compile src/dataset.py
python scripts/test_dataset.py
python scripts/show_next_token_data.py

重点确认:

Shift relationship correct: True

这是整个实验里另一个让我彻底理解的地方。

假设有:

A B C D E

训练数据不是:

Input = A B C D
Target = A B C D

而是:

Input  = A B C D
Target = B C D E

也就是 target 整体向后移动一个 Token。

因此如果 sequence length 是 128,就需要先取:

129 个 Token

然后:

input_ids = chunk[:-1]
target_ids = chunk[1:]

这就是 Next Token Prediction。


16. DataLoader

对应测试:

python scripts/test_dataloader.py
python scripts/test_dataset_model.py

预期能看到:

Input IDs:  [B, T]
Target IDs: [B, T]
Logits:     [B, T, V]

Dataset 返回:

[128]

DataLoader 如果 batch size 是 16:

16 × [128]

最后自动堆成:

[16, 128]

这就是模型真正收到的:

[B, T]

Token ID 必须使用:

torch.long

因为 nn.Embedding 输入的是索引,而不是浮点数。


17. 第一次真正开始训练

正式训练前,我先做一个非常小的 smoke test:

python -m py_compile scripts/train.py

python scripts/train.py \
  --max-steps 10 \
  --batch-size 16 \
  --seq-len 128 \
  --save-every 10 \
  --log-every 1

只有这 10 step 正常跑通后,我才开始后台训练 2000 step。

训练配置:

max_steps      = 2000
batch_size     = 16
seq_len        = 128
learning_rate  = 3e-4
warmup_steps   = 100
optimizer      = AdamW
mixed precision= BF16

之所以一开始用 128,而不是 1024,是因为我的第一批数据太小。

小数据阶段先用短 sequence 更适合验证链路。


17.1 一个 Training Step 到底发生什么

一次训练其实就是:

Input
↓
Forward
↓
Logits
↓
Cross Entropy
↓
Loss
↓
Backward
↓
Gradient
↓
Optimizer Step
↓
参数发生变化

核心代码:

with torch.autocast(
    device_type="cuda",
    dtype=torch.bfloat16,
):
    logits = model(input_ids)

    loss = F.cross_entropy(
        logits.reshape(-1, vocab_size),
        target_ids.reshape(-1),
    )

loss.backward()

torch.nn.utils.clip_grad_norm_(
    model.parameters(),
    max_norm=1.0,
)

optimizer.step()
scheduler.step()

18. Cross Entropy Loss 我现在怎么理解

模型会在每一个位置对整个词表打分。

比如正确答案是:

get

但是模型最看好:

Linux

Cross Entropy 就会衡量:

模型对正确答案到底有多不自信。

模型刚初始化时,如果 vocab 大概是 1500,随机预测的初始 loss 理论上大概接近:

ln(1500) ≈ 7.31

这个数量级非常有参考意义。


19. Backward 到底做了什么

这一句:

loss.backward()

会把梯度一路从:

Loss
↓
LM Head
↓
Block 8
↓
Block 7
↓
...
↓
Block 1
↓
Embedding

算回去。

也就是说,每一个参数都会知道:

我应该往哪个方向改
改多少

然后:

optimizer.step()

才真正修改参数。


20. 后台训练

为了让服务器自己跑,我最终用:

nohup python -u scripts/train.py \
  --max-steps 2000 \
  --batch-size 16 \
  --seq-len 128 \
  --learning-rate 3e-4 \
  --warmup-steps 100 \
  --save-every 100 \
  --log-every 10 \
  > logs/train.log 2>&1 &

查看日志:

tail -f logs/train.log

查看 GPU:

nvidia-smi

Checkpoint 每 100 step 保存一次:

step_000100.pt
step_000200.pt
...

最终:

final.pt

21. 第一次训练结果

最终:

Training step: 2000
Training loss: 0.0009591744747012854

这个 loss 低得非常夸张。

一开始我第一反应是:

模型是不是练得特别好了?

后来一看生成结果,马上就明白了:

不是模型变强了,而是数据太少,它把训练集背下来了。


22. 第一次生成

训练完成后先确认 checkpoint:

ls -lh checkpoints

然后第一次生成:

python scripts/generate.py \
  --checkpoint checkpoints/final.pt \
  --prompt "Linux" \
  --max-new-tokens 100 \
  --temperature 0.8 \
  --top-k 20

再测试 Kubernetes:

python scripts/generate.py \
  --checkpoint checkpoints/final.pt \
  --prompt "Kubernetes" \
  --max-new-tokens 100 \
  --temperature 0.3 \
  --top-k 10

Prompt:

Kubernetes

生成:

Kubernetes。
遇到 Kubernetes 故障时,应该结合 Pod 状态、Events、日志、节点状态和监控指标进行分析。
Linux 是一种广泛使用的开源操作系统。
Linux 内核负责进程调度、内存管理、文件系统、设备驱动和网络协议栈。
Linux 中的进程是正在运行的程序实例。
每个进程通常拥有一个唯一的进程 ID,也就是 PID。
ps 命令可以查看系统中的进程。

这个结果非常像训练语料本身。

而且 Kubernetes 说完以后突然跳到 Linux,很可能是因为我原始 cleaned.txt 里不同主题文本被拼接成了一个连续 Token Stream。

模型不仅记住了内容,还记住了某些“文档顺序关系”。


23. 第二次测试:Pending

我实际执行的是:

python scripts/generate.py \
  --prompt "一个 Pod 一直处于 Pending 状态" \
  --max-new-tokens 100 \
  --temperature 0.3 \
  --top-k 10

Prompt:

一个 Pod 一直处于 Pending 状态

生成:

一个 Pod 一直处于 Pending 状态。

当系统发 HTTP 不需要�信息和事件。

kubectl logs 可以查看容器日志。

kubectl exec 可以进入容器执行命令。

Pod 处于 Pending 状态时,可能是因为资源不足、调度限制或者存储问题。

Pod 出现 CrashLoopBackOff 时,通常表示容器启动后不断退出并重新启动。

排查 CrashLoopBackOff 时应该先查看容器日志和 Pod Events。

OOMKilled 通常表示容器超过了

这个结果很有意思。

它说明模型已经不是纯随机输出了。

它学到了:

Pod
Pending
资源不足
调度
存储
CrashLoopBackOff
Events
OOMKilled

这些词之间存在某种领域关联。

但是它也明显是在顺着训练语料继续背。


24. 第三次测试:真正的泛化问题

为了验证它是不是只会背训练集,我故意用了训练语料里没有原样出现过的问题:

python scripts/generate.py \
  --prompt "Kubernetes 中应用访问数据库很慢,但是数据库本身没有异常" \
  --max-new-tokens 150 \
  --temperature 0.5 \
  --top-k 20

Prompt:

Kubernetes 中应用访问数据库很慢,但是数据库本身没有异常

生成:

Kubernetes 中应用访问数据库很慢,但是数据库本身没有异常。

Linux 是一种广泛使用的开源操作系统。

GPU 内核负责进程调度、内存管理、文件系统、设备驱动和网络协议栈。

Linux 中的进程是正在运行的程序实例。

每个进程通常拥有一个唯一的进程 ID,也就是 PID。

ps 命令可以查看系统中的进程。

top 命令可以实时观察系统负载和进程资源使用情况。

htop 提供了更加直观的进程监控界面面面节点和信息。

choket。
choket。
choket。
choket。
...

这段输出让我彻底确定:

它会记忆,会局部组合,但泛化能力很弱。


25. 我第一次真正看到“错误组合”

最典型的一句:

GPU 内核负责进程调度、内存管理、文件系统、设备驱动和网络协议栈。

原训练语料显然是:

Linux 内核负责进程调度、内存管理、文件系统、设备驱动和网络协议栈。

模型把:

GPU

和:

Linux 内核负责...

错误拼在了一起。

这说明它已经学会了一些局部句式模式,但是没有真正理解语义。

这也是小模型 + 小数据特别典型的现象。


26. Repetition Loop:为什么会一直 choket

后面生成:

choket。
choket。
choket。
...

这不是代码死循环。

而是自回归生成进入了一个高概率闭环。

生成过程是:

当前文本
↓
预测下一个 Token
↓
把刚预测的 Token 拼回输入
↓
继续预测

一旦某个局部模式形成:

choket -> 。
。 -> choket

模型就可能一直循环。

以后可以通过:

  • repetition penalty;
  • top-p;
  • 更好的数据;
  • 更多训练语料;
  • 更合理的 EOS 学习;

来缓解。

但第一阶段最根本的问题依然是:

数据太少。


27. Byte-level Tokenizer 为什么会出现 �

这次生成里还出现过:

�

Byte-level BPE 理论上可以表示任意 UTF-8 字节,因此不会遇到传统意义上的 OOV。

但“Tokenizer 能表示任意 UTF-8”并不等于“模型一定生成合法 UTF-8 序列”。

一个汉字一般由多个 UTF-8 byte 组成。

如果模型生成了一半 byte 组合,没有组成完整字符,那么 decode 时就可能出现:

�

这个现象在小数据、小模型阶段很容易观察到。


28. 这次实验到底证明了什么

第一阶段我认为已经完成了它的使命。

我亲手验证了:

原始文本
↓
Tokenizer
↓
Token IDs
↓
Embedding
↓
Self-Attention
↓
Multi-Head Attention
↓
MLP
↓
Transformer Block
↓
8 层 Decoder-only Transformer
↓
LM Head
↓
Logits
↓
Cross Entropy
↓
Backward
↓
AdamW
↓
Checkpoint
↓
自回归生成

这一整条链路是真实可工作的。


29. 这次实验没有证明什么

它没有证明:

OpsLM 已经会推理
OpsLM 已经理解 Kubernetes
OpsLM 已经能处理真实 SRE 问题
OpsLM 泛化很好

相反,它更多证明了:

模型可以拟合训练数据
模型能记忆局部模式
模型能学到词之间的关联
模型会发生严重过拟合
模型会发生生成退化

30. Training Loss 很低不等于模型很强

这是我这次最大的收获之一。

最后:

train loss = 0.000959

看起来非常漂亮。

但真正拿一个训练语料里没有的复杂问题去问,它马上暴露问题。

所以:

Training Loss 很低
≠
Validation Loss 很低
≠
模型泛化很好
≠
模型理解了知识
≠
模型会推理

它只代表:

模型非常擅长拟合训练集。


31. 为什么下一步不能直接上 150M

一开始我的规划里还有一个更正式的:

OpsLM-150M

但是做完第一阶段以后,我决定暂时不放大模型。

原因很简单:

30M 参数 + 几十 KB 数据

已经严重过拟合。

如果直接换成:

150M 参数 + 几十 KB 数据

结果只会是:

背得更快。

不是变聪明。


32. 第二阶段我准备怎么做

下一阶段重点不再是模型,而是数据和训练工程。

计划:

几十 KB
↓
5~10 MB
↓
50 MB
↓
100~200 MB

同时把 Dataset 改成真正的文档级数据:

Document A
<eos>
Document B
<eos>
Document C
<eos>

而不是所有文本简单拼成一个连续流。


33. 必须加入 Validation Set

第一阶段最大的训练工程缺陷是:

只有 Train Loss
没有 Validation Loss

下一版我要拆成:

data/processed/train.txt
data/processed/val.txt

并同时观察:

train_loss
val_loss

真正有意义的曲线应该类似:

step 100
train_loss = 5.3
val_loss   = 5.4

step 300
train_loss = 2.8
val_loss   = 3.0

step 500
train_loss = 1.5
val_loss   = 2.2

step 800
train_loss = 0.3
val_loss   = 3.1

这个时候就能非常直观地看到:

前期:一起下降
↓
中期:Validation 最好
↓
后期:Train 继续下降,但 Val 开始上升
↓
过拟合

下一阶段真正该保存的也不是:

final.pt

而是:

best.pt

也就是 Validation Loss 最低时的模型。


34. 第一阶段完整复现命令

这一部分是我专门给自己留的“以后从头还原实验”版本。等仓库开源以后,别人也可以基本照着这一段直接跑。

34.1 克隆仓库

git clone https://github.com/noovertime7/opslm-toy.git
cd opslm-toy

如果是自己从空目录搭:

mkdir -p opslm-toy/{data/raw,tokenizer/artifacts,src,scripts,logs,checkpoints}
cd opslm-toy
touch src/__init__.py

34.2 创建 Conda 环境

conda create -n opslm python=3.11 -y
conda activate opslm
python -m pip install torch tokenizers numpy tqdm

确认 Python 和 GPU:

python --version
python - <<'PY'
import torch
print('torch:', torch.__version__)
print('cuda available:', torch.cuda.is_available())
print('cuda runtime:', torch.version.cuda)
if torch.cuda.is_available():
    print('gpu:', torch.cuda.get_device_name(0))
PY

nvidia-smi

34.3 检查原始语料

ls -lh data/raw
wc -l data/raw/*.txt

34.4 清洗语料

python scripts/clean_text.py
ls -lh data/cleaned.txt
wc -l data/cleaned.txt
head -n 20 data/cleaned.txt

34.5 训练 Tokenizer

python tokenizer/train_tokenizer.py
ls -lh tokenizer/artifacts/tokenizer.json
python tokenizer/test_tokenizer.py

34.6 验证模型各组件

python scripts/test_embedding.py
python scripts/test_attention.py
python scripts/show_attention.py
python scripts/test_multihead_attention.py
python scripts/test_mlp.py
python scripts/test_attention_mlp.py
python scripts/test_block.py
python scripts/test_residual.py
python scripts/test_model.py
python scripts/test_initialization.py

34.7 验证 Dataset 和 Next Token Prediction

python scripts/test_dataset.py
python scripts/show_next_token_data.py
python scripts/test_dataloader.py
python scripts/test_dataset_model.py
python scripts/test_next_token_prediction.py
python scripts/show_topk_predictions.py

这里至少要确认:

Shift relationship correct: True

以及:

Input:  [B,T]
Target: [B,T]
Logits: [B,T,V]

34.8 先做 10 Step 冒烟训练

mkdir -p logs checkpoints

python -m py_compile scripts/train.py

python scripts/train.py \
  --max-steps 10 \
  --batch-size 16 \
  --seq-len 128 \
  --save-every 10 \
  --log-every 1

检查 checkpoint:

ls -lh checkpoints

34.9 后台训练 2000 Step

nohup python -u scripts/train.py \
  --max-steps 2000 \
  --batch-size 16 \
  --seq-len 128 \
  --learning-rate 3e-4 \
  --warmup-steps 100 \
  --save-every 100 \
  --log-every 10 \
  > logs/train.log 2>&1 &

记录 PID:

echo $!

确认进程:

ps -ef | grep '[t]rain.py'

看 GPU:

nvidia-smi

实时看日志:

tail -f logs/train.log

查看最开始和最后的 Loss:

grep 'loss=' logs/train.log | head -n 20
grep 'loss=' logs/train.log | tail -n 30

image-1789267541033

34.10 断点续训

如果训练中断,可以从最近 checkpoint 恢复,例如:

python scripts/train.py \
  --max-steps 2000 \
  --resume checkpoints/step_000500.pt

34.11 文本生成

python scripts/generate.py \
  --checkpoint checkpoints/final.pt \
  --prompt "Kubernetes" \
  --max-new-tokens 100 \
  --temperature 0.3 \
  --top-k 10

image-1789267596533
测试 Pending:

python scripts/generate.py \
  --checkpoint checkpoints/final.pt \
  --prompt "一个 Pod 一直处于 Pending 状态" \
  --max-new-tokens 100 \
  --temperature 0.3 \
  --top-k 10

测试训练集外组合问题:

python scripts/generate.py \
  --checkpoint checkpoints/final.pt \
  --prompt "Kubernetes 中应用访问数据库很慢,但是数据库本身没有异常" \
  --max-new-tokens 150 \
  --temperature 0.5 \
  --top-k 20

34.12 保存第一阶段实验模型

cp checkpoints/final.pt \
  checkpoints/opslm_toy_overfit_demo.pt

到这里,第一阶段实验就可以完整复现。


35. 第一阶段我真正学会的概念

Tokenizer

把文本变成 Token ID。

文本 -> Token -> ID

Embedding

把离散的 Token ID 转成连续向量。

ID -> 512维向量

Position Embedding

告诉模型 Token 在序列中的位置。

因为只看 Token Embedding:

A B C

和:

C B A

只是同一批向量,模型需要额外的位置信息。


Q / K / V

我现在可以粗略理解为:

Q:我现在想找什么信息
K:我这里有什么信息可以被匹配
V:真正被拿走的内容

Causal Mask

防止模型偷看未来答案。


Multi-Head Attention

让模型可以同时学习多种 Token 关系。


MLP

让每个 Token 的内部表示进一步进行非线性加工。


Residual Connection

不彻底覆盖旧信息,而是在原表示基础上增量修改。


LayerNorm

帮助网络保持数值尺度稳定。


Logits

模型对词表里每个 Token 的原始打分,不是概率。


Softmax

把 logits 转成概率分布。


Cross Entropy

衡量模型对正确 Token 有多不自信。


Backward

从 Loss 反向计算所有参数的梯度。


Optimizer

根据梯度真正修改模型参数。


Checkpoint

保存的不只是模型权重,还应该包含:

model_state_dict
optimizer_state_dict
scheduler_state_dict
step
loss
config

这样才能真正恢复训练。


Overfitting

训练集越来越熟,但对没见过的数据越来越差。

这次 loss=0.000959 就是一个非常鲜活的例子。


36. 第一阶段最值得保留的 Checkpoint

我会把这次严重过拟合但非常有教学价值的模型单独保存:

cp checkpoints/final.pt \
  checkpoints/opslm_toy_overfit_demo.pt

ls -lh checkpoints/opslm_toy_overfit_demo.pt

我准备把最终模型额外保存成:

cp checkpoints/final.pt \
   checkpoints/opslm_toy_overfit_demo.pt

这个模型以后不是拿来用,而是作为:

“30M 模型在几十 KB 数据上严重过拟合”

的实验样本。


37. 我的阶段性结论

这次实验让我对“训练语言模型”这件事的理解发生了很大变化。

以前我看到:

Attention
Transformer
Loss
Backward

更多还是概念。

真正自己写过一遍以后,我发现语言模型最底层并没有那么神秘。

它最终还是:

矩阵乘法
线性层
Softmax
残差
归一化
Loss
梯度
参数更新

真正困难的地方,反而在模型规模变大以后出现:

  • 数据质量;
  • 数据规模;
  • 分布式训练;
  • 显存;
  • 通信;
  • Checkpoint;
  • 训练稳定性;
  • 评估;
  • 推理效率;
  • GPU 调度。

这也正好和我本身做 SRE / AI Infra 的方向开始接上了。

第一阶段,我解决的是:

模型到底是怎么从零开始学会预测下一个 Token 的?

第二阶段,我准备解决的是:

怎么让它不只是把教材背下来,而是真的开始拥有一点泛化能力?


38. 下一篇准备写什么

第二部分我准备继续做:

1. 扩大运维 / AI Infra 训练语料
2. 文档级数据切分
3. Train / Validation Split
4. Validation Loss
5. Best Checkpoint
6. Early Stopping 思路
7. 更合理的数据混合
8. 观察真正的过拟合曲线
9. 对比不同 Checkpoint 的生成效果
10. 决定是否扩大到 150M

等这部分做完,我再考虑:

RoPE
RMSNorm
SwiGLU
FlashAttention
更大的 Context
更高效的 Multi-Head Attention

以及后面的:

Continued Pretraining
SFT
DPO
领域模型
AI Infra / SRE Agent

39. GitHub 开源说明

这个项目后续会整理到:

https://github.com/noovertime7/opslm-toy

我希望仓库至少包含:

README.md
requirements.txt 或 environment.yml
data/raw/                 # 可公开的示例训练数据
tokenizer/                # Tokenizer 训练与测试
src/                      # 模型核心实现
scripts/                  # 清洗、测试、训练、生成脚本
博客文档/                  # 实验复盘

我不会把大型 checkpoint 直接提交进普通 Git 历史。模型权重更适合放到 GitHub Release、Git LFS 或单独的模型托管平台。

如果我要第一次推送仓库,大致会是:

git init
git add .
git commit -m "feat: first OpsLM-Toy experiment"
git branch -M main
git remote add origin https://github.com/noovertime7/opslm-toy.git
git push -u origin main

正式推送前,我还会补 .gitignore,至少排除:

__pycache__/
*.pyc
logs/*.log
checkpoints/*.pt
.env

结尾

这是我第一次真正从随机权重开始,把一个 Decoder-only Transformer 从零写出来,并亲手看到它:

不会说话
↓
Loss 很高
↓
开始学习
↓
Loss 不断下降
↓
能生成领域文本
↓
把训练集背穿
↓
出现错误组合
↓
出现重复退化

这个过程比直接下载一个开源模型有意思得多。

因为从这一刻开始,我再看到:

Tokenizer
Attention
KV Cache
Loss
Context Length
GPU Memory
Checkpoint

这些词的时候,我脑子里不再只是一个抽象概念,而是能想到它在整个模型里的具体位置。
image
第一阶段结束。

下一阶段:

让 OpsLM 从“会背书”开始走向“会一点泛化”。