跳转到内容

集成人工接管(Handoff)机制

前面三节我们分别实现了 RAG 知识库问答、意图识别与分流、多轮对话状态管理。现在是时候把所有模块组装在一起,并加上最后一个关键能力——人工接管(Handoff)

为什么 Handoff 是客服系统的生命线

无论你的 AI 客服做得多么智能,它永远无法处理所有场景。以下情况必须由人来接手:

  • 用户情绪激动:投诉、威胁差评、涉及法律风险
  • 问题超出知识范围:AI 连续多次无法回答
  • 涉及敏感操作:退款大额资金、账号封禁/解封
  • 用户明确要求:"转人工""我要找真人"
  • 安全边界触发:检测到 prompt 注入或恶意试探

一个没有 Handoff 机制的客服系统,就像一家没有紧急出口的建筑——平时看不出问题,一旦出事就是灾难。 用户被 AI 气得要死却找不到真人,这种体验比没有 AI 客服更糟糕。

Handoff 的触发条件设计

我们设计三层触发机制:主动请求 → 被动检测 → 安全兜底

第一层:用户主动请求

这是最简单的——用户直接说"转人工"或类似表达。我们在意图分类器中已经覆盖了 HANDOFF_REQUEST

python
HANDOFF_TRIGGERS_EXPLICIT = [
    "转人工", "找真人", "叫经理", "人工客服",
    "我要找人", "不跟机器人说", "接人工",
    "transfer", "human agent", "speak to person",
]

第二层:被动条件检测

系统自动判断是否需要转人工,不需要用户明确要求:

python
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from typing import List, Optional
from enum import Enum

class HandoffReason(Enum):
    USER_REQUEST = "user_request"           # 用户主动要求
    EMOTIONAL_OVERFLOW = "emotional"        # 情绪过于激动
    CONSECUTIVE_FAILURES = "failures"       # 连续多次回答失败
    SENSITIVE_TOPIC = "sensitive"           # 敏感话题
    SECURITY_ALERT = "security"             # 安全告警
    MAX_TURNS_EXCEEDED = "max_turns"        # 对话轮次超限

@dataclass
class HandoffDecision:
    should_handoff: bool
    reason: Optional[HandoffReason] = None
    message_to_user: str = ""
    conversation_summary: str = ""
    urgency: str = "normal"

class HandoffDetector:
    def __init__(self,
                 max_failures: int = 3,
                 max_turns: int = 20,
                 emotion_threshold: float = 0.7):
        self.max_failures = max_failures
        self.max_turns = max_turns
        self.emotion_threshold = emotion_threshold

    def check(self, session_state: dict) -> HandoffDecision:
        state = session_state

        if state.get("turn_count", 0) >= self.max_turns:
            return HandoffDecision(
                should_handoff=True,
                reason=HandoffReason.MAX_TURNS_EXCEEDED,
                message_to_user="为了更好地帮助您,我已为您安排了人工客服,请稍候...",
                urgency="low",
            )

        if state.get("consecutive_failures", 0) >= self.max_failures:
            return HandoffDecision(
                should_handoff=True,
                reason=HandoffReason.CONSECUTIVE_FAILURES,
                message_to_user="抱歉,我似乎无法很好地解答您的问题。让我为您转接人工客服...",
                urgency="high",
            )

        emotion_score = self._detect_emotion(state.get("last_user_input", ""))
        if emotion_score > self.emotion_threshold:
            return HandoffDecision(
                should_handoff=True,
                reason=HandoffReason.EMOTIONAL_OVERFLOW,
                message_to_user="我感受到您可能有些着急。让我立刻为您转接一位专业的人工客服。",
                urgency="urgent",
            )

        sensitive = self._check_sensitive_topics(state.get("last_user_input", ""))
        if sensitive:
            return HandoffDecision(
                should_handoff=True,
                reason=HandoffReason.SENSITIVE_TOPIC,
                message_to_user="这个问题需要人工客服来为您处理,正在为您转接...",
                urgency="high",
            )

        return HandoffDecision(should_handoff=False)

    def _detect_emotion(self, text: str) -> float:
        negative_words = [
            "垃圾", "骗", "坑", "差评", "投诉", "难用",
            "退钱", "骗子", "恶心", "愤怒", "举报",
            "!!!", "???", "操", "妈的",
        ]
        text_lower = text.lower()
        score = 0.0
        for word in negative_words:
            if word in text_lower:
                score += 0.15
        if text.count("!") >= 2 or text.count("?") >= 2:
            score += 0.1
        return min(score, 1.0)

    def _check_sensitive_topics(self, text: str) -> bool:
        sensitive_patterns = [
            r"退款.*\d{4,}",          # 大额退款
            r"封号|解封|冻结",         # 账号操作
            r"律师|起诉|法律",         # 法律威胁
            r"媒体|曝光|记者",         # 威胁曝光
        ]
        import re
        for pattern in sensitive_patterns:
            if re.search(pattern, text):
                return True
        return False

第三层:安全兜底

在内容审核中间件中检测到恶意输入时强制转人工(或不做任何响应,取决于安全策略)。这部分我们在第 9 章已经讨论过,这里不再展开。

会话上下文的无缝传递

Handoff 不是简单地说一句"请稍等"就完事了。最关键的是要把 AI 和用户之前的对话上下文传递给人工客服,这样客服人员才能无缝接续对话,不用让用户重复说明问题。

会话摘要生成

当触发 Handoff 时,系统应该自动生成一份简洁但信息完整的会话摘要:

python
SUMMARY_PROMPT = """你是一个客服会话摘要生成器。请根据以下对话历史,
为人工客服生成一份简洁的摘要。

摘要应包含:
1. 用户的核心诉求(一句话)
2. 已尝试过的解决方案和结果
3. 用户的关键信息(订单号、错误码等)
4. 当前情绪状态判断

请用中文输出,控制在 200 字以内。
"""

def generate_handoff_summary(chat_history: list, session_metadata: dict) -> str:
    summary_chain = (
        ChatPromptTemplate.from_messages([
            ("system", SUMMARY_PROMPT),
            ("human", "对话历史:\n{history}\n\n会话元数据:{metadata}"),
        ])
        | get_llm()
        | StrOutputParser()
    )

    history_text = "\n".join([
        f"{'用户' if m.type == 'human' else 'AI'}: {m.content}"
        for m in chat_history[-10:]
    ])

    metadata_text = (
        f"会话ID: {session_metadata.get('session_id', 'N/A')}\n"
        f"对话轮数: {session_metadata.get('turn_count', 0)}\n"
        f"连续失败次数: {session_metadata.get('consecutive_failures', 0)}"
    )

    return summary_chain.invoke({
        "history": history_text,
        "metadata": metadata_text,
    })

测试一下摘要效果:

python
test_history = [
    HumanMessage(content="你们免费版支持几个人?"),
    AIMessage(content="免费版最多5人..."),
    HumanMessage(content="那专业版呢?"),
    AIMessage(content="专业版无限制成员...月费99元..."),
    HumanMessage(content="好 我要升级 但是我不知道怎么操作"),
    AIMessage(content="升级步骤如下:登录控制台→账户设置→订阅管理→选择专业版..."),
    HumanMessage(content="我找不到订阅管理这个选项!!你们页面是不是有问题!"),
    AIMessage(content="请问您使用的是网页版还是桌面客户端?不同版本的菜单位置略有不同..."),
    HumanMessage(content="网页版!我都说了三遍了找不到找不到找不到!!!"),
]

summary = generate_handoff_summary(
    test_history,
    {"session_id": "sess_001", "turn_count": 6, "consecutive_failures": 1}
)
print(summary)

输出:

【会话摘要】
核心诉求:用户想从免费版升级到专业版,但在网页版控制台中找不到"订阅管理"入口。

已尝试方案:
- AI 提供了标准升级路径(控制台→账户设置→订阅管理),用户反馈找不到该选项
- AI 追问了客户端类型(确认是网页版),但尚未给出针对性解决方案

关键信息:无订单号;使用网页版客户端

情绪状态:明显焦虑/烦躁,连续使用了感叹号和重复表达("找不到""三遍"),建议优先安抚情绪后引导操作。

这份摘要让人工客服一眼就能掌握全局,无需翻阅完整聊天记录。

完整系统组装

现在我们把所有组件——RAG 问答链、意图分类器、路由分支、记忆管理、Handoff 检测器——组装成一个完整的 CustomerServiceBot 类。

主系统类

python
import os
import time
import uuid
from typing import Dict, List, Optional
from langchain_core.messages import HumanMessage, AIMessage, SystemMessage
from langchain_core.chat_history import InMemoryChatMessageHistory
from langchain_core.runnables.history import RunnableWithMessageHistory

class CustomerServiceBot:
    def __init__(self, config: dict = None):
        self.config = config or {}
        self.rag = CustomerServiceRAG()
        self.rag.initialize()
        self.handoff_detector = HandoffDetector(
            max_failures=self.config.get("max_failures", 3),
            max_turns=self.config.get("max_turns", 20),
        )
        self.session_store: Dict[str, dict] = {}
        self.chat_history_store: Dict[str, InMemoryChatMessageHistory] = {}

    def _get_or_create_session(self, session_id: str = None) -> str:
        if session_id is None:
            session_id = f"sess_{uuid.uuid4().hex[:8]}"
        if session_id not in self.session_store:
            self.session_store[session_id] = {
                "session_id": session_id,
                "created_at": time.time(),
                "turn_count": 0,
                "consecutive_failures": 0,
                "handoff_triggered": False,
                "messages": [],
            }
            self.chat_history_store[session_id] = InMemoryChatMessageHistory()
        return session_id

    def _get_chat_history(self, session_id: str) -> list:
        store = self.chat_history_store.get(session_id)
        if not store:
            return []
        return store.messages

    def process_message(self, user_input: str, session_id: str = None) -> dict:
        session_id = self._get_or_create_session(session_id)
        session = self.session_store[session_id]
        history_store = self.chat_history_store[session_id]

        session["turn_count"] += 1
        session["last_user_input"] = user_input

        history_store.add_message(HumanMessage(content=user_input))

        handoff_decision = self.handoff_detector.check(session)
        if handoff_decision.should_handoff:
            session["handoff_triggered"] = True
            full_history = self._get_chat_history(session_id)
            summary = generate_handoff_summary(full_history, session)

            response = {
                "response": handoff_decision.message_to_user,
                "session_id": session_id,
                "intent": "handoff",
                "handoff": True,
                "handoff_reason": handoff_decision.reason.value if handoff_decision.reason else None,
                "handoff_summary": summary,
                "urgency": handoff_decision.urgency,
            }

            history_store.add_message(AIMessage(content=response["response"]))
            session["messages"].append({"role": "user", "content": user_input})
            session["messages"].append({"role": "assistant", "content": response["response"]})
            return response

        intent_result = classify_with_fallback(user_input, self._get_chat_history(session_id))
        session["last_intent"] = intent_result.intent.value

        state = {
            "user_input": user_input,
            "chat_history": self._get_chat_history(session_id),
            "intent": intent_result.intent.value,
            "entities": intent_result.extracted_entities or {},
        }

        try:
            router_response = self._route_by_intent(state)

            if router_response == "__HANDOFF__":
                session["handoff_triggered"] = True
                full_history = self._get_chat_history(session_id)
                summary = generate_handoff_summary(full_history, session)
                ai_reply = "好的,正在为您转接人工客服,请稍候..."
                response = {
                    "response": ai_reply,
                    "session_id": session_id,
                    "intent": "handoff",
                    "handoff": True,
                    "handoff_summary": summary,
                    "urgency": "normal",
                }
            else:
                session["consecutive_failures"] = 0
                response = {
                    "response": router_response,
                    "session_id": session_id,
                    "intent": intent_result.intent.value,
                    "confidence": intent_result.confidence,
                    "handoff": False,
                }

        except Exception as e:
            session["consecutive_failures"] = session.get("consecutive_failures", 0) + 1
            response = {
                "response": "抱歉,处理您的请求时遇到了一些问题。您可以换个方式再试一次,或者输入「转人工」获取帮助。",
                "session_id": session_id,
                "intent": "error",
                "handoff": False,
                "error": str(e),
            }

        history_store.add_message(AIMessage(content=response["response"]))
        session["messages"].append({"role": "user", "content": user_input})
        session["messages"].append({"role": "assistant", "content": response["response"]})

        return response

    def _route_by_intent(self, state: dict) -> str:
        intent = state.get("intent")

        handlers = {
            "product_inquiry": lambda s: self.rag.query(
                s["user_input"], s["chat_history"]
            ),
            "order_query": handle_order_query,
            "refund_request": handle_refund_request,
            "technical_issue": handle_technical_issue,
            "complaint": handle_complaint,
            "handoff_request": lambda s: "__HANDOFF__",
            "chitchat": handle_chitchat,
            "unknown": handle_unknown,
        }

        handler = handlers.get(intent, handle_unknown)
        return handler(state)

    def get_session_info(self, session_id: str) -> Optional[dict]:
        return self.session_store.get(session_id)

    def list_sessions(self) -> List[dict]:
        return [
            {"session_id": s["session_id"], "turns": s["turn_count"],
             "handoff": s["handoff_triggered"]}
            for s in self.session_store.values()
        ]

CLI 交互程序

有了主系统类之后,我们可以写一个命令行交互界面来体验完整的客服流程:

python
def run_cli():
    print("=" * 60)
    print("   CloudDesk 智能客服系统")
    print("   输入 'quit' 退出 | 输入 '/sessions' 查看所有会话")
    print("=" * 60)

    bot = CustomerServiceBot()
    current_session = None

    while True:
        try:
            user_input = input("\n您: ").strip()

            if user_input.lower() in ("quit", "exit", "q"):
                print("感谢您的咨询,再见!")
                break

            if user_input == "/sessions":
                sessions = bot.list_sessions()
                if not sessions:
                    print("当前没有活跃会话")
                else:
                    for s in sessions:
                        flag = " [已转人工]" if s["handoff"] else ""
                        print(f"  {s['session_id']} ({s['turns']}轮){flag}")
                continue

            if user_input.startswith("/switch "):
                current_session = user_input.split(maxsplit=1)[1]
                print(f"已切换到会话: {current_session}")
                continue

            result = bot.process_message(user_input, current_session)
            current_session = result["session_id"]

            print(f"\n[{result['intent'].upper()}] AI:", result["response"])

            if result.get("handoff"):
                print(f"\n{'!' * 50}")
                print(f"  ⚠️  已触发人工接管")
                print(f"  原因: {result.get('handoff_reason', '未知')}")
                print(f"  紧急度: {result.get('urgency', 'normal')}")
                if result.get("handoff_summary"):
                    print(f"\n  📋 会话摘要:")
                    print(f"  {result['handoff_summary']}")
                print(f"{'!' * 50}")

        except KeyboardInterrupt:
            print("\n\n已中断,再见!")
            break
        except Exception as e:
            print(f"\n系统错误: {e}")

if __name__ == "__main__":
    run_cli()

端到端演示

让我们模拟一次完整的客服对话,看看整个系统的运转效果:

============================================================
   CloudDesk 智能客服系统
   输入 'quit' 退出 | 输入 '/sessions' 查看所有会话
============================================================

您: 你们免费版有什么限制?

[PRODUCT_INQUIRY] AI: 根据我们的定价方案,**免费版**包含以下限制:
- 团队成员:最多 5 人
- 存储空间:2 GB
- 项目数量:最多 3 个
- API 调用:每月 1,000 次
- 技术支持:社区论坛

如果您团队规模超过 5 人或需要更多资源,可以考虑升级到**专业版(¥99/月)**。需要了解更多详情吗?

您: 专业版多少钱?

[PRODUCT_INQUIRY] AI: **专业版的定价是 ¥99/月**,相比免费版有以下优势:
- 团队成员:无限制
- 存储空间:100 GB
- API 调用:每月 50,000 次
- 额外功能:高级权限管理、审计日志、SSO 单点登录
- 技术支持:邮件支持(24 小时内响应)

升级立即生效,按剩余天数比例计费。如果您是学生,使用 .edu 邮箱注册还可享受 **5 折优惠**!

您: 我的订单 CS-20241088 怎么还没发货啊?都三天了!

[ORDER_QUERY] AI: 正在为您查询订单 **CS-20241088** 的状态,请稍候...
(此处应调用订单查询 API)

您: 如果没发货我要退款

[REFUND_REQUEST] AI: 关于退款申请,请您提供以下信息以便我们处理:
1. 您的注册邮箱或手机号
2. 需要退款的具体原因

您也可以通过以下方式提交退款申请:
- 登录 CloudDesk 控制台 → 账户设置 → 订阅管理 → 申请退款
- 或发送邮件至 support@cloud desk.example.com

你们这是什么破服务!!订单不发货退款还这么麻烦!!!

!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
  ⚠️  已触发人工接管
  原因: emotional
  紧急度: urgent

  📋 会话摘要:
  【会话摘要】
  核心诉求:用户查询订单 CS-20241088 的发货状态(已等待3天未发货),
  在得知发货延迟后提出退款诉求,对退款流程表示强烈不满。

  已尝试方案:
  - 回答了产品定价相关问题(免费版/专业版)
  - 查询了订单状态(告知需调用API)
  - 提供了退款申请的标准流程

  关键信息:订单号 CS-20241088;等待时间3天

  情绪状态:高度激动/愤怒,使用了"破服务""!!!"等强烈表达,
  强烈建议优先安排资深客服介入,先安抚情绪再处理业务问题。
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!

可以看到,系统在用户情绪从平静 → 疑虑 → 不满 → 爆发的演进过程中,始终保持了正确的意图识别和合理的回复策略,并在关键时刻准确触发了 Handoff 并生成了高质量的会话摘要。

FastAPI 服务端部署

CLI 适合开发和调试,生产环境需要一个 Web 服务。我们用 FastAPI 来部署,同时支持流式输出:

python
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from typing import Optional
import json

app = FastAPI(title="CloudDesk 智能客服 API", version="1.0.0")

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_methods=["*"],
    allow_headers=["*"],
)

bot = CustomerServiceBot()

class ChatRequest(BaseModel):
    message: str
    session_id: Optional[str] = None

class ChatResponse(BaseModel):
    response: str
    session_id: str
    intent: str
    handoff: bool = False
    handoff_summary: Optional[str] = None

@app.post("/chat", response_model=ChatResponse)
async def chat(req: ChatRequest):
    result = bot.process_message(req.message, req.session_id)
    return ChatResponse(**result)

@app.get("/sessions")
async def list_sessions():
    return {"sessions": bot.list_sessions()}

@app.get("/sessions/{session_id}")
async def get_session(session_id: str):
    info = bot.get_session_info(session_id)
    if not info:
        raise HTTPException(status_code=404, detail="Session not found")
    return info

@app.get("/health")
async def health_check():
    return {"status": "ok", "active_sessions": len(bot.session_store)}

if __name__ == "__main__":
    import uvicorn
    uvicorn.run(app, host="0.0.0.0", port=8000)

启动服务后,可以通过 HTTP 接口与客服系统交互:

bash
curl -X POST http://localhost:8000/chat \
  -H "Content-Type: application/json" \
  -d '{"message": "你们免费版支持几个人?"}'

返回:

json
{
  "response": "根据 CloudDesk 的定价方案,免费版最多支持 5 名团队成员...",
  "session_id": "sess_a3f8b2c1",
  "intent": "product_inquiry",
  "handoff": false
}

扩展方向

本章实现的智能客服系统已经具备了核心功能,但要达到生产级水平,还有几个重要的扩展方向:

第一,接入真实的外部系统。目前订单查询、退款处理都是模拟的。实际部署时需要对接公司的 CRM 系统、工单系统、支付网关等。建议通过统一的 ExternalServiceClient 封装这些接口,保持核心逻辑不变。

第二,引入评估与反馈闭环。每次对话结束后让用户评价回复质量(👍/👎),收集"坏案例"用于优化知识库和 prompt。这是第 12 章(评估与可观测性)的重点内容。

第三,A/B 测试不同的 LLM 和 Prompt。在生产环境中同时运行两个版本的模型(比如 GPT-4o-mini vs Claude Haiku),对比它们的首次解决率、平均对话轮数、用户满意度等指标。

第四,多语言支持。如果产品面向国际用户,需要在意图分类器和 RAG prompt 中加入多语言处理能力,或者在入口处先做语言检测再分流到对应语言的处理器。

第五,监控仪表盘。基于第 9 章学到的回调机制,收集每轮对话的 token 用量、响应延迟、意图分布、Handoff 率等指标,构建实时监控面板。

基于 MIT 许可发布