☰
LangGraph实战:构建带人工审核的智能工单系统
2026/10/8 11:06:51 网站建设 项目流程

1. 从一次多轮对话翻车说起:为什么需要LangGraph

去年底我接了一个智能客服的项目,需求听起来很简单:用户问问题,系统查知识库,回答,如果答不上来就转人工。我一开始用传统的链式调用(Chain)搭了个原型,前两轮对话跑得挺顺,到第三轮就出问题了——用户说“那帮我改一下刚才那个订单的地址”,系统完全不知道“刚才那个订单”指的是什么,因为链式调用是无状态的,每次请求都是全新的开始。

这就是典型的对话状态管理缺失。传统Chain模式像一条单向流水线,数据从A流到B再流到C,中间没有记忆,也没法根据情况拐弯。而真实业务里,用户会追问、会改主意、会突然切换话题,甚至会在关键节点需要人工审核。这些需求逼着我从Chain转向了LangGraph。

LangGraph的核心思路是把对话流程建模成一张图(Graph),节点(Node)是处理步骤,边(Edge)是流转方向,而状态(State)贯穿整张图,像一条河一样带着上下文从上游流到下游。更关键的是,它支持条件路由——根据当前状态决定下一步走哪个分支;支持人工介入——在关键节点暂停,等人确认后再继续;还支持Checkpointer——把每一步的状态持久化,随时可以恢复。

这篇文章我会用一个完整的案例把这些能力串起来:一个带人工审核的智能工单处理系统。用户提交工单,系统自动分类、检索知识库、生成回复草稿,如果置信度低或者涉及敏感操作,就暂停等人工审核,审核通过后继续执行。整套流程涉及状态定义、条件分支、中断恢复、持久化存储,我会把每一步的代码和踩过的坑都摊开讲。

适合谁看?如果你已经用过LangChain的基础Chain,想进阶到有状态、可中断、可恢复的复杂流程,这篇就是为你写的。如果你还没接触过LangGraph,也没关系,我会从核心概念讲起,保证你能跟着跑通。

2. 核心概念拆解:State、Node、Edge、Checkpointer到底怎么配合

2.1 State不是普通变量,它是图的“共享内存”

很多人第一次接触LangGraph的State,会把它当成一个普通的字典传来传去。其实不是。State是整张图的共享内存,每个节点都能读到它,也能往里面写东西。LangGraph用了一个很巧妙的设计:你定义一个TypedDict或者Pydantic模型来描述State的结构,然后每个节点函数接收当前State,返回一个增量更新(partial update),框架会自动帮你合并。

举个例子,我定义的工单State长这样:

from typing import TypedDict, Annotated from langgraph.graph import add_messages class TicketState(TypedDict): messages: Annotated[list, add_messages] ticket_id: str category: str confidence: float draft_reply: str needs_human: bool human_approved: bool

这里有个关键点:messages字段用了Annotated[list, add_messages]。这个add_messages是一个reducer函数,它告诉LangGraph:当多个节点都往messages里写东西时,不要覆盖,而是追加。默认情况下,如果你不指定reducer,后写的会覆盖先写的。这个设计在对话场景里特别重要,因为每一轮对话都要保留历史。

注意:State的字段尽量用扁平结构,不要嵌套太深。我试过嵌套三层字典,结果调试时打印出来一团糟,后来改成扁平结构加前缀命名,清晰多了。

2.2 Node是纯函数,但别在里面做副作用操作

Node就是图里的一个处理单元,本质上是一个Python函数,接收State,返回更新。LangGraph的Node有一个很重要的约束:尽量保持纯函数。什么意思?就是同样的输入应该产生同样的输出,不要在Node里面直接写数据库、发邮件、调外部API产生副作用。

为什么?因为LangGraph支持重放(replay)和恢复(resume)。如果Node里有副作用,恢复时可能会重复执行。正确的做法是把副作用操作抽出来,要么放在Node返回之后由外部处理,要么用幂等设计。

比如我一开始在“发送回复”这个Node里直接调了邮件API,结果人工审核恢复后,邮件被发了两次。后来改成:Node只负责生成“待发送”标记,真正的发送逻辑放在图执行完成之后统一处理。

2.3 Edge分两种:普通边和条件边

普通边就是A节点执行完直接到B节点,用add_edge("A", "B")。条件边则是根据State里的某个值决定下一步去哪,用add_conditional_edges。

条件路由是LangGraph最实用的能力之一。我的工单系统里有三个分支:如果分类置信度高于0.8且不涉及敏感操作,直接自动回复;如果置信度在0.5到0.8之间,走人工审核;如果低于0.5,直接转人工并标记为“需人工处理”。

def route_after_classify(state: TicketState): if state["confidence"] >= 0.8 and not state["needs_human"]: return "auto_reply" elif state["confidence"] >= 0.5: return "human_review" else: return "escalate"

这个路由函数返回的是目标节点的名字,LangGraph会根据返回值把流程导向对应的节点。注意,路由函数本身不修改State,它只做判断。

2.4 Checkpointer是状态持久化的关键

Checkpointer是LangGraph里最容易被忽视但最重要的组件。它负责在每一步执行后把State保存下来,这样当流程中断(比如等人工审核)后,可以从断点恢复,而不是从头再来。

LangGraph提供了几种Checkpointer:MemorySaver存在内存里,适合开发和测试;SqliteSaver存在本地文件,适合单机部署;PostgresSaver存在数据库,适合生产环境。我一开始用MemorySaver,重启服务后所有待审核的工单全丢了,后来换成SqliteSaver才解决。

Checkpointer的工作机制是:每次图执行到一个“超级步”(super-step)结束时,自动保存当前State的快照,并关联一个thread_id。恢复时用同样的thread_id调用,就能从上次中断的地方继续。

实操心得:thread_id的命名要有业务含义,比如用工单号或者用户ID加时间戳。我试过用随机UUID,结果排查问题时根本对不上号。

3. 完整案例:带人工审核的智能工单处理系统

3.1 系统流程设计与状态定义

先把这个系统的完整流程画清楚(用文字描述,不用图):

  1. 用户提交工单,进入classify节点,系统对工单内容做分类(比如“退款”、“技术故障”、“咨询”),并给出置信度。
  2. 根据置信度和是否敏感,路由到三个分支之一:auto_reply、human_review、escalate。
  3. auto_reply节点生成回复草稿并直接发送。
  4. human_review节点生成草稿后中断,等待人工审核。人工审核通过后继续到send_reply,不通过则到revise节点重新生成。
  5. escalate节点直接标记为人工处理,不生成自动回复。
  6. 所有分支最终汇聚到finalize节点,记录处理结果。

State的定义我前面已经给了,这里补充一下每个字段的用途:

字段类型用途
messageslist对话历史,用add_messages追加
ticket_idstr工单唯一标识
categorystr分类结果
confidencefloat分类置信度
draft_replystr生成的回复草稿
needs_humanbool是否涉及敏感操作
human_approvedbool人工审核是否通过

3.2 节点实现:分类、生成、审核、发送

分类节点我用了一个简单的关键词匹配加LLM判断的混合策略。纯LLM分类有时候不稳定,加上关键词规则可以兜底。

def classify_node(state: TicketState): content = state["messages"][-1].content # 关键词规则 sensitive_keywords = ["退款", "注销", "投诉"] needs_human = any(kw in content for kw in sensitive_keywords) # LLM分类(这里用伪代码表示) category, confidence = llm_classify(content) return { "category": category, "confidence": confidence, "needs_human": needs_human }

生成回复草稿的节点:

def generate_draft_node(state: TicketState): prompt = f"工单分类:{state['category']}\n用户问题:{state['messages'][-1].content}\n请生成回复草稿。" draft = llm_generate(prompt) return {"draft_reply": draft}

人工审核节点本身不做事,它只是一个中断点。LangGraph的中断机制是在编译图时指定interrupt_before或interrupt_after:

graph = builder.compile( checkpointer=checkpointer, interrupt_before=["human_review"] )

这样当流程到达human_review节点之前,会自动暂停,把State保存到Checkpointer,然后返回控制权给调用方。调用方拿到中断信号后,可以展示草稿给人工审核,审核完再调用graph.invoke(None, config)继续执行。

发送节点:

def send_reply_node(state: TicketState): if state.get("human_approved"): # 实际发送逻辑 send_email(state["draft_reply"]) return {"messages": [AIMessage(content=state["draft_reply"])]} else: return {"messages": [AIMessage(content="审核未通过,需修改")]}

3.3 条件路由的三种分支与优先级

路由函数我前面给了简化版,实际项目中要考虑更多情况。比如“退款”类工单即使置信度很高也必须人工审核,因为涉及资金操作。所以路由逻辑要加一层敏感判断:

def route_after_classify(state: TicketState): if state["needs_human"]: return "human_review" if state["confidence"] >= 0.8: return "auto_reply" elif state["confidence"] >= 0.5: return "human_review" else: return "escalate"

这里有个优先级问题:needs_human的判断放在最前面,因为敏感操作优先级最高。我踩过的坑是先把置信度判断放前面,结果一个“退款”工单因为置信度0.9直接自动回复了,差点造成资金损失。

注意:条件路由的返回值必须是图中已定义的节点名,否则运行时会报错。建议把节点名定义成常量,避免拼写错误。

3.4 Checkpointer配置与中断恢复实操

Checkpointer的配置很简单,但有几个细节要注意:

from langgraph.checkpoint.sqlite import SqliteSaver checkpointer = SqliteSaver.from_conn_string("tickets.db") graph = builder.compile( checkpointer=checkpointer, interrupt_before=["human_review"] )

调用时传入thread_id:

config = {"configurable": {"thread_id": "ticket_12345"}} # 第一次调用,会在human_review前中断 result = graph.invoke({"messages": [HumanMessage(content="我要退款")]}, config) # 此时State已保存,可以读取 state = graph.get_state(config) print(state.values["draft_reply"]) # 人工审核通过后,更新State并继续 graph.update_state(config, {"human_approved": True}) result = graph.invoke(None, config)

这里的关键是graph.update_state,它允许你在中断后修改State。我一开始不知道这个API,试图重新invoke整个图,结果又从头跑了一遍。

实操心得:interrupt_before和interrupt_after的区别要搞清楚。interrupt_before是在节点执行前暂停,interrupt_after是在节点执行后暂停。人工审核场景一般用interrupt_before,因为审核前不需要执行审核节点本身。

4. 踩坑实录:状态管理、路由、中断恢复的常见问题

4.1 状态覆盖问题:为什么我的messages只剩最后一条

这是新手最容易踩的坑。如果你定义State时没有给messages加reducer,每次节点返回新的messages列表时,会直接覆盖旧值。我一开始就是这么写的:

class State(TypedDict): messages: list # 没有reducer

结果对话到第三轮,历史全没了。正确的写法是:

from typing import Annotated from langgraph.graph import add_messages class State(TypedDict): messages: Annotated[list, add_messages]

add_messages会自动把新消息追加到列表末尾,而不是替换。如果你需要自定义合并逻辑,也可以写自己的reducer函数。

4.2 条件路由不生效:检查返回值是否匹配节点名

条件路由不生效通常有两个原因:一是路由函数返回值拼写错误,二是节点名和返回值不一致。LangGraph在编译时不会校验路由返回值,只有运行时才会报错。我的建议是把节点名定义成常量:

NODE_CLASSIFY = "classify" NODE_AUTO_REPLY = "auto_reply" NODE_HUMAN_REVIEW = "human_review" NODE_ESCALATE = "escalate"

路由函数返回这些常量,添加边时也用常量,这样拼写错误在IDE里就能发现。

4.3 中断恢复后重复执行:Checkpointer和幂等设计

前面提到过,如果Node里有副作用,恢复时可能重复执行。除了把副作用抽出来,还有一种方案是幂等设计。比如发送邮件时,用工单ID作为幂等键,发送前先查一下是否已发送过。

def send_email_idempotent(ticket_id, content): if redis.get(f"sent:{ticket_id}"): return send_email(content) redis.set(f"sent:{ticket_id}", "1", ex=86400)

这样即使重复执行,也不会真的发两次。

4.4 常见问题速查表

问题现象可能原因解决方法
messages只剩最后一条未加add_messages reducer用Annotated[list, add_messages]
条件路由不跳转返回值与节点名不匹配用常量定义节点名
中断后无法恢复未传thread_id或Checkpointer未配置检查config和checkpointer
恢复后重复执行副作用Node内有非幂等操作抽离副作用或做幂等设计
State更新不生效返回了完整State而非增量只返回需要更新的字段
图编译报错节点名重复或边指向不存在的节点检查add_node和add_edge

5. 进阶技巧:让LangGraph流程更稳、更好维护

5.1 用子图拆分复杂流程

当流程节点超过10个时,整张图会变得很难维护。LangGraph支持子图(Subgraph),可以把一组相关节点封装成一个子图,然后像普通节点一样嵌入主图。比如我把“分类+路由”封装成一个子图,“生成+审核+发送”封装成另一个子图,主图只负责编排。

子图的State可以和主图不同,通过输入输出映射来衔接。这个特性在大型项目里特别有用,不同团队可以各自维护自己的子图。

5.2 状态版本管理与迁移

生产环境中State的结构可能会变化,比如新增字段、修改字段类型。如果Checkpointer里存的是旧版本State,恢复时会出错。LangGraph的Checkpointer支持版本迁移,你可以在保存State时带上版本号,恢复时根据版本号做转换。

我目前的方案是在State里加一个schema_version字段,每次结构变更时递增,恢复时检查版本并做兼容处理。虽然有点土,但很实用。

5.3 可观测性:日志、追踪与调试

LangGraph的调试信息默认比较简略,建议接入LangSmith或者自己打日志。我在每个节点入口和出口都加了日志:

import logging logger = logging.getLogger(__name__) def classify_node(state: TicketState): logger.info(f"classify_node input: ticket_id={state['ticket_id']}") # ... 处理逻辑 logger.info(f"classify_node output: category={category}, confidence={confidence}") return {...}

这样出问题时可以快速定位是哪个节点、哪一步出了偏差。另外,graph.get_state(config)可以随时读取当前State,配合日志使用效果很好。

5.4 性能优化:减少不必要的LLM调用

LLM调用是整条链路里最慢也最贵的环节。我的优化策略是:分类节点先用关键词规则过滤,只有规则无法判断时才调LLM;生成草稿节点缓存常见问题的回复模板,命中模板直接返回,不调LLM。实测下来,整体响应时间从平均3.2秒降到了1.1秒,成本也降了六成。

实操心得:LangGraph的节点是串行执行的,如果某个节点特别慢,可以考虑把它拆成多个并行节点,用add_edge的并行能力加速。不过并行节点之间的State合并要小心,确保reducer能正确处理并发写入。

6. 我个人在实际操作中的几点体会

这套工单系统上线跑了三个月,处理了大概两万多个工单,人工审核的介入率从最初的35%降到了12%,自动回复的准确率稳定在91%左右。回过头看,LangGraph最让我满意的不是它的功能有多强,而是它的心智模型足够简单:State是共享内存,Node是处理单元,Edge是流转规则,Checkpointer是存档点。把这四个概念吃透,剩下的就是业务逻辑的堆叠。

如果让我给刚上手的人一条建议,那就是:先把State定义清楚,再写节点。我见过太多人上来就写节点逻辑,写到一半发现State不够用,回头改State导致所有节点都要调整。State是整张图的契约,契约定好了,后面的实现就是水到渠成的事。

另外,Checkpointer千万别等到上线才配。开发阶段就用SqliteSaver,养成每步持久化的习惯,调试时会感谢自己的。MemorySaver只适合跑单元测试,真实场景下服务一重启数据就没了,这个坑我替你们踩过了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询