AI 智能客服系统架构设计与实现

1. 项目概述#

本文档详细介绍一款基于检索增强生成(RAG)技术的智能客服系统的架构设计与实现方案。该系统通过自然语言处理技术,为企业提供智能化客户服务能力。

1.1 核心功能#

系统提供三大核心功能:

  • 智能问答:基于知识库的智能问答服务,支持多轮对话
  • 问题推荐:根据用户问题智能推荐相关问题,提升咨询效率
  • 知识库管理:支持批量导入问答数据,自动向量化存储

1.2 技术选型#

组件技术栈说明
后端框架FastAPI高性能异步 API 框架
向量数据库Milvus开源向量数据库,支持亿级向量检索
大语言模型Ollama / DeepSeek本地或云端 LLM 部署
EmbeddingOllama Embeddings本地 embedding 模型
RAG 框架LangChain + LangGraph检索增强生成编排
对话历史MemorySaver会话状态持久化

2. 系统架构#

2.1 系统架构#

系统采用分层架构设计,主要包含以下四层:

1. 客户端层

用户通过 Web 或 App 客户端发起 HTTP 请求,与 API 服务层进行交互。

2. API 服务层(FastAPI)

核心服务层,处理三类主要请求:

  • POST /chat - 智能问答接口,支持多轮对话
  • POST /suggest_questions - 根据用户问题推荐相关问题
  • POST /ingest_qa_csv - 批量导入问答数据到知识库

3. 机器学习层(Ollama)

提供本地 Embedding 模型服务,将文本转换为向量表示,用于向量检索。

4. 数据存储层

  • Milvus:向量数据库,负责存储知识库的向量表示,支持高效的相似度检索
  • Memory Saver:会话历史管理,支持多用户多轮对话的上下文记忆

2.2 组件说明#

API 服务层(server.py)#

FastAPI 应用,负责处理三类请求:

  • POST /chat - 核心问答接口
  • POST /suggest_questions - 问题推荐接口
  • POST /ingest_qa_csv - 知识库导入接口

向量检索层#

使用 Milvus 向量数据库存储知识库的向量表示,支持高效的相似度检索。

LLM 层#

支持多种大语言模型切换:

  • LLM_TYPE=0:Ollama 本地 Qwen3 模型
  • LLM_TYPE=1:Ollama Cloud GPT-OSS 模型
  • LLM_TYPE=2:DeepSeek 云端 API
  • LLM_TYPE=3:Ollama GPT-OSS 20B 模型
  • LLM_TYPE=4:Ollama Gemma 31B 模型

3. 核心模块详解#

3.1 智能问答流程#

用户问题 ──► 检索向量库 ──► 获取相关上下文
                │
                ▼
         构建 Prompt ──► 调用 LLM ──► 返回答案

关键代码实现

# 构建检索链
chain = RunnableParallel({
    "context": RunnableLambda(lambda x:x["question"]) | app.state.retriever,
    "question": RunnableLambda(lambda x:x["question"]),
    "history": RunnableLambda(lambda x: get_by_session_id(x["session_id"]))
}) | prompt | app.state.chat_llm

# 添加对话历史支持
chain_with_history = RunnableWithMessageHistory(
    chain,
    get_by_session_id,
    input_messages_key="question",
    history_messages_key="history",
) | StrOutputParser()

3.2 对话历史管理#

系统使用内存存储对话历史,支持跨请求的会话追踪:

class InMemoryHistory(BaseChatMessageHistory):
    messages: list[BaseMessage] = Field(default_factory=list)

    def add_messages(self, messages: list[BaseMessage]) -> None:
        self.messages.extend(messages)
        # 保留最近 MAX_HISTORY_MESSAGES 条消息
        self.messages = self.messages[-MAX_HISTORY_MESSAGES:]

通过 openid 区分不同用户会话,实现多用户并发支持。

3.3 知识库导入#

支持从 CSV 文件批量导入问答数据:

def build_docs_from_qa_csv(csv_path, question_col, answer_col, ...):
    # CSV 格式:问题列、答案列、来源列
    # 转换为 LangChain Document 对象
    content = "问题:" + q + "\n" + "答案:" + a
    metadata = {"source": src}
    return Document(page_content=content, metadata=metadata)

导入时自动去重,避免重复数据进入向量库。


4. 部署方案#

4.1 Docker Compose 部署#

项目提供完整的 Docker Compose 配置,包含所有依赖服务:

services:
  app:
    # FastAPI 应用服务
    ports:
      - "8081:8000"
    depends_on:
      - ollama
      - milvus-standalone

  ollama:
    # 本地 LLM 服务
    image: ollama/ollama:0.13.0
    ports:
      - "11434:11434"

  milvus-standalone:
    # 向量数据库(嵌入式 ETCD)
    image: milvusdb/milvus:v2.6.4
    environment:
      - ETCD_USE_EMBED=true
      - DEPLOY_MODE=STANDALONE
    ports:
      - "19530:19530"

  attu:
    # Milvus Web 管理界面
    image: zilliz/attu:v2.6
    ports:
      - "8000:3000"

4.2 环境变量配置#

# Ollama 服务地址
CUSTOMER_SERVICE_OLLAMA_HOST=http://localhost:11434

# Milvus 数据库地址
MILVUS_HOST=localhost
MILVUS_PORT=19530

# LLM 类型选择
LLM_TYPE=1

# DeepSeek API(如使用云端模型)
DEEPSEEK_API_KEY=your_api_key

4.3 启动命令#

# 启动所有服务
docker-compose up -d

# 查看服务状态
docker-compose ps

# 查看日志
docker-compose logs -f app

5. API 接口文档#

5.1 智能问答#

接口地址POST /chat

请求参数

{
  "question": "如何重置密码?",
  "openid": "user123"
}

返回结果

{
  "answer": "您可以通过以下步骤重置密码..."
}

5.2 问题推荐#

接口地址POST /suggest_questions

请求参数

{
  "question": "账号登录失败"
}

返回结果

{
  "questions": [
    "如何修改绑定的手机号?",
    "账号被锁定怎么办?",
    "如何联系人工客服?"
  ]
}

5.3 知识库导入#

接口地址POST /ingest_qa_csv

请求参数

{
  "csv_path": "questionLibrary.csv",
  "collection_name": "customer_service_rag",
  "chunk_size": 1000,
  "chunk_overlap": 200,
  "source_prefix": "客服问答库"
}

6. 总结#

本系统采用 RAG 架构,结合向量检索与大语言模型,实现了高效的智能客服能力。系统具备以下特点:

  • 可扩展:支持多种 LLM 模型灵活切换
  • 易部署:Docker Compose 一键部署
  • 可维护:模块化设计,代码清晰
  • 高性能:Milvus 向量检索,亚秒级响应

如需进一步了解项目细节,请参考源码注释或联系开发团队。

阅读全文

文明6 RAG 知识库

1. 概述#

本项目基于 Civilopedia 构建《文明6》RAG 问答系统,支持用户询问游戏相关的各类问题。

核心挑战:游戏知识涉及多个领域(伟人、建筑、单位、奇观等),用户问题往往跨领域。例如问"秦始皇的特色单位是什么",需要同时检索"领袖"和"单位"两个知识库。

解决方案:路由判定 + 多路检索。用户提问后,系统先判断涉及哪些知识库,再并行召回、聚合生成。


2. 整体架构#

系统分为三大阶段:

阶段职责关键技术
数据采集抓取、解析、入库BeautifulSoup, LangChain
检索生成路由判定、多路召回、生成回答Ollama LLM, Milvus, LangChain
服务部署API 服务化FastAPI, Docker Compose

3. 数据采集层#

3.1 采集策略#

数据来源为 Civilopedia 网站,共 15 个采集器:

采集器主题说明
fetch_great_person.ipynb伟人采集所有伟人信息
fetch_building.ipynb建筑采集建筑属性
fetch_unit.ipynb单位采集单位属性
fetch_wonder.ipynb奇观采集奇观属性
fetch_religion.ipynb宗教采集宗教信息
fetch_leader.ipynb领袖采集领袖信息
… 共15个

3.2 解析与文档化#

每个采集器的工作流程相同:

  1. 构造 URL 列表:根据主题构造待抓取页面 ID 列表
  2. 发送请求:requests.get 抓取页面
  3. 解析 HTML:BeautifulSoup 提取关键字段
  4. 封装 Document:LangChain Document 对象

伟人采集关键代码(fetch_great_person.ipynb):

from bs4 import BeautifulSoup
from langchain_core.documents import Document
import requests

base_url = 'https://www.civilopedia.net/zh-CN/gathering-storm/greatpeople/'
url = f"{base_url}great_person_individual_{person_id}"
response = requests.get(url)
soup = BeautifulSoup(response.content, 'html.parser')

# 提取关键字段
name = soup.find('div', class_='App_pageHeaderText__SsfWm').get_text()
# 提取特色能力、身份等...

doc = Document(
    page_content=f"伟人姓名:{name}\n特色能力:{abilities}\n身份:{duty}",
    metadata={"source": url}
)

3.3 文本分词与入库#

from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_community.vectorstores import Milvus
from langchain_ollama import OllamaEmbeddings

# 分词:chunk=1000, overlap=200
text_splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=200)
split_docs = text_splitter.split_documents(docs)

# 向量化入库
embeddings = OllamaEmbeddings(model="embeddinggemma")
vectorstore = Milvus.from_documents(
    split_docs,
    embedding=embeddings,
    collection_name="great_person"
)

4. 向量存储层#

4.1 Milvus Collection 设计#

Collection存储内容示例字段
belief万神殿信仰信仰名、效果
religion宗教宗教名、信徒领袖
country文明国家文明特性
leader领袖名称、特色能力
building建筑名称、成本、产出
wonder奇观名称、建造条件
resource资源类型、分布
citystate城邦名称、增益类型
district城区名称、建造条件
feature地形地形效果
great_person伟人名称、时代、特长
improvement改良设施名称、建造条件
moment历史时刻名称、触发条件
unit单位名称、强度、升级
unit_promotion晋升名称、效果

4.2 向量化#

使用 embeddinggemma 模型进行向量化,存入 Milvus。


5. 检索生成层#

这是系统的核心,分为三个步骤:

5.1 路由判定#

问题:用户问题可能涉及多个领域,如何确定检索范围?

方案:让 LLM 自主判断,用 Pydantic 模型保证输出稳定。

RouteQuery 定义(server.py):

from pydantic import BaseModel, Field
from typing import List, Literal

class RouteQuery(BaseModel):
    tables: List[Literal[
        "belief", "religion", "country", "leader", "building", "wonder",
        "resource", "citystate", "district", "feature", "great_person",
        "improvement", "moment", "unit", "unit_promotion"
    ]] = Field(description="这个问题与哪些关键字有关,返回对应的表名")

判定流程(server.py):

import os
from langchain_ollama import ChatOllama, OllamaEmbeddings
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser

# 第一次调用:让 LLM 分析问题
llm = ChatOllama(model="gpt-oss:120b-cloud")
first_llm = ChatOllama(model="llama3.1")
prompt = ChatPromptTemplate.from_template(template)
chain = prompt | first_llm | StrOutputParser()
response = chain.invoke({"question": question})

# 第二次调用:结构化输出保证 JSON 稳定
structured_llm = ChatOllama(model="llama3.1")
structured_chain = structured_llm.with_structured_output(RouteQuery)
result = structured_chain.invoke(response)
# result.tables 即确定的检索范围
用户问题判定结果
“秦始皇的特色单位是什么”["leader", "unit"]
“哪些领袖信仰天主教”["religion", "leader"]
“科学类伟人有哪些”["great_person"]

5.2 多路检索#

问题:跨领域查询需要同时从多个 Collection 召回。

方案:为每个涉及的 Collection 创建独立检索器,并行执行。

from langchain_core.runnables import RunnableParallel
from langchain_ollama import OllamaEmbeddings
from lib import get_vectorstore_from_milvus

embeddings = OllamaEmbeddings(model="embeddinggemma")

# 为每个涉及的表创建检索器
retriever_dict = {}
for table in result.tables:
    vectorstore = get_vectorstore_from_milvus(
        embeddings,
        collection_name=table,
        connection_args={"host": MILVUS_HOST, "port": MILVUS_PORT}
    )
    retriever_dict[table] = vectorstore.as_retriever(search_kwargs={"k": 100})

# RunnableParallel 并行执行,结果自动聚合
multi_retriever = RunnableParallel(retriever_dict)

5.3 回答生成#

原则:严格基于检索结果,禁止编造和省略。

提示词约束(lib.py):

你是一名文明6的专家,根据用户的问题严格回答:
1. 只能使用上下文中的内容,禁止使用外部知识
2. 按 source 逐条列出,不合并
3. 不省略、不推断

RAG Chain(lib.py):

from langchain_core.runnables import RunnableParallel, RunnablePassthrough
from langchain_core.prompts import ChatPromptTemplate
from langchain_ollama import ChatOllama
from langchain_core.output_parsers import StrOutputParser

def generate_answer_by_multiple_retriever(question, multiple_retriever, llm):
    template = """..."""

    prompt = ChatPromptTemplate.from_template(template)

    rag_chain = (
        RunnableParallel({
            "context": multiple_retriever,
            "question": RunnablePassthrough(),
        })
        | prompt
        | llm
        | StrOutputParser()
    )
    return rag_chain.invoke(question)

6. 服务接口#

FastAPI 提供单一端点(server.py):

from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()

class AskRequest(BaseModel):
    question: str

class AskResponse(BaseModel):
    answer: str

@app.post("/ask", response_model=AskResponse)
def ask(req: AskRequest):
    answer = generate_answer(req.question.strip())
    return AskResponse(answer=answer)

调用示例

// POST /ask
// 请求
{ "question": "哪些领袖信仰天主教" }

// 响应
{ "answer": "..." }

7. 部署架构#

Docker Compose 编排四个服务:

服务镜像端口职责
app自定义8081RAG API
ollamaollama/ollama:0.13.011434LLM 推理 + Embedding
milvusmilvusdb/milvus:v2.6.419530向量数据库
attuzilliz/attu:v2.68000Milvus 管理界面

核心环境变量

变量说明
CIVI6_OLLAMA_HOSTOllama 服务地址
MILVUS_HOSTMilvus 服务地址
MILVUS_PORTMilvus 端口

8. 设计亮点#

设计意图
路由判定按需检索,避免全量扫描,节省资源
结构化输出Pydantic + 二次调用确保 JSON 稳定
多路并行RunnableParallel 实现真正并行
严格回答提示词约束保证答案准确性
按需扩展新增领域只需新建 Collection
阅读全文

微调原神角色信息

简介#

源码地址

业余时间研究了大语言模型的微调技巧,也成功微调出了一个原神的大模型

我微调的大模型要达到的效果是

问:钟离是什么性别?
答:钟离是男性

问:钟离来自什么国家?
答:钟离来自璃月

问:钟离掌握什么元素力?
答:岩元素

问:钟离的身份是什么?
答:往生堂客卿,岩神

问: 钟离的性格特点是什么?
答: 沉稳,深知璃月文化底蕴

准备事项#

python环境#

我的requirements.txt文件内容如下

transformers>=4.46.3
datasets>=3.4.1
accelerate==1.5.2
peft==0.14.0
trl==0.16.0
tokenizers==0.20.3
gradio==5.20.0
pandas==2.2.3
scipy
einops
sentencepiece
tiktoken
protobuf
uvicorn
pydantic
fastapi
sse-starlette
matplotlib==3.10.1
fire
packaging
pyyaml
numpy==1.26.4
av
librosa

安装依赖包 pip install -r requirements.txt

数据集#

我的原始数据集是从huggingface下载的,原始数据格式大致如下

{
    "丽莎": {
        "性别": "成年女性",
        "国籍": "蒙德",
        "元素力": "雷元素",
        "身份": "西风骑士团图书管理员",
        "性格特征": "喜欢挑逗人的魔法师大姐姐"
    },
    "行秋": {
        "性别": "少年男性",
        "国籍": "璃月",
        "元素力": "水元素",
        "身份": "飞云商会少爷",
        "性格特征": "行侠仗义的侠客"
    },
    "钟离": {
        "性别": "成年男性",
        "国籍": "璃月",
        "元素力": "岩元素",
        "身份": "往生堂客卿,岩神",
        "性格特征": "沉稳,深知璃月文化底蕴"
    },
    "温迪": {
        "性别": "少年男性",
        "国籍": "蒙德",
        "元素力": "风元素",
        "身份": "吟游诗人,风神",
        "性格特征": "喜欢开玩笑的轻佻少年"
    }
}

这样的数据集不能直接喂给大模型,我们需要处理,对于每一个角色,都做如下处理,以钟离为例

[{
        "role":"user",
        "content":"请问钟离的性别是什么?"
    },
    {
        "role":"assistant",
        "content":"钟离是男性"
    },
    {
        "role":"user",
        "content":"钟离是男性还是女性?"
    },
    {
        "role":"assistant",
        "content":"钟离是男性"
    },
    {
        "role":"user",
        "content":"钟离是男性还是女性?"
    },
    {
        "role":"assistant",
        "content":"钟离是男性"
    },
    {
        "role":"user",
        "content":"钟离是男人还是女人?"
    },
    {
        "role":"assistant",
        "content":"钟离是男性"
    },
    {
        "role":"user",
        "content":"钟离先生,他是男孩还是女孩?"
    },
    {
        "role":"assistant",
        "content":"钟离是男性"
    },
    {
        "role":"user",
        "content":"钟离先生,他是什么性别?"
    },
    {
        "role":"assistant",
        "content":"钟离是男性"
    },
    {
        "role":"user",
        "content":"钟离先生,是什么性别呢?"
    },
    {
        "role":"assistant",
        "content":"钟离是男性"
    },
    {
        "role":"user",
        "content":"钟离什么性别"
    },
    {
        "role":"assistant",
        "content":"钟离是男性"
    },
    {
        "role":"user",
        "content":"钟离是男还是女啊"
    },
    {
        "role":"assistant",
        "content":"钟离是男性"
    },
    {
        "role":"user",
        "content":"钟离是男孩还是女孩?"
    },
    {
        "role":"assistant",
        "content":"钟离是男性"
    },
    {
        "role":"user",
        "content":"钟离先生是男孩还是女孩?"
    },
    {
        "role":"assistant",
        "content":"钟离是男性"
    },
    {
        "role":"user",
        "content":"钟离先生是什么性别?"
    },
    {
        "role":"assistant",
        "content":"钟离是男性"
    },
    {
        "role":"user",
        "content":"请问钟离的国籍是什么?"
    },
    {
        "role":"assistant",
        "content":"钟离来自璃月"
    },
    {
        "role":"user",
        "content":"钟离先生是属于哪个国家的?"
    },
    {
        "role":"assistant",
        "content":"钟离来自璃月"
    },
    {
        "role":"user",
        "content":"钟离先生来自哪个国家?"
    },
    {
        "role":"assistant",
        "content":"钟离来自璃月"
    },
    {
        "role":"user",
        "content":"你好,请问钟离先生来自哪里?"
    },
    {
        "role":"assistant",
        "content":"钟离来自璃月"
    },
    {
        "role":"user",
        "content":"钟离先生从哪里来的?"
    },
    {
        "role":"assistant",
        "content":"钟离来自璃月"
    },
    {
        "role":"user",
        "content":"钟离从哪里来的?"
    },
    {
        "role":"assistant",
        "content":"钟离来自璃月"
    },
    {
        "role":"user",
        "content":"钟离先生,来自何方?"
    },
    {
        "role":"assistant",
        "content":"钟离来自璃月"
    },
    {
        "role":"user",
        "content":"钟离来自哪个国家?"
    },
    {
        "role":"assistant",
        "content":"钟离来自璃月"
    },
    {
        "role":"user",
        "content":"钟离先生来自什么国家?"
    },
    {
        "role":"assistant",
        "content":"钟离来自璃月"
    },
    {
        "role":"user",
        "content":"请问钟离的元素力是什么?"
    },
    {
        "role":"assistant",
        "content":"岩元素"
    },
    {
        "role":"user",
        "content":"钟离先生掌握哪种元素力?"
    },
    {
        "role":"assistant",
        "content":"岩元素"
    },
    {
        "role":"user",
        "content":"钟离先生能控制什么元素力?"
    },
    {
        "role":"assistant",
        "content":"岩元素"
    },
    {
        "role":"user",
        "content":"你好,请问钟离先生使用哪种元素?"
    },
    {
        "role":"assistant",
        "content":"岩元素"
    },
    {
        "role":"user",
        "content":"你好,钟离先生使用哪种元素?"
    },
    {
        "role":"assistant",
        "content":"岩元素"
    },
    {
        "role":"user",
        "content":"钟离使用哪种元素?"
    },
    {
        "role":"assistant",
        "content":"岩元素"
    },
    {
        "role":"user",
        "content":"钟离先生会用哪种元素?"
    },
    {
        "role":"assistant",
        "content":"岩元素"
    },
    {
        "role":"user",
        "content":"钟离使用的元素是什么?"
    },
    {
        "role":"assistant",
        "content":"岩元素"
    },
    {
        "role":"user",
        "content":"钟离的元素是什么?"
    },
    {
        "role":"assistant",
        "content":"岩元素"
    },
    {
        "role":"user",
        "content":"钟离先生的元素是什么?"
    },
    {
        "role":"assistant",
        "content":"岩元素"
    },
    {
        "role":"user",
        "content":"钟离用的是什么元素?"
    },
    {
        "role":"assistant",
        "content":"岩元素"
    },
    {
        "role":"user",
        "content":"钟离的元素属性是什么?"
    },
    {
        "role":"assistant",
        "content":"岩元素"
    },
    {
        "role":"user",
        "content":"钟离能控制什么元素力?"
    },
    {
        "role":"assistant",
        "content":"钟离控制岩元素"
    },
    {
        "role":"user",
        "content":"钟离会用哪种元素?"
    },
    {
        "role":"assistant",
        "content":"岩元素"
    },
    {
        "role":"user",
        "content":"钟离什么元素?"
    },
    {
        "role":"assistant",
        "content":"岩元素"
    },
    {
        "role":"user",
        "content":"钟离使用什么元素力?"
    },
    {
        "role":"assistant",
        "content":"钟离使用岩元素"
    },
    {
        "role":"user",
        "content":"钟离会用哪种元素?"
    },
    {
        "role":"assistant",
        "content":"岩元素"
    },
    {
        "role":"user",
        "content":"钟离的元素是什么?"
    },
    {
        "role":"assistant",
        "content":"岩元素"
    },
    {
        "role":"user",
        "content":"请问钟离的身份是什么?"
    },
    {
        "role":"assistant",
        "content":"钟离是往生堂客卿,岩神"
    },
    {
        "role":"user",
        "content":"钟离先生是什么身份?"
    },
    {
        "role":"assistant",
        "content":"钟离是往生堂客卿,岩神"
    },
    {
        "role":"user",
        "content":"钟离先生的职责是什么?"
    },
    {
        "role":"assistant",
        "content":"钟离是往生堂客卿,岩神"
    },
    {
        "role":"user",
        "content":"你好,请问钟离先生是什么职务?"
    },
    {
        "role":"assistant",
        "content":"钟离是往生堂客卿,岩神"
    },
    {
        "role":"user",
        "content":"你好,请问钟离先生是做什么工作的?"
    },
    {
        "role":"assistant",
        "content":"钟离是往生堂客卿,岩神"
    },
    {
        "role":"user",
        "content":"钟离先生是做什么工作的?"
    },
    {
        "role":"assistant",
        "content":"钟离是往生堂客卿,岩神"
    },
    {
        "role":"user",
        "content":"钟离先生的身份是什么?"
    },
    {
        "role":"assistant",
        "content":"钟离是往生堂客卿,岩神"
    },
    {
        "role":"user",
        "content":"钟离先生的职业是什么?"
    },
    {
        "role":"assistant",
        "content":"钟离是往生堂客卿,岩神"
    },
    {
        "role":"user",
        "content":"钟离的职业是什么?"
    },
    {
        "role":"assistant",
        "content":"钟离是往生堂客卿,岩神"
    },
    {
        "role":"user",
        "content":"钟离的工作是什么?"
    },
    {
        "role":"assistant",
        "content":"钟离是往生堂客卿,岩神"
    },
    {
        "role":"user",
        "content":"钟离先生的工作是什么?"
    },
    {
        "role":"assistant",
        "content":"钟离是往生堂客卿,岩神"
    },
    {
        "role":"user",
        "content":"钟离是什么身份?"
    },
    {
        "role":"assistant",
        "content":"钟离是往生堂客卿,岩神"
    },
    {
        "role":"user",
        "content":"钟离的职责是什么?"
    },
    {
        "role":"assistant",
        "content":"钟离是往生堂客卿,岩神"
    },
    {
        "role":"user",
        "content":"钟离是什么职务?"
    },
    {
        "role":"assistant",
        "content":"钟离是往生堂客卿,岩神"
    },
    {
        "role":"user",
        "content":"请问钟离的性格特征是什么?"
    },
    {
        "role":"assistant",
        "content":"钟离,沉稳,深知璃月文化底蕴"
    },
    {
        "role":"user",
        "content":"钟离先生是什么性格?"
    },
    {
        "role":"assistant",
        "content":"钟离,沉稳,深知璃月文化底蕴"
    },
    {
        "role":"user",
        "content":"钟离先生是什么性格的人?"
    },
    {
        "role":"assistant",
        "content":"钟离,沉稳,深知璃月文化底蕴"
    },
    {
        "role":"user",
        "content":"你好,请问钟离先生的性格特点是什么?"
    },
    {
        "role":"assistant",
        "content":"钟离,沉稳,深知璃月文化底蕴"
    },
    {
        "role":"user",
        "content":"你好,请问钟离先生有什么性格特点呢?"
    },
    {
        "role":"assistant",
        "content":"钟离,沉稳,深知璃月文化底蕴"
    },
    {
        "role":"user",
        "content":"你好,请问钟离有什么性格特点呢?"
    },
    {
        "role":"assistant",
        "content":"钟离,沉稳,深知璃月文化底蕴"
    },
    {
        "role":"user",
        "content":"你好,请问钟离的性格特点是什么?"
    },
    {
        "role":"assistant",
        "content":"钟离,沉稳,深知璃月文化底蕴"
    },
    {
        "role":"user",
        "content":"钟离是什么性格?"
    },
    {
        "role":"assistant",
        "content":"钟离,沉稳,深知璃月文化底蕴"
    },
    {
        "role":"user",
        "content":"钟离是什么性格的人?"
    },
    {
        "role":"assistant",
        "content":"钟离,沉稳,深知璃月文化底蕴"
    },
    {
        "role":"user",
        "content":"钟离什么性格?"
    },
    {
        "role":"assistant",
        "content":"沉稳,深知璃月文化底蕴"
    },
    {
        "role":"user",
        "content":"钟离是什么性格?"
    },
    {
        "role":"assistant",
        "content":"沉稳,深知璃月文化底蕴"
    },
    {
        "role":"user",
        "content":"钟离有什么性格特征?"
    },
    {
        "role":"assistant",
        "content":"沉稳,深知璃月文化底蕴"
    },
    {
        "role":"user",
        "content":"钟离的性格特征是什么?"
    },
    {
        "role":"assistant",
        "content":"钟离,沉稳,深知璃月文化底蕴"
    },
    {
        "role":"user",
        "content":"钟离的性格特点是啥?"
    },
    {
        "role":"assistant",
        "content":"沉稳,深知璃月文化底蕴"
    },
    {
        "role":"user",
        "content":"钟离的性格是什么?"
    },
    {
        "role":"assistant",
        "content":"钟离,沉稳,深知璃月文化底蕴"
    }]

对于每个问题,我设计多样的提问方式,这样可以让模型更好地理解我的提问

下载数据集#

数据集经过转换后,我上传到了huggingface,从这个仓库下载

# 加载数据集
from datasets import load_dataset
raw_datasets=load_dataset('maxwell60701/genshin-impact-role-chat-model')
raw_datasets
DatasetDict({
    train: Dataset({
        features: ['messages'],
        num_rows: 3480
    })
    test: Dataset({
        features: ['messages'],
        num_rows: 870
    })
})

可以看到我的训练集为3480行,测试集为870行

raw_datasets['train'][0]

打印一笔训练数据集看看格式

{'messages': [{'content': '砂糖女士来自什么国家?', 'role': 'user'},
  {'content': '砂糖来自蒙德', 'role': 'assistant'}]}

训练#

分词#

from transformers import AutoTokenizer, AutoModelForCausalLM
import torch
check_point='deepseek-ai/DeepSeek-R1-Distill-Qwen-7B'  
# 加载分词器
tokenizer = AutoTokenizer.from_pretrained(check_point) 
# 加载大模型
model = AutoModelForCausalLM.from_pretrained(check_point,torch_dtype = torch.float16, device_map ="auto")
print(tokenizer)

首先加载模型以及它的tokenizer,tokenizer是分词器的意思。tokenizer在大语言模型中是一个非常重要的概念。比如 “钟离来自什么国家”,会被分词为

["<|begin▁of▁sentence|>","钟","离","来自","什么","国家"]

并且转换成一串数字

[151646,  75061,  99372, 101919,  99245,  99599]

这些都是分词器提前设置好的,在大模型目录下的tokenizer.json中可以查询到具体的分词详情

tokenizer.add_special_tokens({
    "additional_special_tokens": ["<|User|>", "<|Assistant|>"]
})
tokenizer.add_special_tokens({'pad_token': '<|pad|>'})
tokenizer.pad_token = '<|pad|>'
print(tokenizer.pad_token_id)
print(tokenizer.eos_token_id)
model.resize_token_embeddings(len(tokenizer))

添加几个special_tokens,因为在deepseek中没有<|Assistant|>这个分词,我们需要手动添加, 另外deepseek中,pad_token 和 eos_token是一样的,对于后面的DataCollatorForCompletionOnlyLM无法正确识别,所以这边必须手动再添加一个pad_token

转换格式#

我们需要将数据通过apply_chat_template 方法,转换为模型可识别的格式

def tokenizer_convert(example):
  prompt=tokenizer.apply_chat_template(example['messages'],tokenize=False)
  return {'text':prompt}
train_datasets=raw_train_datasets.map(tokenizer_convert).remove_columns('messages')
train_datasets[0]['text']
test_datasets=raw_test_datasets.map(tokenizer_convert).remove_columns('messages')
test_datasets[0]['text']
'<|begin▁of▁sentence|><|User|>砂糖女士来自什么国家?<|Assistant|>砂糖来自蒙德<|end▁of▁sentence|>'

只有[{'content': '砂糖女士来自什么国家?', 'role': 'user'},{'content': '砂糖来自蒙德', 'role': 'assistant'}] 的格式,才能被模型识别,并被apply_chat_template方法所转换,这是由大模型的tokenizer_config.json文件中的一个配置项chat_template决定的

定义 collator#

接下来定义一个collator,它会将数据进一步整合

from trl import DataCollatorForCompletionOnlyLM
data_collator = DataCollatorForCompletionOnlyLM(
    tokenizer=tokenizer,
    instruction_template="<|User|>",
    response_template = "<|Assistant|>"
)
data_collator

定义 trainer#

先定义SFTConfig

from trl import SFTTrainer,SFTConfig

training_args=SFTConfig(
    per_device_train_batch_size=2,
    gradient_accumulation_steps=8,
    gradient_checkpointing=True,
    lr_scheduler_type="cosine",
    bf16=True,
    logging_steps=10,
    output_dir="./",
    save_strategy="epoch",
    num_train_epochs=10,
    eval_steps=10,
    eval_strategy="epoch",
    learning_rate=5e-5,
    save_steps=15,
    push_to_hub=False,
    dataset_text_field="text",
    report_to="none",
)

我们采用lora微调,定义lora

from peft import LoraConfig,get_peft_model
peft_config=LoraConfig(
    r=32,  
    lora_alpha=64,
    lora_dropout=0.05,
    bias="none",
    target_modules=["embed_tokens", "lm_head","q_proj","k_proj","v_proj","o_proj"],
    task_type="CAUSAL_LM"
)

定义SFTTrainer

trainer= SFTTrainer(
    model=model,
    train_dataset=train_datasets,
    eval_dataset=test_datasets,
    args=training_args,
    peft_config=peft_config,
    processing_class=tokenizer,
    data_collator=data_collator,
    callbacks=[LossPlotCallback()]
)

绘制损失曲线#

定义LossPlotCallback()

class LossPlotCallback(TrainerCallback):
    def __init__(self):
        self.train_losses = []
        self.eval_losses = []
        self.train_epoch = []
        self.eval_epoch = []

    def on_log(self, args, state, control, logs=None, **kwargs):
        if logs is None:
            return
        print(f"self: {self}", flush=True)
        print(f"logs: {logs}", flush=True)
        epoch = logs.get("epoch")
        if epoch is None:
            return
        if "loss" in logs:
            self.train_epoch.append(epoch)
            self.train_losses.append(logs["loss"])
        if "eval_loss" in logs:
            self.eval_epoch.append(epoch)
            self.eval_losses.append(logs["eval_loss"])
        self.plot_losses()

    def on_evaluate(self, args, state, control, metrics, **kwargs):
        if metrics and "eval_loss" in metrics:
            self.eval_losses.append(metrics["eval_loss"])
            print(f"[Eval] Step {state.global_step}: eval_loss = {metrics['eval_loss']}")
            
    def plot_losses(self):
        plt.figure(figsize=(10, 6))
        plt.plot(self.train_epoch, self.train_losses, label='Train Loss', marker='o')
        plt.plot(self.eval_epoch, self.eval_losses, label='Eval Loss', marker='x')
        plt.xlabel("Epoch")
        plt.ylabel("Loss")
        plt.title("Training & Evaluation Loss")
        plt.legend()
        plt.grid(True)
        plt.tight_layout()
        plt.savefig("loss_plot.png")
        plt.close()

这个方法会生成一张图表,用于统计和分析损失率loss

我们来打印看下最终喂给大模型的数据是什么样的格式

dataloader = trainer.get_train_dataloader()

for batch in dataloader:
    print(batch)
    break

打印第一行结果

{'input_ids': tensor([[151646, 151646, 151644,  93488,  93488, 101523, 100660, 104066, 102021,
             30, 151645,  93488,  93488, 101523,  20412, 103324,  17340,  99410,
          75117,  99412, 151643],
        [151665, 151646, 151646, 151644,  99315,  69249, 120827,  17340, 101919,
         104673,  99599,     30, 151645,  99315,  69249, 120827,  17340, 101919,
         102995, 100866, 151643]], device='cuda:0'), 'attention_mask': tensor([[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
        [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]],
       device='cuda:0'), 'labels': tensor([[  -100,   -100,   -100,   -100,   -100,   -100,   -100,   -100,   -100,
           -100,   -100,  93488,  93488, 101523,  20412, 103324,  17340,  99410,
          75117,  99412, 151643],
        [  -100,   -100,   -100,   -100,   -100,   -100,   -100,   -100,   -100,
           -100,   -100,   -100,   -100,  99315,  69249, 120827,  17340, 101919,
         102995, 100866, 151643]], device='cuda:0')}

input_ids: 指的是经过tokenizer分词后的结果,是一个矩阵数组,每一个数字都代表一个token,例如151646代表的就是<| begin__of__sentence |>

attention_mask: 因为每个对话的长度都是不一样的,所以为了让矩阵的长度一致,短的那行就需要自动填充,也就是添加padding作为填充符,0 代表是被填充的,否则为1

labels: <|Assistant|> 之前的分词都被标记为-100,只将回答的部分算作loss统计的一部分

开始训练#

trainer.train() 

损失图表

损失图表

损失曲线图

损失曲线图

trainer.save_model('genshin-impact-role-model') #训练结束后保存模型

合并模型#

将微调后的模型与基座模型合并

from  peft import PeftModel
from transformers import AutoTokenizer, AutoModelForCausalLM
import torch
check_point='deepseek-ai/DeepSeek-R1-Distill-Qwen-7B'
tokenizer = AutoTokenizer.from_pretrained(check_point)
model = AutoModelForCausalLM.from_pretrained(check_point,device_map="cuda",torch_dtype=torch.float16)

tokenizer.add_special_tokens({
   "additional_special_tokens": ["<|User|>", "<|Assistant|>"]
})
tokenizer.add_special_tokens({'pad_token': '<|pad|>'})
tokenizer.pad_token = '<|pad|>'
print(tokenizer.pad_token_id)
print(tokenizer.eos_token_id)
model.resize_token_embeddings(len(tokenizer))
peft_model=PeftModel.from_pretrained(model,'genshin-impact-role-model-7B')
merged_model=peft_model.merge_and_unload()
merged_model.save_pretrained('genshin-impact-role-model-7B-merged')
tokenizer.save_pretrained('genshin-impact-role-model-7B-merged')

推理#


message='砂糖来自哪个国家'

chat=[{"role":"user","content":message}]


from transformers import AutoTokenizer, AutoModelForCausalLM
import torch
check_point='genshin-impact-role-model-7B-merged'
tokenizer = AutoTokenizer.from_pretrained(check_point)
model = AutoModelForCausalLM.from_pretrained(check_point,torch_dtype=torch.float16, device_map="cuda")
model.to("cuda")

prompt=tokenizer.apply_chat_template(chat,tokenize=True,add_generation_prompt=True,return_tensors='pt')
print(tokenizer.chat_template)
prompt=prompt.to(model.device)

output=model.generate(prompt,
    temperature=0.1,
    top_p=0.1,
    top_k=10,
    max_length=512,
    eos_token_id=tokenizer.eos_token_id,
    pad_token_id=tokenizer.pad_token_id
   ) # 提前停止生成,防止不必要的输出)

print(tokenizer.decode(output[0],skip_special_tokens=True))

输出

砂糖来自蒙德

评估#

from transformers import AutoTokenizer,AutoModelForCausalLM
import torch
model_name='maxwell60701/genshin-impact-role-chat-model'
tokenizer = AutoTokenizer.from_pretrained(model_name)
model = AutoModelForCausalLM.from_pretrained(model_name, device_map="auto", torch_dtype=torch.float16)
from datasets import load_dataset
raw_datasets=load_dataset('maxwell60701/genshin-impact-role-chat-model')
raw_datasets

我采用bertscore来进行评估,它支持中文

from evaluate import load
bertscore = load("bertscore")
import pandas as pd

# 定义方法写入csv
def write_to_csv(question, answer, prediction, precision,recall,f1,hash):
    data = {'question': [question], 'answer': [answer], 'prediction': [prediction], 'precision': [precision],'recall':[recall],'f1':[f1],'hash':[hash]}
    df = pd.DataFrame(data)
    df.to_csv('evalute.csv', mode='a', header=False, index=False)
for item in raw_datasets['train']:
   message=[item["messages"][0]]
   question=item["messages"][0]['content']
   answer=item["messages"][1]['content']
   print(question)
   print(answer)
   prompt=tokenizer.apply_chat_template(message,tokenize=True,add_generation_prompt=True,return_tensors="pt")
   prompt=prompt.to(model.device)
   output=model.generate(prompt,
                         temperature=0.1,
                         top_p=0.1, 
                         top_k=10,
                         max_new_tokens=512,
                         pad_token_id=tokenizer.pad_token_id,
                         eos_token_id=tokenizer.eos_token_id)
   response=tokenizer.decode(output[0], skip_special_tokens=True)
   if "</think>" in response:
      prediction = response.split("</think>")[-1].strip()
   elif "<think>" in response:
      prediction = response.split("<think>")[-1].strip()
   else:
      prediction = response.strip()   
   print(prediction)
   predictions = [prediction]
   references = [answer]
   # 比较预期值和实际值得到分数
   results = bertscore.compute(predictions=predictions, references=references, lang="zh", model_type="bert-base-chinese")
   print(results)
   precision=results['precision'][0]
   recall=results['recall'][0]
   f1=results['f1'][0]
   hash=results['hashcode']
   print(results['precision'][0])
   write_to_csv(question, answer, prediction, precision,recall,f1,hash)
for item in raw_datasets['test']:
   message=[item["messages"][0]]
   question=item["messages"][0]['content']
   answer=item["messages"][1]['content']
   print(question)
   print(answer)
   prompt=tokenizer.apply_chat_template(message,tokenize=True,add_generation_prompt=True,return_tensors="pt")
   prompt=prompt.to(model.device)
   output=model.generate(prompt,
                         temperature=0.1,
                         top_p=0.1, 
                         top_k=10,
                         max_new_tokens=512,
                         pad_token_id=tokenizer.pad_token_id,
                         eos_token_id=tokenizer.eos_token_id)
   response=tokenizer.decode(output[0], skip_special_tokens=True)
   if "</think>" in response:
      prediction = response.split("</think>")[-1].strip()
   elif "<think>" in response:
      prediction = response.split("<think>")[-1].strip()
   else:
      prediction = response.strip()   
   print(prediction)
   predictions = [prediction]
   references = [answer]
   results = bertscore.compute(predictions=predictions, references=references, lang="zh", model_type="bert-base-chinese")
   print(results)
   precision=results['precision'][0]
   recall=results['recall'][0]
   f1=results['f1'][0]
   hash=results['hashcode']
   print(results['precision'][0])
   write_to_csv(question, answer, prediction, precision,recall,f1,hash)

以上代码大致含义是将正确的答案,与模型推理的答案,进行比较打分

bertscore有三个指标

precision(精确率):生成答案中有多少内容与参考答案(标准答案)语义相符。衡量生成内容的“准确性”。

recall(召回率):参考答案中有多少内容被生成答案语义覆盖。衡量生成内容的“全面性”。

f1:精确率和召回率的调和平均值,是综合评价生成内容和参考答案语义相似度的指标。

可以用f1作为最终的评估指标

经过筛选,总数5100条,114条数据f1分数小于0.6

evaluate.csv

阅读全文