背景痛点:传统客服的“三座大山”
过去两年,我帮三家不同规模的公司改造客服系统,踩的坑出奇一致:
- 对话上下文丢失——用户刚说完订单号,刷新页面后机器人“失忆”,又得重来。
- 多平台对接复杂——微信、网页、App、邮件各一条线,客服后台像“联合国开会”,同一用户被拆成四条记录。
- 扩展性差——SaaS 方案加一条“意图识别”规则要排期两周,流量一高就强制升级套餐,成本直线上升。
于是团队决定自建,目标只有一句话:“像搭积木一样拼出可扩展的智能客服,且代码自己说了算。”
技术选型:为什么选了 Chatwoot
Chatwoot 在 GitHub 上标星 18k,核心就两点打动我:
- 100% Ruby on Rails,二次开发效率≅写脚本
- 插件化 Webhook,AI 模型想换就换,不用等厂商排期
对比表一眼看懂:
| 维度 | Chatwoot 开源 | 主流 SaaS |
|---|---|---|
| 定制深度 | 代码级 | 规则级 |
| 数据归属 | 自己 DB | 对方云 |
| 并发上限 | 靠你架构 | 套餐硬顶 |
| 费用 | 服务器成本 | 坐席×功能×月 |
一句话总结:“SaaS 适合快速试错,Chatwoot 适合长期沉淀。”
核心实现:让 AI 模型说“人话”
1. Webhook ↔ AI 服务集成
Chatwoot 原生支持account.webhooks,只要在后台填一个 URL,每条消息都会 POST 过来。我们搭了一座“AI 桥”:
用户消息 → Chatwoot → Webhook → AI 服务 → 回复 → Chatwoot APIAI 服务用 FastAPI 包了一层,模型是自家 fine-tune 的 BERT+CRF,意图识别准确率 92%,槽位抽取 F1 0.87,够用。
2. Rails 端消息处理代码
在app/services/ai_reply_service.rb里塞一个状态机,保证多轮对话不翻车:
class AiReplyService include AASM aasm column: 'status' do state :welcome, initial: true state :await_phone state :await_code state :resolved event :ask_phone do transitions from: :welcome, to: :await_phone end event :verify_code do transitions from: :await_phone, to: :await_code end event :finish do transitions from: [:await_phone, :await_code], to: :resolved end end def initialize(conversation) @conv = conversation reload_status! end def handle(message) case status when 'welcome' quick_reply '请输入手机号' ask_phone! when 'await_phone' if valid_phone?(message) Redis.current.hset("conv:#{@conv.id}", :phone, message) quick_reply '已发送验证码' verify_code! else quick_reply '手机号格式不对' end when 'await_code' if message.strip == Redis.current.hget("conv:#{@conv.id}", :code) quick_reply '验证通过,正在为您转人工' finish! else quick_reply '验证码错误' end end end private def quick_reply(text) @conv.messages.create!(content: text, message_type: :outgoing, account_id: @conv.account_id) end end要点:
- AASM 把状态落地到 PG,重启也不丢
- Redis 只存临时字段,减少 DB 压力
3. 对话路由 & 负载均衡
客服机器人再聪明也有边界,我们设了“置信度 < 0.7 直接转人工”。路由伪代码:
function route(conversation, ai_score): if ai_score > 0.7: return :bot if online_agents() == 0: return :leave_msg return :least_loaded_agent()负载层用 HAProxy+Sticky Cookie,保证同一用户落到同一 Agent,避免“多头回答”。
生产考量:高并发下的“三板斧”
1. Redis 集群的正确姿势
- 对话热数据 TTL 600 s,冷数据异步落地 PG
- 用 Hash Tag
{conversation:id}保证分片后仍落在同一槽,避免 multi-key 事务 - 开启
maxmemory-policy allkeys-lru,内存满时自动踢旧会话,防止 OOM
2. 消息队列幂等设计
Kafka 场景下,Producer 给每条事件带conversation_id+message_id组合键,Consumer 用 Redis SETNX 做去重:
if redis.setnx(f"dup:{cid}:{mid}", 1): redis.expire(f"dup:{cid}:{mid}", 3600) process(message)重复消息 99% 被挡在门外。
3. 压测脚本(Locust)
from locust import HttpUser, task, between class ChatUser(HttpUser): wait_time = between(1, 3) def on_start(self): self.cid = self.client.post("/api/v1/widget/conversations", json="{}").json()["id"] @task def send_msg(self): self.client.post(f"/api/v1/widget/conversations/{self.cid}/messages", json={"content": "优惠还有吗?", "type": "incoming"})单机 4 核可模拟 3k 并发,CPU 70%、P99 延迟 380 ms,作为基线够直观。
避坑指南:三次“血案”复盘
Webhook 超时导致会话中断
现象:AI 服务 5 s 没返回,Chatwoot 自动重试 3 次,结果用户收到 3 条“你好”。
解决:- AI 服务内部改异步,先回 204,结果通过“发送 API”补推
- Nginx 调整
proxy_read_timeout 2s,快速失败,避免堆积
Redis 挂掉后所有状态归零
现象:节点宕机,热数据蒸发,用户被迫重新输入手机号。
解决:- 热数据双写 Redis+PG,后台定时 sync
- 引入 Redis Sentinel,故障 30 s 内完成主从切换
消息队列堆积引发雪崩
现象:大促凌晨 Kafka lag 飙到 100 万,Consumer 被 OOM kill,重启后继续挂。
解决:- Consumer 改批量拉取为 200 条/次,降低内存
- 增加分区 + 动态扩容 Consumer Group,10 分钟内 lag 归零
上线效果 & 下一步
上线三个月,核心数据:
- 机器人解决率 68% → 81%
- 平均响应 1.2 s → 0.6 s
- 服务器成本仅为同流量 SaaS 的 35%
下一步打算把语音热线也接进来,用同样的 Webhook 机制把 ASR+TTS 串起来,让“智能客服”真正覆盖全渠道。
写在最后
Chatwoot 不是银弹,但把代码权交回给你,让“需求-上线”不再排期两周。只要踩对 Redis、MQ、Webhook 几个关键点,自研智能客服并没有想象那么“重”。希望这篇笔记能帮你少踩几个坑,早点下班。