背景介绍

最近我一直在研究如何让LLM更好地理解和生成特定领域的内容。我选择了自己喜欢的小说《逍遥小散仙》作为实验数据,希望通过SFT(Supervised Fine-Tuning)让模型学习小说的内容、人物关系和写作风格,最终能够进行相关的分析和续写。这篇文章详细记录了我的数据集创建过程,以及最终的微调结果。

数据集创建过程

数据预处理

首先将小说用calibre从epub格式转成txt格式,为后续的处理做准备。

方法一:使用LLM提取问答数据

第一种方法是使用LLM从小说文本中提取问答对。这种方法的核心思想是将小说分成多个段落,然后让LLM为每个段落生成相关的问题和答案。

1. 设置LLM接口

首先,我创建了llm.py文件,用于与LLM API进行交互:

from openai import OpenAI
from typing import List, Dict
import json
import re

BaseURL = 'https://api.aiproxy.io/v1'
APIKEY = 'sk-xxxx'  # 已隐藏真实API密钥
Model = 'gpt-4o-mini-2024-07-18'

# 也可以使用DeepSeek API
# BaseURL='https://api.deepseek.com/v1'
# APIKEY='sk-xxxx'
# Model='deepseek-chat'

client = OpenAI(api_key=APIKEY, base_url=BaseURL)

def get_qa(raw_content, stream=False) -> str:
    response = client.chat.completions.create(
        model=Model,
        messages=[
            {'role': 'system', 'content': '你是一位小说分析师,负责从以下文本中提取信息并生成问答对。输出格式要求:\n```json\n[{"question": "*", "answer": "*"}]```'},
            {'role': 'user', 'content': f'请仔细阅读文本,并根据文本内容生成问题和答案,问答对要包含文本所有有效信息, 问答对互相独立,看不到其它问题。确保问题涵盖角色、情节、情感和背景等多方面。答案应直接复制原文内容。直接用JSON格式回答。文本:\n[{raw_content}]\n请生成JSON格式(列表)问答对:\n'}
        ],
        temperature=0.7,
        top_p=1.0,
        max_tokens=4096,
        stream=stream
    )

    if stream:
        for chunk in response:
            if chunk.choices[0].delta.content is not None:
                print(chunk.choices[0].delta.content, end='')
    else:
        r_content = response.choices[0].message.content
        json_match = re.findall(r'```(?:json)?\n(.*?)\n```', r_content, re.DOTALL)
        return json_match[0] if json_match else None
2. 加载和处理数据

接下来,创建main.py文件来加载小说文本并分块处理:

from datasets import load_dataset
from llm import get_qa
import tqdm
import json
import time
from typing import List

# 加载整个TXT文件作为训练集
dataset = load_dataset("text", data_files={"train": "xiaoyao.txt"}, sample_by="paragraph")

# 输出数据集信息
print(dataset)

chunk_size = 10
output_file = 'results.jsonl'

def convert_to_json(s) -> List:
    try:
        return json.loads(s)
    except Exception:
        return False

total_chunks = (len(dataset['train']) + chunk_size - 1) // chunk_size

pbar = tqdm.tqdm(total=len(dataset['train']))
for idx in tqdm.tqdm(range(0, len(dataset['train']), chunk_size), total=total_chunks):
    chunk = '\n'.join(dataset['train'][idx: idx+chunk_size]['text'])
    # 获取 QA 结果
    response = get_qa(chunk)
    
    # 处理响应并保存
    try:
        j_list = convert_to_json(response)
        if isinstance(j_list, list):
            with open(output_file, 'a', encoding='utf-8') as f:
                for item in j_list:
                    if not item:
                        continue
                    json.dump(item, f, ensure_ascii=False)
                    f.write('\n')
        else:
            # 如果是单个对象,直接写入
            with open(output_file, 'a', encoding='utf-8') as f:
                json.dump(response, f, ensure_ascii=False)
                f.write('\n')
    except Exception as e:
        print(f"Error processing response: {e}")

这段代码的工作流程是:

  1. 使用datasets库加载小说文本,按段落进行分割
  2. 每次取10个段落组成一个chunk
  3. 将chunk发送给LLM进行问答对生成
  4. 解析LLM返回的JSON格式问答对
  5. 将问答对保存到JSONL文件中
3. 数据转换为SFT格式

生成问答对后,需要将其转换为SFT训练所需的格式。创建convert.py文件:

import json

# 初始化一个空列表来存储转换后的数据
data = []

with open('results.jsonl', 'r', encoding='utf-8') as file:
    for line in file:
        # 解析每一行的 JSON 数据
        json_object = json.loads(line)
        new_format = {
            "instruction": json_object["question"],
            "input": "",  # 如果需要,可以根据需要填充
            "output": json_object["answer"]
        }
        
        # 将新格式字典添加到列表中
        data.append(new_format)

with open('output.json', 'w', encoding='utf-8') as outfile:
    json.dump(data, outfile, ensure_ascii=False, indent=2)

这段代码将问答对转换为SFT训练常用的指令格式:

  • instruction:问题
  • input:输入(在这个场景中为空)
  • output:答案
4. 生成的数据示例

以下是生成的问答对数据示例:

{
  "instruction": "小玄对自己的处境有何自我安慰的想法?",
  "input": "",
  "output": "忽尔厚颜无耻地思道:男子汉大丈夫,自古以来就三妻四妾,她们老爹不就娶了五房夫人嘛,岳父可以放火,女婿就不能点灯么,姐妹俩总不能一点情理也不讲吧......"
},
{
  "instruction": "水若对小玄的反应是什么?",
  "input": "",
  "output": "水若突地一呆,用手抵住了他的胸口,怔怔盯了他片刻,猛然地将其推开,挣扎坐起,恼恨交加道:滚开!以后,再不许你碰我!"
}

方法二:提取对话数据

第二种方法是提取小说中的对话内容。我使用了extract-dialogue工具来完成这项工作,该工具可以从小说文本中自动识别并提取对话内容,包括说话人和对话内容。

1. 安装和配置工具

首先,克隆工具仓库并安装依赖:

git clone https://github.com/KMnO4-zx/extract-dialogue.git
cd extract-dialogue && pip install -r requirements.txt
2. 配置API

创建.env文件,配置DeepSeek API:

# 创建.env文件
DEEPSEEK_BASE_URL=https://api.deepseek.com
DEEPSEEK_API=sk-xxxx  
3. 创建主程序

创建main.py文件,用于处理小说文本并提取对话:

from extract import system_prompt
from schema import novel_schema
from LLM import DeepseekChat
from utils import ReadFiles
from tqdm import tqdm
import json

# 指定小说文件路径
file_path = './xiaoyao.txt'
# 读取文件内容并分块,每块最大token数为500
docs = ReadFiles(file_path).get_content(max_token_len=500, cover_content=0)

# 获取系统提示词,用于指导LLM提取对话
sys_prompt = system_prompt(novel_schema)

# 初始化DeepSeek模型
model = DeepseekChat()

# 获取文件名(不含扩展名)用于输出文件命名
file_name = file_path.split('/')[-1].split('.')[0]

# 遍历所有文本块,提取对话
for i in tqdm(range(len(docs))):
    response = model.chat(sys_prompt, docs[i])
    try:
        # 解析JSON响应
        response = json.loads(response)
        # 将每条对话写入JSONL文件
        for item in response:
            with open(f'{file_name}.jsonl', 'a', encoding='utf-8') as f:
                json.dump(item, f, ensure_ascii=False)
                f.write('\n')
    except Exception as e:
        print(e)
4. 提取结果示例

运行程序后,会得到包含角色和对话内容的JSONL文件,每行是一个JSON对象:

{"role": "小玄", "dialogue": "办不到。"}
{"role": "方少麟", "dialogue": "办不到?你是不想?还是办不到?"}
{"role": "小玄", "dialogue": "换个条件。"}
{"role": "方少麟", "dialogue": "我想不出别的。如果这两个都做不到,那么我无法相信你之前说的话。"}
{"role": "方少麟", "dialogue": "行,你告诉我,除此之外,你还能做到什么?"}
{"role": "小玄", "dialogue": "我能让皇朝军退兵。如果你也退回泽阳,就可避免两败俱伤,令万千生灵涂炭!"}
{"role": "方少麟", "dialogue": "我不能。朝廷失政日久,如今天下荒荒,皆要推倒昏君,如果你无法证明昏君已经不在,凭我是说服不了别人的。"}

失败原因与经验总结

后续使用了LammaFactory工具进行SFT微调,测试效果都比较差,模型只能简单记忆问答对,而不能进行更复杂的对话。我认为一方面是数据集质量比较差,并且在微调时候产生了过拟合,模型遗忘了原有的能力。但更主要问题在于直接进行SFT可能不是最佳选择,应该先进行领域适应性的预训练。此外,数据集的规模和质量也有待提高。