一、从一次线上事故说起
去年双十一凌晨三点,我正盯着监控面板上那条诡异的错误曲线——智能客服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' # 默认走知识库查询
七、那些年我踩过的坑(经验性建议)
-
别让LLM决定状态转换。LLM生成文本时可能“自由发挥”,比如用户说“我要投诉”,LLM可能回答“好的,请问您要投诉什么”,但状态机还停留在GREETING。状态转换必须由代码控制,LLM只负责填充自然语言模板。
-
知识库检索要加阈值。向量相似度低于0.6的结果直接丢弃,否则LLM会基于错误信息胡编。我见过最离谱的是,用户问“怎么退款”,检索到“退款政策”相似度0.55,LLM直接编了一套不存在的退款流程。
-
工单创建一定要异步确认。API超时后不要立即重试,等5秒再查询工单系统。双十一期间工单API响应时间飙到10秒,重试导致雪崩。
-
对话上下文要序列化。如果Session存在内存里,服务重启就全丢了。用Redis存,key设计为
agent:session:{user_id},value用JSON序列化,TTL设30分钟。 -
日志里记录每个决策点。线上排查问题时,我需要知道:用户说了什么→意图识别结果→检索到了哪些文档→LLM生成了什么回答→是否创建了工单。每个环节打一条日志,格式统一为
[AGENT] user_id | action | detail。 -
灰度发布时先切1%流量。我犯过最蠢的错误是,新版本知识库索引没更新,全量上线后所有用户都收到“没找到相关信息”。先在内部群测试,再切5%用户,观察24小时。
这个项目上线后,智能客服解决了70%的常见问题,人工客服压力大减。但记住:智能客服不是替代人工,而是过滤掉重复性问题。当用户连续三次问同一个问题,或者情绪检测到愤怒时,果断转人工。
下一篇我会讲如何用RAG(检索增强生成)优化知识库检索的准确率,以及如何用流式输出提升用户体验。

2377

被折叠的 条评论
为什么被折叠?



