背景痛点:传统微信客服的三座大山
过去两年,我帮三家客户做过“纯自研”微信客服:从搭网关、写 NLP 到画前端,一条龙全包。上线后几乎都被同一组问题反复捶打:
- 消息延迟:微信服务器 5 秒内要收到回包,自研链路(Nginx→Java→MySQL→Redis)动辄 2-3 秒,高峰期直接超时,用户看到“该公众号暂时无法提供服务”。
- 上下文丢失:多轮对话靠 openid+时间窗口硬匹配,一旦用户同时发两条消息,顺序乱、状态丢,客服答非所问。
- 扩展性差:618、双 11 流量瞬间翻 10 倍,扩容要先买 ECS、装 JDK、调 JVM,等机器到位,活动都结束了。
这三座大山,让“客服”变成“客诉”。痛定思痛,我把目光投向 Serverless 化的扣子(coze)平台——下面记录完整踩坑与调优过程,供中高级同学抄作业。
技术选型:自建 NLP vs 扣子 coze
| 维度 | 自建 NLP 服务 | 扣子 coze |
|---|---|---|
| 机器成本 | 4 核 8G × 3 台 ≈ 1.2 万/年 | 0,按调用计费 |
| 运维人力 | 1 个 DevOps 全职 | 0 |
| 冷启动 | 无,但常驻空转 | 百毫秒级,可优化 |
| 峰值 QPS | 受限于 ECS 规格,需提前估 | 官方 1000 QPS 兜底 |
| 多轮对话 | 自己写状态机 | 内置 Graph Dialog |
| 第三方 API | 自己写鉴权、重试 | 节点拖拽即可 |
一句话总结:业务团队只想“快、稳、省”,扣子把 NLP、状态机、函数计算全打包成 Serverless,成本曲线随流量走,再也不怕“活动后吃灰”。
。
核心实现:让 Bot 长在扣子,让消息落在微信
1. 事件总线:微信 → 扣子 → 业务
微信 POST 先到我们自己网关,只做两件事:验签、透传。网关把事件原封不动丢给扣子 Webhook URL,后续逻辑全在扣子流里完成,网关只回 200,保证 5 秒 SLA。
2. 对话状态机设计
扣子自带「变量」+「条件分支」可画状态图,但生产里为了可追踪,我额外在 Redis 维护精简状态:
- key:
state:{openid} - value:JSON,含
cur_node、expire_at、context_dict
状态转换图如下(Mermaid 语法):
stateDiagram-v2 [*] --> Welcome: 首次消息 Welcome --> Query: 输入问题 Query --> Clarify: 意图置信度<0.7 Clarify --> Query: 用户重述 Query --> Answer: 意图置信度≥0.7 Answer --> Query: 继续提问 Answer --> [*]: 5min 无交互3. 敏感词过滤 & 异步日志
- 敏感词:扣子「内容审核」节点自带 2W+ 词库,可 1 分钟生效;业务侧再补“品牌黑灰词”正则,双保险。
- 日志:扣子流里把关键节点打 JSON,通过「HTTP 节点」推给日志网关,写 Kafka,再落 ES;不在主链路同步写,避免拖慢回包。
代码示例:网关层最简实现(Python 3.11)
以下代码跑在函数计算,128 MB 内存即可,单实例 500 并发无压力。
# wechat_gateway.py import os, time, json, hmac, hashlib, requests WECHAT_TOKEN = os.getenv("WECHAT_TOKEN") COZE_WEBHOOK = os.getenv("COZE_WEBHOOK") def verify(signature: str, timestamp: str, nonce: str, echostr: str) -> bool: tmp = "".join(sorted([WECHAT_TOKEN, timestamp, nonce])) return hmac.new(tmp.encode(), digestmod=hashlib.sha1).hexdigest() == signature def lambda_handler(event, context): query = event.get("queryString") or {} if "echostr" in query: # 微信首次验证 return {"statusCode": 200, "body": query["echostr"]} signature = query.get("signature", "") timestamp = query.get("timestamp", "") nonce = query.get("nonce", "") if not verify(signature, timestamp, nonce, ""): return {"statusCode": 403} # 透传给扣子 payload = json.loads(event["body"]) headers = {"Content-Type": "application/json; charset=utf-8"} r = requests.post(COZE_WEBHOOK, json=payload, headers=headers, timeout=3) return {"statusCode": 200, "body": "success"}多轮上下文存储片段(Redis)
import redis, json, time r = redis.Redis(host="redis-cn-xxxx.redis.rds.aliyuncs.com", decode_responses=True) def get_ctx(openid: str) -> dict: raw = r.get(f"state:{openid}") return json.loads(raw) if raw else {"node": "Welcome", "ts": time.time()} def set_ctx(openid: str, ctx: dict, ttl=300): r.setex(f"state:{openid}", ttl, json.dumps(ctx, ensure_ascii=False))生产考量:冷启动 & 配额
冷启动优化
- 函数计算选「闲置计费」模式,最小实例数保持 1,可把冷启动从 800 ms 压到 120 ms。
- 扣子侧「预置并发」购买 50 单元,官方承诺 P99 300 ms 内。
微信 API 调用配额
- 客服消息接口:默认 500 次/分钟,通过「公众号+小程序」双主体把额度池化,峰值 1000 次。
- 引入令牌桶:在扣子流里用「脚本节点」维护
available_token,每 10 秒回充一次,超量时转「文本提示:客服忙,请稍等」。
避坑指南:三次深夜被叫醒的教训
签名过期
微信会偶尔重试 30 秒前的消息,网关验签用当前时间对比,导致timestamp超 5 分钟被拒。解决:验签时放宽到 600 秒,并幂等处理MsgId。事件去重
同一MsgId微信可能重推,扣子流本身无去重。做法:在 Redis 记录processed:{MsgId},TTL 300 秒,重复即直接返回空。扣子流版本回滚
扣子支持「发布」与「草稿」隔离,但草稿调试时会把线上流量切过去。务必建两个 Bot:一个「测试」、一个「生产」,用不同 Webhook,通过网关 header 路由,杜绝“一滚全挂”。
性能压测数据
| 场景 | 并发 | P99 延迟 | 成功率 |
|---|---|---|---|
| 单轮文本 | 500 | 260 ms | 99.95% |
| 多轮+Redis | 300 | 380 ms | 99.90% |
| 高峰 1k QPS | 1000 | 520 ms | 99.80% |
压测工具:阿里云 PTS,地域上海,与函数同 VPC。数据说明:扣子 Serverless 链路在 1k 并发仍低于微信 5 秒红线,可放心睡大觉。
写在最后
把客服从“堆机器”转向“搭积木”后,最直观的的收益是发版速度:运营上午提“加个发货节点”,中午我在扣子拖两条线、发版,下午就生效。过去这套流程最短也要三天(代码→PR→灰度→全量)。如果你也在微信生态里被消息超时、状态丢失折磨,不妨给扣子一个迭代,或许就能提前下班。祝少踩坑,多睡觉。