背景痛点:高并发下的“智障”客服
去年双十一,公司自研的聊天机器人差点把客服主管逼疯:
- 凌晨 0 点流量一冲,平均响应从 400 ms 飙到 3 s,用户疯狂点“人工客服”
- 意图识别模型是 3 年前用 TF-IDF+TextCNN 训的,遇到“我订单里那件衣服能退吗”和“衣服能退吗,订单里那件”就懵圈,准确率掉到 68%
- 多轮对话靠正则维护,用户中途改一句“算了不退了”,机器人还继续追问“退货原因”,体验社死
痛定思痛,决定把整套系统推翻重做,目标只有一个:在高并发场景下,让机器人“快”且“懂人话”。
技术选型:规则、传统 NLP、预训练模型怎么选?
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 规则引擎(AIML、正则) | 0 训练成本,可解释 | 写死、难维护、泛化≈0 | 固定 FAQ,量小 |
| 传统 NLP(TF-IDF+LR/TextCNN) | 训练快,资源省 | 长句、口语、同义词一塌糊涂 | 语料干净、域内 |
| 预训练模型(BERT 系) | 泛化强,口语也能抓住重点 | 重、延迟高、吃 GPU | 高并发必须做蒸馏/量化 |
结论:
- 用BERT-mini(蒸馏版)做意图识别,准确率和延迟兼得
- 规则只当“兜底+敏感词”守门员,不再参与业务
- 整体拆成微服务,让模型服务可以独立扩容,不跟业务逻辑抢资源
核心实现:Python+FastAPI 搭骨架,HuggingFace 当心脏
1. 微服务拆分图
chat-api:对接前端,负责鉴权、限流、会话管理nlp-intent:只干一件事——把句子映射到意图 IDdialogue-svc:维护多轮状态、槽位填充、答案拼装faq-svc:走 ES 的精准问答兜底
2. 意图分类模型训练(代码片段)
# train_intent.py from datasets import load_dataset from transformers import AutoTokenizer, AutoModelForSequenceClassification, Trainer, TrainingArguments model_name = "bert-base-chinese" tokenizer = AutoTokenizer.from_pretrained(model_name) def tokenize(batch): return tokenizer(batch["text"], truncation=True, padding="max_length", max_length=32) ds = load_dataset("csv", data_files={"train": "intent_train.csv", "test": "intent_test.csv"}) ds = ds.map(tokenize, batched=True) model = AutoModelForSequenceClassification.from_pretrained( model_name, num_labels=len(ds["train"].unique("label")) ) args = TrainingArguments( output_dir="intent_model", per_device_train_batch_size=64, num_train_epochs=3, learning_rate=3e-5, 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=ds["train"], eval_dataset=ds["test"]) trainer.train() trainer.save_model("intent_model")训练完用TextBrewer蒸馏到 4 层,大小 52 M→18 M,GPU 推理 6 ms→2 ms,贼香。
3. 对话状态管理设计
- 用Redis Hash存会话,key 格式
chat:{user_id},TTL 30 min - 字段示例:
intent,slots,history,turn - 每轮只更新 diff,减少写放大;AOF 日志异步刷盘,宕机可恢复
代码示例:生产级意图识别 API
下面给出完整可运行文件,依赖:fastapi==0.110transformers==4.40uvloopaioredisslowapi
# intent_svc.py import os import time import logging from contextlib import asynccontextmanager from functools import lru_cache from fastapi import FastAPI, HTTPException, Request from slowapi import Limiter, _rate_limit_exceeded_handler from slowapi.util import get_remote_address from slowapi.errors import RateLimitExceeded from transformers import AutoTokenizer, AutoModelForSequenceClassification import torch import aioredis # ------------------ 日志 ------------------ logging.basicConfig( level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", ) logger = logging.getLogger("intent") # ------------------ 限流 ------------------ limiter = Limiter(key_func=get_remote_address) app = FastAPI() app.state.limiter = limiter app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) # ------------------ 模型加载 ------------------ model_path = os.getenv("MODEL_PATH", "intent_model") tokenizer = AutoTokenizer.from_pretrained(model_path) model = AutoModelForSequenceClassification.from_pretrained(model_path) model.eval() device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model.to(device) # ------------------ 缓存 ------------------ redis = aioredis.from_url("redis://localhost:6379", decode_responses=True) @lru_cache(maxsize=1024) def cached_predict(text: str): """LRU 本地缓存,防重复句刷屏""" inputs = tokenizer(text, return_tensors="pt", truncation=True, padding=True, max_length=32) inputs = {k: v.to(device) for k, v in inputs.items()} with torch.no_grad(): logits = model(**inputs).logits probs = torch.nn.functional.softmax(logits, dim=-1) label = int(torch.argmax(probs)) return label, float(probs[0, label]) # ------------------ 接口 ------------------ @app.post("/predict") @limiter.limit("60/minute") async def predict(request: Request): body = await request.json() text = body.get("text", "").strip() if not text or len(text) > 200: raise HTTPException(status_code=400, detail="text length invalid") try: # 先查 Redis cache_key = f"intent:{text}" if (rc := await redis.get(cache_key)) is not None: label, score = map(float, rc.split(",")) logger.info(f"hit cache | text={text}") else: t0 = time.time() label, score = cached_predict(text) logger.info(f"model inference | time={1000*(time.time()-t0):.2f}ms") await redis.setex(cache_key, 300, f"{label},{score}") # 5min except Exception as e: logger.exception("predict error") raise HTTPException(status_code=500, detail="internal error") return {"intent_id": int(label), "confidence": score}启动命令:
uvicorn intent_svc:app --host 0.0.0.0 --port 8001 --workers 4性能优化:让 GPU 不喘,CPU 也能顶
缓存双层
- 本地 LRU 挡最热 Top-1024 请求,命中率 35%+
- Redis 挡 5 min 内重复句,减少 20% 模型调用
模型量化
- PyTorch 自带
torch.quantization.quantize_dynamic把 FP32→INT8,延迟再降 30%,显存减半,准确率掉 0.3%,可接受
- PyTorch 自带
批量推理
- 把 1 句 2 ms 改成 8 句 5 ms,QPS 直接×4;
chat-api侧加微批聚合 10 ms 窗口
- 把 1 句 2 ms 改成 8 句 5 ms,QPS 直接×4;
负载均衡
nlp-intent容器 CPU 与 GPU 比例 3:1,K8s HPA 按 GPU 利用率 70% 扩容;同时开istio做 canary,新模型灰度 5% 流量
避坑指南:上线前必须踩的坑
对话上下文丢失
- 早期把状态放
chat-api内存,发布重启用户直接从头开始。改为Redis + 滚动 key(chat:{user_id}:v2),重启前先热 key 双写,再切流
- 早期把状态放
敏感词过滤
- 别用
replace暴力,会把“沙发”也干掉。用AC 自动机多模式匹配,10 万条敏感库 2 ms 扫完;同时维护白名单,支持产品运营热更新
- 别用
模型冷启动
- 容器刚拉起第一次推理要编译 CUDA kernel,延迟飙到 200 ms。解决:
- 启动脚本里先跑一条“你好”预热
- 把
torch.jit.trace导出为.pt文件,侧车容器启动即加载,省去动态图编译
- 容器刚拉起第一次推理要编译 CUDA kernel,延迟飙到 200 ms。解决:
延伸思考:下一步往 Magnus 玩什么?
知识图谱
- 把商品属性、活动规则写进 Neo4j,用户问“ iPhone 15 256G 蓝色有没有货”→ 实体链接→ 子图查询→ 直接返回库存,比 FAQ 精准
强化学习
- 对话策略不再写死,用User Simulator造数据,训练 DQN 选择“追问/回答/转人工”,以解决率当 reward,3 万轮后转人工率降 12%
多模态
- 用户发图问“这双鞋有红色吗”,用 CLIP 做图文匹配,再查库存。需要把
nlp-intent升级成multimodal-intent,GPU 显存又是一场恶战
- 用户发图问“这双鞋有红色吗”,用 CLIP 做图文匹配,再查库存。需要把
整套方案上线两个月,双十一大考峰值 QPS 4.3 K,平均延迟 180 ms,意图准确率 93.4%,转人工率降 28%。代码已开源在团队 GitLab,改两行配置就能接新领域。如果你也在为“智障”客服掉头发,不妨从蒸馏 BERT + FastAPI 微服务开始,先让机器人“听懂”人话,再慢慢教它“懂事”。祝调试愉快,少踩坑,多睡觉。