谛听客服智能体开发实战:AI辅助开发中的架构设计与性能优化
背景痛点:客服系统最怕“慢”和“错”
去年双十一,我们内部客服系统被瞬间 3w+ 并发搞到崩溃:
- 平均响应 1.8s,TP99 飙到 5s,用户直接开骂。
- 多轮对话里“我要退订单”被拆成两句,状态机瞬间失忆,重复追问“您要退什么?”。
- 意图识别准确率 78%,售后组人工兜底率 35%,成本翻倍。
核心矛盾就三件事:
- 并发高 → 单线程 Flask 阻塞
- 对话长 → 状态散落在内存,重启就丢
- 意图多 → 规则引擎写到最后连自己都不认识
技术对比:规则、ML、DL 的硬数据
我们把 21 个月的真实日志(2.4M 条)按 7:1:2 切成训练、验证、测试,在同一台 2080Ti 上跑三种方案:
| 方案 | 准确率 | TP99 延迟 | 代码行数 | 备注 |
|---|---|---|---|---|
| 规则引擎(Esper + DSL) | 72% | 120ms | 3.2k | 规则>400 条后冲突爆炸 |
| 传统 ML(FastText + LR) | 81% | 45ms | 1.1k | 特征工程占 60% 工作量 |
| Fine-tune BERT-base | 91.4% | 280ms | 380 | 后面会降到 90ms |
结论:
- 规则引擎适合冷启动,但 80% 以后每 1% 的准确率提升要翻 3× 规则量,不可持续。
- BERT 虽然重,可一旦加缓存 + 批量推理,延迟能压到业务可接受范围,准确率提升立竿见影。
架构设计:把“对话”和“语义”拆开
1. 微服务拓扑(PlantUML 代码可直接粘到 plantuml.com 渲染)
@startuml !define MS(name,desc) rectangle name as "desc" <<MicroService>> MS(gateway,API Gateway) -> MS(dm,Dialog Manager) MS(dm) -> MS(nlu,NLU Service) MS(nlu) -> MS(cache,Redis Cache) MS(dm) -> MS(state,State Store) MS(nlu) -> MS(bert,BERT Inference) MS(bert) -> GPU @enduml- Dialog Manager(DM):只负责“对话节奏”,任何语义都不碰,重启无状态。
- NLU Service:唯一会调 GPU 的推理服务,通过 gRPC 暴露,方便独立扩缩容。
- State Store:Redis Hash 存多轮槽位,TTL=30min,key=uid+scene。
2. Flask 异步中间件(Python 3.11)
下面这段代码同时解决“重复请求”和“缓存”两个问题,基于 Flask 2.2 + gevent,单进程 QPS 从 200 提到 800。
# middleware.py import hashlib, json, redis, gevent from flask import Flask, request, jsonify from functools import wraps r = redis.Redis(host='127.0.0.1', decode_responses=True) app = Flask(__name__) def cache_key(uid, text): return f"nlu:{uid}:{hashlib.md5(text.encode()).hexdigest()}" def async_cache(ttl=60): def decorator(f): @wraps(f) def wrapper(*args, **kwargs): uid = request.json['uid'] text = request.json['text'] key = cache_key(uid, text) ret = r.get(key) if ret: return jsonify(json.loads(ret)) # 异步防重 lock = f"lock:{key}" if r.set(lock, 1, nx=True, ex=5): resp = f(*args, **kwargs) r.setex(key, ttl, json.dumps(resp)) r.delete(lock) return jsonify(resp) else: # 轮询等待 while not r.get(key): : gevent.sleep(0.05) return jsonify(json.loads(r.get(key))) return wrapper return decorator @app.route('/nlu', methods=['POST']) @async_cache(ttl=120) def nlu(): # 实际调用 BERT 推理 return {'intent':'Refund','slots':{'order_id':None}}时间复杂度:
- 缓存命中 O(1)
- 锁等待最坏 O(k) 轮询,k<20(经验值)
核心实现:Fine-tune BERT 与状态幂等
1. Fine-tune 脚本(PyTorch 2.1)
数据增强:
- 同义词替换(中文近义词林)
- 随机拼接历史句,模拟多轮
类别不平衡:
- 采用 Focal Loss(Lin et al. 2017),γ=2 时,少数类 F1 提升 6%。
# train.py from torch.utils.data import Dataset, DataLoader from transformers import BertTokenizer, BertForSequenceClassification, AdamW import torch, json, random, numpy as np from sklearn.utils.class_weight import compute_class_weight class IntentDataset(Dataset): def __init__(self, path): with open(path) as f: self.data = [json.loads(l) for l in f] self.tok = BertTokenizer.from_pretrained('bert-base-chinese') def __len__(self): return len(self.data) def __getitem__(self, idx): text, label = self.data[idx]['text'], self.data[idx]['label'] enc = self.tok(text, padding='max_length', truncation=True, max_length=64, return_tensors='pt') return enc['input_ids'].squeeze(), enc['attention_mask'].squeeze(), label def focal_loss(y_true, y_pred, gamma=2.0, alpha=None): ce_loss = torch.nn.functional.cross_entropy(y_pred, y_true, reduction='none') p_t = torch.exp(-ce_loss) loss = alpha[ y_true ] * (1 - p_t) ** gamma * ce_loss return loss.mean() train = IntentDataset('intent_train.json') weights = compute_class_weight('balanced', classes=np.unique([d[2] for d in train]), y=[d[2] for d in train]) alpha = torch.tensor(weights, dtype=torch.float32) model = BertForSequenceClassification.from_pretrained('bert-base-chinese', num_labels=35) opt = AdamW(model.parameters(), lr=2e-5) dl = DataLoader(train, batch_size=64, shuffle=True) for epoch in range(3): for bid, (ids, mask, lbl) in enumerate(dl): opt.zero_grad() logits = model(input_ids=ids, attention_mask=mask).logits loss = focal_loss(lbl, logits, gamma=2.0, alpha=alpha) loss.backward() opt.step() torch.save(model.state_dict(), f'bert_intent_ep{epoch}.pt')训练耗时:2080Ti 上 3epoch ≈ 50min,最终准确率 91.4%,比交叉熵基线高 4.3%。
2. Redis 维护对话状态(幂等)
Lua 脚本保证“读-改-写”原子性,避免并发覆盖:
-- update_slots.lua local key = KEYS[1] local new = cjson.decode(ARGV[1]) local old = redis.call('GET', key) if not old then old = '{}' end old = cjson.decode(old) for k,v in pairs(new) do old[k]=v end redis.call('SET', key, cjson.encode(old), 'EX', 1800) return old在 DM 里调用:
slots = r.evalsha(redis.script_load(lua), 1, f"state:{uid}", json.dumps(new_slots))性能优化:让 GPU 别偷懒
1. 批大小 vs GPU 利用率
实验环境:T4 * 1,CUDA 11.8,torch 2.1
| batch_size | GPU-Util | 平均推理延迟 | 吞吐 |
|---|---|---|---|
| 1 | 22% | 280ms | 3.6/s |
| 8 | 65% | 95ms | 84/s |
| 16 | 83% | 90ms | 177/s |
| 32 | 89% | 92ms | 350/s |
线上最终选 16:延迟 <100ms,吞吐够用,留 10% GPU 给滚动发布。
2. JMeter 压测报告(8C32G 容器 * 4)
- 线程 500,Ramp-up 30s,循环思考时间 1s
- 500 QPS 持续 5min
- 错误率 0.2%(全为超时 >3s,已触发熔断)
- TP99 1.12s,CPU 68%,GPU 83%
避坑指南:上线前一定要踩的坑
1. 冷启动默认回复
- 模型第一次加载 + 缓存空,TP99 会瞬间飙到 4s。
- 方案:容器启动时预热 Top-200 高频句,异步推送到 NLU,缓存提前加热;同时兜底回复“正在加速为您查询,请稍等~”,把用户预期压下来。
2. 敏感词过滤器误判
- 规则+词典误杀“退订单”里的“退”为敏感词。
- 方案:
- 采用双通道,先过白名单业务术语,再过敏感词;
- 被拦截句子二次送审 BERT 二分类“是否真敏感”,召回率从 94% 提到 99%,投诉量降 70%。
延伸思考:LLM 时代,RAG 是下一站
BERT 小模型在封闭场景够用,但开放域外问题(“你们和竞品差在哪”)立马露馅。下一步计划:
- 用 LLM(ChatGLM3-6B)做生成,RAG 架构外挂知识库(ElasticSearch + 向量双路召回)。
- 意图识别仍用 BERT 做“路由”,命中售后场景才走知识库,否则走小模型,成本可控。
- 对比实验已跑 1k 条,LLM 回答满意度 93%,比纯 BERT 高 12%,但 10× 成本;通过“路由+缓存”能把额外成本压到 1.8×,业务侧已点头。
写在最后的体会
把谛听从 0 到 1 推上线,最大的感受是:
- 别迷信单点模型,工程里的缓存、异步、批处理往往比换模型更管用;
- 微服务拆分按“是否依赖 GPU”划边界,扩容最省心;
- 压测一定用真实流量回放,JMeter 模板再真也模拟不了用户脑洞。
如果你也在做客服智能体,希望这篇笔记能帮你少踩几个坑。代码已放到内部 GitLab,有需要随时交流,一起把 AI 真正落到业务一线。