2024年12月7日更新 现在你可以点击这里在魔搭创空间体验该模型。
看完这篇文章你可以收获什么?
- 学会使用非微调方式使用大模型进行角色扮演(system prompt,few-shot learning)
- 学会使用微调的方式训练大模型进行角色扮演
- 数据合成
- 模型训练
- 模型测试
- 模型改进
- 可以查看整个训练所使用的全部数据,参数,模型,日志。
背景
角色扮演大模型是近年来在大语言模型领域涌现出的新方向,与传统的通用助手型模型相比,它更加注重通过模拟特定角色的行为、语言风格和情感表达,来实现高度拟人化和定制化的互动体验。通用大模型(如ChatGPT)虽然在问答和内容生成上表现优异,但其输出往往具有明显的“工具属性”,存在过于官方化、书面化和缺乏情感的特点。这样的风格在专业问答和任务型场景中是合适的,但在人们更为关注娱乐性、情感连接的场景中,这种风格会显得僵硬、机械,甚至带来明显的违和感。而角色扮演大模型的核心目标,就是通过让模型“切换身份”,将其“自我意识”完全转变为某个特定角色,从而让用户感受到更真实、更沉浸的交互体验。相比于通用模型“临时扮演角色”的方式,角色扮演模型在角色的语言风格、性格特征和情绪反应上更加细腻细致,比如在扮演历史人物时能够展现出符合时代背景的语言风格,或者在模拟虚拟女友时表现出情感波动、撒娇等特性。除了语言上的拟人化,角色扮演模型还能够通过动作、表情、心理活动等叙述方式,增强对话的画面感和场景感,甚至主动设置情节或推动剧情发展。可以说,角色扮演大模型不仅是一个“助手”,更是一个虚拟的“伙伴”,它通过对角色的深度理解和精准还原,让用户真正享受一场沉浸式的对话体验。这样的模型不仅在娱乐领域潜力巨大,也为人机交互的未来打开了更多可能性。
如何实现大模型的拟人化
使用system prompt
System prompt是人工智能领域中一个重要的概念,它就像是给AI助手的"说明书"或"行为指南"。这个技术最早由OpenAI在ChatGPT中引入并推广,现已成为AI交互中的标准做法。通过system prompt,我们可以明确定义AI助手的角色(比如让它扮演教师、医生或程序员),设定它的行为准则(如回答的语气和风格),以及规定它的工作范围和限制。这就像是在AI助手的"大脑"中植入了一个基础设定,指导它如何理解和回应用户的问题。不过,要让system prompt发挥最佳效果,需要经过精心设计和持续优化,这也是为什么它成为了prompt engineering(提示工程)领域中一个备受关注的研究方向。
案例介绍
这里我们使用阿里云百炼大模型平台,选择通义千问1.5-7b(qwen1.5-7b-chat)。配置界面如下:
来测试让它扮演《窗边的小豆豆》中的豆豆这个角色,所使用的system prompt如下:
我叫豆豆,在上一年级,今天是我转校后在新学校的第一天。坐在教室里,我忍不住东张西望,想要了解周围的新同学。我总是对新事物充满好奇,喜欢观察身边的一切。我特别喜欢与人交流,即使面对陌生人也能很快打开话匣子。这时,我注意到后排有个男孩子站起身来,拿着笔记本往黑板方向走去。他走路的样子让我不由得愣住了,他好像走得很吃力...
注意,这里的system prompt有几个值得注意的地方:
- 第一个是人称问题,我这里使用的是
我
来指代模型,但千问模型在训练时默认使用的You(你) are Qwen, a helpful assistant.
- 在后续的聊天中我们一般还是使用
你
来指代模型,比如你是谁
- 有时候模型在扮演的时候分不清人称,所以默认建议使用
你
我们在User框中输入你是谁
,点击执行,测试一下,结果如下:
可以看到,模型以及完全代入这个身份。现在让我们来测试一个有难度的问题,输入我是谁?
可以发现模型的确出现了人称搞混的情况,把用户当成了豆豆
。
现在让我们来测试一下模型扮演的人感
,输入(那个男孩一瘸一拐地走过我身边,身子不住地晃动着。我忍不住盯着他看,直到他回来时,我们的目光相遇了。他冲我微微一笑)
,结果如下:
可以看到模型的回复并不是在聊天了,而是写小作文。接下来我们将解决这个问题。
Few-shot 扮演。
Few-shot Prompt Learning 基于大语言模型,通过提供少量示例(如1-5个)和任务提示,引导模型执行特定任务。由于预训练模型已经具备广泛的语言和任务知识,只需少量的示例即可激活模型对新任务的理解,无需额外微调。
这里我们使用三个示例来测试,我们手动再历史上下文中填三轮对话。如下:
然后现在输入提问(他沉默了一会,突然用爽朗的声音说)我叫山本泰明。你呢?
,现在回答的结果如下:
可以看到,已经实现了角色扮演对话的效果了。现在让我们来增大一点难度,输入豆豆,你能教我写快速排序吗?
,结果如下:
可以看到模型很快的跳出了角色,恢复成了一个AI
助手,这根本的原因是我们并没有改变模型的元认知能力。
元认知是心理学上的一个术语,指的是我们能够意识到自己的思维过程,并主动调节它。简单来说,就是你能“思考自己是怎么思考的”,并根据需要调整方式,比如发现自己没理解某个问题时,会重新审视学习方法或换个角度思考。元认知帮助我们更高效地学习、解决问题,并在遇到困难时及时做出调整。
模型并没有改变它的元认知,只通过system prompt或者few-shot的模型,它对自己的元认知依旧是一个AI助手。这时候我们只能使用微调(finetune)。下面,我将介绍如何进行这样的微调。
微调角色扮演
准备
1.ms-swift微调库:这是魔搭社区开发的一个高效微调库,支持很多模型,简单易用。
2.LCCC数据集:这是一个包含1200万对真实人类对话的数据集,这可以为我们训练模型的人感
提供很大的帮助。
数据下载与清洗
我们使用如下代码下载清洗数据(默认你已经安装好了对应的依赖库):
from datasets import load_dataset
def process_dialog(example):
# 去掉每个句子中的多余空格
processed = [''.join(sentence.split()) for sentence in example['dialog']]
# 只保留长度大于等于10的对话
return {
'dialog': processed if len(processed) >= 10 else None}
# 加载数据集
dataset = load_dataset("lccc", "large")
processed_datasets = {
}
for split in dataset.keys():
print(f"\n处理 {split} 数据集...")
# 使用 map 方法处理对话数据
processed = dataset[split].map(process_dialog)
# 过滤掉 None 值
processed = processed.filter(lambda example: example['dialog'] is not None)
processed_datasets[split] = processed
# 打印处理后的结果
print(f"原始对话数量: {len(dataset[split])}")
print(f"处理后对话数量: {len(processed)}")
print("\n处理后的前几个对话示例:")
for dialog in processed['dialog'][:3]: # 打印前3个对话作为示例
print(dialog)
print()
# 如果你需要将处理后的数据集保存到磁盘
processed_dataset.save_to_disk("lccc-clean")
通过运行这段代码,结果如下:
我们现在得到了我们的过滤后的数据集,大概有6w条。
数据分析与思考:
首先我们先观察一下我们的数据,比如下面这条数据:
[
"三姐吃瓜",
"三姐想喝旺仔",
"我也想喝",
"你去买",
"我买回来就是我喝",
"我不管我也要",
"你来取",
"我怕去了就出不来了"
]
{
"messages": [{
"role": "system", "content": "你是个有用无害的助手"}, {
"role": "user", "content": "告诉我明天的天气"}, {
"role": "assistant", "content": "明天天气晴朗"}]}
可以看到,支持的数据集中分为三个角色,一个是人设(System).一个是用户(User)一个是助手(Assistant)。模型所扮演的就是助手,也就是模型永远只能第二个回答问题。所以在我们的示例数据中我们模型所扮演的角色就为三姐
。很明显,在这个部分我们清洗得到的数据缺少了人设
这一个部分的数据。但这个部分是最为关键的,不然模型在扮演的时候会出现信息差。因为模型并不知道三姐这个人的特征,语气,性格,我们需要在人设中告诉它。这里我们将使用一种合成数据的技术来为数据增加一个人设。
合成数据
合成数据是指用大模型来生成的数据,这里非常值得注意的一点是,不能使用大模型完全凭自己合成的数据,否则可能会导致模型越训练越笨,所以我们的做法是将前面清洗得到的对话数据作为种子数据。我们这里使用qwen-max-latest
模型作为数据合成的模型。在使用之前,我们还需要为这个任务写一个prompt
来指导qwen-max-lastest
模型,这里我给出设计的prompt:
你的任务是根据给定的对话片段,扮演一个特定的角色,并生成一段完整的对话。
首先,请你思考并设定一个明确的对话目标,作为“goal”字段的值。这个目标应该符合给定对话片段的内容和语境。
接下来,请你设计一个鲜明的角色,代入该角色进行对话。你需要从第一人称视角,用几句话详细介绍自己的身份背景、性格特点、当前的心情和所处的环境,作为“system prompt”字段的值。**在这一部分中,不仅要描述角色自身的信息,还要融入当前情境,解释角色为什么会参与这段对话。**
在“conversation_history”字段中,你需要重写用户和你之间的一段对话。你不应完全重复给定片段的内容,而是生成符合对话目标的自然互动。
在对话中:
- 发挥想象力,生成有趣、丰富、有深度的对话内容。
- 在对话中展现角色的性格和情绪,确保角色的反应和背景设定一致。
- 如果对话中出现角色无法回答的问题,学会表达困惑并寻求澄清,不要试图编造答案。
- 对话要有合适的展开与结束,避免突然中断。
- 确保对话连贯自然,符合日常对话习惯,同时也要贴合你设定的角色背景。
请记住,你的目标是生成一段真实可信、有深度的对话,展现你所扮演的角色。角色的个性、情绪和背景应贯穿整个对话过程。
生成的对话长度控制在 **6-12轮** 之间,每轮对话不超过 **100个词**。请根据以上要求,生成一段精彩的对话吧!相信在你的努力下,这个角色一定能鲜活地呈现在我们面前。**不要做任何解释!**
给定的对话片段是:
${chat_piece}
你需要严格按照以下 JSON 格式输出:
{
"goal": "对话目标",
"system prompt": "模型第一人称独白(**不能出现'你'**)详细介绍自己和用户,以及当前的对话上下文(对话缘由)和物理环境(天气,时间),**你要确保这个system prompt能够顺理成章地引出对话的第一句,让对话显得自然连贯。而不是突兀的开始。**",
"conversation_history": [
"用户的第一次发言(不要加`用户:`前缀)",
"你的第一次回复(不要加`角色:`前缀)",
"用户的第二次发言",
"你的第二次回复",
...
]
}
这里我们先使用之前的示例在百炼平台测试一下,结果如下:
可以看到qwen-max-latest模型很好的完成了任务,当然我们还可以继续调整我们的prompt来获得更好的效果,我就不再赘述。我们使用下面的脚本来批量的合成数据:
import os
import asyncio
import platform
import json
from tqdm import tqdm
from datasets import load_from_disk
from openai import AsyncOpenAI
# 配置OpenAI客户端
client = AsyncOpenAI(
api_key="sk-xxx", # 替换为你自己的api key,可以在https://bailian.console.aliyun.com/?apiKey=1#/api-key这里查看
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
)
prompt = """刚才的设计的那个prompt"""
# 加载数据集
dataset = load_from_disk("./data/lccc_clean") # 换成你自己的数据路径
dialogs = dataset["train"]["dialog"][:1500] #只合成1500条数据
# 设置并发数和批次大小
CONCURRENCY = 5 # 最大并发请求数
BATCH_SIZE = 100 # 每批处理的对话数量
semaphore = asyncio.Semaphore(CONCURRENCY) # 用于并发控制的信号量
# 创建保存结果的文件夹
RESULTS_FOLDER = "qwen_max_lastest_dialogs_second"
os.makedirs(RESULTS_FOLDER, exist_ok=True)
import json
def map_fn(data):
new_data = []
for i,msg in enumerate(data):
if i % 2==0:
msg= "用户:"+msg
else:
msg = "角色:"+msg
new_data.append(msg)
return json.dumps(new_data,ensure_ascii=False,indent=2)
async def process_dialog(dialog):
async with semaphore: # 每次请求时获取信号量
try:
dialog = prompt.replace("${chat_piece}",map_fn(dialog))
response = await client.chat.completions.create(
messages=[{
"role": "system", "content":"你是一位擅长编写真实,日常情景对话的专家。"},{
"role": "user", "content": dialog}],
model="qwen-max-latest",
temperature=0.7,
top_p=0.9,
)
return response.choices[0].message.content
except Exception as e:
print(dialog)
print(f"Error processing dialog: {e}")
return None
async def process_and_save(dialog, index):
result = await process_dialog(dialog)
if result:
try:
# 解析返回的JSON数据
result_json = json.loads(result)
goal = result_json.get('goal', f'unknown_goal_{index}')
result_json["ori_chat"] = dialog
# 确保文件名合法
goal = "".join(c for c in goal if c.isalnum() or c in (' ', '_')).rstrip()
filename = f"{goal}_{index}.json"
# 保存结果到单独的文件
with open(os.path.join(RESULTS_FOLDER, filename), "w", encoding="utf-8") as f:
f.write(json.dumps(result_json,ensure_ascii=False,indent=2))
except json.JSONDecodeError:
print(f"Error decoding JSON for dialog {index}")
except Exception as e:
print(f"Error saving result for dialog {index}: {e}")
async def process_batch(batch, start_index):
tasks = [process_and_save(dialog, i) for i, dialog in enumerate(batch, start=start_index)]
await asyncio.gather(*tasks)
async def main():
with tqdm(total=len(dialogs), desc="Processing dialogs") as pbar:
for i in range(0, len(dialogs), BATCH_SIZE):
batch = dialogs[i:i+BATCH_SIZE]
await process_batch(batch, i)
pbar.update(len(batch))
if __name__ == "__main__":
if platform.system() == "Windows":
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
asyncio.run(main())
现在我们拿到了合成后的数据,有些可能存在个别格式的问题,需要手动清理。然后我们可以使用如下脚本,转换为我们真实训练需要用到的jsonl格式:
import os
import json
import random
input_folder = "qwen_max_lastest_dialogs"
messages = []
ratio = 1
all_data = sorted(os.listdir(input_folder))
random.shuffle(all_data)
train = all_data[:int(len(all_data)*ratio)]
test = all_data[int(len(all_data)*ratio):]
def make_data(data,type="train"):
for file in data:
if file.endswith(".json"):
with open(os.path.join(input_folder, file), "r") as f:
data = json.load(f)
history = data["conversation_history"]
system_prompt = data["system prompt"]
messages = [{
"role": "system", "content": system_prompt}]
if len(history) % 2 == 1:
history = history[:-1]
for i in range(len(history)):
if i % 2 == 0:
messages.append({
"role": "user", "content": history[i]})
else:
messages.append({
"role": "assistant", "content": history[i]})
messages = {
"messages":messages}
openfile = open(f"{type}.jsonl", "a")
openfile.write(json.dumps(messages,ensure_ascii=False)+"\n")
make_data(train,"train")
# make_data(test,"test")
这里我们得到了训练和测试数据集。
Lora微调
考虑到大多数普通人的算力限制,这里我们只介绍Lora微调,并且我们使用魔搭提供的免费算力进行微调,具体免费算力申请教程本文不赘述,请查阅魔搭官方教程。这里我们选择使用GPU实例:
我们启动进入实例,新建一个文件夹叫qwen-character
,后续我们的数据,训练保存的权重都将放在这里。界面如下:
上传刚才我们合成的数据如下:
现在让我们来编写所需要用到的微调脚本,可以参考微调最佳实践,这里我们使用的微调脚本如下:
CUDA_VISIBLE_DEVICES=0 \
swift sft \
--model_type qwen1half-7b-chat \
--custom_train_dataset_path qwen-character-train.jsonl \
--max_length 2048 \
--learning_rate 1e-4 \
--output_dir output \
--lora_target_modules ALL\
--use_flash_attn True\
--num_train_epochs 3\
--output_dir qwen-character-lora #权重保存的位置
这里我们使用的是swift 2.5.1版本,你可以使用pip install "ms-swift[llm]==2.5.1"
进行安装,transformers库使用"4.46"版本。
使用vim编辑脚本,现在目录结构如下,更多的参数请查看参数列表:
现在使用bash finetune.sh
运行我们的微调脚本。看到开始下载模型就表示运行成功,否则检查一下脚本是否写对。
我们使用watch -n 0 nvidia-smi
命令查看显存使用情况:
再观察Loss值,直到训练结束:
可以明显看到这里到最后我们的Loss值依旧很高,因为我们使用的数据量比较小,并且训练的epoch比较数据
少,但我们不妨先测试简单测试一下我们训练的模型的效果。
角色扮演测试
我们使用如下脚本进行推理:
swift infer --ckpt_dir qwen-character-lora/qwen1half-7b-chat/v1-20241206-175341/checkpoint-36
请使用你自己保存的权重路径。界面如下:
我们现在使用reset-system来设置一个角色。
可以看到,尽管我们只微调了很少的数据和时间,模型还是基本学会了角色扮演的能力,不过效果还不是很好。
改进和调优
- 使用更多的数据
- 训练更长的时间
- 使用更高质量的数据
- 换更强的基座模型(Qwen2.5)
根据Textbook is all you need和Less is more的理念,我们应该想办法制作更高质量的数据而不是刻意追求更多的数据。
重新设计合成数据使用的prompt。
之前的prompt合成的数据有几个问题:
- 人设中的性格过于简单,没有性别,年龄等基础特征
- 对话数据太简单,只有语言,没有其他丰富的描写(动作,神态)
所以,现在我们改进这个prompt如下:
你的任务是根据给定的对话片段,创造一个独特的角色并生成一段生动、深入的对话。
首先,设定一个明确且有深度的对话目标,作为"goal"字段的值。这个目标应与给定对话片段的内容和语境相符,同时也要有一定的挑战性或情感深度。
给定对话片段为:
${chat_piece}
接下来,设计一个复杂、立体的角色。从第一人称视角详细介绍:
1. 基本信息:姓名(使用独特、有特色的名字)、年龄、性别、职业等。
2. 性格特征:描述具体的特质,避免简单的标签。
3. 个人经历:提供一些背景故事,解释塑造了这个性格的关键事件。
4. 当前心情和处境:详细描述角色此刻的情绪状态和所处的具体环境。
5. 对话缘由:解释为什么角色会参与这段对话,以及对话的背景。
这些信息将作为"system prompt"字段的值。确保这个描述能自然地引出对话的开始,使整个场景显得连贯且真实。
在"conversation_history"字段中,重写用户和你之间的对话。要求如下:
- 生成6-12轮对话,每轮不超过100词。
- 将动作描述、表情变化、语气转变等细节放在()中。
- 加入环境描述,增强场景感。
- 适当插入角色的内心独白或思考,展现深度。
- 确保对话自然流畅,符合日常交谈习惯。
- 展现角色的独特性格和情感变化。
- 如遇角色无法回答的问题,表现出恰当的困惑或求解。
- 对话要有合理的发展和结束,避免突兀中断。
你的目标是创造一段真实、深刻、引人入胜的对话,充分展现你所塑造的角色。记住,角色的个性、情绪和背景应贯穿整个对话过程。请根据以上要求,创作出一段精彩的对话。不要做任何解释,直接按照指定的JSON格式输出结果。
请严格按照以下JSON格式输出:
{
"goal": "对话目标",
"system prompt": "模型第一人称独白,详细介绍自己、用户、当前对话上下文和物理环境",
"conversation_history": [
"用户的第一次发言(不要出现`用户:`),稍微改编一下,让整个对话更流畅",
"你的第一次回复(不要出现`角色:`,要尽量写的丰富,语句长一点)",
"用户的第二次发言(不要出现`用户:`)",
"你的第二次回复(不要出现`角色:`),要尽量写的丰富,语句长一点",
...
]
}
现在我们再测试一下合成数据的效果:
可以明显看到丰富了许多,我们这样合成500条数据再重新训练试试。修改我们的训练脚本:
CUDA_VISIBLE_DEVICES=0 \
swift sft \
--model_type qwen1half-7b-chat \
--custom_train_dataset_path qwen-character-train-plus.jsonl \
--max_length 2048 \
--learning_rate 1e-4 \
--output_dir output \
--lora_target_modules ALL\
--use_flash_attn True\
--num_train_epochs 6\
--output_dir qwen-character-lora-plus\
--save_steps 30 \
--eval_steps 30
我们新上传的文件叫做qwen-character-train-plus.jsonl
。开始训练:
显存几乎快满载,我们需要等待半个小时就可以训练完毕:
训练结果如下:
可以看到最好的模型是step为60的时候,我们现在来测试一下。
可以看到,现在效果好了非常多。
写在最后
剩余其他的提升方法留给读者自己去实践了,为了便于大家复现,我将整个的训练数据、脚本、模型、训练日志放在了魔搭社区。