微调 Qwen 之后回答变呆板、格式乱,偶尔又像学会了,先别急着改 learning_rate。这类现象通常出在训练数据与对话模板没对齐,或者样本在切分时被截断,参数层面很难调回来。建议按固定顺序复查:先看单条样本字段,再核对角色与特殊标记,然后统计长度分布与截断比例,接着用固定问题做训练前后对照,最后按最小改动逐项回退。边界要说清楚:如果基座模型本身不具备目标任务的先验,或者标注数据本身有错,这套顺序只能帮你定位问题,不能保证效果一定变好。
判断:Qwen 微调效果不稳,多数情况先怀疑训练数据字段与对话模板对齐,而不是学习率。操作:按数据字段、角色标记、长度截断、固定评测、逐项回退五步复查,每一步只改一个变量。验证:把单条样本原样打印、把训练文本与 tokenizer 的 chat_template 比对、用同一组问题对照前后输出。边界:本流程用于定位问题;若数据标注错误或基座能力不足,仍需要回到数据本身处理。
这五步分别对应数据字段、对话模板、样本配比、评测口径四层。每一步都能靠打印、日志或脚本输出验证,不依赖感觉判断。
把训练集里的一条完整样本原样打印出来
很多格式问题的源头是读数据时字段名就认错了。先看一条原始内容,不要先看处理后的 batch。jsonl 数据可以直接这样读:
import json
path = "train.jsonl"
with open(path, "r", encoding="utf-8") as f:
sample = json.loads(f.readline())
print(sample.keys()) # 字段名:messages / instruction / conversations 等
print(repr(sample)) # 完整内容,保留空格、换行与特殊符号
用 repr 而不是 print 是有意的,末尾的换行、多余空格、全角冒号都能看出来。如果数据是 parquet,换成 pandas 打印一行同样可以:
import pandas as pd
df = pd.read_parquet("train.parquet")
print(df.columns.tolist())
print(repr(df.iloc[0].to_dict()))
需要与模板对齐的字段位置通常有三处:角色字段的取值(user / assistant 还是 human / gpt)、内容字段的名字(content / value / output)、以及多轮样本里轮次的摆放顺序。如果训练集包含多轮对话,务必单独打印一条多轮样本,确认最后一条是助手回复而不是用户提问。
核对对话模板里的角色与特殊标记
以 Qwen 常见的 ChatML 风格为例,一条训练文本大致长这样,实际以你所用模型的配置为准:
<|im_start|>system
{system}<|im_end|>
<|im_start|>user
{question}<|im_end|>
<|im_start|>assistant
{answer}<|im_end|>
不要凭记忆手写这段字符串,直接把 tokenizer 里的模板读出来比对:
from transformers import AutoTokenizer
tok = AutoTokenizer.from_pretrained("Qwen/Qwen2.5-7B-Instruct")
print(tok.chat_template)
print(tok.apply_chat_template(
[{"role": "user", "content": "你好"}],
tokenize=False,
add_generation_prompt=False,
))
常见的不一致点集中在三处。第一,结束符缺失,比如助手段落后没写 <|im_end|>,模型学不到该在哪里收尾,输出容易拖尾或重复。第二,角色名写法不统一,assistant、Assistant、bot 混着用,或者角色名后面多带一个空格。第三,同一次训练里混用了两种格式,一部分样本走 ChatML,另一部分还是 Alpaca 的 ### Instruction / ### Response,模型需要同时学两套边界,表现就会飘。训练侧一般不加 generation prompt,推理侧才加,两边不一致也会让模型学到的结束位置和实际生成位置对不上。
统计样本长度分布与截断比例
回答被切掉一半是“学不到完整回答”的常见原因。用与训练相同的 tokenizer 和相同的 max length 口径统计一遍:
import json
import numpy as np
from transformers import AutoTokenizer
tok = AutoTokenizer.from_pretrained("Qwen/Qwen2.5-7B-Instruct")
MAX_LEN = 2048 # 与训练配置里的 max_seq_length / cutoff_len 保持一致
lens = []
with open("train.jsonl", encoding="utf-8") as f:
for line in f:
s = json.loads(line)
text = tok.apply_chat_template(
s["messages"], tokenize=False, add_generation_prompt=False
)
ids = tok(text, add_special_tokens=False)["input_ids"]
lens.append(len(ids))
lens = np.array(lens)
for q in (50, 90, 95, 99, 100):
print(q, int(np.percentile(lens, q)))
print("超过 MAX_LEN 的样本占比:", round(float((lens > MAX_LEN).mean()), 4))
如果训练时启用了 packing 或者按 batch 内最长补齐,这里的口径要相应说明,否则算出来的截断比例不可比。截断比例偏高时,先确认被截掉的是哪一段:答案尾部被截,模型就学不会终止;上下文被截,模型可能丢失前提。此时优先改数据构造,比如只截输入、保留完整答案,或者直接过滤超长样本,之后再考虑是否放大 max_seq_length。这一步没做完,调学习率的意义不大。
固定一组问题做训练前后对照
准备十条左右固定问题,覆盖格式、多轮、知识、边界四类,训练前后各跑一次。例如:用三行说明某个概念;以 JSON 返回固定字段;先问 A 再基于回答追问 B;给出不存在的名词看是否乱编;要求输出不超过指定字数。评分口径建议固定为:格式是否合规、内容是否正确(1 到 5 分)、是否出现重复或截断、长度是否符合要求。
对比时推理参数必须一致,包括 prompt 模板、temperature、top_p、max_new_tokens 和随机种子。要稳定复现可以先关闭采样:
out = model.generate(**inputs, do_sample=False, max_new_tokens=512)
每次实验落一份记录,方便回看是哪个版本变差的:
{
"exp_id": "v0-baseline",
"params": {"temperature": 0, "max_new_tokens": 512},
"results": [
{"qid": "q1", "format_ok": true, "score": 4, "note": ""},
{"qid": "q2", "format_ok": false, "score": 2, "note": "JSON 未闭合"}
]
}
按最小改动逐项回退验证
一次改多个变量,结论就没法用。建议的改动顺序是:先模板,再样本配比,最后训练参数。模板阶段把所有样本统一成与 tokenizer 配置一致的格式,确认结束符和角色名;样本配比阶段检查是否某类样本堆得过多、多轮样本轮次是否整齐;只有前两层稳定后,再去动 learning_rate、epoch 或 LoRA rank。
每轮只改一项,其余保持不变,并写下唯一变更项和对照结论:
{
"exp_id": "v1",
"changed": "训练文本统一由 apply_chat_template 生成",
"kept": ["样本集", "训练超参", "推理参数"],
"compare": "v0 对照:q2 格式恢复,q7 仍有截断",
"decision": "保留本改动,下一轮改样本配比"
}
如果某项改完对照结果变差,直接退回该项,再动下一项,不要在同一轮里叠加。模板和样本配比稳定下来之后,再比较不同学习率,得到的差异才和参数本身有关。