LangGraph实战:搭建一个带人工介入的智能客服系统

序言:当你发现AI客服遇到复杂问题时只会"车轱辘话"来回转,而用户已经气得要投诉了,你就该考虑让真人介入了。

一、那个差点搞砸的上线日

三个月前,我负责的智能客服系统正式上线。团队熬了两个月,做了意图识别、知识库检索、多轮对话,自以为万无一失。

上线第一小时,数据还行。80%的问题AI都能自己解决,用户满意度看起来也凑合。

但第二小时,问题开始冒头。

一个用户在咨询"退款流程",AI识别了意图,也调出了知识库的答案。但用户的情况比较特殊:他买的商品已经拆封了,而且超过了7天无理由退货期。AI按照标准流程回复"可以申请退款",用户填了退款申请,结果被后台驳回------拆封商品不支持退。

用户炸了。他在对话框里连发了五条消息,语气越来越冲。AI还在机械地重复"抱歉给您带来不好的体验,退款流程是......"

那一刻,运营主管在群里@我:"这种明显搞不定的,为什么不转人工?"

我无言以对。因为当时的系统架构是:用户问→AI答,答完结束。没有"判断AI搞不定"的逻辑,没有"转人工"的通道,更没有"AI和人工之间交接状态"的机制。

那天晚上,我重新梳理了需求。一个靠谱的客服系统,至少需要三种能力:

  1. 自知之明:AI知道自己什么时候搞不定,主动求助;
  2. 循环追问:信息没收集全时,能反复问用户,而不是瞎猜;
  3. 断点续传:用户聊到一半退出了,下次进来还能接着聊。

这三件事,用LangChain的Chain几乎无法实现。Chain是线性的,走完A→B→C就结束,不支持循环,不支持中途暂停等人,也不支持状态持久化。

LangGraph就是来解决这些问题的。

二、用StateGraph定义对话状态:给货车装货

LangGraph的核心思想是"状态驱动"。整个对话过程,就是一辆货车在各个站点之间跑,每个站点加工货物,最终把完整的"订单"送到终点。

我们先把这辆货车能拉什么货定义清楚。

python 复制代码
from typing import TypedDict, Annotated, List
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.memory import MemorySaver
from langgraph.types import interrupt, Command
from langchain_openai import ChatOpenAI
import operator

# 定义对话状态
class CustomerServiceState(TypedDict):
    user_input: str                    # 用户当前输入
    intent: str                        # 识别出的意图
    collected_info: Annotated[dict, operator.or_]  # 已收集的信息(如订单号、手机号)
    confidence: float                  # AI置信度(0-1)
    kb_answer: str                     # 知识库检索结果
    needs_human: bool                  # 是否需要转人工
    human_feedback: str                # 人工反馈内容
    messages: Annotated[List[dict], operator.add]  # 对话历史
    turn_count: int                    # 对话轮次,防死循环

这个TypedDict就是货车的"货舱清单"。Annotated配合operator.addoperator.or_告诉LangGraph:当新的数据进来时,是追加到列表里,还是合并到字典里。

collected_infooperator.or_,意味着每次节点返回新的字典,会自动和已有的字典合并,而不是覆盖。这样信息收集节点可以分多次补充字段,比如第一次拿到"订单号",第二次拿到"手机号",两者都会保留。

messagesoperator.add,意味着每次返回的新消息会自动追加到历史列表末尾,实现对话历史的累积。

三、设计节点:五个站点构成完整服务链

节点是图中的执行单元,每个节点只做一件事。我们的客服系统设计了五个核心节点。

节点一:意图识别

python 复制代码
def intent_recognition_node(state: CustomerServiceState) -> dict:
    """识别用户意图,并评估置信度"""
    user_input = state["user_input"]
    messages = state.get("messages", [])

    llm = ChatOpenAI(model="gpt-4o", temperature=0)

    prompt = f"""你是一位意图识别专家。根据用户输入判断意图类别和置信度。
可选意图:退款咨询、物流查询、产品咨询、投诉建议、其他。

用户输入:{user_input}
历史对话:{messages[-3:] if len(messages) > 3 else messages}

请严格按以下格式输出:
意图:<类别>
置信度:<0-1之间的小数>
缺失信息:<如果需要额外信息才能回答,列出缺失字段,否则填"无">"""

    response = llm.invoke(prompt)
    content = response.content

    # 解析输出
    intent = "其他"
    confidence = 0.5
    missing = "无"

    for line in content.split("\n"):
        if line.startswith("意图:"):
            intent = line.replace("意图:", "").strip()
        elif line.startswith("置信度:"):
            try:
                confidence = float(line.replace("置信度:", "").strip())
            except:
                pass
        elif line.startswith("缺失信息:"):
            missing = line.replace("缺失信息:", "").strip()

    return {
        "intent": intent,
        "confidence": confidence,
        "messages": [{"role": "user", "content": user_input}],
        "turn_count": state.get("turn_count", 0) + 1
    }

这个节点不仅识别意图,还评估置信度。如果用户输入很模糊(比如"那个东西怎么回事"),置信度会偏低,后续路由会把它导向"信息收集"节点,而不是直接瞎答。

节点二:信息收集

python 复制代码
def info_collection_node(state: CustomerServiceState) -> dict:
    """根据缺失信息,追问用户"""
    collected = state.get("collected_info", {})
    intent = state.get("intent", "")

    # 根据意图定义必填字段
    required_fields = {
        "退款咨询": ["订单号", "退款原因"],
        "物流查询": ["订单号"],
        "产品咨询": ["产品型号"],
        "投诉建议": ["联系方式"]
    }

    missing = []
    for field in required_fields.get(intent, []):
        if field not in collected or not collected[field]:
            missing.append(field)

    if missing:
        question = f"为了帮您处理{intent},还需要您提供以下信息:{', '.join(missing)}。请问您方便提供吗?"
        return {
            "messages": [{"role": "assistant", "content": question}],
            "collected_info": {}  # 本轮无新增,但operator.or_会保留已有的
        }

    # 信息已收集全,标记为可进入知识库检索
    return {
        "collected_info": {"_complete": True}
    }

这个节点实现了循环追问 。如果用户没给订单号,就问订单号;没给退款原因,就问退款原因。直到collected_info里集齐了所有必填字段,才放行到下一个节点。

节点三:知识库检索

python 复制代码
def kb_retrieval_node(state: CustomerServiceState) -> dict:
    """从知识库检索答案"""
    intent = state.get("intent", "")
    collected = state.get("collected_info", {})

    # 实际项目中这里调用RAG检索
    # 为演示,用模拟数据
    kb_data = {
        "退款咨询": "退款流程:1.进入我的订单 2.点击申请退款 3.选择退款原因 4.提交审核(1-3个工作日)",
        "物流查询": "物流查询:进入我的订单→查看物流→复制快递单号到快递公司官网查询",
        "产品咨询": "产品参数请查看商品详情页,或咨询专属客服获取技术白皮书",
        "投诉建议": "您的反馈已记录,客服专员将在24小时内致电回访"
    }

    answer = kb_data.get(intent, "抱歉,暂时无法回答您的问题,为您转接人工客服。")

    # 如果涉及敏感词,标记需要人工介入
    sensitive_words = ["投诉", "举报", "工商局", "媒体", "曝光", "律师"]
    needs_human = any(word in state["user_input"] for word in sensitive_words)

    return {
        "kb_answer": answer,
        "needs_human": needs_human,
        "messages": [{"role": "assistant", "content": answer}]
    }

这个节点有两个职责:一是检索知识库,二是敏感词检测 。如果用户提到了"工商局""媒体曝光"等关键词,直接标记needs_human=True,后续路由会强制转人工。

节点四:人工介入(interrupt机制)

这是整个系统最关键、也最优雅的节点。

python 复制代码
def human_handoff_node(state: CustomerServiceState) -> dict:
    """触发人工介入,暂停工作流等待人工反馈"""
    # 组装当前会话摘要,供人工客服参考
    summary = f"""【会话摘要】
用户意图:{state.get('intent', '未知')}
已收集信息:{state.get('collected_info', {})}
AI回答:{state.get('kb_answer', '无')}
转人工原因:{'敏感词触发' if any(w in state['user_input'] for w in ['投诉','举报','工商局','媒体','曝光','律师']) else '置信度低或用户要求'}
"""

    # interrupt会暂停图的执行,把控制权交还给外部系统
    # 外部系统(如客服后台)收到中断信息后,人工客服介入
    human_response = interrupt({
        "type": "human_handoff",
        "summary": summary,
        "prompt": "人工客服请处理后输入反馈,或输入'pass'让AI继续处理"
    })

    # 当人工客服在后台提交反馈后,工作流从interrupt处恢复
    return {
        "human_feedback": human_response.get("feedback", ""),
        "needs_human": False,  # 人工已处理,重置标记
        "messages": [{"role": "assistant", "content": f"【人工客服回复】{human_response.get('feedback', '')}"}]
    }

interrupt是LangGraph 0.2.31+引入的机制。它的工作方式是:

  1. 图执行到human_handoff_node时,遇到interrupt()调用;
  2. 图立即暂停,当前状态被自动保存到checkpointer;
  3. invoke()返回,外部系统(如Web后台)收到包含__interrupt__字段的结果;
  4. 人工客服在后台看到会话摘要,处理用户问题;
  5. 人工处理完后,外部系统调用graph.invoke(Command(resume={"feedback": "..."}), config)
  6. LangGraph从checkpointer读取断点状态,interrupt()返回人工输入,节点继续执行,图恢复流转。

这意味着:工作流暂停期间不占用任何运行时资源,但状态完整保留。人工客服可以过十分钟再处理,甚至换一台机器处理,流程都能无缝恢复。

节点五:结束

python 复制代码
def end_node(state: CustomerServiceState) -> dict:
    """结束节点,生成最终回复"""
    kb_answer = state.get("kb_answer", "")
    human_fb = state.get("human_feedback", "")

    if human_fb:
        final = human_fb
    else:
        final = kb_answer + "\n\n如果还有其他问题,随时问我。"

    return {
        "messages": [{"role": "assistant", "content": final}]
    }

四、配置条件边:三个红绿灯控制交通

节点定义好了,但怎么决定用户走到哪个节点?靠条件边(Conditional Edge)。

python 复制代码
def route_after_intent(state: CustomerServiceState) -> str:
    """意图识别后的路由逻辑"""
    confidence = state.get("confidence", 0)
    intent = state.get("intent", "")
    turn_count = state.get("turn_count", 0)

    # 规则1:轮次超限,强制结束,防止死循环
    if turn_count >= 10:
        return "end"

    # 规则2:置信度低于0.6,且不是信息收集阶段,转人工
    if confidence < 0.6 and state.get("collected_info", {}).get("_complete"):
        return "human"

    # 规则3:信息未收集全,进入信息收集节点
    collected = state.get("collected_info", {})
    required = {
        "退款咨询": ["订单号", "退款原因"],
        "物流查询": ["订单号"],
        "产品咨询": ["产品型号"],
        "投诉建议": ["联系方式"]
    }
    missing = any(f not in collected or not collected[f] for f in required.get(intent, []))
    if missing:
        return "collect"

    # 规则4:信息已全,进入知识库检索
    return "kb"

def route_after_kb(state: CustomerServiceState) -> str:
    """知识库检索后的路由逻辑"""
    if state.get("needs_human", False):
        return "human"
    return "end"

def route_after_human(state: CustomerServiceState) -> str:
    """人工介入后的路由逻辑"""
    # 人工反馈后,如果用户还有追问,可以回到意图识别继续
    # 这里简化为直接结束
    return "end"

三条条件边分别对应三个"红绿灯":

  • 置信度低时转人工confidence < 0.6直接进human_handoff_node,不让AI在不确定的情况下瞎答。
  • 缺必填信息时循环追问missing为True时进info_collection_node,追问到信息补齐为止。这是一个循环:信息收集→回到意图识别→再判断→如果还缺→再收集。直到_complete=True才放行。
  • 敏感词触发升级needs_human=True时强制进人工节点,无论AI多自信。

五、组装完整图:把零件拼成机器

python 复制代码
# 创建StateGraph
workflow = StateGraph(CustomerServiceState)

# 注册节点
workflow.add_node("intent", intent_recognition_node)
workflow.add_node("collect", info_collection_node)
workflow.add_node("kb", kb_retrieval_node)
workflow.add_node("human", human_handoff_node)
workflow.add_node("end", end_node)

# 设置入口
workflow.add_edge(START, "intent")

# 条件边1:意图识别后分流
workflow.add_conditional_edges(
    "intent",
    route_after_intent,
    {
        "collect": "collect",   # 缺信息,去收集
        "kb": "kb",            # 信息全了,去检索
        "human": "human",      # 置信度低,转人工
        "end": "end"           # 超限,结束
    }
)

# 信息收集后,回到意图识别重新判断(循环)
workflow.add_edge("collect", "intent")

# 条件边2:知识库检索后分流
workflow.add_conditional_edges(
    "kb",
    route_after_kb,
    {
        "human": "human",      # 敏感词触发,转人工
        "end": "end"           # 正常结束
    }
)

# 人工介入后结束
workflow.add_edge("human", "end")

# 编译图,加上checkpointer实现持久化
memory = MemorySaver()  # 开发用内存,生产环境换SqliteSaver或PostgresSaver
app = workflow.compile(checkpointer=memory)

这段代码的精髓在于:图的拓扑结构一眼就能看懂。从START进入意图识别,然后有三个分支:缺信息→收集→回到意图识别(循环);信息全了→检索→结束或转人工;置信度低→直接转人工。

六、Checkpointer:让对话有"记忆",让中断能"续传"

前面的代码里有一行memory = MemorySaver(),这行代码背后是整个LangGraph最值钱的能力之一:状态持久化

为什么需要持久化?

想象一个场景:用户和AI客服聊了5轮,收集了订单号和退款原因,AI正在检索知识库。这时候,服务器突然重启了。

如果没有持久化,用户刷新页面后重新进入对话,AI会问:"请问您要咨询什么问题?"------用户崩溃了:"我刚才不是已经说了订单号了吗?"

LangGraph的checkpointer在每个super-step(节点执行完)自动保存状态快照 。这些快照按thread_id归档,同一个thread_id的对话,无论调用多少次、无论中间中断多久,LangGraph都会从最新的checkpoint恢复状态,继续执行。

三种持久化方案

开发环境:InMemorySaver

python 复制代码
from langgraph.checkpoint.memory import InMemorySaver
memory = InMemorySaver()
app = workflow.compile(checkpointer=memory)

状态存在内存里,进程退出就丢。适合调试。

测试/小生产环境:SqliteSaver

python 复制代码
import sqlite3
from langgraph.checkpoint.sqlite import SqliteSaver

conn = sqlite3.connect("customer_service.db", check_same_thread=False)
memory = SqliteSaver(conn=conn)
app = workflow.compile(checkpointer=memory)

状态存在本地SQLite文件里,程序重启后还能恢复。适合中小规模部署。

生产环境:PostgresSaver

python 复制代码
from langgraph.checkpoint.postgres import PostgresSaver
import psycopg

conn = psycopg.connect(
    "postgresql://user:pass@localhost:5432/cs_db",
    autocommit=True,
    row_factory=psycopg.rows.dict_row
)
checkpointer = PostgresSaver(conn)
checkpointer.setup()  # 首次运行创建表结构
app = workflow.compile(checkpointer=checkpointer)

PostgreSQL支持高并发、事务安全、集群部署,是生产环境的首选。

断点续传的实战用法

python 复制代码
# 第一次调用:用户问退款
config = {"configurable": {"thread_id": "user_9527"}}
result = app.invoke(
    {"user_input": "我要退款", "collected_info": {}, "turn_count": 0},
    config
)

# 假设执行到info_collection_node,AI追问"请提供订单号"
# 用户关闭页面,去翻找订单号

# 十分钟后,用户带着订单号回来了
# 再次调用,同一个thread_id
result = app.invoke(
    {"user_input": "订单号是TB20240615001"},
    config  # 同一个thread_id
)

# LangGraph会自动加载user_9527的历史状态
# 发现上一轮在collect节点等待订单号
# 新的输入被合并到collected_info里
# 然后自动流转到下一个节点

这就是断点续传的魔力:用户不需要重复已经说过的话,系统记得你们聊到哪了。

查看历史状态

python 复制代码
# 查看当前会话的最新状态
snapshot = app.get_state(config)
print(f"当前状态:{snapshot.values}")
print(f"下一个待执行节点:{snapshot.next}")

# 查看完整历史
history = list(app.get_state_history(config))
for i, snap in enumerate(history):
    print(f"第{i}步:{snap.values.get('intent', 'N/A')} → 下一步:{snap.next}")

get_state_history返回所有checkpoint,你可以像看录像一样回放整个对话流程,排查哪一步出了问题。

七、完整运行示例

把上面的代码串起来,我们模拟一次完整的对话:

python 复制代码
# 场景:用户咨询退款,但没给订单号
config = {"configurable": {"thread_id": "demo_001"}}

# 第1轮:用户输入
state = app.invoke({
    "user_input": "我要退款",
    "collected_info": {},
    "turn_count": 0
}, config)

# 输出:AI追问"请提供订单号和退款原因"
print(state["messages"][-1]["content"])

# 第2轮:用户提供订单号(模拟断点续传)
state = app.invoke({
    "user_input": "订单号是TB20240615001"
}, config)

# AI继续追问退款原因
print(state["messages"][-1]["content"])

# 第3轮:用户说明原因
state = app.invoke({
    "user_input": "商品质量有问题,屏幕有坏点"
}, config)

# 信息收集全了,进入知识库检索,返回退款流程
print(state["messages"][-1]["content"])

# 第4轮:用户不满意,提到"我要投诉到工商局"
state = app.invoke({
    "user_input": "你们处理太慢了,我要投诉到工商局"
}, config)

# 敏感词触发,进入human_handoff_node
# interrupt暂停,等待人工介入
if "__interrupt__" in str(state):
    print("【系统】已转人工客服,请等待...")

    # 模拟人工客服处理完后恢复
    from langgraph.types import Command
    final = app.invoke(
        Command(resume={"feedback": "您好,我是客服主管,已为您加急处理,预计2小时内退款到账。"}),
        config
    )
    print(final["messages"][-1]["content"])

这个示例展示了系统的全部能力:

  1. 循环追问:用户没给全信息时,AI反复问;
  2. 断点续传:用户中途离开再回来,对话无缝衔接;
  3. 敏感词升级:提到"工商局"自动转人工;
  4. interrupt协作:人工处理完后,系统恢复执行,把人工反馈带给用户。

八、写在最后:从"问答机器人"到"服务系统"

写完这个项目,我对LangGraph最大的感受是:它让AI客服从"问答机器人"进化成了"服务系统"。

问答机器人的逻辑是:问→答,结束。服务系统的逻辑是:识别→收集→判断→检索→人工→结束,中间有循环、有分支、有暂停、有恢复。

LangGraph的StateGraph让我们能精确控制这个流程的每一个分支。条件边不是黑盒,是写死在代码里的业务规则。interrupt不是魔法,是显式的暂停点。checkpointer不是抽象概念,是实实在在的状态快照。

如果你还在用Chain硬凑多轮对话,或者用Agent黑盒赌运气,不妨试试LangGraph。当你第一次画出一张完整的客服流程图,看到用户在不同节点之间流转,看到断点续传无缝恢复,看到人工客服优雅地介入------你会明白:这不是又一个框架,这是AI服务工程的成人礼。

相关推荐
测试员周周1 小时前
【Appium 系列】第04节-Page Object 模式 — BasePage 基类设计
开发语言·数据库·人工智能·python·语言模型·appium·web app
学习论之费曼学习法1 小时前
AI 入门 30 天挑战 - Day 29 - 面试准备指南
人工智能·面试·职场和发展
爱学习的徐徐1 小时前
监督学习核心算法:单变量线性回归
人工智能·机器学习
JavaGuide1 小时前
万字详解 Harness Engineering:六层架构、上下文管理与一线团队实战
人工智能·ai编程
java1234_小锋1 小时前
Spring AI 2.0 开发Java Agent智能体 - 工具调用(Function Calling / Tools)
java·人工智能·spring
nix.gnehc1 小时前
AI Agent 设计范式的演进之路:从工具调用到多智能体协作
人工智能·agent
小辰记事本1 小时前
RDMA:AI算力集群的“网络命脉”
网络·人工智能·网络协议·rdma
keineahnung23451 小时前
PyTorch SymNode 的 _is_contiguous 從何而來?──sizes_strides_impl 實作詳解
人工智能·pytorch·python·深度学习
测试员周周1 小时前
【Appium 系列】第02节-环境搭建 — Android + iOS 双平台环境配置
开发语言·人工智能·功能测试·appium·自动化·测试用例·web app