背景痛点:轮询式客服为什么越用越卡
传统客服后台最常见的架构是“HTTP 短轮询”:前端每 3~5 秒发一次 GET,问“有没有我的消息?”
。
这种模型在 100 并发时还能扛,一旦促销峰值冲到 1000+ 连接,立刻出现三重瓶颈:
- 无效请求占比 94%:服务端 200 OK 里九成以上是空轮询,带宽白白浪费。
- 平均响应延迟 1.2 s:PHP-FPM+Apache 的进程模型下,每个请求都要重新建立框架上下文,CPU 空转。
- 横向扩容收益递减:加机器只能把 502 错推迟 10 分钟,因为 MySQL 连接数先打满,线程池排队呈指数增长。
一句话:轮询不是为“实时”设计的,只是“假装实时”。
技术选型:MCP、WebSocket 还是 gRPC?
为了把延迟压到 200 ms 以内,我们圈定了三条实时通道:WebSocket、gRPC、MCP(Message Communication Protocol)。在 4C8G 云主机、CentOS 8、同机房千兆网络下,用自研压测脚本(基于 k6)跑 5 分钟,拿到一组对比数据:
| 协议 | 单核 QPS | P99 延迟 | 内存占用 | 备注 |
|---|---|---|---|---|
| WebSocket | 18 k | 260 ms | 1.4 GB | 心跳+掩码开销大 |
| gRPC(H2) | 22 k | 180 ms | 1.1 GB | 需要 envoy 做边缘网关 |
| MCP | 28 k | 120 ms | 0.9 GB | 原生多路复用,包头 6 B |
MCP 的包头只有 6 字节,变长 Length + 1 字节 Type,省掉 WebSocket 的掩掩码计算,也省掉 gRPC 的 HPACK 头部压缩,CPU 占用降低 18%。再加上“连接即会话”的模型,少维护一份映射表,内存直接省 30%。综合评估后,我们拍板:核心链路用 MCP,边缘网关继续用 gRPC 对接老系统。
核心实现:让 AI 听懂人话、让协议看懂机器
1. 意图识别:BERT 三行代码就能上线
客服场景里,用户 80% 的问题集中在“订单在哪”“怎么退款”等 12 个意图。我们用 bert-base-chinese 微调,训练集 2 万条,30 epoch,最终 F1=0.94。推理侧用 PyTorch+TorchServe,GPU 是 Tesla T4,单卡可扛 600 qps。
# intent_server.py from transformers import BertTokenizer, BertForSequenceClassification import torch, json, base64 model = BertForSequenceClassification.from_pretrained("/data/bert-intent") tokenizer = BertTokenizer.from_pretrained("bert-base-chinese") def handler(event, _): text = json.loads(event["body"])["text"] inputs = tokenizer(text, return_tensors="pt", max_length=32, truncation=True) with torch.no_grad(): logits = model(**inputs).logits label_id = int(logits.argmax(-1)) return {"statusCode": 200, "body": json.dumps({"intent_id": label_id})}把模型封成 Docker,挂在 K8s HPA,CPU>60% 自动扩容,平均推理耗时 22 ms。
2. MCP 报文结构:6 字节头 + Protobuf 体
MCP 没有标准,我们参考 MQTT 3.1.1 自定了 Type:
- 0x01 CONNECT
- 0x02 CONNACK
- 0x03 PUBLISH
- 0x04 PUBACK
- 0x05 PING
- 0x06 DISCONNECT
Length 字段用变长编码(1-4 B),最大 256 MB,足够塞图片。序列图如下:
客户端一次 CONNECT 带上 user_id,服务端返回 CONNACK 携带 session_present,如果为 1 表示断线重连成功,客户端可跳过全量同步,直接续聊。
3. 对话状态机:Go 实现有限状态自动机
客服机器人只有 4 个主状态:Idle → WaitingForGoods → WaitingForRefund → Done。用 sync.Map 做并发安全存储,代码如下:
package fsm type State uint8 const ( Idle State = iota WaitingForGoods WaitingForRefund Done ) type Session struct { UID string State State Data map[string]string // 暂存槽位 } var pool = &sync.Map{} // map[string]*Session func Transit(uid string, intent string) (reply string, next State) { v, _ := pool.LoadOrStore(uid, &Session{UID: uid, State: Idle}) s := v.(*Session) switch s.State { case Idle: if intent == "where_is_order" { s.State = WaitingForGoods return "请提供订单号", WaitingForGoods } if intent == "apply_refund" { s.State = WaitingForRefund return "请问商品是否已收货", WaitingForRefund } case WaitingForGoods: s.Data["order_id"] = intent // 简化:直接把原文当订单号 s.State = Done return "正在查询,请稍候", Done } return "没听懂,转人工", Idle }每个状态迁移函数都是纯逻辑,方便单测。压测 1 万并发 goroutine,调度延迟 P99 8 ms。
性能优化:把 28 k QPS 再翻一倍
1. 连接池管理
MCP 基于 TCP,我们让客户端长连,服务端用 epoll 边缘触发。为避免 accept 抖动,预分配 4 个 listener 均匀绑在 4 核,SO_REUSEPORT+REUSEADDR 全开。连接对象放进环形数组,复用对象池,GC 压力降 35%。
2. Redis 会话缓存
状态机虽然轻,但重启即丢。我们把 Session 每 3 秒异步快照到 Redis,Hash 结构,key=mcp:uid:{uid},TTL=24 h。实测 4C8G 上 10 万 Hash 读 QPS 延迟 1.8 ms,比写 MySQL 快 10 倍。
3. 负载测试报告
JMeter 配置要点:
- 线程组:1000 并发,Ramp-up 30 s,循环 300 次
- TCP Sampler 插件发原始 MCP 包,关闭 Re-use connection,模拟真实断链
- 监控 Backend Listener 打到 InfluxDB,Grafana 看板实时 P99
结果:峰值 56 k QPS,CPU 占用 78%,内存 2.3 GB,网络吞吐 480 Mbps,无 5xx。对比优化前(无连接池、无 Redis)QPS 28 k,提升正好 100%。
避坑指南:上线前必须踩的三颗雷
1. 消息幂等性
MCP 的 PUBLISH 支持 QoS=1,需要 PUBACK。如果客户端超时重发,服务端须用 message_id 去重。我们采用“内存 bitmap + Redis set”两级方案:bitmap 存最近 1024 个 ID,周期 5 分钟;冷数据落到 Redis,key 带小时级时间戳,防止 bitmap 无限膨胀。
2. 冷启动知识库预热
客服知识库 8 万条 QA,全部塞进 Faiss 索引要 1.2 GB。服务刚拉起时若瞬间流量涌入,会触发 OOM。解决:
- 启动脚本先加载 20% 高频数据(占 200 MB),提供基础能力;
- 后台 goroutine 按 PV 排序异步加载剩余 80%,每 200 ms 睡 10 ms,把 CPU 让给在线请求;
- 加载完向 Prometheus 推一条 custom metric,告警解除。整体冷启动时间从 180 s 降到 25 s,且无 502。
3. 敏感词过滤 DFA 优化
传统 DFA 构造完 2 万条敏感词,状态节点 18 万,每次替换都要全树遍历,CPU 占 15%。把树转成 Double Array Trie,数组存储,节点仅 4 字节,内存降 60%;再引入 Aho-Corasick 失败指针,扫描一次即可,最终单核 20 MB/s,CPU 降到 3%。
延伸思考:把 LLM 塞进 MCP 管道
MCP 的 PUBLISH 可以承载任意业务 payload,天然适合把大模型流式结果切片下发。设想:
- 用户问“帮我写一段 Python 爬虫”,意图识别后路由到 LLM 服务;
- LLM 用 SSE 把 token 流推到边缘节点,节点按 64 B 切片封装成 MCP PUBLISH,message_id 顺序递增;
- 客户端收到后本地拼接,UI 逐字渲染,延迟体感 <200 ms。
挑战在于:
- 流式消息也要幂等,message_id 必须全局自增,需要 Snowflake 发号器;
- 大模型输出 2 k token 时,MCP 包体膨胀,要开启 gzip,CPU 又会上去 10%,需要硬件加速;
- 多轮上下文超过 4 k token 时,如何与状态机融合、避免每次都把历史塞进 prompt,还得再拆微服务。
整体可行,但得等 GPU 预算批下来再试。
把 MCP 塞进客服系统后,我们最大的感受是“省机器”:同一批 4C8G,以前只能扛 5 k 轮询并发,现在轻松跑到 50 k 长连,CPU 还有 20% 余量。对业务方来说,平均等待时长从 45 s 降到 5 s,投诉工单下降 37%。如果你也在为“实时”和“扩展”两头烧脑,不妨给 MCP 一个 sprint 的时间,搭套最小原型,数据自己会说话。