资讯详情

LangGraph状态图编排实战:从State到循环与人工介入的完整指南

📅 2026/10/19 13:06:54 | 华诺云谱 👁 阅读
LangGraph状态图编排实战:从State到循环与人工介入的完整指南
我第一次在项目里引入LangGraph时同事看到代码的第一反应是这不就是给Agent外面套了个while循环吗当时我辩解了几句但说实话自己也没完全想清楚“状态图编排”到底比循环强在哪。直到后来一个多轮调研Agent项目被迫支持人工审核、断点续跑和失败重试时我才真正体会到状态图这套东西解决的不是“能不能跑”而是“跑乱了以后能不能收拾”。这篇文章不聊概念定义直接拆LangGraph状态图编排的核心机制State怎么管、Node和Edge怎么搭、循环和人工介入怎么落地最后放一个完整的多轮调研Agent搭建过程和我踩过的坑。适合已经写过LangChain基础链、正准备做复杂Agent的开发者。你不需要用过LangGraph从第一节开始跟着建图基本就能上手。1. 链式调用的死穴就是状态图登场的理由1.1 一个让我放弃链式调用的实际场景我最早写的版本用的是链式调用先检索再生成草稿再校验发现问题就手动回去重新调用一次检索函数。看起来也不复杂函数就是那三五个逻辑都直来直去。但真正跑起来以后问题一个接一个冒出来。第一个问题是状态没法自然地在步骤间传递。校验节点发现“资料偏旧”希望检索节点用这个原因重新构造关键词。在链式写法里这通常要靠一个全局字典或者层层传参来做。一开始只有两三个字段还好等我把“历史检索词、上一轮结论、错误原因、重试次数”都塞进去以后那个全局字典已经变成了谁都不敢动的黑盒。改一处字段另外两处可能就漏了同步。第二个问题是流程规则一变代码结构就得跟着重排。产品后来提了需求校验失败以后先基于失败原因精化检索词最多重试三次如果第三次还是不行就走人工兜底。用链式代码写这个分支逻辑意味着外层要套上多层的if/else每加一个分支分支之间的耦合就高一层。这类代码不是不能写是真的难以维护也没法可靠测试。第三个问题最致命是没法中断和恢复。链式代码一旦开始跑要么一口气跑到结束要么自己实现复杂的暂停机制。可到了真实业务里人工审核可能要等好几分钟甚至更久一个调用卡在那里整个流程都得跟着停摆。1.2 图编排到底给了我们什么LangGraph是LangChain生态里针对Agent执行过程的状态编排框架。它不负责选模型、不负责调工具干的事只有一件把你定义的每一步业务逻辑组织成一张有状态的图然后按图执行。和链式调用相比它提供的核心能力可以归结成四点显式状态所有节点读写同一个State对象不再有藏在函数参数里的隐式流转。条件路由边可以根据节点返回的结果决定下一步去哪个节点。循环与重试图允许存在环反思、修正、重写这类Agent行为变成结构内天然支持的路径。暂停与恢复通过检查点机制一次运行可以被挂起之后从断点继续。可以简单理解成链式调用是一条固定的流水线工人A做完给工人B工人B做完给工人C中途想改流程就得把线拆了重装而状态图更像一个带着任务面板的调度中心每个工人只负责自己那一道工序状态都写在共享面板上换工序顺序只改接线不用重写工人。能力链式调用状态图编排状态管理手动传参或全局变量统一State对象条件分支靠外部if/else包住条件边内建循环重试难以优雅实现天然支持中断恢复基本不支持检查点机制可视化无可导出图结构当然图编排不是银弹。如果你的Agent流程就是检索→回答这种一次性调用用链式反而更轻。这也是我后来给团队定的选型标准只有流程中包含“循环、分支、人工介入、断点续跑”四件事中至少两件时才值得上状态图。2. 先把State讲透它相当于整个编排系统的共享数据库2.1 从TypedDict声明开始但真正的魔法藏在Annotated里在LangGraph里定义一个图的起点是声明State。最常用的方式是用TypedDict描述字段配合Annotated声明字段的更新方式。看一个例子from typing import TypedDict, Annotated, Literal import operator from langgraph.graph import StateGraph, START, END class AgentState(TypedDict): topic: str # 调研主题 notes: Annotated[list, operator.add] # 检索笔记自动追加 feedback: str # 上一轮校验反馈 retry_count: int # 已经重试了几次 draft: str # 当前草稿 final_report: str # 最终报告这里有两层含义。TypedDict只是给状态定义了一个“外壳”告诉你和LangGraph这个状态会包含哪些字段每个字段是什么类型。真正影响行为的是Annotated的第二参数。LangGraph把notes: Annotated[list, operator.add]里的operator.add当成一个reducer当某个节点返回新notes时LangGraph不会直接覆盖旧值而是把旧列表和新列表拼在一起。用一句话概括State的作用它是一个贯穿整张图的共享数据对象每个节点都接收它的当前版本业务代码处理完以后把要更新的字段返回出来LangGraph按字段上的reducer合并出下一版本。2.2 Reducer机制覆盖、追加、还有自定义合并策略Reducer是我觉得理解LangGraph的分水岭。就好比多人协作修改一份文档如果每个人都直接覆盖同一个段落后写的就会把先写的顶掉如果事先约定新内容只能追加到文末就不会互相挤掉。Reducer就是这种约定。LangGraph里Reducer的默认规则是覆盖也就是节点返回某个字段的新值就直接替换旧值。比如search_node里返回{retry_count: 3}上一次的count会被直接换成3。这个规则适用于topic、draft这种“只需要最新版本”的字段。第二种是追加典型写法就是Annotated[list, operator.add]。检索笔记、消息历史这种会持续累积的字段最好都声明成追加型。这样每次节点产生新笔记旧笔记不会丢。第三种是自定义Reducer。你完全可以自己写一个小函数LangGraph只要求它接收两个参数——当前旧值和新值返回合并后的结果。我写过的一个用法是保留最新的N条消息def keep_last_n(old_messages: list, new_messages: list) - list: return (old_messages new_messages)[-10:] class AgentState(TypedDict): messages: Annotated[list, keep_last_n]为什么需要Reducer因为状态图里可能存在并行分支。两条分支同时想要写同一个字段如果不定义合并策略LangGraph就会采用默认覆盖后执行完的分支把先执行完的分支的结果丢掉了。Reducer就是给这类冲突场景提供的“冲突消解规则”。2.3 返回值约定部分更新、空返回、和千万别改传入对象节点函数的返回值是它和LangGraph之间的通信协议约定很简单返回一个dict表示对State做部分更新只更新dict里出现的key其余字段保持不变。返回None表示这个节点不修改任何状态。返回空dict效果等同于None。三条约定之外还有一条非常容易踩的经验不要在节点内部直接修改传入的state对象。比如state[notes].append(...)再返回空dict看起来也能达到目的但这样做绕过了reducer机制在并行分支和检查点恢复时会出现状态对不上的玄学问题。正确做法永远是构造新值返回让LangGraph去走reducer。注意绝对不要在节点内部直接修改传入的state对象。构造新值返回让LangGraph走reducer是唯一稳妥的写法。还有个细节节点读state时不要假设字段一定存在。图的第一轮里feedback可能压根没写过。习惯用state.get(feedback, )这种写法能省掉很多“KeyError”现场。3. Node和Edge的本质状态机上最小粒度的业务单元3.1 Node就是一个“输入State、输出State变更”的普通函数Node是图里真正干活的单元。它可以调用大模型、调工具、跑计算但在LangGraph眼里Node就是一个接收State并返回State变更的函数。它的签名统一是(state) - dict | None。我实际写节点时最舒服的一点是它完全不用关心自己在图里的位置。一个检索节点不需要知道上游是哪个节点只要state里有所需字段比如topic它就能独立运行。这让单元测试变得非常轻构造一份state字典直接调用节点函数就能验证逻辑。写一个示例def search_node(state: AgentState) - dict: query state[topic] if state.get(feedback): query f{query}重点针对反馈: {state[feedback]} results fake_search(query) # 真实项目里替换成检索服务 return { notes: [f[第{state.get(retry_count, 0) 1}轮] {r} for r in results[:3]], retry_count: state.get(retry_count, 0) 1, }这个节点同时更新了notes和retry_count两个字段分别走追加和覆盖两种reducer一次调用可以混着用。节点内部只干一件事基于已有状态算出新状态。3.2 边的三种接法顺序、条件、并行节点定义好了之后剩下的就是接线。LangGraph支持三类边。第一类是普通顺序边。workflow.add_edge(node_a, node_b)表示node_a执行完就执行node_b。它适合没有分叉的路径。第二类是条件边。它由三部分组成来源节点、路由函数、路由映射表。路由函数接收当前State返回一个字符串映射表把这个字符串对应到目标节点。前面已有的should_retry就是这个思路。第三类是并行边。在LangGraph里从一个节点同时连到多个下游节点就形成了并行Fan-out这些并行分支最终汇聚到一个下游节点时就是Fan-in。图引擎会等所有并行分支执行完再继续跑汇总节点。并行分支共享同一个State好处是数据不用单独传坏处是如果没有好的Reducer多个分支同时写一个字段就会互相覆盖。实际做法一般是并行分支各自写自己独立命名的字段最后汇聚节点统一读取并汇总。3.3 compile之后你写的拓扑结构才变成一个可执行程序把节点和边注册完之后调用compile()会做两件事一是做结构校验检查节点之间是否连通、边的指向是否存在、条件边返回的节点名是否合法二是把这套图结构打包成一个可调用的执行器。workflow StateGraph(AgentState) workflow.add_node(search, search_node) workflow.add_node(draft, draft_node) workflow.add_edge(START, search) workflow.add_edge(search, draft) workflow.add_edge(draft, END) app workflow.compile() result app.invoke({topic: 低代码平台的数据权限设计}) print(result[draft])我刚开始用的时候觉得StateGraph(AgentState)这一行有点多余State已经写在TypedDict里了为什么编译时还要再传一次后来才明白这一步是告诉执行引擎“这张图的State结构长什么样”好让它在运行时统一处理Reducer和检查点。没有这层声明LangGraph不知道哪些字段该合并、哪些该覆盖。到这里为止一套最小的图已经能跑了。但真正让LangGraph和普通链式代码拉开差距的是接下来要说的循环和人工介入。那是Agent场景里最硬的需求也是链式代码几乎做不优雅的部分。4. 循环、断点恢复与人工介入状态图真正拉开差距的地方4.1 条件边指向历史节点就形成了循环在链式代码里循环靠 while 和 for 实现在状态图里循环就是一条“往回指”的条件边。比如check_node校验完草稿条件边返回 “retry” 时路由目标直接指回search_node图执行器就会带着更新后的State再跑一遍检索节点。每跑一轮retry_count加1直到条件边返回“finish”跳出。这个设计把“重试”从代码逻辑变成了图结构好处是显而易见的所有节点仍然是纯函数式的输入输出不需要自己维护循环变量循环次数、跳出条件全部集中在一个路由函数里想改规则只改一处。循环路径上最容易出的问题是忘记更新退出条件。如果路由函数只判断retry_count 3但整个图的任何节点都没有把retry_count往上加这个判断就永远是False图就会在循环里一直跑下去。更稳妥的做法是在进入循环的那个节点里统一累加计数器同时把路由函数的判断条件作为唯一出口两者配合才不会出现死循环。4.2 Checkpointer把一次运行变成可以被暂停和恢复的会话LangGraph的另一个关键机制是检查点。当你用compile(checkpointer...)编译图时每次节点执行完都会把当前State快照写入检查点存储。再次执行时你传入一个thread_id执行器就从这个thread_id对应的状态继续跑而不是从零开始。from langgraph.checkpoint.memory import InMemorySaver memory InMemorySaver() app workflow.compile(checkpointermemory) config {configurable: {thread_id: task-001}} # 第一次运行跑到某个节点比如人工节点停下来 app.invoke({topic: 某个主题}, config) # 第二次运行从上次停止的位置继续执行 app.invoke({topic: 某个主题}, config)对真实业务来说这个能力比想象中有用得多。进程崩溃了状态没丢一个人工审核等了好几个小时确认之后还能准确回到原来的位置继续跑甚至同一套图可以同时跑多个任务每个任务用不同thread_id隔离状态。InMemorySaver在单机、单进程的小型服务里够用生产环境一般要换成持久化的检查点实现但接口用法是一致的。4.3 interrupt()把图挂起等一个人来拍板真正让我朋友从“while循环论”里闭嘴的是interrupt这个能力。在不带状态编排的Agent代码里想实现“等人工确认后再继续”非常别扭通常要拆成两个流程还得自己维护待确认任务的持久化。LangGraph里只需要在节点里调用interrupt。from langgraph.types import interrupt, Command def review_node(state: AgentState) - dict: decision interrupt({draft: state[draft]}) if decision approve: return {final_report: state[draft]} return {draft: f根据反馈修订: {decision}}图执行到interrupt时会停下来把中间数据这里是一份草稿交给调用方你拿着这份草稿给人工看等人工写下决定再用Command把决定喂回图图会从interrupt点继续执行。这个机制让“机器先干活人来拍板”成为流程的一等公民而不是事后补救。我个人的使用经验是interrupt节点尽量设计成“只暴露必要信息”。真实项目里人工审核看到的内容越精简越好不要把检索笔记、中间日志全塞进去。我在调研报告助手里只让审核人看到草稿和三条核心摘要人看得快也更容易判断。5. 一个多轮调研Agent的完整搭建从State定义到逐轮跑通5.1 需求拆解与State设计前面的概念都拆开讲了这一节把它们拼在一起做一个能用的小系统。假设需求是这样的用户输入一个调研主题Agent要多次检索、整理出草稿、校验草稿质量校验不过就带着反馈回到检索节点精化搜索词最多循环3轮校验通过之后进入人工审核审核通过才输出最终报告。这个需求包含了条件路由、循环、人工介入三类特性正好覆盖前面所有机制。先定义Statefrom typing import TypedDict, Annotated, Literal import operator from langgraph.graph import StateGraph, START, END from langgraph.checkpoint.memory import InMemorySaver from langgraph.types import interrupt, Command class ReportState(TypedDict): topic: str notes: Annotated[list, operator.add] feedback: str retry_count: int draft: str final_report: str5.2 节点实现让每个业务动作独立成函数然后定义五个节点。检索节点负责根据topic和feedback生成查询词并追加笔记同时把retry_count加一整理节点把notes汇总成一份草稿校验节点判断草稿是否可用并返回feedback人工审核节点interrupt等待确认收尾节点写final_report。def search_node(state: ReportState) - dict: query state[topic] if state.get(feedback): query f并按反馈修正: {state[feedback]} results [相关资料A, 相关资料B, 相关资料C] # 实际项目替换为真实检索 new_notes [f[第{state.get(retry_count, 0) 1}轮] {r} for r in results] return {notes: new_notes, retry_count: state.get(retry_count, 0) 1} def draft_node(state: ReportState) - dict: content \n.join(state[notes]) draft f关于{state[topic]}的调研草稿\n{content} return {draft: draft} def check_node(state: ReportState) - dict: # 真实项目里让大模型判断这里简化成前两轮必失败第三轮通过 quality pass if state[retry_count] 3 else fail return {feedback: if quality pass else 信息不够新需要补查最新进展} def review_node(state: ReportState) - dict: decision interrupt({draft: state[draft]}) if decision approve: return {final_report: state[draft]} return {draft: f按审核意见修订{decision}} def finalize_node(state: ReportState) - dict: return {final_report: state[draft]}5.3 条件路由、编译与逐轮状态验证接下来连边。这里有一条条件边从check_node出发根据校验结果选择回检索节点还是进人工审核如果校验失败但重试次数已经到上限也进人工审核兜底。def should_retry(state: ReportState) - Literal[retry, finish]: if state[feedback] : return finish if state[retry_count] 3: return finish return retry workflow StateGraph(ReportState) workflow.add_node(search, search_node) workflow.add_node(draft, draft_node) workflow.add_node(check, check_node) workflow.add_node(review, review_node) workflow.add_node(finalize, finalize_node) workflow.add_edge(START, search) workflow.add_edge(search, draft) workflow.add_edge(draft, check) workflow.add_conditional_edges( check, should_retry, {retry: search, finish: review}, ) workflow.add_edge(review, finalize) workflow.add_edge(finalize, END) app workflow.compile(checkpointerInMemorySaver())最后用stream模式逐轮看状态变化。第一次invoke会停在interrupt拿到草稿后再以Command(resumeapprove)继续跑最终拿到final_report。下面是前两轮跑失败、第三轮进人工审核的状态快照运行步retry_countnotes长度feedback下一步第1轮search结束13为空进入draft第1轮check结束13信息不够新条件边跳回search第2轮check结束26信息不够新条件边跳回search第3轮check结束39为空进入review等待人工resume之后39为空finalize生成final_report代码到这里为止。给你留个小小的挑战把should_retry里的条件从“retry_count 3”改成“notes长度超过20就强制退出”看看整个流程的行为变化。这种规则改动在链式代码里要拆好几个函数在状态图里只需要改一条边和一个路由函数。6. 我踩过的坑状态污染、路由失配与调试三板斧6.1 并行分支同写一个字段后写把先写顶掉了我第一次做并行检索时把两个并行分支各自返回的notes都定义成了普通字段结果跑完经常只剩其中一路的结果。原因就是默认Reducer是覆盖两个分支都写notes后执行完的覆盖先执行完的。后来把notes改成Annotated[list, operator.add]才解决。凡是会被多个节点写入的累积型字段优先考虑追加型Reducer如果并行分支要各自保留独立结果就给每个分支单独命名一个字段最后汇聚节点再统一处理。这个原则写在代码注释里都不为过。6.2 条件边返回了未注册的节点名还有一次编译时一切正常运行到条件边才报错。原因是路由函数里返回的字符串是 “search”而我注册节点时用的是 “search_node”。LangGraph编译时不会检查条件边映射里的目标名是否真实存在这个问题直到运行到那一步才暴露。后来我习惯把节点名定义成模块级常量路由映射和节点注册共用同一组常量从源头避免手写字符串不一致。再配合类型注解返回值的Literal编辑器也能提前帮你标红。6.3 大对象堆在State里循环越多性能越差LangGraph为了隔离状态会对写入State的数据做拷贝处理。如果你的节点把每次检索的全文都追加进notes循环三轮后state里会攒下一大批重复文本内存和反序列化开销都会上去。我的优化习惯是检索节点只存摘要或与问题最相关的几个片段原始长文本存在外部存储里用id引用需要完整内容时再临时读取。State尽量轻量——它是用来流转任务信息的不是用来当数据库的。6.4 调试三板斧先看图、再单测、最后stream逐步看排查状态图问题我一般按这个顺序来。第一板斧是可视化。LangGraph本身提供了图结构导出接口可以看到节点和边的完整拓扑检查条件边连的到底是不是想去的节点。第二板斧是节点单测。不管整张图有多复杂每个节点本身就是个普通函数直接构造一份固定的State调用它看返回值和State变更是否符合预期。绝大多数逻辑bug在这一步就能定位。第三板斧是用stream模式按步骤观察。invoke只给你终态stream能让你看到每一步节点执行完后的State变化。哪一步状态没更新、哪一步updated到了多余字段一眼就能看出来。调试Agent和调试普通后端服务的思路不一样。普通后端看日志就行状态图这种“每一步都在改变状态快照”的程序最重要的是盯住状态本身的变化。把这套三板斧用熟比翻着文档猜强得多。最后再分享一个我在实际项目里的习惯给节点命名的时候按“动词对象”统一风格比如search、draft、check不要混用名词和动词同时把图里所有节点名集中放在一个常量模块里节点注册、条件边映射、测试用例都引用同一份常量。这样图变复杂以后改名字不会连带出一堆字符串失配。状态图编排看起来把简单的事变复杂了但它把“流程状态”变成了一等公民一旦你的Agent开始需要反思、重试、人工介入这个复杂度就是值得的。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑