
本文是「LangGraph 教程系列」第 2 篇。写作时基于 langgraph 1.2.10、langchain 1.3.14、langchain-deepseek 1.1.0、Python 3.12。配套代码仓库 https://github.com/wxj006007/deep-research-assistant 本篇对应 tagv0.1。上一篇我们用 State / Node / Edge 三件套把 v0 跑了起来还立了个 flag链和循环都是图的特例Agent 需要的判断和分叉只有图能表达。这一篇就来兑现「分叉」。一、v0 撞的墙一条道走到黑给 v0 提两个问题LangGraph 是哪家公司开源的为什么 Agent 框架纷纷从链式结构转向图结构第一个是事实型问题有客观标准答案好的回答应该准确、简短。LangChain Inc.完事。第二个是分析型问题没有标准答案好的回答应该多角度展开、给出推理过程。但 v0 只有一个answer节点、一套系统提示词。结果就是两头不讨好事实型问题它答出一篇小作文分析型问题它草率地丢一个结论。你想想看「简洁准确」和「充分展开」这两个目标本身就是矛盾的一套提示词没法同时优化。解法很自然先判断问题类型再走不同的分支。这在链里做不到管道在写代码时就焊死了。在图里这就是一条条件边conditional edge。二、v0.1 的图结构factualanalyticalSTARTclassify判断问题类型factual_answer简洁准确地回答analytical_answer多角度展开分析END实线是普通边必然走虚线是条件边运行时二选一。整个 v0.1 相比 v0 的变化answer节点一分为二前面加了一个classify节点中间用条件边接起来。三、动手实现3.1 State加一个字段classResearchState(TypedDict):question:strquestion_type:str# factual | analyticalanswer:str相比 v0 只多了question_type。分类结果要写进 state因为路由的依据必须存在于 state 里。这是 LangGraph 的基本纪律节点之间不直接传参一切经过 state。3.2 分类节点让 LLM 当裁判defclassify_node(state:ResearchState)-dict:llmget_llm()responsellm.invoke([(system,你是一个问题分类器。判断用户问题属于哪一类\n- factual有客观标准答案的事实型问题是什么、何时、多少\n- analytical需要推理和多角度讨论的分析型问题为什么、如何评价、利弊\n只输出 factual 或 analytical 这一个单词不要输出其他内容。),(human,state[question]),])labelresponse.content.strip().lower()# 兜底分类器输出意外内容时按分析型处理iflabelnotin(factual,analytical):labelanalyticalreturn{question_type:label}这里有两个工程细节。一个是强约束输出提示词里写死「只输出一个单词」。分类节点的输出要喂给程序逻辑不是给人看的越死板越好。另一个是必须有兜底LLM 是概率模型总有一天它会给你输出Factual.或者一段解释。这个事儿我也踩过坑strip().lower()处理大小写和空白白名单校验兜住其余所有意外。宁可答得啰嗦走分析型不可让图在运行时崩掉。3.3 两个回答节点各自优化各自的目标deffactual_answer_node(state:ResearchState)-dict:llmget_llm()# temperature0要稳responsellm.invoke([(system,你是一个研究助手。这是一个事实型问题请给出准确、简洁的答案不要展开议论。),(human,state[question]),])return{answer:response.content}defanalytical_answer_node(state:ResearchState)-dict:llmget_llm(temperature0.7)# 分析型允许更发散responsellm.invoke([(system,你是一个研究助手。这是一个分析型问题请从多个角度展开分析给出推理过程最后总结你的观点。),(human,state[question]),])return{answer:response.content}分支的价值在这里显形。两个节点不只提示词不同连 temperature 都不同事实型要 0稳定可复现分析型给 0.7允许发散。如果没有分叉这种「按题施策」根本无从谈起。3.4 路由函数条件边的扳道工defroute_by_type(state:ResearchState)-Literal[factual,analytical]:returnstate[question_type]就这三行就这三行。路由函数的职责被刻意压到最小读 state返回一个字符串别的什么都不干。新手最容易犯的错是把分类逻辑写进路由函数在里面调 LLM、做判断。不是说这样跑不起来而是说它违背了 LangGraph 的设计意图节点干活边选路。节点classify可以调用模型、可以有副作用、结果写进 state可以被 checkpoint 记录第 6 篇会讲为什么这很重要。路由函数应该是纯函数只读 state 做决定快进快出。把它们分开图的每一步都有据可查。混在一起路由就成了监控和调试的盲区。3.5 组装add_conditional_edgesbuilderStateGraph(ResearchState)builder.add_node(classify,classify_node)builder.add_node(factual_answer,factual_answer_node)builder.add_node(analytical_answer,analytical_answer_node)builder.add_edge(START,classify)builder.add_conditional_edges(classify,# 从哪个节点出发route_by_type,# 用哪个函数选路{# 返回值 - 目标节点 的映射factual:factual_answer,analytical:analytical_answer,},)builder.add_edge(factual_answer,END)builder.add_edge(analytical_answer,END)graphbuilder.compile()add_conditional_edges三个参数出发节点、路由函数、映射表。映射表把路由函数的返回值翻译成目标节点名。这里两个键恰好和节点名部分重合但它们是两个命名空间路由函数返回的是「路名」映射表才决定「路通向哪」。第三个参数可以省略此时路由函数直接返回节点名。但我自己的感受是显式写映射表更划算LangGraph 能据此画出准确的图结构而且路由逻辑和图拓扑解耦改节点名不用改路由函数。3.6 跑起来questions[LangGraph 是哪家公司开源的,为什么 Agent 框架纷纷从链式结构转向图结构,]forquestioninquestions:resultgraph.invoke({question:question})print(f分类{result[question_type]})print(f回答{result[answer]}\n)第一个问题被分到factual回答一两句话。第二个被分到analytical回答分点展开。同一个图两条执行路径这就是条件边。四、几个值得记住的点顺着上面的再聊聊有三个点值得单独拎出来。条件边是「运行时」的决策。普通边在你写代码时就定了条件边到invoke那一刻才知道走哪条。这是图对链最根上的超越流程结构可以响应数据。路由依据一定要落在 state 里。你可能想分类和路由合成一步多省事。这个想法不是没道理代码确实能少几行。但 state 里留下question_type好处是调试时能看到分类结果下游节点能引用它将来做 checkpoint 回放时它是历史的一部分。state 是图的记忆别绕过它抄近道。分支可以不止两条。映射表想加几个键就加几个键。将来你的助手可能要区分闲聊、事实、分析、需要检索四种类型条件边的写法不变只是映射表变长。五、本篇小结回到「分叉」这块v0 撞的墙是一套提示词无法同时服务事实型和分析型问题解法是classify节点判断类型再用add_conditional_edges按类型选路。过程中我们顺手立了三条纪律节点干活边选路路由函数保持纯函数。路由依据写进 state不走私下传参。LLM 分类必须有兜底白名单校验防止图崩溃。v0.1 会分叉了但它还有个隐患没暴露。目前每个字段都是「整存整取」新值直接覆盖旧值。等到下一步我们让助手做多轮检索问题就来了第二次检索的结果会把第一次的覆盖掉。检索结果应该是累积的不是覆盖的。状态如何累加而不是覆盖下一篇reducer 登场。赞或收藏 关注 我们下次再见