痛点分析:传统客服系统到底卡在哪
去年做外包项目时,我接手过一套“上古”客服系统:前端是 jQuery,后端是同步阻塞的 Flask,意图识别靠关键词 if-else,高峰期 CPU 飙到 90%,用户平均等待 8 秒才能收到“您好,请问有什么可以帮您?”。
意图识别准确率感人
关键词+正则的组合,一旦用户说“我的快递怎么还没到”,系统只能匹配“快递”两个字,结果把物流查询、退货、改地址全部导向同一答案,准确率不到 55%。多轮对话状态维护靠 session
把对话历史塞进 Redis 字符串,key 是手机号,value 是 JSON 字符串,结果客服一改模板,字段名对不上,直接 500;用户刷新页面,session 丢失,对话断片。高并发响应成噩梦
同步阻塞 + 模型加载在进程内,4 核 8 G 的机器,QPS(Query Per Second)峰值 30 就开始 502;再加两台机器,负载均衡后又出现重复回复,用户体验“双份惊喜”。
技术选型:Rasa vs Dialogflow vs 自搭 BERT
Dialogflow(Google)
优点:可视化、多语言、内置实体识别;缺点:中文支持一般、请求要走外网、按调用量计费,数据出境审计麻烦。Rasa(开源)
优点:本地部署、可插拔 NLU + DM(Dialogue Management)双组件、社区活跃;缺点:训练 pipeline 调参多,文档“劝退”新人。自搭轻量 BERT + FastAPI
优点:模型可控、Python 全栈、可深度定制;缺点:要自己写状态机、数据标注成本高。
综合交付周期、数据隐私、二次开发自由度,我们最终选了“Python+自搭 BERT+FastAPI”路线:训练数据留在本地,接口代码一把梭,后期想加语音、加知识图谱都方便。
核心实现:三步搭出可异步的对话服务
1. 项目结构速览
chatbot/ ├─ app/ │ ├─ main.py # FastAPI 入口 │ ├─ nlu/ │ │ ├─ intent.py # 意图识别 │ │ └─ entity.py # 实体抽取(预留) │ ├─ dm/ │ │ └─ state_machine.py │ └─ models/ │ └─ bert_intent/ ├─ tests/ └─ requirements.txt2. 意图识别模块(Transformers 版)
预处理、模型加载、推理优化写在一个文件,方便后期换模型。
# nlu/intent.py import os import torch from transformers import AutoTokenizer, AutoModelForSequenceClassification MODEL_PATH = os.path.join("app/models/bert_intent") class IntentClassifier: def __init__(self): self.tokenizer = AutoTokenizer.from_pretrained(MODEL_PATH) self.model = AutoModelForSequenceClassification.from_pretrained(MODEL_PATH) self.id2label = {0: "logistics", 1: "greeting", 2: "human"} # 按自己数据来 self.model.eval() async def predict(self, text: str) -> str: """异步推理,防止阻塞主线程""" inputs = self.tokenizer(text, return_tensors="pt", truncation=True, max_length=64) with torch.no_grad(): logits = self.model(**inputs).logits pred = torch.argmax(logits, dim=-1).item() return self.id2label[pred]要点
- 构造函数只加载一次,常驻线程安全。
predict用async包裹,内部依旧同步,但可以被run_in_executor丢到线程池,FastAPI 主循环不卡。
3. 对话状态机(简化版)
状态图:
greeting → logistics → (fill_slot → confirm → done)
任何时刻都可跳转到 human(人工客服)。
# dm/state_machine.py from enum import Enum, auto class State(Enum): GREET = auto() LOGISTICS = auto() FILL_SLOT = auto() CONFIRM = auto() DONE = auto() HUMAN = auto() class Context: def __init__(self, uid: str): self.uid = uid self.state = State.GREET self.slots = {"tracking_number": None} def jump(self, new_state: State): self.state = new_state业务层调用示例:
# 伪代码 ctx = await redis.get(f"ctx:{uid}") or Context(uid) if intent == "logistics": ctx.jump(State.LOGISTICS) return "请提供您的快递单号"4. FastAPI 异步接口
# main.py from fastapi import FastAPI from app.nlu.intent import IntentClassifier from app.dm.state_machine import Context, State import aioredis app = FastAPI() clf = IntentClassifier() redis = aioredis.from_url("redis://localhost:6379/0") @app.post("/chat") async def chat(uid: str, query: str): # 1. 意图识别 intent = await clf.predict(query) # 2. 加载/创建上下文 ctx_bytes = await redis.get(f"ctx:{uid}") if ctx_bytes: ctx = Context.loads(ctx_bytes) # 自定义序列化 else: ctx = Context(uid) # 3. 状态迁移 & 槽位填充(略) ... # 4. 回写 Redis,过期 30 min await redis.set(f"ctx:{uid}", ctx.dumps(), ex=1800) return {"reply": reply, "state": ctx.state.name}- 全程
async/await,I/O 耗时操作(Redis、模型推理)不阻塞事件循环。 - 模型常驻内存,避免每次请求重复加载。
生产考量:压测、安全、日志一个都不能少
- 压测脚本(Locust)
# tests/locustfile.py from locust import HttpUser, task, between class ChatUser(HttpUser): wait_time = between(0.5, 2.0) @task def ask(self): self.client.post("/chat", json={"uid": "u123", "query": "快递啥时候到"})本地 4 核 i7 + 8 G,单 worker Uvicorn,QPS 冲到 220,P99 响应 120 ms,CPU 70%,满足日活 5 万的小程序场景。瓶颈在 BERT 推理,下一步可上 ONNX+GPU 或量化。
- 敏感词 & 审计日志
- 敏感词:维护一份动态 Trie,用户消息先过 filter,命中直接返回“亲亲,咱们文明沟通哦”。
- 审计:FastAPI 中间件统一写
uid, query, reply, intent, ts到 Kafka,下游再入 Hive,方便运营回溯。
避坑指南:这五颗雷我替你们踩过了
未做请求限流 → 雪崩
解决:slowapi桶限流 60/分钟,超限返回 429;Nginx 层再加连接数限制。对话上下文丢失
Redis 序列化字段一改,旧数据loads抛异常。
解决:给 Context 加版本号,反序列化时缺字段用默认值补。模型热更新导致抖动
直接覆盖model.bin,推理线程读一半,直接段错误。
解决:双缓冲,先加载到新对象,原子替换引用,旧对象延迟 5 秒del。异步误用
time.sleep()
事件循环整体卡住,QPS 掉 80%。
解决:I/O 等待用await asyncio.sleep(),CPU 密集用run_in_executor。日志打满磁盘
一开 DEBUG,一个请求 200 行日志。
解决:生产 ENV 设INFO,并按大小滚动,保留 7 天。
代码规范小结
- 全项目
black格式化,行宽 88,函数 <= 20 行。 - 异步函数必须加
async def,调用处写await,否则RuntimeWarning。 - 模型路径、环境变量统一位于
settings.py,禁止硬编码。
延伸思考:用户说“可能我要退货吧”到底算不算退货意图?
模糊边界、口语化、否定句式、方言……都是意图识别永远的坑。你在业务里怎么收集 badcase、怎么做主动学习、怎么让模型“越聊越聪明”?欢迎留言一起实践。
—— 完 ——