文章目录
- [第 11 章:全面实践项目:智能客服工单系统](#第 11 章:全面实践项目:智能客服工单系统)
-
- [11.1 本章目标](#11.1 本章目标)
- [11.2 项目概述](#11.2 项目概述)
-
- [11.2.1 系统描述](#11.2.1 系统描述)
- [11.2.2 系统架构图](#11.2.2 系统架构图)
- [11.2.3 项目结构](#11.2.3 项目结构)
- [11.3 核心概念(图文讲解)](#11.3 核心概念(图文讲解))
-
- [11.3.1 生动类比:智能客服中心](#11.3.1 生动类比:智能客服中心)
- [11.3.2 意图识别流程图](#11.3.2 意图识别流程图)
- [11.4 实战:完整代码](#11.4 实战:完整代码)
- [11.5 API 速查](#11.5 API 速查)
- [11.6 单元测试](#11.6 单元测试)
-
- [11.6.1 tests/test_workflow.py](#11.6.1 tests/test_workflow.py)
- [11.6.2 requirements.txt](#11.6.2 requirements.txt)
- [11.6.3 Dockerfile](#11.6.3 Dockerfile)
- [11.6.4 docker-compose.yml](#11.6.4 docker-compose.yml)
- [11.7 常见错误与避坑指南](#11.7 常见错误与避坑指南)
-
- [错误 1:忘记在 Entrypoint 配置 checkpointer 就使用 interrupt()](#错误 1:忘记在 Entrypoint 配置 checkpointer 就使用 interrupt())
- [错误 2:恢复执行时使用了错误的 thread_id](#错误 2:恢复执行时使用了错误的 thread_id)
- [错误 3:RAG 检索时没有考虑意图分类,检索结果不准确](#错误 3:RAG 检索时没有考虑意图分类,检索结果不准确)
- [错误 4:工单创建后没有更新状态,导致工单一直处于 OPEN 状态](#错误 4:工单创建后没有更新状态,导致工单一直处于 OPEN 状态)
- [错误 5:没有对用户输入做验证,导致恶意输入或异常输入](#错误 5:没有对用户输入做验证,导致恶意输入或异常输入)
- [11.8 最佳实践总结](#11.8 最佳实践总结)
- [结语:从零到 Hero 的旅程](#结语:从零到 Hero 的旅程)
第 11 章:全面实践项目:智能客服工单系统

11.1 本章目标
学完本章后,你将能够:
- 从零构建一个完整的生产级智能客服工单系统
- 掌握意图识别、RAG 检索、工单创建、人工客服介入等核心能力
- 实现多 Agent 协作(技术支持 + 账单咨询 + 一般客服)的完整工作流
- 使用 Checkpointing 实现对话历史持久化
- 编写单元测试和 Docker 部署配置
- 将前 10 章所学的所有知识点融会贯通
11.2 项目概述
11.2.1 系统描述
本章将构建一个智能客服工单系统(Smart Ticket System),它的核心功能是:
- 用户意图识别:自动识别用户咨询的类型(技术问题、账单问题、一般咨询)
- 知识库检索(RAG):从知识库中检索相关文档,辅助回答
- 工单创建与路由:根据意图自动创建工单,并路由到对应的客服 Agent
- 人工客服介入(Human-in-the-loop):当 AI 无法解决时,自动转接人工客服
- 多 Agent 协作:技术支持 Agent、账单咨询 Agent、一般客服 Agent 各司其职
- 对话历史持久化:通过 Checkpointing 保存完整的对话历史
11.2.2 系统架构图
#mermaid-svg-OClaFPYaby4b4YLn{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-OClaFPYaby4b4YLn .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-OClaFPYaby4b4YLn .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-OClaFPYaby4b4YLn .error-icon{fill:#552222;}#mermaid-svg-OClaFPYaby4b4YLn .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-OClaFPYaby4b4YLn .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-OClaFPYaby4b4YLn .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-OClaFPYaby4b4YLn .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-OClaFPYaby4b4YLn .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-OClaFPYaby4b4YLn .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-OClaFPYaby4b4YLn .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-OClaFPYaby4b4YLn .marker{fill:#333333;stroke:#333333;}#mermaid-svg-OClaFPYaby4b4YLn .marker.cross{stroke:#333333;}#mermaid-svg-OClaFPYaby4b4YLn svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-OClaFPYaby4b4YLn p{margin:0;}#mermaid-svg-OClaFPYaby4b4YLn .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-OClaFPYaby4b4YLn .cluster-label text{fill:#333;}#mermaid-svg-OClaFPYaby4b4YLn .cluster-label span{color:#333;}#mermaid-svg-OClaFPYaby4b4YLn .cluster-label span p{background-color:transparent;}#mermaid-svg-OClaFPYaby4b4YLn .label text,#mermaid-svg-OClaFPYaby4b4YLn span{fill:#333;color:#333;}#mermaid-svg-OClaFPYaby4b4YLn .node rect,#mermaid-svg-OClaFPYaby4b4YLn .node circle,#mermaid-svg-OClaFPYaby4b4YLn .node ellipse,#mermaid-svg-OClaFPYaby4b4YLn .node polygon,#mermaid-svg-OClaFPYaby4b4YLn .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-OClaFPYaby4b4YLn .rough-node .label text,#mermaid-svg-OClaFPYaby4b4YLn .node .label text,#mermaid-svg-OClaFPYaby4b4YLn .image-shape .label,#mermaid-svg-OClaFPYaby4b4YLn .icon-shape .label{text-anchor:middle;}#mermaid-svg-OClaFPYaby4b4YLn .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-OClaFPYaby4b4YLn .rough-node .label,#mermaid-svg-OClaFPYaby4b4YLn .node .label,#mermaid-svg-OClaFPYaby4b4YLn .image-shape .label,#mermaid-svg-OClaFPYaby4b4YLn .icon-shape .label{text-align:center;}#mermaid-svg-OClaFPYaby4b4YLn .node.clickable{cursor:pointer;}#mermaid-svg-OClaFPYaby4b4YLn .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-OClaFPYaby4b4YLn .arrowheadPath{fill:#333333;}#mermaid-svg-OClaFPYaby4b4YLn .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-OClaFPYaby4b4YLn .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-OClaFPYaby4b4YLn .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-OClaFPYaby4b4YLn .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-OClaFPYaby4b4YLn .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-OClaFPYaby4b4YLn .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-OClaFPYaby4b4YLn .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-OClaFPYaby4b4YLn .cluster text{fill:#333;}#mermaid-svg-OClaFPYaby4b4YLn .cluster span{color:#333;}#mermaid-svg-OClaFPYaby4b4YLn div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-OClaFPYaby4b4YLn .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-OClaFPYaby4b4YLn rect.text{fill:none;stroke-width:0;}#mermaid-svg-OClaFPYaby4b4YLn .icon-shape,#mermaid-svg-OClaFPYaby4b4YLn .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-OClaFPYaby4b4YLn .icon-shape p,#mermaid-svg-OClaFPYaby4b4YLn .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-OClaFPYaby4b4YLn .icon-shape .label rect,#mermaid-svg-OClaFPYaby4b4YLn .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-OClaFPYaby4b4YLn .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-OClaFPYaby4b4YLn .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-OClaFPYaby4b4YLn :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 持久化层
多 Agent 协作层
Agent 引擎层
接入层
用户层
技术问题
账单问题
一般咨询
AI 可解决
AI 无法解决
用户
FastAPI 服务
Entrypoint
客服工作流入口
Task: 意图识别
classify_intent
Task: RAG 检索
search_knowledge_base
Task: 自动回复
generate_response
Task: 创建工单
create_ticket
Task: 人工介入
wait_for_human
Supervisor
路由决策
技术支持 Agent
Tech Support
账单咨询 Agent
Billing Support
一般客服 Agent
General Support
Checkpointer
对话历史
知识库
文档检索
工单数据库
SQLite
11.2.3 项目结构
smart_ticket_system/
├── main.py # 主程序:Agent 工作流定义
├── models.py # 数据模型:State 定义
├── knowledge_base.py # 知识库:文档索引和检索
├── agents.py # 多 Agent 定义
├── api.py # FastAPI 服务
├── tests/
│ └── test_workflow.py # 单元测试
├── requirements.txt # 依赖清单
├── Dockerfile # Docker 部署配置
└── docker-compose.yml # Docker Compose 配置
11.3 核心概念(图文讲解)
11.3.1 生动类比:智能客服中心
想象你打电话给一家大公司的客服热线。你的体验是这样的:
- IVR 语音导航 :你听到「技术问题请按 1,账单问题请按 2,其他请按 3」------这就是意图识别
- AI 自助查询 :系统先尝试用知识库自动回答你的问题------这就是 RAG 检索
- 转接人工 :如果 AI 解决不了,系统说「正在为您转接人工客服」------这就是 Human-in-the-loop
- 工单记录 :系统记录了你的问题编号、处理进度------这就是工单创建与路由
- 专业客服 :技术问题转到技术部门,账单问题转到财务部门------这就是多 Agent 协作
| 呼叫中心概念 | 我们的系统 | LangGraph 实现 |
|---|---|---|
| IVR 语音导航 | 意图识别 | classify_intent Task |
| 知识库查询 | RAG 检索 | search_knowledge_base Task |
| 转接人工 | Human-in-the-loop | interrupt() + Command(resume=...) |
| 工单记录 | 工单创建 | create_ticket Task |
| 专业客服 | 多 Agent 协作 | Supervisor 模式路由 |
11.3.2 意图识别流程图
多Agent路由 人工介入 创建工单 自动回复 RAG检索 意图识别 Entrypoint 用户 多Agent路由 人工介入 创建工单 自动回复 RAG检索 意图识别 Entrypoint 用户 #mermaid-svg-Nczv1VHHRYwRkfCP{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Nczv1VHHRYwRkfCP .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Nczv1VHHRYwRkfCP .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Nczv1VHHRYwRkfCP .error-icon{fill:#552222;}#mermaid-svg-Nczv1VHHRYwRkfCP .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Nczv1VHHRYwRkfCP .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Nczv1VHHRYwRkfCP .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Nczv1VHHRYwRkfCP .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Nczv1VHHRYwRkfCP .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Nczv1VHHRYwRkfCP .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Nczv1VHHRYwRkfCP .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Nczv1VHHRYwRkfCP .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Nczv1VHHRYwRkfCP .marker.cross{stroke:#333333;}#mermaid-svg-Nczv1VHHRYwRkfCP svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Nczv1VHHRYwRkfCP p{margin:0;}#mermaid-svg-Nczv1VHHRYwRkfCP .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-Nczv1VHHRYwRkfCP text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-Nczv1VHHRYwRkfCP .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-Nczv1VHHRYwRkfCP .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-Nczv1VHHRYwRkfCP .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-Nczv1VHHRYwRkfCP .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-Nczv1VHHRYwRkfCP #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-Nczv1VHHRYwRkfCP .sequenceNumber{fill:white;}#mermaid-svg-Nczv1VHHRYwRkfCP #sequencenumber{fill:#333;}#mermaid-svg-Nczv1VHHRYwRkfCP #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-Nczv1VHHRYwRkfCP .messageText{fill:#333;stroke:none;}#mermaid-svg-Nczv1VHHRYwRkfCP .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-Nczv1VHHRYwRkfCP .labelText,#mermaid-svg-Nczv1VHHRYwRkfCP .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-Nczv1VHHRYwRkfCP .loopText,#mermaid-svg-Nczv1VHHRYwRkfCP .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-Nczv1VHHRYwRkfCP .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-Nczv1VHHRYwRkfCP .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-Nczv1VHHRYwRkfCP .noteText,#mermaid-svg-Nczv1VHHRYwRkfCP .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-Nczv1VHHRYwRkfCP .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-Nczv1VHHRYwRkfCP .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-Nczv1VHHRYwRkfCP .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-Nczv1VHHRYwRkfCP .actorPopupMenu{position:absolute;}#mermaid-svg-Nczv1VHHRYwRkfCP .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-Nczv1VHHRYwRkfCP .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-Nczv1VHHRYwRkfCP .actor-man circle,#mermaid-svg-Nczv1VHHRYwRkfCP line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-Nczv1VHHRYwRkfCP :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} alt AI 无法解决 "我的订单支付失败了" classify_intent() intent="billing", confidence=0.95 search_knowledge_base("支付失败") 检索到 3 条相关文档 generate_response("支付失败", documents) auto_reply + can_resolve=False create_ticket("支付失败", "billing") ticket_id="TK-20260712-001" wait_for_human(ticket) human_response route_to_agent(intent="billing") 路由到账单咨询 Agent billing_agent_response 最终回复 + 工单编号
11.4 实战:完整代码
11.4.1 models.py --- 数据模型
python
# ============================================================
# models.py --- 智能客服工单系统的数据模型
# ============================================================
from pydantic import BaseModel, Field # 导入 Pydantic 数据模型
from typing import Annotated, List, Optional, Dict, Any # 导入类型注解
import operator # 导入 operator 模块,用于 reducer
from enum import Enum # 导入枚举类型
# ---- 意图枚举 ----
class IntentType(str, Enum):
"""用户意图类型枚举
使用 Enum 确保意图类型是有限且确定的,避免拼写错误。
"""
TECHNICAL = "technical" # 技术问题:如系统故障、API 报错
BILLING = "billing" # 账单问题:如支付失败、退款、发票
GENERAL = "general" # 一般咨询:如产品使用、功能询问
# ---- 工单状态枚举 ----
class TicketStatus(str, Enum):
"""工单状态枚举"""
OPEN = "open" # 已创建,等待处理
IN_PROGRESS = "in_progress" # 处理中
WAITING_HUMAN = "waiting_human" # 等待人工介入
RESOLVED = "resolved" # 已解决
CLOSED = "closed" # 已关闭
# ---- 工单模型 ----
class Ticket(BaseModel):
"""工单数据模型
每个工单代表一个用户的客服请求,包含完整的问题信息和处理状态。
"""
ticket_id: str = Field(default="", description="工单编号,格式:TK-YYYYMMDD-NNN")
intent: IntentType = Field(default=IntentType.GENERAL, description="意图类型")
subject: str = Field(default="", description="工单主题")
description: str = Field(default="", description="问题详细描述")
status: TicketStatus = Field(default=TicketStatus.OPEN, description="工单状态")
priority: int = Field(default=3, description="优先级:1-最高,5-最低")
created_at: str = Field(default="", description="创建时间")
assigned_agent: str = Field(default="", description="分配的客服 Agent")
resolution: str = Field(default="", description="解决方案")
# ---- 知识库文档模型 ----
class KnowledgeDocument(BaseModel):
"""知识库文档模型"""
doc_id: str = Field(default="", description="文档 ID")
title: str = Field(default="", description="文档标题")
content: str = Field(default="", description="文档内容")
category: IntentType = Field(default=IntentType.GENERAL, description="文档分类")
tags: List[str] = Field(default_factory=list, description="标签列表")
# ---- 主 State 模型 ----
class TicketSystemState(BaseModel):
"""智能客服工单系统的主状态
这个 State 贯穿整个工作流,所有 Task 共享和修改它。
"""
# 对话历史(使用 operator.add 作为 reducer,新消息追加到列表)
messages: Annotated[List[Dict[str, Any]], operator.add] = Field(
default_factory=list,
description="完整对话历史,每条消息包含 role 和 content",
)
# 用户输入
user_input: str = Field(default="", description="当前用户输入")
# 意图识别结果
intent: IntentType = Field(default=IntentType.GENERAL, description="识别的用户意图")
intent_confidence: float = Field(default=0.0, description="意图识别的置信度")
# RAG 检索结果
retrieved_docs: List[KnowledgeDocument] = Field(
default_factory=list,
description="从知识库检索到的相关文档",
)
# 自动回复
auto_reply: str = Field(default="", description="AI 自动生成的回复")
can_resolve: bool = Field(default=True, description="AI 是否能解决此问题")
# 工单
ticket: Optional[Ticket] = Field(default=None, description="创建的工单")
# 人工介入
human_response: str = Field(default="", description="人工客服的回复")
needs_human: bool = Field(default=False, description="是否需要人工介入")
# 多 Agent 协作
agent_response: str = Field(default="", description="专业 Agent 的回复")
# 最终输出
final_output: str = Field(default="", description="最终返回给用户的回复")
11.4.2 knowledge_base.py --- 知识库
python
# ============================================================
# knowledge_base.py --- 知识库(简化版 RAG)
# 在生产环境中,应替换为 Chroma/Pinecone/Weaviate 等向量数据库
# ============================================================
from models import KnowledgeDocument, IntentType # 导入数据模型
from typing import List # 导入类型注解
# ============================================================
# 模拟知识库(生产环境替换为向量数据库 + 嵌入模型)
# ============================================================
# 这里我们使用一个简单的文档列表来模拟知识库
# 在实际生产环境中,你需要:
# 1. 使用嵌入模型(如 text-embedding-3-small)将文档转换为向量
# 2. 将向量存储在 Chroma/Pinecone/Weaviate 中
# 3. 使用余弦相似度或欧几里得距离进行语义搜索
KNOWLEDGE_DOCS: List[KnowledgeDocument] = [
# ---- 技术问题文档 ----
KnowledgeDocument(
doc_id="KB-TECH-001",
title="API 连接超时问题排查",
content="如果 API 连接超时,请检查:1. 网络连接是否正常;2. API Key 是否有效;3. 请求频率是否超过限制(默认 60 次/分钟);4. 服务器是否正常运行。如果问题持续,请提供错误日志。",
category=IntentType.TECHNICAL,
tags=["API", "超时", "连接", "错误排查"],
),
KnowledgeDocument(
doc_id="KB-TECH-002",
title="系统 500 错误处理指南",
content="遇到 500 错误通常表示服务器内部错误。处理步骤:1. 刷新页面重试;2. 清除浏览器缓存;3. 检查请求参数是否正确;4. 如果持续出现,请记录错误时间和操作步骤,提交工单给技术团队。",
category=IntentType.TECHNICAL,
tags=["500", "服务器错误", "错误排查"],
),
KnowledgeDocument(
doc_id="KB-TECH-003",
title="SDK 安装与配置常见问题",
content="SDK 安装失败常见原因:1. Python 版本不兼容(需要 3.10+);2. pip 版本过旧(运行 pip install --upgrade pip);3. 依赖冲突(使用虚拟环境 venv);4. 网络问题(使用国内镜像源)。",
category=IntentType.TECHNICAL,
tags=["SDK", "安装", "配置", "Python"],
),
# ---- 账单问题文档 ----
KnowledgeDocument(
doc_id="KB-BILL-001",
title="支付失败常见原因与解决方案",
content="支付失败可能的原因:1. 银行卡余额不足;2. 银行卡已过期;3. 银行风控拦截;4. 支付限额超限。解决方案:1. 检查银行卡状态;2. 尝试其他支付方式;3. 联系发卡银行;4. 如果是系统问题,请提交工单处理。",
category=IntentType.BILLING,
tags=["支付失败", "银行卡", "风控", "退款"],
),
KnowledgeDocument(
doc_id="KB-BILL-002",
title="退款政策与流程",
content="退款政策:1. 购买后 7 天内可全额退款;2. 7-30 天按比例退款;3. 超过 30 天不可退款。退款流程:登录账户 → 订单管理 → 选择订单 → 申请退款 → 填写原因 → 提交。退款通常在 3-5 个工作日内到账。",
category=IntentType.BILLING,
tags=["退款", "退款政策", "退款流程", "订单"],
),
KnowledgeDocument(
doc_id="KB-BILL-003",
title="发票开具指南",
content="发票开具流程:1. 登录账户进入订单详情;2. 点击「申请发票」;3. 填写发票抬头和税号;4. 选择发票类型(电子/纸质);5. 提交申请。电子发票一般在 1 小时内发送到注册邮箱,纸质发票 3-5 个工作日寄出。",
category=IntentType.BILLING,
tags=["发票", "电子发票", "纸质发票", "税号"],
),
# ---- 一般咨询文档 ----
KnowledgeDocument(
doc_id="KB-GEN-001",
title="产品功能介绍",
content="我们的产品提供以下核心功能:1. 智能数据分析(支持 CSV/Excel/JSON 导入);2. 可视化图表(支持 20+ 图表类型);3. 团队协作(实时协作编辑);4. API 集成(RESTful API 和 SDK);5. 自动化报告(定时生成和发送)。",
category=IntentType.GENERAL,
tags=["产品介绍", "功能", "数据分析", "图表"],
),
KnowledgeDocument(
doc_id="KB-GEN-002",
title="账号注册与登录问题",
content="注册流程:1. 访问官网点击注册;2. 输入邮箱和密码;3. 验证邮箱;4. 完成注册。如果收不到验证邮件,请检查垃圾邮件箱,或联系客服。登录问题:如果忘记密码,点击「忘记密码」重置。如果账号被锁定,等待 15 分钟后重试。",
category=IntentType.GENERAL,
tags=["注册", "登录", "账号", "密码", "验证"],
),
KnowledgeDocument(
doc_id="KB-GEN-003",
title="价格方案与套餐对比",
content="我们提供三种套餐:1. 免费版(基础功能,每月 1000 次 API 调用);2. 专业版(¥99/月,完整功能,每月 10000 次 API 调用);3. 企业版(¥499/月,全部功能 + 专属支持,无限制 API 调用)。所有套餐均支持 7 天免费试用。",
category=IntentType.GENERAL,
tags=["价格", "套餐", "免费", "专业版", "企业版"],
),
]
# ============================================================
# 简易关键词检索函数(生产环境替换为语义搜索)
# ============================================================
def search_knowledge(query: str, top_k: int = 3) -> List[KnowledgeDocument]:
"""基于关键词匹配的简易检索函数
在生产环境中,应替换为向量相似度搜索(语义搜索)。
这里使用简单的关键词匹配作为演示。
参数:
query: 用户查询文本
top_k: 返回的最相关文档数量
返回:
匹配的文档列表,按匹配度排序
"""
# 将查询转为小写,便于匹配
query_lower = query.lower()
# 将查询拆分为关键词列表
query_keywords = set(query_lower.split())
# 计算每个文档的匹配分数
scored_docs = []
for doc in KNOWLEDGE_DOCS:
score = 0 # 匹配分数初始化
# 检查标题匹配
if any(kw in doc.title.lower() for kw in query_keywords):
score += 3 # 标题匹配权重最高
# 检查标签匹配
for tag in doc.tags:
if tag.lower() in query_lower:
score += 2 # 标签匹配权重次之
# 检查内容关键词匹配
content_lower = doc.content.lower()
for kw in query_keywords:
if len(kw) > 1 and kw in content_lower: # 忽略单字匹配
score += 1 # 内容匹配权重最低
if score > 0:
scored_docs.append((score, doc))
# 按分数降序排序,取 top_k 个
scored_docs.sort(key=lambda x: x[0], reverse=True)
return [doc for score, doc in scored_docs[:top_k]]
11.4.3 agents.py --- 多 Agent 定义
python
# ============================================================
# agents.py --- 多 Agent 协作层
# 使用 Supervisor 模式管理三个专业 Agent
# ============================================================
from langgraph.func import entrypoint, task # 导入 LangGraph Functional API
from langgraph.types import Command # 导入 Command 类型
from langgraph.checkpoint.memory import MemorySaver # 导入内存检查点
from langchain_openai import ChatOpenAI # 导入 LLM
from models import TicketSystemState, IntentType, Ticket, TicketStatus # 导入数据模型
from knowledge_base import search_knowledge # 导入知识库检索函数
import os # 导入操作系统模块
# ============================================================
# 初始化 LLM 和检查点
# ============================================================
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0.7) # 初始化 LLM
checkpointer = MemorySaver() # 初始化检查点存储(生产环境替换为 SqliteSaver)
# ============================================================
# 定义三个专业 Agent(每个都是独立的 @entrypoint 子图)
# ============================================================
# ---- Agent 1:技术支持 ----
@entrypoint()
def tech_support_agent(state: TicketSystemState) -> TicketSystemState:
"""技术支持 Agent:专门处理技术问题
当用户的问题涉及技术故障、API 错误、系统问题等时,
Supervisor 会将任务路由到这个 Agent。
"""
# 构建系统提示词
system_prompt = (
"你是一位经验丰富的技术支持工程师。你的职责是:\n"
"1. 分析用户遇到的技术问题\n"
"2. 提供详细的排查步骤和解决方案\n"
"3. 如果问题超出了你的能力范围,建议用户升级到高级支持\n"
"4. 使用专业但易懂的语言回复用户\n"
"请基于以下知识库内容回答问题。"
)
# 如果工单存在,加入工单上下文
ticket_context = ""
if state.ticket:
ticket_context = f"\n当前工单编号:{state.ticket.ticket_id}\n工单主题:{state.ticket.subject}"
# 构建完整消息
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": f"用户问题:{state.user_input}\n{ticket_context}"},
]
# 调用 LLM 生成回复
response = llm.invoke(messages)
# 更新 state
state.agent_response = response.content
state.messages.append({"role": "assistant", "content": response.content})
return state
# ---- Agent 2:账单咨询 ----
@entrypoint()
def billing_support_agent(state: TicketSystemState) -> TicketSystemState:
"""账单咨询 Agent:专门处理支付、退款、发票等账单问题"""
system_prompt = (
"你是一位专业的账单客服专员。你的职责是:\n"
"1. 处理用户的支付、退款、发票等账单相关问题\n"
"2. 准确解释退款政策和流程\n"
"3. 帮助用户理解账单明细\n"
"4. 对于需要人工处理的操作(如手动退款),告知用户预期处理时间\n"
"请以专业、耐心的态度回复用户。"
)
ticket_context = ""
if state.ticket:
ticket_context = f"\n当前工单编号:{state.ticket.ticket_id}\n工单主题:{state.ticket.subject}"
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": f"用户问题:{state.user_input}\n{ticket_context}"},
]
response = llm.invoke(messages)
state.agent_response = response.content
state.messages.append({"role": "assistant", "content": response.content})
return state
# ---- Agent 3:一般客服 ----
@entrypoint()
def general_support_agent(state: TicketSystemState) -> TicketSystemState:
"""一般客服 Agent:处理产品咨询、账号问题等一般性问题"""
system_prompt = (
"你是一位专业的一般客服代表。你的职责是:\n"
"1. 回答用户关于产品功能、使用方法的咨询\n"
"2. 帮助用户解决账号注册、登录等问题\n"
"3. 介绍产品价格方案和套餐对比\n"
"4. 以友好、热情的态度服务每一位用户\n"
"如果用户的问题涉及技术或账单,请告知用户你会帮忙转接。"
)
ticket_context = ""
if state.ticket:
ticket_context = f"\n当前工单编号:{state.ticket.ticket_id}\n工单主题:{state.ticket.subject}"
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": f"用户问题:{state.user_input}\n{ticket_context}"},
]
response = llm.invoke(messages)
state.agent_response = response.content
state.messages.append({"role": "assistant", "content": response.content})
return state
# ============================================================
# Agent 路由函数:根据意图类型决定使用哪个 Agent
# ============================================================
def route_to_agent(state: TicketSystemState) -> TicketSystemState:
"""根据意图类型路由到对应的专业 Agent
这是 Supervisor 模式的核心路由逻辑。
规则:
- TECHNICAL(技术问题) → tech_support_agent
- BILLING(账单问题) → billing_support_agent
- GENERAL(一般咨询) → general_support_agent
"""
# 根据意图类型选择对应的 Agent
if state.intent == IntentType.TECHNICAL:
result = tech_support_agent(state).result() # 调用技术支持 Agent
elif state.intent == IntentType.BILLING:
result = billing_support_agent(state).result() # 调用账单咨询 Agent
else:
result = general_support_agent(state).result() # 调用一般客服 Agent
return result
11.4.4 main.py --- 主程序
python
# ============================================================
# main.py --- 智能客服工单系统主程序
# 这是系统的核心,将所有模块串联成完整的工作流
# 保存为 main.py 后运行:python main.py
# 运行前请确保已设置:export OPENAI_API_KEY="your-key"
# ============================================================
import os # 导入操作系统模块
import uuid # 导入 UUID 模块,用于生成唯一 ID
from datetime import datetime # 导入日期时间模块
from typing import Optional # 导入类型注解
# ---- LangGraph 核心 ----
from langgraph.func import entrypoint, task # Functional API 核心
from langgraph.types import Command, interrupt # Command 和 interrupt
from langgraph.checkpoint.memory import MemorySaver # 检查点存储
# ---- LangChain ----
from langchain_openai import ChatOpenAI # LLM 客户端
# ---- 项目模块 ----
from models import ( # 导入数据模型
TicketSystemState,
IntentType,
Ticket,
TicketStatus,
KnowledgeDocument,
)
from knowledge_base import search_knowledge # 导入知识库检索
from agents import route_to_agent # 导入 Agent 路由
# ============================================================
# 初始化 LLM 和检查点
# ============================================================
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0.7) # 初始化 LLM
checkpointer = MemorySaver() # 初始化检查点(生产环境替换为 SqliteSaver)
# ============================================================
# Task 1:意图识别
# ============================================================
@task
def classify_intent(state: TicketSystemState) -> TicketSystemState:
"""识别用户意图:技术问题、账单问题还是一般咨询
这是工作流的第一个 Task,其输出决定了后续的执行路径。
"""
# 构建意图识别的提示词
prompt = f"""你是一个客服意图识别系统。请分析以下用户消息,判断其意图类型。
用户消息:"{state.user_input}"
意图类型(三选一):
- technical:技术问题(系统故障、API 错误、SDK 安装、性能问题等)
- billing:账单问题(支付失败、退款、发票、价格咨询等)
- general:一般咨询(产品功能、账号注册、使用方法等)
请只返回意图类型(一个单词),不要返回其他内容。
示例:technical
"""
# 调用 LLM 进行意图识别
response = llm.invoke(prompt)
intent_str = response.content.strip().lower() # 清理输出
# 将 LLM 返回的字符串映射到 IntentType 枚举
intent_map = {
"technical": IntentType.TECHNICAL,
"billing": IntentType.BILLING,
"general": IntentType.GENERAL,
}
state.intent = intent_map.get(intent_str, IntentType.GENERAL) # 默认归为一般咨询
state.intent_confidence = 0.9 # 在实际项目中,可以从 LLM 的 logprobs 获取真实置信度
# 记录意图识别结果
state.messages.append({
"role": "system",
"content": f"[系统] 意图识别结果:{state.intent.value}(置信度:{state.intent_confidence})",
})
return state
# ============================================================
# Task 2:RAG 检索
# ============================================================
@task
def search_knowledge_base(state: TicketSystemState) -> TicketSystemState:
"""从知识库检索相关文档(RAG)
根据用户输入和识别的意图,从知识库中检索最相关的文档。
检索到的文档将作为后续 LLM 调用的上下文,提高回答质量。
"""
# 调用知识库检索函数
state.retrieved_docs = search_knowledge(state.user_input, top_k=3)
# 记录检索结果
doc_titles = [doc.title for doc in state.retrieved_docs]
state.messages.append({
"role": "system",
"content": f"[系统] RAG 检索到 {len(state.retrieved_docs)} 条相关文档:{', '.join(doc_titles)}",
})
return state
# ============================================================
# Task 3:自动回复生成
# ============================================================
@task
def generate_response(state: TicketSystemState) -> TicketSystemState:
"""基于知识库检索结果生成自动回复
如果检索到了相关文档,LLM 将基于这些文档生成回复。
同时,LLM 还会判断自己是否能解决这个问题。
"""
# 构建包含知识库内容的提示词
kb_context = "" # 知识库上下文
if state.retrieved_docs:
kb_context = "相关知识库内容:\n"
for i, doc in enumerate(state.retrieved_docs, 1):
kb_context += f"\n文档 {i}:{doc.title}\n{doc.content}\n"
# 提示词要求 LLM 同时返回回复和是否能解决的判断
prompt = f"""你是一个智能客服助手。请根据知识库内容回答用户的问题。
{kb_context}
用户问题:{state.user_input}
请按以下格式回复(严格遵守格式):
【能否解决】:是 或 否
【回复内容】:
(你的回复内容,基于知识库信息。如果知识库中没有相关信息,请诚实说明。)
注意:
- 如果知识库中有明确答案,标记【能否解决】:是
- 如果知识库中没有相关信息,或者问题需要人工处理(如退款操作、技术深度排查),标记【能否解决】:否
- 回复内容要友好、专业、有帮助
"""
# 调用 LLM 生成回复
response = llm.invoke(prompt)
response_text = response.content
# 解析 LLM 返回的内容,提取「能否解决」和「回复内容」
can_resolve = True # 默认认为可以解决
reply_content = response_text # 默认整个回复都是回复内容
# 尝试解析「能否解决」标记
if "【能否解决】:" in response_text:
parts = response_text.split("【能否解决】:")
if len(parts) > 1:
after_marker = parts[1]
if "\n" in after_marker:
can_resolve_str = after_marker.split("\n")[0].strip()
can_resolve = "是" in can_resolve_str # 包含「是」则认为是可解决的
# 提取回复内容
if "【回复内容】:" in response_text:
reply_parts = response_text.split("【回复内容】:")
if len(reply_parts) > 1:
reply_content = reply_parts[1].strip()
# 更新 state
state.auto_reply = reply_content
state.can_resolve = can_resolve
state.messages.append({
"role": "assistant",
"content": reply_content,
})
return state
# ============================================================
# Task 4:创建工单
# ============================================================
@task
def create_ticket(state: TicketSystemState) -> TicketSystemState:
"""创建工单
当 AI 无法解决问题时,自动创建工单,记录问题信息。
工单会分配给对应类型的专业 Agent 处理。
"""
# 生成工单编号:TK-YYYYMMDD-NNN
today = datetime.now().strftime("%Y%m%d") # 当前日期
ticket_id = f"TK-{today}-{uuid.uuid4().hex[:6].upper()}" # 唯一工单编号
# 创建工单对象
ticket = Ticket(
ticket_id=ticket_id,
intent=state.intent,
subject=f"【{state.intent.value}】{state.user_input[:50]}", # 截取前 50 字符作为主题
description=state.user_input,
status=TicketStatus.OPEN, # 初始状态:已创建
priority=3, # 默认优先级
created_at=datetime.now().isoformat(), # 创建时间
assigned_agent=state.intent.value, # 根据意图类型分配 Agent
)
# 更新 state
state.ticket = ticket
state.needs_human = True # 标记需要人工介入
state.messages.append({
"role": "system",
"content": f"[系统] 已创建工单 {ticket_id},状态:{ticket.status.value},分配给:{ticket.assigned_agent}",
})
return state
# ============================================================
# Task 5:人工客服介入(Human-in-the-loop)
# ============================================================
@task
def wait_for_human(state: TicketSystemState) -> TicketSystemState:
"""等待人工客服介入
使用 interrupt() 暂停工作流,等待人类客服查看工单并回复。
这是 Human-in-the-loop 模式的核心实现。
"""
# 构建中断信息(人类客服看到的内容)
interrupt_message = {
"action": "需要人工客服介入",
"ticket_id": state.ticket.ticket_id if state.ticket else "N/A",
"intent": state.intent.value,
"user_question": state.user_input,
"auto_reply": state.auto_reply,
"instruction": "请审核自动回复并提供人工回复。如需修改,输入修改后的回复。",
}
# 调用 interrupt() 暂停工作流
# 工作流会在此处暂停,直到外部调用 Command(resume=...) 恢复
human_decision = interrupt(interrupt_message)
# 人类客服的回复(通过 Command(resume=...) 传入)
if isinstance(human_decision, dict):
state.human_response = human_decision.get("response", str(human_decision))
# 更新工单状态
if state.ticket:
state.ticket.status = TicketStatus.IN_PROGRESS
state.ticket.resolution = human_decision.get("resolution", "")
elif isinstance(human_decision, str):
state.human_response = human_decision
if state.ticket:
state.ticket.status = TicketStatus.IN_PROGRESS
else:
state.human_response = str(human_decision)
state.messages.append({
"role": "human",
"content": f"[人工客服] {state.human_response}",
})
return state
# ============================================================
# Task 6:多 Agent 协作处理
# ============================================================
@task
def process_with_agent(state: TicketSystemState) -> TicketSystemState:
"""将任务路由到对应的专业 Agent 处理
根据意图类型,将工单分配给技术支持、账单咨询或一般客服 Agent。
"""
# 调用路由函数,根据意图类型选择 Agent
state = route_to_agent(state)
return state
# ============================================================
# Entrypoint:主工作流
# ============================================================
@entrypoint(checkpointer=checkpointer) # 配置检查点,支持多轮对话和中断恢复
def ticket_system_workflow(state: TicketSystemState) -> TicketSystemState:
"""智能客服工单系统主工作流
工作流步骤:
1. 意图识别 → 2. 知识库检索 → 3. 自动回复 → 4. 判断是否需要人工介入
→ 5a. 不需要 → 直接返回 AI 回复
→ 5b. 需要 → 创建工单 → 人工介入 → 多 Agent 协作 → 返回最终回复
"""
# ---- 步骤 1:意图识别 ----
state = classify_intent(state).result()
# ---- 步骤 2:知识库检索 ----
state = search_knowledge_base(state).result()
# ---- 步骤 3:自动回复生成 ----
state = generate_response(state).result()
# ---- 步骤 4:判断是否需要人工介入 ----
if not state.can_resolve:
# ---- 步骤 5a:AI 无法解决,需要人工介入 ----
# 创建工单
state = create_ticket(state).result()
# 等待人工客服介入(interrupt)
state = wait_for_human(state).result()
# 多 Agent 协作处理
state = process_with_agent(state).result()
# 生成最终输出(包含人工回复和 Agent 回复)
final_parts = []
if state.ticket:
final_parts.append(f"工单编号:{state.ticket.ticket_id}")
if state.human_response:
final_parts.append(f"人工客服回复:{state.human_response}")
if state.agent_response:
final_parts.append(f"专业顾问建议:{state.agent_response}")
state.final_output = "\n\n".join(final_parts)
else:
# ---- 步骤 5b:AI 可以解决,直接返回自动回复 ----
state.final_output = state.auto_reply
state.messages.append({
"role": "system",
"content": "[系统] AI 自动解决,无需人工介入。",
})
return state
# ============================================================
# 主程序入口:演示三个场景
# ============================================================
if __name__ == "__main__":
print("=" * 70)
print("智能客服工单系统 v1.0")
print("=" * 70)
print()
# ---- 场景 1:技术问题(AI 可解决) ----
print("【场景 1】技术问题 --- AI 可解决")
print("-" * 50)
state1 = TicketSystemState(
user_input="我的 API 调用总是超时,怎么排查?",
)
result1 = ticket_system_workflow.invoke(state1)
print(f"意图:{result1.intent.value}")
print(f"AI 可解决:{result1.can_resolve}")
print(f"回复:\n{result1.final_output[:300]}...")
print()
# ---- 场景 2:账单问题(AI 无法解决,需要人工介入) ----
print("【场景 2】账单问题 --- 需要人工介入")
print("-" * 50)
# 使用唯一的 thread_id 确保会话隔离
thread_id = f"session-{uuid.uuid4().hex[:8]}"
config = {"configurable": {"thread_id": thread_id}}
state2 = TicketSystemState(
user_input="我的订单支付失败了,钱已经扣了但订单没生成,请帮我退款",
)
try:
# 第一次调用:会触发 interrupt 并抛出 GraphInterrupt 异常
result2 = ticket_system_workflow.invoke(state2, config=config)
except Exception as e:
# 捕获 GraphInterrupt 异常(interrupt 中断时抛出)
print(f"工作流已暂停,等待人工客服介入...")
print(f"异常类型:{type(e).__name__}")
# 模拟人工客服回复
human_response = {
"response": "您好,我们已经收到您的退款请求。经核实,您的支付确实已扣款但订单未生成。我们已为您发起退款流程,预计 3-5 个工作日内到账。",
"resolution": "已发起退款,退款编号:RF-20260712-001",
}
# 使用 Command(resume=...) 恢复工作流
resume_cmd = Command(resume=human_response)
result2 = ticket_system_workflow.invoke(resume_cmd, config=config)
print(f"意图:{result2.intent.value}")
print(f"工单编号:{result2.ticket.ticket_id if result2.ticket else 'N/A'}")
print(f"最终回复:\n{result2.final_output[:300]}...")
print()
# ---- 场景 3:一般咨询(AI 可解决) ----
print("【场景 3】一般咨询 --- AI 可解决")
print("-" * 50)
state3 = TicketSystemState(
user_input="你们的产品有哪些功能?价格是多少?",
)
result3 = ticket_system_workflow.invoke(state3)
print(f"意图:{result3.intent.value}")
print(f"AI 可解决:{result3.can_resolve}")
print(f"回复:\n{result3.final_output[:300]}...")
print()
print("=" * 70)
print("智能客服工单系统演示完成!")
print("=" * 70)
11.4.5 代码逐段解析
第一部分:意图识别(classify_intent)
python
@task
def classify_intent(state: TicketSystemState) -> TicketSystemState:
prompt = f"""你是一个客服意图识别系统。请分析以下用户消息...
意图类型(三选一):
- technical:技术问题
- billing:账单问题
- general:一般咨询
请只返回意图类型(一个单词),不要返回其他内容。"""
response = llm.invoke(prompt)
intent_str = response.content.strip().lower()
state.intent = intent_map.get(intent_str, IntentType.GENERAL)
意图识别是整个工作流的第一道关卡。它的输出决定了下游所有 Task 的行为。我们使用 LLM 来做意图识别,因为 LLM 能理解自然语言中的细微差异。例如,「API 调用超时」和「支付超时」虽然都包含「超时」,但一个是技术问题,一个是账单问题。
第二部分:RAG 检索(search_knowledge_base)
python
@task
def search_knowledge_base(state: TicketSystemState) -> TicketSystemState:
state.retrieved_docs = search_knowledge(state.user_input, top_k=3)
RAG(Retrieval-Augmented Generation,检索增强生成)是提高 AI 回答质量的关键技术。它通过从知识库中检索相关文档,将检索结果作为 LLM 的上下文,让 LLM 能基于真实、准确的信息来回答。在本教程中,我们使用简单的关键词匹配来模拟检索,生产环境中应该使用向量数据库和语义搜索。
第三部分:Human-in-the-loop(wait_for_human)
python
@task
def wait_for_human(state: TicketSystemState) -> TicketSystemState:
interrupt_message = {
"action": "需要人工客服介入",
"ticket_id": state.ticket.ticket_id,
...
}
human_decision = interrupt(interrupt_message) # 暂停工作流
# 工作流恢复后,human_decision 包含人类客服的回复
interrupt() 是 LangGraph 的 Human-in-the-loop 核心 API。它暂停工作流并保存检查点,向外部发送中断信号。外部调用方通过 Command(resume=...) 恢复执行。在恢复时,interrupt() 的返回值是 Command(resume=...) 中传入的值。
第四部分:多 Agent 协作(process_with_agent)
python
@task
def process_with_agent(state: TicketSystemState) -> TicketSystemState:
state = route_to_agent(state) # 根据意图路由到对应的 Agent
return state
route_to_agent 函数内部根据意图类型选择对应的 Agent。这是 Supervisor 模式的简化实现------不使用 langgraph-supervisor 库,而是直接在代码中实现路由逻辑。这种方式更灵活,但需要手动维护路由规则。
11.5 API 速查
| API | 类型 | 说明 | 签名/导入路径 |
|---|---|---|---|
interrupt(value) |
函数 | 暂停工作流,向外部发送中断信号 | from langgraph.types import interrupt |
Command(resume=...) |
类 | 恢复被中断的工作流 | from langgraph.types import Command |
MemorySaver |
类 | 内存检查点存储(开发用) | from langgraph.checkpoint.memory import MemorySaver |
.invoke(state, config) |
方法 | 执行工作流,传入 state 和 config(含 thread_id) | workflow.invoke(state, config=config) |
uuid.uuid4().hex |
方法 | 生成唯一 ID(用于 thread_id 和工单编号) | import uuid |
11.6 单元测试
11.6.1 tests/test_workflow.py
python
# ============================================================
# tests/test_workflow.py --- 智能客服工单系统的单元测试
# 运行方式:pytest tests/test_workflow.py -v
# 运行前请确保已设置:export OPENAI_API_KEY="your-key"
# ============================================================
import pytest # 导入 pytest 测试框架
import sys # 导入系统模块
import os # 导入操作系统模块
# 将项目根目录添加到 Python 路径,确保可以导入项目模块
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from models import TicketSystemState, IntentType, Ticket, TicketStatus # 导入数据模型
from knowledge_base import search_knowledge # 导入知识库检索函数
from main import ticket_system_workflow # 导入主工作流
# ============================================================
# 测试 1:知识库检索功能
# ============================================================
class TestKnowledgeBase:
"""知识库检索功能的单元测试"""
def test_search_technical_issue(self):
"""测试:搜索技术问题应该返回技术相关文档"""
docs = search_knowledge("API 调用超时", top_k=3)
# 验证返回了文档
assert len(docs) > 0, "应该返回至少 1 条文档"
# 验证返回的文档包含技术相关标签
tech_docs = [d for d in docs if d.category == IntentType.TECHNICAL]
assert len(tech_docs) > 0, "应该返回技术类文档"
def test_search_billing_issue(self):
"""测试:搜索账单问题应该返回账单相关文档"""
docs = search_knowledge("支付失败退款", top_k=3)
assert len(docs) > 0, "应该返回至少 1 条文档"
billing_docs = [d for d in docs if d.category == IntentType.BILLING]
assert len(billing_docs) > 0, "应该返回账单类文档"
def test_search_no_match(self):
"""测试:搜索不存在的关键词应该返回空列表"""
docs = search_knowledge("xyzabc123不存在的关键词", top_k=3)
assert len(docs) == 0, "不存在的关键词应该返回空列表"
def test_search_top_k_limit(self):
"""测试:top_k 参数应该限制返回文档数量"""
docs = search_knowledge("问题", top_k=2)
assert len(docs) <= 2, "返回的文档数量不应超过 top_k"
# ============================================================
# 测试 2:数据模型
# ============================================================
class TestModels:
"""数据模型的单元测试"""
def test_ticket_creation(self):
"""测试:创建工单对象"""
ticket = Ticket(
ticket_id="TK-20260712-ABC123",
intent=IntentType.TECHNICAL,
subject="API 超时",
description="API 调用超时",
)
assert ticket.ticket_id == "TK-20260712-ABC123"
assert ticket.intent == IntentType.TECHNICAL
assert ticket.status == TicketStatus.OPEN # 默认状态应为 OPEN
assert ticket.priority == 3 # 默认优先级应为 3
def test_ticket_status_transition(self):
"""测试:工单状态转换"""
ticket = Ticket(
ticket_id="TK-TEST-001",
intent=IntentType.BILLING,
subject="退款",
description="请退款",
)
# 初始状态
assert ticket.status == TicketStatus.OPEN
# 状态转换
ticket.status = TicketStatus.IN_PROGRESS
assert ticket.status == TicketStatus.IN_PROGRESS
ticket.status = TicketStatus.RESOLVED
assert ticket.status == TicketStatus.RESOLVED
def test_state_initialization(self):
"""测试:State 初始化"""
state = TicketSystemState(
user_input="测试问题",
)
assert state.user_input == "测试问题"
assert state.intent == IntentType.GENERAL # 默认意图
assert state.messages == [] # 默认消息列表为空
assert state.retrieved_docs == [] # 默认检索结果为空
assert state.can_resolve is True # 默认 AI 可解决
assert state.needs_human is False # 默认不需要人工
assert state.ticket is None # 默认没有工单
# ============================================================
# 测试 3:意图识别(集成测试,需要 LLM)
# ============================================================
@pytest.mark.integration # 标记为集成测试(需要 LLM API)
class TestIntentClassification:
"""意图识别功能的集成测试"""
@pytest.mark.skipif(
not os.environ.get("OPENAI_API_KEY"),
reason="需要设置 OPENAI_API_KEY 环境变量",
)
def test_technical_intent(self):
"""测试:技术问题应该被识别为 TECHNICAL"""
state = TicketSystemState(
user_input="我的 API 调用返回了 500 错误,怎么解决?",
)
result = ticket_system_workflow.invoke(state)
assert result.intent == IntentType.TECHNICAL, (
f"预期意图为 TECHNICAL,实际为 {result.intent}"
)
@pytest.mark.skipif(
not os.environ.get("OPENAI_API_KEY"),
reason="需要设置 OPENAI_API_KEY 环境变量",
)
def test_billing_intent(self):
"""测试:账单问题应该被识别为 BILLING"""
state = TicketSystemState(
user_input="我的订单扣款了但是没有成功,请帮我退款",
)
result = ticket_system_workflow.invoke(state)
assert result.intent == IntentType.BILLING, (
f"预期意图为 BILLING,实际为 {result.intent}"
)
@pytest.mark.skipif(
not os.environ.get("OPENAI_API_KEY"),
reason="需要设置 OPENAI_API_KEY 环境变量",
)
def test_general_intent(self):
"""测试:一般咨询应该被识别为 GENERAL"""
state = TicketSystemState(
user_input="你们的产品有哪些功能?",
)
result = ticket_system_workflow.invoke(state)
assert result.intent == IntentType.GENERAL, (
f"预期意图为 GENERAL,实际为 {result.intent}"
)
# ============================================================
# 运行测试的说明
# ============================================================
if __name__ == "__main__":
# 运行所有测试
# pytest tests/test_workflow.py -v
# 只运行单元测试(不依赖 LLM):
# pytest tests/test_workflow.py -v -m "not integration"
# 运行集成测试(需要 LLM):
# pytest tests/test_workflow.py -v -m integration
print("请使用 pytest 运行测试:")
print(" 单元测试:pytest tests/test_workflow.py -v -m 'not integration'")
print(" 集成测试:pytest tests/test_workflow.py -v -m integration")
print(" 全部测试:pytest tests/test_workflow.py -v")
11.6.2 requirements.txt
# ============================================================
# requirements.txt --- 智能客服工单系统依赖清单
# 安装:pip install -r requirements.txt
# ============================================================
# LangGraph 核心
langgraph>=1.2.9
# LangChain 集成
langchain>=0.3.0
langchain-openai>=0.3.0
langchain-core>=0.3.0
# 数据模型
pydantic>=2.0
# Web 服务(API 部署)
fastapi>=0.115.0
uvicorn>=0.30.0
# 测试
pytest>=8.0
pytest-asyncio>=0.24.0
# 环境变量管理
python-dotenv>=1.0.0
11.6.3 Dockerfile
dockerfile
# ============================================================
# Dockerfile --- 智能客服工单系统 Docker 镜像
# 构建:docker build -t smart-ticket-system .
# 运行:docker run -p 8000:8000 -e OPENAI_API_KEY=your-key smart-ticket-system
# ============================================================
# 使用 Python 3.11 官方镜像(slim 版本更小)
FROM python:3.11-slim
# 设置工作目录
WORKDIR /app
# 设置环境变量
# 不生成 .pyc 文件;不缓冲输出(日志实时显示);设置 Python 路径
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PYTHONPATH=/app
# 安装系统依赖
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \ # 健康检查工具
&& rm -rf /var/lib/apt/lists/* # 清理 apt 缓存
# 复制依赖文件并安装 Python 依赖
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# 复制项目代码
COPY . .
# 创建非 root 用户运行应用(安全最佳实践)
RUN useradd --create-home --shell /bin/bash appuser
RUN chown -R appuser:appuser /app
USER appuser
# 暴露端口
EXPOSE 8000
# 健康检查
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost:8000/health || exit 1
# 启动命令
CMD ["uvicorn", "api:app", "--host", "0.0.0.0", "--port", "8000"]
11.6.4 docker-compose.yml
yaml
# ============================================================
# docker-compose.yml --- 多服务编排配置
# 启动:docker-compose up -d
# 停止:docker-compose down
# ============================================================
version: '3.8'
services:
# 主应用服务
app:
build: .
container_name: smart-ticket-system
ports:
- "8000:8000" # 将容器的 8000 端口映射到宿主机的 8000 端口
environment:
- OPENAI_API_KEY=${OPENAI_API_KEY} # 从宿主机环境变量读取
- LOG_LEVEL=INFO # 日志级别
volumes:
- ./data:/app/data # 持久化数据目录(检查点、工单数据等)
restart: unless-stopped # 除非手动停止,否则自动重启
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3
11.7 常见错误与避坑指南
错误 1:忘记在 Entrypoint 配置 checkpointer 就使用 interrupt()
错误代码:
python
# 错误:没有配置 checkpointer
@entrypoint() # 缺少 checkpointer=checkpointer
def my_workflow(state):
...
result = wait_for_human(state).result() # wait_for_human 内部有 interrupt()
# 运行时会报错,因为 interrupt() 需要检查点支持
正确代码:
python
checkpointer = MemorySaver() # 或 SqliteSaver
@entrypoint(checkpointer=checkpointer) # 必须配置 checkpointer
def my_workflow(state):
...
result = wait_for_human(state).result()
原因 :interrupt() 需要检查点来保存和恢复执行状态。如果 entrypoint 没有配置 checkpointer,调用 interrupt() 会抛出错误。
错误 2:恢复执行时使用了错误的 thread_id
错误代码:
python
# 第一次调用
config1 = {"configurable": {"thread_id": "session-abc"}}
try:
result = workflow.invoke(state, config=config1)
except GraphInterrupt:
pass
# 错误:恢复时使用了不同的 thread_id
config2 = {"configurable": {"thread_id": "session-xyz"}} # 不同的 thread_id!
resume_cmd = Command(resume=human_response)
result = workflow.invoke(resume_cmd, config=config2) # 找不到中断的检查点!
正确代码:
python
# 第一次调用
config = {"configurable": {"thread_id": "session-abc"}}
try:
result = workflow.invoke(state, config=config)
except GraphInterrupt:
pass
# 正确:恢复时使用相同的 config
resume_cmd = Command(resume=human_response)
result = workflow.invoke(resume_cmd, config=config) # 相同的 config!
原因 :LangGraph 通过 thread_id 定位中断的检查点。恢复时必须使用相同的 thread_id,否则 LangGraph 找不到中断的会话。
错误 3:RAG 检索时没有考虑意图分类,检索结果不准确
错误代码:
python
# 错误:只根据用户输入检索,没有利用意图分类结果
# 可能导致「API 超时」检索到「支付超时」的文档
@task
def search_knowledge_base(state):
# 没有传入意图分类,检索结果可能包含不相关的文档
state.retrieved_docs = search_knowledge(state.user_input)
return state
正确代码:
python
# 正确:结合意图分类进行检索,提高准确率
@task
def search_knowledge_base(state):
# 将意图作为额外的过滤条件
query = f"{state.intent.value} {state.user_input}" # 加入意图作为上下文
docs = search_knowledge(query, top_k=3)
# 进一步过滤:只保留与意图类型匹配的文档
filtered_docs = [
d for d in docs
if d.category == state.intent or d.category == IntentType.GENERAL
]
state.retrieved_docs = filtered_docs if filtered_docs else docs[:2] # 有兜底
return state
原因:意图识别已经告诉我们用户问题的大致类型。利用这个信息进行检索过滤,可以显著提高检索结果的相关性,避免「技术问题的用户看到账单文档」。
错误 4:工单创建后没有更新状态,导致工单一直处于 OPEN 状态
错误代码:
python
# 错误:创建工单后忘记更新状态
@task
def create_ticket(state):
ticket = Ticket(
ticket_id=f"TK-{today}-{uuid.uuid4().hex[:6]}",
status=TicketStatus.OPEN, # 创建时是 OPEN
...
)
state.ticket = ticket
# 忘记更新状态!工单永远是 OPEN
return state
正确代码:
python
# 正确:在工单生命周期的关键节点更新状态
@task
def create_ticket(state):
ticket = Ticket(
ticket_id=f"TK-{today}-{uuid.uuid4().hex[:6]}",
status=TicketStatus.OPEN,
...
)
state.ticket = ticket
return state
@task
def wait_for_human(state):
human_decision = interrupt(...)
# 正确:人工介入后更新工单状态
if state.ticket:
state.ticket.status = TicketStatus.IN_PROGRESS # 更新为处理中
return state
@task
def resolve_ticket(state):
# 正确:问题解决后更新工单状态
if state.ticket:
state.ticket.status = TicketStatus.RESOLVED # 更新为已解决
return state
原因:工单状态是客服系统的重要指标。如果状态不更新,管理者和用户都无法知道工单的进展。在工单生命周期的关键节点(创建、处理中、等待人工、已解决、已关闭)更新状态。
错误 5:没有对用户输入做验证,导致恶意输入或异常输入
错误代码:
python
# 错误:直接使用用户输入,没有验证
@entrypoint(checkpointer=checkpointer)
def workflow(state: TicketSystemState):
# 如果用户输入为空、超长或包含恶意内容,直接传给 LLM
state = classify_intent(state).result() # 可能传入空字符串或超长文本
return state
正确代码:
python
# 正确:在入口处验证用户输入
MAX_INPUT_LENGTH = 2000 # 最大输入长度
MIN_INPUT_LENGTH = 2 # 最小输入长度
@entrypoint(checkpointer=checkpointer)
def workflow(state: TicketSystemState):
# 输入验证
user_input = state.user_input.strip()
# 检查是否为空
if not user_input:
state.final_output = "请输入您的问题,我们很乐意帮助您。"
return state
# 检查长度
if len(user_input) > MAX_INPUT_LENGTH:
state.final_output = f"您的问题描述过长(当前 {len(user_input)} 字符),请精简到 {MAX_INPUT_LENGTH} 字符以内。"
return state
if len(user_input) < MIN_INPUT_LENGTH:
state.final_output = "请提供更多细节,以便我们更好地帮助您。"
return state
# 更新 state 中的用户输入为清理后的版本
state.user_input = user_input
# 继续正常流程
state = classify_intent(state).result()
return state
原因:用户输入是外部数据,不可信任。在传给 LLM 之前,需要验证长度、格式和内容。这可以防止 LLM 被恶意输入利用(prompt injection),也能避免不必要的 API 调用成本。
11.8 最佳实践总结
-
意图识别是入口,要准确:意图识别决定了整个工作流的方向。使用清晰、具体的 prompt,限制输出格式(如「只返回一个单词」),确保 LLM 输出的一致性。
-
RAG 检索要结合意图:先识别意图,再检索。这样可以过滤掉不相关的文档,提高检索准确率。如果知识库较大,使用向量数据库和语义搜索替代简单的关键词匹配。
-
Human-in-the-loop 要精准 :只在 AI 无法解决时才触发人工介入。不要滥用
interrupt(),否则会降低用户体验和系统效率。在interrupt()中提供足够的信息(如工单内容、AI 建议),帮助人类客服快速做出决策。 -
工单状态要追踪 :在工单生命周期的每个关键节点更新状态。使用枚举类型(
TicketStatus)确保状态值是有限的、确定的,避免拼写错误。 -
多 Agent 职责要清晰:每个 Agent 只处理自己擅长领域的问题。技术支持处理技术问题,账单咨询处理支付问题,一般客服处理产品咨询。不要混在一起,否则 Agent 的回答质量会下降。
-
输入验证是安全的第一道防线:在入口处验证用户输入的长度、格式和内容。设置合理的限制(如最大 2000 字符),防止恶意输入和 prompt injection。
-
测试要分层:单元测试(不依赖 LLM)覆盖数据模型和工具函数,集成测试(依赖 LLM)覆盖意图识别等核心功能。使用 pytest marker 区分两种测试,在 CI/CD 中分别运行。
结语:从零到 Hero 的旅程
恭喜你完成了全部 11 章的学习!让我们回顾一下这段旅程:
| 章节 | 内容 | 你学到了什么 |
|---|---|---|
| 第 1 章 | 概念与架构 | 理解 LangGraph 是什么,它解决了什么问题 |
| 第 2 章 | 快速上手 | 编写第一个 LangGraph Agent |
| 第 3 章 | State 与 Schema | 让 Agent 拥有结构化记忆 |
| 第 4 章 | Graph 构建 | 多步骤编排与条件分支 |
| 第 5 章 | 工具调用 | 让 AI 拥有行动能力 |
| 第 6 章 | Checkpointing | 让 Agent 拥有记忆 |
| 第 7 章 | Human-in-the-Loop | 让人与 AI 协作 |
| 第 8 章 | 并行与子图 | 高效编排与模块化 |
| 第 9 章 | 多 Agent 协作 | 让多个 AI 各司其职 |
| 第 10 章 | 生产部署 | 从原型到上线的最后一公里 |
| 第 11 章 | 附录项目 | 全面实践:智能客服工单系统 |
从「什么是 LangGraph」到「构建完整的智能客服工单系统」,你已经掌握了使用 LangGraph Functional API 构建生产级 AI Agent 应用的全部核心技能。
下一步建议:
- 基于本教程的代码,构建你自己的 Agent 项目
- 阅读 LangGraph 官方文档 获取最新 API 信息
- 加入 LangChain 社区,与其他开发者交流经验
- 关注 LangGraph 的版本更新,及时了解新特性
祝你构建出优秀的 AI Agent 应用!