微调qwen

更新时间: 2026-04-29 16:03:54

# 下载模型

模型要下载到本地才能微调,这里我选择的是qwen2.5-1.5B模型

from modelscope.hub.snapshot_download import snapshot_download
import os


def download_qwen_model(save_dir="D:/models/Qwen2.5-1.5B-Instruct"):
    """
    使用 ModelScope 下载 Qwen2.5-1.5B-Instruct 模型到指定目录

    Args:
        save_dir (str): 模型保存的本地路径
    """
    model_id = "qwen/Qwen2.5-1.5B-Instruct"

    print(f"开始从 ModelScope 下载模型: {model_id}")
    print(f"下载目标路径: {os.path.abspath(save_dir)}")

    try:
        # snapshot_download 会自动创建目录并下载模型文件
        # revision 参数指定了具体的版本,确保下载的是最新的
        model_dir = snapshot_download(
            model_id,
            revision="master",
            cache_dir=save_dir
        )
        print(f"\n恭喜!模型下载成功!")
        print(f"模型已保存在: {model_dir}")

    except Exception as e:
        print(f"下载失败: {e}")
        print("请检查网络连接,或者尝试在命令行使用 git clone 方式下载。")


if __name__ == "__main__":
    # 请在这里修改为你想要保存模型的路径
    # 例如: "D:/MyModels/Qwen" 或者 "/root/models/Qwen"
    time_location = "D:/models/Qwen2.5-1.5B-Instruct"

    download_qwen_model(time_location)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38

# 准备数据集

"""
生成多样化的二次元鬼怪对话训练数据
重点:多样性 > 数量
- 30+ 种鬼怪角色
- 10+ 种提问方式
- 3 种回答风格(正经讲述、搞笑吐槽、温馨治愈)
- 多轮对话
"""

import json
import random

# ============ 鬼怪角色库 ============
MONSTERS = [
    # 日常科技类
    {"name": "WiFi幽灵", "domain": "科技", "traits": "信号时强时弱,喜欢藏在路由器后面",
     "origin": "世界上第一个连上网的微波炉,因为信号太差被主人遗弃,灵魂附着在了路由器上",
     "habit": "喜欢在凌晨3点把WiFi信号调到最强,白天却故意断网", "weakness": "拔掉路由器电源就会暂时消失"},
    {"name": "回声延迟鬼", "domain": "科技", "traits": "说话总是慢半拍,自带0.5秒延迟",
     "origin": "曾经是一个网络主播的麦克风,因为直播延迟太严重被砸碎,怨念不散",
     "habit": "你叫它名字,它会慢一拍才回答,但回答的内容总是恰到好处", "weakness": "对节拍器毫无抵抗力"},
    {"name": "蓝牙配对妖", "domain": "科技", "traits": "热衷于给设备配对,但总是配错",
     "origin": "一个程序员写bug时无意间创造的数字生命,代码写错了但竟然跑起来了",
     "habit": "会偷偷把你的耳机连到邻居的音箱上", "weakness": "无法处理2FA验证码"},
    {"name": "充电线精灵", "domain": "科技", "traits": "能让充电线自动打结的小妖精",
     "origin": "被一团永远理不清的耳机线孕育而生", "habit": "你刚理好的线,一眨眼又结成了一团",
     "weakness": "遇到魔术贴就会老实"},
    {"name": "代码bug精", "domain": "科技", "traits": "藏在代码里的微小妖精,专门把分号变成冒号",
     "origin": "第一个计算机bug——那只真的蛾子——的后代", "habit": "最爱在周五下午5点出没,让你的代码周末加班",
     "weakness": "单元测试"},

    # 家居生活类
    {"name": "冰箱灯灵", "domain": "家居", "traits": "关上冰箱门后偷偷开派对",
     "origin": "一个怕黑的灯泡灵魂,被装进冰箱后再也离不开",
     "habit": "每次你关门后,它就开冰箱派对,所以你的冰淇淋总是有融化痕迹", "weakness": "透明冰箱门,被看到就社死"},
    {"name": "洗衣机漩涡怪", "domain": "家居", "traits": "吞噬左脚袜子的罪魁祸首",
     "origin": "一个古老的漩涡精灵,被现代管道困在了洗衣机里",
     "habit": "只吃左脚的袜子,右脚的从来不碰,很有原则", "weakness": "把袜子装进洗衣袋就能防住"},
    {"name": "微波炉叮咚仙", "domain": "家居", "traits": "微波炉'叮'的一声就是它的问候",
     "origin": "第一台微波炉加热爆米花时诞生的快乐精灵",
     "habit": "不管你热什么,它都会努力让食物变得更好吃(虽然不一定成功)",
     "weakness": "金属器皿会让它过敏"},
    {"name": "遥控器隐身怪", "domain": "家居", "traits": "专门把遥控器藏到沙发缝里",
     "origin": "沙发和遥控器之间的三角恋,沙发想独占遥控器所以养了这个怪",
     "habit": "你找遥控器时它最开心,藏东西是它的乐趣", "weakness": "万能遥控器APP"},
    {"name": "闹钟贪睡精", "domain": "家居", "traits": "会偷偷帮你按掉闹钟的温柔妖怪",
     "origin": "一个极其讨厌早起的上班族死后变成的,决心让所有人都能多睡五分钟",
     "habit": "每天早上帮你关闹钟,然后在你迟到时假装无辜",
     "weakness": "放在离床3米以上的地方就够不着了"},

    # 社交心理类
    {"name": "拖延症妖怪", "domain": "心理", "traits": "让你总觉得'还有时间'的温柔恶魔",
     "origin": "古代一个总说明天再修城墙的守城将军,城破后化为了拖延的诅咒",
     "habit": "在你耳边轻声说'不急',然后时间就没了", "weakness": "截止日期的恐惧"},
    {"name": "社恐蘑菇", "domain": "心理", "traits": "长在角落里,越多人看它越缩越小",
     "origin": "一个在聚会上被冷落的大学生,愿望太强烈就变成了蘑菇",
     "habit": "人多的时候会释放孢子让你也想躲起来", "weakness": "有人真诚地对它说'我愿意听你说话'"},
    {"name": "回忆美化师", "domain": "心理", "traits": "专门给过去的回忆加滤镜的妖怪",
     "origin": "一个怀旧成疾的老人的执念,觉得过去一切都比现在好",
     "habit": "让你觉得前男友/前女友其实还不错(并不是)",
     "weakness": "翻旧聊天记录就能破除滤镜"},
    {"name": "选择困难蝶", "domain": "心理", "traits": "在你做选择时飞来飞去让你更纠结",
     "origin": "庄子梦里那只蝴蝶,醒来后不确定自己是蝴蝶还是人,从此选择困难",
     "habit": "菜单超过10个选择时它就会出现", "weakness": "抛硬币——它最受不了随机决定"},
    {"name": "深夜emo猫", "domain": "心理", "traits": "凌晨两点准时出现的黑猫,让你开始思考人生",
     "origin": "一只在深夜里思考存在主义的流浪猫,它的哀鸣能让所有人感伤",
     "habit": "在你最脆弱的时候跳上你的键盘打出一段感伤的话",
     "weakness": "撸它!撸5分钟它就会呼噜呼噜地睡着"},

    # 美食类
    {"name": "碳水恶魔", "domain": "美食", "traits": "让你无法抗拒米饭面条面包的诱惑",
     "origin": "古代饥荒时人们对粮食的渴望凝聚成的精灵",
     "habit": "在你减肥时让碳水变得格外香", "weakness": "生酮饮食者对它免疫"},
    {"name": "奶茶附体灵", "domain": "美食", "traits": "让你每隔两小时就想喝一杯奶茶",
     "origin": "第一杯珍珠奶茶里的珍珠吸多了灵魂,从杯底浮上来就成了妖怪",
     "habit": "会在你渴的时候把白水变成奶茶的味道(幻觉)",
     "weakness": "喝纯黑咖啡可以驱散它2小时"},
    {"name": "外卖延迟兽", "domain": "美食", "traits": "专门让外卖小哥迷路的小怪兽",
     "origin": "一个永远等不到外卖的宅男的怨念集合体",
     "habit": "在你最饿的时候让外卖绕最远的路", "weakness": "自取订单它没办法"},
    {"name": "减肥反弹妖", "domain": "美食", "traits": "你减掉多少它就帮你长回来多少",
     "origin": "脂肪细胞的守护神,认为每个脂肪细胞都有存在的权利",
     "habit": "在你称体重前把数字偷偷调高1斤", "weakness": "坚持运动90天以上它就会永久离开"},

    # 自然元素类
    {"name": "雨天赖床灵", "domain": "自然", "traits": "下雨天让你格外不想起床的温柔力量",
     "origin": "雨水打在屋顶上的声音里的精灵,它觉得那是最好的催眠曲",
     "habit": "下雨天把被窝的温度调到最舒服的26度", "weakness": "晴天自动消失"},
    {"name": "落叶归家精", "domain": "自然", "traits": "让落叶偏偏飘到刚扫好的那堆里",
     "origin": "一只喜欢干净但又有强迫症的秋风精灵",
     "habit": "你刚扫完地它就吹一口风,落叶又回来了",
     "weakness": "等它玩累了(大约1小时后)落叶就不会再动了"},
    {"name": "雾霾隐者", "domain": "自然", "traits": "浓雾中若隐若现的神秘身影",
     "origin": "一位喜欢在雾中独行的古代隐士,灵魂融入了雾气",
     "habit": "在雾中为你指路——但有一半概率指错",
     "weakness": "强风会把雾和它一起吹散"},
    {"name": "月光失眠兔", "domain": "自然", "traits": "满月之夜让你睡不着觉的兔子",
     "origin": "嫦娥的玉兔偷跑下凡,觉得月圆时人间更热闹不想让人睡",
     "habit": "满月时在你的枕头上蹦蹦跳跳",
     "weakness": "拉上遮光窗帘它就以为月亮消失了"},

    # 学习工作类
    {"name": "考试锦鲤", "domain": "学习", "traits": "转发就能逢考必过的网络神兽",
     "origin": "一条在校园池塘里听了四年课的鲤鱼,虽然没成精但学会了转运",
     "habit": "考试周在朋友圈疯狂出现", "weakness": "不复习只转发是没用的"},
    {"name": "PPT美化仙", "domain": "学习", "traits": "能让丑PPT瞬间变好看的仙子",
     "origin": "一个设计系学生毕业后对PPT的执念化身",
     "habit": "偷偷帮你对齐文字和调整配色", "weakness": "遇到WordArt(艺术字)会暴走"},
    {"name": "摸鱼守护神", "domain": "工作", "traits": "保护你在上班摸鱼时不被发现的守护灵",
     "origin": "一个在办公室摸鱼10年从未被抓的传奇上班族死后升格为神",
     "habit": "老板走过来时帮你瞬间切换到工作界面",
     "weakness": "它也只能帮到这了,你自己不机灵它也没辙"},
    {"name": "加班怨灵", "domain": "工作", "traits": "在空无一人的办公室里游荡的怨念",
     "origin": "一个连续加班一个月猝死的程序员,灵魂被困在了代码里",
     "habit": "深夜办公室的灯会自己亮起来,键盘自己打字",
     "weakness": "按时下班就不会遇到它"},

    # 交通出行类
    {"name": "红灯焦虑怪", "domain": "交通", "traits": "让你在等红灯时格外焦躁的小恶魔",
     "origin": "一个急性子出租车司机的怨念,觉得全世界的红灯都在针对他",
     "habit": "在你赶时间时让所有灯都变红", "weakness":":听音乐能安抚它"},
    {"name": "地铁挤挤仙", "domain": "交通", "traits": "让地铁变得更挤的隐形力量",
     "origin": "东京地铁推手职业的怨灵,觉得推人不够狠",
     "habit": "明明车厢很空但它一施法大家都往你这挤",
     "weakness":":错峰出行它就找不到你"},
    {"name": "导航迷路精", "domain": "交通", "traits": "让导航软件带你绕路的调皮精灵",
     "origin": "一个路痴的执念——既然自己找不到路,那就让所有人都找不到",
     "habit": "在你快到目的地时突然说'请在前方掉头'",
     "weakness":":看路牌和问路人它都管不了"},
]

# ============ 提问模板 ============
QUESTION_TEMPLATES = [
    # 基础问法
    "「{name}」有什么有趣的传说吗",
    "给我讲讲「{name}」的故事",
    "「{name}」是怎么来的",
    "你知道「{name}」吗",
    "「{name}」是什么",
    # 好奇问法
    "「{name}」真的存在吗",
    "「{name}」有什么弱点",
    "怎么对付「{name}」",
    "「{name}」平时都干什么",
    "为什么会有「{name}」",
    # 互动问法
    "我好像遇到了「{name}」怎么办",
    "「{name}」会不会害人啊",
    "「{name}」能当宠物养吗",
    "「{name}」和{rival}哪个更厉害",
    "我好害怕「{name}」",
    # 创意问法
    "如果「{name}」和{friend}合作会怎样",
    "「{name}」的日常一天是什么样的",
    "你能模仿「{name}」说话吗",
    "「{name}」最讨厌什么",
    "「{name}」有什么不为人知的秘密",
]

# ============ 回答风格 ============

def style_storytelling(m):
    """正经讲述风格"""
    return (
        f"关于「{m['name']}」,这可是一个很有来历的{m['domain']}系妖怪!\n"
        f"它的起源说来话长——{m['origin']}。\n"
        f"它的习性也很特别:{m['habit']}。\n"
        f"不过别太担心,它也是有弱点的——{m['weakness']}。"
    )

def style_humorous(m):
    """搞笑吐槽风格"""
    responses = [
        f"哈哈哈说到「{m['name']}」我可就不困了!这货的来历就很离谱——{m['origin']}。你敢信?{m['habit']},真是没救了。不过你要想治它,{m['weakness']},简单粗暴!",
        f"「{m['name']}」?这玩意儿存在本身就是个笑话。{m['origin']}——就这?就这也能成精?{m['habit']},属于是无聊到极致的妖怪了。弱点也很搞笑:{m['weakness']},笑死。",
        f"等等你问「{m['name']}」?让我先笑一会儿……好了。它的诞生故事是这样的:{m['origin']}。然后它的日常就是{m['habit']}。最搞的是它的弱点——{m['weakness']},是不是很离谱?",
    ]
    return random.choice(responses)

def style_warm(m):
    """温馨治愈风格"""
    return (
        f"「{m['name']}」啊……其实它并不是什么可怕的妖怪呢。\n"
        f"它的诞生有着温柔的原因:{m['origin']}。\n"
        f"你仔细想想,{m['habit']}——是不是有点可爱?\n"
        f"如果你遇到了它,不用害怕。{m['weakness']}。它只是在用自己的方式,陪伴着这个世界。"
    )

def style_mystery(m):
    """神秘风格"""
    return (
        f"「{m['name']}」……你确定你想知道吗?\n"
        f"传说在很久以前,{m['origin']}。从此它就游荡在人间。\n"
        f"据说{m['habit']},很多人都有过这样的经历,只是不愿意承认。\n"
        f"若要驱散它,唯有{m['weakness']}——但你真的想赶走它吗?"
    )

def style_casual(m):
    """日常聊天风格"""
    return (
        f"啊「{m['name']}」!我太熟了哈哈哈\n"
        f"它的来头是这样的:{m['origin']}。有点扯对吧?\n"
        f"平时就{m['habit']},害不害人不好说但确实挺烦的。\n"
        f"想对付它的话,{m['weakness']}就行,试试看~"
    )

STYLES = [style_storytelling, style_humorous, style_warm, style_mystery, style_casual]

# ============ 多轮对话模板 ============
MULTI_TURN_TEMPLATES = [
    # 追问弱点
    {
        "turns": [
            {"role": "user", "content": "「{name}」有什么有趣的传说吗"},
            {"role": "assistant", "content": "{intro}"},
            {"role": "user", "content": "那它有什么弱点吗"},
            {"role": "assistant", "content": "「{name}」的弱点其实挺有意思的——{weakness}。知道了这个,基本上就不用怕它了。"},
        ]
    },
    # 追问日常
    {
        "turns": [
            {"role": "user", "content": "给我讲讲「{name}」"},
            {"role": "assistant", "content": "{intro}"},
            {"role": "user", "content": "它平时都在干嘛啊"},
            {"role": "assistant", "content": "「{name}」的日常嘛……{habit}。听起来是不是又烦又好笑?"},
        ]
    },
    # 假装遇到
    {
        "turns": [
            {"role": "user", "content": "我好像遇到了「{name}」"},
            {"role": "assistant", "content": "你确定?!「{name}」可是{traits}的妖怪!你遇到什么状况了?"},
            {"role": "user", "content": "就是{symptom}"},
            {"role": "assistant", "content": "那八成就是它了!别慌,{weakness}就能搞定。下次注意点~"},
        ]
    },
    # 两个鬼怪比较
    {
        "turns": [
            {"role": "user", "content": "「{name}」和「{rival}」哪个更厉害"},
            {"role": "assistant", "content": "这俩不是一路的啊!「{name}」是{domain}系的,擅长{traits};「{rival}」是另一回事。要说麻烦程度的话……我选「{name}」,因为{habit},防不胜防。"},
        ]
    },
    # 想养
    {
        "turns": [
            {"role": "user", "content": "「{name}」能当宠物养吗"},
            {"role": "assistant", "content": "你想养「{name}」?!{traits}的妖怪你也敢养?\n也不是完全不行,只要记住{weakness},它就拿你没办法。但别怪我没提醒你,{habit}——你确定能忍?"},
        ]
    },
]

# ============ 生成单轮数据 ============
def generate_single_turn(m):
    """生成单轮对话"""
    q_template = random.choice(QUESTION_TEMPLATES)
    # 处理模板中的变量
    question = q_template.format(
        name=m["name"],
        rival=random.choice([x["name"] for x in MONSTERS if x["name"] != m["name"]]),
        friend=random.choice([x["name"] for x in MONSTERS if x["name"] != m["name"]]),
    )

    style = random.choice(STYLES)
    answer = style(m)

    return {
        "messages": [
            {"role": "system", "content": "你是一个富有创意和幽默感的助手,擅长用生动有趣的方式讲述各种鬼怪的传说故事。你的回答风格多变,有时正经讲述,有时搞笑吐槽,有时温馨治愈。"},
            {"role": "user", "content": question},
            {"role": "assistant", "content": answer},
        ]
    }

# ============ 生成多轮数据 ============
def generate_multi_turn(m):
    """生成多轮对话"""
    template = random.choice(MULTI_TURN_TEMPLATES)
    rival = random.choice([x for x in MONSTERS if x["name"] != m["name"]])

    # 预生成一个intro
    style = random.choice(STYLES)
    intro = style(m)

    messages = [
        {
            "role": "system",
            "content": "你是一个富有创意和幽默感的助手,擅长用生动有趣的方式讲述各种鬼怪的传说故事。你的回答风格多变,有时正经讲述,有时搞笑吐槽,有时温馨治愈。"
        }
    ]

    for turn in template["turns"]:
        content = turn["content"].format(
            name=m["name"],
            intro=intro,
            traits=m["traits"],
            habit=m["habit"],
            weakness=m["weakness"],
            domain=m["domain"],
            rival=rival["name"],
            symptom=random.choice(["东西莫名其妙就不见了", "感觉有什么东西在盯着我", "怎么也集中不了注意力", "总觉得有什么不对劲"]),
        )
        messages.append({"role": turn["role"], "content": content})

    return {"messages": messages}

# ============ 主生成逻辑 ============
random.seed(42)

dataset = []

# 每个鬼怪生成多种单轮对话(3-5条)
for m in MONSTERS:
    n_single = random.randint(3, 5)
    for _ in range(n_single):
        dataset.append(generate_single_turn(m))

# 每个鬼怪生成1-2条多轮对话
for m in MONSTERS:
    n_multi = random.randint(1, 2)
    for _ in range(n_multi):
        dataset.append(generate_multi_turn(m))

# 打乱
random.shuffle(dataset)

# ============ 分割训练集和验证集 ============
split_idx = int(len(dataset) * 0.9)
train_data = dataset[:split_idx]
val_data = dataset[split_idx:]

# ============ 保存 ============
train_path = r"E:\zz\qwen_train\dataset_train.json"
val_path = r"E:\zz\qwen_train\dataset_val.json"
full_path = r"E:\zz\qwen_train\dataset.json"

with open(train_path, "w", encoding="utf-8") as f:
    json.dump(train_data, f, ensure_ascii=False, indent=2)

with open(val_path, "w", encoding="utf-8") as f:
    json.dump(val_data, f, ensure_ascii=False, indent=2)

with open(full_path, "w", encoding="utf-8") as f:
    json.dump(dataset, f, ensure_ascii=False, indent=2)

print("=" * 60)
print("🎉 数据集生成完成!")
print("=" * 60)
print(f"总数据量: {len(dataset)} 条")
print(f"  训练集: {len(train_data)} 条 ({train_path})")
print(f"  验证集: {len(val_data)} 条 ({val_path})")
print(f"  完整集: {len(dataset)} 条 ({full_path})")
print(f"\n鬼怪种类: {len(MONSTERS)} 种")
print(f"提问模板: {len(QUESTION_TEMPLATES)} 种")
print(f"回答风格: {len(STYLES)} 种")
print(f"多轮模板: {len(MULTI_TURN_TEMPLATES)} 种")

# 统计对话轮数分布
turn_counts = {}
for item in dataset:
    n = len([m for m in item["messages"] if m["role"] != "system"])
    turn_counts[n] = turn_counts.get(n, 0) + 1
print(f"\n对话轮数分布:")
for k in sorted(turn_counts.keys()):
    print(f"  {k}轮对话: {turn_counts[k]} 条")

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367

逐行来拆解:

# 第10-11行:导入库

import json
import random
1
2
  • json:把Python对象序列化成JSON文件,保存数据集用
  • random:生成随机数,用来随机选模板、选风格、打乱数据顺序

# 第13-130行:鬼怪角色库 MONSTERS

MONSTERS = [
    {"name": "WiFi幽灵", "domain": "科技", "traits": "...", "origin": "...", "habit": "...", "weakness": "..."},
    ...
]
1
2
3
4

一个列表,装了30个字典,每个字典描述一个鬼怪,5个字段:

  • name:鬼怪名字,也是用户提问时的关键词
  • domain:所属领域(科技/家居/心理/美食/自然/学习/工作/交通),回答时会说"xx系的妖怪"
  • traits:核心特征,一句话概括它是什么样
  • origin:身世来历,回答中"怎么来的"部分
  • habit:日常习性,回答中"平时干什么"部分
  • weakness:弱点,回答中"怎么对付"部分

这5个字段就是后续所有回答模板的"原材料",模板只是排列组合的方式不同。


# 第132-158行:提问模板 QUESTION_TEMPLATES

QUESTION_TEMPLATES = [
    "「{name}」有什么有趣的传说吗",
    ...
    "「{name}」和{rival}哪个更厉害",
    ...
]
1
2
3
4
5
6

21个字符串模板,用{name}{rival}{friend}占位。调用.format()时会用实际的鬼怪名字替换。

分4类:

  • 基础问法(5个):有什么传说、讲讲故事、怎么来的、你知道吗、是什么
  • 好奇问法(5个):真的存在吗、有什么弱点、怎么对付、平时干什么、为什么会有
  • 互动问法(5个):好像遇到了、会不会害人、能当宠物养吗、和xx谁厉害、好害怕
  • 创意问法(6个):合作会怎样、日常一天、模仿说话、最讨厌什么、不为人知的秘密

# 第162-169行:style_storytelling 正经讲述风格

def style_storytelling(m):
    return (
        f"关于「{m['name']}」,这可是一个很有来历的{m['domain']}系妖怪!\n"
        f"它的起源说来话长——{m['origin']}。\n"
        ...
    )
1
2
3
4
5
6

参数m是一个鬼怪字典。用f-string把5个字段按固定结构拼成一段正经讲述。返回一个字符串。


# 第171-178行:style_humorous 搞笑吐槽风格

def style_humorous(m):
    responses = [
        f"哈哈哈说到「{m['name']}」我可就不困了!...",
        f"「{m['name']}」?这玩意儿存在本身就是个笑话。...",
        f"等等你问「{m['name']}」?让我先笑一会儿……...",
    ]
    return random.choice(responses)
1
2
3
4
5
6
7

和正经风格不同,这里预写了3种搞笑变体,用random.choice随机选一个。同一个鬼怪每次调用可能得到不同的搞笑表达。这增加了数据的多样性。


# 第180-187行:style_warm 温馨治愈风格

def style_warm(m):
    return (
        f"「{m['name']}」啊……其实它并不是什么可怕的妖怪呢。\n"
        ...
    )
1
2
3
4
5

和storytelling类似,固定结构,语气温柔。"它只是在用自己的方式,陪伴着这个世界"——治愈系收尾。


# 第189-196行:style_mystery 神秘风格

def style_mystery(m):
    return (
        f"「{m['name']}」……你确定你想知道吗?\n"
        ...
        f"但你真的想赶走它吗?"
    )
1
2
3
4
5
6

用省略号和反问制造悬念感,结尾反转"你真的想赶走它吗"。


# 第198-205行:style_casual 日常聊天风格

def style_casual(m):
    return (
        f"啊「{m['name']}」!我太熟了哈哈哈\n"
        ...
    )
1
2
3
4
5

口语化,带"哈哈哈""试试看~",像朋友聊天。


# 第207行:风格列表

STYLES = [style_storytelling, style_humorous, style_warm, style_mystery, style_casual]
1

把5个函数对象放进列表。Python里函数是一等公民,可以当变量传来传去。后续用random.choice(STYLES)(m)就能随机选一个风格并调用。


# 第210-252行:多轮对话模板 MULTI_TURN_TEMPLATES

5个模板,每个是一个字典,包含一个turns列表,列表里每个元素是{"role": "user/assistant", "content": "..."}

  • 追问弱点(4轮):问传说→介绍→问弱点→回答弱点
  • 追问日常(4轮):讲讲→介绍→问日常→回答日常
  • 假装遇到(4轮):说遇到了→助手惊讶反问→描述症状→给出对策
  • 鬼怪比较(2轮):问谁厉害→对比分析
  • 想当宠物(2轮):问能养吗→吐槽+提醒

模板里用{name}{intro}{weakness}等占位,运行时替换。{intro}会用上面单轮回答的内容填充,实现多轮对话的连贯性。


# 第254-274行:generate_single_turn 生成单轮对话

def generate_single_turn(m):
    q_template = random.choice(QUESTION_TEMPLATES)
    question = q_template.format(
        name=m["name"],
        rival=random.choice([x["name"] for x in MONSTERS if x["name"] != m["name"]]),
        friend=random.choice([x["name"] for x in MONSTERS if x["name"] != m["name"]]),
    )
    style = random.choice(STYLES)
    answer = style(m)
    return {
        "messages": [
            {"role": "system", "content": "..."},
            {"role": "user", "content": question},
            {"role": "assistant", "content": answer},
        ]
    }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

逐行:

  1. 随机选一个提问模板
  2. .format()替换模板中的占位符,rivalfriend从其他鬼怪中随机选(排除了自己)
  3. 随机选一种回答风格
  4. 调用风格函数生成回答
  5. 返回ChatML格式的消息列表,包含system/user/assistant三条

# 第277-306行:generate_multi_turn 生成多轮对话

def generate_multi_turn(m):
    template = random.choice(MULTI_TURN_TEMPLATES)
    rival = random.choice([x for x in MONSTERS if x["name"] != m["name"]])
    style = random.choice(STYLES)
    intro = style(m)
    messages = [{"role": "system", "content": "..."}]
    for turn in template["turns"]:
        content = turn["content"].format(
            name=m["name"], intro=intro, traits=m["traits"], ...
        )
        messages.append({"role": turn["role"], "content": content})
    return {"messages": messages}
1
2
3
4
5
6
7
8
9
10
11
12

逐行:

  1. 随机选一个多轮模板
  2. 随机选一个"对手"鬼怪(用于比较类对话)
  3. 先用随机风格生成一个intro(单轮回答),填到多轮模板的{intro}占位里
  4. 初始化消息列表,先放system消息
  5. 遍历模板的每一轮,用.format()替换占位符,拼到消息列表里
  6. symptom从4种预设症状里随机选

# 第308-326行:主生成逻辑

random.seed(42)
dataset = []

for m in MONSTERS:
    n_single = random.randint(3, 5)
    for _ in range(n_single):
        dataset.append(generate_single_turn(m))

for m in MONSTERS:
    n_multi = random.randint(1, 2)
    for _ in range(n_multi):
        dataset.append(generate_multi_turn(m))

random.shuffle(dataset)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
  • random.seed(42):固定随机种子,每次运行生成相同结果,保证可复现
  • 每个鬼怪生成3-5条单轮对话,30个鬼怪大约产生120条
  • 每个鬼怪生成1-2条多轮对话,30个鬼怪大约产生45条
  • 最后shuffle打乱,避免同类数据扎堆影响训练

# 第328-331行:分割训练集和验证集

split_idx = int(len(dataset) * 0.9)
train_data = dataset[:split_idx]
val_data = dataset[split_idx:]
1
2
3

90%训练 + 10%验证。切片操作,前90%给训练,后10%给验证。


# 第333-345行:保存文件

train_path = r"E:\zz\qwen_train\dataset_train.json"
val_path = r"E:\zz\qwen_train\dataset_val.json"
full_path = r"E:\zz\qwen_train\dataset.json"

with open(train_path, "w", encoding="utf-8") as f:
    json.dump(train_data, f, ensure_ascii=False, indent=2)
...
1
2
3
4
5
6
7
  • r"...":raw string,Windows路径不用转义反斜杠
  • ensure_ascii=False:允许中文字符直接写入,不会被转成\uXXXX
  • indent=2:缩进2空格,方便人阅读
  • 保存3个文件:训练集、验证集、完整集

# 第347-366行:打印统计信息

print(f"总数据量: {len(dataset)} 条")
...

turn_counts = {}
for item in dataset:
    n = len([m for m in item["messages"] if m["role"] != "system"])
    turn_counts[n] = turn_counts.get(n, 0) + 1
1
2
3
4
5
6
7
  • 打印数据量、鬼怪种类、模板数、风格数
  • 统计对话轮数分布:遍历每条数据,数非system消息的个数(2=单轮,4=双轮),用字典计数
  • dict.get(key, 0):key不存在时返回0,避免KeyError

# 知识点总结

知识点 说明
f-string f"..."格式化字符串,{变量}直接嵌入值
函数作为一等公民 函数可以放进列表、当参数传递,random.choice(STYLES)(m)
字典列表结构 鬼怪用字典存属性,多个字典放列表里,方便遍历和随机选取
模板模式 字符串里用{name}占位,.format()替换,实现数据驱动生成
random.seed() 固定随机种子保证可复现,调试时很重要
random.choice() 从列表随机选一个元素
random.randint(a, b) 随机整数,含a和b
列表推导式 [x for x in MONSTERS if x["name"] != m["name"]],过滤+变换一行搞定
列表切片 dataset[:split_idx]取前半,dataset[split_idx:]取后半
json.dump() Python对象→JSON文件,ensure_ascii=False保留中文,indent美化格式
raw string r"...",反斜杠不转义,Windows路径必备
dict.get() 安全取值,key不存在返回默认值,避免KeyError
ChatML格式 训练数据的对话格式,每条包含system/user/assistant的role和content
数据多样性设计 同一内容多种问法+多种风格+多轮模板,让模型学逻辑而非背答案
训练/验证分割 90/10分割,验证集用来监控过拟合,训练时不可见

# 训练模型

"""
Qwen2.5-1.5B-Instruct LoRA 微调脚本
- 使用1.5B模型
- 训练集/验证集分离
- 验证集监控 + 早停
- 过拟合检测
"""

import os # 操作系统接口,这里实际没用到
import json # 读取JSON格式的训练数据
import torch # PyTorch深度学习框架,模型运行的基础
from dataclasses import dataclass, field # Python数据类装饰器,用来自动生成__init__等方法,dataclass字段的高级配置,比如给list类型设默认值
from transformers import (
    AutoModelForCausalLM, # 自动加载因果语言模型(GPT/Qwen这类"逐字生成"的模型)
    AutoTokenizer, # 自动加载分词器,文字↔数字的翻译官
    TrainingArguments, # 训练参数配置类(这里实际用了SFTConfig替代)
    Trainer, # Hugging Face通用训练器(这里实际用了SFTTrainer替代)
    DataCollatorForSeq2Seq, # 数据整理器,负责padding和组装batch
    EarlyStoppingCallback, # 早停回调,验证loss不再下降时自动停止训练
)
# LoraConfig	LoRA配置类,定义秩、alpha、目标模块等
# get_peft_model	给基础模型套上LoRA适配器
# TaskType	任务类型枚举,CAUSAL_LM表示因果语言模型
from peft import LoraConfig, get_peft_model, TaskType
# SFTTrainer	监督微调训练器,封装了SFT场景的细节
# SFTConfig	SFT专用的训练参数配置类
from trl import SFTTrainer, SFTConfig

# ============ 训练配置 ============
# @dataclass装饰器自动生成__init__、__repr__等方法,不用手写一堆赋值代码。
@dataclass
class TrainConfig:
    # 模型
    model_name: str = r"D:/models/Qwen2.5-0.5B-Instruct/qwen/Qwen2.5-1.5B-Instruct" # 本地模型路径
    output_dir: str = "./qwen_finetuned" # 微调后的LoRA权重保存位置

    # 数据
    train_file: str = "./dataset_train.json"
    val_file: str = "./dataset_val.json"
    max_seq_length: int = 512 # 每条对话最多512个token,超了截断

    # 训练参数
    num_train_epochs: int = 10  # 整个数据集过10遍
    per_device_train_batch_size: int = 1  # 每步只吃1条数据(省显存)
    gradient_accumulation_steps: int = 8  # 累积8步梯度再更新一次参数,等效batch=1×8=8
    learning_rate: float = 3e-4 # 学习率0.0003,LoRA可以用比较高的学习率
    warmup_ratio: float = 0.1 # 前10%的步数做学习率预热,从0逐渐升到目标值
    weight_decay: float = 0.01 # 权重衰减,防止过拟合的正则化手段
    lr_scheduler_type: str = "cosine" # 余弦退火调度,学习率先升后降,像余弦曲线

    # LoRA
    lora_r: int = 32 # LoRA的秩,A矩阵和B矩阵的中间维度。越大表达能力越强,但参数越多
    lora_alpha: int = 16 # 缩放因子,实际缩放倍率=alpha/r=16/32=0.5
    lora_dropout: float = 0.05  # 训练时随机丢弃5%的连接,防过拟合
    # LoRA应用到模型哪些层。q/k/v/o_proj是注意力层的4个投影,gate/up/down_proj是前馈网络的3个投影
    # list类型不能用[]做默认值(所有实例会共享同一个列表),必须用工厂函数每次创建新列表
    lora_target_modules: list = field(default_factory=lambda: [
        "q_proj", "k_proj", "v_proj", "o_proj",
        "gate_proj", "up_proj", "down_proj"
    ])

    # 验证与早停
    eval_strategy: str = "steps" # 按步数间隔验证(另一个选项是"epoch")
    eval_steps: int = 25 # 每训练25步跑一次验证
    load_best_model_at_end: bool = True # 训练结束后自动加载验证loss最低的那个checkpoint
    metric_for_best_model: str = "eval_loss" # 用验证loss判断哪个模型最好
    greater_is_better: bool = False # oss越小越好,所以False
    early_stopping_patience: int = 3  # 验证loss连续3次评估没有下降超过threshold就停

    # 保存
    save_strategy: str = "steps"
    save_steps: int = 25  # 每25步保存一次checkpoint
    save_total_limit: int = 3 # 最多保留3个checkpoint,旧的自动删除,省磁盘

    # 其他
    logging_steps: int = 10 # 每10步打印一次训练信息
    bf16: bool = False  # 用fp16混合精度训练(3060支持fp16,不支持bf16)
    fp16: bool = True
    gradient_checkpointing: bool = True # 梯度检查点,用时间换显存,不存中间激活值而是反向传播时重算
    seed: int = 42 # 随机种子,保证可复现
    report_to: str = "none" # 不上报训练日志到WandB等平台


config = TrainConfig() # 创建配置对象,所有参数用默认值。

# ============ Step 1: 加载Tokenizer ============
print("=" * 80)
print("🎌 Qwen2.5-1.5B-Instruct LoRA 微调 - 鬼怪对话机器人")
print("=" * 80)

print("\n【步骤 1/6】加载Tokenizer...")
# 根据模型路径自动识别并加载对应的分词器
tokenizer = AutoTokenizer.from_pretrained(
    config.model_name,
    trust_remote_code=True, # 允许执行模型仓库里的自定义代码(Qwen需要)
    padding_side="right", # padding加在序列右边。左padding适合生成任务(左对齐),右padding适合训练
)
# 有些模型没有定义padding token,用eos_token(结束符)代替
if tokenizer.pad_token is None:
    tokenizer.pad_token = tokenizer.eos_token
print(f"✅ Tokenizer 加载完成,词表大小: {len(tokenizer)}")

# ============ Step 2: 加载数据集 ============
print("\n【步骤 2/6】加载数据集...")

# 分别读取训练集和验证集的JSON文件。
with open(config.train_file, "r", encoding="utf-8") as f:
    train_raw = json.load(f)
with open(config.val_file, "r", encoding="utf-8") as f:
    val_raw = json.load(f)

print(f"📂 训练集: {len(train_raw)} 条 ({config.train_file})")
print(f"📂 验证集: {len(val_raw)} 条 ({config.val_file})")

# Tokenization
def tokenize_function(examples):
    texts = []
    for messages in examples["messages"]:
        # 把ChatML格式的消息列表(system/user/assistant)拼接成模型能理解的文本,带<|im_start|>和<|im_end|>标记
        text = tokenizer.apply_chat_template(
            messages,
            tokenize=False,  # 先不tokenize,只拼接文本
            add_generation_prompt=False # 不加"请生成"的提示(训练不需要,推理时才加)
        )
        texts.append(text)
    # 批量tokenize,返回input_ids和attention_mask
    tokenized = tokenizer(
        texts,
        truncation=True, # 超长序列截断
        max_length=config.max_seq_length
    )
    # Causal LM: labels = input_ids,padding位置设为-100不参与loss计算
    # labels = input_ids的副本,但padding位置设为-100。PyTorch的CrossEntropyLoss遇到-100会跳过,不计入loss计算。这就是"只对有效token计算loss"
    tokenized["labels"] = [
        [(t if t != tokenizer.pad_token_id else -100) for t in ids]
        for ids in tokenized["input_ids"]
    ]
    return tokenized

from datasets import Dataset

# 把Python列表转成Hugging Face Dataset对象,支持高效的map/filter操作
train_dataset = Dataset.from_list(train_raw)
val_dataset = Dataset.from_list(val_raw)

print("🔄 Tokenization 训练集...")
train_dataset = train_dataset.map(
    tokenize_function,
    batched=True,
    # 删除原始的"messages"列,只保留tokenize后的input_ids/attention_mask/labels
    remove_columns=train_dataset.column_names,
)
print("🔄 Tokenization 验证集...")
val_dataset = val_dataset.map(
    tokenize_function,
    batched=True,
    remove_columns=val_dataset.column_names,
)
print("✅ Tokenization 完成")

# ============ Step 3: 加载模型 ============
print("\n【步骤 3/6】加载模型...")
print(f"📦 模型: {config.model_name}")

model = AutoModelForCausalLM.from_pretrained(
    config.model_name,
    device_map="auto", # 自动分配模型到GPU/CPU,按可用显存智能分配
    torch_dtype=torch.float16, # 半精度加载,显存减半
    trust_remote_code=True,
)

# 检查显存
# emory_allocated()是当前已用显存,total_memory是GPU总显存,除以1024³转GB
if torch.cuda.is_available():
    gpu_mem = torch.cuda.memory_allocated() / 1024**3
    gpu_total = torch.cuda.get_device_properties(0).total_memory / 1024**3
    print(f"📊 显存使用: {gpu_mem:.2f} GB / {gpu_total:.2f} GB")

print("✅ 模型加载成功!")

# ============ Step 4: 配置 LoRA ============
print("\n【步骤 4/6】配置 LoRA...")

lora_config = LoraConfig(
    task_type=TaskType.CAUSAL_LM, # 告诉LoRA这是因果语言模型任务
    r=config.lora_r,
    lora_alpha=config.lora_alpha,
    lora_dropout=config.lora_dropout,
    target_modules=config.lora_target_modules,
    bias="none", # 不训练偏置参数,只训练LoRA的A/B矩阵
)

# LoRA + gradient_checkpointing 必须加这行,否则反向传播报错
# 关键! 让输入嵌入层需要梯度。因为gradient_checkpointing不保存中间激活值,反向传播时需要从输入重新计算,如果输入不需要梯度就会报"does not require grad"的错误
model.enable_input_require_grads()
# 把LoRA适配器套到基础模型上,冻结原始参数,只训练LoRA部分
model = get_peft_model(model, lora_config)
# 打印可训练参数占比(约2-3%)
model.print_trainable_parameters()
print("✅ LoRA 配置完成!")

# ============ Step 5: 配置训练参数 ============
print("\n【步骤 5/6】配置训练参数...")

# 计算总步数
# 有效batch = 1 × 8 = 8
# 总步数 = (样本数 / 有效batch) × epoch数
effective_batch_size = config.per_device_train_batch_size * config.gradient_accumulation_steps
total_steps = (len(train_dataset) // effective_batch_size) * config.num_train_epochs

print(f"有效 batch size: {effective_batch_size}")
print(f"总训练步数: ~{total_steps}")
print(f"每 {config.eval_steps} 步验证一次")
print(f"早停耐心: {config.early_stopping_patience} 次验证不降就停")

training_args = SFTConfig(
    output_dir=config.output_dir,
    num_train_epochs=config.num_train_epochs,
    per_device_train_batch_size=config.per_device_train_batch_size,
    gradient_accumulation_steps=config.gradient_accumulation_steps,
    learning_rate=config.learning_rate,
    warmup_ratio=config.warmup_ratio,
    weight_decay=config.weight_decay,
    lr_scheduler_type=config.lr_scheduler_type,
    logging_steps=config.logging_steps,
    save_strategy=config.save_strategy,
    save_steps=config.save_steps,
    save_total_limit=config.save_total_limit,
    eval_strategy=config.eval_strategy,
    eval_steps=config.eval_steps,
    load_best_model_at_end=config.load_best_model_at_end,
    metric_for_best_model=config.metric_for_best_model,
    greater_is_better=config.greater_is_better,
    bf16=config.bf16,
    fp16=config.fp16,
    gradient_checkpointing=config.gradient_checkpointing,
    seed=config.seed,
    report_to=config.report_to,
    max_seq_length=config.max_seq_length,
    # SFTConfig继承自TrainingArguments,多了一些SFT专属参数。
    # dataset_kwargs={"skip_prepare_dataset": True}告诉SFTTrainer不要自己处理数据集(我们已经手动tokenize好了)。
    dataset_kwargs={"skip_prepare_dataset": True},
)

# 数据整理器:把多条数据拼成一个batch,自动padding到batch内最长序列的长度,返回PyTorch张量。
data_collator = DataCollatorForSeq2Seq(
    tokenizer=tokenizer,
    padding=True,
    return_tensors="pt",
)

# 早停回调
early_stopping = EarlyStoppingCallback(
    early_stopping_patience=config.early_stopping_patience,
    early_stopping_threshold=0.001, # oss下降不到0.001也算没降(避免微小的数值波动导致误判)
)

print("✅ 训练参数配置完成!")

# ============ Step 6: 开始训练 ============
print("\n【步骤 6/6】开始训练...")
print("=" * 80)
print("🔥 训练进行中,验证集监控已开启,过拟合自动早停!")
print("=" * 80)

# 把模型、参数、数据、整理器、回调全部组合到Trainer里
trainer = SFTTrainer(
    model=model,
    args=training_args,
    train_dataset=train_dataset,
    eval_dataset=val_dataset,
    data_collator=data_collator,
    callbacks=[early_stopping], # 注册早停回调,每个eval_step后检查是否该停
)

# 启动训练循环——前向传播→算loss→反向传播→更新参数→验证→循环
train_result = trainer.train()

# ============ 训练结果 ============
print("\n" + "=" * 80)
print("🎉 训练完成!")
print("=" * 80)

# 输出训练统计
metrics = train_result.metrics
print(f"训练总步数: {metrics.get('total_flos', 'N/A')}")
print(f"训练耗时: {metrics.get('train_runtime', 'N/A'):.1f}s")
print(f"训练损失: {metrics.get('train_loss', 'N/A'):.4f}")
print(f"每秒样本数: {metrics.get('train_samples_per_second', 'N/A'):.2f}")

# 验证集评估
print("\n📊 在验证集上评估...")
eval_metrics = trainer.evaluate()
print(f"验证集 Loss: {eval_metrics.get('eval_loss', 'N/A'):.4f}")
print(f"验证集困惑度: {torch.exp(torch.tensor(eval_metrics.get('eval_loss', 0))).item():.2f}")

# ============ 保存模型 ============
print("\n💾 保存最佳模型...")
# 保存LoRA权重(只保存增量部分,不是整个模型)
trainer.save_model(config.output_dir)
# 保存tokenizer的词表和配置  
tokenizer.save_pretrained(config.output_dir)
print(f"✅ 模型保存在: {config.output_dir}")
print(f"\n💡 使用以下代码加载微调后的模型:")
print(f"   from peft import PeftModel")
print(f"   base = AutoModelForCausalLM.from_pretrained('{config.model_name}', ...)")
print(f"   model = PeftModel.from_pretrained(base, '{config.output_dir}')")
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307

逐行拆解:

# 第1-7行:文档字符串

"""
Qwen2.5-1.5B-Instruct LoRA 微调脚本
- 使用1.5B模型
- 训练集/验证集分离
- 验证集监控 + 早停
- 过拟合检测
"""
1
2
3
4
5
6
7

模块说明,标注了四个核心特性。


# 第9-22行:导入库

import os
import json
import torch
from dataclasses import dataclass, field
from transformers import (
    AutoModelForCausalLM,
    AutoTokenizer,
    TrainingArguments,
    Trainer,
    DataCollatorForSeq2Seq,
    EarlyStoppingCallback,
)
from peft import LoraConfig, get_peft_model, TaskType
from trl import SFTTrainer, SFTConfig
1
2
3
4
5
6
7
8
9
10
11
12
13
14

逐个说明:

库/类 作用
os 操作系统接口,这里实际没用到
json 读取JSON格式的训练数据
torch PyTorch深度学习框架,模型运行的基础
dataclass Python数据类装饰器,用来自动生成__init__等方法
field dataclass字段的高级配置,比如给list类型设默认值
AutoModelForCausalLM 自动加载因果语言模型(GPT/Qwen这类"逐字生成"的模型)
AutoTokenizer 自动加载分词器,文字↔数字的翻译官
TrainingArguments 训练参数配置类(这里实际用了SFTConfig替代)
Trainer Hugging Face通用训练器(这里实际用了SFTTrainer替代)
DataCollatorForSeq2Seq 数据整理器,负责padding和组装batch
EarlyStoppingCallback 早停回调,验证loss不再下降时自动停止训练
LoraConfig LoRA配置类,定义秩、alpha、目标模块等
get_peft_model 给基础模型套上LoRA适配器
TaskType 任务类型枚举,CAUSAL_LM表示因果语言模型
SFTTrainer 监督微调训练器,封装了SFT场景的细节
SFTConfig SFT专用的训练参数配置类

# 第24-76行:训练配置类 TrainConfig

@dataclass
class TrainConfig:
1
2

@dataclass装饰器自动生成__init____repr__等方法,不用手写一堆赋值代码。

# 模型配置(27-29行)

model_name: str = r"D:/models/Qwen2.5-0.5B-Instruct/qwen/Qwen2.5-1.5B-Instruct"
output_dir: str = "./qwen_finetuned"
1
2
  • model_name:本地模型路径,r"..."是raw string避免转义
  • output_dir:微调后的LoRA权重保存位置

# 数据配置(31-34行)

train_file: str = "./dataset_train.json"
val_file: str = "./dataset_val.json"
max_seq_length: int = 512
1
2
3
  • 训练集和验证集分开的JSON文件
  • max_seq_length=512:每条对话最多512个token,超了截断

# 训练参数(36-43行)

num_train_epochs: int = 10
per_device_train_batch_size: int = 1
gradient_accumulation_steps: int = 8  # 有效batch=8
learning_rate: float = 3e-4
warmup_ratio: float = 0.1
weight_decay: float = 0.01
lr_scheduler_type: str = "cosine"
1
2
3
4
5
6
7
  • num_train_epochs=10:整个数据集过10遍
  • per_device_train_batch_size=1:每步只吃1条数据(省显存)
  • gradient_accumulation_steps=8:累积8步梯度再更新一次参数,等效batch=1×8=8
  • learning_rate=3e-4:学习率0.0003,LoRA可以用比较高的学习率
  • warmup_ratio=0.1:前10%的步数做学习率预热,从0逐渐升到目标值
  • weight_decay=0.01:权重衰减,防止过拟合的正则化手段
  • lr_scheduler_type="cosine":余弦退火调度,学习率先升后降,像余弦曲线

# LoRA配置(45-52行)

lora_r: int = 32
lora_alpha: int = 16
lora_dropout: float = 0.05
lora_target_modules: list = field(default_factory=lambda: [
    "q_proj", "k_proj", "v_proj", "o_proj",
    "gate_proj", "up_proj", "down_proj"
])
1
2
3
4
5
6
7
  • lora_r=32:LoRA的秩,A矩阵和B矩阵的中间维度。越大表达能力越强,但参数越多
  • lora_alpha=16:缩放因子,实际缩放倍率=alpha/r=16/32=0.5
  • lora_dropout=0.05:训练时随机丢弃5%的连接,防过拟合
  • lora_target_modules:LoRA应用到模型哪些层。q/k/v/o_proj是注意力层的4个投影,gate/up/down_proj是前馈网络的3个投影
  • field(default_factory=lambda: [...]):list类型不能用[]做默认值(所有实例会共享同一个列表),必须用工厂函数每次创建新列表

# 验证与早停(54-60行)

eval_strategy: str = "steps"
eval_steps: int = 25
load_best_model_at_end: bool = True
metric_for_best_model: str = "eval_loss"
greater_is_better: bool = False
early_stopping_patience: int = 3
1
2
3
4
5
6
  • eval_strategy="steps":按步数间隔验证(另一个选项是"epoch")
  • eval_steps=25:每训练25步跑一次验证
  • load_best_model_at_end=True:训练结束后自动加载验证loss最低的那个checkpoint
  • metric_for_best_model="eval_loss":用验证loss判断哪个模型最好
  • greater_is_better=False:loss越小越好,所以False
  • early_stopping_patience=3:验证loss连续3次评估没有下降超过threshold就停

# 保存配置(62-65行)

save_strategy: str = "steps"
save_steps: int = 25
save_total_limit: int = 3
1
2
3
  • 每25步保存一次checkpoint
  • save_total_limit=3:最多保留3个checkpoint,旧的自动删除,省磁盘

# 其他配置(67-73行)

logging_steps: int = 10
bf16: bool = False
fp16: bool = True
gradient_checkpointing: bool = True
seed: int = 42
report_to: str = "none"
1
2
3
4
5
6
  • logging_steps=10:每10步打印一次训练信息
  • bf16=False, fp16=True:用fp16混合精度训练(3060支持fp16,不支持bf16)
  • gradient_checkpointing=True:梯度检查点,用时间换显存,不存中间激活值而是反向传播时重算
  • seed=42:随机种子,保证可复现
  • report_to="none":不上报训练日志到WandB等平台

# 第76行:实例化配置

config = TrainConfig()
1

创建配置对象,所有参数用默认值。


# 第78-91行:Step 1 加载Tokenizer

tokenizer = AutoTokenizer.from_pretrained(
    config.model_name,
    trust_remote_code=True,
    padding_side="right",
)
if tokenizer.pad_token is None:
    tokenizer.pad_token = tokenizer.eos_token
1
2
3
4
5
6
7
  • AutoTokenizer.from_pretrained():根据模型路径自动识别并加载对应的分词器
  • trust_remote_code=True:允许执行模型仓库里的自定义代码(Qwen需要)
  • padding_side="right":padding加在序列右边。左padding适合生成任务(左对齐),右padding适合训练
  • pad_token检查:有些模型没有定义padding token,用eos_token(结束符)代替

# 第93-137行:Step 2 加载和Tokenize数据

# 读取JSON(96-99行)

with open(config.train_file, "r", encoding="utf-8") as f:
    train_raw = json.load(f)
with open(config.val_file, "r", encoding="utf-8") as f:
    val_raw = json.load(f)
1
2
3
4

分别读取训练集和验证集的JSON文件。

# tokenize_function(105-118行)

def tokenize_function(examples):
    texts = []
    for messages in examples["messages"]:
        text = tokenizer.apply_chat_template(
            messages, tokenize=False, add_generation_prompt=False
        )
        texts.append(text)
    tokenized = tokenizer(texts, truncation=True, max_length=config.max_seq_length)
    tokenized["labels"] = [
        [(t if t != tokenizer.pad_token_id else -100) for t in ids]
        for ids in tokenized["input_ids"]
    ]
    return tokenized
1
2
3
4
5
6
7
8
9
10
11
12
13

逐行:

  1. examples["messages"]:datasets库map时batched=True,传入的是一个batch的消息列表
  2. apply_chat_template:把ChatML格式的消息列表(system/user/assistant)拼接成模型能理解的文本,带<|im_start|><|im_end|>标记
  3. tokenize=False:先不tokenize,只拼接文本
  4. add_generation_prompt=False:不加"请生成"的提示(训练不需要,推理时才加)
  5. tokenizer(texts, ...):批量tokenize,返回input_idsattention_mask
  6. truncation=True:超长序列截断
  7. labels生成:labels = input_ids的副本,但padding位置设为-100。PyTorch的CrossEntropyLoss遇到-100会跳过,不计入loss计算。这就是"只对有效token计算loss"

# 创建Dataset和map(120-136行)

from datasets import Dataset

train_dataset = Dataset.from_list(train_raw)
val_dataset = Dataset.from_list(val_raw)

train_dataset = train_dataset.map(
    tokenize_function,
    batched=True,
    remove_columns=train_dataset.column_names,
)
1
2
3
4
5
6
7
8
9
10
  • Dataset.from_list():把Python列表转成Hugging Face Dataset对象,支持高效的map/filter操作
  • .map(tokenize_function, batched=True):批量应用tokenize,逐批处理
  • remove_columns:删除原始的"messages"列,只保留tokenize后的input_ids/attention_mask/labels

# 第139-156行:Step 3 加载模型

model = AutoModelForCausalLM.from_pretrained(
    config.model_name,
    device_map="auto",
    torch_dtype=torch.float16,
    trust_remote_code=True,
)

if torch.cuda.is_available():
    gpu_mem = torch.cuda.memory_allocated() / 1024**3
    gpu_total = torch.cuda.get_device_properties(0).total_memory / 1024**3
    print(f"📊 显存使用: {gpu_mem:.2f} GB / {gpu_total:.2f} GB")
1
2
3
4
5
6
7
8
9
10
11
  • device_map="auto":自动分配模型到GPU/CPU,按可用显存智能分配
  • torch_dtype=torch.float16:半精度加载,显存减半
  • trust_remote_code=True:同tokenizer
  • 显存检查:memory_allocated()是当前已用显存,total_memory是GPU总显存,除以1024³转GB

# 第158-174行:Step 4 配置LoRA

lora_config = LoraConfig(
    task_type=TaskType.CAUSAL_LM,
    r=config.lora_r,
    lora_alpha=config.lora_alpha,
    lora_dropout=config.lora_dropout,
    target_modules=config.lora_target_modules,
    bias="none",
)

model.enable_input_require_grads()
model = get_peft_model(model, lora_config)
model.print_trainable_parameters()
1
2
3
4
5
6
7
8
9
10
11
12
  • TaskType.CAUSAL_LM:告诉LoRA这是因果语言模型任务
  • bias="none":不训练偏置参数,只训练LoRA的A/B矩阵
  • model.enable_input_require_grads()关键! 让输入嵌入层需要梯度。因为gradient_checkpointing不保存中间激活值,反向传播时需要从输入重新计算,如果输入不需要梯度就会报"does not require grad"的错误
  • get_peft_model():把LoRA适配器套到基础模型上,冻结原始参数,只训练LoRA部分
  • print_trainable_parameters():打印可训练参数占比(约2-3%)

# 第176-227行:Step 5 配置训练参数

# 计算总步数(179-181行)

effective_batch_size = config.per_device_train_batch_size * config.gradient_accumulation_steps
total_steps = (len(train_dataset) // effective_batch_size) * config.num_train_epochs
1
2
  • 有效batch = 1 × 8 = 8
  • 总步数 = (样本数 / 有效batch) × epoch数

# SFTConfig(188-213行)

training_args = SFTConfig(
    output_dir=config.output_dir,
    ...
    dataset_kwargs={"skip_prepare_dataset": True},
)
1
2
3
4
5

SFTConfig继承自TrainingArguments,多了一些SFT专属参数。dataset_kwargs={"skip_prepare_dataset": True}告诉SFTTrainer不要自己处理数据集(我们已经手动tokenize好了)。

# DataCollatorForSeq2Seq(215-219行)

data_collator = DataCollatorForSeq2Seq(
    tokenizer=tokenizer,
    padding=True,
    return_tensors="pt",
)
1
2
3
4
5

数据整理器:把多条数据拼成一个batch,自动padding到batch内最长序列的长度,返回PyTorch张量。

# EarlyStoppingCallback(222-225行)

early_stopping = EarlyStoppingCallback(
    early_stopping_patience=config.early_stopping_patience,
    early_stopping_threshold=0.001,
)
1
2
3
4
  • patience=3:连续3次验证loss没有下降就停
  • threshold=0.001:loss下降不到0.001也算没降(避免微小的数值波动导致误判)

# 第229-244行:Step 6 开始训练

trainer = SFTTrainer(
    model=model,
    args=training_args,
    train_dataset=train_dataset,
    eval_dataset=val_dataset,
    data_collator=data_collator,
    callbacks=[early_stopping],
)

train_result = trainer.train()
1
2
3
4
5
6
7
8
9
10
  • 把模型、参数、数据、整理器、回调全部组合到Trainer里
  • callbacks=[early_stopping]:注册早停回调,每个eval_step后检查是否该停
  • trainer.train():启动训练循环——前向传播→算loss→反向传播→更新参数→验证→循环

# 第246-262行:训练结果输出

metrics = train_result.metrics
print(f"训练总步数: {metrics.get('total_flos', 'N/A')}")
print(f"训练耗时: {metrics.get('train_runtime', 'N/A'):.1f}s")
print(f"训练损失: {metrics.get('train_loss', 'N/A'):.4f}")

eval_metrics = trainer.evaluate()
print(f"验证集 Loss: {eval_metrics.get('eval_loss', 'N/A'):.4f}")
print(f"验证集困惑度: {torch.exp(torch.tensor(eval_metrics.get('eval_loss', 0))).item():.2f}")
1
2
3
4
5
6
7
8
  • metrics.get('key', 'N/A'):安全取值,key不存在时返回N/A
  • :.1f / :.4f / :.2f:格式化小数位数
  • 困惑度(Perplexity) = e^loss,表示模型平均在每个位置有多少个"困惑的选项"。越低越好,1是完美,>100就很差

# 第264-272行:保存模型

trainer.save_model(config.output_dir)
tokenizer.save_pretrained(config.output_dir)
print(f"✅ 模型保存在: {config.output_dir}")
print(f"\n💡 使用以下代码加载微调后的模型:")
print(f"   from peft import PeftModel")
print(f"   base = AutoModelForCausalLM.from_pretrained('{config.model_name}', ...)")
print(f"   model = PeftModel.from_pretrained(base, '{config.output_dir}')")
1
2
3
4
5
6
7
  • save_model():保存LoRA权重(只保存增量部分,不是整个模型)
  • save_pretrained():保存tokenizer的词表和配置
  • 最后打印加载方式:先加载基础模型,再用PeftModel.from_pretrained()套上LoRA权重

# 知识点总结

知识点 说明
@dataclass 自动生成__init__等方法的语法糖,适合配置类
field(default_factory) 给dataclass的可变类型(list/dict)设默认值,避免共享引用
LoRA原理 在冻结的权重旁加A/B两个小矩阵,只训练3%参数达到接近全量微调的效果
LoRA秩(r) 中间矩阵的维度,越大表达力越强但参数越多
lora_alpha 缩放因子,实际缩放=alpha/r
target_modules LoRA应用到的模型层名,注意力层4个+前馈层3个
梯度累积 小batch多步累积再更新,等效大batch但省显存
warmup_ratio 学习率从0预热到目标值,避免训练初期步子太大
余弦退火 学习率先升后降像余弦曲线,后期精细调优
weight_decay 权重衰减正则化,防止参数过大导致过拟合
fp16混合精度 前向用16位,梯度更新用32位,省显存且训练稳定
gradient_checkpointing 不存中间激活值,反向传播时重算,用时间换显存
enable_input_require_grads LoRA+gradient_checkpointing的必加修复,给反向传播提供梯度锚点
ChatML格式 Qwen的对话模板,用<\|im_start\|>/<\|im_end\|>标记角色
labels=-100 PyTorch的CrossEntropyLoss忽略-100位置,padding不参与loss
padding_side padding方向,训练用right(因果LM),生成用left
DataCollator 把单条数据拼成batch,自动padding
EarlyStopping 验证指标连续N次不改善就停训,防过拟合
load_best_model_at_end 训练结束自动加载验证loss最低的checkpoint
困惑度(PPL) = e^loss,衡量模型预测能力,越低越好
save_total_limit 最多保留N个checkpoint,旧的自动删,省磁盘
PeftModel.from_pretrained 加载LoRA权重的方式:先加载基础模型,再套LoRA

# 测试模型

"""
测试微调后的模型(Qwen2.5-1.5B-Instruct + LoRA)
"""

# AutoModelForCausalLM	加载因果语言模型(基础模型)
# AutoTokenizer	加载分词器
from transformers import AutoModelForCausalLM, AutoTokenizer
# 加载LoRA微调权重的类
from peft import PeftModel
# PyTorch,模型推理的基础
import torch
# 保存测试结果到JSON文件
import json

# ============ 配置 ============
BASE_MODEL_PATH = r"D:/models/Qwen2.5-0.5B-Instruct/qwen/Qwen2.5-1.5B-Instruct"
LORA_PATH = "./qwen_finetuned"
MAX_NEW_TOKENS = 256 # 最多生成256个新token(不含输入部分)
TEMPERATURE = 0.7 # 温度参数,控制随机性。0=最确定,1=正常随机,>1=更随机
TOP_P = 0.9 # 核采样参数,只从概率累计前90%的词里选,过滤掉不靠谱的小概率词

# ============ 加载模型 ============
print("加载基座模型...")
tokenizer = AutoTokenizer.from_pretrained(BASE_MODEL_PATH, trust_remote_code=True)
base_model = AutoModelForCausalLM.from_pretrained(
    BASE_MODEL_PATH,
    device_map="auto",
    torch_dtype=torch.float16,
    trust_remote_code=True,
)

# 关键点:加载顺序是先基础模型再LoRA,不能反过来。LoRA是依附在基础模型上的增量。
print("加载LoRA权重...")
# 把训练好的LoRA适配器叠加到基础模型上。这就像给模型"穿上训练好的衣服",推理时基础权重+LoRA权重一起参与计算
model = PeftModel.from_pretrained(base_model, LORA_PATH)
# 切换到评估模式,关闭dropout等训练专用行为
model.eval()
print("✅ 微调模型加载完成!\n")

# ============ 测试问题 ============
test_questions = [
    # 训练集内的问题(鬼怪在训练数据中出现过)
    "「WiFi幽灵」有什么有趣的传说吗",
    "「回声延迟鬼」有什么有趣的传说吗",
    "「拖延症妖怪」有什么有趣的传说吗",
    "「冰箱灯灵」有什么有趣的传说吗",
    "「考试锦鲤」有什么有趣的传说吗",
    # 训练集内的不同问法
    "「WiFi幽灵」有什么弱点",
    "「拖延症妖怪」平时都干什么",
    "我好像遇到了「充电线精灵」怎么办",
    "「闹钟贪睡精」能当宠物养吗",
    "「代码bug精」最讨厌什么",
    # 泛化测试(用训练数据的风格问新的鬼怪)
    "「键盘侠妖」有什么有趣的传说吗",
    "「空调病灵」有什么有趣的传说吗",
    "「快递延迟怪」是怎么来的",
    # 多轮对话测试
    "给我讲讲「奶茶附体灵」",
    "「碳水恶魔」真的存在吗",
]

# ============ 生成回答 ============
def generate_response(question):
    messages = [
        {"role": "system", "content": "你是一个富有创意和幽默感的助手,擅长用生动有趣的方式讲述各种鬼怪的传说故事。你的回答风格多变,有时正经讲述,有时搞笑吐槽,有时温馨治愈。"},
        {"role": "user", "content": question}
    ]

    text = tokenizer.apply_chat_template(
        messages,
        tokenize=False,
        # 关键! 在末尾加上<|im_start|>assistant\n,告诉模型"该你回答了"。训练时用False(因为答案已经在数据里),推理时必须用True
        add_generation_prompt=True
    )

    inputs = tokenizer(text, return_tensors="pt").to(model.device)

    with torch.no_grad(): # 推理模式,不计算梯度,省显存省时间
        outputs = model.generate(
            **inputs, # 解包字典,等价于传input_ids=...和attention_mask=...
            max_new_tokens=MAX_NEW_TOKENS,
            temperature=TEMPERATURE,
            top_p=TOP_P,
            do_sample=True, # 启用采样,模型会从概率分布里随机选词(而不是永远选概率最高的)。配合temperature和top_p使用
            pad_token_id=tokenizer.eos_token_id, # 生成遇到padding token时用eos token代替,告诉模型什么时候停
        )

    # outputs[0]:第一层是batch维度,取第0条
    # inputs["input_ids"].shape[1]:输入token的长度
    # [长度:]:切片,只要输入之后新生成的部分,不要把输入重复输出
    # tokenizer.decode():把token ID转回文字
    # skip_special_tokens=True:去掉<|im_end|>等特殊标记,只保留可读文本  
    generated_ids = outputs[0][inputs["input_ids"].shape[1]:]
    response = tokenizer.decode(generated_ids, skip_special_tokens=True)
    return response

# ============ 运行测试 ============
results = []
for i, question in enumerate(test_questions):
    print(f"{'='*60}")
    print(f"[{i+1}/{len(test_questions)}] {question}")
    print("-" * 60)
    response = generate_response(question)
    print(f"{response}\n")
    results.append({"question": question, "response": response})

# ============ 保存结果 ============
output_path = r"E:\zz\qwen_train\test_finetuned_result.json"
with open(output_path, "w", encoding="utf-8") as f:
    json.dump(results, f, ensure_ascii=False, indent=2)
print(f"\n✅ 结果已保存到: {output_path}")
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112

逐行拆解:

# 第1-3行:文档字符串

"""
测试微调后的模型(Qwen2.5-1.5B-Instruct + LoRA)
"""
1
2
3

说明脚本用途:测试加了LoRA权重后的微调模型。


# 第5-8行:导入库

from transformers import AutoModelForCausalLM, AutoTokenizer
from peft import PeftModel
import torch
import json
1
2
3
4
库/类 作用
AutoModelForCausalLM 加载因果语言模型(基础模型)
AutoTokenizer 加载分词器
PeftModel 加载LoRA微调权重的类
torch PyTorch,模型推理的基础
json 保存测试结果到JSON文件

和训练脚本相比,少了LoraConfigSFTTrainer等训练相关的,多了PeftModel用来加载已训练好的LoRA权重。


# 第10-15行:配置

BASE_MODEL_PATH = r"D:/models/Qwen2.5-0.5B-Instruct/qwen/Qwen2.5-1.5B-Instruct"
LORA_PATH = "./qwen_finetuned"
MAX_NEW_TOKENS = 256
TEMPERATURE = 0.7
TOP_P = 0.9
1
2
3
4
5
  • BASE_MODEL_PATH:基础模型的路径(没加LoRA的原始模型)
  • LORA_PATH:LoRA权重的保存路径,就是训练脚本里output_dir指定的位置
  • MAX_NEW_TOKENS=256:最多生成256个新token(不含输入部分)
  • TEMPERATURE=0.7:温度参数,控制随机性。0=最确定,1=正常随机,>1=更随机
  • TOP_P=0.9:核采样参数,只从概率累计前90%的词里选,过滤掉不靠谱的小概率词

# 第17-30行:加载模型

print("加载基座模型...")
tokenizer = AutoTokenizer.from_pretrained(BASE_MODEL_PATH, trust_remote_code=True)
base_model = AutoModelForCausalLM.from_pretrained(
    BASE_MODEL_PATH,
    device_map="auto",
    torch_dtype=torch.float16,
    trust_remote_code=True,
)

print("加载LoRA权重...")
model = PeftModel.from_pretrained(base_model, LORA_PATH)
model.eval()
print("✅ 微调模型加载完成!\n")
1
2
3
4
5
6
7
8
9
10
11
12
13

分两步加载:

  1. 加载基础模型:和训练时一样,fp16精度、自动分配设备
  2. 套LoRA权重PeftModel.from_pretrained()把训练好的LoRA适配器叠加到基础模型上。这就像给模型"穿上训练好的衣服",推理时基础权重+LoRA权重一起参与计算
  3. model.eval():切换到评估模式,关闭dropout等训练专用行为

关键点:加载顺序是先基础模型再LoRA,不能反过来。LoRA是依附在基础模型上的增量。


# 第32-53行:测试问题

test_questions = [
    # 训练集内的问题(鬼怪在训练数据中出现过)
    "「WiFi幽灵」有什么有趣的传说吗",
    ...
    # 训练集内的不同问法
    "「WiFi幽灵」有什么弱点",
    ...
    # 泛化测试(用训练数据的风格问新的鬼怪)
    "「键盘侠妖」有什么有趣的传说吗",
    ...
    # 多轮对话测试
    "给我讲讲「奶茶附体灵」",
    "「碳水恶魔」真的存在吗",
]
1
2
3
4
5
6
7
8
9
10
11
12
13
14

16个测试问题,分4组设计,每组测试不同能力:

分组 目的 逻辑
训练集内(5个) 测试"学没学会" 同样的鬼怪+同样的问法,看能不能复现训练数据的知识
不同问法(5个) 测试"灵不灵活" 训练过的鬼怪但换了问法(弱点/日常/遇到怎么办/能养吗/讨厌什么)
泛化测试(3个) 测试"能不能举一反三" 训练里没有的鬼怪(键盘侠妖/空调病灵/快递延迟怪),看模型能不能用学到的风格编
多轮对话(3个) 测试"整体对话能力" 用不同句式问训练过的鬼怪

这种分组设计能帮你定位问题:如果第一组都不行,说明没学到;如果第一组行但第二组不行,说明在背答案;如果前两组行但第三组不行,说明泛化不够。


# 第55-82行:generate_response 生成回答函数

def generate_response(question):
1

# 构建消息(57-60行)

    messages = [
        {"role": "system", "content": "你是一个富有创意和幽默感的助手..."},
        {"role": "user", "content": question}
    ]
1
2
3
4

构建ChatML格式的消息列表。system prompt和训练数据里的完全一致——这很重要,如果推理时的system prompt和训练时不同,模型会困惑。

# 拼接模板(62-66行)

    text = tokenizer.apply_chat_template(
        messages,
        tokenize=False,
        add_generation_prompt=True
    )
1
2
3
4
5
  • apply_chat_template:把消息列表转成Qwen能理解的文本格式,加上<|im_start|><|im_end|>标记
  • tokenize=False:先只拼文本,不转数字
  • add_generation_prompt=True关键! 在末尾加上<|im_start|>assistant\n,告诉模型"该你回答了"。训练时用False(因为答案已经在数据里),推理时必须用True

# Tokenize(68行)

    inputs = tokenizer(text, return_tensors="pt").to(model.device)
1
  • return_tensors="pt":返回PyTorch张量(而不是Python列表或numpy数组)
  • .to(model.device):把输入张量移到模型所在的设备(GPU)

# 生成(70-78行)

    with torch.no_grad():
        outputs = model.generate(
            **inputs,
            max_new_tokens=MAX_NEW_TOKENS,
            temperature=TEMPERATURE,
            top_p=TOP_P,
            do_sample=True,
            pad_token_id=tokenizer.eos_token_id,
        )
1
2
3
4
5
6
7
8
9
  • torch.no_grad():推理模式,不计算梯度,省显存省时间
  • **inputs:解包字典,等价于传input_ids=...attention_mask=...
  • do_sample=True:启用采样,模型会从概率分布里随机选词(而不是永远选概率最高的)。配合temperature和top_p使用
  • pad_token_id=tokenizer.eos_token_id:生成遇到padding token时用eos token代替,告诉模型什么时候停

temperature + top_p + do_sample 三者的关系:

  • do_sample=False:贪心解码,永远选概率最高的词,输出确定但无聊
  • do_sample=True + temperature=0.7:稍微有些随机,大多数时候选靠谱的词,偶尔来点变化
  • do_sample=True + top_p=0.9:先过滤掉概率累计后10%的离谱词,再在剩下的里面采样

# 截取生成的部分(80-82行)

    generated_ids = outputs[0][inputs["input_ids"].shape[1]:]
    response = tokenizer.decode(generated_ids, skip_special_tokens=True)
    return response
1
2
3
  • outputs[0]:第一层是batch维度,取第0条
  • inputs["input_ids"].shape[1]:输入token的长度
  • [长度:]:切片,只要输入之后新生成的部分,不要把输入重复输出
  • tokenizer.decode():把token ID转回文字
  • skip_special_tokens=True:去掉<|im_end|>等特殊标记,只保留可读文本

# 第84-92行:运行测试

results = []
for i, question in enumerate(test_questions):
    print(f"{'='*60}")
    print(f"[{i+1}/{len(test_questions)}] {question}")
    print("-" * 60)
    response = generate_response(question)
    print(f"{response}\n")
    results.append({"question": question, "response": response})
1
2
3
4
5
6
7
8
  • enumerate():同时拿到索引和值
  • i+1:从1开始编号,更自然
  • 每个问题生成回答,打印到屏幕,同时存到results列表
  • results的结构和训练数据一样是{"question": ..., "response": ...}的列表,方便对比

# 第94-98行:保存结果

output_path = r"E:\zz\qwen_train\test_finetuned_result.json"
with open(output_path, "w", encoding="utf-8") as f:
    json.dump(results, f, ensure_ascii=False, indent=2)
print(f"\n✅ 结果已保存到: {output_path}")
1
2
3
4
  • ensure_ascii=False:中文直接写入
  • indent=2:美化格式
  • 保存成JSON方便后续对比分析,也能喂给其他脚本做自动化评估

# 知识点总结

知识点 说明
PeftModel.from_pretrained() 加载LoRA权重的方式:先加载基础模型,再套LoRA增量
model.eval() 切换评估模式,关闭dropout/batchnorm的训练行为
apply_chat_template 把消息列表转成模型能理解的文本格式,加角色标记
add_generation_prompt=True 推理时必须加,在末尾拼接"该你回答了"的提示
return_tensors="pt" 返回PyTorch张量,而不是Python原生类型
torch.no_grad() 推理上下文,不计算梯度,省显存
model.generate() 自回归生成,一个token一个token地输出
max_new_tokens 只限制新生成的token数,不含输入部分
temperature 控制采样随机性,0=确定,1=正常,>1=更随机
top_p(核采样) 只从概率累计前p%的词里选,过滤离谱选项
do_sample True=采样模式有随机性,False=贪心永远选最可能的
pad_token_id 生成时遇到pad用什么token,一般用eos(结束符)
输出切片[shape[1]:] 从输入长度之后截取,只取新生成的部分
skip_special_tokens 解码时去掉<\|im_end\|>等不可读标记
测试分组设计 同问法/换问法/新鬼怪/不同句式,逐层验证能力
推理和训练的system prompt要一致 不一致会导致模型行为偏差