实战指南:如何用Chatbot JS构建高可用对话系统
去年我在公司负责把客服机器人从“能跑”升级到“能扛”,踩坑无数,最后把 Chatbot JS 打磨成一套能扛 3w 并发、平均响应 120 ms 的生产级方案。今天把血泪经验打包成一份可直接抄作业的实战笔记,顺带回答一个开放式问题:怎么让对话策略像网页一样随时 AB 测试?看完你就有思路。
1. 背景痛点:轮询的“三宗罪”
老系统用 HTTP 轮询,前端每 2 s 问一次“有消息吗?”,结果:
- 90 % 请求空跑,带宽白白烧掉;
- 用户一句话分三条发,顺序乱成麻花;
- 服务器一扩容,Nginx 日志里 502 比对话还多。
一句话:轮询在对话场景就是“假实时”,状态同步全靠客户端猜,猜错就翻车。
2. 技术选型:WebSocket vs 长轮询
| 维度 | 长轮询 | WebSocket |
|---|---|---|
| 延迟 | ≥ 1 s(受轮询间隔限制) | ≤ 100 ms |
| 头部开销 | 每次 800 B+ | 一次握手,后续 2 B 起 |
| 服务器内存 | 连接一多就爆炸 | 单机 10 w 连接轻松 |
| 断线感知 | 靠超时,用户感知慢 | TCP 层立刻回调 |
结论:对话场景下 WebSocket 是“默认答案”,长轮询只配做降级。
3. 核心实现:三板斧搞定健壮逻辑
####代码基于 Node 18 + ws 8,浏览器端用原生 WebSocket,全部 ESModule,直接复制就能跑。
3.1 用有限状态机(FSM)管理对话流程
把聊天抽象成 4 个状态,杜绝 if/else 地狱:
// fsm.js export const STATE = { IDLE: 'idle', // 刚连接,未说话 WAITING: 'waiting', // 已发消息,等机器人回 CHATTING: 'chatting', // 多轮对话中 ERROR: 'error' // 需要人工介入 }; export const EVENT = { USER_MSG: 'USER_MSG', BOT_REPLY:'BOT_REPLY', TIMEOUT: 'TIMEOUT', RESET: 'RESET' }; // 状态转移表:当前状态 + 事件 → 下一状态 + 副作用 const transitions = { [STATE.IDLE]: { [EVENT.USER_MSG]: STATE.WAITING }, [STATE.WAITING]: { [EVENT.BOT_REPLY]: STATE.CHATTING, [EVENT.TIMEOUT]: STATE.ERROR }, [STATE.CHATTING]: { [EVENT.USER_MSG]: STATE.WAITING, [EVENT.RESET]: STATE.IDLE }, [STATE.ERROR]: { [EVENT.RESET]: STATE.IDLE } }; export function nextState(current, event) { return transitions[current]?.[event] || current; }好处:新增状态只要加一行配置,不用改大堆业务代码。
3.2 对话上下文持久化
用 Redis Hash 存userId → context,设置 15 min 过期,既省内存又防泄漏:
// repo.js import { createClient } from 'redis'; const redis = createClient({ url: 'redis://127.0.0.1:6379' }); await redis.connect(); export async function loadCtx(userId) { const raw = await redis.hGet('chat:ctx', userId); return raw ? JSON.parse(raw) : { state: STATE.IDLE, history: [] }; } export async function saveCtx(userId, ctx) { await redis.hSet('chat:ctx', userId, JSON.stringify(ctx)); await redis.expire(`chat:ctx:${userId}`, 900); }3.3 消息路由与重试
把“收-发-确认”包成一条 Promise 链,任何环节抛错都自动重试 2 次,超 3 s 就进人工队列:
// router.js import { nextState, EVENT, STATE } from './fsm.js'; import { saveCtx, loadCtx } from './repo.js'; const RETRY = 2, TIMEOUT = 3000; async function botAnswer(text) { // 这里调火山豆包 LLM,返回 Promise<string> const res = await fetch('https://bot-api.example.com/ask', { method: 'POST', body: JSON.stringify({ q: text }), headers: { 'Content-Type': 'application/json' } }); if (!res.ok) throw new Error('bot error'); return res.json().then(j => j.answer); } export async function handleMessage(userId, text, ws) { let ctx = await loadCtx(userId); const newState = nextState(ctx.state, EVENT.USER_MSG); if (newState === ctx.state) return; // 非法事件,丢弃 ctx.state = newState; ctx.history.push({ role: 'user', text }); for (let i = 0; i <= RETRY; i++) { try { const answer = await Promise.race([ botAnswer(text), new Promise((_, rej) => setTimeout(() => rej('timeout'), TIMEOUT)) // 注意:生产环境用 AbortSignal 更优雅 ]); ctx.history.push({ role: 'bot', text: answer }); ctx.state = nextState(ctx.state, EVENT.BOT_REPLY); ws.send(JSON.stringify({ type: 'reply', payload: answer })); break; } catch (e) { if (i === RETRY) { ctx.state = nextState(ctx.state, EVENT.TIMEOUT); ws.send(JSON.stringify({ type: 'error', payload: '客服忙,请稍后再试' })); } } } await saveCtx(userId, ctx); }关键点:
- 所有异步调用都包
Promise.race,防止 BOT hang 住整个链路; - 失败重试只针对网络/超时,业务层异常直接进 ERROR 状态,避免无限死循环。
4. 性能优化:让单机说话算数
4.1 WebSocket 连接池参数
ws 库默认无限制,生产一定要加阀值:
import { WebSocketServer } from 'ws'; const wss = new WebSocketServer({ port: 8080, maxPayload: 1 * 1024 * 1024, // 1 MB 防炸弹 perMessageDeflate: false // 关闭压缩,省 CPU });同时用ulimit -n 100000把文件句柄拉满,配合pm2多进程,单机轻松 5 w 连接。
4.2 负载测试方案
工具链:k6 + WebSocket 插件,脚本核心段:
import ws from 'k6/ws'; export default function () { const url = 'ws://localhost:8080'; const res = ws.connect(url, {}, function (socket) { socket.on('open', () => socket.send(JSON.stringify({ type: 'hello' }))); socket.on('message', (data) { /* 统计延迟 */ }); socket.setTimeout(() => socket.close(), 30000); }); check(res, { 'status is 101': (r) => r && r.status === 101 }); }结果(8 核 16 G):
- 3 w 并发连接,CPU 58 %,内存 1.2 G;
- 99 % 消息端到端 < 120 ms;
- 断线重连成功率 99.95 %。
5. 避坑指南:状态丢失与幂等
5.1 状态丢失 3 大元凶
- Redis 过期时间 < 心跳间隔 → 用户一走神就被踢;
- 负载均衡没开 sticky session → 请求跳到新实例,内存状态对不上;
- 服务器重启不落地 → 进程一挂全部清零。
解法:Redis + 磁盘双写,重启时预热热加载。
5.2 多轮对话幂等
用户狂点发送可能产生重复 msgId。为每条消息生成 ULID,BOT 侧用seenULIDsSet 去重:
if (seenULIDs.has(msgId)) return; seenULIDs.add(msgId);Set 只保留最近 1000 条,防内存爆炸。
6. 安全考量:输入与频率
6.1 输入过滤
用xss+he双库,先过正则黑名单(<script|onerror|javascript:),再实体转义:
import xss from 'xss'; const filter = new xss.FilterXSS({ stripIgnoreTag: true }); const safe = filter.process(dirty);6.2 频率限制
基于 Redis-Cell 模块,一句命令解决:
CL.THROTTLE user_${userId} 15 30 60 1含义:同一用户 60 s 窗口最多 30 次,突发允许 15 次,超了直接返回 429,前端自动弹“说话太快”提示。
7. 开放式思考:AB 测试的对话策略引擎
现在机器人只有一套“大脑”。如果产品想试“活泼 vs 专业”两种人设,怎么做?
- 把策略抽象成可插拔函数,用户进线时按 UUID 分桶;
- 策略 ID 随消息埋点,后端实时落日志;
- 用 Flink 算转化率、满意度,第二天看板自动给出优胜版本;
- 热更新:配置中心推送到 Node 进程,FSM 在 RESET 事件里重新加载策略,无需重启。
这样,产品、运营都能自助实验,程序员安心睡觉。
8. 写在最后:动手才是硬道理
看完如果手痒,别急着从 0 搭底座,可以直接去从0打造个人豆包实时通话AI动手实验,把上面这套 FSM + WebSocket 思路套进去,十分钟就能跑通一个“能听会说”的网页版语音机器人。我亲测一路绿灯,连 Redis 都是脚本自动起的,小白也能顺利玩下来。等你把 ASR、LLM、TTS 串成一条线,再回来优化策略引擎,相信会有更多灵感。评论区等你交作业!