背景痛点:传统客服的“三宗罪”
先放一张图,看看传统客服系统每天都在经历什么:
意图识别准确率感人
关键词+正则的“老派”NLU(Natural Language Understanding)在口语化表达面前瞬间破防。用户一句“我昨天买的那玩意儿怎么还没发货”,系统只能抓到“发货”两个字,结果把退货、换货、催单全混在一起,准确率不到 60%。上下文保持≈金鱼记忆
多轮对话靠 session 里硬编码几个槽位(slot),一旦用户跳句“那如果改地址呢?”——上下文直接断片,机器人礼貌地从头再问一遍“请问您想咨询哪笔订单?”体验瞬间归零。并发一上来就“躺平”
同步调用+无状态设计,高峰期 QPS 从 200 涨到 800 就 CPU 打满,RT(Response Time)从 500 ms 飙到 3 s,客服群里开始刷屏“机器人又卡死了”。
架构设计:纯 LLM 一条腿走路 vs. 混合架构“双引擎”
| 维度 | 纯 LLM 方案 | LLM+规则引擎混合架构 |
|---|---|---|
| 意图识别 | E2E 生成式,泛化强,但幻觉难控 | 小模型 NLU 预筛+LLM 兜底,可控可回退 |
| 多轮管理 | 靠 Prompt 里的“历史对话”硬塞,Token 爆炸 | DST(Dialog State Tracking)模块独立维护状态,轻量 |
| 响应延迟 | 每次都要走 7B/13B 大模型,RT 1.2 s 起步 | 80% 常规走规则/小模型,RT < 200 ms |
| 知识更新 | 重新训练 or 外挂向量库,实时性差 | 知识图谱+向量双路召回,分钟级更新 |
| 可解释性 | 黑盒,客服后台只能“猜” | 每步输出 state+规则轨迹,可回溯 |
系统组件一览:
- NLU Service:BERT 轻量分类+正则词典,输出 intent & entities
- DM(Dialog Manager):DST 维护槽位、对话策略(Policy)决定下一步动作
- KG(Knowledge Graph):订单、商品、售后规则图谱,毫秒级查询
- LLM Engine):7B 量化模型,负责“长尾”开放问答与话术润色
- Response Tuner:安全过滤、话术拼装、敏感词拦截
核心实现:用 Python 手撕一个 DST 模块
以下代码基于 Rasa 3.x,但剥离了框架依赖,方便你移植到自己的服务里。重点看 state 怎么“活”在多轮里,以及上下文如何“抽骨拔筋”。
# dst_core.py from typing import Dict, List, Optional, Text import time class DialogState: """ 轻量级 DST,O(1) 查询更新,空间复杂度 O(n) 与槽位数量线性相关 """ def __init__(self, max_turn: int = 10): self.max_turn = max_turn self._turn_id = 0 self._slots: Dict[Text, Text] = {} # 当前槽位 self._history: List[Dict] = [] # 状态历史,用于回溯 # 更新时间复杂度 O(1) def update(self, intent: Text, entities: Dict[Text, Text], user_text: Text): """每轮调用一次,增量更新 state""" self._turn_id += 1 # 1. 去重合并实体,K 为槽位名 for k, v in entities.items(): self._slots[k] = v # 2. 记录快照,方便调试与回滚 snapshot = { "turn": self._turn_id, "intent": intent, "slots": self._slots.copy(), "user": user_text, "ts": int(time.time() * 1000), } self._history.append(snapshot) # 3. 防止内存泄漏,超长对话自动裁剪 if len(self._history) > self.max_turn: self._history.pop(0) # 查询时间复杂度 O(1) def get_slot(self, key: Text) -> Optional[Text]: return self._slots.get(key) def is_complete(self, required: List[Text]) -> bool: """检查必填槽位是否齐全""" return all(self.get_slot(k) for k in required) def to_policy_input(self) -> Dict: """给 Policy 模块做特征输入""" return {"intent": self._history[-1]["intent"], "slots": self._slots.copy()}使用示例(单轮更新):
state = DialogState() # 用户说:我要退掉昨天买的 iPhone state.update( intent="apply_return", entities={"product": "iPhone", "order_date": "昨天"}, user_text="我要退掉昨天买的 iPhone" ) print(state.get_slot("product")) # iPhone print(state.is_complete(["product", "order_id"])) # False,缺 order_idPolicy 层再基于to_policy_input()决定下一步是“追问 order_id”还是“直接走退货 API”。
这样就把 LLM 从“状态维护”的泥潭里解放出来,只让它干最擅长的“生成友好回复”。
性能优化:把 QPS 从 300 提到 1500 的三板斧
批处理请求
把 50 ms 窗口内的多条 query 拼成一次 tensor batch,GPU 利用率从 35% → 78%,线上 QPS +120%。模型量化(INT8)
7B 模型用 GPTQ 压缩后显存 4.8 GB → 2.1 GB,首 token 延迟 680 ms → 390 ms,精度下降 < 1.5%(采样 1 w 条线上日志人工评估)。三级缓存
- L1 本地 LRU(1 k 条,< 0.1 ms命中)
- L2 Redis 向量缓存(10 w 条,< 1 ms)
- L3 LLM 生成结果缓存(TTL=300 s,命中率 42%)
整体缓存命中率 63%,后端 LLM 调用量直接腰斩。
压测对比(4C8G,单卡 A10):
| 场景 | 平均 RT | P95 RT | QPS | CPU | GPU |
|---|---|---|---|---|---|
| 优化前 | 880 ms | 1.6 s | 300 | 90 % | 35 % |
| 优化后 | 320 ms | 580 ms | 1500 | 65 % | 72 % |
避坑指南:生产环境三连击
长对话内存泄漏
现象:运行 3 h 后 Pod OOMKilled。
根因:DST 历史快照无限追加,未设置 max_turn。
解法:见上方代码max_turn裁剪;上线前用 memory_profiler 跑 1 k 轮压测,确保 RSS 不涨。多轮意图漂移
现象:用户先问“怎么退货”,中途插一句“运费谁出”,机器人直接切到“运费险”流程,再也回不到退货。
根因:Policy 仅看当前 intent,未考虑“主流程”锁定。
解法:给对话加“主意图栈”,栈空才能切换;栈非空时,新意图>0.85 置信度才允许抢占,否则先缓存回复,主流程结束后再弹出。LLM 服务雪崩
现象:LLM 集群 502,整条链路 5 s 超时,用户看到“机器人已读不回”。
根因:没做降级,所有流量直捣黄龙。
解法:见文末『扩展思考』。
代码规范与复杂度小结
- 全项目统一 Black 格式化,行宽 88,强制 PEP8
- 函数圈复杂度 < 10,DST 更新函数 7,Policy 决策函数 9
- 关键路径时间复杂度:
- DST update:O8dO(1)
- Policy predict:O(k) k=意图数≤200
- 图谱查询:加索引后 O(logn) n≈500 w 边
Code Review 重点看“可变默认值”与“异步阻塞”两条红线,基本能杜绝 90% 的线上事故。
扩展思考:如果 LLM 完全不可用,系统该怎么活?
降级开关
在网关层做 health check,5 s 内失败率 > 50% 即触发降级,DM 自动切到“纯规则模式”,所有开放问答统一回复“人工客服忙,请留下联系方式,稍后回电”。本地小模型热备
提前准备一个 3B 蒸馏模型放在 sidecar,降级后 Pod 内本地推理,RT 增加 < 150 ms,至少能回答 FAQ 80% 问题。预热缓存
降级瞬间把最近 24 h 的高频问题 Top 1 k 提前渲染好,静态缓存直接返回,保证“机器人还在”的体感。
一句话:把 LLM 当“涡轮增压”,而不是“唯一发动机”,系统才有底气 7×24 活下去。