概述
learn-langgraph
使用链式调用 也能跑通Demo,但是一旦加入条件分支、循环、错误恢复、代码就变成迷宫,因为代码逻辑变得复杂了
LangGraph用图结构来描述Agent的执行逻辑,有边和节点,节点就代表操作,边就是转移,状态是贯穿全程的上下文。
这不只是换了一种说法,而是完全换了一种思维模式------从怎么写代码变成怎么设计状态机
包含了什么
| 章节 | 文章 | 学到了什么 |
|---|---|---|
| 1 | LangGraph是什么,为什么不用链式法则 |
链式调用的局限性,图结构的优势,核心抽象 |
| 2 | State Node Graph三件套 |
TypedDict状态设计,节点函数签名,编译和运行 |
| 3 | 顺序图:第一个可运行的workflow |
add_edge模式,多节点顺序流,实战BMI |
| 4 | 条件分支:add_conditional_edges |
路由函数,情感分析路由,Pydantic结构化输出 |
| 5 | 并行执行:Fan-out/Fan-in |
多节点同时启动,汇聚节点,cricket统计实战 |
| 6 | Prompt Chaining:分布生成 |
拆解生成任务,节点间传递中间结果,HuggingFace集成 |
| 7 | 接入LLM:OpenAI与HuggingFace |
ChatOpenAI,HuggingFaceEndPoint在节点里调用模型 |
前置知识
- 读过
Agent Basic或者知道什么是Agent、Tool Calling - 会写基本的
python,知道TypedDict是什么 - 不需要
LangGraph经验
LangGraph是什么,为什么要用LangGraph不用链式调用
链式调用能做什么,做不到什么
在早期的时候大家开发LLM应用的时候都是使用链式调用:
python
# 上一次模型执行输出的结果作为下一次模型的输入,作为下一次模型的提示词
response1 = llm.invoke(prompt1) # 括号里面的内容就是向LLM输入的内容
response2 = llm.invoke(prompt2 + response1.content) # 向LLm输入的是第一次执行的结果和第二次输入的prompt2
response3 = llm.invoke(prompt3 + response2.content)
举个例子:
python# 代码 prompt1 = "总结西游记" response1 = llm.invoke(prompt1) prompt2 = "把下面内容翻译成英文" response2 = llm.invoke(prompt2 + response1) prompt3 = "把英文压缩成一句话" response3 = llm.invoke(prompt3 + resposne2)
python# 输出 response1.content = "西游记讲述了....." response2.content = "把response1的内容翻译成英文:the west jounery illustrate...." response3.content = "......"
为什么叫链式调用
Prompt1 ↓ LLM ↓ response1 ↓ Prompt2 + response1 ↓ LLM ↓ response2 ↓ Prompt3 + response2 ↓ LLM ↓ response3像链条一样,逐步往下执行,依赖于上一步的执行结果
链式法则直观、简单、能跑通大部份的简单情况下的Demo
但是如果你的业务复杂了,分支多了,要进行判断选择就会出问题:
-
没有条件分支 :如果要根据不同情况来进行选择操作的话,就只能使用
if-else语句来实现,就像不使用设计模式来进行开发,一直使用if-else来判断,拓展性不好,而且很冗余代码就会不断膨胀,而且每次修改分支逻辑都要找对位置进行修改
python# 例子:根据天气状况来选择走不同的分支 weather = analyze_weather(usser_input) if weather == "sunny" : response = sunny_handler(user_input) elif weather == "raniny" : response = rainy_handler(user_input) else : response = cloudy_handler(user_input) # 如果还有其他情况,就继续加分支,就再加一层if-else -
没有循环和重试 :如果在执行过程中某个步骤失败了,在链式调用里只能自己手动实现,手写
while true然后break,状态要自己传来传去 -
状态管理混乱:在链式调用里,数据通过变量进行传递,一旦有多个分支、多个节点,你就需要手动决定哪么变量传给哪个步骤,代码的全局状态就变成了隐式依赖,就会出现变量传错的情况
LangGraph的核心思路
把执行流描述成一张图Graph
节点
Node= 一个操作(函数)边
edge= 节点之间的转移状态
State= 贯穿整张图的数据容器
执行过程:从起点出发,按边走过各个节点,每个节点读取状态,更新状态,最终到达终点
用图来描述的好处:
- 条件分支变成了路由函数 ------不再使用
if-else,而是一个返回下一个节点名的函数 - 函数变成图里的环------节点之间是双向连接的,节点可以返回之前的节点,天然就支持重试和迭代
- 状态是显式的------所有节点共享同一个状态对象,谁改了什么一目了然
- 可视化------图结构可以直接画出来,方便理解和调试
三个概念
State状态
用python的TypedDict定义,是整张图共享的内存,是一个全局的State
python
from typing import TypedDict
# 定义State
class AgentState(TypedDict) :
user_input : str
sentiment : str
reply : str
图里的每一个节点都要接收读取这个State,然后根据执行结果进行修改后返回新的State
Node节点
节点就是普通的python函数,具体进行的是什么操作,签名是(state : state) -> dict :
python
def analyze_node(state: AgentState) -> dict:
# 读取State(状态)
text = state["user_input"]
# 做处理
sentiment = "positive" if "好" in text else "negative"
# 返回需要更新的字段
return {"sentiment" : sentiment}
返回的dict会被合并到全局state中,不需要返回完整的state对象
Graph图
把节点和边组装起来:就形成图了
python
# 导入StateGraph创建状态图 END表示流程结束了
from langgrph.graph import StateGraph, END
# 创建图,整个流程里共享的数据是AgentState,AgentState是一个贯穿全流程的状态,就是前面使用TypedDict来定义的状态
graph = StateGraph(AgentState)
# 下面两个节点都是共享同一个State,对同一个State进行操作,这个State在节点之间是互相传递的
graph.add_node("analyze", analyze_node) # analyze节点,执行函数是analyze_node
graph.add_node("reply", reply_node) # reply节点
graph.set_entry_point("analyze") # 设置节点入口,整个流程从analyze开始
# 添加边,analyze执行完后会自动进入到reply中,reply执行结束后,就END执行结束了
graph.add_edge("analyze", "reply")
graph.add_edge("reply", END)
app = graph.compile() # 编译图,把前面定义的流程图变成可以编译的程序,得到一个可执行的app,可以使用invoke进行运行
python
result = app.invoke({"user_input": "今天很开心"})
print(result)
和链式调用的对比
| 纬度 | 链式调用 | langgraph |
|---|---|---|
| 分支逻辑 | if-else散落在代码里 |
add_coonditional_edge集中管理 |
| 循环/重试 | 手写while状态容易混乱 |
图里天然就支持重试和迭代 |
| 状态管理 | 变量传递,隐式依赖,手动管理的 | 使用TypedDict来定义一个全局的State,显式类型安全 |
| 可维护性 | 流程越复杂越难改 | 改节点不影响其他节点 |
| 调试 | print大法 | 可视化图结构,节点独立测试 |
什么时候使用LangGraph
- 执行流程有条件分支,需要根据
LLM的输出来决定走那条分支 - 需要循环迭代,
Agent需要多次调用工具直到完成目标 - 多个
Agent协作,每一个Agent就是一个节点 - 需要
human-in-the-loop,暂停等待人工确认的操作 - 需要错误调试和重试
什么时候不需要LangGraph
- 单次
LLM调用 - 固定的线性流程(
prompt -> response -> done) - 非常简单的两步
chain
总结
LangGraph的核心:当Agent的执行逻辑复杂后怎么让代码变得可维护
简单场景链式调用更快更高效
需要分支处理、循环迭代、重试的复杂的业务逻辑场景下LangGraph更高效
LangGraph是什么,核心组成,如何实现一个简单的LangGraph
State Node Graph三件套
LangGraph是由这三个东西构成的:State状态、Node节点、Graph图,在上面粗略讲结果,在这里就详细讲解一下,后面所有的实例都是建立在这个基础上的
在pycharm或者终端进行安装
dash
pip install langgraph langchain-core
如果要用open-ai
pip install langchain-openai
State图的内存
State是整张图共享的数据容器,贯穿整张图的所有流程,所有的节点都从State中读取数据,然后修改,最后更新
用TypedDict定义State
python
from typing import typedDict, List, Optional
class MyState(TypedDict) :
user_input : str # 用户输入
analysis : str # 中间结果
result : str # 中间结果
error : Optional[str] # 可选字段,error要么是Node,要么是str
TypedDict是python标准库里的类型,,让字典有了类型检查,LangGraph用它来追踪状态结构
节点返回Dict,而不是返回完整的State
每次执行完节点的操作后只会返回修改的字段,然后更新会全局State中,不会返回完整的State
python
def my_node(state : MyState) -> dict :
# 只会返回需要更新的字段,不会返回完整的State
return {"analysis" : "positive"}
使用Annotated + operator.add追加列表
如果某个字段是列表,想追加而不是覆盖
python
from typing import Annotated
import operator
class ChatState(TypedDict) :
messages : Annotated[List[str], operator.add]
summary : str
这样每个节点返回的{"messages"}:["新消息"]}就会被追加到列表中,而不是替换整个列表,就不会被覆盖
Node界定------图的操作单元
节点就是普通的python函数,签名固定:
python
def node_name(state : YourState) -> dict :
value = state["some_field"] # 读取状态
result = process(value) # 做处理
return {"another_field" : result} # 返回需要更新的字段
节点的几个原则
-
**节点只做一件事:**每个节点只聚焦于一个职责,不要把分析、调用API、格式化输出、重试都塞到一个节点中
-
**节点尽可能是纯的:**理想情况下节点的输出只依赖
State输出,没有隐藏的全局状态,这样更容易测试和调试 -
返回
dict,不是state对象:python# 正确的返回形式 return {"field_a" : value_a, "field_b" : value_b} # 错误的返回形式 return state
Graph图------把节点连起来
python
from langgraph.graph import StateGraph, END, START
# 1.创建图,指定State的类型
graph = StateGraph(MyState)
# 2.添加节点
graph.add_node("node_a", function_a)
graph.add_node("node_b", function_b)
graph.add_node("node_c", function_c)
# 3.设置节点入口
# 其实就等价于:graph.set_entry_point(START, "node_a")
graph.set_entry_point("node_a")
# 4.添加边
graph.add_edge("node_a", "node_b")
graph.add_edge("node_b", "node_c")
graph.add_edge("node_c", "END")
# 5.编译
app = graph.compile()
运行图
python
# 通过invoke同步运行, 返回最终state
result = app.invoke({"user_input" : "hello"})
print(result)
# stream流式运行,每个节点完成后返回一次
for chunk in app.stream({"user_input" : "hello"}) :
print(chunk)
invoke 返回的是最终的完整 state 字典。stream 每次 yield 一个 {node_name: state_update} 的字典。
完整示例:文本处理管道
python
from typing import TypedDict
from langgraph.graph import StateGraph, END
# 1. 定义 State
class TextState(TypedDict):
raw_text: str
cleaned: str
word_count: int
summary: str
# 2. 定义节点
def clean_node(state: TextState) -> dict:
"""清理文本:去掉多余空格"""
text = state["raw_text"].strip()
return {"cleaned": text}
def count_node(state: TextState) -> dict:
"""统计词数"""
count = len(state["cleaned"].split())
return {"word_count": count}
def summary_node(state: TextState) -> dict:
"""生成摘要(这里简化为截断)"""
text = state["cleaned"]
summary = text[:50] + "..." if len(text) > 50 else text
return {"summary": summary}
# 3. 组装图
graph = StateGraph(TextState)
graph.add_node("clean", clean_node)
graph.add_node("count", count_node)
graph.add_node("summarize", summary_node)
graph.set_entry_point("clean")
graph.add_edge("clean", "count")
graph.add_edge("count", "summarize")
graph.add_edge("summarize", END)
app = graph.compile()
# 4. 运行
result = app.invoke({
"raw_text": " LangGraph 是一个用于构建有状态 Agent 应用的框架,基于图结构描述执行流。 ",
"cleaned": "",
"word_count": 0,
"summary": ""
})
print("清理后:", result["cleaned"])
print("词数:", result["word_count"])
print("摘要:", result["summary"])
输出:
清理后: LangGraph 是一个用于构建有状态 Agent 应用的框架,基于图结构描述执行流。 词数: 17 摘要: LangGraph 是一个用于构建有状态 Agent 应用的框架,基于图结构描述执行流。
可视化图结构
LangGraph可以把图渲染成ASCII或图片
python
# ASCII可视化
print(app.get_graph().draw_ascii())
# 输出大致如下
# +-----------+
# | __start__ |
# +-----------+
# |
# clean
# |
# count
# |
# summarize
# |
# +---------+
# | __end__ |
# +---------+
总结
三件套关系:
State是全局数据,所有节点共享,TypedDict定义结构Node是操作/函数,读State写State,普通函数Graph是流程,把节点用边连起来,然后compile()进行编译运行
记住这三条规则:
- 节点只返回要修改的字段(dict),不返回完整 state
- 边决定执行顺序,
END是终止信号 invoke传入的是初始 state(dict),返回的是最终 state(dict)
顺序图------第一个可运行Workflow
顺序图是LangGraph里最简单的模式:节点A执行完,接着执行节点B,再执行节点C,没有分支,没有循环
这里我们用一个BMI计算器来演示,因为他的逻辑足够清晰:输入->计算->分类->输出
为什么从顺序图开始
顺序图没有额外的复杂度,是最简单的,可以让你专注于LangGraph的基本操作:
- 怎么设计
State - 怎么写节点
Node - 怎么用
add_edge连接节点 - 怎么运行和查看结果
顺序图是暂时没有进行条件分支的处理的,条件分支只是在顺序图的基础上加一个路由函数
BMI计算器:需求
输入:用户身高cm、体重kg、用户姓名
步骤:
- 验证输入
- 计算
BMI值 - 根据
BMI进行分类(偏瘦/正常/超重/肥胖) - 生成健康建议
- 格式化输出报告
代码实现
python
from typing import TypedDict, Optional
from langgraph.graph import StateGraph, END
# State的定义
class BMIState(TypedDict) :
name : str
height_cm : float
weight_kg : float
bmi : float
category : str
advice : str
report : str
error : Optional[str]
# 定义节点
# 1.验证输入的数据
def validate_input(state : BMIState) -> dict :
"""验证输入的数据"""
height = state["height_cm"]
weight = state["weight_cm"]
if height <= 0 or height > 300:
return {"error": f"身高数据异常: {height}cm"}
if weight <= 0 or weight > 500:
return {"error": f"体重数据异常: {weight}kg"}
return {"erroe" : None}
# 2.计算BMI
def calculate_bmi(state : BMIState) -> dict :
"""计算BMI"""
if state.get("error") :
return {}
height_m = state["height_cm"] / 100
bmi = state["weight_kg"] / (height_m ** 2)
bmi = round(bmi, 2)
return {"bmi", bmi}
# 3.进行分类
def classify_bmi(state : BMIState) -> dict :
"""BMI分类"""
if state.get("error") :
return{}
bmi = state["bmi"]
if bmi < 18.5 :
category = "偏瘦"
elif bmi < 24.9 :
category = "正常"
elif bmi < 29.9 :
category = "超重"
else :
category = "肥胖"
return {"category", category}
# 4.生成健康报告
def generate_radvice(state : BMIState) -> dict :
"""生成健康报告"""
if state.get("error") :
return {}
category = state["category"]
advice_map = {
"偏瘦" : "建议适量增加营养摄入,加强力量训练,必要时咨询营养师"
"正常" : "保持当前饮食和运动习惯,定期体检"
"超重" : "控制饮食,每天进行两小时运动"
"肥胖" : "在医生指导下进行减肥"
}
return {"advice", advice_map[category]}
# 5.格式化结果输出
def format_report(state : BMIState) -> dict :
"""生成最终报告"""
if state.get("error") :
report = f"错误:{state['error']}"
else :
report = f"""
===BMI健康报告===
姓名:{state['name']}
身高:{state['height_cm']}cm
体重:{state['wright_kg']}kg
BMI:{state['bmi']}
分类:{state['category']}
建议:{statet['adice']}
""".strip()
return {"repost" : repost}
# 组装图
graph = StateGraph(BMIState)
# 添加节点
graph.add_node("validate", validate_input)
graph.add_node("calculate", calculate_bmi)
graph.add_node("classify", classify_bmi)
graph.add_node("advise", generate_advice)
graph.add_node("format", format_report)
# 设置节点入口
graph.set_entry_point("validate")
# 添加边,串联节点
graph.add_edge("validate", "calculate")
graph.add_edge("calculate", "classify")
graph.add_edge("classify", "advise")
graph.add_edge("advise", "format")
graph.add_edge("format", END)
# 编译运行输出结果
app = graph.compile()
运行结果
python
# 正常输入
result = app.invoke({
"name" : "zhangsan",
"height_cm" : 175.0,
"weight_kg" : 70.0,
"bmi" : 0.0,
"category" : "",
"advice" : "",
"report" : "",
"error" : None
})
print(result["report"])
# 输出
=== BMI 健康报告 ===
姓名:张三
身高:175.0 cm
体重:70.0 kg
BMI:22.86
分类:正常
建议:保持当前的饮食和运动习惯,定期体检。
python
# 异常输入
result = app.invoke({
"name": "李四",
"height_cm": -10.0,
"weight_kg": 60.0,
"bmi": 0.0,
"category": "",
"advice": "",
"report": "",
"error": None,
})
print(result["report"])
# 输出:错误:身高数据异常: -10.0cm
# 不会输出格式化报告
用stream去观察每个节点的输出
python
# 正常输入
for step in app.stream({
"name": "王五",
"height_cm": 160.0,
"weight_kg": 80.0,
"bmi": 0.0,
"category": "",
"advice": "",
"report": "",
"error": None,
}):
node_name, state_update = list(step.items())[0]
print(f"[{node_name}]{state_update}")
# 输出
[validate] {'error': None}
[calculate] {'bmi': 31.25}
[classify] {'category': '肥胖'}
[advise] {'advice': '建议在医生指导下制定减重计划,注意饮食结构和规律运动。'}
[format] {'report': '=== BMI 健康报告 ===\n...'}
使用stream进行运行的话,每个节点输出的是他修改的字段,不是完整的state,这就是stream模式的用法,可以实时看见每一步的结果
顺序图的图结构
START
|
validate
|
calculate
|
classify
|
advise
|
format
|
END
五个节点五条边,没有分支没有交叉,add_edge(A, B)的意识是:节点A完成后,无条件去执行节点B
初始State如何设置
在调用invoke进行运行的时候需要传入完整的State,即需要传入所有的字段
因为TypedDict要求字段完整,在实际项目中通常有两种处理方式:
用optional加默认值:这里初始state里未知字段传入Node即可
python
class BMIState(TypedDict):
name: str
height_cm: float
weight_kg: float
bmi: Optional[float] # 允许为 None
category: Optional[str]
advice: Optional[str]
report: Optional[str]
error: Optional[str]
用total=False:使得所有字段都是可选的
python
class BMIState(TypedDict, total=False):
name: str
height_cm: float
# ...其他字段不是必填的
总结
顺序图的要点:
add_edge(A, B)= A 完成后执行 B,无条件set_entry_point("node_name")= 从哪个节点开始add_edge("last_node", END)= 告诉 LangGraph 到这里结束invoke运行图,传入初始 state,返回最终 statestream流式运行,每个节点完成后 yield 一次
条件分支add_conditional_edges--上面的案例是没有分支的,在这里加上条件分支
在顺序图里面每条边都是固定的,A执行完后必然会无条件去执行B。但是在真实的Agent中是需要根据每一次的运行结果决定下一步该如何走的------用户反馈是正面走一条路,负面走另外一条路
所以在LangGraph中就使用add_conditional_edges来处理这种情况
核心API
python
graph.add_conditional_edges(
"source_node", # 从哪个节点出发
routing_function, #路由函数:接收state,返回下一个节点的名字
{
"route_a" : "node_a", # 路由函数返回"route_a"时走node_a
"route_b" : "node_b", # 路由函数返回"route_a"时走node_a
"end" : END, # 路由函数返回"end"时走end结束
}
)
python
# 路由函数就是一个普通的python函数:
def routing_function(state : MyState) -> dict :
if state["some_condition"]:
return "route_a"
else :
return "route_b"
他接收当前State会返回一个字符串key
LangGraph用这个key从映射表里找到下一个节点
用户反馈路由
客服机器人系统根据收到用户的反馈,根据用户不同的情绪走不同的处理流程
rust
用户输入
|
情绪分析
|
+--正面--->感谢回复
|
+--负面-->道歉赔偿
|
+--中性-->标准回复
|
最终格式化输出
具体实现
python
from typing import TypedDict
from langgraph.graph import StateGraph, END
class FeedbackState(TypedDict):
user_input: str
sentiment: str # "positive" / "negative" / "neutral"
response: str
final_output: str
def analyze_sentiment(state: FeedbackState) -> dict:
"""情绪分析(简化版,实际可以用 LLM)"""
text = state["user_input"].lower()
positive_words = ["好", "棒", "满意", "喜欢", "excellent", "great", "happy"]
negative_words = ["差", "烂", "不满", "投诉", "terrible", "bad", "angry"]
pos_count = sum(1 for w in positive_words if w in text)
neg_count = sum(1 for w in negative_words if w in text)
if pos_count > neg_count:
sentiment = "positive"
elif neg_count > pos_count:
sentiment = "negative"
else:
sentiment = "neutral"
return {"sentiment": sentiment}
def handle_positive(state: FeedbackState) -> dict:
"""处理正面反馈"""
response = f"感谢您的好评!很高兴我们的服务让您满意。您的反馈:'{state['user_input']}'"
return {"response": response}
def handle_negative(state: FeedbackState) -> dict:
"""处理负面反馈"""
response = (
f"非常抱歉给您带来了不好的体验!"
f"您的反馈已记录:'{state['user_input']}'。"
f"我们的高级客服将在 24 小时内联系您。"
)
return {"response": response}
def handle_neutral(state: FeedbackState) -> dict:
"""处理中性反馈"""
response = f"感谢您的反馈:'{state['user_input']}'。我们会继续改进服务。"
return {"response": response}
def format_output(state: FeedbackState) -> dict:
"""格式化最终输出"""
output = f"[情绪:{state['sentiment']}]\n{state['response']}"
return {"final_output": output}
def route_by_sentiment(state: FeedbackState) -> str:
"""根据情绪返回路由 key"""
return state["sentiment"] # 直接返回 "positive" / "negative" / "neutral"
graph = StateGraph(FeedbackState)
# 已经不再是一连串往下的逻辑了,是有分支的
graph.add_node("analyze", analyze_sentiment)
graph.add_node("positive_handler", handle_positive)
graph.add_node("negative_handler", handle_negative)
graph.add_node("neutral_handler", handle_neutral)
graph.add_node("format", format_output)
graph.set_entry_point("analyze")
graph.add_conditional_edges(
"analyze",
route_by_sentiment,
{
"positive": "positive_handler",
"negative": "negative_handler",
"neutral": "neutral_handler",
}
)
# 三个分支最终都汇入 format
graph.add_edge("positive_handler", "format")
graph.add_edge("negative_handler", "format")
graph.add_edge("neutral_handler", "format")
graph.add_edge("format", END)
app = graph.compile()
运行
python
# 正面反馈
r = app.invoke({
"user_input": "产品很棒,服务也很满意!",
"sentiment": "",
"response": "",
"final_output": ""
})
print(r["final_output"])
# 负面反馈
r = app.invoke({
"user_input": "太差了,完全不满意,要投诉!",
"sentiment": "",
"response": "",
"final_output": ""
})
print(r["final_output"])
# [情绪:negative]
# 非常抱歉给您带来了不好的体验!...
图结构
rust
START
|
analyze
|
+-- positive --> positive_handler --> format --> END
|
+-- negative --> negative_handler --> format --> END
|
+-- neutral --> neutral_handler --> format --> END
三条分支汇入同一个format节点------这叫 Fan-out + Fan-in,是多分支合并的标准模式。
配合llm做情绪分析
在实际的项目中,analyze_sentiment会调用llm来进行处理:
python
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
def analyze_sentiment(state: FeedbackState) -> dict:
prompt = f"""分析以下文本的情绪,只回复 "positive"、"negative" 或 "neutral" 之一。
文本:{state['user_input']}"""
response = llm.invoke([HumanMessage(content=prompt)])
sentiment = response.content.strip().lower()
# 容错处理
if sentiment not in ["positive", "negative", "neutral"]:
sentiment = "neutral"
return {"sentiment": sentiment}
路由函数本身不需要改,它只看 state 里的sentiment字段
节点:负责更新状态
路由函数负责读取状态做出决策
Pydantic结构化输出
如果你想用Pydantic让LLM输出更稳定可靠
python
from pydantic import BaseModel
from langchain_openai import ChatOpenAI
class SentimentOutput(BaseModel):
sentiment: str # "positive" / "negative" / "neutral"
confidence: float # 0.0 ~ 1.0
reason: str # 判断原因
llm = ChatOpenAI(model="gpt-4o-mini")
structured_llm = llm.with_structured_output(SentimentOutput)
def analyze_sentiment(state: FeedbackState) -> dict:
result = structured_llm.invoke(
f"分析情绪:{state['user_input']}"
)
return {
"sentiment": result.sentiment,
"confidence": result.confidence,
}
with_structured_output让LLM返回结构化的Pydantic对象,而不是纯文本,这样的路由函数拿到的字段更可靠
条件分支 VS if-else
为什么要用条件分支呢,if-else不也能达到同样的效果吗
理论上可以,但是有问题:
- 可见性 :使用
add_conditional_edges可以让图结构可以被可视化,if-else隐藏在函数内部看不出来 - 可测试性 :路由函数可以单独测试,不会依赖完整的节点执行,而
if-else是嵌入在具体代码中的 - 可维护性:加一条新分支只需要加一个节点+在映射表里加一列,不需要修改现有逻辑
就是跟java设计模式一个道理,为什么在实现业务功能的时候不用if-else for while等这么直白的操作,就是为了便于后续的的维护、功能拓展、解耦合
总结
add_conditional_edges(source, routing_fn, mapping)是LangGraph的条件分支API- 路由函数返回字符串
key,mapping表把key映射到节点名 - 路由函数只读
state,不做操作------决策和操作分离 - 多个分支可以汇入同一个节点
Fan-in
并行执行 : Fan-out / Fan- in
在顺序图中是按照节点一个个执行的,但是有些任务天然是可以并行执行的,例如同时获取多个数据源,或者同时做多个纬度的分析
LangGraph支持并行执行:从一个节点出发,可以同时触发多个节点去执行Fan-out,等他们执行完成后,就会汇聚到一个节点中进行处理结果Fan-in
Fan-out / Fan-in模式
START
|
[获取输入]
/ | \
[A] [B] [C] <- 并行执行
\ | /
[汇聚节点]
|
END
在LangGraph里,实现并行是很简单的:就是让多个节点都从一个节点出发
python
# 从 "fetch_input" 同时出发到三个节点
# Fan-out
graph.add_edge("fetch_input", "node_a")
graph.add_edge("fetch_input", "node_b")
graph.add_edge("fetch_input", "node_c")
# 三个节点都指向汇聚节点
# Fan-in
graph.add_edge("node_a", "aggregate")
graph.add_edge("node_b", "aggregate")
graph.add_edge("node_c", "aggregate")
LangGraph会自动检测到`node_a node_b node_c可以同时执行,并行运行他们
状态合并:用Annotated + operator.add
并行节点都会更新State,LangGraph需要知道怎么合并这些并行节点的执行结果
对于列表字段,用Annotated + operator.add来追加,这个在前面也讲过
python
from typing import TypedDict, Annotated, List
import operator
class ParallelState(TypedDict):
input: str
results: Annotated[List[str], operator.add] # 并行结果追加到列表
summary: str
每一个并行节点返回执行结果{"result" : ["自己的结果"]}
LangGraph把这些列表拼接起来,不是覆盖,是追加
例子: 篮球运动员综合评估
场景:从三个纬度去分析一个篮球运动员,同时去进行分析:三分表现、防守表现、抢断表现,最后进行汇总返回结果
python
from typing import TypedDict, Annotated, List
import operator
from langgraph.graph import StateGraph, END
# ===== State =====
class CricketState(TypedDict):
player_name: str
batting_avg: float
bowling_avg: float
fielding_rating: float
analyses: Annotated[List[str], operator.add] # 三个并行节点的输出
final_report: str
# ===== 并行节点 =====
def analyze_batting(state: CricketState) -> dict:
"""分析击球表现"""
avg = state["batting_avg"]
if avg >= 50:
level = "世界级"
elif avg >= 35:
level = "优秀"
elif avg >= 20:
level = "一般"
else:
level = "较弱"
analysis = f"[击球] 平均分 {avg},评级:{level}"
return {"analyses": [analysis]}
def analyze_bowling(state: CricketState) -> dict:
"""分析投球表现(投球均值越低越好)"""
avg = state["bowling_avg"]
if avg <= 20:
level = "世界级"
elif avg <= 30:
level = "优秀"
elif avg <= 40:
level = "一般"
else:
level = "较弱"
analysis = f"[投球] 平均分 {avg},评级:{level}"
return {"analyses": [analysis]}
def analyze_fielding(state: CricketState) -> dict:
"""分析防守表现"""
rating = state["fielding_rating"]
if rating >= 8:
level = "出色"
elif rating >= 6:
level = "良好"
elif rating >= 4:
level = "一般"
else:
level = "需改进"
analysis = f"[防守] 评分 {rating}/10,评级:{level}"
return {"analyses": [analysis]}
# ===== 汇聚节点 =====
def aggregate_results(state: CricketState) -> dict:
"""汇聚三个分析结果,生成总报告"""
name = state["player_name"]
analyses = state["analyses"]
report = f"=== {name} 综合评估报告 ===\n"
for a in analyses:
report += f" {a}\n"
# 计算综合评分(简化逻辑)
score = (
min(state["batting_avg"] / 50, 1.0) * 40 +
max(0, (40 - state["bowling_avg"]) / 40) * 40 +
state["fielding_rating"] / 10 * 20
)
report += f"\n综合得分:{score:.1f} / 100"
return {"final_report": report}
# ===== 入口节点 =====
def start_node(state: CricketState) -> dict:
"""入口节点:什么都不做,只是作为 Fan-out 的起点"""
return {}
# ===== 组装图 =====
graph = StateGraph(CricketState)
graph.add_node("start", start_node)
graph.add_node("batting", analyze_batting)
graph.add_node("bowling", analyze_bowling)
graph.add_node("fielding", analyze_fielding)
graph.add_node("aggregate", aggregate_results)
graph.set_entry_point("start")
# Fan-out:start 同时触发三个分析节点
graph.add_edge("start", "batting")
graph.add_edge("start", "bowling")
graph.add_edge("start", "fielding")
# Fan-in:三个节点都汇入 aggregate
graph.add_edge("batting", "aggregate")
graph.add_edge("bowling", "aggregate")
graph.add_edge("fielding", "aggregate")
graph.add_edge("aggregate", END)
app = graph.compile()
运行
python
result = app.invoke({
"player_name": "Virat Kohli",
"batting_avg": 59.8,
"bowling_avg": 34.0,
"fielding_rating": 9.0,
"analyses": [],
"final_report": "",
})
print(result["final_report"])
输出
python
=== Virat Kohli 综合评估报告 ===
[击球] 平均分 59.8,评级:世界级
[防守] 评分 9.0/10,评级:出色
[投球] 平均分 34.0,评级:优秀
综合得分:82.6 / 100
三个节点的分析顺序可能不同,因为他们是并行执行的,但是analyses列表会把他么全都收集进来
并行+条件分支
Fan-out和add_conditional_edes可以混用,例如:
python
# 入口节点之后:根据任务类型分流
graph.add_conditional_edges(
"start",
route_by_type,
{
"simple": "quick_analysis",
"complex": "detailed_analysis",
}
)
# 详细分析再 Fan-out 到多个子节点
graph.add_edge("detailed_analysis", "sub_a")
graph.add_edge("detailed_analysis", "sub_b")
graph.add_edge("sub_a", "merge")
graph.add_edge("sub_b", "merge")
并行执行需要注意的点
1. 并行节点不能互相依赖
如果节点 B 的输入依赖节点 A 的输出,它们就不能并行------这是逻辑上的串行依赖。
2. Annotated 列表的顺序不确定
并行节点完成顺序不固定,收集到 Annotated[List, operator.add] 里的顺序也不固定。如果顺序重要,在汇聚节点里排序。
3. 对于普通(非 Annotated)字段
如果多个并行节点都修改同一个普通字段,最后一个完成的节点会覆盖前面的。一般来说并行节点应该各自负责不同的字段。
总结
- Fan-out:从一个节点出发,用多个
add_edge同时触发多个节点 - Fan-in:多个节点都用
add_edge指向同一个汇聚节点 - 并行结果用
Annotated[List[T], operator.add]收集 - LangGraph 自动检测并行机会,不需要手动管理线程
Prompt Chaining:分布生成
一次性让 LLM 生成完整的长文内容,效果往往不理想------模型容易跑题,或者生成质量参差不齐。
Prompt Chaining 的思路是:把大任务拆成多个步骤,每个步骤用单独的 prompt,上一步的输出作为下一步的输入。每个节点专注一件事,最终串联出高质量的结果。
其实一个prompt对应每一个小任务在刚开始讲的时候就提到过
为什么要对一个大任务进行拆解
一次性生成 vs 分步生成的区别:
| 方式 | 优点 | 缺点 |
|---|---|---|
| 一次性生成 | 简单快速 | 长文质量不稳定,难以干预中间过程 |
| Prompt Chaining | 每步可控,中间结果可检查 | 多次 LLM 调用,耗时更长 |
分步生成适合需要结构化输出的场景:文章生成、报告撰写、代码生成等。
案例:生成博客文章
python
from typing import TypedDict
from langgraph.graph import StateGraph, END
# ===== State =====
class BlogState(TypedDict):
topic: str
outline: str
draft: str
final_article: str
# ===== 节点(不依赖特定 LLM,方便你替换)=====
def generate_outline(state: BlogState) -> dict:
"""第一步:生成文章大纲"""
topic = state["topic"]
# 这里用占位符表示 LLM 调用,下面会展示真实代码
prompt = f"""为以下主题创建一个清晰的博客文章大纲:
主题:{topic}
请提供:
1. 引言要点
2. 3-4 个主要章节标题
3. 结论要点
大纲:"""
# outline = llm.invoke(prompt) # 替换成真实 LLM 调用
# 示例输出(演示用)
outline = f"""
## {topic} 完整指南
**引言**:介绍 {topic} 的背景和重要性
**第一章:基础概念**
- 核心定义
- 关键术语
**第二章:实现方式**
- 主流方案对比
- 最佳实践
**第三章:实战案例**
- 具体示例
- 常见问题
**结论**:总结要点,给出建议
""".strip()
return {"outline": outline}
def expand_content(state: BlogState) -> dict:
"""第二步:根据大纲生成正文草稿"""
outline = state["outline"]
topic = state["topic"]
prompt = f"""根据以下大纲,为主题"{topic}"写一篇详细的博客文章草稿。
每个章节至少写 2-3 段,包含具体示例。
大纲:
{outline}
文章草稿:"""
# draft = llm.invoke(prompt) # 替换成真实 LLM 调用
draft = f"""# {topic} 完整指南
## 引言
{topic} 是现代软件开发中的重要话题...(正文草稿)
## 基础概念
理解 {topic} 首先需要掌握几个核心概念...
## 实现方式
在实际项目中,有多种方式可以实现 {topic}...
## 实战案例
以下是一个典型的 {topic} 应用场景...
## 结论
通过本文的介绍,我们深入了解了 {topic} 的各个方面...
""".strip()
return {"draft": draft}
def polish_article(state: BlogState) -> dict:
"""第三步:润色优化文章"""
draft = state["draft"]
prompt = f"""请对以下文章草稿进行润色,使其:
1. 语言更流畅自然
2. 逻辑更清晰
3. 适合技术博客读者
草稿:
{draft}
润色后的文章:"""
# final = llm.invoke(prompt) # 替换成真实 LLM 调用
final = draft + "\n\n---\n*(已润色优化)*"
return {"final_article": final}
# ===== 组装图 =====
graph = StateGraph(BlogState)
graph.add_node("outline", generate_outline)
graph.add_node("expand", expand_content)
graph.add_node("polish", polish_article)
graph.set_entry_point("outline")
graph.add_edge("outline", "expand")
graph.add_edge("expand", "polish")
graph.add_edge("polish", END)
app = graph.compile()
运行
python
result = app.invoke({
"topic": "LangGraph 入门指南",
"outline": "",
"draft": "",
"final_article": "",
})
print("=== 大纲 ===")
print(result["outline"])
print("\n=== 最终文章 ===")
print(result["final_article"])
使用stream来观察每一步
python
for step in app.stream({
"topic": "LangGraph 入门指南",
"outline": "",
"draft": "",
"final_article": "",
}):
node_name = list(step.keys())[0]
print(f"\n--- [{node_name}] 完成 ---")
if node_name == "outline":
print(step["outline"]["outline"][:200])
elif node_name == "expand":
print(step["expand"]["draft"][:200])
elif node_name == "polish":
print("文章已润色完成")
接入真实LLM
使用OpenAI
python
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0.7)
def generate_outline(state: BlogState) -> dict:
topic = state["topic"]
prompt = f"为主题'{topic}'创建博客大纲(3-4个章节):"
response = llm.invoke([HumanMessage(content=prompt)])
return {"outline": response.content}
使用HuggingFace(Mistral)
python
import os
from langchain_huggingface import HuggingFaceEndpoint
# 需要 HuggingFace API token
llm = HuggingFaceEndpoint(
repo_id="mistralai/Mistral-7B-Instruct-v0.2",
huggingfacehub_api_token=os.environ["HUGGINGFACEHUB_API_TOKEN"],
task="text-generation",
max_new_tokens=512,
temperature=0.7,
)
def generate_outline(state: BlogState) -> dict:
topic = state["topic"]
prompt = f"[INST] 为主题'{topic}'创建博客大纲(3-4个章节):[/INST]"
response = llm.invoke(prompt)
return {"outline": response}
两者替换
LangGraph里的LLM调用只在节点函数里,切换模型只需要修改节点内部------图的结构完全不变 。这是Prompt Chaining模式的一个好处:执行逻辑和模型选择解耦。
中间结果的自我排查
分步生成的另一个优势:可以在节点之间检查中间结果,决定是否继续。
python
def check_outline_quality(state: BlogState) -> str:
"""检查大纲质量,决定是直接展开还是重新生成"""
outline = state["outline"]
# 简单检查:大纲是否包含足够的章节
if outline.count("##") >= 3:
return "expand" # 质量够,继续展开
else:
return "regenerate_outline" # 质量不够,重新生成
graph.add_conditional_edges(
"outline",
check_outline_quality,
{
"expand": "expand",
"regenerate_outline": "outline", # 循环回去重新生成
}
)
这就把 Prompt Chaining 和条件分支结合起来了,形成一个可以自我修正的生成循环。
总结
Prompt Chaining的要点:
- 把大任务拆成多个小步骤,每步一个节点
- 上一步的输出存到
state,下一步从state里读 - 每个节点的
prompt只关注当前步骤,不用一次性解决所有问题 - 中间结果可以用条件分支检查质量,不满足就重跑
LLM只在节点函数里,切换模型不影响图结构
接入LLM:OpenAI和HuggingFace
在前几篇文章中LLM的调用都是使用占位符来代替,这里就演示一下把真实的LLM接入
安装依赖
bash
# OpenAI
pip install langchain-openai
# HuggingFace
pip install langchain-huggingface huggingface_hub
# 两者都需要
pip install langgraph langchain-core
OpenAI方法
基础用法
python
import os
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage, SystemMessage
# 初始化(建议在节点外部初始化,避免每次调用都重新创建)
llm = ChatOpenAI(
model="gpt-4o-mini",
temperature=0,
api_key=os.environ.get("OPENAI_API_KEY"),
)
def summarize_node(state: dict) -> dict:
"""在节点里调用 ChatOpenAI"""
text = state["input_text"]
response = llm.invoke([
SystemMessage(content="你是一个专业的文本摘要助手。"),
HumanMessage(content=f"请用 2-3 句话总结以下内容:\n\n{text}"),
])
return {"summary": response.content}
用PromptTemplate管理Prompt
python
from langchain_core.prompts import ChatPromptTemplate
prompt_template = ChatPromptTemplate.from_messages([
("system", "你是一个专业的{role}。"),
("human", "{task}"),
])
chain = prompt_template | llm
def analyze_node(state: dict) -> dict:
response = chain.invoke({
"role": "代码审查专家",
"task": f"审查以下代码:\n{state['code']}",
})
return {"review": response.content}
结构化输出
python
from pydantic import BaseModel, Field
from typing import List
class CodeReview(BaseModel):
score: int = Field(description="代码质量评分 1-10")
issues: List[str] = Field(description="发现的问题列表")
suggestions: List[str] = Field(description="改进建议")
structured_llm = llm.with_structured_output(CodeReview)
def review_node(state: dict) -> dict:
result: CodeReview = structured_llm.invoke(
f"审查这段代码并给出评分:\n{state['code']}"
)
return {
"score": result.score,
"issues": result.issues,
"suggestions": result.suggestions,
}
HuggingFace方案
基础用法
python
import os
from langchain_huggingface import HuggingFaceEndpoint
llm = HuggingFaceEndpoint(
repo_id="mistralai/Mistral-7B-Instruct-v0.2",
huggingfacehub_api_token=os.environ["HUGGINGFACEHUB_API_TOKEN"],
task="text-generation",
max_new_tokens=512,
temperature=0.1,
do_sample=True,
)
def generate_node(state: dict) -> dict:
"""HuggingFace 节点"""
topic = state["topic"]
# Mistral 用 [INST] 标记
prompt = f"[INST] 用中文简要介绍:{topic} [/INST]"
response = llm.invoke(prompt)
return {"output": response}
不同模型的Prompt格式
不同模型有不同的指令格式,调用时要匹配:
ini
# Mistral / Mixtral
prompt = f"[INST] {instruction} [/INST]"
# Llama 2
prompt = f"<s>[INST] <<SYS>>\n{system}\n<</SYS>>\n\n{user} [/INST]"
# Llama 3
prompt = f"<|begin_of_text|><|start_header_id|>user<|end_header_id|>\n{user}<|eot_id|><|start_header_id|>assistant<|end_header_id|>"
# ChatML(Qwen, Yi 等)
prompt = f"<|im_start|>system\n{system}<|im_end|>\n<|im_start|>user\n{user}<|im_end|>\n<|im_start|>assistant\n"
用ChatHuggingFace统一接口
ChatHuggingFace 用 HumanMessage/SystemMessage 接口,自动处理prompt格式:
python
from langchain_huggingface import ChatHuggingFace, HuggingFaceEndpoint
from langchain_core.messages import HumanMessage, SystemMessage
endpoint = HuggingFaceEndpoint(
repo_id="mistralai/Mistral-7B-Instruct-v0.2",
huggingfacehub_api_token=os.environ["HUGGINGFACEHUB_API_TOKEN"],
task="text-generation",
max_new_tokens=512,
)
chat_llm = ChatHuggingFace(llm=endpoint)
def chat_node(state: dict) -> dict:
response = chat_llm.invoke([
SystemMessage(content="你是一个助手。"),
HumanMessage(content=state["user_input"]),
])
return {"response": response.content}
完整代码------多步内容生成workflow
用 OpenAI 实现一个完整的三步内容生成流程:
python
import os
from typing import TypedDict
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
from langgraph.graph import StateGraph, END
# ===== State =====
class ContentState(TypedDict):
topic: str
keywords: str
outline: str
article: str
# ===== LLM(只初始化一次)=====
llm = ChatOpenAI(
model="gpt-4o-mini",
temperature=0.7,
api_key=os.environ.get("OPENAI_API_KEY"),
)
# ===== 节点 =====
def extract_keywords(state: ContentState) -> dict:
"""提取关键词"""
response = llm.invoke([
HumanMessage(content=f"从主题'{state['topic']}'中提取 5 个核心关键词,用逗号分隔,只输出关键词:")
])
return {"keywords": response.content.strip()}
def create_outline(state: ContentState) -> dict:
"""根据关键词生成大纲"""
response = llm.invoke([
HumanMessage(content=(
f"基于主题'{state['topic']}'和关键词'{state['keywords']}',"
f"生成一个 4 节的文章大纲,每节一行,格式为:数字. 标题"
))
])
return {"outline": response.content.strip()}
def write_article(state: ContentState) -> dict:
"""根据大纲写文章"""
response = llm.invoke([
HumanMessage(content=(
f"根据以下大纲,写一篇关于'{state['topic']}'的短文(约 300 字):\n\n"
f"{state['outline']}"
))
])
return {"article": response.content.strip()}
# ===== 图 =====
graph = StateGraph(ContentState)
graph.add_node("keywords", extract_keywords)
graph.add_node("outline", create_outline)
graph.add_node("write", write_article)
graph.set_entry_point("keywords")
graph.add_edge("keywords", "outline")
graph.add_edge("outline", "write")
graph.add_edge("write", END)
app = graph.compile()
# ===== 运行 =====
if __name__ == "__main__":
result = app.invoke({
"topic": "LangGraph 状态图编程",
"keywords": "",
"outline": "",
"article": "",
})
print("关键词:", result["keywords"])
print("\n大纲:\n", result["outline"])
print("\n文章:\n", result["article"])
在节点里处理LLM错误
python
import time
from langchain_core.exceptions import OutputParserException
def robust_llm_node(state: dict) -> dict:
"""带重试的 LLM 节点"""
max_retries = 3
for attempt in range(max_retries):
try:
response = llm.invoke([HumanMessage(content=state["prompt"])])
return {"result": response.content}
except Exception as e:
if attempt < max_retries - 1:
time.sleep(2 ** attempt) # 指数退避
continue
else:
return {"result": f"错误:{str(e)}", "error": True}
环境变量管理
python
# 推荐用 .env 文件 + python-dotenv
from dotenv import load_dotenv
load_dotenv()
# .env 文件内容:
# OPENAI_API_KEY=sk-...
# HUGGINGFACEHUB_API_TOKEN=hf_...
import os
openai_key = os.environ["OPENAI_API_KEY"]
hf_token = os.environ["HUGGINGFACEHUB_API_TOKEN"]
OpenAI vs HuggingFace 选哪个
| 维度 | OpenAI (GPT-4o) | HuggingFace (Mistral 等) |
|---|---|---|
| 质量 | 更高,特别是复杂推理 | 中等,小模型差距明显 |
| 成本 | 按 token 计费 | 推理 API 免费(有限额) |
| 速度 | 快 | 免费 Endpoint 较慢 |
| 离线部署 | 不支持 | 支持(本地推理) |
| 结构化输出 | 原生支持 | 需要自己解析 |
| 适合场景 | 生产环境,高质量需求 | 学习实验,成本敏感 |
学习阶段:用 HuggingFace 免费额度跑通流程,不需要花钱。
生产阶段:换 OpenAI/Claude,只改节点里的 LLM 初始化代码,图结构不变。
总结
LLM在节点函数里调用,图结构与模型选择完全解耦OpenAI:ChatOpenAI+HumanMessage/SystemMessage,支持with_structured_outputHuggingFace:HuggingFaceEndpoint+ChatHuggingFace,注意 prompt 格式LLM对象在图外部初始化,节点函数闭包引用- 生产代码记得加重试和错误处理
到这里,LangGraph 的核心模式都覆盖到了:顺序图、条件分支、并行执行、Prompt Chaining、LLM 集成。