背景痛点:知乎咨询场景的三座大山
知乎的问答氛围决定了客服系统必须同时接住“三高”:
- 高突发:热榜问题一出,同款疑问瞬间涌入,峰值可达平日 8~10 倍。
- 高长尾:站内 70% 提问只出现一次,规则引擎根本写不全。
- 高情绪:答主被举报、盐选会员退款等场景,用户容忍度极低,响应慢 1 秒投诉量就翻倍。
传统 if-else 机器人在这三座大山面前几乎失灵:维护 3000+ 条正则,周迭代一次,人力成本线性上涨;并发一上来,CPU 抢占式匹配直接把 RT 拉到 2 s 开外。
我们去年 Q2 试水机器学习方案,用 BERT+Faiss 做语义召回,上线 4 周就把平均响应从 1800 ms 压到 600 ms,人力工单下降 42%。下面把踩过的坑和调优数据一次性摊开。
方便阅读,先放一张总览图:
技术方案:三层架构与核心代码
1. 架构分层
- Query 理解层:基于 Sentence-BERT(Reimers & Gurevych, 2019)微调,输出 768 维句向量。
- 索引层:Faiss HNSW 索引,分层导航 + PQ 量化,单节点 400 万条 FAQ 内存 < 4 GB。
- 业务逻辑层:asyncio 流水线,把“相似度计算 → 答案渲染 → 敏感词过滤”拆成 3 个异步 Stage,通过 asyncio.Queue 解耦,任一环节阻塞不影响整体吞吐。
2. 异步任务分发(Python 3.10)
# tasks.py import asyncio from concurrent.futures import ThreadPoolExecutor import torch from faiss_index import index, tokenizer, model sem = asyncio.Semaphore(200) # 保护 GPU 内存 executor = ThreadPoolExecutor(max_workers=8) async def embed(text: str) -> torch.Tensor: """把文本异步转成向量,释放 GIL 让 CPU 与 GPU 并行""" loop = asyncio.get_event_loop() return await loop.run_in_executor( executor, lambda: model(**tokenizer(text, return_tensors="pt")).pooler_output ) async def search(text: str, top_k: int = 5): async with sem: vec = await embed(text) D, I = index.search(vec.detach().cpu().numpy(), top_k) return D.tolist(), I.tolist() async def handle_request(q: asyncio.Queue, resp_q: asyncio.Queue): while True: user_id, query = await q.get() scores, idxs = await search(query) await resp_q.put((user_id, scores, idxs))3. 语义相似度计算(PyTorch)
# similarity.py import torch.nn.functional as F def cosine_similarity(a: torch.Tensor, b: torch.Tensor, eps=1e-8): """GPU 上一次性算 batch 余弦,避免逐条 Python 循环""" a_norm = F.normalize(a, p=2, dim=1) b_norm = F.normalize(b, p=2, dim=1) return torch.mm(a_norm, b_norm.T).clamp(min=eps)性能优化:QPS、召回与 GPU 内存
1. 索引算法对比
在 400 万 FAQ、单卡 A10 环境压测 5 分钟,数据如下:
| 算法 | QPS | 召回@10 | 内存占用 |
|---|---|---|---|
| Flat IP | 1200 | 0.98 | 12 GB |
| HNSW 32 | 5800 | 0.96 | 4.1 GB |
| IVQ 8-bit | 3200 | 0.93 | 1.9 GB |
最终线上选 HNSW32:牺牲 2% 召回换 4.8 倍 QPS,性价比最高。
2. 线程池与 GPU 显存
经验值:
- 每 1 GB 显存 ≈ 并发 40 条 768 维向量请求;
- ThreadPoolExecutor max_workers 取
num_cpu_cores * 1.5,过高会触发 CUDA context 切换抖动; - 在
torch.cuda.empty_cache()前加gc.collect(),可把峰值显存再降 8%。
避坑指南:生产环境 3 个暗坑
1. 对话状态幂等
用户狂点“重新回答”会重复回调,我们在 Redis 里用SETNX user:{uid}:lock 60s做幂等,并在响应体带回request_id,前端重复点击直接返回同一答案,避免后台重复计算。
2. 敏感词 Hook
把公司合规组给的 1.2 万敏感词编译成 Aho-Corasick 自动机,挂在答案渲染 Stage 的末尾,单条 200 字文本匹配 < 0.3 ms,对整体 RT 无感。
3. 模型冷启动
BERT 首次推理 CUDA kernel 编译要 3~4 s,我们在容器启动脚本里加一条“warmup”:
dummy = tokenizer("hello", return_tensors="pt") _ = model(**dummy)把冷启动时间摊到发布窗口,用户无感知。
延伸思考:持续学习的双刃剑
线上每天新增 5 千条用户采纳“此答案无用”的负反馈,天然是优质训练样本。想做在线增量训练,却发现两大挑战:
- 灾难性遗忘:微调 2 epoch 后,旧知识召回率掉 9%。
- 数据偏斜:负样本远多于正样本,模型趋向“保守”,把通用问题也判为“无解”。
目前折中方案是“小步快跑”:
- 每晚离线合并正负样本,控制新旧比例 1:1;
- 用 EWC(Elastic Weight Consolidation)约束重要参数漂移;
- 灰度 5% 流量 AB 测试,召回率下降 >1% 自动回滚。
效果还在观察,欢迎有经验的同学一起交流。
写在最后
把 BERT+Faiss 搬进客服,不是简单“换个模型”那么浪漫。线程池、显存、幂等、合规,每一步都是工程活。
现在系统稳定跑在 40 台 4 卡 A10 上,日均 1200 万次调用,平均 RT 600 ms,比初代规则引擎快 3 倍,节省 14 名运营同学。
下一版想把 LLM 生成式答案也接进来,但成本、合规、延迟三座大山还在前面,路漫漫,继续搬砖。