Skip to content

第 27 章 实战项目一:多轮智能客服系统 ​

27.1 需求与场景 ​

多轮智能客服系统面向电商与 SaaS 产品的在线客服场景。用户通过 Web 或移动端会话入口发起咨询,系统在单个会话内处理订单查询、物流跟踪、退货退款、常见问题解答、工单创建与转人工六类任务。能力边界限定于已接入的业务域,不处理账户注销、合同变更、投诉升级等需人工坐席或后台审核的操作,此类请求统一路由至人工坐席。

业务场景覆盖三个典型流程。其一为订单类咨询,用户询问"我的订单什么时候发货""订单 12345 现在什么状态",系统需要识别意图、抽取订单号(若未提供则追问)、调用订单中心接口返回状态。其二为退款类高风险操作,用户提出"要退货退款",系统校验退款资格后必须中断等待审批,不得直接执行资金操作。其三为多轮混合诉求,用户先查物流、再问退货政策、最后要求退款,系统需在同一会话内保留上下文实体并在不同子图间切换。

功能需求涵盖七个维度。其一为多轮对话上下文记忆,系统需在同一 session 内跨轮次保留用户问题与已提取实体,避免重复追问订单号等信息,messages 列表以 add_messages 归约器追加。其二为意图识别与路由(Router,路由器模式),将用户输入分发至订单、物流、退款、FAQ(Frequently Asked Questions,常见问题)、工单、人工六个专业 Agent(智能体)。其三为业务工具调用,封装内部订单中心、物流网关、退款中心、工单系统的 HTTP 或 RPC(Remote Procedure Call,远程过程调用)接口,工具须具备超时、重试与幂等能力。其四为高风险操作人工审批(Human-in-the-Loop,HITL,人在回路),退款类操作必须在执行前由坐席或风控审批人确认,金额超过阈值还需二级审批。其五为兜底转人工,无法识别意图、置信度低于阈值或用户明确要求时移交坐席,并附带会话摘要以缩短坐席应答时间。其六为会话结束自动归档摘要,生成结构化摘要写入长期存储,用于后续会话召回与客服质量分析。其七为全程流式回复,前端以 SSE(Server-Sent Events,服务器推送事件)逐 token 渲染并接收自定义进度事件,降低首字延迟。

非功能需求包括:单会话 P95 响应延迟低于 2 秒(不含外部工具调用);工具调用统一 30 秒超时;单租户支持 500 并发会话;对话记录与工具调用全程可追溯;所有工具调用与审批决策写入审计日志;系统支持灰度发版与快速回滚;知识库覆盖度 ≥ 85% FAQ 高频问题;意图识别准确率 ≥ 92%;高风险操作零误触发(100% 走 HITL)。

典型用户旅程示意:用户打开客服窗口输入"我的订单怎么还没到",intent_router 识别 logistics 意图但未抽取 order_id,logistics 子图内部追问"请问您要查询的订单号是?";用户回复订单号后,物流 Agent 调用 logistics_track_tool 返回轨迹;若用户继续追问"这个能退吗",子图结束后回到 intent_router 识别 refund 意图,进入 refund 子图;退款资格通过后 interrupt 暂停等待审批;审批通过后退款执行;最后用户说"没别的事了",进入 summary_node 生成摘要并存入 Store。

27.2 架构设计 ​

系统采用 Router 模式与 Handoffs(交接)混合架构。顶层为客服 Supervisor(监督者)图,内部以 intent_router 为入口分发至六个业务子图(Subgraph),每个业务 Agent 封装为独立子图以隔离状态、工具集与提示词,处理完成后返回 Supervisor 以判断是否继续多轮对话或进入归档节点结束。

┌────────┐   HTTP/SSE   ┌──────────────┐   invoke/stream   ┌────────────────────────────────────────┐
│ Client │ ───────────▶ │  API Layer   │ ────────────────▶ │         Customer Service Graph         │
│ (Web)  │ ◀─────────── │ (FastAPI)    │ ◀──────────────── │  ┌──────────┐                          │
└────────┘              └──────────────┘                   │  │ intent_  │  Command(goto=...)       │
                                                           │  │ router   │ ──┬──┬──┬──┬──┬──┬──┐   │
                                                           │  └──────────┘   │  │  │  │  │  │  │   │
                                                           │                 ▼  ▼  ▼  ▼  ▼  ▼  ▼   │
                                                           │         ┌───────────────────────────┐  │
                                                           │         │ order/logistics/refund/   │  │
                                                           │         │ faq/ticket/handoff sub-   │  │
                                                           │         │ graphs (each a Subgraph)  │  │
                                                           │         └─────────┬─────────────────┘  │
                                                           │                   ▼                    │
                                                           │           ┌──────────────┐             │
                                                           │           │ summary_node │──▶ END      │
                                                           │           └──────────────┘             │
                                                           └───────────────┬────────────────────────┘
                                                                           │
                              ┌──────────────────────┬─────────────────────┼─────────────────────┐
                              ▼                      ▼                     ▼                     ▼
                       ┌────────────┐        ┌─────────────┐       ┌──────────────┐      ┌────────────┐
                       │ Postgres   │        │ Postgres    │       │ LangSmith    │      │ Business   │
                       │ Saver      │        │ Store       │       │ Tracing      │      │ APIs       │
                       │(checkpoints)│       │(user profile)│      │(runs/traces) │      │(order/...) │
                       └────────────┘        └─────────────┘       └──────────────┘      └────────────┘

技术选型:LangGraph 1.2.x 作为编排框架,Supervisor 为顶层 StateGraph,业务 Agent 以 Subgraph 形式通过 add_node 注册,子图间通过 Command(goto=...) 实现显式跳转。Checkpointer(检查点器)使用 PostgresSaver 保存会话检查点,支持断点续跑与 HITL 暂停恢复。Store(长期存储)使用 PostgresStore 保存用户画像与长期偏好,跨 session 复用。追踪接入 LangSmith,记录每次 run 的节点输入输出、token 消耗与延迟。模型层默认使用 gpt-4o-mini 做意图识别、FAQ 与摘要,gpt-4o 做退款资格推理、工单信息提取等重任务;按租户维度配置可切换主备模型。

项目目录结构:

customer_service/
├── app/
│   ├── main.py              # FastAPI 入口,SSE 与审批回调
│   ├── graph.py             # Supervisor 图构建
│   ├── state.py             # 共享 State 定义
│   ├── nodes/
│   │   ├── router.py        # intent_router 节点
│   │   ├── post_dispatch.py # Agent 结束后的路由分发
│   │   ├── summary.py       # summary 节点
│   │   └── agents/
│   │       ├── faq.py       # FAQ 子图(RAG)
│   │       ├── order.py     # 订单子图
│   │       ├── logistics.py # 物流子图
│   │       ├── refund.py    # 退款子图(含 HITL)
│   │       ├── ticket.py    # 工单子图
│   │       └── handoff.py   # 转人工子图(含 HITL)
│   ├── tools/
│   │   ├── order_api.py     # 订单查询工具
│   │   ├── logistics_api.py # 物流跟踪工具
│   │   ├── refund_api.py    # 退款资格与执行工具
│   │   ├── ticket_api.py    # 工单创建工具
│   │   └── faq_retriever.py # FAQ 向量检索工具
│   └── utils/
│       ├── db.py            # 数据库连接池
│       ├── http.py          # 统一 httpx 客户端与重试
│       └── security.py      # 脱敏与审批校验
├── prompts/                 # 版本化 YAML 提示词
│   ├── intent_router.yaml
│   ├── refund.yaml
│   └── summary.yaml
├── tests/
│   ├── test_intent.py
│   ├── test_refund_approval.py
│   ├── test_session_isolation.py
│   └── test_tool_fallback.py
├── docker-compose.yml
├── requirements.txt
└── .env

依赖安装命令:

bash
pip install "langgraph>=1.2,<1.3" langchain langchain-openai langchain-core \
    psycopg[binary,pool] fastapi uvicorn httpx pydantic pyyaml sse-starlette

各子图职责边界划分:faq 子图仅挂载 faq_retriever 向量检索工具,不访问业务写接口,其职责是从知识库中返回标准问答对,无法命中时可回退到 LLM 通用回答并提示答案仅供参考;order 子图挂载 order_query_tool,只读不写,未取得 order_id 时通过追问节点向用户索取,不会反复重试;logistics 子图挂载 logistics_track_tool,负责格式化轨迹节点为可读文本,对于运输异常(滞留、退回)节点自动高亮;refund 子图挂载 refund_eligibility_tool 与 refund_execute_tool,是唯一包含写操作与 HITL 审批的子图;ticket 子图挂载 create_ticket_tool,需要与用户多轮收集 title/category/description/priority 四项必填字段,缺失字段逐项追问;human_handoff 子图不挂载业务工具,只负责通过 interrupt 通知坐席并等待 accept/close 信号,坐席接管后进入"人工回复"通道,图本身不再调用 LLM 产生自动回复,仅在坐席关闭会话时回到 summary_node。

Supervisor 与子图的 State 隔离策略:顶层 ServiceState 包含所有跨子图共享字段;子图内部若需临时字段(如 refund 子图的 refund_amount、ticket 子图的 ticket_form),可在子图 State 中以 TypedDict 扩展,并在子图输出时只回写顶层关心的字段(messages、current_intent 等),私有字段不回写,避免 checkpoint 膨胀。子图通过 input/output 字段映射(compile 时的 config 或 add_node 时的通道对齐)选择性暴露通道。

27.3 State 与节点路由设计 ​

顶层 State 承载跨子图共享的会话上下文。messages 使用 add_messages 归约器追加消息,避免子图间覆盖。业务实体字段在 intent_router 阶段填充,子图节点按需读取,不在子图间传递冗余业务数据。

python
from typing import Annotated, Optional, Literal
from typing_extensions import TypedDict
from langgraph.graph.message import add_messages
from langchain_core.messages import BaseMessage

class ServiceState(TypedDict):
    """客服会话共享状态。所有业务子图共享该结构。"""
    messages: Annotated[list[BaseMessage], add_messages]
    user_id: str
    session_id: str
    order_id: Optional[str]               # 从用户话语中提取的订单号
    current_intent: Optional[Literal[
        "order","logistics","refund","faq","ticket","human"]]
    human_approval_pending: bool          # 是否存在未完成的人工审批
    conversation_summary: str             # 会话结束时回填
    handoff_reason: Optional[str]         # 转人工原因(低置信度/用户主动/失败降级)
    retry_count: int                      # 当前轮工具失败重试计数
    last_tool_error: Optional[str]        # 最近一次工具错误信息

子图内部可扩展私有字段(如 refund 子图的 refund_amount 与 refund_reason),私有字段定义在子图 State 中,不回写到顶层 State,避免状态污染与 checkpoint 体积膨胀。

节点设计遵循单一职责原则:

  • intent_router:使用 with_structured_output 调用 LLM 识别意图并提取实体(order_id、退款金额、工单分类等),输出 Command(goto=target_agent, update=...)。置信度低于 0.6 时直接路由至 human_handoff_agent,并在 handoff_reason 中记录"低置信度"或"无法识别"。
  • faq_agent:基于向量检索知识库回答常见问题,子图内挂载 faq_retriever 工具,无业务写操作,仅做 RAG(Retrieval-Augmented Generation,检索增强生成)。
  • order_agent:调用 order_query_tool,将结构化返回转为自然语言状态描述;若未提供 order_id 则追问用户。
  • logistics_agent:调用 logistics_track_tool,解析轨迹节点列表为按时间倒序的可读文本。
  • refund_agent:先调用 refund_eligibility_tool 校验资格,校验通过后调用 interrupt() 携带退款详情 payload 等待审批;审批通过后再调用 refund_execute_tool;驳回则告知原因并建议转人工。
  • ticket_agent:多轮收集工单标题、问题分类、问题描述与紧急度,信息齐全后调用 create_ticket_tool 返回工单号。
  • human_handoff_agent:调用 interrupt() 通知坐席端有会话待接入,payload 携带会话摘要与用户基础信息,等待坐席 accept 后恢复。
  • summary_node:会话结束触发时,对 messages 做结构化摘要(问题类型、解决结果、用户情绪),写入 PostgresStore 的用户画像条目。

条件路由规则:每个业务子图结束节点返回 Command(goto=...),统一跳到 post_agent_dispatch 路由节点;该节点根据最后一条用户消息判断是否继续多轮。若用户消息包含"没有其他问题""结束""没事了"等结束标记,路由到 summary_node 后 END;否则回到 intent_router 继续识别下一个意图。

python
# graph.py 核心图构建
from langgraph.graph import StateGraph, START, END
from langgraph.types import Command
from .state import ServiceState
from .nodes.router import intent_router
from .nodes.summary import summary_node
from .nodes.post_dispatch import post_agent_dispatch
from .nodes.agents.faq import build_faq_graph
from .nodes.agents.order import build_order_graph
from .nodes.agents.logistics import build_logistics_graph
from .nodes.agents.refund import build_refund_graph
from .nodes.agents.ticket import build_ticket_graph
from .nodes.agents.handoff import build_handoff_graph

builder = StateGraph(ServiceState)
builder.add_node("intent_router", intent_router)
builder.add_node("faq", build_faq_graph())
builder.add_node("order", build_order_graph())
builder.add_node("logistics", build_logistics_graph())
builder.add_node("refund", build_refund_graph())
builder.add_node("ticket", build_ticket_graph())
builder.add_node("human_handoff", build_handoff_graph())
builder.add_node("post_agent_dispatch", post_agent_dispatch)
builder.add_node("summary", summary_node)

builder.add_edge(START, "intent_router")
for agent in ["faq","order","logistics","refund","ticket","human_handoff"]:
    builder.add_edge(agent, "post_agent_dispatch")
builder.add_conditional_edges("post_agent_dispatch",
    lambda s: "summary" if s.get("_finish") else "intent_router",
    {"summary":"summary", "intent_router":"intent_router"})
builder.add_edge("summary", END)

intent_router 节点实现采用结构化输出 IntentResult。系统提示词存储于 prompts/intent_router.yaml 并按版本加载,模型选择上优先 gpt-4o-mini 以降低延迟与成本,若租户配置中要求高准确率可切换到 gpt-4o。路由时同时考虑当前轮消息与近几轮历史,以识别省略主语或代词指代的情况(如"那这个能退吗"中的"这个"需要通过上下文判断指代上一个订单)。若用户消息出现"人工""转人工""找人"等关键词,直接路由 human_handoff 并跳过置信度判断,尊重用户选择权。

python
# nodes/router.py
from pydantic import BaseModel, Field
from typing import Optional, Literal
from langchain.chat_models import init_chat_model
from langchain_core.messages import AIMessage
from langgraph.types import Command

class IntentResult(BaseModel):
    intent: Literal["order","logistics","refund","faq","ticket","human","fallback"]
    order_id: Optional[str] = None
    confidence: float = Field(ge=0, le=1)
    rationale: str

async def intent_router(state: ServiceState):
    llm = init_chat_model("openai:gpt-4o-mini").with_structured_output(IntentResult)
    history = state["messages"][-10:]  # 裁剪近 10 条节省 token
    result: IntentResult = await llm.ainvoke([
        {"role":"system","content":"该角色为客服意图识别器,根据对话历史判定当前轮意图,提取订单号,返回置信度与理由。"},
        *history
    ])
    if result.intent in ("fallback","human") or result.confidence < 0.6:
        return Command(
            goto="human_handoff",
            update={"current_intent":"human",
                    "handoff_reason":f"置信度{result.confidence:.2f}或主动转人工",
                    "order_id": result.order_id or state.get("order_id")})
    return Command(
        goto=result.intent,
        update={"current_intent": result.intent,
                "order_id": result.order_id or state.get("order_id"),
                "retry_count": 0,
                "last_tool_error": None})

FAQ 子图结构示意(RAG 检索 + 回答):

python
# nodes/agents/faq.py
from langgraph.graph import StateGraph, START, END
from langchain.chat_models import init_chat_model
from ...tools.faq_retriever import faq_retriever_tool

async def faq_retrieve_node(state):
    # 先用检索工具找最相关的 5 条 FAQ
    res = await faq_retriever_tool.ainvoke(state["messages"][-1].content)
    return {"_faq_context": res}

async def faq_answer_node(state):
    llm = init_chat_model("openai:gpt-4o-mini")
    ctx = state.get("_faq_context","")
    ans = await llm.ainvoke([
        {"role":"system","content":"基于以下 FAQ 知识库片段用中文回答用户问题,若知识库无相关信息,请如实说明并建议转人工或提交工单。\n"+ctx},
        *state["messages"][-6:]])
    return {"messages": [ans], "_faq_context": ""}

def build_faq_graph():
    sub = StateGraph(ServiceState)
    sub.add_node("faq_retrieve", faq_retrieve_node)
    sub.add_node("faq_answer", faq_answer_node)
    sub.add_edge(START, "faq_retrieve")
    sub.add_edge("faq_retrieve", "faq_answer")
    sub.add_edge("faq_answer", END)
    return sub.compile()

订单子图与物流子图结构类似,均为"参数校验 → 追问或调用工具 → 格式化回复"三段式,若 order_id 缺失则由 ask_order_id 节点发起追问并 END,等待用户下一轮消息重新进入意图识别,避免子图内部阻塞等待用户输入(用户输入永远通过顶层 graph.astream 注入)。

python
# nodes/post_dispatch.py
async def post_agent_dispatch(state: ServiceState):
    # 读取最近一条用户消息(若存在)
    user_msgs = [m for m in state["messages"] if m.type == "human"]
    if not user_msgs:
        return {"_finish": False}
    last_user = user_msgs[-1].content
    end_markers = ["没有其他问题", "结束", "谢谢", "没事了", "再见"]
    finish = any(m in last_user for m in end_markers)
    # 若已转人工且坐席已结束会话,也进入 summary
    if state.get("current_intent") == "human" and state.get("_handoff_closed"):
        finish = True
    return {"_finish": finish}

27.4 工具实现与 HITL ​

业务工具统一以 @tool 封装,外层通过 with_retry 装饰器加入超时、指数退避重试与结构化错误返回。工具返回结构化 dict,键包括 ok、data、error,便于下游节点判断分支,避免将自然语言错误信息交给 LLM 解析。

python
# utils/http.py 与 tools/order_api.py 合并示意
from langchain_core.tools import tool
import httpx, asyncio
from functools import wraps

def with_retry(max_retry: int = 2, timeout: float = 10.0):
    """HTTP 工具统一重试装饰器。"""
    def decorator(fn):
        @wraps(fn)
        async def wrapper(*args, **kwargs):
            last_exc = None
            for i in range(max_retry + 1):
                try:
                    async with httpx.AsyncClient(timeout=timeout) as client:
                        return await fn(client, *args, **kwargs)
                except (httpx.HTTPError, asyncio.TimeoutError) as e:
                    last_exc = e
                    await asyncio.sleep(0.5 * (i + 1))
            return {"ok": False,
                    "error": {"code": "tool_timeout_or_error", "message": str(last_exc)}}
        return wrapper
    return decorator

@tool
@with_retry()
async def order_query_tool(client, order_id: str) -> dict:
    """查询订单状态。参数 order_id 为订单编号字符串。"""
    r = await client.get(f"http://order-svc.internal/orders/{order_id}")
    r.raise_for_status()
    return {"ok": True, "data": r.json()}

@tool
@with_retry()
async def logistics_track_tool(client, order_id: str) -> dict:
    """查询物流轨迹节点列表。参数 order_id 为订单编号。"""
    r = await client.get(f"http://logistics-svc.internal/track/{order_id}")
    r.raise_for_status()
    return {"ok": True, "data": r.json()}

@tool
@with_retry(max_retry=0)  # 退款执行禁止自动重试,避免重复退款
async def refund_execute_tool(client, order_id: str, amount: float, reason: str, idem_key: str) -> dict:
    """执行退款。需审批通过后方可调用,idem_key 为幂等键。"""
    r = await client.post("http://refund-svc.internal/execute",
        json={"order_id": order_id, "amount": amount, "reason": reason,
              "idem_key": idem_key})
    r.raise_for_status()
    return {"ok": True, "data": r.json()}

@tool
@with_retry()
async def refund_eligibility_tool(client, order_id: str) -> dict:
    """校验订单是否满足退款条件,返回可退金额与原因。"""
    r = await client.get(f"http://refund-svc.internal/eligibility/{order_id}")
    r.raise_for_status()
    return {"ok": True, "data": r.json()}

@tool
@with_retry()
async def create_ticket_tool(client, user_id: str, title: str, category: str,
                              description: str, priority: str = "normal") -> dict:
    """创建客服工单。category 取值 billing/technical/account/other。"""
    r = await client.post("http://ticket-svc.internal/tickets",
        json={"user_id": user_id, "title": title, "category": category,
              "description": description, "priority": priority})
    r.raise_for_status()
    return {"ok": True, "data": r.json()}

退款子图的 HITL 流程是系统安全核心。eligibility 校验通过后,节点调用 interrupt() 将退款详情抛出给外部审批系统,运行图在此暂停,checkpoint 自动持久化至 Postgres。审批人在后台完成 approve/reject/modify 操作后,通过 API 以 Command(resume=decision) 恢复图执行,refund_request 节点从 interrupt 返回值处获得审批结果并继续执行。

python
# nodes/agents/refund.py 退款子图核心节点
from langgraph.types import interrupt
from langchain_core.messages import AIMessage
from langgraph.graph import StateGraph, START, END
from ...tools.refund_api import refund_eligibility_tool, refund_execute_tool
from ...state import ServiceState
import uuid

async def refund_check_node(state: ServiceState):
    oid = state.get("order_id")
    if not oid:
        return {"messages": [AIMessage(content="请提供需要退款的订单号。")],
                "_refund_done": False}
    elig = await refund_eligibility_tool.ainvoke({"order_id": oid})
    if not elig.get("ok"):
        return {"messages":[AIMessage(content="暂时无法校验退款资格,请稍后再试或转人工。")],
                "last_tool_error": elig["error"]["message"], "_refund_done": True}
    if not elig["data"].get("eligible"):
        return {"messages":[AIMessage(
            content=f"订单 {oid} 暂不符合退款条件:{elig['data'].get('reason','未知原因')}")],
                "_refund_done": True}
    amount = elig["data"]["refundable_amount"]
    # 触发中断,等待审批人操作,payload 包含审批 UI 所需全部信息
    decision = interrupt({
        "type": "refund_approval",
        "order_id": oid,
        "amount": amount,
        "user_id": state["user_id"],
        "reason_snapshot": state["messages"][-1].content if state["messages"] else "",
        "awaiting": "approver_decision"
    })
    # decision 期望结构:{"action":"approve"|"reject"|"modify",
    #                    "amount":float,"comment":str}
    if decision.get("action") == "reject":
        return {"messages":[AIMessage(
            content=f"退款申请未通过:{decision.get('comment','审批驳回')},可申请转人工进一步处理。")],
                "human_approval_pending": False, "_refund_done": True}
    final_amount = decision.get("amount", amount)
    idem_key = f"refund:{state['session_id']}:{oid}:{uuid.uuid4().hex[:8]}"
    exe = await refund_execute_tool.ainvoke({
        "order_id": oid, "amount": final_amount,
        "reason": "customer_request", "idem_key": idem_key})
    if exe.get("ok"):
        return {"messages":[AIMessage(
            content=f"退款 {final_amount:.2f} 元已提交,原路返回预计 1-3 个工作日到账。")],
                "human_approval_pending": False, "_refund_done": True}
    return {"messages":[AIMessage(content="退款执行失败,已为您转人工处理。")],
            "human_approval_pending": False, "handoff_reason":"退款执行失败",
            "_refund_done": False, "_escalate_human": True}

def build_refund_graph():
    sub = StateGraph(ServiceState)
    sub.add_node("refund_check", refund_check_node)
    sub.add_edge(START, "refund_check")
    sub.add_edge("refund_check", END)
    return sub.compile()

工单与转人工子图要点:ticket_agent 使用 FormFilling 思路,用 Pydantic 模型 TicketForm 校验已收集字段,若有缺失则生成对应追问,全部字段齐备后调用工具;human_handoff_agent 的 interrupt payload 中包含最近 10 条消息的摘要、用户已识别意图、当前业务实体(order_id)以及失败原因,帮助坐席快速切入。坐席端接收 interrupt 事件后有 60 秒响应窗口,超时自动升级到下一组坐席并在 payload 中标记 escalated=true。

工具幂等与超时策略:所有写工具(refund_execute_tool、create_ticket_tool)必须接受 idem_key 参数,由调用节点用 f"{session_id}:{action}:{entity_id}:{attempt}" 生成幂等键传入后端,后端按 idem_key 去重;读工具可重试 2 次,指数退避间隔 0.5s/1s;工具超时时间按接口 SLA 差异化配置,订单查询 5s、物流跟踪 8s、退款执行 15s、工单创建 10s;所有工具返回值必须包含 ok 字段,错误码 code 使用稳定字符串(tool_timeout、permission_denied、business_rejected、network_error)便于上层节点做分支判断。

27.5 持久化、流式与错误处理 ​

持久化层使用 PostgresSaver 与 PostgresStore,二者共享同一连接池以减少连接数。thread_id 按会话维度设置为 sess:{session_id},确保同一 session 的所有 checkpoint 连续且可随时通过 thread_id 恢复。Store 以 ("users", user_id) 为 namespace,保存用户长期偏好(称呼、常用地址、对响应长度的偏好)、历史会话摘要、常见问题命中记录等跨会话信息。

python
# graph.py 编译
from langgraph.checkpoint.postgres import PostgresSaver
from langgraph.store.postgres import PostgresStore
from psycopg_pool import ConnectionPool

conn_pool = ConnectionPool(
    conninfo="postgresql://cs_user:cs_pwd@pg:5432/langgraph",
    max_size=20,
    kwargs={"autocommit": True})
checkpointer = PostgresSaver(conn_pool)
store = PostgresStore(conn_pool)
checkpointer.setup()
store.setup()

graph = builder.compile(
    checkpointer=checkpointer,
    store=store,
    interrupt_before=["human_handoff"],  # 转人工前额外的入口断点(可选)
)

流式输出采用双模式:messages 模式输出模型 token 流供前端逐字渲染;custom 模式输出业务节点进度事件,前端根据 event 类型展示"正在查询订单…""已提交退款审批"等提示。

python
# main.py 流式入口
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from langchain_core.messages import HumanMessage
import json, uuid

app = FastAPI()

class ChatIn(BaseModel):
    message: str
    user_id: str
    session_id: str

async def stream_turn(user_input: str, user_id: str, session_id: str):
    config = {"configurable": {
                  "thread_id": f"sess:{session_id}",
                  "user_id": user_id},
              "recursion_limit": 50}
    async for mode, payload in graph.astream(
        {"messages": [HumanMessage(content=user_input)],
         "user_id": user_id,
         "session_id": session_id,
         "human_approval_pending": False,
         "retry_count": 0},
        config=config,
        stream_mode=["messages", "custom"]):
        if mode == "messages":
            msg, meta = payload
            if msg.content:
                yield {"type":"token", "content": msg.content,
                       "node": meta.get("langgraph_node")}
        elif mode == "custom":
            yield {"type":"progress", "data": payload}

@app.post("/chat/stream")
async def chat_stream(body: ChatIn):
    async def gen():
        async for ev in stream_turn(body.message, body.user_id, body.session_id):
            yield f"data: {json.dumps(ev, ensure_ascii=False)}\n\n"
        yield "data: [DONE]\n\n"
    return StreamingResponse(gen(), media_type="text/event-stream")

在工具调用节点内通过 get_stream_writer() 主动推送进度事件:

python
from langgraph.config import get_stream_writer

async def order_query_node(state):
    writer = get_stream_writer()
    writer({"event":"tool_start","tool":"order_query","message":"正在查询订单信息…"})
    res = await order_query_tool.ainvoke({"order_id": state["order_id"]})
    writer({"event":"tool_end","tool":"order_query"})
    if not res.get("ok"):
        # 降级处理
        ...

错误处理覆盖四类场景。工具调用失败由 with_retry 装饰器自动重试 2 次;若仍失败,节点返回降级文案"系统暂时无法查询订单信息,已为您转人工"并通过 Command(goto="human_handoff") 路由。API 超时由 httpx 超时捕获,同上降级。意图识别置信度低于 0.6 直接转人工。退款执行工具 refund_execute_tool 禁用自动重试,防止重复退款,失败即转人工。summary_node 失败不阻塞会话结束,异常捕获后写入错误日志,摘要字段留空,由夜间批处理任务补救。所有工具错误均写入 last_tool_error 字段,summary 时一并写入审计库。

27.6 部署与测试 ​

部署形态采用 Docker Compose 启动 FastAPI 应用、Postgres 与可选的 LangGraph Server。前端通过 SSE 接入 /chat/stream,审批后台通过 /approval 接口调用 Command(resume=...) 恢复中断的图。

yaml
# docker-compose.yml
version: "3.9"
services:
  app:
    build: .
    ports: ["8000:8000"]
    environment:
      - DATABASE_URL=postgresql://cs_user:cs_pwd@pg:5432/langgraph
      - OPENAI_API_KEY=${OPENAI_API_KEY}
      - LANGSMITH_API_KEY=${LANGSMITH_API_KEY}
      - LANGSMITH_TRACING=true
    depends_on: [pg]
    restart: always
  pg:
    image: postgres:16
    environment:
      POSTGRES_USER: cs_user
      POSTGRES_PASSWORD: cs_pwd
      POSTGRES_DB: langgraph
    ports: ["5432:5432"]
    volumes: ["cs_pgdata:/var/lib/postgresql/data"]
    healthcheck:
      test: ["CMD-SHELL","pg_isready -U cs_user -d langgraph"]
      interval: 5s
      timeout: 3s
      retries: 10
volumes:
  cs_pgdata:

审批恢复接口示例:

python
class ApprovalIn(BaseModel):
    session_id: str
    action: Literal["approve","reject","modify"]
    amount: float = None
    comment: str = ""

@app.post("/approval")
async def handle_approval(body: ApprovalIn):
    """审批后台在审批人操作后调用此接口恢复中断的图。"""
    config = {"configurable": {"thread_id": f"sess:{body.session_id}"}}
    # 通过 Command(resume=...) 注入审批结果,框架从 interrupt 断点继续执行
    async for _ in graph.astream(
        Command(resume={"action": body.action,
                        "amount": body.amount,
                        "comment": body.comment}),
        config=config,
        stream_mode="updates"):
        pass
    return {"status": "ok"}

部署要点:FastAPI 以 uvicorn worker 启动,worker 数量按 CPU 核数配置(I/O 密集场景建议 2×cores);数据库连接池上限匹配 worker 数 × 每 worker 最大连接(通常每 worker 2-3 个 PG 连接);生产环境前置 Nginx 做 TLS 终止与连接复用,SSE 路径关闭 proxy_buffering 设置 proxy_read_timeout 1h 防止长连接断开;LangSmith 项目名按租户分项目(如 cs-tenantA),便于按租户观测 token 与错误率;Postgres 主从部署,checkpointer/Store 使用连接串指向主库写入,若部署只读副本可通过 separate pool 服务读查询;HITL 审批后台单独部署,只暴露审批 API 与坐席 SSE 通道,不直接连接模型。

运维与监控:Prometheus 暴露 /metrics,指标包括 cs_sessions_active、cs_intent_total{intent}、cs_tool_latency_seconds{tool}、cs_human_handoff_total{reason}、cs_refund_approval_total{action}、cs_checkpoint_write_latency、cs_sse_connections_active;关键告警:人工兜底率连续 10 分钟 > 15%、工具错误率连续 5 分钟 > 5%、refund 工具调用次数异常(可能资金风险)、checkpoint 写入 P99 > 500ms(存储瓶颈);LangSmith dashboard 监控每个节点的平均 token 与延迟,对异常意图节点触发 prompt 审查。

客服系统特有的质量保障:建立坐席反馈闭环,人工坐席接手后将"机器人回答错误/遗漏/不准确"的 case 标记回传,作为 intent_router 与各业务 Agent prompt 的优化样本;每周自动生成机器人解决率、平均会话轮次、一次性解决率、人工兜底率四类核心指标报表;对高频无法识别的问法聚类后补充 FAQ 知识库或新增业务工具,持续提升自动化率。

多轮对话体验优化:使用 trim_messages 控制 messages 长度但保留最近一轮的用户问题与系统回复;当意图切换时(如刚查完订单马上问退款),在新子图首轮消息中自动注入已确认的 order_id 实体,避免用户重复输入;对于需要收集多字段的工单场景,使用 Pydantic Form 模型记录已收集字段,避免子图内部用散乱的 flag 字段维护状态;流式回复先输出"您好,"等开场,再边生成边发送,首字延迟控制在 500ms 以内。

测试方案分为四层。第一层意图识别测试:维护 200+ 条标注语料覆盖六类意图与歧义问法,使用 pytest 参数化批量校验 intent 准确率、order_id 抽取 F1、置信度阈值下的人工兜底率,目标整体准确率 ≥ 0.92,兜底率 ≤ 8%。第二层退款审批流程测试:覆盖 approve/reject/modify/超时四种路径,断言 refund_execute_tool 调用次数与资金接口不被重复触发、checkpoint 状态迁移符合预期、幂等键生效。第三层并发 session 隔离测试:以 asyncio.gather 启动 50 个并发 session,每个 session 使用独立 thread_id,交叉校验 messages 不串流、order_id 不串写、human_approval_pending 不跨会话污染。第四层工具失败降级测试:以 respx/pytest-httpx mock 订单接口 500、超时、空返回,断言降级文案出现且转人工被触发。第五层 HITL 恢复测试:通过 graph.get_state 读取 interrupt 状态,注入 approve/reject 两种 resume 值,断言后续节点按分支执行、历史 messages 不丢失。所有测试以 pytest + pytest-asyncio 编写,纳入 CI,Golden Dataset 回归作为发版门禁。

数据与合规:用户消息中可能包含手机号、地址等 PII(Personally Identifiable Information,个人身份信息),日志落盘前需用正则脱敏;退款执行必须校验审批人权限(角色、金额阈值、与工单的绑定关系);会话摘要写入 Store 前去除敏感字段;审计日志保留 180 天以上以满足合规要求。