这篇能做出什么
读完并跟着做完,你会得到一个能直接对话的中文模型:给它一句“帮我把这段合同条款改写成客户能看懂的说明”,它会输出成段的答复,而不是接着你的话往下续写英文或复读问句。整个过程走完后,你手上会有三样东西:
1. 一份已经校验过的 Base 权重和配套 tokenizer;
2. 一份补齐的对话模板(chat template),Base 权重靠它才能“听懂”多轮格式;
3. 一个用你自己的领域数据(售后工单、病历摘要、内部制度问答,随便哪种)做小样本 SFT 出来的 LoRA 适配器,以及一份“同尺寸基准复测”的结果表,能量化说明微调后答得好在哪、退步在哪。
关键认知先说清楚:Base 权重不是聊天模型。它是“下一个词预测器”,你把问题丢进去,它最自然的反应是接着问题往下编,而不是回答你。这不是模型坏了,而是缺少两样东西——对话格式(模板)和指令跟随行为(SFT)。下面的流程就是按这两步补上。
前置条件清单
- 硬件:一张能装下 Base 权重的 GPU。具体显存需求按权重文件大小、精度(bf16 / fp16)和是否量化来估,乘以 1.2~1.5 的余量;LoRA 微调比全参省得多,单卡通常够用。具体数值以官方模型卡当前版本为准。
- 软件:Python 3.10+、PyTorch、transformers、datasets、peft、trl、accelerate。版本号以官方文档当前版本为准,不要死磕某个旧版本,trl 的 API 跨版本变动较大。
- 数据:300~2000 条高质量领域指令数据起步,jsonl 一行一条。
- 磁盘:权重 + 训练输出 + 合并后权重,预留三倍空间。
- 一个评测题集:50~200 条你自己写的题,不要和训练数据重叠。这是后面复测的基准,先写好再训练。
分步骤
第 1 步:建环境、定目录
先把目录固定下来,后面所有命令都基于这个结构,照抄即可。
```bash
mkdir -p ernie-lab/{ckpt,data,out,scripts}
cd ernie-lab
python -m venv .venv
source .venv/bin/activate
pip install -U torch transformers datasets peft trl accelerate
pip install -U "huggingface_hub[cli]"
```
如果国内网络拉取权重慢,用 ModelScope 的下载工具,或者给 huggingface-cli 设置镜像端点,具体端点地址以官方文档为准。
第 2 步:下载权重并做一次“裸跑”
仓库 ID 以官方发布页为准,这里用占位符:
```bash
huggingface-cli download <组织>/<模型名>-Base \
--local-dir ./ckpt/base \
--exclude "*.gguf" "*.onnx"
```
下完先做完整性检查,同时看清 tokenizer 里已经有哪些特殊 token——这决定你第 4 步能不能“沿用”而不是“自造”:
```python
from transformers import AutoAI 词典:Token">Tokenizer, AutoModelForCausalLM
import torch
path = "./ckpt/base"
tok = AutoTokenizer.from_pretrained(path, trust_remote_code=True)
print("模板:", tok.chat_template)
print("特殊token:", tok.special_tokens_map)
print("词表大小:", len(tok))
model = AutoModelForCausalLM.from_pretrained(
path, torch_dtype=torch.bfloat16, device_map="auto", trust_remote_code=True
)
prompt = "请用三句话解释什么是复利。"
ids = tok(prompt, return_tensors="pt").to(model.device)
out = model.generate(**ids, max_new_tokens=120, do_sample=False)
print(tok.decode(out[0], skip_special_tokens=True))
```
如果输出是“请用三句话解释什么是复利。请用三句话解释什么是复利。……”或者把问题翻译成英文再展开,恭喜,你确认了 Base 的本来面目。把这段输出截图存下来,它是复测时的“改造前”基线。
第 3 步:确认模型有没有自带模板
先看第 2 步打印的 tok.chat_template。若为 None,说明权重是纯续写模型,模板必须自己补。若已有模板,直接沿用,不要改,否则你等于在原始分布之外重造格式,小样本下模型很难适应。
无论哪种情况,都要先做个“模板自检”:
```python
msgs = [
{"role": "system", "content": "你是一名严谨的技术文档编辑。"},
{"role": "user", "content": "把这句话改短:由于当前系统正在进行升级维护,因此暂时无法提供相关服务。"},
]
text = tok.apply_chat_template(msgs, tokenize=False, add_generation_prompt=True)
print(repr(text))
```
repr 用上,空格和换行才是坑的来源。
第 4 步:补全对话模板
模板要复用 tokenizer 已有的特殊 token,别自造 <|user|> 这种没进词表的字符串——它会被切成三四个普通 token,训练时极不稳定。正确做法是查 special_tokens_map,看有没有 <start_of_turn>、<|im_start|>、[CLS] 之类的现成标记,用它们拼。
同时必须用 {% generation %} 块把 assistant 内容包起来,trl 才会只对回答算 loss:
```jinja
{%- for message in messages -%}
{%- if message['role'] == 'system' -%}
<|system|>{{ message['content'] }}\n
{%- elif message['role'] == 'user' -%}
<|user|>{{ message['content'] }}\n
{%- elif message['role'] == 'assistant' -%}
<|assistant|>{% generation %}{{ message['content'] }}{% endgeneration %}\n
{%- endif -%}
{%- endfor -%}
{%- if add_generation_prompt -%}<|assistant|>{%- endif -%}
```
把这段存成 scripts/chat_template.jinja,加载时显式传入:
```python
tok.chat_template = open("scripts/chat_template.jinja").read()
```
上面 <|system|> 这类标记请替换成你模型 tokenizer 里真实存在的特殊 token。改完后重跑第 3 步的自检,肉眼核对:生成提示片段必须精确等于训练数据里 assistant 开头的那一段,一个空格都不能差。
第 5 步:把领域数据整理成 jsonl
一行一条,messages 格式:
```json
{"messages":[{"role":"system","content":"你是某品牌净水器的售后客服,只依据保修条款作答。"},{"role":"user","content":"滤芯用了两年坏了能免费换吗?"},{"role":"assistant","content":"滤芯属于耗材,保修期为 6 个月。您使用两年已超出保修范围,需自费更换。若您购买过延保服务,请提供订单号,我帮您核对。"}]}
```
清洗要点,逐条过一遍:
- 答案要短而准,200~600 字之间为主,混杂少量长答案。全是长答案,模型会学会啰嗦。
- 问题要有真实口吻,别全写成“请介绍……”。加错别字、口语、半句话,模型抗噪能力才上得来。
- 拒绝样本要占 5%~10%,比如问超出业务范围的问题时,回答“这个我查不到,建议联系人工”。不然模型会开始编。
- 去重:
sort train.jsonl | uniq -d | head,重复样本会让模型背诵。 - 防泄漏:用评测题集里的关键词 grep 一遍训练文件,撞车的一条不留。
第 6 步:跑 LoRA 微调
```python
from datasets import load_dataset
from transformers import AutoTokenizer
from trl import SFTTrainer, SFTConfig
path = "./ckpt/base"
tok = AutoTokenizer.from_pretrained(path, trust_remote_code=True)
tok.chat_template = open("scripts/chat_template.jinja").read()
if tok.pad_token is None:
tok.pad_token = tok.eos_token
ds = load_dataset("json", data_files="data/train.jsonl", split="train")
cfg = SFTConfig(
output_dir="./out/lora",
num_train_epochs=3,
per_device_train_batch_size=1,
gradient_accumulation_steps=8,
learning_rate=1e-4,
lr_scheduler_type="cosine",
warmup_ratio=0.03,
bf16=True,
max_length=2048,
gradient_checkpointing=True,
logging_steps=10,
save_strategy="epoch",
report_to="none",
)
trainer = SFTTrainer(
model=path,
args=cfg,
train_dataset=ds,
processing_class=tok,
)
trainer.train()
trainer.save_model("./out/lora")
```
几个必须盯住的点:
- 学习率:LoRA 用 1e-4 量级,全参微调降到 1e-5 量级。小数据上学习率开大,模型会迅速忘掉通用能力。
- epoch 数:1000 条以内 2~3 轮足够。第 4 轮开始,验证集 loss 往往回升。
- max_length:设得比数据实际长度略大即可,过长浪费显存。
- loss 曲线:从 1.x 稳步下降是正常的;如果第 20 步就掉到 0.1 以下,多半是数据重复或模板把答案部分重复计入了输入。
第 7 步:合并并用对话接口验证
```python
from peft import PeftModel
from transformers import AutoModelForCausalLM
base = AutoModelForCausalLM.from_pretrained(path, torch_dtype="bfloat16", device_map="auto")
model = PeftModel.from_pretrained(base, "./out/lora").merge_and_unload()
model.save_pretrained("./out/merged")
tok.save_pretrained("./out/merged")
```
然后换回对话模板推理:
```python
msgs = [{"role": "user", "content": "滤芯用了两年坏了能免费换吗?"}]
text = tok.apply_chat_template(msgs, tokenize=False, add_generation_prompt=True)
out = model.generate(**tok(text, return_tensors="pt").to(model.device),
max_new_tokens=256, do_sample=False)
print(tok.decode(out[0][tok(text).input_ids.shape[-1]:], skip_special_tokens=True))
```
注意只解码“新生成的部分”,别把输入再打一遍,否则你分不清是续写还是回答。
第 8 步:同尺寸基准复测
固定三样东西再比:同一套评测题、同样的解码参数、同样的打分口径。
```bash
python scripts/eval.py --model ./ckpt/base --out base.jsonl
python scripts/eval.py --model ./out/merged --out sft.jsonl
```
eval.py 里对每题同时算两类分:
- 规则分:是否包含必须出现的关键事实、是否超过长度上限、是否出现“我无法回答”这类拒答、是否重复句子。这类分能自动跑。
- 人工分:1~5 分打“是否符合业务口径”,抽 50 条足够。
再单独跑一组通用能力回归题(算术、改写、常识),10~20 条就行。这张表的重点不是“平均分涨了多少”,而是看有没有掉点——领域题涨了 30%,但通用改写题掉了 20%,说明模型被你的数据带偏好,需要减 epoch 或掺入通用数据。
常见坑与排错
模板不一致,微调白做。 现象:训练 loss 一路降,推理时模型自问自答或输出格式标记。原因是训练用 apply_chat_template、推理手拼字符串,中间差了个换行。解决办法:全流程只走 apply_chat_template 一个入口。
模型不收尾。 输出停不下来,一直生成。检查 eos_token 是否在训练数据末尾出现、pad_token 是否被误设成 eos 导致注意力掩码出问题。补齐后重新训练,比在推理端加 stop 字符串靠谱。
loss 算到了问题部分。 忘记加 {% generation %},模型会学“复述用户提问”。判断方法:验证集 loss 异常低,但实际对话里模型爱先重复你的话。
padding 方向搞反。 训练时 padding_side 应当在左,推理时在左,解码批量时更要一致。搞反后同一条 prompt 的单条推理和批量推理结果不同,非常难查。
量化后精度掉了,误判为微调失败。 先拿 bf16 原权重复测一遍再下结论。
评测集混进训练集。 这是最隐蔽的坑,分数漂亮但线上无效。训练前做一次关键词交叉检索。
显存不够就降 max_length 和 batch,别急着换 4bit。 先降长度、再开梯度检查点、最后才量化,量化会改变数值分布。
下一步建议
模板跑通、SFT 出效果之后,往上走有四条路:一是补 DPO 或偏好数据,把“答得对但答得不客气”修掉;二是扩充数据到万条量级,同时同步扩充评测集,否则评测就失去意义;三是上量化推理服务,用 vLLM 之类引擎起 OpenAI 兼容接口,把 /v1/chat/completions 接到你现有的应用里;四是把每轮复测的 jsonl 存档,做成版本对比,后面每换一版数据或超参,都能一句话说清“这一版比上一版好在哪”。
最后提醒一句:所有涉及仓库地址、镜像 tag、显存门槛、上下文长度的具体数字,都以官方模型卡和文档的当前版本为准,本文里出现的只是流程骨架,照着换个占位符就能用。
