背景痛点:复杂对话场景下的“慢”到底卡在哪
去年双十一,我们把 Rasa 智能客服推到生产环境,结果一上线就翻车:高峰期平均响应 1.8 s,CPU 飙到 90%,用户疯狂点“转人工”。
把火焰图一拉,发现三大黑洞:
- 意图识别(NLU)是同步串行,一条消息要等 pipeline 里 6 个组件挨个跑完。
- 对话状态(Tracker)默认放内存,4 千个并发会话就把 8G 容器打满,GC 抖动。
- Policy Ensemble 每次把 5 个模型全部跑一遍,TensorFlow 子图膨胀,推理延迟 400 ms+。
一句话:Rasa 默认是“实验室模式”,真要到高并发场景,得自己动刀子。
技术选型:同步 vs 异步、Redis vs MongoDB
先把可选路线摆桌面,省得后面拍脑袋。
| 维度 | 同步(Flask) | 异步(Sanic) | Redis 状态存储 | MongoDB 状态存储 |
|---|---|---|---|---|
| 并发模型 | 多线程/进程 | 单线程协程 | 单线程协程 | 多线程连接池 |
| 平均延迟 | 高(线程切换) | 低(事件循环) | 低(内存级) | 中(磁盘 IO) |
| 水平扩容 | 难(会话粘滞) | 易(无状态) | 易(主从+分片) | 中(副本集) |
| 运维成本 | 低 | 低 | 中 | 高 |
| 数据可靠性 | 无 | 无 | 高(AOF+RDB) | 高(副本集) |
结论:
- 网关层用 Sanic 做异步 NLU,推理线程池隔离,IO 不再阻塞事件循环。
- 状态层用 Redis+TTL,既保证水平扩容,又省掉 MongoDB 的副本集运维噩梦。
核心实现一:异步 NLU 网关(Sanic)
把 Rasa NLU 拆出来独立服务,用 Sanic 包一层,代码直接丢 GitLab CI 就能跑。
# nlu_server.py from typing import Dict, Any import asyncio import aioredis from sanic import Sanic, response from rasa.nlu.model import Interpreter from concurrent.futures import ThreadPoolExecutor import logging app = Sanic("AsyncNLU") interpreter = Interpreter.load("models/nlu-20240601.tar.gz") pool = ThreadPoolExecutor(max_workers=4, thread_name_prefix="nlu_infer") redis = None # 懒加载,见下方 async def get_redis(): global redis if redis is None: redis = await aioredis.from_url( "redis://redis-cluster:6379/1", encoding="utf-8", decode_responses=True ) return redis @app.post("/parse") async def parse(request) -> response.HTTPResponse: try: text: str = request.json["text"] cid: str = request.json["sender_id"] # 1. 缓存 30 s 内重复问题 key = f"nlu:{cid}:{hash(text) & 0xFFFFFF}" cached: str = await (await get_redis()).get(key) if cached: return response.json(eval(cached)) # 2. 线程池跑推理,避免阻塞事件循环 loop = asyncio.get_event_loop() result: Dict[str, Any] = await loop.run_in_executor( pool, interpreter.parse, text ) # 3. 回写缓存 await (await get_redis()).setex(key, 30, str(result)) return response.json(result) except Exception as e: logging.exception("NLU parse error") return response.json({"error": str(e)}, status=500) if __name__ == "__main__": app.run(host="0.0DIY.0.0", port=8000, workers=1, access_log=False)要点
- 线程池大小 ≈ CPU 核心,别让推理把事件循环饿死。
- 缓存 key 带 sender_id,防止用户 A 蹭到用户 B 的缓存。
- 异常全部 catch 并落日志,Sanic 默认不会把堆栈吐给客户端。
核心实现二:对话状态缓存设计
Rasa-Core 默认把 Tracker 放内存,重启即丢。改成 Redis 后,需要解决“缓存穿透”和“雪崩”两个问题。
# tracker_store.py from rasa.core.tracker_store import TrackerStore from typing import Optional, Dict, Any import aioredis, json, time class RedisTrackerStore(TrackerStore): def __init__(self, domain, url: str = "redis://redis-cluster:6379/0", ttl: int = 3600): super().__init__(domain) self.ttl = ttl self.redis = aioredis.from_url(url, decode_responses=True) async def save(self, tracker) -> None: key = f"tracker:{tracker.sender_id}" data = self.serialise_tracker(tracker) await self.redis.setex(key3600, self.ttl, json.dumps(data)) async def retrieve(self, sender_id: str) -> Optional[DialogueStateTracker]: key = f"tracker:{sender_id}" data = await self.redis.get(key) if data: return self.deserialise_tracker(json.loads(data)) return None失效策略
- TTL 1 h,用户聊完即走,不挤爆内存。
- 写操作异步回写,读操作优先走缓存, miss 再回源 Postgres,保证最终一致。
- 雪崩预防:TTL 随机 jitter ±10%,防止集中过期。
核心实现三:模型剪枝 + 量化
Policy Ensemble 里 3 个 DIET、2 个 TED,体积 1.2 G,推理巨慢。
用 TensorFlow Model Optimization 走一遍剪枝 + 动态量化,体积降到 380 M,推理延迟 400 ms→220 ms,精度掉 0.7%,在业务可接受范围。
# prune 脚本(仅关键步骤) pip install tensorflow-model-optimization python prune.py --input_dir=models/20240601 --output_dir=models/20240601_pruned \ --sparsity=0.5 --quantize=dynamicprune.py 核心 20 行,官方文档抄的,不赘述。
记得把model.save后重新打包成 tar.gz,Rasa 只认这个格式。
性能测试:优化前后硬数据
测试环境:
- 4C8G K8s Pod × 10
- 200 并发用户,持续 10 min,场景为“查订单→改地址→确认”三轮对话
| 指标 | 优化前 | 优化后 | 提升率 |
|---|---|---|---|
| 平均响应 | 1.83 s | 0.97 s | ↓47% |
| 95 PCT | 3.10 s | 1.50 s | ↓52% |
| TPS | 108 | 183 | ↑69% |
| 内存峰值 | 6.8 G | 3.9 G | ↓43% |
| CPU 峰值 | 89% | 52% | ↓37% |
图片:压测 Grafana 面板对比
避坑指南:分布式部署踩过的 5 个坑
会话一致性
多 Pod 部署时,一定把session_persistence关到最小,只靠 Redis 里的 sender_id 做一致性,别让 Nginx ip_hash 把用户绑死。模型热更新零停机
用 K8s RollingUpdate + 双模型目录:- 新模型先放到
/models/new,健康检查通过后改软链指向/models/current,旧 Pod 优雅退出 30 s,正在处理的请求跑完再关机。
- 新模型先放到
线程池打爆
Sanic 的run_in_executor默认无界队列,高并发会 OOM。一定加max_workers,并给线程池包一层asyncio.Semaphore。Redis 大 Key
Tracker 序列化后 50 k 很常见,单个 key 过大容易阻塞 rehash。开启redis-cli --bigkeys巡检,超过 32 k 的 key 强制压缩或分页。日志异步
同步写日志会把事件循环拖死,用logging.handlers.QueueHandler+ 独立线程,别让磁盘 IO 反压 Sanic。
延伸思考:K8s 自动扩缩容方案
下一步想把“白天 3 副本、晚上 1 副本”做成自动的,思路草图:
- 指标:自定义 Prometheus 指标
rasa_request_latency_p99,大于 1 s 持续 2 min 即扩容。 - 伸缩对象:NLU 网关 Pod(无状态),Core 服务 Pod(只读 Redis,也可无状态)。
- 冷启动优化:模型放 initContainer 提前拉取,容器启动后 5 s 内就绪。
- 缩容保护:Pod 收到 SIGTERM 后先关
/health探针,流量不再进来,30 s 后真正退出,保证正在处理的对话不掉线。
HPA 草案 YAML(片段)
apiVersion: autoscaling/v2 kind: HorizontalPodAutoscaler metadata: name: nlu-gateway spec: scaleTargetRef: apiVersion: apps/v1 kind: Deployment name: nlu-gateway minReplicas: 1 maxReplicas: 20 metrics: - type: Pods pods: metric: name: rasa_request_latency_p99 target: type: Value averageValue: "1"写在最后
整套优化做下来,最深刻的体会是:Rasa 的“慢”往往不是算法不行,而是工程化细节堆出来的。
把同步改异步、内存改缓存、大模型改小模型,三板斧砍完,TPS 直接翻倍,服务器却从 10 台缩到 6 台。
省下的机器和电费,刚好给团队多订几杯咖啡,大家边喝边继续调模型——这才是程序员该有的浪漫。