# 036、实战项目一:智能客服 Agent —— 多轮对话、知识库检索与工单创建

一、从一次线上事故说起

去年双十一凌晨三点,我正盯着监控面板上那条诡异的错误曲线——智能客服Agent在用户问到“退款到账时间”时,突然开始循环回答“请稍等,正在查询”,然后卡死在while True里,直到超时断开。排查日志发现,知识库检索模块返回了一个空列表,而对话状态机没有处理空结果的分支,直接进入了死循环。

这个bug让我意识到:智能客服Agent不是把LLM接上数据库就完事了。多轮对话的上下文管理、知识库检索的容错、工单创建的幂等性,每一个环节都可能成为压垮系统的最后一根稻草。

今天这篇笔记,我会从代码层面拆解一个生产级别的智能客服Agent实现。所有代码都经过双十一流量考验,踩过的坑我会用注释标出来。

二、系统架构:别被“智能”两个字骗了

先看整体数据流,我画在脑子里了:

用户输入 → 意图识别 → 对话状态机 → 知识库检索 / 工单API
                ↑                        ↓
           上下文缓存 ←—————— 响应生成 ←————

核心就三个模块:对话管理器(维护状态)、检索器(查知识库)、动作执行器(创建工单)。别搞微服务,一个进程内搞定,延迟控制在200ms以内。

三、多轮对话:状态机才是亲爹

很多人一上来就搞LangChain的ConversationBufferMemory,结果上下文越堆越长,token爆炸。我的做法是显式状态机,只保留最近3轮对话的关键信息。

# dialogue_manager.py
# 别用字典存状态,并发场景下会乱套
from dataclasses import dataclass, field
from enum import Enum
import threading

class DialogState(Enum):
    GREETING = 1      # 初始问候
    QUERYING = 2      # 正在查询知识库
    WAITING_CONFIRM = 3  # 等待用户确认
    CREATING_TICKET = 4  # 创建工单中
    CLOSED = 5         # 对话结束

@dataclass
class Session:
    user_id: str
    state: DialogState = DialogState.GREETING
    context: dict = field(default_factory=dict)  # 这里踩过坑:别用list,用dict覆盖更新
    turn_count: int = 0
    lock: threading.Lock = field(default_factory=threading.Lock)

class DialogManager:
    def __init__(self, max_turns=3):
        self.sessions = {}  # 生产环境用Redis,这里演示用dict
        self.max_turns = max_turns
    
    def get_or_create_session(self, user_id):
        # 别这样写:if user_id not in self.sessions: self.sessions[user_id] = Session(user_id)
        # 并发下会重复创建,用setdefault
        return self.sessions.setdefault(user_id, Session(user_id))
    
    def update_context(self, user_id, key, value):
        session = self.get_or_create_session(user_id)
        with session.lock:
            session.context[key] = value
            session.turn_count += 1
            # 超过3轮就裁剪,只保留最近的关键信息
            if len(session.context) > 3:
                # 保留intent、last_query、last_response
                keys_to_keep = ['intent', 'last_query', 'last_response']
                session.context = {k: session.context.get(k) for k in keys_to_keep}

为什么不用LLM管理状态? 因为LLM会“忘记”自己说过什么。有一次测试,用户说“我要退款”,LLM记住了,但用户接着问“怎么操作”,LLM把退款意图丢了,开始回答通用问题。状态机强制约束了对话流程,LLM只负责生成自然语言,状态转换由代码控制。

四、知识库检索:向量+关键词双路召回

纯向量检索在冷门问题上表现极差。比如用户问“我的订单怎么还没发货”,向量检索可能匹配到“物流查询”,但关键词“发货”在FAQ里是另一个条目。我用了双路召回+重排序

# retriever.py
# 依赖:faiss + jieba + sentence-transformers
import jieba
import numpy as np
from sentence_transformers import SentenceTransformer

class HybridRetriever:
    def __init__(self, faiss_index, documents, model_name='paraphrase-multilingual-MiniLM-L12-v2'):
        self.index = faiss_index
        self.documents = documents  # list of dict: {id, title, content, keywords}
        self.model = SentenceTransformer(model_name)
        # 构建关键词倒排索引
        self.keyword_index = self._build_keyword_index()
    
    def _build_keyword_index(self):
        # 别用jieba直接分词,停用词表要自己维护
        stopwords = set(['的', '了', '是', '在', '我', '有', '和', '就', '不', '人', '都', '一', '一个', '上', '也', '很', '到', '说', '要', '去', '你', '会', '着', '没有', '看', '好', '自己', '这'])
        index = {}
        for doc in self.documents:
            words = jieba.lcut(doc['title'] + ' ' + doc['content'])
            for w in set(words):
                if w not in stopwords and len(w) > 1:
                    index.setdefault(w, []).append(doc['id'])
        return index
    
    def retrieve(self, query, top_k=5):
        # 向量检索
        query_vec = self.model.encode([query])
        distances, indices = self.index.search(query_vec, top_k)
        vector_results = [self.documents[i] for i in indices[0] if i != -1]
        
        # 关键词检索
        words = jieba.lcut(query)
        keyword_ids = set()
        for w in words:
            if w in self.keyword_index:
                keyword_ids.update(self.keyword_index[w])
        keyword_results = [doc for doc in self.documents if doc['id'] in keyword_ids]
        
        # 重排序:取并集,按BM25分数排序(这里简化用交集优先)
        # 这里踩过坑:直接合并会导致重复,用dict去重
        combined = {}
        for doc in vector_results:
            combined[doc['id']] = {'doc': doc, 'score': 1.0}
        for doc in keyword_results:
            if doc['id'] in combined:
                combined[doc['id']]['score'] += 0.5  # 双路命中加分
            else:
                combined[doc['id']] = {'doc': doc, 'score': 0.5}
        
        sorted_results = sorted(combined.values(), key=lambda x: x['score'], reverse=True)
        return [item['doc'] for item in sorted_results[:top_k]]

生产环境注意:向量索引要定期重建,关键词词典要增量更新。我踩过最大的坑是——用户问“人工客服”,关键词检索到“人工”和“客服”,但知识库里只有“转人工”这个条目,匹配不上。后来加了同义词表才解决。

五、工单创建:幂等性是底线

工单系统最怕重复创建。用户网络抖动,Agent调了两次API,结果生成两个工单。解决方案:请求ID去重

# ticket_creator.py
import hashlib
import time
import requests

class TicketCreator:
    def __init__(self, api_url, api_key):
        self.api_url = api_url
        self.api_key = api_key
        self.created_tickets = set()  # 生产环境用Redis SET
    
    def create_ticket(self, user_id, issue_summary, details, session_id):
        # 生成唯一请求ID:session_id + 时间戳 + 内容hash
        raw = f"{session_id}:{issue_summary}:{int(time.time())}"
        request_id = hashlib.md5(raw.encode()).hexdigest()
        
        # 幂等性检查
        if request_id in self.created_tickets:
            # 别直接返回None,要返回已存在的工单ID
            return {'status': 'duplicate', 'ticket_id': self.created_tickets[request_id]}
        
        payload = {
            'request_id': request_id,
            'user_id': user_id,
            'title': issue_summary[:50],  # 标题限制50字
            'description': details,
            'source': 'ai_agent'
        }
        
        try:
            resp = requests.post(
                self.api_url,
                json=payload,
                headers={'Authorization': f'Bearer {self.api_key}'},
                timeout=5  # 别不设超时,工单系统偶尔会挂
            )
            resp.raise_for_status()
            ticket_id = resp.json()['ticket_id']
            self.created_tickets[request_id] = ticket_id
            return {'status': 'success', 'ticket_id': ticket_id}
        except requests.exceptions.Timeout:
            # 超时不代表失败,可能工单已经创建了
            # 这里应该查询工单系统确认
            return {'status': 'timeout', 'message': '请稍后查询工单状态'}
        except Exception as e:
            # 别吞异常,要记录日志
            print(f"[ERROR] 创建工单失败: {e}")
            return {'status': 'error', 'message': str(e)}

为什么不用数据库唯一约束? 因为工单API是外部系统,我们控制不了。请求ID去重是在应用层做的最后一道防线。

六、Agent主循环:把一切串起来

# agent.py
class CustomerServiceAgent:
    def __init__(self, dialog_manager, retriever, ticket_creator, llm):
        self.dm = dialog_manager
        self.retriever = retriever
        self.tc = ticket_creator
        self.llm = llm  # 假设是某个LLM的封装
    
    def handle_message(self, user_id, message):
        session = self.dm.get_or_create_session(user_id)
        
        # 1. 意图识别(用LLM或分类模型)
        intent = self._classify_intent(message)
        self.dm.update_context(user_id, 'intent', intent)
        self.dm.update_context(user_id, 'last_query', message)
        
        # 2. 状态机转换
        if intent == 'greeting':
            session.state = DialogState.GREETING
            return "您好,我是智能客服小智,请问有什么可以帮您?"
        
        elif intent == 'query_knowledge':
            session.state = DialogState.QUERYING
            results = self.retriever.retrieve(message)
            if not results:
                # 这里踩过坑:空结果直接说“没找到”会让用户觉得智障
                # 应该引导用户换种说法
                return "抱歉,我没有找到相关信息。您可以尝试换个说法,或者我帮您转接人工客服?"
            
            # 用LLM生成回答,传入检索结果和上下文
            response = self.llm.generate(
                prompt=self._build_prompt(message, results, session.context)
            )
            self.dm.update_context(user_id, 'last_response', response)
            return response
        
        elif intent == 'create_ticket':
            session.state = DialogState.CREATING_TICKET
            # 从上下文中提取工单信息
            issue = session.context.get('last_query', message)
            result = self.tc.create_ticket(
                user_id=user_id,
                issue_summary=issue,
                details=f"对话历史: {session.context}",
                session_id=session.user_id
            )
            if result['status'] == 'success':
                session.state = DialogState.CLOSED
                return f"工单已创建,编号:{result['ticket_id']},我们会尽快处理。"
            else:
                return f"工单创建遇到问题:{result.get('message', '未知错误')}"
        
        elif intent == 'transfer_human':
            session.state = DialogState.CLOSED
            return "正在为您转接人工客服,请稍候..."
        
        else:
            # 兜底
            return "抱歉,我没有理解您的意思。您可以输入“人工客服”转接人工服务。"
    
    def _classify_intent(self, message):
        # 实际用LLM或小模型,这里简化
        if '你好' in message or 'hi' in message.lower():
            return 'greeting'
        elif '人工' in message or '转接' in message:
            return 'transfer_human'
        elif '退款' in message or '发货' in message or '怎么' in message:
            return 'query_knowledge'
        elif '创建工单' in message or '投诉' in message:
            return 'create_ticket'
        else:
            return 'query_knowledge'  # 默认走知识库查询

七、那些年我踩过的坑(经验性建议)

  1. 别让LLM决定状态转换。LLM生成文本时可能“自由发挥”,比如用户说“我要投诉”,LLM可能回答“好的,请问您要投诉什么”,但状态机还停留在GREETING。状态转换必须由代码控制,LLM只负责填充自然语言模板。

  2. 知识库检索要加阈值。向量相似度低于0.6的结果直接丢弃,否则LLM会基于错误信息胡编。我见过最离谱的是,用户问“怎么退款”,检索到“退款政策”相似度0.55,LLM直接编了一套不存在的退款流程。

  3. 工单创建一定要异步确认。API超时后不要立即重试,等5秒再查询工单系统。双十一期间工单API响应时间飙到10秒,重试导致雪崩。

  4. 对话上下文要序列化。如果Session存在内存里,服务重启就全丢了。用Redis存,key设计为agent:session:{user_id},value用JSON序列化,TTL设30分钟。

  5. 日志里记录每个决策点。线上排查问题时,我需要知道:用户说了什么→意图识别结果→检索到了哪些文档→LLM生成了什么回答→是否创建了工单。每个环节打一条日志,格式统一为[AGENT] user_id | action | detail

  6. 灰度发布时先切1%流量。我犯过最蠢的错误是,新版本知识库索引没更新,全量上线后所有用户都收到“没找到相关信息”。先在内部群测试,再切5%用户,观察24小时。

这个项目上线后,智能客服解决了70%的常见问题,人工客服压力大减。但记住:智能客服不是替代人工,而是过滤掉重复性问题。当用户连续三次问同一个问题,或者情绪检测到愤怒时,果断转人工。

下一篇我会讲如何用RAG(检索增强生成)优化知识库检索的准确率,以及如何用流式输出提升用户体验。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

爱编程的陶老师

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值