主流开源大模型(如LLaMA系列)大多以英文语料为核心,中文词表覆盖不足,对中文支持不友好。要让英文大模型更好支持中文,通常需要三个关键步骤:构建中文Tokenizer → 继续预训练 → 指令微调。本文介绍完整的中文适配流程。
原生英文模型的词表对中文覆盖不足,中文通常会被拆分成单个字节,导致:
因此,第一步需要扩展词表,增加中文词汇。
首先收集中文语料,并进行清洗过滤:
1# 示例:预处理《斗破苍穹》小说语料
2sentences = []
3with open("data/raw.txt", "r", encoding="utf-8") as fp:
4 for line in fp:
5 line = line.strip()
6 # 过滤无效内容
7 if "===" in line or len(line) == 0 or "来自:" in line:
8 continue
9 sentences.append(line)
10
11with open("data/corpus.txt", "w", encoding="utf-8") as fp:
12 fp.write("\n".join(sentences))SentencePiece是Google开源的子词分词工具,适合训练多语言词表:
1import sentencepiece as spm
2
3spm.SentencePieceTrainer.train(
4 input='data/corpus.txt',
5 model_prefix='tokenizer',
6 vocab_size=50000,
7 character_coverage=1.0, # 1.0表示覆盖所有字符
8 model_type="bpe",
9)主要参数说明:
vocab_size:设置词表大小,中文通常建议50k-60kcharacter_coverage:中文建议设为1.0,保证所有中文字符都被覆盖model_type:可选unigram或bpe,BPE更常用tokenizer.model和tokenizer.vocab两个文件。将训练好的中文词表与原英文模型词表合并:
1from transformers import LlamaTokenizer
2from sentencepiece import sentencepiece_model_pb2 as sp_pb2_model
3import sentencepiece as spm
4
5# 加载原有LLaMA词表
6llama_tokenizer = LlamaTokenizer.from_pretrained("original_llama_tokenizer")
7llama_spm = sp_pb2_model.ModelProto()
8llama_spm.ParseFromString(llama_tokenizer.sp_model.serialized_model_proto())
9
10# 加载新训练的中文词表
11chinese_sp = spm.SentencePieceProcessor()
12chinese_sp.Load("tokenizer.model")
13chinese_spm = sp_pb2_model.ModelProto()
14chinese_spm.ParseFromString(chinese_sp.serialized_model_proto())
15
16# 合并词表:添加原有词表中没有的中文词
17llama_spm_tokens = set(p.piece for p in llama_spm.pieces)
18for p in chinese_spm.pieces:
19 piece = p.piece
20 if piece not in llama_spm_tokens:
21 new_p = sp_pb2_model.ModelProto().SentencePiece()
22 new_p.piece = piece
23 new_p.score = 0
24 llama_spm.pieces.append(new_p)
25
26# 保存合并后的词表
27output_dir = 'chinese_llama_tokenizer'
28os.makedirs(output_dir, exist_ok=True)
29with open(output_dir + '/chinese_llama.model', 'wb') as f:
30 f.write(llama_spm.SerializeToString())
31tokenizer = LlamaTokenizer(vocab_file=output_dir + '/chinese_llama.model')
32tokenizer.save_pretrained(output_dir)合并效果对比:
| 模型 | "白日依山尽,黄河入海流。"分词结果 | token数量 |
|---|---|---|
| 原生LLaMA | ['▁', '白', '日', '<0xE4>', '<0xBE>', ... | 约30+ |
| 中文适配后 | ['▁白日', '依山', '尽', ',', ... | 约20 |
中文词表合并后,分词粒度更合理,显著减少token数量。
合并词表后需要调整模型的输入嵌入层和输出层:
1from transformers import AutoConfig, AutoModelForCausalLM
2
3config = AutoConfig.from_pretrained(...)
4tokenizer = LlamaTokenizer.from_pretrained(...)
5model = AutoModelForCausalLM.from_pretrained(..., config=config)
6
7# 调整词表大小,新增token随机初始化
8model.resize_token_embeddings(len(tokenizer))如果要保留原有token的Embedding,新增token初始化:
normal_(mean=0, std=config.initializer_range)扩充词表后,新增中文token的Embedding是随机初始化的,需要通过继续预训练让模型学习中文知识。
继续预训练的数据预处理与普通LM训练类似:
1# 核心步骤:将所有文本拼接,按block_size切分
2block_size = 512
3
4def tokenize_function(examples):
5 return tokenizer(examples["text"])
6
7def group_texts(examples):
8 # 将所有文本拼接
9 concatenated_examples = {
10 k: list(chain(*examples[k])) for k in examples.keys()
11 }
12 total_length = len(concatenated_examples[list(examples.keys())[0]])
13 # 按block_size切分,丢弃末尾不足block_size的部分
14 total_length = (total_length // block_size) * block_size
15 result = {
16 k: [t[i : i + block_size] for i in range(0, total_length, block_size)]
17 for k, t in concatenated_examples.items()
18 }
19 # 语言模型任务,labels等于input_ids
20 result["labels"] = result["input_ids"].copy()
21 return resultblock_size的连续中文文本,模型训练目标仍然是自回归语言建模(根据上文预测下一个token)。参数高效微调:通常使用LoRA等PEFT方法减少显存占用,只需要训练少数参数
1# 示例启动命令
2torchrun --nnodes 1 --nproc_per_node 1 run_clm_pt_with_peft.py \
3 --deepspeed ds_zero2_no_offload.json \
4 --model_name_or_path base_model_path \
5 --tokenizer_name_or_path tokenizer_path \
6 --dataset_dir data \
7 --per_device_train_batch_size 32 \
8 --learning_rate 2e-4 \
9 --max_steps 2500 \
10 --lora_rank 8 \
11 --lora_alpha 32 \
12 --modules_to_save transformer.wte,lm_head \
13 --block_size 512 \
14 --output_dir output_dir关键注意点:
transformer.wte)和输出层(lm_head)需要全量训练,不能只训练LoRA在继续预训练让模型学好中文后,需要进行指令微调(Supervised Fine-Tuning,SFT)让模型对齐人类指令。
指令数据一般包含三个字段:
instruction:任务指令描述input:可选,用户输入/问题上下文output:期望模型输出的回答示例:
1[
2 {
3 "instruction": "什么是大语言模型?",
4 "input": "",
5 "output": "大语言模型是一种基于Transformer架构的大规模预训练语言模型..."
6 }
7]不同模型的Prompt模板和输入构造方式不同,需要特别注意:
Alpaca格式:
Below is an instruction that describes a task.
Write a response that appropriately completes the request.
### Instruction:
{instruction}{input}
### Response: [gMASK]标记:1# instruction + input 拼接得到source
2# output 添加bos/eos
3source = instruction + ("\n" + input if input else "")
4target = f"{tokenizer.bos_token}{output}{tokenizer.eos_token}"
5# 构造input_ids: source_ids + [gmask] + sop + target_ids + eos
6input_ids = source_ids + [gmask_id] + [sop_id] + target_ids + [eos_id]
7# labels: source部分全设为-100,只计算target部分损失
8labels = [IGNORE_INDEX] * len(source_ids) + [gmask_id] + [sop_id] + target_ids + [eos_id]关键标签构造规则:
-100,损失计算时会被忽略全量微调(需要多卡A100):
1deepspeed --num_gpus=8 src/train_bash.py \
2 --stage sft \
3 --model_name_or_path baichuan-inc/Baichuan-13B-Base \
4 --do_train \
5 --dataset alpaca_gpt4_en,alpaca_gpt4_zh \
6 --finetuning_type full \
7 --output_dir path_to_your_sft_checkpoint \
8 --per_device_train_batch_size 4 \
9 --learning_rate 5e-5 \
10 --num_train_epochs 2.0 \
11 --fp16 \
12 --deepspeed deepspeed.jsonLoRA微调(单卡A100即可):
1CUDA_VISIBLE_DEVICES=0 python src/train_bash.py \
2 --stage sft \
3 --model_name_or_path baichuan-inc/Baichuan-13B-Base \
4 --do_train \
5 --dataset alpaca_gpt4_en,alpaca_gpt4_zh \
6 --finetuning_type lora \
7 --lora_rank 8 \
8 --output_dir path_to_your_sft_checkpoint \
9 --learning_rate 5e-5 \
10 --num_train_epochs 2.0 \
11 --fp16适配完成后需要在中文基准上评测效果,常用中文评测数据集:
| 评测集 | 简介 | 类型 |
|---|---|---|
| C-Eval | 覆盖52个学科的中小学到大学难度选择题 | 知识理解 |
| CMMLU | 中文语言理解测评,覆盖更多领域 | 综合能力 |
| MMLU-ZH | MMLU中文翻译版 | 知识问答 |
| GSM8K-ZH | 小学数学应用题 | 数学推理 |
| HumanEval-X | 多语言代码生成,含中文 | 代码能力 |
如何让一个预训练好的英文大模型更好支持中文?
为什么原生LLaMA对中文支持不好?
合并词表后如何处理模型Embedding层?
model.resize_token_embeddings(len(tokenizer))自动调整指令微调中labels怎么构造?为什么输入部分要设为-100?
labels中输入部分(instruction+input)全部设为-100,只保留输出部分的真实标签不同大模型的指令数据构造有什么差异?
[gMASK]、sop等特殊标记bos_token/eos_token位置可能不同中文适配中,继续预训练的目的是什么?能不能直接指令微调?