背景痛点:高并发、方言与上下文的三重夹击
去年“618”大促,我们团队负责的智能客服在零点 3 分钟内涌入 42 万条消息,CPU 瞬间飙到 96%,P99 延迟从 400 ms 涨到 3.8 s,大量用户被转人工坐席,投诉率飙升。复盘发现,除了流量突增,还有两大隐形炸弹:
- 方言与口语化表达让 NLU 意图识别准确率从 92% 跌到 78%,“我要退钱”被误分为“退款”与“返现”两类,导致下游流程错乱。
- 多轮上下文保持依赖单体 Session 内存,Pod 横向扩容后会话亲和性失效,用户上一句“改成周六送货”在下一句就失忆。
这三点叠加,让“智能”秒变“智障”。下文记录我们如何用微服务 + 异步化把系统重新拉回“人话”水平。
技术选型:Rasa、Dialogflow 与自研引擎三角对决
| 维度 | Rasa 3.x | Dialogflow ES | 自研轻量引擎 |
|---|---|---|---|
| 意图识别准确率(标准测试集) | 89.3% | 91.7% | 90.1% |
| 平均响应延迟(P99) | 280 ms | 210 ms | 120 ms |
| 支持语种 | 137 种 | 40+ 种 | 中英粤 + 方言扩展 |
| 可定制程度 | 高 | 低 | 极高 |
| 离线费用 | 0 美元 | 0.002$/次 | 服务器折旧 |
结论:
- 对隐私、可定制要求高的金融/医疗场景,Rasa 与自研更合适。
- Dialogflow 在“开箱即用”上最省事,但黑盒模型无法微调,且 210 ms 延迟里 60% 为网络开销,高并发下不可控。
- 自研引擎基于 ALBERT + CNN 意图分类,蒸馏后模型 8 MB,单核 QPS 1.8 k,延迟稳定 120 ms,最终成为主方案,Rasa 降级为备用。
核心实现
1. 使用 Spring Cloud Gateway 做请求分流
网关层按租户 + 地域分片,配合 Redis 令牌桶限流,防止恶意刷量。关键配置:
spring: cloud: gateway: routes: - id: nlu-service uri: lb://nlu-service predicates: - Path=/nlu/** filters: - name: RequestRateLimiter args: rate-limiter: "#{@redisRateLimiter}" key-resolver: "#{@tenantKeyResolver}"令牌桶每秒补充 2000 令牌,突发可透支 500,保证 2000 TPS 内不拒单。
2. 基于 Redis 的对话状态机(含分布式锁)
状态机采用 Hash 存储,key 为session:{tenant}:{userId},field 放“意图栈”“实体槽位”“轮次”等,过期 30 min 自动清理。核心代码:
public class DialogueStateManager { private final StringRedisTemplate redis; private final long LOCK_TTL_MS = 5000; private final String LOCK_KEY_PREFIX = "lock:dialogue:"; public Optional<DialogueState> fetch(String tenant, String userId) { String key = "session:" + tenant + ":" + userId; Map<Object, Object> hash = redis.opsForHash().entries(key); if (hash.isEmpty()) return Optional.empty(); DialogueState state = DialogueState.fromMap(hash); redis.expire(key, 30, TimeUnit.MINUTES); // 续期 return Optional.of(state); } public boolean save(DialogueState state) { String key = "session:" + state.getTenant() + ":" + state.getUserId(); String lockKey = LOCK_KEY_PREFIX + key; String uuid = Thread.currentThread().getId() + ":" + System.nanoTime(); try { // 获取分布式锁,5s 自动过期 Boolean locked = redis.opsForValue() .setIfAbsent(lockKey, uuid, LOCK_TTL_MS, TimeUnit.MILLISECONDS); if (!Boolean.TRUE.equals(locked)) return false; redis.opsForHash().putAll(key, state.toMap()); redis.expire(key, 30, TimeUnit.MINUTES); return true; } finally { // 仅当 value 匹配才释放,防止误删 String lua = "if redis.call('get', KEYS[1]) == ARGV[1] then " + "return redis.call('del', KEYS[1]) else return 0 end"; redis.execute(new DefaultRedisScript<>(lua, Long.class), Collections.singletonList(lockKey), uuid); } } }时间复杂度:
- Hash 读写 O(1),分布式锁 Lua 脚本 O(1),整体并发安全且对延迟影响 < 5 ms。
3. 异步处理流水线与 Kafka 分区策略
- Gateway 把请求打到 Kafka 的
chat.inTopic,按userId%分区数保证同一会话顺序。 - NLU 服务消费后写回
chat.out,再由 WebSocket Push 回前端。 - 分区数 = 2 × Broker 核数,经实验 24 分区即可让 3 节点 6 核集群 CPU 维持 65%,再增加分区边际延迟下降 < 3%,收益递减。
性能优化
1. JMeter 压测 2000 TPS 方法论
- 线程组:2000 并发线程,1 s 内 Ramp-up,循环 300 s。
- 报文体:采样真实对话,JSON 大小 0.8 KB。
- 断言:响应码 200 + 延迟 < 800 ms。
- 监控:Prometheus + Grafana 拉取 Pod CPU、QPS、P99;同时跑
jfr记录 JVM。
结果:
- 2000 TPS 下 P99 延迟 620 ms,CPU 71%,无错误。
- 2400 TPS 出现 1.2% 超时,瓶颈在 Redis 单分片网卡打满,后续把状态 Hash 压缩 40%,并启用 Redis 集群,极限 QPS 提到 3200。
2. FAQ 知识库预加载
系统启动时把 3.2 万条高频 FAQ 按意图维度预加载到 Caffeine 本地缓存,最大权重 2 GB,过期策略写后 30 min。冷启动阶段意图识别平均延迟从 180 ms 降到 120 ms,缓存命中率 68%,Redis 查询量下降 30%,与摘要中“降低 30% 延迟”数据吻合。
避坑指南
1. 对话超时重试的幂等性
用户侧 5 s 未收到回复自动重发,如果服务端无幂等,会重复下单/退款。做法:在 Gateway 层给每条消息生成msgId(UUID),下游所有写操作以msgId做唯一索引,MySQL 层加UNIQUE KEY,重复写入捕获DuplicateKeyException后直接返回缓存结果,保证“一次请求,一次副作用”。
2. 敏感词过滤的多语言陷阱
中文“”在繁体里可能是“”,泰文语音词在 UTF-8 占 3 字节,直接用正则\w+会截断。采用 Unicode 字符遍历 + 归一化(NFKC)后再匹配 DFA 词库,支持 Emoji、藏文、维文,CPU 增长 < 6%,误杀率 0.02%。
延伸思考:用强化学习优化多轮对话
当前策略靠规则 + 优先级,无法在线更新。我们正试验把“对话完成度”作为奖励,用 Policy Gradient 训练对话策略网络:
- 状态:用户意图、已填槽位、历史动作序列。
- 动作:追问/澄清/直接回答/转人工。
- 奖励:任务完成 +1,用户主动结束 −0.2,转人工 −1。
离线训练 30 万段日志后,多轮对话平均轮次从 3.8 降到 2.4,任务成功率提升 6.7%。下一步将模型蒸馏成 3 MB TFLite 部署到边缘 Pod,实现毫秒级推理,兼顾在线学习与隐私合规。
把高并发、状态一致、低延迟同时做到并不容易,以上方案在 2023 年大促中扛住 3200 TPS,P99 延迟稳定在 650 ms 以内,错误率 < 0.1%。如果你也在做智能客服,希望这份“踩坑 + 调优”笔记能帮你少熬几个通宵。