超越线性工作流的 LangGraph
在技术架构选型中,一个常见的认知是:框架的先进性与复杂性往往同解决方案的优越性划等号。
然而,在追求极致效能与可控性的工程实践中,这一等式未必成立。
最近在参与一个 RAG 检索问答系统的项目,该系统的核心约束非常明确:
LLM 必须严格基于我们提供的高质量知识库生成回答,任何超纲的“幻觉”或事实性错误都是不可接受的。
这意味着我们需要:
- 精准控制每一步:从 Query 改写、ES 的复杂查询构建、多路检索结果的融合排序,到相关性判定的精细阈值,每一步我们都希望能像操作手术刀一样精准。
- 极致的透明度:当用户反馈某个问题回答得不好时,我需要能立刻追溯到是检索环节出了问题,还是 LLM 的 Prompt 没给好,甚至是某个工具函数返回了异常。
在此背景下,一个最直观的方案是采用原生 Python 自行构建核心流程。
这条路径看似直接、掌控力最强,也似乎最高效。
但我也不禁思考,如果引入如 LangGraph 这类专为构建复杂 AI 应用而生的状态管理框架,它究竟能为项目带来哪些范式上的提升,又是否会引入不必要的复杂性?
如果说 LangChain 的核心链式语法 LCEL(LangChain Expression Language)提供的是构建 AI 应用的“预制件”和“线性流水线”,那么 LangGraph 提供的则是一个基于图的、更底层的 状态机(State Machine) 引擎。
它允许开发者将应用流程明确定义为一个由节点(Nodes)和边(Edges)构成的图结构。
每个节点代表一个可执行的功能单元(如检索、生成、判断),而边则定义了基于当前执行结果的流转逻辑。
这使得以下关键特性成为可能:
- 循环:可以轻松实现“思考 - 行动 - 观察”的循环,直到任务完成。
- 并行与汇聚:多个分支可以同时执行,再根据结果汇聚到下一个节点。
- 清晰的状态管理:整个系统的运行状态在图中透明地流动和演化,可控性和可观测性极强。
1. LangGraph 的核心概念
1.1. 状态(State)
LCEL 中:数据在 Runnable 之间以输入/输出的形式单向传递,每个组件都是无状态的。
LangGraph 中:整个图的执行过程围绕一个中心化的状态对象进行。这个对象(通常是一个 Python TypedDict)在图的生命周期内被所有节点共享和修改。它就像是 Agent 的“内存”或“草稿纸”。
1.2. 节点(Nodes)
LCEL 中:节点是任意 Runnable 对象。
LangGraph 中:节点是一个函数或 Runnable,它接收当前的状态作为输入,并返回一个用以更新状态的字典。LangGraph 会自动将返回的字典合并(merge)到主状态中,这是一种可控的、增量式的状态修改机制。
1.3. 边(Edges)
- 入口点(set_entry_point):定义图从哪个节点开始执行。
- 普通边(add_edge):定义一个节点执行完毕后,无条件地流转到下一个节点。
- 条件边(add_conditional_edges):LangGraph 的精髓所在。它允许定义一个决策函数,该函数检查当前状态,并返回下一个要执行的节点名,从而实现动态的、基于状态的路由。
- 终点(END):一个特殊的节点名,表示图的执行终止。
LCEL 中:边由 | 符号隐式定义,代表数据流动的方向。
LangGraph 中:边是显式定义的,它代表了控制流。
2. 代码实战:使用 LCEL 和 LangGraph 两种方式重构 RAG 项目
2.1. 模块定义
首先,我们先定义一些业务所需逻辑模块。这些模块都是原生 Python 函数,可以被 LCEL 和 LangGraph 共同调用。
from langchain_core.documents import Document
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnableLambda, RunnablePassthrough
from langchain_core.output_parsers import StrOutputParser
from typing import TypedDict, List
from langgraph.graph import StateGraph, END
import sys
from elasticsearch import Elasticsearch
from dotenv import load_dotenv
load_dotenv()
# 配置es客户端
es_client = None
es_index = None
try:
es_client = Elasticsearch(es_url, basic_auth=(es_user, es_password))
except Exception as e:
import traceback
traceback.print_exc()
sys.exit(1)
# 这里使用腾讯云DeepSeek-v3模型
model = ChatOpenAI(
model="deepseek-v3",
base_url="https://api.lkeap.cloud.tencent.com/v1")
def preprocess_query1(state: dict) -> dict:
"""模块A: Query预处理1"""
print(f"--- 模块A: 预处理Query1 ---")
state['preprocessed_question'] = state['question'].strip().lower()
return state
def preprocess_query2(state: dict) -> dict:
"""模块B: Query预处理2"""
print(f"--- 模块B: 预处理Query2 ---")
state['preprocessed_question'] = state['preprocessed_question'].replace('\n', ' ')
return state
# 可以增加其他预处理函数...
def retrieve_documents(state: dict) -> dict:
"""模块C&D: 检索模块 & 获取相关文档"""
print(f"--- 模块C&D: 检索文档 ---")
try:
query = {
"query": {"match": {"content": state['preprocessed_question']}},
"size": 2
}
results = es_client.search(index=es_index, body=query)
documents = []
for hit in results['hits']['hits']:
source = hit['_source']
doc = Document(
page_content=source.get('content', ''),
metadata={'score': hit['_score'], 'id': source.get('id', '')}
)
documents.append(doc)
state['documents'] = documents
except Exception as e:
import traceback
traceback.print_exc()
state['documents'] = []
return state
def rerank_and_filter(state: dict) -> dict:
"""模块E: 重排 & 过滤模块"""
print(f"--- 模块E: 重排与过滤 ---")
# E:通过es score或者LLM模型重排
# 此处省略代码...
# F:通过阈值过滤
threshold = 0.7
filtered_docs = []
for doc in state['documents']:
score = doc.metadata.get('score', 0)
score = float(score)
if score >= threshold:
filtered_docs.append(doc)
state['filtered_documents'] = filtered_docs if filtered_docs else state['documents']
return state
def build_prompt(state: dict) -> dict:
"""模块F: Prompt构建模块"""
print(f"--- 模块F: 构建Prompt ---")
template = """仅根据以下上下文回答问题:
---
{context}
---
问题: {question}
"""
prompt_template = ChatPromptTemplate.from_template(template)
context_str = "\n\n".join([doc.page_content for doc in state['filtered_documents']])
state['prompt'] = prompt_template.format(context=context_str, question=state['question'])
return state
def llm_call(state: dict) -> str:
"""模块G: LLM调用"""
print(f"--- 模块G: 调用腾讯云DeepSeek-v3模型 ---")
response = model.invoke(state['prompt'])
return response.content
def post_process_answer(state: dict) -> dict:
"""模块H: 生成答案 & 后处理"""
print(f"--- 模块H: 后处理答案 ---")
state['final_answer'] = state['generation'].strip()
return state
2.2. 使用 LCEL 实现代码示例
链式调用:通过管道符 | 将一系列处理步骤串联起来,形成一个线性的处理流。
函数式风格:每个步骤都是一个函数或 Runnable 对象,接收上一步的输出作为输入。
无状态:LCEL 本身是无状态的,每个步骤只关心自己的输入和输出,不维护全局状态。
lcel_chain = (
RunnableLambda(preprocess_query1)
| RunnableLambda(preprocess_query2)
| RunnableLambda(retrieve_documents)
| RunnableLambda(rerank_and_filter)
| RunnableLambda(build_prompt)
| {
"generation": RunnableLambda(llm_call), # 模块H: LLM调用
"final_state": RunnablePassthrough() # 将整个状态传递下去
}
| {
"generation": lambda x: x['generation'], # 提取内容
"final_state": lambda x: x['final_state']
}
| RunnableLambda(lambda state_dict: post_process_answer({**state_dict['final_state'], "generation": state_dict['generation']}))
)
2.2.1. 输出结果
==================== 通过LCEL 实现 RAG ====================
[DEBUG] 构建LCEL处理链...
[DEBUG] LCEL链构建完成
==================== 执行LCEL链 ====================
[DEBUG] 开始执行LCEL链,问题: '什么是langgraph?'
--- 模块A: 预处理Query1 ---
--- 模块B: 预处理Query2 ---
--- 模块C&D: 检索文档 ---
--- 模块E: 重排与过滤 ---
--- 模块F: 构建Prompt ---
--- 模块G: 调用腾讯云DeepSeek-v3模型 ---
--- 模块H: 后处理答案 ---
==================== [LCEL] 最终结果 ====================
根据提供的上下文,没有提到与"langgraph"相关的任何信息。因此,我无法回答这个问题。
2.2.2. 优点
- 简洁直观:对于线性的工作流,代码非常简洁,可读性强。
- 易于上手:语法简单,学习曲线平缓。
- 组件化:每个处理步骤都是一个独立的组件,易于复用和测试。
2.2.3. 缺点
- 线性限制:不适合复杂的分支、循环或并行流程。
- 状态管理困难:在复杂场景下,手动管理和传递状态会变得非常繁琐。
- 调试困难:虽然可以查看中间步骤的输出,但整体流程是黑盒的,难以追踪状态变化。
2.3. 使用 LangGraph 实现代码示例
图结构:将工作流定义为一个有向图,每个节点代表一个处理步骤,边代表流程方向。
状态驱动:整个图共享一个中心化的状态对象,每个节点都可以读取和更新这个状态。
显式流程控制:通过添加节点和边来明确定义流程,支持分支、循环和并行。
# 1. 定义中心化的状态
class AdvancedRAGState(TypedDict):
question: str
preprocessed_question: str
rewritten_question: str
documents: List[Document]
filtered_documents: List[Document]
prompt: str
generation: str
final_answer: str
# 2. 将业务逻辑函数适配为LangGraph节点
# 节点的签名是 (State) -> PartialStateUpdate
def preprocess_query1_node(state: AdvancedRAGState):
return preprocess_query1(state)
def preprocess_query2_node(state: AdvancedRAGState):
return preprocess_query2(state)
def retrieve_documents_node(state: AdvancedRAGState):
return retrieve_documents(state)
def rerank_and_filter_node(state: AdvancedRAGState):
return rerank_and_filter(state)
def build_prompt_node(state: AdvancedRAGState):
return build_prompt(state)
def generate_answer_node(state: AdvancedRAGState):
# 模块H: LLM接口调用
print(f"--- 模块H: LLM接口调用 ---")
generation = model.invoke(state['prompt'])
return {"generation": generation.content}
def post_process_node(state: AdvancedRAGState):
return post_process_answer(state)
# 3. 构建图并定义流程
workflow = StateGraph(AdvancedRAGState)
# 添加节点
workflow.add_node("preprocess1", preprocess_query1_node)
workflow.add_node("preprocess2", preprocess_query2_node)
workflow.add_node("retrieve", retrieve_documents_node)
workflow.add_node("rerank", rerank_and_filter_node)
workflow.add_node("build_prompt", build_prompt_node)
workflow.add_node("generate", generate_answer_node)
workflow.add_node("post_process", post_process_node)
# 设置入口点并定义边的连接
workflow.set_entry_point("preprocess1")
workflow.add_edge("preprocess1", "preprocess2")
workflow.add_edge("preprocess2", "retrieve")
workflow.add_edge("retrieve", "rerank")
workflow.add_edge("rerank", "build_prompt")
workflow.add_edge("build_prompt", "generate")
workflow.add_edge("generate", "post_process")
workflow.add_edge("post_process", END)
app = workflow.compile()
# 执行LangGraph
langgraph_result = app.invoke({"question": "什么是langgraph? "})
print("--- [LangGraph] 最终结果 ---")
print(langgraph_result)
2.3.1. 输出结果
==================== 通过LangGraph 实现 RAG ====================
--- 模块A: 预处理Query1 ---
--- 模块B: 预处理Query2 ---
--- 模块C&D: 检索文档 ---
--- 模块E: 重排与过滤 ---
--- 模块F: 构建Prompt ---
--- 模块H: LLM接口调用 ---
--- 模块H: 后处理答案 ---
--- [LangGraph] 最终结果 ---
{'question': '什么是langgraph? ',
'preprocessed_question': '什么是langgraph?',
'documents':[Document(metadata={'score': 15.526812, 'id': '2'}, page_content='In the Launch state, the weapon will continue to navigate based on IMU ... during this state.')],
'filtered_documents': [Document(metadata={'score': 15.773178, 'id': '3'}, page_content='These factors affect weapon performance, the degree to which depends on the flight characteristics... performance of the weapon (e.g.'), Document(metadata={'score': 15.526812, 'id': '4'}, page_content='In the Launch state, the ... during this state.')],
'prompt': 'Human: 仅根据以下上下文回答问题:\n These factors affect weapon performance, the degree to which depends on the flight characteristics... during this state.\n 问题: 什么是langgraph? \n',
'generation': '根据提供的上下文,没有提到"langgraph"这一概念或相关信息,因此无法回答这个问题。上下文主要讨论了武器性能的影响因素、内部组件特性以及武器在发射状态下的操作(如导航、通信和遥测数据发送),但未涉及任何与"langgraph"相关的内容。',
'final_answer': '根据提供的上下文,没有提到"langgraph"这一概念或相关信息,因此无法回答这个问题。上下文主要讨论了武器性能的影响因素、内部组件特性以及武器在发射状态下的操作(如导航、通信和遥测数据发送),但未涉及任何与"langgraph"相关的内容。'}
可以看出,LangGraph 的输出包含了整个状态字典,这是因为 LangGraph 基于状态管理的工作方式。
与 LCEL 不同,LangGraph 会在每个节点执行后保存完整的状态,并在流程结束时返回最终的完整状态。
2.3.2. LangGraph 的实现方式
- 图结构:将工作流定义为一个有向图,每个节点代表一个处理步骤,边代表流程方向。
- 状态驱动:整个图共享一个中心化的状态对象,每个节点都可以读取和更新这个状态。
- 显式流程控制:通过添加节点和边来明确定义流程,支持分支、循环和并行。
2.3.3. 优点
- 灵活性高:可以构建任意复杂的流程,包括多智能体协作、条件分支和循环。
- 状态管理清晰:中心化的状态对象使得状态追踪和管理变得容易。
- 可观测性强:可以方便地查看每个节点的状态变化,便于调试和监控。
- 可扩展性好:易于添加新的节点和边,扩展工作流。
2.3.4. 缺点
- 代码量稍多:相比 LCEL,需要定义状态、节点和边,代码量稍多。
- 学习曲线稍陡:需要理解图、节点和状态的概念。
3. 总结
通过以上对比,我们可以得出结论:
- LCEL 是构建固化、线性工作流的利器。它追求的是开发效率和简洁性。
- LangGraph 是构建复杂、动态、可演进工作流的专业工具。它追求的是流程的清晰性、状态的可控性和架构的可扩展性。
LCEL 适合快速构建简单的、线性的 RAG 流程,而 LangGraph 则更适合构建复杂的、需要精细流程控制和状态管理的 Agent 应用。
LangGraph 可以更清晰地展示每个步骤的状态变化,也为未来扩展更复杂的逻辑打下了基础。