背景痛点:规则引擎的困境

在智能客服项目初期,很多团队会选择基于规则引擎的方案。这种方案听起来很直接:用户说“我要退款”,就触发退款流程;用户说“查订单”,就跳转到订单查询。我们用Python写个简单的规则匹配,看起来就能跑起来了。

但实际一上线,问题就全暴露出来了。用户不会按你设定的剧本说话。“我昨天买的东西不想要了,钱能退吗?”、“刚下的单,后悔了怎么办?”这些自然语言表达,用简单的关键词规则(比如“退款”、“退钱”)很难准确覆盖。更头疼的是业务变化,今天增加一个“价保”功能,明天修改退货政策,规则库就要跟着大改,维护成本像滚雪球一样越滚越大。

这就是传统规则引擎在智能客服场景下的核心痛点:冷启动成本高(需要人工穷举大量规则)、泛化能力差(对未见过或相似的说法束手无策)、以及维护困难。项目要想走得远,必须换条路。

技术选型:为什么是Rasa+Transformer?

面对规则引擎的瓶颈,我们通常有几个主流选择:云服务(如Dialogflow、腾讯云智聆)、开源框架(如Rasa)、或者完全自研。这里简单对比一下:

  • Dialogflow等云服务:上手快,有现成的NLU能力,适合快速验证想法或对定制化要求不高的场景。但“黑盒”特性明显,数据要上传到云端,模型和流程定制深度有限,长期来看有 vendor lock-in(供应商锁定)的风险,且按调用量计费,规模大了成本不低。
  • 自研框架:自由度最高,所有组件都可控。但挑战巨大,需要从零搭建NLU(自然语言理解)、对话管理、状态跟踪等全套系统,对团队的技术深度和工程能力要求极高,容易陷入“重复造轮子”且轮子还不圆的境地。
  • Rasa开源框架:它提供了一个相对平衡的选择。核心是开源的,代码可控,可以私有化部署,数据安全有保障。它提供了对话管理的骨架(Stories, Policies)和可替换的NLU组件,让我们既能站在巨人肩膀上,又能针对特定业务进行深度定制。

所以,我们的选型思路是:用Rasa作为对话管理的“操作系统”,负责对话状态跟踪和流程控制;用基于Transformer的预训练模型(如BERT)作为“大脑”,负责高精度的意图识别和实体抽取,解决规则引擎泛化能力差的问题。 Rasa的模块化设计允许我们轻松替换其默认的NLU组件,接入我们微调好的BERT模型,强强联合。

下图展示了这个核心架构的组件交互关系:

系统架构图

用户输入首先经过NLU拦截器(可做敏感词过滤、基础清洗),然后送入微调的BERT模型进行意图分类和实体提取。解析结果被传递给 Rasa Core 的对话管理系统,该系统根据当前对话状态和策略(Policy)决定下一步动作(如:询问具体订单号、调用API查询、直接回复等)。动作执行器会处理业务逻辑,并生成机器回复。整个过程的对话状态被持久化到状态存储中,确保多轮对话的连贯性。

核心实现:对话管理与意图识别实战

1. 对话管理:用有限状态机(FSM)实现清晰流程

Rasa内部使用基于机器学习的对话策略,但对于一些强流程性的业务(如退款、修改地址),我们可以用更直观、可控的有限状态机来辅助或实现。下面是一个用Python实现的简易FSM,用于管理“退货申请”流程:

class ReturnGoodsFSM:
    """
    退货流程有限状态机
    状态:START -> CONFIRM_ORDER -> REASON -> CONFIRM_ADDRESS -> END
    """
    def __init__(self):
        # 定义状态转移规则:{当前状态: {触发意图: 下一个状态}}
        self.transitions = {
            'START': {'apply_return': 'CONFIRM_ORDER'},
            'CONFIRM_ORDER': {
                'affirm': 'REASON',
                'deny': 'END'  # 用户否认订单,结束
            },
            'REASON': {'provide_reason': 'CONFIRM_ADDRESS'},
            'CONFIRM_ADDRESS': {
                'affirm': 'END',  # 确认地址,流程完成
                'deny': 'UPDATE_ADDRESS'  # 需要更新地址
            },
            'UPDATE_ADDRESS': {'provide_address': 'END'},
            'END': {}  # 终止状态
        }
        self.current_state = 'START'
        self.context = {}  # 用于存储收集到的信息(订单号、原因、地址等)

    def process(self, user_intent: str, entities: dict) -> (str, str):
        """
        处理用户输入,推进状态机。
        参数:
            user_intent: 识别到的用户意图
            entities: 提取到的实体,如{'order_id': '12345'}
        返回:
            (next_action, next_state): 系统应执行的动作和下一个状态
        """
        # 1. 实体信息存入上下文
        if entities:
            self.context.update(entities)

        # 2. 获取当前状态允许的转移
        available = self.transitions.get(self.current_state, {})
        next_state = available.get(user_intent)

        # 3. 处理状态转移
        if next_state:
            old_state = self.current_state
            self.current_state = next_state
            action = self._get_action(old_state, next_state, user_intent)
            return action, next_state
        else:
            # 未定义的转移:可能是用户胡言乱语,保持在当前状态并提示
            # 这里可以设计一个默认的澄清或引导动作
            return "ask_clarification", self.current_state

    def _get_action(self, old_state: str, new_state: str, intent: str) -> str:
        """根据状态转移决定系统执行什么动作(简化示例)"""
        action_map = {
            ('START', 'CONFIRM_ORDER'): 'ask_order_id',
            ('CONFIRM_ORDER', 'REASON'): 'ask_return_reason',
            ('REASON', 'CONFIRM_ADDRESS'): 'confirm_default_address',
            ('CONFIRM_ADDRESS', 'END'): 'submit_return_application',
            ('CONFIRM_ADDRESS', 'UPDATE_ADDRESS'): 'ask_new_address',
            ('UPDATE_ADDRESS', 'END'): 'update_and_submit',
        }
        return action_map.get((old_state, new_state), 'default_response')

# 使用示例
fsm = ReturnGoodsFSM()
# 用户说:“我要退货”(识别意图为‘apply_return’)
action, state = fsm.process('apply_return', {})
print(f"系统动作: {action}, 新状态: {state}") # 输出: ask_order_id, CONFIRM_ORDER
# 用户提供订单号:“订单号是123456”(意图‘affirm’,实体{'order_id': '123456'})
action, state = fsm.process('affirm', {'order_id': '123456'})
print(f"系统动作: {action}, 新状态: {state}") # 输出: ask_return_reason, REASON

这个FSM虽然简单,但清晰地定义了流程边界。在实际项目中,我们可以将这样的FSM与Rasa的RulePolicy结合,让Rasa来管理状态和选择动作,我们只定义业务规则,这样既保持了灵活性,又拥有了可控性。

2. 意图识别:微调BERT实现领域自适应

Rasa默认的DIET分类器效果不错,但在垂直领域(如电商客服、金融咨询),有大量专业术语和特有表达,通用BERT模型可能力不从心。这时,我们需要用自己领域的对话数据对BERT进行微调。

假设我们已经积累了一批标注好的客服对话数据(textintent标签)。下面是一个使用Hugging Face transformers库进行微调的简化示例:

import torch
from torch.utils.data import Dataset, DataLoader
from transformers import BertTokenizer, BertForSequenceClassification, AdamW
from sklearn.model_selection import train_test_split
import pandas as pd

# 1. 准备数据
# 假设df有两列: 'text' (用户语句) 和 'intent' (意图标签)
df = pd.read_csv('customer_service_data.csv')
intents = df['intent'].unique().tolist()
intent2id = {intent: i for i, intent in enumerate(intents)}
id2intent = {i: intent for intent, i in intent2id.items()}

df['label'] = df['intent'].map(intent2id)
train_df, val_df = train_test_split(df, test_size=0.2)

# 2. 定义数据集
class IntentDataset(Dataset):
    def __init__(self, texts, labels, tokenizer, max_len=128):
        self.texts = texts
        self.labels = labels
        self.tokenizer = tokenizer
        self.max_len = max_len

    def __len__(self):
        return len(self.texts)

    def __getitem__(self, idx):
        text = str(self.texts[idx])
        label = self.labels[idx]
        encoding = self.tokenizer.encode_plus(
            text,
            add_special_tokens=True,
            max_length=self.max_len,
            return_token_type_ids=False,
            padding='max_length',
            truncation=True,
            return_attention_mask=True,
            return_tensors='pt',
        )
        return {
            'input_ids': encoding['input_ids'].flatten(),
            'attention_mask': encoding['attention_mask'].flatten(),
            'label': torch.tensor(label, dtype=torch.long)
        }

# 3. 初始化模型和分词器
model_name = 'bert-base-chinese'  # 中文场景
tokenizer = BertTokenizer.from_pretrained(model_name)
model = BertForSequenceClassification.from_pretrained(model_name, num_labels=len(intents))

# 4. 创建数据加载器
train_dataset = IntentDataset(train_df['text'].values, train_df['label'].values, tokenizer)
val_dataset = IntentDataset(val_df['text'].values, val_df['label'].values, tokenizer)
train_loader = DataLoader(train_dataset, batch_size=16, shuffle=True)
val_loader = DataLoader(val_dataset, batch_size=16)

# 5. 训练准备
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
model = model.to(device)
optimizer = AdamW(model.parameters(), lr=2e-5)
epochs = 3

# 6. 训练循环(简化版,省略了验证和保存逻辑)
model.train()
for epoch in range(epochs):
    for batch in train_loader:
        input_ids = batch['input_ids'].to(device)
        attention_mask = batch['attention_mask'].to(device)
        labels = batch['label'].to(device)

        optimizer.zero_grad()
        outputs = model(input_ids=input_ids, attention_mask=attention_mask, labels=labels)
        loss = outputs.loss
        loss.backward()
        optimizer.step()
    print(f'Epoch {epoch+1} completed.')

# 7. 使用微调后的模型进行预测
def predict_intent(text, model, tokenizer, id2intent):
    model.eval()
    encoding = tokenizer.encode_plus(
        text,
        add_special_tokens=True,
        max_length=128,
        return_tensors='pt',
        padding='max_length',
        truncation=True,
        return_attention_mask=True
    )
    input_ids = encoding['input_ids'].to(device)
    attention_mask = encoding['attention_mask'].to(device)

    with torch.no_grad():
        outputs = model(input_ids, attention_mask=attention_mask)
        prediction = torch.argmax(outputs.logits, dim=1)
    return id2intent[prediction.item()]

# 示例预测
new_text = “我这个订单物流三天没更新了,能催一下吗?”
predicted_intent = predict_intent(new_text, model, tokenizer, id2intent)
print(f"预测意图: {predicted_intent}")  # 可能输出: query_logistics

微调后的BERT模型对领域内语言的把握会精准得多。我们可以将这个模型集成到Rasa中,替换其默认的NLU组件,具体可以通过实现一个自定义的NLUComponent并配置到config.yml中来完成。

生产考量:稳定与合规

1. 压力测试:用JMeter验证高可用

系统上线前,压力测试必不可少。我们使用JMeter来模拟高并发用户对话,关键要看两个指标:QPS(每秒查询率)响应时间百分位数(如P95, P99)。P99响应时间意味着99%的请求都在这个时间内返回,它比平均响应时间更能反映尾部延迟,对用户体验影响很大。

一个简单的JMeter测试计划可能包含:

  • 线程组:模拟500个并发用户,在1分钟内启动完毕,持续运行5分钟。
  • HTTP请求采样器:指向我们的对话API端点(例如 /webhook),Body中携带模拟的用户消息JSON。
  • 响应断言:检查返回状态码是否为200。
  • 监听器:查看结果树、聚合报告、用图形结果查看响应时间趋势。

在聚合报告中,我们会重点关注:

  • 样本数(Sample):总请求数。
  • 平均值(Average)中位数(Median):整体响应时间水平。
  • 90%百分位(90% Line)95%百分位(95% Line)99%百分位(99% Line):尾部延迟。
  • 吞吐量(Throughput):即QPS。

假设我们得到一份测试结果:平均响应时间120ms,P99响应时间500ms,QPS达到200。如果P99时间过长,我们就要考虑优化,例如:

  • 对NLU模型进行量化或使用更轻量的模型(如DistilBERT)。
  • 对话状态存储使用Redis等高性能缓存,而非直接查数据库。
  • 对耗时的外部API调用(如查询订单详情)进行异步处理或缓存。

2. 日志脱敏:合规性设计

客服对话日志对于分析问题、优化模型至关重要,但里面包含大量用户敏感信息(手机号、地址、订单号等)。存储这些数据必须进行脱敏处理,以满足数据安全法规(如GDPR、个人信息保护法)的要求。

我们的设计原则是:原始数据尽快脱敏,脱敏后数据方可落盘存储,且脱敏过程不可逆

具体实现可以在对话系统的入口处(NLU拦截器)或出口处(动作执行器记录日志前)添加一个脱敏组件:

import re

class LogDesensitizer:
    """日志脱敏器"""
    def __init__(self):
        # 定义敏感模式的正则表达式
        self.patterns = {
            'phone': r'(1[3-9]\d{9})',  # 手机号
            'id_card': r'([1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx])', # 身份证号简化版
            'email': r'([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})',
        }

    def desensitize_text(self, text: str) -> str:
        """对文本进行脱敏"""
        desensitized_text = text
        for key, pattern in self.patterns.items():
            desensitized_text = re.sub(pattern, f'[{key}_masked]', desensitized_text)
        # 还可以处理其他自定义模式,如订单号(假设格式为10位数字)
        desensitized_text = re.sub(r'\b(\d{10})\b', '[order_masked]', desensitized_text)
        return desensitized_text

    def desensitize_dict(self, data: dict) -> dict:
        """对字典(如对话上下文、实体字典)进行递归脱敏"""
        desensitized = {}
        for key, value in data.items():
            if isinstance(value, str):
                desensitized[key] = self.desensitize_text(value)
            elif isinstance(value, dict):
                desensitized[key] = self.desensitize_dict(value)
            elif isinstance(value, list):
                desensitized[key] = [self.desensitize_dict(item) if isinstance(item, dict) else (self.desensitize_text(item) if isinstance(item, str) else item) for item in value]
            else:
                desensitized[key] = value
        return desensitized

# 使用示例
desensitizer = LogDesensitizer()
original_log = {
    'user_input': '我的手机号是13800138000,邮箱是abc@example.com,订单号1234567890有问题。',
    'entities': {'phone': '13800138000', 'order_id': '1234567890'}
}
safe_log = desensitizer.desensitize_dict(original_log)
print(safe_log)
# 输出:
# {
#   'user_input': '我的手机号是[phone_masked],邮箱是[email_masked],订单号[order_masked]有问题。',
#   'entities': {'phone': '[phone_masked]', 'order_id': '[order_masked]'}
# }

脱敏后的日志可以安全地存入ES或数据库,用于后续分析。原始敏感数据如果业务确实需要(例如,需要真实手机号发送短信),则应存储在高度加密、访问权限严格控制的其他系统中,并与脱敏日志通过不可逆的令牌(Token)进行关联,实现数据隔离。

避坑指南:来自实战的经验

1. NLU模型热更新与会话一致性

业务在增长,模型需要迭代。但直接替换线上NLU模型可能导致正在进行的会话“精神分裂”:用户上一句话被旧模型理解成意图A,下一句话被新模型理解成意图B,对话状态就乱套了。

解决方案:会话绑定的模型版本管理。

  • 为每个会话(session_id)记录其开始时使用的NLU模型版本号。
  • 在该会话的整个生命周期内,都使用同一个版本的模型进行意图识别。
  • 只有新开始的会话,才会使用最新的模型版本。

这可以通过在对话状态(tracker)中存储一个 nlu_model_version 字段来实现。在自定义的NLU组件中,根据这个版本号加载对应的模型文件进行预测。

2. 预防多轮对话中的状态丢失

用户聊到一半关闭了页面,或者服务重启了,如何恢复对话状态?Rasa默认的内存跟踪器(InMemoryTrackerStore)在服务重启后状态会全部丢失。

解决方案:使用外部持久化存储。 Rasa支持多种Tracker Store,如 RedisTrackerStore, SQLTrackerStore。以Redis为例,配置非常简单,在 endpoints.yml 中配置即可:

tracker_store:
  type: redis
  url: localhost  # Redis地址
  port: 6379
  db: 0
  password: null
  record_expiry: 3600  # 会话记录过期时间(秒)

这样,对话状态会以 session_id 为键存储在Redis中。即使服务重启,只要 session_id 不变(通常由前端传递或根据用户ID生成),就能从Redis中恢复完整的对话上下文,用户感觉不到中断。

互动挑战:优化对话策略

理论说了这么多,我们来个实战挑战吧,检验一下你对对话管理的理解。

挑战场景:处理用户突然切换话题 假设你的客服机器人正在引导用户完成“退货申请”流程(状态在CONFIRM_ORDER),此时用户突然问了一句:“你们最近有什么优惠活动?”(意图 query_promotion)。按照我们上面写的简单FSM,这个意图在当前状态下没有定义转移规则,系统可能会回复一个笼统的 ask_clarification,这显然不够智能。

你的任务: 请设计一个更优雅的对话策略来处理这种“话题跳跃”。你可以思考并尝试用伪代码或文字描述如何实现以下能力:

  1. 临时挂起当前的退货流程。
  2. 简洁地回答用户关于优惠活动的问题。
  3. 友好地引导用户回到之前的流程,例如:“刚才我们正在处理您的退货,关于订单号是XXX的退货,您确认是这个订单吗?”

提示: 可以考虑在对话状态中增加一个“栈”(stack)结构来保存被中断的流程上下文。当检测到明显的话题跳跃时,将当前流程压栈,处理新话题,处理完毕后从栈中弹出原流程并给出引导。

期待你在实践中探索出更好的解决方案!智能客服Agent的优化之路,就是在不断处理这些边缘场景中逐渐成熟的。

Logo

中国智能体开发者社区,聚焦智能体与大模型开发,提供前沿资讯、实用工具链、开源项目及行业案例。通过技术沙龙、开发者大赛等活动,促进经验交流与协作,助力开发者快速构建创新智能应用。

更多推荐