背景痛点:为什么“能聊”≠“能扛”
去年帮一家电商客户做客服小程序,上线首日就翻车了:
- 用户同时咨询量超过 200 时,对话上下文串台,A 用户收到 B 的物流单号;
- 意图识别服务在高峰期 RT 99 线飙到 3 s,微信 5 s 超时直接断连;
- 单体 Node 容器 CPU 打到 90%,运维同学手动扩容都来不及。
复盘发现,传统“单体+数据库”模式在三个环节最脆弱:
- 会话保持:WebSocket 长连接断开后,会话状态落在内存,实例重启就丢;
- 意图识别:NLP 模型和规则引擎混跑,高并发时线程池被打满,请求堆积产生背压;**
- 资源调度:固定 2C4G 容器,无法应对秒杀期间的 10 倍流量,扩容脚本最快也要 2 min,而活动流量峰值 30 s 就到。
于是把架构推倒重来,目标一句话——“让客服小程序既能聊,又能扛”。
架构设计:单体 vs Serverless 的取舍
先画一张对比表:
| 维度 | 单体容器 | Serverless(微信云函数+Dialogflow+Redis) |
|---|---|---|
| 扩容速度 | 2~3 min 拉起新 Pod | 100 ms 级冷启动,并发 500+ 实例 |
| 会话状态 | 本地内存,易丢 | Redis 持久化,支持最终一致性 |
| 运维成本 | 需维护镜像、K8s、监控 | 免运维,日志、监控托管 |
| 费用 | 常驻 2C4G 一天 20 元 | 按调用计费,闲时几乎 0 元 |
最终架构图如下:
核心链路:
小程序端 ➜ 微信云托管(云函数) ➜ Dialogflow ES ➜ 云开发数据库/Redis ➜ 回包给小程序。
云函数只做三件事:鉴权、消息幂等、对话状态机驱动;重 NLP 交给 Dialogflow,避免把模型打包进函数包导致冷启动膨胀。
核心实现:代码能直接跑
1. 用 wx.cloud.callContainer 实现弹性扩缩容
小程序端不需要关心后端有几台机器,只要调callContainer:
// miniprogram/utils/cloud.js async function callContainer(api, data)那对{ try { const res = await wx.cloud.callContainer({ path: api, method: 'POST', data, header一对一对{ 'X-WX-OPENID': wx.getStorageSync('openid') } }); if (res.statusCode !== 200) throw { code: res.data.code, msg: res.data.msg }; return res.data; } catch (err) { console.error('[callContainer]', api, err); throw err; } }云托管侧配置最小 0 实例、最大 500 实例,CPU 60% 触发扩容;实测 1 k 并发下 8 s 完成 200→500 实例的横向扩展。
2. 对话状态机:把“聊天”抽象成状态图
多轮对话最怕丢上下文。定义一张stateDiagram表存在 Redis:
{ "openid_xxx": { "curNode": "askDelivery", "vars": { "orderId": "123456" }, "ttl": 1698987654 } }云函数里用state-machine包驱动:
// cloudfunctions/chat/index.js const StateMachine = require('javascript-state-machine'); exports.main = async (event, context) => { const { openid, msg } = event; let session = await redis.get(`sm:${openid}`); const fsm = StateMachine.create({ initial: session ? session.curNode : 'idle', transitions: [ { name: 'askDelivery', from: 'idle', to: 'waitDate' }, { name: 'provideDate', from: 'waitDate', to: 'end' } ] }); // 驱动状态迁移 fsm[fsm.state](); // 把最新状态落库 session = { curNode: fsm.state, vars: fsm.vars, ttl: Date.now() + 300000 }; await redis.setex(`sm:${openid}`, 300, JSON.stringify(session)); return { reply: '已为您预约配送时间' }; };好处:
- 状态迁移可单元测试;
- Redis 过期即自动清掉僵尸会话,省内存。
3. 幂等消息 + 错误重试:云数据库实现
微信会重推同一msgId,必须幂等。云数据库唯一索引 + 乐观锁:
// cloudfunctions/chat/idempotent.js const cloud = require('wx-server-sdk'); cloud.init(); const db = cloud.database(); const _ = db.command; exports.insertMsg = async (msgId, payload) => { try { await db.collection('msg_record').add({ data: { _id: msgId, payload, createTime: new Date(), processed: false } }); } catch (e) { if (e.errCode === -502005) { // 唯一索引冲突 console.log(`[Dup] ${msgId} 已存在`); return false; } throw e; // 其他异常继续抛 } return true; };调用方逻辑:
const ok = await idempotent.insertMsg(msgId, payload); if (!ok) return { errCode: 0, errMsg: '重复消息,直接丢弃' };重试策略:
- 云函数内部异常 → 微信会 3 次重推,msgId 不变,幂等表挡掉;
- Dialogflow 超时 → 捕获后返回兜底文案“客服忙,稍等”,并记日志,避免把异常抛给微信导致“红色感叹号”。
性能优化:让冷启动不“冷”
1. 预热策略
- 云托管最小实例置 3,保证日常 50 并发无冷启动;
- 定时触发器每 5 min 调一次
/_warmup接口,内部require()全部懒加载的依赖,让容器包常驻内存; - 把 500 k 的意图语料拆到 OSS,函数启动时按需流式拉取,避免一次性读入导致 6 s 冷启动。
2. 对话状态缓存:内存 vs Redis
| 方案 | 读写耗时 | 数据安全 | 适用场景 |
|---|---|---|---|
| 云函数内存变量 | <1 ms | 实例重启即丢 | 压力测试、可接受丢会话 |
| Redis 单节点 | 3~5 ms | 主从切换丢 1 s | 线上默认 |
| Redis + AOF 每秒刷盘 | 5~7 ms | 最多丢 1 s | 对一致性敏感业务 |
实测 500 TPS 下,Redis 单节点 CPU 30%,QPS 1 w 足够扛;若后续翻倍,可加 8 分片 Redis Cluster。
避坑指南:少踩就是赚
1. 微信 API 频次限制
customerServiceMessage.send默认 300 次/分;- 秒杀 活动前提前 3 天申请临时额度,提供活动方案、流量预估截图,一般可给到 3000 次/分;
- 代码层加令牌桶,超过阈值时把消息落延迟队列,1 min 后重试,避免直接 45001 报错。
2. 敏感信息加密
- 用户手机号、地址不走明文,用微信开放数据 AES 解密后,立即用
crypto-js二次加密落库; - 密钥放云托管环境变量,KMS 自动轮转;
- 数据库审计字段用
TEXT存密文,防止 DBA 直窥。
3. 对话超时处理
| 模式 | 实现 | 优劣 |
|---|---|---|
| 固定 5 min 清会话 | Redis ttl | 简单,但用户回来又得重填信息 |
| 动态心跳续期 | 每收到消息重置 ttl | 体验好,内存占用高 |
| 业务 idle 超时 | 超过 10 min 无关键字段,触发总结工单 | 折中,内存可控,用户感知好 |
线上采用方案 3,把 idle 节点单独标记,ttl 15 min,用户再说话先检查是否 idle,是则引导“继续上次工单”。
延伸思考:把 LLM 塞进去,指标怎么定?
如果以后把 Dialogflow 换成自研 LLM,建议先跑灰度,关注三组指标:
- 意图准确率= 正确识别数 / 总问询数;
- 多轮完成率= 完成目标用户数 / 进入对话用户数;
- 成本 RT= 单轮 LLM 耗时 * 单轮平均调用次数,目标 <1.2 s。
灰度 5% 流量,两周内若指标齐平且成本下降 20%,再全量。
另外,LLM 输出要做“安全护栏”:
- 用另一小模型做 toxicity 过滤,概率 >0.85 直接走兜底文案;
- 敏感词正则二次兜底,防止被恶意 prompt 刷出广告。
整套方案上线后,客服小程序在 618 峰值 520 TPS 下稳定运行,平均响应 480 ms,P99 1.2 s;运维同学再也不用凌晨两点起来扩容,我也终于能安心睡个整觉。
如果你正准备动手,不妨把上面的代码片段直接粘到云开发模板里,跑通后再一点点替换自己的业务语料。先让系统“能扛”,再谈“能聊”,节奏就对了。