背景痛点:传统规则引擎客服系统的瓶颈
去年做客服系统重构时,我们踩过最大的坑就是“规则引擎+同步线程池”的老架构。
高峰期只要出现 10% 的长尾请求(用户一句话要查 5~ 个外部接口),整个线程池就被打满,后续请求排队 3 s 以上,客服同学疯狂截图吐槽。
更尴尬的是意图识别模块:规则正则+关键词权重,准确率 72%,每天人工纠偏 1 200+ 单,运营直接甩 KPI 给技术部。
总结下来,老系统三大硬伤:
- 同步阻塞 → 长尾请求拖垮整体吞吐
- 规则膨胀 → 意图冲突、优先级混乱
- 无状态设计 → 多轮对话靠 Cookie 硬编码,横向扩展就丢上下文
技术选型:Coze 与主流方案对比
| 维度 | Coze | Rasa 3.x | Dialogflow ES |
|---|---|---|---|
| 并发模型 | 事件驱动 + Actor 轻量进程 | 同步 Sanic + 外部消息队列 | 谷歌托管(黑盒) |
| NLU 语种 | 中/英/日/韩 官方预训练 | 依赖 spaCy 组件,中文需自训 | 中文支持弱,需付费 |
| 自定义插件 | Python 热插拔 | 需写 Component 并重启 | 通过 Webhook 回调 |
| 水平扩容 | 无状态,直接 k8s 副本 | 需 Rasa-Pro + Kafka 事件总线 | 不可控 |
| 学习成本 | 低,30 min 可上线 | 高,Pipeline 概念多 | 文档碎片化 |
我们最终选 Coze,核心原因是:
- 自带事件循环,单实例就能跑满 4 核,省下一层消息队列
- 插件市场有官方“Redis 状态存储”模板,十分钟集成
- 托管在火山引擎,国内延迟 30 ms 以内,合规报告直接复用
核心实现
1. 异步对话处理框架
下面这段代码跑在 Coze 的“自定义技能”容器里,收到用户消息后会:
- 异步聚合外部接口(订单、物流、优惠券)
- 用
@retry做幂等重试,防止重复扣券
# skills/order_query.py import asyncio import httpx from typing import Dict from tenacity import retry, stop_after_attempt, wait_random class OrderQuerySkill: """订单查询技能,演示异步聚合与重试""" def __init__(self, timeout: float = 2.0): self.client = httpx.AsyncClient(timeout=timeout) async def __aenter__(self): return self async def __aexit__(self, exc_type, exc, tb): await self.client.aclose() @retry(stop=stop_after_attempt(3), wait=wait_random(0.5, 1.5)) async def _get_order(self, order_id: str) -> dict: """带重试的订单详情查询""" rsp = await self.client.get( f"https://api.shop.com/order/{order_id}", headers={"Authorization": "Bearer " + self._token()} ) rsp.raise_for_status() return rsp.json() async def handle(self, order_id: str) -> str: order, shipping, coupon = await asyncio.gather( self._get_order(order_id), self._get_shipping(order_id), self._get_coupon(order_id), return_exceptions=True ) # 异常转友好提示 if isinstance(order, Exception): return "订单查询超时,请稍后再试~" return self._build_answer(order, shipping, coupon) # 其余辅助方法省略...要点:
- 全部 IO 走
asyncio.gather,长尾请求并行化,平均 RT 从 1 800 ms 降到 420 ms @retry默认指数退避,对相同order_id具备幂等性(下游接口支持幂等 Token)
2. 对话状态机 + Redis 分布式存储
多轮对话最怕“刷新页面就失忆”。我们把状态机拆成两段:
- 内存:只保存当前活跃节点指针
- Redis:序列化整个
DialogueState(含实体槽位、历史事件),TTL 15 min
# state_store.py import json import redis from typing import Optional from pydantic import BaseModel class DialogueState(BaseModel): user_id: str node_id: str slots: dict ts: float class RedisStore: def __init__(self, host: str, ttl: int = 900): self.r = redis.Redis(host, decode_responses=True) self.ttl = ttl def _key(self, user_id: str) -> str: return f"coze:dialog:{user_id}" def get(self, user_id: str) -> Optional[DialogueState]: data = self.r.get(self._key(user.id)) return DialogueState.parse_raw(data) if data else None def set(self, state: DialogueState) -> None: self.r.setex( self._key(state.user_id), self.ttl, state.model_dump_json() )TTL 最佳实践:
- 15 min 是客服平均会话时长 + 2 倍方差,既省内存又保证体验
- 每次
set都重新续期,防止“聊到一半过期” - 大促前把 TTL 临时缩到 5 min,内存降 30%,QPS 提升 12%
性能优化
1. 压测数据对比
JMeter 脚本:200 线程、Ramp 60 s、循环 300 次,模拟“订单查询+优惠券”混合场景
| 版本 | 平均 RT | 95% RT | Error % | QPS |
|---|---|---|---|---|
| 老系统(同步) | 1 800 ms | 4 200 ms | 5.3 % | 110 |
| Coze 异步 + 本地缓存 | 420 ms | 980 ms | 0.4 % | 430 |
吞吐量提升 ≈ (430-110)/110 = 290%,远超 PPT 里吹的“30%”
2. Sentinel 熔断策略
外部物流接口偶发 5 s 慢查询,会把协程吃光。接入 Sentinel 后:
- 慢调用比例 > 40% 且 QPS > 50 时,熔断 5 s
- 熔断期间直接返回“物流信息维护中,稍后推送”
# sentinel-golang 配置片段 flow: - resource: getShipping grade: 1 # 0=线程 1=QPS count: 50 degrade: - resource: getShipping grade: 0 # 0=慢调用比例 count: 40 # 40% timeWindow: 5上线一周,物流接口超时从 3 000 次/天降到 120 次/天,客服投诉量同步下降 60%
避坑指南
1. 冷启动时预加载模型
Coze 的 NLU 模型默认懒加载,第一个请求要拉 300 M 文件,RT 飙到 7 s。
解决:在 Dockerfile 里加WARM_UP=1环境变量,容器启动时先跑一条“你好”自请求,模型进内存后再注册服务。这样外部流量进来就是热模型,P99 延迟稳定 < 200 ms
2. 多轮对话上下文压缩算法
Redis 存储大对象 2 k→8 k,内存暴涨。
我们采用“差分快照”:
- 只保存每轮 diff(新增/覆盖的槽位)
- 回放时顺序 apply 到初始空对象
压缩率 72%,大促 200 万会话节省 6 G 内存,效果肉眼可见
生产级容错与降级
- 双写日志:对话状态写 Redis 同时落盘,机器重启可秒级回放
- 版本灰度:Coze 支持按 UserId 尾号分流,先放 5% 流量观察 30 min,无异常再全量
- 降级剧本:当 Redis 失联,自动切本地 LRU 缓存,兜底文案“正在为您转人工,请稍等”
小结
到这,我们完成了:
- 用 Coze 事件驱动替换同步线程池,长尾请求不再阻塞
- 异步聚合 + 幂等重试,把订单类查询 RT 降到 1/4
- Redis 状态机 + TTL + 压缩,让多轮对话扩容不再掉线
- Sentinel 熔断 + 预加载 + 灰度,系统稳定性可量化
整套方案已在 3 月内全量切流,目前稳定承载日均 80 万对话,峰值 3 k QPS,客服人力节省 35%
开放性问题
如何设计跨渠道的会话亲和性保持方案?
当同一用户先在小程序咨询,又拨 400 电话,再切到 App 聊天,如何保证客服视角只有一条连续会话,且状态不丢、路由不冲突?欢迎留言聊聊你的做法。