本文是「LangGraph 教程系列」第 10 篇。写作时基于 langgraph 1.2.10、langchain 1.3.14、langchain-openai 1.4.1、Python 3.12+。配套代码仓库 https://github.com/wxj006007/deep-research-assistant ,本篇对应 tag
v3。
v2.3 已经能把回答、进度和状态变化分开送出去。可当我把那张图铺开时,另一个问题更刺眼,读取记忆、规划、审批、检索、评估、成稿、保存摘要全堆在同一个 builder 里。
图还能运行,读起来却开始费劲。想改检索循环时,要先穿过长期记忆和成稿逻辑。想复用写作流程时,又得把整张图一起搬走。
这不是节点太多就该机械拆文件。真正该拆的是稳定职责。本篇把研究助手分成 planner、researcher、writer 三个子图,让父图回到它该做的事,安排交接,而不是参与每一个细节。
一、单图为什么会开始难维护
到 v2.2,研究流程已经有两类节点。
一类是业务主线,规划、检索、评估和成稿。另一类是横切能力,加载长期记忆、保存摘要、取消任务和 checkpoint。它们放在一起当然能跑,但阅读图时要不断在不同层级切换。
v3 的拆分依据不是模型数量,也不是每个图必须有多少节点。
- planner 负责把问题和记忆变成首条查询。
- researcher 负责审批、搜索、评估和继续搜索的循环。
- writer 只负责根据资料和记忆写出回答。
它们都是同一个研究助手的内部模块。子图是组合和封装,不是多智能体。真正引入 supervisor、专家角色与跨 agent 协作,是下一篇的主题。
二、父图负责交接,子图负责自己的工作
拆分后的外层结构很短。
researcher 自己仍有一张循环图。第 7 篇的人工审批没有消失,只是终于回到了它所属的研究职责里。
父图只关心 researcher 最后是带着资料回来,还是带着 cancelled 回来。它不需要知道研究子图循环了几轮。这个边界一旦稳定,检索策略增加重排序、来源过滤或并行工具时,父图不必跟着膨胀。
三、状态交接依赖字段契约
把编译后的图直接作为 add_node 的节点时,LangGraph 会按字段名在父图与子图间传递数据。这里最重要的不是语法,而是双方事先约定哪些字段可以交接。
| 子图 | 读取字段 | 输出字段 |
|---|---|---|
| planner | question、memory_context | current_query |
| researcher | question、current_query | docs、executed_queries、round、verdict、cancelled |
| writer | question、docs、memory_context | answer |
代码把这份约定写进三个 TypedDict。
classPlannerState(TypedDict,total=False):question:strmemory_context:MemoryContext current_query:strclassWriterState(TypedDict,total=False):question:strdocs:Annotated[list[Doc],operator.add]memory_context:MemoryContext answer:str父图声明同一批共享字段。对于 docs 和 executed_queries,父图和 researcher 都使用 operator.add reducer。这样子图中的每轮检索先累积在自己的 state 里,返回父图时字段语义也不会改变。
字段同名不是约束偷懒,而是一份小而明确的接口。若某个子图想用完全不同的数据结构,就在父图和子图间加一个适配节点,显式转换输入和输出。不要让一个子图悄悄依赖父图所有字段,那只是在另一种形式里把大图搬回来了。
四、三个子图如何编译
planner 只有一个 plan 节点,也值得成为子图。职责已经稳定,日后它可以扩展为问题拆解、查询改写或预算控制,父图的调用方式不用改变。
defbuild_planner_subgraph():builder=StateGraph(PlannerState)builder.add_node("plan",plan_node)builder.add_edge(START,"plan")builder.add_edge("plan",END)returnbuilder.compile()researcher 复用前文的节点和路由函数,把循环封在内部。
defbuild_researcher_subgraph():builder=StateGraph(ResearcherState)builder.add_node("review",review_plan_node)builder.add_node("search",search_node)builder.add_node("evaluate",evaluate_node)builder.add_node("cancel",cancel_node)builder.add_edge(START,"review")builder.add_conditional_edges("review",route_after_review,{"search":"search","cancel":"cancel"},)builder.add_edge("search","evaluate")builder.add_conditional_edges("evaluate",route_after_evaluate,{"review":"review","synthesize":END},)returnbuilder.compile()writer 的结构也很小,它只调用已有的 synthesize_node。这里不复制提示词、模型工厂和记忆格式化逻辑,v3 是组合已有能力,不是重新实现它们。
五、持久化仍只有一份
子图编译时没有传入 MemorySaver 或 InMemoryStore。
builder.add_node("planner",build_planner_subgraph())builder.add_node("researcher",build_researcher_subgraph())builder.add_node("writer",build_writer_subgraph())returnbuilder.compile(checkpointer=checkpointerorMemorySaver(),store=storeorInMemoryStore(),)checkpointer 和 store 在父图统一配置后,子图会继承这次运行的资源。thread_id 仍然是整次研究的会话标识,ResearchContext 仍然提供 user_id 与 research_id。子图不另开一条会话,也不另建一份用户记忆。
这点在 interrupt 上最容易看清。researcher 的 review_plan_node 暂停后,调用方仍然向父图传 Command(resume=…)。
initial=graph.invoke({"question":"子图如何继承父图的 checkpoint?"},config=config,context=context,)final=resume_until_complete(graph,initial,config,context,)调用方不需要知道 interrupt 停在哪一个子图。相同的 thread_id 能让 LangGraph 沿嵌套 checkpoint 命名空间定位到正确的 review 节点。
调试时可以请求包含子图的快照。
snapshot=graph.get_state(config,subgraphs=True)fortaskinsnapshot.tasks:print(task.name,task.state)运行到审批点时,父图任务列表里会有 researcher 的嵌套状态。它很适合排查流程和展示子图边界,但不应被当成业务接口直接暴露给用户。
六、取消与长期记忆的边界
researcher 收到拒绝决定后返回 cancelled。父图据此路由到 cancel,直接结束。
defroute_after_researcher(state:V3ResearchState,)->Literal["writer","cancel"]:return"cancel"ifstate.get("cancelled")else"writer"只有 writer 成功产出 answer 后,父图才会进入 persist_memory。这样,一次被人工取消的研究不会以完成摘要的形式落进 Store。
这段路由看起来简单,却守住了 v2.2 就建立的长期记忆边界。长期记忆是跨会话资产,不能因为图拆开了就失去原先的写入条件。
七、跑起来
代码在src/v3_subgraphs.py。
python-msrc.v3_subgraphs脚本会检查首次调用在 researcher 内部暂停,父图可以用同一条 thread 恢复,writer 最终得到资料并写出答案,成功任务才会保存摘要。它还会读取 subgraphs=True 的嵌套状态,并验证拒绝路径不会增加长期记忆。
回到那张越来越大的图。拆成子图没有减少实际工作,研究仍会规划、审批、检索和写作。它减少的是理解一段流程时必须同时握住的东西。父图看调度,子图看职责,字段契约看交接。
下一篇,模块边界已经有了。我们再让不同角色真的开始协作,从单兵研究助手走向多专家团队。
赞或收藏 关注 我们下次再见