痛点分析:短文本为何总把机器人逼疯
智能客服每天面对的不是“你好”,就是“我订单呢”。平均长度不到 10 个字,却暗藏玄机:
- 语义模糊——“打不开”到底指 App 闪退,还是优惠券无法领取?
- 领域术语——“我的券被风控了”里的“风控”在电商语境下是冻结,不是金融风控。
- 口语缩写——“tb”可以是淘宝、也可以是“退吧”,模型一脸懵。
- 类别极不均衡——“查物流”占 60%,而“修改实名”不到 1%,传统指标容易“被平均”。
结果就是:规则系统维护到秃头,人工标注成本直线上升,用户转人工率居高不下。我们需要一个“听得懂人话”又能“跑得动高并发”的分类模型。
技术选型:TF-IDF、LSTM 还是 BERT?
在 10 万条真实会话数据上跑离线实验,硬件单卡 T4,batch=32,结果如下:
| 方案 | 准确率 | P99 延迟(ms) | 训练时长 | 备注 |
|---|---|---|---|---|
| TF-IDF + LR | 0.852 | 3 | 5 min | 特征工程重,对新词敏感 |
| Word2Vec + Bi-LSTM | 0.885 | 21 | 2 h | 需要预训练词向量 |
| BERT-base-Chinese | 0.931 | 67 | 40 min | 直接微调,无需特征 |
结论:BERT 以 4.6% 的绝对提升碾压传统方案,虽然延迟高一个量级,但后面可以优化;再往上换 BERT-large 收益仅 +0.8%,性价比低,因此 base 版作为生产首选。
核心实现:30 行代码搞定微调
环境统一:
pip install transformers==4.35.0 datasets accelerate onnxruntime-gpu训练脚本(train_qq.py):
from datasets import load_dataset from transformers import (AutoTokenizer, AutoModelForSequenceClassification, Trainer, TrainingArguments, DataCollatorWithPadding) import torch, evaluate, numpy as np MODEL_NAME: str = "bert-base-chinese" NUM_LABELS: int = 32 # 业务类别数 MAX_LEN: int = 32 # 客服短文本 32 字足够 tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME) def encode(ex): return tokenizer(ex["text"], truncation=True, max_length=MAX_LEN) def compute_metrics(eval_pred): logits, labels = eval_pred preds = np.argmax(logits, axis=-1) acc = evaluate.load("accuracy").compute(predictions=preds, references=labels)["accuracy"] return {"accuracy": acc} dataset = load_dataset("csv", data_files={"train":"train.csv","test":"test.csv"}) dataset = dataset.map(encode, batched=True).rename_column("label", "labels") dataset = dataset.map(lambda x: {"labels": torch.tensor(x["labels"])}) data_collator = DataCollatorWithPadding(tokenizer=tokenizer) model = AutoModelForSequenceClassification.from_pretrained( MODEL_NAME, num_labels=NUM_LABELS) args = TrainingArguments( output_dir="ckpts", per_device_train_batch_size=128, # T4 16G 显存可吃下 per_device_eval_batch_size=256, num_train_epochs=3, learning_rate=2e-5, weight_decay=0.01, fp16=True, # 显存瞬间省 30% logging_steps=50, evaluation_strategy="epoch", save_strategy="epoch", load_best_model_at_end=True, metric_for_best_model="accuracy", ) trainer = Trainer( model=model, args=args, train_dataset=dataset["train"], eval_dataset=dataset["test"], tokenizer=tokenizer, data_collator=data_collator, compute_metrics=compute_metrics, ) trainer.train() trainer.save_model("qq_cls")数据增强小技巧(离线脚本):
- 回译:中→英→中,用 googletrans 批量生成,置信度低于 0.9 的丢弃。
- 术语替换:把“快递”随机换成“物流”“配送”,保持标签不变。
- 随机截断:对长句随机去掉末尾 2-4 字,模拟用户手滑。
增强后训练集 +38%,对低频类过拟合缓解明显。
部署优化:让 GPU 像挤地铁一样“塞满”
- ONNX 转换 + 图优化
from pathlib import Path import torch, onnx, onnxruntime as ort from transformers import AutoModelForSequenceClassification, AutoTokenizer model = AutoModelForSequenceClassification.from_pretrained("qq_cls") tokenizer = AutoTokenizer.from_pretrained("qq_cls") dummy = tokenizer("测试", return_tensors="pt", max_length=MAX_LEN, padding="max_length") torch.onnx.export( model, (dummy["input_ids"], dummy["attention_mask"]), "qq_cls.onnx", input_names=["input_ids", "attention_mask"], output_names=["logits"], dynamic_axes={"input_ids": {0: "batch"}, "logits": {0: "batch"}}, opset_version=14, ) # 图优化 onnx_model = onnx.load("qq_cls.onnx") from onnxruntime.tools import optimizer optimized = optimizer.optimize_model("qq_cls.onnx", model_type="bert", num_heads=12, hidden_size=768) optimized.save("qq_cls.opt.onnx")- 动态批处理(server 片段)
import asyncio, onnxruntime as ort, numpy as np from typing import List, Tuple class OnnxPool: def __init__(self, path: str, max_batch: int = 64): self.sess = ort.InferenceSession(path, providers=["CUDAExecutionProvider"]) self.max_batch = max_batch self.queue = asyncio.Queue() self.loop_task = asyncio.create_task(self._loop()) async def infer(self, ids: np.ndarray, mask: np.ndarray) -> np.ndarray: future = asyncio.Future() await self.queue.put((ids, mask, future)) return await future async def _loop(self): batch_ids, batch_mask, futures = [], [], [] while True: ids, mask, fut = await self.queue.get() batch_ids.append(ids) batch_mask.append(mask) futures.append(fut) # 攒够或超时 10ms 即发 while len(batch_ids) < self.max_batch and not self.queue.empty(): try: ids, mask, fut = await asyncio.wait_for(self.queue.get(), 0.01) batch_ids.append(ids) batch_mask.append(mask) futures.append(fut) except asyncio.TimeoutError: break if batch_ids: logits = self.sess.run(None, { "input_ids": np.vstack(batch_ids), "attention_mask": np.vstack(batch_mask), })[0] for fut, out in zip(futures, logits): fut.set_result(out) batch_ids.clear(); batch_mask.clear(); futures.clear()- GPU 内存池化
- 设置
CUDAExecutionProvider的gpu_mem_limit=2GB,多模型共卡不打架。 - 开启
cudnn_conv_algo_search=HEURISTIC减少启动探查显存峰值。
压测结果:P99 延迟从 67 ms 降到 29 ms,QPS 提升 2.3 倍,显存占用稳定在 1.8 G。
避坑指南:那些藏在日志里的血泪
标签泄漏 早期把“是否包含订单号”当特征,结果模型学会“有订单号→查物流”,线下 99%,线上掉到 82。解决:任何与标签强相关的人工规则不得入特征。
异步推理线程冲突 onnxruntime 的 CUDA 执行 provider 非线程安全,多个协程共用 Session 会 segfault。解决:加一层
asyncio.Lock或者干脆用单线程事件循环。热更新 直接替换 .onnx 文件会导致旧模型句柄悬空。解决:双 Session 切换 + 引用计数,新模型加载完毕再下线旧 Session,实现 0 downtime。
置信度校准 初期用 softmax 最大概率当置信度,发现对“未知”类过于自信。解决:用验证集做 Platt Scaling,把 0.9 阈值调到 0.82,误拒率降 40%。
延伸思考:当模型说“我不知道”
Fallback 机制设计
- 置信度低于阈值 → 触发“相似问题推荐”模块,走 ES 召回。
- 连续两轮 fallback → 直接转人工,并在后台标注池里插入“待确认”样本,每周人工复核一次,实现主动学习闭环。
多语种迁移
- 先用 bert-base-multilingual-cased 做通用模型,覆盖 80% 场景。
- 对泰语、越南语等小语种,采用“adapter”微调,仅训练 3% 参数,推理时动态挂载,显存零增长。
- 语种识别用 fastText 线性分类器,延迟 < 1 ms,按语种路由到对应 adapter,整体准确率 +5.2%。
持续学习 线上回流数据先跑“影子模式”,对比旧模型预测差异超过 5% 的样本才进入训练池,防止概念漂移把模型带歪。
写在最后
把 BERT 从论文搬到生产,最大的感受是:离线涨点只是入场券,真正的坑都在线上流量里。显存、线程、热更新,每一步都是细节战场。希望这份避坑指南能让你少熬几个通宵,把更多时间留给优化用户体验,而不是和 CUDA 报错面面相觑。祝你部署顺利,分类准确率一路飙升。