一次重构,把规则脚本换成了AI分类
2024年4月,运营扔给我一个需求:把过去3年的20万条客服工单按24类业务标签打标,用于服务质量分析。工单数据在MySQL里躺了三年,字段包含ticket_id、content、category_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.8ms | 0.32 | ≈0(纯CPU) |
| GPT-4-turbo + 提示词 | 92.6% | 380ms | 0.76 | 约¥12,800 |
| Qwen2.5-14B微调 | 93.8% | 85ms(并发16) | 0.81 | GPU电费约¥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辅助分析」落地成一个可持续运转的系统。数据怎么清洗、置信度怎么设、失败怎么兜底,这些才是工程里真正花时间的地方。