智能客服扣子工作流入门指南:从零搭建高可用对话系统
1. 背景痛点:if-else 地狱长样
第一次做智能客服,我深有体会:用户一句“我要改地址”,代码里就要写:
if (intent === 'modify_address' && step === 1) { ... } else if (intent === 'modify_address' && step === 2) { ... }需求一多,文件膨胀到几千行,新人改一行,老用户全翻车。
更惨的是上线后想加“是否开发票”分支,得把原有逻辑翻个底朝天,测试同学连夜加班。
痛点总结:
- 状态散落在代码,看不懂全貌
- 无法热更新,发版=赌博
- 无法复用,换个业务再写一遍
2. 技术对比:规则引擎、状态机、工作流谁更适合聊天
| 维度 | 规则引擎(Drools) | 状态机(Spring StateMachine) | 扣子工作流 |
|---|---|---|---|
| 意图识别 | 靠事实匹配,规则一多性能O(n) | 单状态跳转,需外部NLU | 节点可内嵌NLU服务,O(1)直接定位 |
| 上下文保持 | 需手动插槽(Slot Filling) | 状态快照小,内存友好 | 节点可挂载上下文schema,自动diff |
| 可视化 | 有,但偏业务规则 | 一般 | 拖拽即状态图,产品也能改 |
| 分布式 | 无原生 | 靠外部存储 | 原生支持Redis回溯、锁 |
| 学习成本 | 中 | 高 | 低,DSL像画流程图 |
结论:
“聊天”这种随时插话、可回退、多分支的场景,工作流最顺手。
3. 核心实现:用扣子工作流画一张“对话状态图”
3.1 先画流程图
节点说明:
- 开始 → NLU识别 → 订机票/改签/退票 三个并行分支
- 每个分支再细化为“收集参数→确认→调用API→结果”
- 任意节点可超时回到“开始”,保证用户永远有路可退
3.2 把图翻译成 Workflow DSL(Node.js 版)
以下代码可直接跑在@kouz/workflow运行时(ES2020):
// ticketBooking.js import { Workflow, Nodes } from '@kouz/workflow'; import { callNlu, callOrderApi } from './services.js'; export const ticketWf = new Workflow({ id: 'ticket_booking', timeout: 300_000, // 5 min 全局超时 retry: { limit: 2, delay: 1_000 }, persistence: { adapter: 'redis', keyPrefix: 'wf' } }); /* 0. 入口节点 */ ticketWf.addNode(new Nodes.Start('start')); /* 1. NLU 识别意图 */ ticketWf.addNode(new Nodes.Script('nlu', { async run({ context }) { const { uid, text } = context.message; const nlu = await callNlu(text); // 耗时 ~150ms context.intent = nlu.intent; context.slots = nlu.slots; // Slot Filling 结果 return nlu.intent; // 出口分支名 } })); /* 2. 参数收集(多轮) */ ticketWf.addNode(new Nodes.Collect('collect_info', { schema: { from: 'city', to: 'city', date: 'date' }, missingHandler({ missing, context }) { context.reply = `请提供${missing.join('、')}`; } })); /* 3. 确认节点 */ ticketWf.addNode(new Nodes.Script('confirm', { async run({ context }) { const { from, to, date } = context.slots; context.reply = `您要订 ${from}→${to} ${date} 的票,确认吗?`; } })); /* 4. 调用后端 */ ticketWf.addNode(new Nodes.Http('call_api', { url: 'http://order-svc/api/book', method: 'POST', body({ context }) { return context.slots; } })); /* 5. 异常捕获 */ ticketWf.addNode(new Nodes.ErrorBoundary('error', { fallbackTo: 'start' })); /* 边 = 状态转移 */ ticketWf.connect('start -> nlu'); ticketWf.connect('nlu:book -> collect_info'); ticketWf.connect('collect_info -> confirm'); ticketWf.connect('confirm:yes -> call_api'); ticketWf.connect('call_api -> end'); ticketWf.connect('nlu:change -> …'); // 省略改签分支关键注释:
timeout与retry在根上声明,子节点自动继承persistence把运行快照刷到 Redis,挂掉重启可续跑Collect节点内部会循环读槽位,直到 schema 全部填满 → 省掉手写 while
时间复杂度:
NLU 节点一次外部调用 O(1),Collect 节点最坏循环次数=缺失槽位数 k,因此单轮复杂度 O(k),常数级。
4. 生产考量:让系统 7×24 不熄火
4.1 对话中断的幂等性
用户问到一半关掉微信,30 分钟后重新进来,必须续上。
做法:
- 用
uid+scene做业务幂等键 - 工作流实例 ID 即键值,Redis 快照 5 min 过期
- 重启时先查实例,存在则
workflow.recover(snapshot),再走下一节点
4.2 分布式会话锁
高并发下,同 uid 两条消息同时进集群,会启动两个实例,造成重复出票。
基于 Redis 的 redlock 实现:
import Redlock from 'redlock'; const redlock = new Redlock([redis]); async function handleMessage(msg) { const lock = await redlock.lock(`locks:${msg.uid}`, 1000); try { const inst = await ticketWf.loadOrCreate(msg.uid); await inst.feed(msg); } finally { await lock.unlock(); } }锁定粒度 1 s,足够单节点跑完一次状态转移;失败端重试采用指数退避,避免惊群。
5. 避坑指南:前辈踩出来的坑,我帮你填
不要把“成人/儿童票规则”写进 DSL
工作流只负责“状态”,业务规则下沉到 RuleService,节点通过 Expression 调用,才能做到不改图只改表。异步事件(支付成功回调)可能晚于下一轮消息
采用事件溯源思路:回调只写 EventStore,工作流定时轮询,发现新事件再向下游转移,避免并发竞争。超时与重试别叠加
节点级重试次数 + 全局重试次数 默认相乘,线上曾出现 3×3=9 次风暴。统一在根配置retry,子节点关闭。日志要带上
workflowId+nodeId
排查时直接 grep,就能在 ELK 里还原用户轨迹,比传统“看 INFO 堆栈”快 5 倍。
6. 互动环节:画一张“多轮机票预订”流程图
挑战任务
假设用户可以说:
“我要订下周二去上海的机票,经济舱,往返,带小孩。”
请用扣子工作流设计器完成:
- 支持“往返”与“单程”双模式
- 小孩=婴儿/儿童两种身份,需补充证件有效期
- 若用户 30 s 未回复,机器人主动追问,且只能追问 2 次
- 最终确认前允许用户说“返回上一步”
要求:
把导出的 DSL json 贴在评论区,我会挑 3 份最简洁的做 Code Review 并送《Conversation Design 中文版》。
7. 小结
从 if-else 到扣子工作流,我最大的感受是:把“对话”当成一张图,而不是一堆条件。
图能一眼看完,就能和产品、测试一起评审;图能热更新,就不用半夜发版。
再配上 Redis 快照与分布式锁,新手也能搭出可横向扩展、可灰度、可回滚的高可用客服系统。
如果你正准备从零开始,不妨 pull 下@kouz/workflow的 starter 项目,把本文的 ticketWf 跑通,再替换自己的 NLU 服务,半天就能搭出第一个 MVP。
剩下的 90% 坑,上面已经帮你踩平。祝开发顺利,少熬夜。