实战项目一:智能客服工单分类系统

目录


项目概述

客服工单每天涌入,人工分拣费时费力还容易出错——这正是 NLP 自动分类的用武之地。智能工单分类系统可以自动将问题分配到“咨询”“投诉”“售后”“技术”“其他”等对应部门,大幅降低运营成本,提升响应速度。

本项目追求轻量且可落地,核心目标如下:

  • 支持五大通用类别:咨询、投诉、售后、技术、其他
  • 准确率 > 90%,加权 F1 > 85%
  • 单条推理耗时 < 0.5 秒,支持批处理
  • Docker 一键部署,内置健康检查与标准化分类接口

整个技术栈经过精心挑选,力求简单高效:

模块选型
数据处理Pandas、Scikit‑learn、Imbalanced‑learn
模型框架Hugging Face Transformers、PyTorch
预训练模型hfl/chinese-roberta-wwm-ext(专为中文优化,轻量好用)
部署框架FastAPI + Uvicorn
容器化Docker

数据预处理

1. 数据加载与可视化

假设原始数据保存在 customer_tickets.csv 中,包含 title(标题)、content(内容)和 category(所属类别)三个关键字段。先做基本检查,了解数据分布。

import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns

df = pd.read_csv('customer_tickets.csv')
print(f"数据规模: {df.shape[0]} 条, {df.shape[1]} 列")
print(f"核心字段缺失值:\n{df[['title','content','category']].isnull().sum()}")

# 可视化类别分布 —— 查看是否有严重的类别不平衡
category_dist = df['category'].value_counts()
plt.figure(figsize=(8, 4))
sns.barplot(x=category_dist.index, y=category_dist.values)
plt.title("工单类别分布")
plt.xticks(rotation=30)
plt.show()

许多客服场景中,“咨询”类工单可能占 60% 以上,而“投诉”类极少,这会直接影响模型训练效果,后面我们会专门处理。

2. 文本清洗与标签编码

为了让模型聚焦语义,需要将标题和内容合并成完整文本,同时去除 URL、邮箱、手机号等无关噪音,并移除中英文标点。

import re
import string
from zhon.hanzi import punctuation

def clean_text(text):
    """基础中文文本清洗:保留有用内容,去除冗余符号"""
    if pd.isna(text):
        return ""
    text = str(text)
    # 合并多余空格
    text = re.sub(r'\s+', ' ', text).strip()
    # 去除URL、邮箱、11位手机号(按需保留)
    text = re.sub(r'http[s]?://\S+|@\S+|\d{11}', ' ', text)
    # 去除中英文标点
    text = re.sub(f'[{punctuation}{string.punctuation}]', ' ', text)
    return text

# 构建新字段
df['text'] = df['title'].fillna('') + ' ' + df['content'].fillna('')
df['cleaned_text'] = df['text'].apply(clean_text)
# 过滤太短的无效文本(如只有“你好”)
df = df[df['cleaned_text'].str.len() > 5]

# 类别转数字标签
label2id = {cat: i for i, cat in enumerate(df['category'].unique())}
id2label = {v: k for k, v in label2id.items()}
df['label'] = df['category'].map(label2id)

小提示:如果数据中包含敏感信息(如身份证号),建议在清洗阶段一并脱敏。此处根据实际业务调整即可。

3. 不平衡数据的轻量级处理

类别分布倾斜会引导模型偏向多数类。传统做法是过采样(如 SMOTE),但对文本数据容易生成无意义的“假”样本,破坏语义。这里我们采用分层抽样 + 类别权重的组合策略:

  • 分层抽样:保证训练集、验证集、测试集中各类别的比例与全量数据一致
  • 类别权重:在损失函数中提升少数类的权重,让模型“更在意”少样本类别
from sklearn.model_selection import train_test_split
from sklearn.utils.class_weight import compute_class_weight

# 先切出70%训练,剩余30%均分为验证集和测试集
train_df, temp_df = train_test_split(
    df, test_size=0.3, stratify=df['label'], random_state=42
)
val_df, test_df = train_test_split(
    temp_df, test_size=0.5, stratify=temp_df['label'], random_state=42
)

# 计算各类别权重
class_weights = compute_class_weight(
    'balanced', classes=list(label2id.values()), y=train_df['label']
)
class_weights = dict(zip(list(label2id.values()), class_weights))
print(f"类别权重: {class_weights}")

模型选型与对比

1. 先选两条路快速摸底

在投入大模型之前,用一个简单方案(TF‑IDF + 线性 SVM)和预训练模型(RoBERTa)做一次基准对比,直观感受效果差异。

方案准确率加权 F1训练时间单条推理时间适用场景
TF‑IDF + 线性 SVM82%79%< 5 分钟< 0.02 秒初步验证、资源受限场景
RoBERTa‑wwm‑ext95%94%~2 小时(单 GPU)~0.3 秒正式生产、高准确要求

显然,RoBERTa 在 F1 和准确率上完胜,而且单条 0.3 秒完全满足业务需求,所以将它作为最终方案。

2. RoBERTa 模型训练实战

我们直接使用 Hugging Face 的 Trainer,它能帮我们自动管理训练循环、评估、保存最佳模型等,非常方便。

from transformers import (
    AutoTokenizer, AutoModelForSequenceClassification,
    TrainingArguments, Trainer
)
from sklearn.metrics import accuracy_score, f1_score
import torch

# 1. 加载中文 RoBERTa
MODEL_NAME = "hfl/chinese-roberta-wwm-ext"
tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
model = AutoModelForSequenceClassification.from_pretrained(
    MODEL_NAME, num_labels=len(label2id),
    id2label=id2label, label2id=label2id
)

# 2. 自定义数据集(适配 Trainer)
class TicketDataset(torch.utils.data.Dataset):
    def __init__(self, texts, labels, tokenizer, max_len=256):
        self.texts = texts
        self.labels = labels
        self.tokenizer = tokenizer
        self.max_len = max_len

    def __len__(self):
        return len(self.texts)

    def __getitem__(self, idx):
        text = str(self.texts[idx])
        label = self.labels[idx]
        encoding = self.tokenizer(
            text, truncation=True, padding='max_length',
            max_length=self.max_len, return_tensors='pt'
        )
        return {
            'input_ids': encoding['input_ids'].flatten(),
            'attention_mask': encoding['attention_mask'].flatten(),
            'labels': torch.tensor(label, dtype=torch.long)
        }

# 3. 准备数据集
train_dataset = TicketDataset(
    train_df['cleaned_text'].tolist(), train_df['label'].tolist(), tokenizer
)
val_dataset = TicketDataset(
    val_df['cleaned_text'].tolist(), val_df['label'].tolist(), tokenizer
)

# 4. 定义评估指标(准确率 + 加权 F1)
def compute_metrics(eval_pred):
    predictions, labels = eval_pred
    predictions = predictions.argmax(axis=-1)
    return {
        'accuracy': accuracy_score(labels, predictions),
        'f1': f1_score(labels, predictions, average='weighted')
    }

# 5. 训练参数配置
training_args = TrainingArguments(
    output_dir='./ticket_classifier',
    num_train_epochs=3,
    per_device_train_batch_size=16,
    per_device_eval_batch_size=32,
    learning_rate=2e-5,
    weight_decay=0.01,
    warmup_ratio=0.1,
    evaluation_strategy="epoch",
    save_strategy="epoch",
    load_best_model_at_end=True,
    metric_for_best_model="f1",
    fp16=torch.cuda.is_available(),     # 混合精度加速
    logging_steps=100,
    seed=42,
    report_to=None                      # 不上传 W&B
)

# 6. 启动训练
trainer = Trainer(
    model=model,
    args=training_args,
    train_dataset=train_dataset,
    eval_dataset=val_dataset,
    compute_metrics=compute_metrics,
    class_weight=class_weights         # 传入类别权重
)
trainer.train()
trainer.save_model('./best_ticket_classifier')

训练完成后,./best_ticket_classifier 目录下就保存了最佳的模型权重,可以直接用于部署。


快速部署API

为了将模型变成可供调用的服务,我们用 FastAPI 搭建一个轻量级 RESTful 接口,并提供健康检查和单条/批量分类两个核心端点。

1. FastAPI 接口代码

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
from transformers import pipeline
import torch
import time
from typing import List, Optional

app = FastAPI(
    title="智能客服工单分类API",
    version="1.0.0",
    description="轻量可落地的中文工单分类服务"
)

# 服务启动时加载模型(不会阻塞请求)
MODEL_PATH = "./best_ticket_classifier"
classifier = pipeline(
    "text-classification",
    model=MODEL_PATH,
    tokenizer=MODEL_PATH,
    device=0 if torch.cuda.is_available() else -1,
    truncation=True,
    max_length=256
)

# 请求与响应数据结构
class SingleTicket(BaseModel):
    title: str = Field(..., description="工单标题")
    content: Optional[str] = Field("", description="工单内容(可选)")

class BatchTickets(BaseModel):
    tickets: List[SingleTicket]

class SingleResponse(BaseModel):
    category: str
    confidence: float
    processing_time: float

class BatchResponse(BaseModel):
    results: List[SingleResponse]
    total_processing_time: float

@app.on_event("startup")
async def startup():
    print("模型加载完成,服务已启动!")

@app.get("/health", tags=["健康检查"])
async def health_check():
    return {"status": "healthy", "timestamp": time.strftime('%Y-%m-%d %H:%M:%S')}

@app.get("/categories", tags=["辅助接口"])
async def get_categories():
    """返回所有支持的类别列表"""
    return {"categories": list(classifier.model.config.id2label.values())}

@app.post("/classify/single", tags=["分类接口"], response_model=SingleResponse)
async def classify_single(ticket: SingleTicket):
    start = time.time()
    try:
        full_text = f"{ticket.title} {ticket.content}".strip()
        result = classifier(full_text)[0]
        return SingleResponse(
            category=result["label"],
            confidence=round(result["score"], 4),
            processing_time=round(time.time() - start, 4)
        )
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"分类失败: {str(e)}")

@app.post("/classify/batch", tags=["分类接口"], response_model=BatchResponse)
async def classify_batch(batch: BatchTickets):
    start = time.time()
    try:
        full_texts = [f"{t.title} {t.content}".strip() for t in batch.tickets]
        raw_results = classifier(full_texts, batch_size=8)
        results = [
            SingleResponse(
                category=r["label"],
                confidence=round(r["score"], 4),
                processing_time=0.0
            ) for r in raw_results
        ]
        return BatchResponse(
            results=results,
            total_processing_time=round(time.time() - start, 4)
        )
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"批量分类失败: {str(e)}")

if __name__ == "__main__":
    import uvicorn
    uvicorn.run(app, host="0.0.0.0", port=8000)

2. Docker 容器化一键部署

编写 Dockerfile 和 requirements.txt,保证环境一致性。

# Dockerfile
FROM python:3.10-slim

WORKDIR /app

# 安装编译依赖(PyTorch 等需要)
RUN apt-get update && apt-get install -y --no-install-recommends \
    gcc g++ git \
    && rm -rf /var/lib/apt/lists/*

# 分步安装 Python 依赖,利用缓存加速
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# 复制模型和 API 代码
COPY best_ticket_classifier /app/best_ticket_classifier
COPY api.py .

EXPOSE 8000

CMD ["uvicorn", "api:app", "--host", "0.0.0.0", "--port", "8000"]
# requirements.txt
fastapi==0.103.2
uvicorn==0.23.2
transformers==4.33.3
torch==2.0.1
pydantic==2.4.2
scikit-learn==1.3.0

构建并启动容器:

docker build -t ticket-classifier .
docker run -d -p 8000:8000 ticket-classifier

访问 http://localhost:8000/docs 即可看到自动生成的交互式文档,直接在线测试接口。


监控与优化

生产环境上线后,还需持续监控和改进,确保服务稳定可靠。

1. 添加请求日志与耗时监控

我们可以在 FastAPI 中增加一个简单的中间件,记录每次请求的路径、状态码和处理时间,方便后期排查问题。

import logging
from starlette.middleware.base import BaseHTTPMiddleware
import time

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("ticket_api")

class LogMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request, call_next):
        start = time.time()
        response = await call_next(request)
        duration = time.time() - start
        logger.info(
            f"{request.method} {request.url.path} "
            f"- {response.status_code} - {duration:.4f}s"
        )
        return response

app.add_middleware(LogMiddleware)

这样,任何请求都会在控制台输出日志,便于通过 docker logs 或日志采集系统查看。

2. 性能与错误告警

  • 健康检查接口 /health 可配合容器编排工具(如 K8s)自动重启异常实例。
  • 如果单条分类耗时持续超过 0.5 秒,建议增加 GPU 资源或考虑模型蒸馏。
  • 对分类接口返回的 confidence 进行监控,若大量请求置信度过低(如 <0.6),表示模型遇到未见过的新类型,需及时收集样本进行迭代训练。

3. 模型迭代与 A/B 测试

  • 将真实生产数据定期导出,与原始训练数据合并后重新训练模型,保持对新工单模式的适应。
  • 部署新版本时可采用 A/B 测试:同一流量部分导向旧模型,部分导向新模型,对比准确率后再全量切换,平滑升级。

通过上述监控与优化措施,你的智能工单分类系统就能从“能跑”升级为“稳跑”,真正为企业创造长期价值。