“您好,请按1转人工。”——这句熟悉的提示背后,是传统规则引擎客服的真实写照:关键词写死、无法处理同义句、多轮对话一深就乱。一旦用户换个说法,系统立刻“宕机”。于是,把自然语言理解(Natural Language Understanding, NLU)交给模型,让代码自己“读心”成了刚需。下面这份笔记,记录了我第一次用Python搓出可部署智能客服的全过程,全程新手向,边踩坑边总结,愿陪你一起把“人工智障”升级成“人工智能”。
技术选型:规则、BERT还是GPT-3?
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 正则+关键词(规则) | 0成本、可解释、上线快 | 维护爆炸、泛化≈0 | 固定FAQ<100条 |
| GPT-3接口 | 生成流畅、上下文感知强 | 贵、延迟高、不可控 | 创意闲聊、冷启动Demo |
| BERT微调+规则兜底 | 精度高、可本地部署、成本可控 | 需要标注数据、训练机器 | 业务意图<50种、数据≥1k/意图 |
结论:选“BERT+规则”混合。原因一句话——在老板能接受的成本里,把90%的常见问题先搞定,剩下10%边缘案例用规则兜底,不至于让用户骂娘。
核心实现三部曲
1. 用PyTorch微调BERT做意图识别
数据准备
把历史工单导成三列:text, intent, split。训练前务必做“口语化”增强,例如把“我要退货”扩展成“能退不”“退个货”等,否则模型上线后会被用户的“花式表达”教做人。
# data_utils.py from transformers import BertTokenizer import torch, random, json tokenizer = BertTokenizer.from_pretrained('bert-base-chinese') MAX_LEN = 32 LABEL2ID = {l:i for i,l in enumerate(sorted(set(df['intent'])))} class IntentDataset(torch.utils.data.Dataset): def __init__(self, texts, labels): self.texts = texts self.labels = labels def __len__(self): return len(self.texts) def __getitem__(self, idx): # 加入随机mask=0.1做轻微增强 txt = self.texts[idx] if random.random() < 0.1: txt = txt.replace(' ','') enc = tokenizer(txt, padding='max_length', truncation=True, max_length=MAX_LEN, return_tensors='pt') return {k:v.squeeze(0) for k,v in enc.items()}, \ torch.tensor(LABEL2ID[self.labels[idx]], dtype=torch.long)微调脚本
用TrainerAPI三行配置就能跑,重点在weighted loss:类别不平衡时给稀有意图加权,公式
$$ L = -\sum_i w_i \cdot y_i \log p_i,\quad w_i=\frac{N}{N_{class}} $$
其中$N$为总样本数,$N_{class}$为该意图样本数。
# train.py from transformers import BertForSequenceClassification, Trainer, TrainingArguments model = BertForSequenceClassification.from_pretrained( 'bert-base-chinese', num_labels=len(LABEL2ID)) def compute_metrics(pred): logits, labels = pred preds = logits.argmax(-1) return {'accuracy': (preds==labels).mean()} training_args = TrainingArguments( output_dir='ckpt', per_device_train_batch_size=32, num_train_epochs=4, learning_rate=2e-5, weight_decay=0.01, evaluation_strategy='epoch') trainer = Trainer(model=model, args=training_args, train_dataset=train_ds, eval_dataset=val_ds, compute_metrics=compute_metrics) trainer.train()训练完把ckpt文件夹整体保存,后面Flask直接from_pretrained加载即可。
2. 基于有限状态机(FSM)的多轮对话
客服场景常分四态:START → INQUIRE → CONFIRM → END
任何时刻可回退到HUMAN态转人工。下面用Python字典硬编码转换表,方便后期可视化。
# fsm.py class StateMachine: def __init__(self, uid): self.uid = uid # 用户唯一标识 self.state = 'START' self.memory = {} # 槽位填充:{brand:None, date:None ...} def trigger(self, intent, entities): # 状态转移表: (当前状态, 意图) -> 下一状态 table = { ('START', 'greet'): 'INQUIRE', ('INQUIRE', 'return'): 'CONFIRM', ('INQUIRE', 'unknown'): 'INQUIRE', ('CONFIRM', 'affirm'): 'END', ('CONFIRM', 'deny'): 'INQUIRE', ('*', 'human'): 'HUMAN' # 万能转人工 } key = (self.state, intent) if key not in table: key = ('*', intent) self.state = table.get(key, self.state) # 槽位填充 if intent=='return': self.memory.update(entities) return self.state状态机把“对话策略”与“NLU”解耦,哪怕以后把BERT换成GPT-5,也只需改一行intent名称。
3. Flask接口封装 + 异步处理
线上最怕并发一大就阻塞,于是把模型推理放到线程池,再加aioredis缓存上下文。
# app.py from flask import Flask, request, jsonify from flask_cors import CORS from flask_limiter import Limiter from concurrent.futures import ThreadPoolExecutor import torch, json, redis, uuid app = Flask(__name__) CORS(app) # 解决前端跨域 limiter = Limiter(app, key_func=lambda: request.remote_addr) executor = ThreadPoolExecutor(4) rdb = redis.Redis(host='localhost', port=6379, decode_responses=True) MODEL = BertForSequenceClassification.from_pretrained('./ckpt') TOKENIZER = BertTokenizer.from_pretrained('./ckpt') def intent_predict(text): # 推理函数,线程池调用 inputs = TOKENIZER(text, return_tensors='pt', truncation=True, max_length=32) with torch.no_grad(): logits = MODEL(**inputs).logits return logits.argmax(-1).item() @app.route('/chat', methods=['POST']) @limiter.limit("30/minute") # 单IP限流 def chat(): uid = request.json.get('uid', str(uuid.uuid4())) text = request.json['text'] # 异步推理 future = executor.submit(intent_predict, text) intent_id = future.result(timeout=2) # 2s超时 intent = ID2LABEL[intent_id] # 读取/写入状态机 fsm_json = rdb.hget('fsm', uid) or '{}' fsm = StateMachine(uid) if fsm_json != '{}': fsm.__dict__ = json.loads(fsm_json) state = fsm.trigger(intent, entities={}) # 实体抽取可再加NER rdb.hset('fsm', uid, json.dumps(fsm.__dict__), ex=3600) return jsonify({'reply': REPLY_TEMPLATE[state], 'state': state})CORS让前端本地调试不再报No Access-Control,限流防止竞争对手刷接口把GPU打满。
测试:准确率 & 压力一起抓
1. 混淆矩阵看意图
# eval.py from sklearn.metrics import classification_report, confusion_matrix import seaborn as sns, matplotlib.pyplot as plt y_true, y_pred = [], [] for batch in test_loader: inputs, labels = batch with torch.no_grad(): logits = MODEL(**inputs).logits y_true.extend(labels.numpy()) y_pred.extend(logits.argmax(-1).numpy()) print(classification_report(y_true, y_pred, target_names=LABEL2ID.keys())) sns.heatmap(confusion_matrix(y_true, y_pred), annot=True, fmt='d', cmap='Blues') plt.savefig('confusion.png')经验:对角线<0.8的意图,优先扩数据而不是调参;多数时候加200条样本就能拉10个点。
2. Locust压测接口
# locustfile.py from locust import HttpUser, task, between class ChatUser(HttpUser): wait_time = between(1, 2) @task def ask(self): self.client.post("/chat", json={"text":"想退货怎么办"})命令行locust -f locustfile.py -u 200 -r 20 --run-time 60s,看P95延迟能否<800 ms,否则考虑把模型放TensorRT。
避坑指南
1. 对话上下文存哪儿?
| 方案 | 实现成本 | 读取延迟 | 重启丢失? | 适合场景 |
|---|---|---|---|---|
| 内存(dict) | 最低 | 0 ms | 会 | 单节点、日活<1k |
| SQLite | 中 | 10 ms级 | 不会 | 本地demo、快速落地 |
| Redis | 高一点点 | 5 ms级 | 可持久化 | 多节点、生产必选 |
结论:Demo用SQLite,上线切Redis,记得给key加TTL,别让内存爆炸。
2. 防过拟合三板斧
- 标注数据按1:8:1划分,每意图≥100条,少样本意图用EDA回译扩增
- 训练时
dropout=0.3起步,加weight_decay=0.01;验证集loss回升立刻停 - 测试集用“盲盒”——把用户真实口语句子留20%不训练,只评估,防止“实验室高分、上线就跪”
还能怎么玩?两个开放问题留给你
- 用户总爱造新词(OOV),如何设计领域自适应机制,让模型在少标注甚至零标注情况下也能秒懂新意图?
- 对话策略目前靠硬编码,如果引入强化学习(Reinforcement Learning),把“用户满意度”当奖励,能否让客服自己学会“少问一句、多办一事”?
把上面代码拼一拼,一个最小型智能客服就能在笔记本里跑起来。先让同事在企微群里“围攻”测试,收集100句骂声,再回去调模型——迭代三轮,基本就能挡住80%的重复问题,值班小姐姐终于有时间喝口水。下一步,把它封装成Docker镜像,扔进公司K8s集群,真正的战场才刚开始。祝你玩得开心,踩坑记得写笔记,回来一起分享!