AI辅助数据分析实战:客服语义分类落地
发布日期: 2026/08/18 阅读总量: 0

一次重构,把规则脚本换成了AI分类

2024年4月,运营扔给我一个需求:把过去3年的20万条客服工单按24类业务标签打标,用于服务质量分析。工单数据在MySQL里躺了三年,字段包含ticket_idcontentcategory_old(历史分类,质量很差)。

我先看了一眼旧数据长什么样:

来源样例内容历史分类
App反馈我的订单已经付款成功了,但是一直显示待付款,银行卡扣款了,麻烦尽快处理。其他
在线客服怎么退运费?我买的东西已经寄回去了,运费是12块,说要退给我,一直没到账。售后
电话入口登录短信一直收不到,手机号是138****,换了网络也不行。账号问题

历史分类里「其他」占比41%,「售后」里混杂退款、退货、运费、换货、维修五个子类。直接拿这个数据做分析,结论没法看。

需求一句话:给20万条历史工单重新分类,准确率不低于90%,且每周增量约800条。本次先处理近一年约8万条高价值工单数据。

老方案:正则加规则,死在长尾场景

旧的自动分类器是一个PHP脚本,运行在Laravel 10的定时任务里。核心逻辑是关键词正则匹配:

// app/Services/TicketClassifier.php
match ($content) {
    str_contains($content, ['退货', '退款', '运费']) => '售后',
    str_contains($content, ['登录', '密码', '验证码']) => '账号',
    str_contains($content, ['怎么用', '如何使用', '教程']) => '操作指导',
    default => '其他',
};

问题很明显:

  • 「订单显示待付款但扣款了」不含任何售后关键词,被丢进「其他」。
  • 「退款」出现在「申请退款后多久到账」和「我不退款了帮我恢复订单」两个完全不同的场景里,正则无法区分。
  • 新增分类或调整规则,需要改代码发版。

抽样500条人工标注作为基准集,正则方案的准确率只有68.4%,「其他」类目F1只有0.32。这个数据直接否掉了继续堆规则的方向。

两个AI方案:GPT-4-turbo提示词 vs Qwen2.5微调

方案A:GPT-4-turbo + 提示词工程

2024年5月,先用GPT-4-turbo做概念验证。用Python 3.11脚本调openai库1.35.0版本,每次请求传工单内容,要求模型返回JSON格式的分类结果。

# 创建venv环境(Python 3.11.8)
python3 -m venv venv_ai
source venv_ai/bin/activate
pip install openai==1.35.0 python-dotenv==1.0.1 tenacity==8.2.3

提示词里给24个分类定义和示例,要求强制输出JSON:

from openai import OpenAI
import json, os
from dotenv import load_dotenv
load_dotenv()

client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))

CLASS_NAMES = "(" + ", ".join([
    "退货", "退款", "换货", "运费问题", "维修",
    "支付失败", "订单状态", "物流查询", "发货延迟",
    "商品破损", "商品错发", "商品漏发", "账号登录",
    "账号绑定", "密码重置", "优惠券使用", "积分兑换",
    "发票问题", "价格问题", "库存查询", "操作指导",
    "投诉", "表扬", "其他"
]) + ")"

SYSTEM_PROMPT = f"""你是客服工单分类器。
分类选项:{CLASS_NAMES}

规则:
1. 只输出JSON对象,格式为: {{"category": "分类名", "confidence": 0.0~1.0}}
2. 如果信息不足或不属于任何分类,填写"其他",confidence <= 0.5
3. 输出只包含JSON,不要任何解释。

示例:
输入: 我买的东西少发了一件,请补发。
输出: {{"category": "商品漏发", "confidence": 0.95}}"""

def classify(content: str):
    resp = client.chat.completions.create(
        model="gpt-4-turbo-2024-04-09",
        temperature=0,
        max_tokens=100,
        messages=[
            {"role": "system", "content": SYSTEM_PROMPT},
            {"role": "user", "content": content[:300]}  # 截断超长文本
        ]
    )
    raw = resp.choices[0].message.content.strip()
    # 防止模型返回带markdown的JSON
    if raw.startswith("```"):
        raw = raw.strip("`")
        if raw.startswith("json"):
            raw = raw[4:]
    return json.loads(raw)

# 测试一条
print(classify("我的订单已经付款成功了,但一直显示待付款,银行卡扣款了。"))

验证集500条上跑,准确率92.6%,看起来不错。但有两个硬伤:

  • 成本:8万条工单日均Token消耗约150万,按GPT-4-turbo价格(输入$10/百万token,输出$30/百万token)算,单次全量处理成本约$1650,人民币约1.2万。
  • 数据出境:工单内容含用户手机号、地址,无法过合规审查。

方案B:Qwen2.5-14B-Instruct微调

改用本地部署。选型标准:单卡A100 80G可跑、中文分类能力达标、Apache 2.0协议可商用。

最终选Qwen2.5-14B-Instruct(2024年9月发布),用vLLM 0.6.3部署推理。微调用LlamaFactory(transformers 4.44.2 + peft 0.12.0 + trl 0.9.4)。

部署配置:

# docker-compose.yml for vLLM, 宿主机NVIDIA A100 80G * 1
services:
  vllm:
    image: vllm/vllm-openai:v0.6.3
    command: >
      --model /models/Qwen2.5-14B-Instruct
      --served-model-name qwen
      --tensor-parallel-size 1
      --gpu-memory-utilization 0.9
      --max-model-len 8192
      --dtype bfloat16
      --port 8000
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: 1
              capabilities: [gpu]
    volumes:
      - /data/models:/models
    environment:
      - HF_HOME=/models
    ports:
      - "8000:8000"

启动后测试推理速度和首Token延迟:

# 用curl测试vLLM服务
curl -s http://localhost:8000/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{
    "model": "qwen",
    "messages": [{"role":"system","content":"你是客服工单分类器"},{"role":"user","content":"订单显示待付款但卡已扣款"}],
    "temperature": 0,
    "max_tokens": 50
  }' | jq '.choices[0].message, .usage'

结果:

{
  "message": {
    "role": "assistant",
    "content": "{\"category\": \"订单状态\", \"confidence\": 0.9}"
  },
  "usage": {
    "prompt_tokens": 42,
    "completion_tokens": 16,
    "total_tokens": 58
  }
}

微调数据准备:把8万条工单变训练集

数据筛选与清洗

关键决策:不用原始8万条全部做微调,先做一轮半监督筛选。

第一步:用GPT-4-turbo对2万条抽样数据分类,人工复核5000条作为「种子集」。这5000条覆盖24个分类,每类最少120条。

第二步:把这5000条作为few-shot示例,用Qwen2.5-14B-Instruct零样本(不微调)处理剩下7.5万条,拿到初步预测结果。再用置信度阈值过滤,保留预测confidence大于0.85的

前6万条数据。再从中按分类均匀采样1万条作为微调训练集,2000条作验证集。

第三步:人工抽检训练集500条,发现两个问题需要修正:

  • 「商品破损」和「商品错发」互相混入,因为用户常同时描述两个问题,如「收到的衣服是破的,而且颜色发错了」。处理规则:单条工单只取第一个明确表达的问题分类,其余忽略。
  • 「投诉」分类被严重滥用,很多正常售后也被标成投诉。删除「投诉」中置信度低于0.9的样本。

训练数据格式

LlamaFactory使用ShareGPT格式。每条数据是:

[
  {
    "conversations": [
      {
        "from": "system",
        "value": "你是客服工单分类器。分类选项:(退货, 退款, 换货, 运费问题, 维修, 支付失败, 订单状态, 物流查询, 发货延迟, 商品破损, 商品错发, 商品漏发, 账号登录, 账号绑定, 密码重置, 优惠券使用, 积分兑换, 发票问题, 价格问题, 库存查询, 操作指导, 投诉, 表扬, 其他)。只输出JSON对象,格式为: {\"category\": \"分类名\", \"confidence\": 0.0~1.0}。不要输出任何解释。"
      },
      {
        "from": "user",
        "value": "我的订单已经付款成功了,一直显示待付款,银行卡扣款了,麻烦尽快处理。"
      },
      {
        "from": "assistant",
        "value": "{\"category\": \"订单状态\", \"confidence\": 0.95}"
      }
    ]
  }
]

之后用llamafactory-cli跑微调:

# 微调命令(LLaMA-Factory 0.9.0)
llamafactory-cli train \
  --model_name_or_path /models/Qwen2.5-14B-Instruct \
  --stage sft \
  --finetuning_type lora \
  --dataset_dir /data/dataset \
  --dataset ticket_classification \
  --template qwen \
  --lora_rank 16 \
  --lora_alpha 32 \
  --lora_dropout 0.1 \
  --output_dir /data/output/lora_qwen_ticket \
  --per_device_train_batch_size 8 \
  --gradient_accumulation_steps 4 \
  --learning_rate 1e-4 \
  --num_train_epochs 3 \
  --lr_scheduler_type cosine \
  --warmup_ratio 0.05 \
  --logging_steps 10 \
  --save_steps 500 \
  --eval_strategy steps \
  --eval_steps 500 \
  --val_size 0.1 \
  --preprocessing_num_workers 8 \
  --max_length 2048 \
  --bf16 true

合并LoRA权重:

llamafactory-cli export \
  --model_name_or_path /models/Qwen2.5-14B-Instruct \
  --adapter_name_or_path /data/output/lora_qwen_ticket \
  --template qwen \
  --finetuning_type lora \
  --export_dir /models/Qwen2.5-14B-ticket \
  --export_size 4 \
  --export_legacy_format false

生产落地:完整分类脚本

微调完成后,写一个完整的Python分类worker,跑Laravel队列,从MySQL取增量数据,做完分类写回,带重试和死信机制。

import json
import time
import pymysql
from openai import OpenAI

# 连接本地vLLM(兼容OpenAI协议)
client = OpenAI(
    base_url="http://127.0.0.1:8000/v1",
    api_key="not-needed"
)

# MySQL连接
conn = pymysql.connect(
    host="127.0.0.1",
    user="worker",
    password="yourpassword",
    database="support_tickets",
    charset="utf8mb4"
)

CLASSES = ["退货", "退款", "换货", "运费问题", "维修", "支付失败",
           "订单状态", "物流查询", "发货延迟", "商品破损", "商品错发",
           "商品漏发", "账号登录", "账号绑定", "密码重置", "优惠券使用",
           "积分兑换", "发票问题", "价格问题", "库存查询", "操作指导",
           "投诉", "表扬", "其他"]

SYSTEM_PROMPT = (
    "你是客服工单分类器。分类选项:" + ",".join(CLASSES) +
    "。只输出JSON对象,格式为: {\"category\": \"分类名\", \"confidence\": 0.0~1.0}。"
    "不要输出任何解释,不要使用markdown。"
)

def classify_with_retry(content: str, max_retries: int = 3):
    for attempt in range(max_retries):
        try:
            resp = client.chat.completions.create(
                model="qwen",
                temperature=0,
                max_tokens=100,
                messages=[
                    {"role": "system", "content": SYSTEM_PROMPT},
                    {"role": "user", "content": content[:300]}
                ]
            )
            raw = resp.choices[0].message.content.strip()
            if raw.startswith("```"):
                raw = raw.strip("`")
                if raw.startswith("json"):
                    raw = raw[4:]
            data = json.loads(raw)
            category = data.get("category")
            confidence = float(data.get("confidence", 0.0))
            if category not in CLASSES:
                raise ValueError(f"未知分类: {category}")
            if confidence < 0.5:
                return "需人工复核", confidence
            return category, confidence
        except Exception as e:
            if attempt == max_retries - 1:
                return "分类失败", 0.0
            time.sleep(2 ** attempt)  # 指数退避
    return "分类失败", 0.0

def process_batch(limit: int = 800):
    """处理未分类的工单"""
    with conn.cursor() as cursor:
        cursor.execute("""
            SELECT id, content FROM tickets
            WHERE ai_category IS NULL
            ORDER BY created_at ASC
            LIMIT %s
        """, (limit,))
        rows = cursor.fetchall()

    print(f"本批处理: {len(rows)} 条")
    updates = []
    failed_ids = []

    for tid, content in rows:
        category, confidence = classify_with_retry(content)
        updates.append((category, confidence, tid))
        if category in ("分类失败", "需人工复核"):
            failed_ids.append(tid)

    with conn.cursor() as cursor:
        for category, confidence, tid in updates:
            cursor.execute("""
                UPDATE tickets
                SET ai_category = %s, ai_confidence = %s, ai_processed_at = NOW()
                WHERE id = %s
            """, (category, confidence, tid))
    conn.commit()
    print(f"完成: {len(rows) - len(failed_ids)} 条成功, {len(failed_ids)} 条需人工")
    # 失败数据进死信表
    if failed_ids:
        with conn.cursor() as cursor:
            for tid in failed_ids:
                cursor.execute(
                    "INSERT INTO ticket_classify_dead_letter (ticket_id, reason) VALUES (%s, '低置信度或失败')",
                    (tid,)
                )
        conn.commit()

if __name__ == "__main__":
    process_batch()

定时任务每分钟跑一次,处理完自动停下,不会堆积。

效果数据对比:三个方案横评

验证集是一样的:500条工单,人工标注,覆盖全部24类。跑在A100上(batch推理)。

方案准确率平均单条耗时「其他」F1成本/8万条
PHP正则规则68.4%0.8ms0.32≈0(纯CPU)
GPT-4-turbo + 提示词92.6%380ms0.76约¥12,800
Qwen2.5-14B微调93.8%85ms(并发16)0.81GPU电费约¥400 + 微调一次性GPU成本约¥800

结论:微调方案准确率最高、单条成本降了32倍、数据不出内网。GPU是已有资源,电费另算。

补充一个用户最关心的耗时细节:单卡A100 vLLM并发16时,平均每token生成时间约28ms。每条工单输出约16token,所以单条总耗时≈(输入时间 + 16×28ms)≈85ms。如果只用单并发串行,耗时约350ms,所以生产环境必须开并发。

避坑指南

这段是花钱买来的教训,每条都具体说。

坑1:训练集直接用GPT-4预测结果,没做人工复核就微调,导致模型继承了GPT-4的分布偏好。第一版微调模型在「商品破损」和「商品错发」上F1只有0.71,训练集里这两个类别的标签就混了。解决:人工抽检500条修正标签,重新生成训练集,F1提升到0.89。

坑2:分类阈值设0.5太低。「需人工复核」挡不住错误样本。统计微调模型在验证集上的confidence分布,发现错误样本的confidence中位数是0.62,正确样本是0.91。把阈值从0.5提到0.75后,准确率从93.8%降到92.1%,但需要人工复核的比例从1.2%升到4.5%。权衡后选择0.75,因为人工复核成本远低于错误分类带来的分析污染。

坑3:vLLM的max_model_len没调,吞长文本。默认2K上下文,工单超过600字就被截断,分类结果开始飘。后来定位到是prompt_tokens超了限制,报错不明显,只看到输出异常。把--max-model-len设为8192后解决。

坑4:微调时把system prompt写进训练样本,但推理时的system prompt和训练时不一致。导致模型行为偏差。正确做法:训练和推理必须用同一个system prompt。我后来把system prompt写死成一个常量,训练和推理共用。

坑5:数据泄漏。第一版微调前做随机切分,同一用户的多个工单同时出现在训练集和验证集里。用户「一键退款」和「退款没到账」两个工单内容高度相似,验证集准确率虚高。改成按ticket_id去重,同一用户只出现在一边后,准确率从96%降到93.8%,这才是真实水平。

坑6:死信表没人消费。每周积压几百条「需人工复核」,运营不看。后来加了一个Laravel命令每天汇总一次发到钉钉群,人工在后台表格里处理,处理完自动回填。这个问题本质是流程设计,不是技术问题。

现在这套系统的运行状态

上线三个月,稳定处理2.1万条增量工单。分类分布和人工抽检吻合度在92%左右。

最终代码量:Python worker约400行,Laravel队列调度改动约60行,死信消费约120行。技术栈是:

  • PHP 8.3 + Laravel 11(主业务系统)
  • Python 3.11.8(AI worker)
  • MySQL 8.0.35(数据存储)
  • vLLM 0.6.3 + Qwen2.5-14B-Instruct(推理)
  • LLaMA-Factory 0.9.0(微调)

这套流程的价值不在单一模型选型,而在把「AI辅助分析」落地成一个可持续运转的系统。数据怎么清洗、置信度怎么设、失败怎么兜底,这些才是工程里真正花时间的地方。