Chapter 08

实战:用 Colossal-LLaMA 训中文增强模型

📌 commit 4f9953be335e 从词表扩充到二次预训练 + SFT 的完整 pipeline

Colossal-LLaMA 是 ColossalAI 团队 2023-2024 年开源的项目, 完整演示了如何在英文模型基础上做中文增强 + 二次预训练 + SFT。 本章按 applications/Colossal-LLaMA/ 目录把这个流程跑一遍。

8.1项目结构

applications/Colossal-LLaMA/
├── README.md
├── prepare_pretrain_dataset.py     ★ 准备预训练数据
├── prepare_sft_dataset.py           ★ 准备 SFT 数据
├── train.py                         ★ 主训练脚本(PT/CPT 共用)
├── inference_example.py
├── stream_chat_example.py
├── colossal_llama/
│   ├── dataset/
│   │   ├── conversation.py          中文 prompt 模板
│   │   └── loader.py                streaming dataset
│   ├── tokenizer/
│   │   ├── init_tokenizer.py        ★ 词表扩展
│   │   └── chat_template.py
│   └── utils/
│       ├── ckpt_io.py
│       └── flash_attention_patch.py
└── docs/

8.2step 1:扩中文词表

原版 LLaMA tokenizer 用 BPE,词表 32K。每个中文字符通常被切成 2–3 个 token,处理中文极其低效。Colossal-LLaMA 用 SentencePiece 在中文语料上训练一个 30K 的中文 tokenizer,再合并到 LLaMA 原表里。

colossal_llama/tokenizer/init_tokenizer.py

# 1) 先在中文语料训一个 SentencePiece
python -m sentencepiece.SentencePieceTrainer \
    --input=chinese.txt \
    --model_prefix=chinese_sp \
    --vocab_size=30000 \
    --character_coverage=0.9995 \
    --model_type=bpe

# 2) 合并到 LLaMA 原 tokenizer
python init_tokenizer.py \
    --source_tokenizer_dir Llama-3-8B \
    --target_tokenizer_dir colossal_llama_tokenizer \
    --expand_tokens_file chinese_sp.model

扩完后词表从 32K → ~58K,每个汉字平均只占 1 token,中文输入序列长度减少 65%。

为什么扩词表很关键

$$\text{每 token 训练 cost} \propto \text{seqlen}^2 \;\Longrightarrow\; \text{seqlen 减 65\%, cost 减 } 1 - 0.35^2 = 87.75\%$$

(attention 是 O(n²),中文场景这一项收益巨大。)

8.3step 2:embedding 扩展 + 初始化

扩了词表后,模型的 embed_tokenslm_head 尺寸必须改。看 train.py 里的处理:

old_size = model.config.vocab_size           # 128256 (Llama-3)
new_size = len(tokenizer)                     # ~154256
model.resize_token_embeddings(new_size)

# 用旧 embedding 的平均值初始化新加的 token
with torch.no_grad():
    mean = model.get_input_embeddings().weight[:old_size].mean(dim=0)
    model.get_input_embeddings().weight[old_size:] = mean
    mean_out = model.get_output_embeddings().weight[:old_size].mean(dim=0)
    model.get_output_embeddings().weight[old_size:] = mean_out

注意:不要用随机初始化,那样新 token 在第一步训练时 loss 会非常高,模型可能直接发散。

8.4step 3:准备 streaming 预训练数据

prepare_pretrain_dataset.py,主要做三件事:

  1. 读 jsonl / parquet 原始语料;
  2. 用扩好的 tokenizer 切;
  3. max_length packing 成 sample,落盘到 indexed_dataset 格式(与 Megatron 兼容)。
python prepare_pretrain_dataset.py \
    --data_input_dirs raw_zh/ raw_code/ raw_en/ \
    --tokenizer_dir colossal_llama_tokenizer \
    --data_output_dirs ./train_data \
    --max_length 4096 \
    --num_spliced_dataset_bins 64

跑完 ./train_data/ 下会有 64 个 bin 文件,训练时 streaming 读取。

8.5step 4:启动二次预训练(CPT)

train.py 关键参数:

colossalai run --nproc_per_node 8 train.py \
    --pretrained meta-llama/Meta-Llama-3-8B \
    --tokenizer_dir colossal_llama_tokenizer \
    --dataset ./train_data \
    --plugin hybrid_parallel \
    --tp 2 --pp 1 \
    --zero 1 \
    --batch_size 1 \
    --num_epochs 1 \
    --max_length 4096 \
    --lr 2e-5 \
    --mixed_precision bf16 \
    --warmup_steps 100 \
    --save_interval 1000 \
    --save_dir ./outputs/cpt \
    --use_flash_attn \
    --pad_token unk

训练循环简化版:

colossalai.launch_from_torch()
plugin = HybridParallelPlugin(tp_size=2, pp_size=1, zero_stage=1, precision="bf16",
                              enable_flash_attention=True, enable_fused_normalization=True)
booster = Booster(plugin=plugin)

model = AutoModelForCausalLM.from_pretrained("Meta-Llama-3-8B", torch_dtype=torch.bfloat16)
model.resize_token_embeddings(len(tokenizer))
optimizer = HybridAdam(model.parameters(), lr=2e-5)
scheduler = ...

model, optimizer, _, dl, scheduler = booster.boost(model, optimizer, None, dl, scheduler)

for epoch in range(num_epochs):
    for batch in dl:
        if pp_size > 1:
            booster.execute_pipeline(iter([batch]), model,
                                     criterion=lambda o, b: o.loss,
                                     optimizer=optimizer, return_loss=True)
        else:
            loss = model(**batch).loss
            booster.backward(loss, optimizer)
        optimizer.step()
        optimizer.zero_grad()
        scheduler.step()

8.6step 5:SFT

CPT 训完后做 SFT。Colossal-LLaMA 的 SFT 数据格式(chat 模板):

{"messages": [
    {"from": "human", "content": "请介绍 ColossalAI"},
    {"from": "assistant", "content": "ColossalAI 是..."}
]}

chat template 在 colossal_llama/tokenizer/chat_template.py 定义,关键点:对 user 部分的 token 做 label_mask = -100(不算 loss),只学 assistant 输出。

python prepare_sft_dataset.py \
    --data_input_dirs raw_sft.jsonl \
    --tokenizer_dir colossal_llama_tokenizer \
    --data_output_dirs ./sft_data \
    --conversation_template colossal_llama \
    --max_length 4096

colossalai run --nproc_per_node 8 train.py \
    --pretrained ./outputs/cpt/epoch-1-step-final \
    --dataset ./sft_data \
    --plugin hybrid_parallel \
    --tp 2 --zero 1 \
    --lr 2e-5 --num_epochs 3 \
    --save_dir ./outputs/sft

8.7资源估算

8B 模型、bf16、TP=2、ZeRO-1、seqlen=4096、micro_bs=1:

数值备注
模型显存(每卡 weight + grad) ~8 GB 8B/(TP=2) × 2 bytes × 2
Optimizer 显存(ZeRO-1 切) ~6 GB/卡 fp32 master + m + v ÷ 8
Activation(4K seq) ~15 GB/卡 开 grad ckpt 可降到 5 GB
总计 ~29 GB/卡 8×A100-40GB 够
训练速度 ~150 samples/s~615k tokens/s

8.8常见问题

问题处理
tokenizer 扩完后载入慢 保存为 fast tokenizer,从 BPE 转 Tokenizers 库格式
新 token loss 高得离谱 检查初始化(应用旧 embedding 平均,不是随机)
CPT 后中文好了英文退化 数据混合比要保留 ≥ 20% 英文;rehearsal 1% 原始
SFT 后回答模式化 降低 epochs(<3),提高 lr cosine 衰减
保存 ckpt 后用 HF 加载失败 booster.save_model(shard=False, sharded_strategy='hf')

8.9这章你需要带走的