Lab A · 客服工单智能分流
本章你会学到:如何把一个「分错工单」的现场问题,做成有基线、有评测、有人工复核、有反馈回流的完整部署。
一句话:这个 Lab 的价值不在模型多准,而在你能证明它比规则好多少、在什么条件下可以自动放行、出问题时怎么退回来。
一个现场片段
以下为合成场景,用于说明典型情境,不对应任何真实客户。
你第一次去客户现场,客服主管指着屏幕说:「昨天又有 7 张安全类工单被分到了计费队列,等了 6 个小时才转回来,客户已经在群里骂人了。」
你打开工单系统:每天约 400 张新工单,12 名一线坐席靠肉眼判断,手动拖进 6 个二线队列。分派平均 4 分钟,约每 4 张就有 1 张要转第二次,每次转派又多等几个小时。二线抱怨总接别人挑剩的活,一线抱怨标签规则三天两头变。
主管只问:「能不能让系统自己分,分不准的再给我看?你怎么证明它比现在的规则好,而且不会把安全工单再分错?」上一家供应商给过一个演示惊艳、上线一周就没人用的工具。
你的任务是回答这个问题。
交付什么
做完这个 Lab,你应该有一份能放进 GitHub、也能在面试里讲 10 分钟的作品:可复现的仓库(数据、基线、模型、评测脚本)、评测报告(基线 vs 模型、分类别指标、混淆矩阵、错误分析)、可演示的分流流程、上线检查与灰度方案、复盘。
背景与用户故事
设定:一家约 300 人的 B2B SaaS 公司(合成),产品提供数据看板与 API。客服体系分一线(12 人,负责首响与分派)和二线(6 个专业队列)。每天约 400 张新工单,来自邮件、在线聊天和电话记录。
三个用户故事,分别对应三类人:
- 一线坐席:「工单进来时直接告诉我该去哪个队列;不确定就给我两个候选,我点一下就好。」
- 二线主管:「我不想再收到错派工单。分错了,我要能看到它依据的是工单里哪句话。」
- 运营负责人:「6 周内看到首次分派时长下降,不能靠加班。出问题要能立刻退回人工,不影响 SLA。」
v1 明确不做四件事:不自动回复客户、不自动关单、不预测优先级、不做多语言。范围越窄,越容易验证。
成功指标
下面的基线是「示例现场」的设定值,目标值也是教学用的合理值,不是行业标准。落地时用自己的数据重算,并写清口径。
| 指标 | 定义与口径 | 基线(示例) | 目标(示例) | 为什么重要 |
|---|---|---|---|---|
| 一级分类 macro-F1 | 6 个队列的 F1 取宏平均,避免大类掩盖小类 | 0.55 | ≥ 0.80 | 主指标,衡量「每个队列都能分对」 |
| top-2 命中率 | 正确队列出现在前两个候选里的比例 | 0.72 | ≥ 0.92 | 决定「建议档」是否有用 |
| 自动分派覆盖率 | 置信度 ≥ 0.85、被系统直接分派的工单占比 | 0 | ≥ 35% | 覆盖率不够,业务方感受不到价值 |
| 自动分派子集准确率 | 只看被自动分派的那部分,准确率是多少 | — | ≥ 95% | 覆盖率与准确率必须成对报告 |
| 人工修正率 | 复核者改掉模型标签的比例 | — | ≤ 15% | 人工负担的直接度量 |
| 首次分派时长(中位数) | 工单创建到进入正确队列的时间 | 4 分钟 | ≤ 1 分钟 | 用户能直接感知的收益 |
| 7 天重派率 | 7 天内被转到第二个队列的工单占比 | 25% | ≤ 15% | 误派的业务后果 |
| 单工单成本 | 模型调用成本 / 工单数 | — | ≤ 0.02 美元 | 决定能不能长期跑 |
| 护栏:P95 时延 | 单张工单从进入到给出结果的耗时 | — | ≤ 2 秒 | 超时要有兜底 |
| 护栏:安全事件 | 数据泄露、越权、错误分派到安全队列未拦截 | — | 0 | 不可用「平均指标好」掩盖 |
指标纪律
只报 macro-F1 是耍流氓,只报自动覆盖率更危险。准确率、覆盖率、人工修正率必须同时出现,否则读者无法判断你到底带来了多少价值。
数据:字段、标签与合成方法
工单字段
| 字段 | 类型 | 用途 | 注意 |
|---|---|---|---|
ticket_id | string | 唯一标识 | 不要用自增数字当训练特征 |
created_at | datetime | 分时间窗、算时长 | 合成数据要虚构时间 |
channel | enum | 邮件 / 聊天 / 电话记录 | 不同渠道质量差异大 |
subject | string | 分类主要信号 | 可能为空 |
body | string | 分类主要信号 | 可能很长,需截断 |
current_tags | list | 现有规则标签 | 可作为基线输入 |
assigned_queue | enum | 人工分派的最终队列 | 这是标签来源,不是因果 |
resolution_notes | text | 错误分析与复盘 | 含 PII,必须脱敏 |
reopened | bool | 质量信号 | 与重派率相关 |
标签体系
v1 只做 6 个一级队列。每类都要给出判定标准和「最容易混的反例」,否则标注不一致,模型学不到稳定边界。
| 队列 | 判定标准 | 易混反例 |
|---|---|---|
| 计费 Billing | 发票、扣款、退款、套餐变更、税率 | 因账号停用导致的「扣款失败」可能是账号问题 |
| 账号与登录 Account | 注册、登录、双因素、成员与权限管理 | 密码重置邮件收不到,可能是邮件网关(集成) |
| API 与集成 Integration | 密钥、限流、webhook、SDK、第三方系统对接 | 数据同步延迟既有 API 也有数据问题 |
| 数据与报表 Data | 导入导出、报表口径、数据丢失、同步延迟 | 权限导致看不到报表,应归账号 |
| 安全与合规 Security | 可疑登录、漏洞报告、数据删除请求、合规文件 | 任何疑似安全事件,默认归安全并人工复核 |
| 其他 Other | 以上都不是,或信息不足无法判断 | 不要把所有「不确定」都丢进来,要能说出为什么 |
安全类要有「默认拒绝」
只要工单里出现安全信号(异常登录、数据删除请求、漏洞报告),无论模型多有把握,都强制进入安全队列并触发人工复核。这是用规则写死的,不交给模型判断。
没有真实数据时怎么合成
- 写种子模板:6 类 × 5 个种子,每个种子描述行业、产品、问题、情绪、信息完整度。例如「制造业客户,API 限流,愤怒,没给请求 ID」。
- 让模型扩写:按种子生成 800 条工单,要求像真实客服邮件——有寒暄、错别字、多问题混杂、缺关键信息,并标注为合成数据。
- 人工标注:标注约 500 条作为金标;抽 50 条隔一天重标,前后一致率低于 85% 就回去改标签定义。
- 可选公开数据:公开客服工单数据集可补多样性,但使用前确认许可证允许学习用途,并检查标签能否映射到 6 类。
四个集合必须分开:few-shot 示例 60 条(每类 10 条)、开发集 120 条(调提示词用)、评测集 200 条(报指标用)、盲测集 120 条(最后只跑一次),合计 500 条。同一批模板生成的工单不能跨集合,否则就是数据泄漏。
数据红线
只能用合成数据、公开数据或已获书面授权的脱敏数据。不要把真实客户工单贴进第三方 API;不要用真实邮箱、电话、卡号测试;截图和报告里不能出现可识别信息。
基线:先做一个能跑的规则分类器
任何模型结果都要和基线比。规则基线很土,但便宜、可解释、能兜底,也是和业务方沟通的锚点。
RULES = {
"billing": ["发票", "退款", "扣款", "续费", "价格", "invoice", "refund"],
"account": ["登录", "密码", "验证码", "注册", "成员", "权限", "2fa"],
"integration": ["api", "密钥", "token", "限流", "webhook", "sdk", "对接"],
"data": ["导入", "导出", "报表", "字段", "同步", "看板", "数据"],
"security": ["异常登录", "漏洞", "删除数据", "泄露", "合规", "审计"],
}
def rule_classify(ticket: dict) -> tuple[str, int]:
text = f"{ticket['subject']} {ticket['body']}".lower()
scores = {q: sum(1 for kw in kws if kw.lower() in text) for q, kws in RULES.items()}
best = max(scores, key=scores.get)
return (best, scores[best]) if scores[best] > 0 else ("other", 0)在 200 条评测集上跑一遍,记录 macro-F1、每类 F1 和混淆矩阵。示例现场里规则基线的 macro-F1 大约 0.55——这个数字必须由你自己跑出来,不要抄。基线越诚实,后面模型提升的说服力越强。
方案:模型 + 置信度 + 人工复核 + 反馈回流
整体架构
工单
│
├─ 预处理:截断正文、正则脱敏(邮箱/电话/卡号)、字段校验
│
├─ 安全规则快路径:命中安全信号 → 直接进安全队列 + 人工复核
│
├─ 规则基线:高确定性关键词命中 → 直接分派(可选,先观察)
│
└─ LLM 分类(输出 JSON:queue / second_choice / confidence / evidence)
│
└─ 置信度校准后分三档
├─ ≥ 0.85:自动分派
├─ 0.60-0.85:给一线「建议 + 候选」,人工确认
└─ < 0.60:不展示模型结论,直接人工分派
│
└─ 人工修正 → 反馈库 → 每周复盘 → few-shot / 评测集 / 规则三档路由的行为定义
| 档位 | 校准后置信度 | 系统行为 | 一线看到什么 | 期望占比 |
|---|---|---|---|---|
| 自动 | ≥ 0.85 | 直接写入正确队列 | 只显示「已自动分派」+ 一句依据 | 30-50% |
| 建议 | 0.60-0.85 | 预填两个候选,等人工确认 | 推荐队列 + 第二候选 + 原文依据 | 30-40% |
| 人工 | < 0.60 或校验失败 | 不给结论,走原流程 | 无推荐(或灰色显示「不建议参考」) | 20-30% |
提示词与输出契约
提示词必须包含:角色与目标、每类定义与反例、JSON 字段说明、不确定时的约定。
{
"queue": "billing | account | integration | data | security | other",
"second_choice": "同 queue 枚举,可与 queue 相同",
"confidence": 0.0,
"evidence": "工单原文中支持判断的一句话,必须逐字摘录",
"needs_human": true,
"reason": "判断依据,≤40 字"
}关键约束:
queue必须是枚举值,否则校验失败,转人工;evidence必须能在原文中做子串匹配,否则降置信度;confidence是自报值,不能当概率用,必须校准;- 多问题混杂时选「最紧急且最专业」的一个,并给第二候选。
置信度校准
模型自报的「我有 90% 把握」通常偏乐观。校准方法:把开发集的自报置信度分桶,算每桶的真实准确率,得到一张映射表。下表只是格式示例,数字必须用你自己的数据跑出来。
| 自报置信度桶 | 样本数 | 实际准确率(示例) | 校准后值 | 路由结果 |
|---|---|---|---|---|
| 0.9-1.0 | 18 | 0.94 | 0.94 | 自动 |
| 0.8-0.9 | 27 | 0.82 | 0.82 | 建议 |
| 0.6-0.8 | 34 | 0.63 | 0.63 | 建议 |
| 0.4-0.6 | 25 | 0.41 | 0.41 | 人工 |
| 0-0.4 | 16 | 0.19 | 0.19 | 人工 |
校准之后,再决定自动档阈值:阈值不是拍脑袋定 0.85,而是看「在保证自动子集准确率 ≥ 95% 的前提下,覆盖率最高能到多少」。
反馈回流
- 每日:抽检 20 条自动分派工单,记录是否有错;
- 每周:30 分钟复盘,把人工修正样本归类——标签歧义、信息缺失、模型误读、新类型;
- 每月:更新 few-shot(每类 ≤ 10 条)、补充评测集、重跑全量评测;
- 每季度:重审标签体系,业务变了标签就要变。
不要「自动学习」用户修正
用户点一下改标签,不一定是对的:可能是他手滑,可能是当班临时规则。修正样本必须经过一次人工质量审核,才能进入 few-shot 或评测集。不要用线上修正自动微调模型,那会把噪声固化进系统。
接口与关键代码
把分类能力包成一个边界清晰的接口,后面换模型、换供应商都不用改业务代码。
import json
import os
from openai import OpenAI
client = OpenAI(
base_url=os.environ.get("OPENAI_BASE_URL"), # OpenAI 兼容网关地址,可为空走官方
api_key=os.environ.get("OPENAI_API_KEY"),
)
SYSTEM_PROMPT = """你是客服工单分流器。队列定义、判定标准与反例见标签表。
规则:
1. 只输出 JSON,不要输出解释性文字。
2. evidence 必须逐字摘自工单原文。
3. 多问题时选最紧急且最专业的一个;无把握时 queue 填 other。
4. 涉及安全信号(异常登录、数据删除、漏洞)时 queue 必须是 security。
"""
def classify_ticket(ticket: dict) -> dict:
user = f"主题:{ticket.get('subject','')}\n正文:{ticket.get('body','')[:2000]}"
resp = client.chat.completions.create(
model=os.environ["LLM_MODEL"],
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user},
],
temperature=0,
response_format={"type": "json_object"},
)
raw = resp.choices[0].message.content
return validate(json.loads(raw), ticket)
def validate(pred: dict, ticket: dict) -> dict:
allowed = {"billing", "account", "integration", "data", "security", "other"}
ok = pred.get("queue") in allowed and 0.0 <= float(pred.get("confidence", -1)) <= 1.0
evidence = pred.get("evidence", "")
if evidence and evidence not in ticket.get("body", ""):
ok = False
pred["valid"] = ok
return pred
AUTO_THRESHOLD, SUGGEST_THRESHOLD = 0.85, 0.60
def route(pred: dict, calibrated_conf: float) -> tuple[str, str | None]:
if not pred["valid"]:
return "human", None
if calibrated_conf >= AUTO_THRESHOLD:
return "auto_assign", pred["queue"]
if calibrated_conf >= SUGGEST_THRESHOLD:
return "suggest", pred["queue"]
return "human", None工程细节别忽略:正文截断到 2,000 字符控制成本;相同文本缓存;批量任务异步跑;每次调用记录 token、花费、模型与提示词版本,否则无法解释成本变化。
评测
评测集结构
| 分层 | 条数 | 目的 | 判定方式 |
|---|---|---|---|
| 6 个队列,每类 25 条 | 150 | 主指标,覆盖常规工单 | 人工标注 |
| 多问题混杂 | 15 | 考察「选最紧急」的规则 | 人工标注 + 标注说明 |
| 信息不足 / 应归 other | 20 | 考察是否乱猜 | 人工标注 |
| 安全信号 | 15 | 安全红线,错一票否决 | 人工标注 + 规则校验 |
必须报告的三张表
- 总表:基线 vs 模型 v1 vs v2,含 macro-F1、top-2、自动覆盖率、自动子集准确率;
- 分类别表:每类 precision / recall / F1,标出最差的一类;
- 混淆矩阵:行是真实标签、列是预测标签,重点看「安全 → 其他」「其他 → 计费」两格。
from sklearn.metrics import f1_score, classification_report
macro_f1 = f1_score(y_true, y_pred, average="macro")
report = classification_report(y_true, y_pred, digits=3)
print(report)错误分析(必做)
从错误里随机抽 30 条,逐条归类并写改进行动:
| 错误类型 | 典型表现 | 改进行动 |
|---|---|---|
| 标签歧义 | 「扣款失败」被标成账号 | 改标签定义,加反例 |
| 信息缺失 | 只有一句「不行了」 | 归 other 不算错;要求低置信 |
| 多问题混杂 | 同时问发票和 API | 规定优先级,或试多标签 |
| 模型误读 | 把「退款政策」当退款申请 | 提示词加区分,补 few-shot |
评测纪律
- 用开发集调提示词,评测集只在版本冻结时跑一次,盲测集最后只跑一次;
- 评测集不进提示词和 few-shot;每次变更(提示词、模型、路由阈值、标签)都跑回归并记版本号;
- 报告必须写清数字来自什么数据、什么版本、什么口径。
上线检查
- 数据合规:全部为合成 / 公开 / 已授权脱敏数据,已做 PII 处理
- 三档路由可用,低置信度稳定落到人工,人工入口不依赖模型
- 安全信号由规则独立兜底,不经过模型判断
- 每次分派可追溯:输入哈希、输出、置信度、模型与提示词版本
- 一键回滚开关能把流量切回 100% 人工,已演练一次
- 监控与告警:P95 时延、错误率、自动档准确率抽检、修正率、成本;超时、非法 JSON、供应商故障各有 Runbook
- 用户培训到位:一线会改标签,主管每天看修正率、自动覆盖率、抽检准确率
- 上线前跑完整回归,结果与评测报告一致
灰度方案
| 阶段 | 流量 | 观察窗口 | 放量条件 | 回滚条件 |
|---|---|---|---|---|
| 影子模式 | 100% 只记录不生效 | 3 个工作日 | 自动档准确率抽检 ≥ 95% | 无需回滚 |
| 灰度 1 | 10% | 3-5 个工作日 | 修正率 ≤ 15%,无安全错分 | 修正率 > 20% 或安全错分 1 次 |
| 灰度 2 | 30% | 3-5 个工作日 | 指标稳定,一线反馈无恶化 | 同上 |
| 全量 | 100% | 持续 | 连续 2 周达标 | 一键回人工 |
扩展方向
按「业务价值 / 风险」两个维度挑:低风险做二级标签、工单摘要草稿、相似工单推荐;中风险要补评测的做优先级与 SLA 预测、多语言;高风险最后做自动回复(必须人工审批)和自动关单(不建议)。
交付清单
- README:问题、场景、指标、如何运行
- 数据说明:合成方法、标签定义、集合划分、许可证
- 规则基线代码与结果
- 模型调用、提示词版本、JSON 输出契约
- 评测脚本与评测报告(总表、分类别、混淆矩阵、校准表)
- 错误分析(至少 30 条,含改进动作)
- 架构图(ASCII 或图片)与数据流说明
- 上线检查清单、灰度方案、回滚步骤
- 5 分钟演示脚本(业务方版本)
- 复盘:做对了什么、失败在哪、重做会怎么改
常见误区
- 一上来就想自动回复:分流是低风险、高频、可度量的入口;自动回复是高风险、难归因的出口。顺序反了。
- 只报准确率:90% 准确率配 1% 覆盖率毫无价值,准确率、覆盖率、人工负担要一起看。
- 置信度不校准:模型说 0.9 就信,自动档实际只有 80%,业务方一次就失去信任。
- 把人工复核当免费:复核也要时间,修正率降不下来,总成本可能比纯人工还高。
- 评测集泄漏:把评测样本写进提示词,指标会好得离谱,上线立刻打回原形。
本章小结
- 先有基线,再有模型;报告里基线和模型必须同时出现。
- 分流系统的核心不是分类器,而是「三档路由 + 人工复核 + 反馈回流」的闭环。
- 置信度必须用开发集校准,阈值由「自动子集准确率」决定,不能拍脑袋。
- 安全类工单用规则强制兜底,不用模型判断。
- 评测集要分层、要防泄漏、要能解释,报告要写清口径和版本。
练习
- 复现基线:用 200 条合成工单跑规则基线,报告 macro-F1、每类 F1 和混淆矩阵。判断标准:能说清哪两类最易混及原因。
- 做一次校准:把自报置信度分 5 桶,算每桶真实准确率,回答「自动档阈值定多少、覆盖率多少」。判断标准:给出至少两组阈值下的准确率/覆盖率权衡。
- 设计灰度:写一页灰度方案,含影子模式、三档放量、观察指标、回滚条件。判断标准:别人能照着执行,回滚不需要你本人操作。