开篇:为什么“像人”这么难
过去两年,我帮三家电商公司搭过智能客服。老板们开口第一句话永远是:“能不能少雇几个人?” 第二句就是:“回答得像真人,别让用户骂娘。” 听起来简单,真正动手才发现,多轮对话里“上下文一丢、意图一歪”,用户立刻甩“转人工”。根据我们自己的埋点数据,意图识别准确率低于 85% 时,人工转接率直接飙到 45% 以上。再加上口语省略、错别字、表情包,传统关键词匹配瞬间崩溃。于是,我把踩过的坑整理成这份笔记,从预处理到上线优化,一条线串起“AI客服”到底怎么让机器听懂人话、记住人话、还能回人话。
技术架构:三条主线撑起对话引擎
1. NLP 预处理:让文本先“干净”再“向量化”
- 正则清洗:去 URL、emoji、全半角统一
- 分词:电商领域新词多,jieba + 自定义词典(SKU、活动名)
- 纠错:pyspellchecker + 自己训练的 2-gram 语言模型,召回率 72%
- 词向量化:上游用 BERT 前,先跑一遍 SentencePiece,得到 2 w 词表,降低 OOV
2. 意图识别模型选型:BERT 与 RNN 的 PK
| 指标 | Bi-LSTM+Attention | BERT-base-chinese |
|---|---|---|
| 准确率 | 89.2% | 94.7% |
| 训练时长 | 1 h(GTX1660) | 3 h |
| 推理延迟 | 12 ms | 28 ms |
| 模型大小 | 48 MB | 128 MB |
结论:BERT 贵 16 ms,但换来 5% 准确率,老板愿意多买两台 3060。
3. 对话状态管理:有限状态机够用吗?
- 规则派:用状态节点写 XML,适合“查订单-催发货-开发票”这种固定流程;新增节点要重启服务,迭代慢
- 数据驱动派:DSTC 框架,把状态当多分类任务,用 BERT 做状态追踪;新增意图只需加数据,不用改代码
- 折中方案:主流程状态机保稳定,异常分支走 DST,上线三个月故障率从 3.2% 降到 0.6%
代码实战:30 行搭一个最小可运行框架
下面示例依赖 transformers==4.30、redis 4.5,Python 3.9 测试通过。
# -*- coding: utf-8 -*- """ 意图识别 + 对话状态跟踪最小 Demo """ import os import json import logging from typing import Dict, Any from transformers import BertTokenizerFast, BertForSequenceClassification from transformers import TextClassificationPipeline import redis # 1. 日志与异常处理 ------------------------------------------------- logging.basicConfig(level=logging.INFO) logger = logging.getLogger("dst") class DSTracker: """简单的内存+Redis 对话状态追踪器""" def __init__(self, ttl: int = 900): self.r = redis.Redis(host='localhost', port=6379, decode_responses=True) self.ttl = ttl def get_state(self, user_id: str) -> Dict[str, Any]: try: data = self.r.hgetall(f"dst:{user_id}") return json.loads(data.get("state", "{}")) except Exception as e: logger.warning("Redis read fail %s, fallback to empty", e) return {} def set_state(self, user_id: str, state: Dict[str, Any]): key = f"dst:{user_id}" try: self.r.hset(key, mapping={"state": json.dumps(state)}) self.r.expire(key, self.ttl) except Exception as e: logger.error("Redis write fail %s", e) # 2. 加载预训练模型 ------------------------------------------------- MODEL_DIR = "./bert-intent-ckpt" tokenizer = BertTokenizerFast.from_pretrained(MODEL_DIR) model = BertForSequenceClassification.from_pretrained(MODEL_DIR) pipe = TextClassificationPipeline( model=model, tokenizer=tokenizer, top_k=1, device=0 if os.getenv("CUDA_VISIBLE_DEVICES") else -1 ) # 3. 推理封装 ------------------------------------------------------- def infer_intent(text: str) -> str: """返回置信度最高的意图""" try: res = pipe(text][0] return res['label'] except Exception as e: logger.exception("predict error") return "Unknown" # 4. 对话回合示例 --------------------------------------------------- def chat_turn(user_id: str, query: str) -> str: dst = DSTracker() state = dst.get_state(user_id) intent = infer_intent(query) # 简单状态机:如果已处在“wait_order”状态,用户又问“取消”则流转 if state.get("phase") == "wait_order" and intent == "cancel": answer = "好的,已帮您取消订单" state.clear() else: answer = f"检测到意图:{intent},状态:{state}" state["phase"] = "wait_order" # 仅示例 dst.set_state(user_id, state) return answer if __name__ == "__main__": print(chat_turn("u123", "我想取消刚刚的订单"))跑通后,/intent 接口延迟稳定在 40 ms(T4 显卡),显存占用 1.1 G。
性能优化:让 GPU 不挤兑、缓存不爆炸
- 模型量化:用 transformers 自带
optimum把 FP32 压到 INT8,大小 128 MB→47 MB,推理延迟 28 ms→21 ms,下降 25%,AUC 掉点 0.3%,可接受 - 缓存策略:
- 对话上下文存 Redis Hash,设置 15 min TTL,防止僵尸 Key
- 热点问题(“双11什么时候开始”)走本地 LRU 缓存,命中率 68%,QPS 3000 时 Redis 压力降一半
- 批量推理:把 20 条在线请求打包成一次 forward,GPU 利用率从 35% 提到 65%,平均延迟反而降到 18 ms
生产环境避坑指南
- 语料清洗:
- 别把“用户发飙语”全删掉,情绪标签对后续满意度模型有用
- 正则过滤地址、手机号时,一定留占位符,否则模型学不到隐私实体特征
- 线程安全:
- transformers Pipeline 默认线程不安全,高并发用 gunicorn + gevent 时,给每个 worker 预加载一份模型,禁止多线程共享
- Redis 连接池 max_connections 设为 50,并加
retry_on_timeout=True,防止促销瞬间把连接打满
- 灰度发布:
- 先切 5% 流量到新模型,对比“转人工率”是否下降,连续 24 小时稳定再全量
- 保留规则引擎兜底,BERT 置信度低于 0.6 自动回落到关键词模板,避免“胡说八道”
结尾思考:规则与模型,左手和右手怎么握?
完全靠状态机,维护成本指数级上涨;全押深度学习,一旦样本分布漂移就翻车。你的业务场景里,哪些对话适合硬编码,哪些必须让模型自己学?如果两者同时给出冲突答案,该信谁?也许答案不是“谁替代谁”,而是把规则当保险丝,模型当发动机——保险丝先熔断,发动机再熄火,系统才既快又稳。