资讯动态

LangGraph实战:状态管理、条件路由与人工介入的工程化落地

发布时间:2026/10/8 4:48:49 来源:尧图企业网站定制
1. 从能跑通到敢上线LangGraph 真正卡住人的三个地方如果你已经用 LangGraph 搭过一个能跑通的对话流程大概率会经历这样一个阶段Demo 阶段一切正常节点串起来、边连起来、跑一遍输出也对但一旦想把它放到真实业务里问题就全冒出来了。用户中途刷新页面对话状态丢了某个节点判断出错整条链路直接崩需要人工审核的环节代码里根本不知道怎么暂停再恢复。这三个问题恰好对应 LangGraph 里最容易被低估、也最能拉开水平差距的三块能力状态管理State Management、条件路由Conditional Routing、人工介入Human-in-the-loop。我见过太多人把 LangGraph 当成带图的 LangChain来用节点里塞一堆逻辑状态用最朴素的字典传来传去路由靠 if-else 硬写。这种写法在单轮、无中断、无持久化的场景下确实能跑但只要业务稍微复杂一点——多轮对话要记住上下文、不同意图要走不同分支、关键操作要人工确认——就会立刻暴露出架构上的脆弱。LangGraph 的设计初衷其实就是为了解决这些有状态、有分支、有中断的复杂编排问题只是很多人没把它的核心机制用起来。这篇内容我会围绕一个完整的案例展开一个带人工审核环节的智能客服工单处理流程。用户提交问题系统先做意图识别简单问题自动回复复杂问题走知识库检索生成草稿草稿必须经过人工审核才能发送审核不通过则打回重做。这个案例麻雀虽小但把状态管理、条件路由、人工介入三块能力全用上了而且每一块都有真实的工程取舍。我会把每个环节的为什么这么设计讲透把踩过的坑摊开让你看完能直接把这套模式迁移到自己的项目里。适合谁看如果你已经写过基础的 LangGraph 节点和边但对StateGraph的 reducer、Checkpointer的持久化、interrupt的中断恢复机制还停留在照着文档抄一遍的阶段那这篇就是给你准备的。如果你还没接触过 LangGraph建议先把官方 Quick Start 跑一遍再回来不然有些概念会有点跳。2. 状态管理为什么你的 State 一并发就乱套2.1 State 不是普通字典reducer 才是它的灵魂很多人第一次定义 LangGraph 的 State会直接写一个TypedDict然后每个节点返回一个字典去更新它。看起来没问题但这里藏着一个关键机制当多个节点或同一节点的多次执行都要更新同一个字段时LangGraph 需要一个合并策略也就是 reducer。如果你不指定 reducer默认行为是覆盖——后写的直接盖掉先写的。这在串行流程里没事但一旦涉及并行分支、循环累加、消息追加就会出大问题。最典型的就是对话历史。假设你的 State 里有个messages字段意图识别节点往里加一条系统消息检索节点又加一条如果用的是默认覆盖策略最后你只会看到最后一条前面的全丢了。正确做法是给这个字段指定add或add_messages作为 reducerfrom typing import Annotated, TypedDict from langgraph.graph.message import add_messages class TicketState(TypedDict): messages: Annotated[list, add_messages] intent: str draft: str review_result: str retry_count: intAnnotated[list, add_messages]这行的意思是这个字段每次更新时不是覆盖而是把新消息追加进去并且add_messages还会自动处理消息 ID 去重和更新。这是 LangGraph 里最常用的 reducer 之一。那retry_count这种计数器呢如果你希望它每次加一而不是被覆盖就得自己写一个 reducerdef increment(current: int, update: int) - int: return current update class TicketState(TypedDict): retry_count: Annotated[int, increment]这样节点返回{retry_count: 1}时实际效果是current 1而不是直接变成 1。这个细节在实现重试次数限制时特别关键我后面讲条件路由时会再用到。提示判断一个字段要不要 reducer就问自己一句话——这个字段会不会被多个地方更新且我希望它们叠加而不是互相覆盖 会就加 reducer不会默认覆盖反而更安全。2.2 状态字段的设计别把什么都往 State 里塞新手另一个常见误区是把 State 当成全局变量仓库什么临时变量都往里放。结果 State 越来越臃肿节点之间耦合越来越重调试时根本不知道哪个字段是谁改的。我的经验是State 里只放跨节点需要共享、且需要被持久化的数据。节点内部的临时计算、只在一个节点里用一次的中间变量直接放在节点函数里当局部变量就行。拿我们这个工单案例来说State 里真正需要保留的字段其实就几个字段类型reducer作用messageslistadd_messages完整对话历史供 LLM 理解上下文intentstr默认覆盖当前识别出的意图决定路由draftstr默认覆盖生成的回复草稿供人工审核review_resultstr默认覆盖人工审核结论approve / rejectretry_countintincrement打回重做的次数用于限制循环注意intent、draft、review_result用的都是默认覆盖因为它们在同一时刻只有一个当前值覆盖是符合语义的。而messages和retry_count需要累积所以加了 reducer。这个区分很重要加错 reducer 会导致状态越滚越大或者逻辑错乱。还有一个容易被忽略的点State 的字段应该是可序列化的。因为一旦你用了 Checkpointer 做持久化整个 State 会被序列化存到数据库或内存里。如果你往 State 里塞了一个数据库连接对象、一个不可序列化的自定义类实例持久化时就会直接报错。我踩过这个坑当时把一个 HTTP client 放进了 State本地跑没事一接 Postgres checkpointer 就崩排查了半天才反应过来。2.3 Checkpointer让状态活过一次请求状态管理里最容易被低估、但工程价值最高的就是Checkpointer。没有它你的图只能在一次调用内保持状态请求一结束State 就没了。有了它每次节点执行后的 State 快照都会被保存下来你可以用同一个thread_id随时恢复对话甚至能做到用户关掉页面第二天回来接着聊。LangGraph 提供了几种 Checkpointer选型上有个简单的判断逻辑MemorySaver存在内存里进程重启就没了。适合本地开发和单元测试绝对不要用在生产。SqliteSaver存本地文件轻量适合单机小规模部署或者桌面应用。PostgresSaver存数据库支持并发、支持多实例共享生产环境首选。配置方式很直接from langgraph.checkpoint.postgres import PostgresSaver with PostgresSaver.from_conn_string(postgresql://user:passlocalhost/db) as checkpointer: checkpointer.setup() # 首次运行建表 graph builder.compile(checkpointercheckpointer)调用时通过config传入thread_idconfig {configurable: {thread_id: ticket-12345}} result graph.invoke({messages: [(user, 我的订单还没发货)]}, config)这里thread_id就是这条对话的身份证。同一个thread_id再次 invokeLangGraph 会自动把上次的 State 加载回来接着往下走。这个机制是人工介入能实现的基础——因为暂停本质上就是把当前 State 存下来恢复就是把它读回来。注意thread_id的生成策略要提前想好。用用户 ID 会导致同一用户多会话互相污染用随机 UUID 又没法让用户接着上次聊。常见做法是用户ID 会话ID组合会话ID由前端在新建对话时生成并保存。我实测下来PostgresSaver 在并发场景下的表现最稳但要注意连接池配置。如果每个请求都新建连接高并发下数据库连接数会爆。正确做法是用连接池把PostgresSaver的初始化放在应用启动时做一次全局复用。3. 条件路由让图自己决定下一步往哪走3.1 条件边的本质一个返回下一个节点名的函数如果说普通边是固定线路那条件边就是岔路口的路牌。它的核心是一个路由函数输入当前 State输出下一个要去的节点名。LangGraph 用add_conditional_edges来注册def route_by_intent(state: TicketState) - str: if state[intent] simple: return auto_reply elif state[intent] complex: return retrieve_knowledge else: return human_review builder.add_conditional_edges( classify_intent, route_by_intent, { auto_reply: auto_reply, retrieve_knowledge: retrieve_knowledge, human_review: human_review, } )第三个参数是路由映射表把路由函数返回的字符串映射到实际节点名。很多人会问既然返回的就是节点名为什么还要这个映射答案是解耦。路由函数返回的是业务语义比如simple映射表负责把它翻译成图结构里的节点。这样以后你改了节点名只要改映射表路由函数不用动。这个设计在大型图里特别有用。3.2 路由函数要纯别在里面做副作用操作我见过有人在路由函数里直接调用 LLM 做判断或者去查数据库。这能跑但非常危险。原因是路由函数可能被多次调用比如图在调试、重放、或者某些边界情况下如果它有副作用就会重复执行导致状态被改乱、费用翻倍。正确做法是把判断逻辑前置到节点里路由函数只读 State 做纯判断。比如意图识别应该在classify_intent节点里调用 LLM把结果写进state[intent]然后路由函数只读这个字段。这样职责清晰也方便测试——你可以直接构造一个 State 来单测路由函数不用 mock LLM。def classify_intent(state: TicketState) - dict: # 这里才调用 LLM result llm.invoke(...) return {intent: result} def route_by_intent(state: TicketState) - str: # 纯读无副作用 return state[intent]3.3 循环与重试用条件边实现打回重做条件边最强大的用法之一是构造循环。我们这个案例里人工审核不通过要打回重新生成草稿这就是一个循环。实现方式是让human_review节点根据审核结果条件路由通过就去send_reply不通过就回到generate_draft。但循环必须有个刹车否则审核一直不通过就会无限循环。这就是前面retry_count字段的用武之地def route_after_review(state: TicketState) - str: if state[review_result] approve: return send_reply if state[retry_count] 3: return escalate # 超过3次转人工升级 return generate_draft这里retry_count用的是 increment reducer每次回到generate_draft前在审核节点里返回{retry_count: 1}它就会累加。到 3 次还没通过就路由到escalate节点交给更高级别的人工处理。这个重试上限 兜底分支的模式是所有带循环的图都必须考虑的否则线上一定会出现某个 case 卡死。提示循环的刹车除了次数限制还可以用时间限制、费用限制。比如在 State 里记一个start_time路由时判断是否超时。具体用哪种取决于你的业务对最坏情况的容忍度。3.4 多条件组合路由的写法真实业务里路由往往不是单一条件而是多个条件的组合。比如意图是复杂问题 且 知识库检索到了高置信度答案 且 用户是 VIP才走自动回复否则走人工。这种多条件路由我建议把判断逻辑抽成一个独立函数保持路由函数本身简洁def should_auto_reply(state: TicketState) - bool: return ( state[intent] complex and state.get(retrieval_score, 0) 0.8 and state.get(user_level) vip ) def route_after_retrieval(state: TicketState) - str: return auto_reply if should_auto_reply(state) else human_review这样should_auto_reply可以单独写单元测试覆盖各种条件组合而路由函数只负责翻译结果。这种分层在条件复杂时能极大降低维护成本。我自己的项目里路由相关的判断函数都会单独放一个模块配一套测试用例改条件时先跑测试心里有底。4. 人工介入让图暂停和恢复的正确姿势4.1 interrupt不是抛异常是存档退出人工介入是 LangGraph 里最让人兴奋、也最容易用错的能力。核心 API 是interrupt它在节点里调用作用是暂停图的执行把当前 State 存进 Checkpointer然后把控制权交还给调用方。等人工处理完再用Command(resume...)恢复执行。很多人第一次用interrupt会以为它像异常一样中断其实不是。它更像游戏里的存档退出——游戏进度存好了你关掉游戏第二天读档接着玩。这个心智模型很重要因为它解释了为什么interrupt必须配合 Checkpointer 使用没有存档机制暂停了就没法恢复。from langgraph.types import interrupt, Command def human_review(state: TicketState) - dict: # 暂停把草稿抛给外部 decision interrupt({ draft: state[draft], question: 请审核这条回复草稿通过请回复 approve打回请回复 reject }) # 恢复后decision 就是外部传入的值 return {review_result: decision}调用侧这样恢复# 第一次 invoke会在 human_review 处暂停 result graph.invoke(inputs, config) # 人工审核后用 Command 恢复 graph.invoke(Command(resumeapprove), config)注意Command(resume...)里的值会作为interrupt(...)的返回值传回节点内部。这个一问一答的机制就是人工介入的核心。4.2 中断点的位置选择越靠后越好但别太靠后中断点放哪里是个需要权衡的设计问题。放太靠前人工要处理的信息太少没法做判断放太靠后比如已经调用了发送接口才中断那审核就失去意义了。原则是中断点应该放在所有自动处理已完成、但副作用操作发送、扣款、写库尚未执行的位置。在我们这个案例里human_review放在generate_draft之后、send_reply之前正好符合这个原则。草稿已经生成好了人工能看到完整内容做判断但消息还没发出去打回的成本很低。还有一个细节中断时抛给外部的数据要自包含。也就是说外部拿到interrupt的 payload应该能独立做出判断不需要再去查 State。所以我在 payload 里放了draft和question而不是只放一个draft_id让外部自己去查。这样前端展示、审核界面都能直接用减少耦合。4.3 恢复时的状态一致性小心恢复到了错误的现场用interrupt最容易踩的坑是恢复时 State 已经变了。因为interrupt暂停后如果同一个thread_id又收到了新的用户输入图可能会先处理新输入导致恢复时的 State 和你暂停时看到的不是同一个。这在多用户、异步场景下特别容易出问题。解决办法有两个。第一给中断加唯一标识恢复时校验def human_review(state: TicketState) - dict: decision interrupt({ review_id: state[draft_id], # 唯一标识 draft: state[draft], }) # 恢复后校验 review_id 是否匹配 if decision.get(review_id) ! state[draft_id]: raise ValueError(恢复的审核结果与当前草稿不匹配) return {review_result: decision[result]}第二在中断期间锁定该 thread不允许新输入进入。这需要业务层配合比如前端在等待审核时禁用输入框后端在恢复前检查是否有未完成的中断。我实际项目里用的是第一种方案为主、第二种为辅。因为纯靠业务层锁定不可靠网络抖动、用户误操作都可能绕过而review_id校验是代码层面的硬约束更稳。4.4 多轮人工介入一个流程里多次暂停有些流程不止一次人工介入。比如工单处理里草稿审核是一次如果打回重做后还是不满意可能需要主管二次审核。这种多次中断的场景LangGraph 是支持的但要注意每次中断都要有独立的标识和恢复逻辑。我的做法是在 State 里维护一个interrupt_stage字段记录当前处于哪个审核阶段def review_node(state: TicketState) - dict: stage state.get(interrupt_stage, first) decision interrupt({stage: stage, draft: state[draft]}) if stage first and decision reject: return {interrupt_stage: second, review_result: reject} return {review_result: decision}这样恢复时节点能根据stage知道当前该处理哪一步。虽然逻辑复杂了点但比一个中断点处理所有情况要清晰得多。我建议中断阶段超过两个时就考虑拆成多个节点每个节点负责一个阶段的中断可读性会好很多。5. 把三块能力拼起来完整案例的图结构与代码骨架5.1 图结构总览把前面讲的东西拼起来我们这个工单处理流程的图结构是这样的入口 → classify_intent意图识别 ├─ simple → auto_reply → END └─ complex → retrieve_knowledge → generate_draft → human_review ├─ approve → send_reply → END ├─ reject3次→ generate_draft └─ reject≥3次→ escalate → END这个图里classify_intent后面是条件路由human_review后面也是条件路由generate_draft → human_review → generate_draft构成一个带刹车的循环human_review内部有interrupt实现人工暂停。三块能力全部用上了而且互相配合。5.2 关键节点的实现要点classify_intent节点负责调 LLM 做意图分类把结果写进state[intent]。这里有个经验意图分类的 prompt 要给出明确的类别定义和边界例子否则 LLM 容易在模糊 case 上摇摆。我一般会在 prompt 里写清楚simple 指无需查知识库即可回答的常见问题complex 指需要检索或需要人工判断的问题并给两三个例子。retrieve_knowledge节点做向量检索把检索到的文档片段和置信度写进 State。置信度这个字段很关键它决定了后面能不能走自动回复。检索置信度的计算方式有很多种简单点可以用向量相似度分数复杂点可以加一个 rerank 模型。我建议至少保留一个分数字段方便后续路由做阈值判断。generate_draft节点把用户问题、检索结果、对话历史一起喂给 LLM 生成草稿。这里要注意prompt 里要明确这是草稿会经过人工审核让 LLM 知道它的输出不是最终版可以更谨慎一些。实测下来加了这句话之后草稿的过度自信问题会少很多。human_review节点就是前面讲的interrupt实现。send_reply节点执行真正的发送操作这是副作用节点必须放在审核之后。escalate节点处理超过重试上限的情况可以发通知给主管、或者转成更高级别的工单。5.3 编译与运行时的配置编译图的时候把 Checkpointer 传进去from langgraph.graph import StateGraph, END builder StateGraph(TicketState) builder.add_node(classify_intent, classify_intent) builder.add_node(auto_reply, auto_reply) builder.add_node(retrieve_knowledge, retrieve_knowledge) builder.add_node(generate_draft, generate_draft) builder.add_node(human_review, human_review) builder.add_node(send_reply, send_reply) builder.add_node(escalate, escalate) builder.set_entry_point(classify_intent) builder.add_conditional_edges(classify_intent, route_by_intent, {...}) builder.add_edge(retrieve_knowledge, generate_draft) builder.add_edge(generate_draft, human_review) builder.add_conditional_edges(human_review, route_after_review, {...}) builder.add_edge(auto_reply, END) builder.add_edge(send_reply, END) builder.add_edge(escalate, END) graph builder.compile(checkpointercheckpointer)运行时每个工单用一个独立的thread_idconfig {configurable: {thread_id: fticket-{ticket_id}}} graph.invoke({messages: [(user, user_input)]}, config)如果图在human_review处暂停了invoke的返回结果里会包含中断信息前端据此展示审核界面。审核完成后用Command(resume...)恢复。5.4 一个容易忽略的细节中断后的返回值结构graph.invoke在遇到interrupt时返回的不是普通的 State而是一个包含__interrupt__字段的特殊结构。很多人第一次遇到会懵不知道怎么取中断数据。正确姿势是result graph.invoke(inputs, config) if __interrupt__ in result: interrupt_data result[__interrupt__][0].value # interrupt_data 就是 interrupt() 里传的 payload print(interrupt_data[draft])这个结构在不同 LangGraph 版本里略有差异建议以你实际安装版本的文档为准。我踩过一次版本升级导致字段名变化的坑所以现在都会在代码里做一层封装把中断数据的提取逻辑集中在一个函数里升级时只改一处。6. 那些文档不会告诉你的实战坑6.1 Checkpointer 的表结构升级问题用 PostgresSaver 时checkpointer.setup()会建表。但如果你后续升级了 LangGraph 版本表结构可能需要迁移。我遇到过一次升级后旧数据读不出来因为新增了字段。解决办法是在应用启动时检查表结构版本必要时执行迁移脚本而不是无脑setup()。生产环境尤其要注意别在流量高峰期做这个操作。6.2 中断恢复的幂等性Command(resume...)恢复时如果网络超时导致调用方重试可能会恢复两次。如果你的恢复逻辑里有副作用比如恢复后直接发送消息就会重复发送。所以恢复操作本身要幂等或者在恢复前检查该中断是否已经被处理过。我的做法是在 State 里记一个resumed标记恢复时先检查。6.3 状态字段的幽灵残留用同一个thread_id长时间对话State 会越来越大尤其是messages字段。如果不做清理最终会撑爆上下文窗口或者存储。我的经验是定期对messages做摘要压缩把早期对话总结成一段文字只保留最近几轮原文。这个操作可以放在一个专门的节点里在对话轮次达到阈值时触发。6.4 条件路由的未覆盖分支路由函数如果返回了一个映射表里没有的字符串LangGraph 会直接报错。这在开发时是好事但在生产环境可能导致整个流程崩溃。我建议给路由函数加一个兜底分支返回一个默认节点比如human_review或escalate确保任何意外情况都有地方去而不是直接抛异常。def route_by_intent(state: TicketState) - str: intent state.get(intent, unknown) if intent in (simple, complex): return intent return human_review # 兜底这个习惯救过我好几次。有一次 LLM 返回了一个预期外的意图标签因为有了兜底流程没崩只是走了人工审核用户侧完全无感。6.5 调试时怎么看到完整状态LangGraph 提供了graph.get_state(config)来查看当前 State这在调试时非常有用。我一般在每个关键节点后打印一次状态快照或者用stream模式逐步观察。stream模式还能看到每个节点的输入输出排查哪个节点改坏了状态时特别高效。for event in graph.stream(inputs, config, stream_modeupdates): print(event)stream_modeupdates会输出每个节点执行后的状态增量一眼就能看出哪个节点返回了什么。这个技巧我在排查 reducer 问题时用得最多比打断点还快。7. 关于这套模式我自己的几点使用体会这套状态管理 条件路由 人工介入的组合我在几个项目里反复用过最大的体会是LangGraph 的复杂度不在 API而在设计决策。API 就那么几个StateGraph、add_conditional_edges、interrupt、Checkpointer一两天就能学会。但State 里放什么字段reducer 怎么选中断点放哪里循环怎么刹车这些决策才是真正决定项目能不能上线的关键。我的建议是在动手写代码之前先拿一张纸把图画出来标清楚每个节点的输入输出、每个条件边的判断依据、每个中断点的位置和恢复逻辑。图想清楚了代码就是翻译。反过来如果图没想清楚就开写大概率写到一半发现状态对不上、路由绕晕了然后推倒重来。另外测试要覆盖异常路径。正常流程跑通很容易难的是覆盖审核打回三次恢复时状态不匹配路由返回未知值这些边界。我现在的习惯是每个路由函数、每个中断节点都配单测构造各种畸形 State 去跑确保兜底逻辑生效。这些测试写起来不费劲但能挡住线上大部分事故。最后说个小的LangGraph 的版本迭代挺快有些 API 在不同版本间有变化。我建议在项目里锁定版本升级前先跑一遍完整测试。别小看这个我有次没锁版本CI 拉到了新版本interrupt的返回结构变了测试直接红了一片排查了半天才发现是版本问题。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑