超越线性工作流的 LangGraph

在技术架构选型中,一个常见的认知是:框架的先进性与复杂性往往同解决方案的优越性划等号。

然而,在追求极致效能与可控性的工程实践中,这一等式未必成立。

最近在参与一个 RAG 检索问答系统的项目,该系统的核心约束非常明确:

LLM 必须严格基于我们提供的高质量知识库生成回答,任何超纲的“幻觉”或事实性错误都是不可接受的。

这意味着我们需要:

  1. 精准控制每一步:从 Query 改写、ES 的复杂查询构建、多路检索结果的融合排序,到相关性判定的精细阈值,每一步我们都希望能像操作手术刀一样精准。
  2. 极致的透明度:当用户反馈某个问题回答得不好时,我需要能立刻追溯到是检索环节出了问题,还是 LLM 的 Prompt 没给好,甚至是某个工具函数返回了异常。

在此背景下,一个最直观的方案是采用原生 Python 自行构建核心流程。

这条路径看似直接、掌控力最强,也似乎最高效。

但我也不禁思考,如果引入如 LangGraph 这类专为构建复杂 AI 应用而生的状态管理框架,它究竟能为项目带来哪些范式上的提升,又是否会引入不必要的复杂性?

图片

如果说 LangChain 的核心链式语法 LCEL(LangChain Expression Language)提供的是构建 AI 应用的“预制件”和“线性流水线”,那么 LangGraph 提供的则是一个基于图的、更底层的 状态机(State Machine) 引擎。

它允许开发者将应用流程明确定义为一个由节点(Nodes)和边(Edges)构成的图结构。

每个节点代表一个可执行的功能单元(如检索、生成、判断),而边则定义了基于当前执行结果的流转逻辑。

这使得以下关键特性成为可能:

  1. 循环:可以轻松实现“思考 - 行动 - 观察”的循环,直到任务完成。
  2. 并行与汇聚:多个分支可以同时执行,再根据结果汇聚到下一个节点。
  3. 清晰的状态管理:整个系统的运行状态在图中透明地流动和演化,可控性和可观测性极强。

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 可以更清晰地展示每个步骤的状态变化,也为未来扩展更复杂的逻辑打下了基础。