Chatbot Workflow 从零搭建指南:核心架构与避坑实践
“让机器人像人一样聊天”听起来浪漫,真正动手写代码时却常被一堆“小毛病”绊住:用户话说一半刷新页面,上下文没了;第三方接口突然 429,流程直接卡死;并发一上来 Redis 先报警。本文记录我用 Node.js + TypeScript 踩坑后沉淀的一套最小可用、可扩展的 chatbot workflow 骨架,侧重“先跑起来,再扛住流量”。如果你刚准备把对话从“if/else 堆”升级成“可维护的系统”,希望下面的思路能帮你少掉几根头发。
。
背景痛点:为什么简单的问答也会翻车
- 状态失忆:HTTP 无状态,刷新页面、切换渠道、服务器重启,对话进度瞬间清零。
- 上下文膨胀:多轮对话把历史消息全塞进内存,GC 压力飙升,水平扩容时实例间无法共享。
- 异步阻塞:调用天气/订单/支付 API 如果同步等待,单线程事件循环被占满,吞吐率掉到两位数。
- 错误雪崩:下游限流返回 503,上游不断重试,结果整个集群互相拖死。
技术选型:FSM 还是行为树?
行为树在 NPC AI 里很香,节点可复用、优先级清晰,但 chatbot 的对话路径多数是可枚举的“菜单式”分支,树层级深、调试时肉眼找节点非常痛苦。有限状态机(FSM)则只有“当前状态 + 事件 → 下一状态”两条轴,画成图就是一张扁平有向图,新人一眼看懂。
选型结论:对话流程短、分支有限、需要快速迭代 → FSM;复杂推理、动态子目标多 → 再上行为树。下文全部基于 FSM 展开。核心实现
3.1 整体分层- 会话层:负责生成唯一对话 ID、校验 JWT、限流。
- 状态层:DAL 抽象,把“当前状态、变量、过期时间”持久化到 Redis。
- 流程层:FSM 定义状态与事件,纯函数,不碰 IO。
- 动作层:异步调用第三方,middleware 做重试、熔断、超时。
3.2 Redis 状态存储(带 TTL 自动清档)
// types.ts export interface ChatContext { userId: string; state: string; // FSM 当前状态 payload: Record<string, any>; // 任意变量 updatedAt: number; } // dal.ts import Redis from 'ioredis'; const redis = new Redis({ port: 6379, host: '127.0.0.1', family: 4, db: 0, // 生产环境记得配连接池 maxRetriesPerRequest: 3, lazyConnect: true, }); export class ContextDAL { // 会话级 TTL:30 分钟无交互自动过期 private static readonly TTL = 30 * 60; static async get(convId: string): Promise<ChatContext | null> { const raw = await redis.get(`conv:${convId}`); return raw ? JSON.parse(raw) : null; } static async set(convId: string, ctx: ChatContext): Promise<void> { // 幂等写入,覆盖式更新 await redis.set( `conv:${convId}`, JSON.stringify(ctx), 'EX', this.TTL ); } static async del(convId: string): Promise<void> { await redis.del(`conv:${convId}`); } }要点:
- 键前缀
conv:方便日后按业务分库。 - TTL 既当垃圾回收,也强制“对话超时”——用户走掉半小时后自动清数据,符合 GDPR 最小存储。
3.3 FSM 定义(纯函数,易单测)
// fsm.ts export type Event = 'TEXT' | 'YES' | 'NO' | 'API_ERROR' | 'TIMEOUT'; export const transitions: Record<string, Partial<Record<Event, string>>> = { IDLE: { TEXT: 'ASK_LOCATION' }, ASK_LOCATION:{ YES: 'QUERY_API', NO: 'IDLE' }, QUERY_API: { API_ERROR: 'ASK_RETRY', TIMEOUT: 'ASK_RETRY' }, ASK_RETRY: { YES: 'QUERY_API', NO: 'GOODBYE' }, GOODBYE: {}, // 终止态 }; export function nextState(current: string, event: Event): string { return transitions[current]?.[event] || current; // 未定义事件保持原状态 }3.4 异步动作 middleware(重试 + 超时)
// api.middleware.ts import axios, { AxiosError } from 'axios'; interface Config { maxRetry: number; retryDelay: number; // ms timeout: number; // ms } export function createApiCaller(cfg: Config) { return async (url: string, payload: any) => { let last = 0; while (true) { try { const res = await axios.post(url, payload, { timeout: cfg.timeout }); return res.data; // 成功直接返回
_dirty } catch (e) { const err = e as AxiosError; // 可重错误才重试,429/500/502/503/504 const canRetry = [429, 500, 502, 503, 504].includes(err.response?.status || 0); if (canRetry && ++current <= cfg.maxRetry) { await new Promise(r => setTimeout(r, cfg.retryDelay)); continue; } throw err; // 不能重试或次数耗尽,抛出去让 FSM 捕获 } } }; }
用法: ```typescript const callWeather = createApiCaller({ maxRetry: 3, retryDelay: 800, timeout: 4000 }); const data = await callWeather('https://api.xxx/weather', { city: 'BeiJing' });生产考量
4.1 压测数据与连接池
本地 8 核机器,1000 并发长连接,QPS≈4k 时,Redis 连接数峰值 64 即够。ioredis 默认开启连接池,推荐设置:maxRetriesPerRequest = 3enableReadyCheck = false(减少 CLUSTER slots 刷新频率)keepAlive = 30000
同时把 Node 的 UV 线程池UV_THREADPOOL_SIZE=128,避免 DNS 解析/ TLS 握手阻塞。
4.2 安全性
- 对话 ID 使用
crypto.randomUUID()(UUID v4),长度 36 位,可挡遍历。 - 所有外部输入先过
validator.js,再做 SQL/Redis 查询,防止注入。 - 对返回内容做 DOMPurify 清洗,避免 XSS 到 H5 页面。
避坑指南
坑 1:第三方 API 限流没做退避,导致 429 越打越猛
解:middleware 里对 429 读取Retry-After头,动态延长等待;同时用令牌桶算法在本地先限流,背压控制。坑 2:对话永不超时,Redis 内存爆炸
解:TTL 必须设,且每次用户发言都EXPIRE刷新;提供“/clear”指令让用户手动清数据。坑 3:状态机里把 API 返回整个塞进 payload,体积失控
解:只存下游业务字段,例如天气只保留{"temp":26,"desc":"晴"},完整原始响应可放对象存储并留索引 ID。
还没完——开放讨论
当用户同时在微信小程序、网页、App 里跟同一个机器人聊天,怎样保证跨渠道的 workflow 状态实时同步? 是把 Redis 换成中央 Pub/Sub,还是各端长连到网关做事件广播?欢迎留言聊聊你的方案。
把上面的骨架跑通后,我原本只想“先让 bot 能回话”,结果一发不可收拾,干脆把整套流程做成了实验。若你也想亲手试一把,从麦克风采集、到豆包大模型实时对话、再到语音播放一条链路的完整体验,可以看看这个动手营:从0打造个人豆包实时通话AI。实验把 ASR→LLM→TTS 的坑都提前填好,本地代码一键跑通,小白也能顺顺利利听到自己专属的“豆包”开口说话。祝你编码愉快,少踩坑,多聊天!