痛点分析:AI 客服到底难测在哪?
把传统接口测试那套“输入-断言”直接搬到智能客服,第一次跑就翻车:
- 用户同一句话换个说法,意图就飘了;
- 多轮对话里,槽位填到一半用户突然改口,状态机直接懵;
- 冷启动时训练数据不足,NLU 模型 F1-score 不到 80%,却要求上线。
简单说,“动态语义 + 状态上下文 + 模型不确定性”三重 buff 叠满,测试用例如果还是静态 JSON,覆盖率永远上不去。
技术方案:三种打法怎么选?
| 方案 | 优点 | 缺点 | 适用阶段 |
|---|---|---|---|
| 录制回放 | 0 成本复现线上对话 | 数据漂移后脚本全废 | 回归冒烟 |
| 规则 Mock | 意图、槽位想定就定 | 与真实模型差距大 | 前后端联调 |
| 真实 API | 结果可信 | 耗时、费钱、限流 | 预发布、压测 |
结论:Mock 做链路通,真实模型做效果验,录制回放做补充。下面给一套能把三者串起来的 BDD 框架,保证“写得爽、跑得稳、加得顺”。
场景化测试框架:Given-When-Then 落地
1. 目录约定
tests/ ├─ features/ # 自然语言描述 ├─ steps/ # Python 实现 ├─ state_machine.py # 对话状态机 └─ fixtures.py # 共享工具2. 特征文件示例
# features/book_ticket.feature Feature: 订票场景 Scenario: 用户中途改目的地 Given 用户处于“出发地已确认”状态 When 用户说“改到上海” Then 应触发“修改目的地”意图 And 槽位“到达城市”应为“上海” And 对话状态仍等待“出发时间”3. 状态机代码(带异常兜底)
# state_machine.py from functools import wraps from enum import Enum, auto class State(Enum): START = auto() ORIGIN_OK = auto() DEST_OK = auto() TIME_OK = auto() DONE = auto() class BookingSession: def __init__(self, uid: str): self.uid = uid self.state = State.START self.slots = {} def jump(self, new_state: State): self.state = new_state def catch_unknown_intent(func): @wraps(func) def wrapper(session: BookingSession, nlu: dict): if nlu.get("intent") is None: session.jump(State.START) raise RuntimeError("未知意图,回退到起始状态") return func(session, nlu) return wrapper4. Steps 实现(pytest-bdd)
# steps/book_ticket_steps.py from pytest_bdd import given, when, then from state_machine import BookingSession, State @given("用户处于“出发地已确认”状态", target_fixture="session") def origin_ok(): s = BookingSession(uid="test-123") s.jump(State.ORIGIN_OK) return s @when("用户说“改到上海”") def user_change(session, nlu_client): session.last_nlu = nlu_client.parse("改到上海") @then("应触发“修改目的地”意图") def check_intent(session): assert session.last_nlu["intent"] == "change_dest"把自然语言当“测试需求”,把状态机当“共享大脑”,需求变动只改 feature 文件,代码零改动就能回归。
核心代码:Pytest 参数化 + 异步耗时统计
1. 参数化验证不同说法
# tests/test_nlu_robust.py import pytest paraphrase = [ ("订机票", "book_ticket"), ("帮我飞广州", "book_ticket"), ("要坐飞机", "book_ticket"), ] @pytest.mark.parametrize("text,intent", paraphrase) def test_intent_stable(text, intent, nlu_client): """同样意图,不同说法,结果必须稳""" resp = nlu_client.parse(text) assert resp["intent"] == intent2. 异步统计 NLU 响应时间
# tests/test_nlu_perf.py import asyncio, aiohttp, time async def latency(url, payload): t0 = time.perf_counter() async with aiohttp.post(url, json=payload) as r: await r.json() return time.perf_counter() - t0 @pytest.mark.asyncio async def test_p95_latency(nlu_endpoint): tasks = [latency(nlu_endpoint, {"q": "我要订票"}) for _ in range(100)] lat = sorted(await asyncio.gather(*tasks)) assert lat[94] < 0.3 # P95 < 300ms避坑指南:那些半夜踩过的雷
对话上下文 ID 的幂等性
压测时 JMeter 多线程复用 UID,导致后端缓存互相覆盖,结果 200 并发只测到 1 个用户。
解决:UID 生成加入{thread_id}_{uuid4},保证“并发不串台”。测试集偏见
早期图省事,全员用“订机票”做阳性样本,模型上线后“退票”全错。
解决:- 采用分层采样,按业务比例 1:1:1 构造“订/改/退”;
- 引入对抗样本,把“我要取消”说成“给我把那张票废了”,增强鲁棒性。
性能考量:让客服顶得住“618”
负载模型
用 Locust 写“思考时间”可配的 User 类,模拟“提问-等待-追问”阶梯流量,峰值 3k 并发,谷值 500,比恒定压测更贴近真实。对话超时压测
把服务端session_ttl调到 30 s,脚本侧故意不回传keep_alive,制造“超时回收”风暴,观察:- 内存是否掉崖式上涨;
- 超时后新会话能否立即复用旧 ID(防止僵尸会话堆积)。
延伸思考:让 AI 自己“想”测试用例?
如果让强化学习 Agent 来做对话探索,把**“意图识别置信度”当奖励**,“状态机跃迁”当动作空间,能否自动生成那些人类想不到的“刁钻”路径?
初步实验发现:
- Agent 能自己组合“先沉默 5 秒→突然发语音→再改口”的复合事件;
- 奖励函数加入 F1-score 负反馈后,生成的负样本帮助模型提升 4.3%。
下一步:把生成路径直接转成 Gherkin,自动入库成为新的回归用例,实现“自我生长”的测试集。
整套流程在我们团队跑下来,测试覆盖率从 65% 提到 91%,回归周期由 2 天缩到 4 小时。代码和 feature 文件已放到内部 GitLab 模板库,新项目一键 fork 就能用。如果你也在为智能客服的“黑盒”头疼,不妨先挑一个最痛的场景,用 BDD 写 10 条例子跑通,再慢慢把状态机、参数化、压测往里填,很快就能看到覆盖率往上窜。