渐进式SFT内化

想象你在教一个实习生写报告:
  1. 第1周:你给他看完整模板(标题格式、字体要求、段落结构)+ 10个范文
  2. 第2周:你问他"记得模板吗?",只给简化版提示+5个范文
  3. 第3周:你直接说"写个市场调研报告",不给模板,让他凭记忆写
内化就是让AI的"记忆"(模型参数)记住template,而不是每次都在"便签"(system prompt)上贴template。

🛠️ 实操Demo:把"小红书文案生成器"内化成zero-shot

场景设定

我们要让模型学会不写system prompt就能自动写出小红书风格的文案(带emoji、分段、标签)。
原始system prompt很长(约150 tokens):
你是一个小红书爆款文案专家。请使用活泼可爱的语气,每段都要有emoji表情,结尾必须包含5个相关话题标签。禁止使用"首先/其次/总之"等公文词汇,要用姐妹聊天的口吻。
 
目标:训练后,用户直接输入"推荐一款防晒霜",模型就自动输出小红书风格文案。

环境准备

bash
复制
# 安装依赖
pip install transformers datasets peft trl bitsandbytes accelerate
 

完整代码(可直接运行)

Python
复制
# ============================================================
# 渐进式SFT内化Demo:小红书文案生成器
# 目标:从"详述指令" -> "内化记忆" -> "零指令"
# ============================================================

import torch
from datasets import Dataset
from transformers import AutoTokenizer, AutoModelForCausalLM, TrainingArguments
from peft import LoraConfig, get_peft_model, PeftModel
from trl import SFTTrainer
import json

# 1. 选择基础模型(这里用Qwen2.5-0.5B作为示例,你可根据显存换更大的)
model_name = "Qwen/Qwen2.5-0.5B-Instruct"  # 或 "microsoft/Phi-3-mini-4k-instruct"

# 2. 加载tokenizer和模型
tokenizer = AutoTokenizer.from_pretrained(model_name, trust_remote_code=True)
tokenizer.pad_token = tokenizer.eos_token

# 使用4bit量化节省显存(如果显存够可去掉)
from transformers import BitsAndBytesConfig
bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_quant_type="nf4",
    bnb_4bit_compute_dtype=torch.float16
)

model = AutoModelForCausalLM.from_pretrained(
    model_name,
    quantization_config=bnb_config,
    device_map="auto",
    trust_remote_code=True
)

# 3. 配置LoRA(只训练部分参数,防止遗忘其他知识)
lora_config = LoraConfig(
    r=16,  # LoRA秩,越大表达能力越强
    lora_alpha=32,
    target_modules=["q_proj", "v_proj", "k_proj", "o_proj"],  # 注意力层
    lora_dropout=0.05,
    bias="none",
    task_type="CAUSAL_LM"
)
model = get_peft_model(model, lora_config)

print("✅ 模型加载完成,总可训练参数:", sum(p.numel() for p in model.parameters() if p.requires_grad))

# ============================================================
# 阶段1:准备训练数据(这里模拟3个阶段的数据)
# ============================================================

# 阶段1数据:完整System Prompt(长提示)+ Few-shot示例
stage1_data = [
    {
        "system": """你是一个小红书爆款文案专家。请遵循以下规则:
1. 语气活泼可爱,像姐妹聊天
2. 每段都要带emoji表情
3. 结尾必须包含5个相关话题标签(带#号)
4. 禁止使用"首先/其次/总之"等公文词汇""",
        "user": "推荐一款防晒霜",
        "assistant": "姐妹们!这个夏天我挖到宝了!🌞\n\n这款防晒真的绝了,质地像水一样💧,上脸完全没有油腻感~我之前用的那些厚重得像面粉糊在脸上😭 这个完全不会!成膜超快,后续上妆也不会搓泥✨\n\n关键是防晒力也超能打!SPF50+ PA++++,我去海边玩了一整天都没晒黑🏖️ 而且价格对学生党也友好,性价比天花板了💰\n\n姐妹们冲就完事了!这个夏天我们一起做冷白皮👸\n\n#防晒推荐 #夏日必备 #学生党好物 #平价防晒 #美妆分享"
    },
    # 再添加20-50条类似数据...(这里简化为3条示例)
    {
        "system": """你是一个小红书爆款文案专家。请遵循以下规则:
1. 语气活泼可爱,像姐妹聊天
2. 每段都要带emoji表情
3. 结尾必须包含5个相关话题标签(带#号)
4. 禁止使用"首先/其次/总之"等公文词汇""",
        "user": "分享减脂餐做法",
        "assistant": "减脂期的姐妹看过来!今天分享我的掉秤神器🥗\n\n这个鸡胸肉沙拉我已经连吃一周了,瘦了3斤!🎉 做法超简单:鸡胸肉用料酒+黑胡椒腌15分钟,煎到金黄切块;生菜、小番茄、黄瓜打底,淋上油醋汁🥒\n\n重点是这个酱汁!2勺生抽+1勺醋+半勺橄榄油+黑胡椒,拌啥都好吃😋 热量超低但饱腹感很强,下午不会饿得啃桌子\n\n姐妹们别再节食了,好好吃饭也能瘦!💪\n\n#减脂餐 #健康饮食 #鸡胸肉做法 #减肥食谱 #瘦身打卡"
    }
]

# 阶段2数据:简化System Prompt(short prompt)
stage2_data = [
    {
        "system": "写小红书风格文案:带emoji,5个标签,活泼语气",  # 大幅缩短
        "user": d["user"],
        "assistant": d["assistant"]
    } for d in stage1_data
]

# 阶段3数据:Zero-shot(无System Prompt,只给任务名)
stage3_data = [
    {
        "system": "",  # 空system prompt!这就是我们的目标
        "user": f"小红书文案:{d['user']}",  # 只在user里提示任务类型
        "assistant": d["assistant"]
    } for d in stage1_data
]

def format_data(raw_data):
    """将数据格式化为对话格式"""
    formatted = []
    for item in raw_data:
        messages = []
        if item["system"]:  # 只有当system不为空时才添加
            messages.append({"role": "system", "content": item["system"]})
        messages.append({"role": "user", "content": item["user"]})
        messages.append({"role": "assistant", "content": item["assistant"]})
        
        # 使用chat template(如果模型支持)
        text = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=False)
        formatted.append({"text": text})
    return Dataset.from_list(formatted)

# 格式化三个阶段的数据集
dataset_stage1 = format_data(stage1_data)
dataset_stage2 = format_data(stage2_data)
dataset_stage3 = format_data(stage3_data)

print(f"\n📊 数据准备完成:")
print(f"阶段1(长提示): {len(dataset_stage1)}条")
print(f"阶段2(短提示): {len(dataset_stage2)}条")
print(f"阶段3(零提示): {len(dataset_stage3)}条")

# 示例查看
print("\n📝 阶段1数据示例:")
print(dataset_stage1[0]["text"][:500] + "...")

# ============================================================
# 阶段1训练:用完整System Prompt建立基础(3个epoch)
# ============================================================

print("\n🚀 开始阶段1训练:学习完整规则...")

trainer_stage1 = SFTTrainer(
    model=model,
    tokenizer=tokenizer,
    train_dataset=dataset_stage1,
    args=TrainingArguments(
        output_dir="./stage1_long_prompt",
        num_train_epochs=3,  # 让模型充分学习规则
        per_device_train_batch_size=4,
        gradient_accumulation_steps=2,
        learning_rate=5e-5,
        logging_steps=1,
        save_strategy="epoch",
        optim="paged_adamw_8bit",
        fp16=True,
        report_to="none"
    )
)

trainer_stage1.train()
print("✅ 阶段1完成!模型已学会完整规则")

# 保存阶段1的LoRA权重(可选,用于对比)
model.save_pretrained("./stage1_weights")

# ============================================================
# 阶段2训练:切换到简化Prompt(2个epoch)
# ============================================================

print("\n🚀 开始阶段2训练:内化规则,简化提示...")

# 注意:我们在同一个模型上继续训练(渐进式)
trainer_stage2 = SFTTrainer(
    model=model,  # 复用阶段1的模型
    tokenizer=tokenizer,
    train_dataset=dataset_stage2,
    args=TrainingArguments(
        output_dir="./stage2_short_prompt",
        num_train_epochs=2,
        per_device_train_batch_size=4,
        gradient_accumulation_steps=2,
        learning_rate=2e-5,  # 学习率降低,微调细节
        logging_steps=1,
        save_strategy="no",
        optim="paged_adamw_8bit",
        fp16=True,
        report_to="none"
    )
)

trainer_stage2.train()
print("✅ 阶段2完成!模型已适应短提示")

# ============================================================
# 阶段3训练:Zero-shot内化(3个epoch,关键阶段!)
# ============================================================

print("\n🚀 开始阶段3训练:完成zero-shot内化...")

trainer_stage3 = SFTTrainer(
    model=model,  # 继续复用
    tokenizer=tokenizer,
    train_dataset=dataset_stage3,
    args=TrainingArguments(
        output_dir="./stage3_zero_prompt",
        num_train_epochs=3,  # 更多epoch巩固记忆
        per_device_train_batch_size=4,
        gradient_accumulation_steps=2,
        learning_rate=1e-5,  # 更低学习率,稳定内化
        logging_steps=1,
        save_strategy="no",
        optim="paged_adamw_8bit",
        fp16=True,
        report_to="none"
    )
)

trainer_stage3.train()
print("🎉 阶段3完成!模型已完成指令内化")

# 保存最终模型
model.save_pretrained("./final_internalized_model")
print("💾 最终模型已保存到 ./final_internalized_model")

# ============================================================
# 验证测试:对比三个阶段的输出
# ============================================================

def test_model(model, tokenizer, user_input, system_prompt=None, desc=""):
    """测试函数"""
    messages = []
    if system_prompt:
        messages.append({"role": "system", "content": system_prompt})
    messages.append({"role": "user", "content": user_input})
    
    text = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
    inputs = tokenizer(text, return_tensors="pt").to(model.device)
    
    outputs = model.generate(
        **inputs,
        max_new_tokens=300,
        do_sample=True,
        temperature=0.7,
        top_p=0.9
    )
    
    result = tokenizer.decode(outputs[0], skip_special_tokens=True)
    # 提取assistant的回答
    if "assistant" in result:
        result = result.split("assistant")[-1].strip()
    
    print(f"\n{'='*50}")
    print(f"🧪 {desc}")
    if system_prompt:
        print(f"📝 System Prompt长度: {len(system_prompt)}字符")
    else:
        print(f"📝 System Prompt: 无(Zero-shot)")
    print(f"👤 用户输入: {user_input}")
    print(f"🤖 模型输出:\n{result[:400]}...")
    return result

# 合并所有adapter版本用于对比(实际推理时只需最终版)
print("\n" + "="*50)
print("🧪 开始效果对比测试")

# 测试输入
test_input = "推荐一款咖啡机"

# 1. 测试阶段1模型(如果有保存)- 这里演示最终模型在不同prompt下的表现
print("\n【测试1】使用最终模型 + 长System Prompt(验证未遗忘)")
long_prompt = """你是一个小红书爆款文案专家...""" # 省略完整版
test_model(model, tokenizer, test_input, long_prompt, "阶段1风格(参考)")

print("\n【测试2】使用最终模型 + 简化Prompt")
short_prompt = "写小红书风格文案:带emoji,5个标签,活泼语气"
test_model(model, tokenizer, test_input, short_prompt, "阶段2风格(参考)")

print("\n【测试3】使用最终模型 + Zero-shot(目标效果!)")
test_model(model, tokenizer, test_input, None, "🎯 阶段3 Zero-shot(内化完成)")

print("\n✨ 如果测试3的输出仍然带emoji和标签,说明内化成功!")
 

📋 使用指南

1. 数据准备(最最关键)

你需要准备:
  • 阶段1:20-50条高质量样本,system prompt写满所有约束(如格式、风格、禁止词)
  • 阶段2:同一批样本,system prompt缩短50%(保留核心关键词)
  • 阶段3:同一批样本,system prompt设为空字符串,只在user里加前缀如"小红书文案:{query}"

2. 训练技巧

  • 学习率递减:阶段1用5e-5,阶段2用2e-5,阶段3用1e-5
  • epoch递增:阶段3要比阶段1更多epoch(让模型"死记"规则)
  • 必须LoRA:使用LoRA/QLoRA,防止破坏模型其他能力

3. 验证标准

成功内化的标志:
Python
复制
# 输入(无任何system prompt)
user: "推荐一款扫地机器人"

# 输出(应该自动符合小红书风格)
姐妹们!扫地自由真的实现了!🎉
这机器人太懂我了...(带emoji)
...
#智能家居 #懒人必备 #幸福感爆棚 #家电分享 #科技改变生活
 

⚠️ 重要提醒

  1. 灾难性遗忘:如果你的模型原本很"通用",微调后可能会变"傻"(只会写小红书文案)。解决方案:
    • 混入10%的通用对话数据一起训练
    • 使用更大的lora_r值(如64)保留表达能力
  2. 回退机制:保存每个阶段的权重,如果阶段3效果差,回退到阶段2
  3. 测试覆盖:阶段3测试时,要用模型没见过的query(如训练里有"防晒霜",测试用"咖啡机")
  4. Prompt Cache清零:每次测试前重启kernel,防止tokenizer的chat template缓存干扰
如果你想把这个Demo应用到你的具体业务(比如法律合同生成、代码规范检查等),只需要替换数据集中的systemassistant内容,保持三阶段结构不变即可!

posted on 2026-01-27 21:24  ExplorerMan  阅读(0)  评论(0)    收藏  举报

导航