本文是「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 ,本篇对应 tag
v0.1。
上一篇我们用 State / Node / Edge 三件套把 v0 跑了起来,还立了个 flag,链和循环都是图的特例,Agent 需要的判断和分叉只有图能表达。
这一篇就来兑现「分叉」。
一、v0 撞的墙,一条道走到黑
给 v0 提两个问题
LangGraph 是哪家公司开源的?
为什么 Agent 框架纷纷从链式结构转向图结构?
第一个是事实型问题,有客观标准答案,好的回答应该准确、简短。LangChain Inc.,完事。第二个是分析型问题,没有标准答案,好的回答应该多角度展开、给出推理过程。
但 v0 只有一个 answer 节点、一套系统提示词。结果就是两头不讨好,事实型问题它答出一篇小作文,分析型问题它草率地丢一个结论。你想想看,「简洁准确」和「充分展开」这两个目标本身就是矛盾的,一套提示词没法同时优化。
解法很自然,先判断问题类型,再走不同的分支。这在链里做不到,管道在写代码时就焊死了。在图里,这就是一条条件边(conditional edge)。
二、v0.1 的图结构
#mermaid-svg-ceQs6yqWXUNegEIx{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-ceQs6yqWXUNegEIx .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ceQs6yqWXUNegEIx .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ceQs6yqWXUNegEIx .error-icon{fill:#552222;}#mermaid-svg-ceQs6yqWXUNegEIx .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ceQs6yqWXUNegEIx .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ceQs6yqWXUNegEIx .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ceQs6yqWXUNegEIx .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ceQs6yqWXUNegEIx .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ceQs6yqWXUNegEIx .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ceQs6yqWXUNegEIx .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ceQs6yqWXUNegEIx .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ceQs6yqWXUNegEIx .marker.cross{stroke:#333333;}#mermaid-svg-ceQs6yqWXUNegEIx svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ceQs6yqWXUNegEIx p{margin:0;}#mermaid-svg-ceQs6yqWXUNegEIx .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-ceQs6yqWXUNegEIx .cluster-label text{fill:#333;}#mermaid-svg-ceQs6yqWXUNegEIx .cluster-label span{color:#333;}#mermaid-svg-ceQs6yqWXUNegEIx .cluster-label span p{background-color:transparent;}#mermaid-svg-ceQs6yqWXUNegEIx .label text,#mermaid-svg-ceQs6yqWXUNegEIx span{fill:#333;color:#333;}#mermaid-svg-ceQs6yqWXUNegEIx .node rect,#mermaid-svg-ceQs6yqWXUNegEIx .node circle,#mermaid-svg-ceQs6yqWXUNegEIx .node ellipse,#mermaid-svg-ceQs6yqWXUNegEIx .node polygon,#mermaid-svg-ceQs6yqWXUNegEIx .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-ceQs6yqWXUNegEIx .rough-node .label text,#mermaid-svg-ceQs6yqWXUNegEIx .node .label text,#mermaid-svg-ceQs6yqWXUNegEIx .image-shape .label,#mermaid-svg-ceQs6yqWXUNegEIx .icon-shape .label{text-anchor:middle;}#mermaid-svg-ceQs6yqWXUNegEIx .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-ceQs6yqWXUNegEIx .rough-node .label,#mermaid-svg-ceQs6yqWXUNegEIx .node .label,#mermaid-svg-ceQs6yqWXUNegEIx .image-shape .label,#mermaid-svg-ceQs6yqWXUNegEIx .icon-shape .label{text-align:center;}#mermaid-svg-ceQs6yqWXUNegEIx .node.clickable{cursor:pointer;}#mermaid-svg-ceQs6yqWXUNegEIx .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-ceQs6yqWXUNegEIx .arrowheadPath{fill:#333333;}#mermaid-svg-ceQs6yqWXUNegEIx .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-ceQs6yqWXUNegEIx .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-ceQs6yqWXUNegEIx .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ceQs6yqWXUNegEIx .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-ceQs6yqWXUNegEIx .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ceQs6yqWXUNegEIx .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-ceQs6yqWXUNegEIx .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-ceQs6yqWXUNegEIx .cluster text{fill:#333;}#mermaid-svg-ceQs6yqWXUNegEIx .cluster span{color:#333;}#mermaid-svg-ceQs6yqWXUNegEIx div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-ceQs6yqWXUNegEIx .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-ceQs6yqWXUNegEIx rect.text{fill:none;stroke-width:0;}#mermaid-svg-ceQs6yqWXUNegEIx .icon-shape,#mermaid-svg-ceQs6yqWXUNegEIx .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ceQs6yqWXUNegEIx .icon-shape p,#mermaid-svg-ceQs6yqWXUNegEIx .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-ceQs6yqWXUNegEIx .icon-shape .label rect,#mermaid-svg-ceQs6yqWXUNegEIx .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ceQs6yqWXUNegEIx .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-ceQs6yqWXUNegEIx .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-ceQs6yqWXUNegEIx :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} factual
analytical
START
classify
判断问题类型
factual_answer
简洁准确地回答
analytical_answer
多角度展开分析
END
实线是普通边(必然走),虚线是条件边(运行时二选一)。整个 v0.1 相比 v0 的变化,answer 节点一分为二,前面加了一个 classify 节点,中间用条件边接起来。
三、动手实现
3.1 State,加一个字段
python
class ResearchState(TypedDict):
question: str
question_type: str # "factual" | "analytical"
answer: str
相比 v0 只多了 question_type。分类结果要写进 state,因为路由的依据必须存在于 state 里。这是 LangGraph 的基本纪律,节点之间不直接传参,一切经过 state。
3.2 分类节点,让 LLM 当裁判
python
def classify_node(state: ResearchState) -> dict:
llm = get_llm()
response = llm.invoke([
("system",
"你是一个问题分类器。判断用户问题属于哪一类:\n"
"- factual:有客观标准答案的事实型问题(是什么、何时、多少)\n"
"- analytical:需要推理和多角度讨论的分析型问题(为什么、如何评价、利弊)\n"
"只输出 factual 或 analytical 这一个单词,不要输出其他内容。"),
("human", state["question"]),
])
label = response.content.strip().lower()
# 兜底:分类器输出意外内容时按分析型处理
if label not in ("factual", "analytical"):
label = "analytical"
return {"question_type": label}
这里有两个工程细节。一个是强约束输出,提示词里写死「只输出一个单词」。分类节点的输出要喂给程序逻辑,不是给人看的,越死板越好。另一个是必须有兜底,LLM 是概率模型,总有一天它会给你输出 "Factual." 或者一段解释。这个事儿我也踩过坑,strip().lower() 处理大小写和空白,白名单校验兜住其余所有意外。宁可答得啰嗦(走分析型),不可让图在运行时崩掉。
3.3 两个回答节点,各自优化各自的目标
python
def factual_answer_node(state: ResearchState) -> dict:
llm = get_llm() # temperature=0,要稳
response = llm.invoke([
("system", "你是一个研究助手。这是一个事实型问题,"
"请给出准确、简洁的答案,不要展开议论。"),
("human", state["question"]),
])
return {"answer": response.content}
def analytical_answer_node(state: ResearchState) -> dict:
llm = get_llm(temperature=0.7) # 分析型允许更发散
response = llm.invoke([
("system", "你是一个研究助手。这是一个分析型问题,"
"请从多个角度展开分析,给出推理过程,最后总结你的观点。"),
("human", state["question"]),
])
return {"answer": response.content}
分支的价值在这里显形。两个节点不只提示词不同,连 temperature 都不同,事实型要 0(稳定可复现),分析型给 0.7(允许发散)。如果没有分叉,这种「按题施策」根本无从谈起。
3.4 路由函数,条件边的扳道工
python
def route_by_type(state: ResearchState) -> Literal["factual", "analytical"]:
return state["question_type"]
就这三行?就这三行。
路由函数的职责被刻意压到最小,读 state,返回一个字符串,别的什么都不干。
新手最容易犯的错是把分类逻辑写进路由函数,在里面调 LLM、做判断。不是说这样跑不起来,而是说它违背了 LangGraph 的设计意图,节点干活,边选路。节点(classify)可以调用模型、可以有副作用、结果写进 state,可以被 checkpoint 记录(第 6 篇会讲为什么这很重要)。路由函数应该是纯函数,只读 state 做决定,快进快出。把它们分开,图的每一步都有据可查。混在一起,路由就成了监控和调试的盲区。
3.5 组装,add_conditional_edges
python
builder = StateGraph(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)
graph = builder.compile()
add_conditional_edges 三个参数,出发节点、路由函数、映射表。映射表把路由函数的返回值翻译成目标节点名。这里两个键恰好和节点名部分重合,但它们是两个命名空间,路由函数返回的是「路名」,映射表才决定「路通向哪」。
第三个参数可以省略,此时路由函数直接返回节点名。但我自己的感受是,显式写映射表更划算,LangGraph 能据此画出准确的图结构,而且路由逻辑和图拓扑解耦,改节点名不用改路由函数。
3.6 跑起来
python
questions = [
"LangGraph 是哪家公司开源的?",
"为什么 Agent 框架纷纷从链式结构转向图结构?",
]
for question in questions:
result = graph.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 登场。
赞或收藏 关注 我们下次再见