LangGraph 源码拆解:它凭什么比 LangChain 更适合 Agent 编排?

用 LangChain 的 AgentExecutor 写过复杂 Agent 的人大概都遇过这种情况:Agent 在第7步突然报错,你想"从第5步重试一下",但做不到,只能从头跑,白白烧了一大串 LLM 调用费。或者你想在某个关键决策节点插入人工审核,发现 AgentExecutor 根本没提供 hook 点,只能 hack 进去。

这些不是 bug,是架构决策的代价。而 LangGraph 就是 LangChain 团队对这套决策的全面推翻。

有意思的是:LangGraph 官方现在已经把 AgentExecutor 标为"遗留组件",所有新 Agent 推荐用 LangGraph 构建。这不是小修小补,是直接推了一套新框架。翻源码之后发现,它的设计决策远比"带状态的 DAG"这个描述有意思------它把 Actor 模型、事件驱动和检查点持久化在架构层强耦合在了一起,不是后来打补丁加进去的。

本文顺着源码捋 LangGraph 的内部实现,重点讲三件事:图执行模型怎么跑、状态怎么管、Checkpoint 机制怎么做到开箱即用。最后给出一个直接的使用建议。

AgentExecutor 的根本缺陷在哪

LangChain 早期的 AgentExecutor 是个很直接的 while 循环:

python 复制代码
# 简化版 AgentExecutor 核心逻辑(langchain/agents/agent.py)
while True:
    action = agent.plan(intermediate_steps, **kwargs)
    if isinstance(action, AgentFinish):
        return action.return_values
    # 执行工具
    observation = tool.run(action.tool_input)
    intermediate_steps.append((action, observation))
    # 检查迭代上限
    if len(intermediate_steps) >= max_iterations:
        return {"output": "Agent stopped due to iteration limit"}

三个硬伤:

状态是个无结构列表,没有 schema,中途做分支判断很费劲

没有持久化,一旦中断就从头来,human-in-the-loop 要自己实现

执行是同步串行的,工具调用没法并发

LangGraph 的答案是引入"图"。但这里说的图不是普通的 DAG------更接近有限状态机(FSM)和数据流图的混合体,每条边可以是条件边(conditional edge),每个节点是一个"状态变换函数"。

StateGraph 的核心:Channel 是什么

先看 StateGraph 的定义(langgraph/graph/state.py):

python 复制代码
class StateGraph(Graph):
    def __init__(self, state_schema: Type[Any], ...):
        super().__init__()
        self.schema = state_schema
        self.nodes: dict[str, RunnableCallable] = {}
        self.edges: set[tuple[str, str]] = set()
        self._conditional_edges: dict = {}
        # 关键:每个状态字段都会变成一个 Channel 对象
        self.channels: dict[str, BaseChannel] = {}
        self._schema_to_channels(state_schema)

注意最后两行,_schema_to_channels 会把你定义的 State 类的每个字段都转成一个 Channel 对象。

Channel 是什么?把它想象成 Google Docs 的协同编辑合并策略------两个人同时修改同一段文字,系统得决定怎么合并,是"后者覆盖前者"还是"追加到末尾"还是"取最大值"。Channel 就是那套合并规则的容器,专门处理多个节点并发写同一状态字段时的冲突:

python 复制代码
# langgraph/channels/base.py
class BaseChannel(ABC):
    @abstractmethod
    def update(self, values: Sequence[Any]) -> bool:
        """接收一批并发写入,决定怎么合并,返回是否有变化"""
        ...
    
    @abstractmethod
    def get(self) -> Any:
        """拿当前值"""
        ...

# Append 语义(messages 字段默认用这个)
# 对应 TypedDict 里写 Annotated[list, operator.add]
class BinaryOperatorAggregate(BaseChannel):
    def __init__(self, typ, operator):
        self.operator = operator  # operator.add 就是列表追加
        self.value = typ()
    
    def update(self, values):
        if values:
            new_val = reduce(self.operator, values, self.value)
            if new_val != self.value:
                self.value = new_val
                return True
        return False

这就是为什么你在 TypedDict State 里写 Annotated[list, operator.add],messages 就会自动追加而不是覆盖------Channel 在幕后帮你做了归约,不需要手动 append。

执行引擎:Pregel 超步是接力跑

LangGraph 的执行引擎叫 Pregel (直接取自 Google 那篇图计算论文)。核心文件是 langgraph/pregel/__init__.py,主循环逻辑是这样的:

python 复制代码
# 简化的 Pregel 执行主循环
async def astream(self, input, config, ...):
    # 1. 初始化 channels(从 checkpoint 恢复或全新初始化)
    channels = self._prepare_channels(config, checkpoint)
    
    # 2. 写入初始输入
    apply_writes(checkpoint, channels, input_writes)
    
    # 3. Pregel 超步循环
    for step in range(max_steps):
        # 找出所有"就绪"的节点(输入 channel 有新数据的节点)
        next_tasks = self._prepare_next_tasks(
            checkpoint, channels, config, step
        )
        if not next_tasks:
            break  # 没有可执行任务,结束
        
        # 并发执行当前 superstep 的所有就绪任务
        async with TaskGroup() as tg:
            for task in next_tasks:
                tg.create_task(
                    execute_task_and_write_results(task, channels)
                )
        
        # 保存 checkpoint
        checkpoint = create_checkpoint(channels, next_tasks)
        await checkpointer.aput(config, checkpoint)
        
        # yield 中间状态(.stream() 能看到中间步骤的原因)
        yield channels_to_output(channels)

Pregel 的超步(superstep)可以类比接力跑的交接棒------每一棒只能接上一棒传来的棒,不能跨棒传递,这保证了数据不会乱。具体来说:节点只能读上一个 superstep 写入的 Channel 值,当前 superstep 里多个节点并发写同一 Channel,写入的结果要等到本轮结束才对外可见,下一个 superstep 才能读到。

这个设计的关键副产品:TaskGroup 会把当前 step 里所有就绪的节点并发跑,工具并行调用是原生支持的,不需要你写任何并发代码。

条件边:路由逻辑怎么变成图结构

LangGraph 最常用的模式是"LLM 决定下一步跳哪个节点",这靠的是条件边。源码里的实现分两层:

python 复制代码
# 添加条件边时,内部存储为 Branch 对象
def add_conditional_edges(self, source, path, path_map=None):
    self._add_edge(source, Branch(path, path_map, then=None))
ruby 复制代码
# Branch 在执行时:调用路由函数,把目标节点名写进 Send channel
class Branch:
    def run(self, writer, reader, config):
        value = reader(self.path)       # 调用路由函数,得到目标节点名
        destinations = self._route(value, self.path_map)
        for dest in destinations:
            writer([Send(dest, reader())])  # 往目标节点的输入 channel 写消息

关键在 Send 这个对象------它不是"直接跳转",而是往目标节点的输入 channel 里写了一条消息。下一个 superstep 开始时,_prepare_next_tasks 扫描所有 channel,发现某节点的输入 channel 有数据,就把它加入就绪队列。

这个设计的好处:路由决策和执行是解耦的。路由函数可以返回多个节点名,LangGraph 会并发执行它们------这是实现 map-reduce 模式(一个任务拆成多个子任务并发处理再聚合)的直接基础。

Checkpoint:把"从第5步重试"变成现实

Checkpoint 是我认为 LangGraph 里工程做得最扎实的部分,没有之一。它解决的是 AgentExecutor 最让人头疼的两个问题:中途暂停等人审核、出错后回滚到历史状态重试。

核心数据结构:

python 复制代码
@dataclass
class Checkpoint:
    v: int                            # schema 版本号
    id: str                           # UUID,唯一标识这个检查点
    ts: str                           # 时间戳
    channel_values: dict[str, Any]    # 所有 channel 的序列化值快照
    channel_versions: dict[str, int]  # 每个 channel 当前的版本号
    versions_seen: dict[str, dict]    # 各节点上次执行时看到的版本号
    pending_sends: list[SendProtocol] # 等待发送的消息(用于恢复)

@dataclass 
class CheckpointMetadata:
    source: Literal["input", "loop", "update", "fork"]
    step: int          # 第几个 superstep
    writes: dict       # 本次写入了什么
    parents: dict      # 父 checkpoint 的 id(支持分支回溯)

versions_seen 是防重放的关键。把它想象成每个节点自己的"进度条"------节点记录自己上次处理时各 Channel 的版本号,下次执行前检查"有没有比我进度条更新的版本",有才执行,没有就跳过。这保证了即使 checkpoint 被恢复多次,节点也不会重复执行,不会重复调 LLM。

Human-in-the-loop 的实现方式出乎意料地简单:

ini 复制代码
# 编译时声明:在 human_review 节点之前暂停
graph = builder.compile(
    checkpointer=MemorySaver(),
    interrupt_before=["human_review"]
)

# 执行到 interrupt_before 节点时,Pregel 抛出 GraphInterrupt 异常
# 当前 checkpoint 已保存,调用方 catch 到后可以做任何事(展示给人看、等待输入等)

# 人工审核完,用同一个 thread_id 重新 invoke
# Pregel 从最新 checkpoint 恢复,从暂停位置继续,不会重跑之前的步骤
result = graph.invoke(
    Command(resume=human_feedback),  # 把人的反馈注入
    config={"configurable": {"thread_id": "session_1"}}
)

源码里,_prepare_next_tasks 在发现即将执行的任务名在 interrupt_before 列表里时,往任务上打 INTERRUPT 标记,主循环 raise GraphInterrupt。恢复时 Pregel 读最新 checkpoint,通过 versions_seen 判断哪些节点已经跑过、哪些没跑,只执行没跑过的。

SubGraph:图里跑图,模块化 Agent

LangGraph 支持在节点里嵌套另一个图(SubGraph),这是构建复杂多 Agent 系统的基础:

ini 复制代码
# 子图编译后直接作为节点加入父图
parent_graph = StateGraph(ParentState)
parent_graph.add_node("subagent", child_graph.compile())

# Pregel 执行子图节点时:
# 1. 父图 State 中对应字段映射为子图的输入
# 2. 子图内部走完整的 Pregel 超步循环(嵌套执行)
# 3. 子图的最终输出映射回父图的 State

有一个细节值得注意:子图有独立的 checkpoint namespace。源码里通过在 config 的 configurable["checkpoint_ns"] 字段加上子图名来区分,父子图的历史记录是隔离的,time-travel 时可以分别回滚。这意味着你可以单独重跑某个子 Agent,而不影响父图的状态。

四种 stream_mode 的实现差异

LangGraph 的 .stream() 支持四种模式,搞清楚差异能省很多调试时间:

stream_mode yield 的内容 适用场景
values 每个 superstep 后的完整 State 快照 需要完整状态,做 UI 渲染
updates 每个节点输出的增量 dict 监控每个节点的输出,日志
debug 任务开始/结束事件 + 完整数据 调试、接 LangSmith trace
messages LLM token 级别的流式输出 前端实时展示打字效果

messages 模式是最复杂的,源码里用了一个 StreamMessagesHandler 回调,在 LLM 调用时 hook 进去,把 token 流实时转出来,同时维护 message 的 id 映射,防止子图里的 message 和父图冲突。这块在 langgraph/pregel/io.py 里,有自定义流式处理需求可以着重看这里。

一个最小 ReAct Agent:30 行,背后做了什么

python 复制代码
from typing import Annotated, TypedDict
import operator
from langgraph.graph import StateGraph, END
from langchain_core.messages import BaseMessage, HumanMessage

# 1. 定义状态:messages 用 add 语义(追加而非覆盖)
class AgentState(TypedDict):
    messages: Annotated[list[BaseMessage], operator.add]

# 2. 定义节点
def call_model(state: AgentState):
    response = llm_with_tools.invoke(state["messages"])
    return {"messages": [response]}

def call_tools(state: AgentState):
    last_msg = state["messages"][-1]
    # 并行执行所有工具调用(Pregel TaskGroup 原生支持)
    results = tool_executor.batch(last_msg.tool_calls)
    return {"messages": results}

# 3. 路由函数
def should_continue(state: AgentState):
    last_msg = state["messages"][-1]
    if last_msg.tool_calls:
        return "tools"
    return END

# 4. 组装图
builder = StateGraph(AgentState)
builder.add_node("agent", call_model)
builder.add_node("tools", call_tools)
builder.set_entry_point("agent")
builder.add_conditional_edges("agent", should_continue)
builder.add_edge("tools", "agent")

# 5. 编译(加 checkpointer 支持持久化 + human-in-the-loop)
graph = builder.compile(checkpointer=MemorySaver())

result = graph.invoke(
    {"messages": [HumanMessage("帮我查一下今天天气")]},
    config={"configurable": {"thread_id": "session_1"}}
)

这 30 行代码背后,LangGraph 帮你处理了:Pregel 超步循环、Channel 合并归约、Checkpoint 保存、并发工具调用、流式输出、会话隔离。你只定义节点逻辑和路由规则。

LangGraph 的局限性

不做无脑安利,说清楚几个需要注意的地方:

序列化耦合:Checkpoint 需要把所有 Channel 的值序列化,State 里不能放不可序列化的对象(数据库连接、文件句柄)。实践中要放 ID,在节点里按需重建资源

调试体验有限:图执行的中间状态虽然可以通过 stream 获取,但原生没有可视化。接 LangSmith 能解决,但不想引入 observability 服务的话,纯靠日志调试还是费劲

图结构静态:目前图在 compile 时确定,不支持运行时动态增减节点。要做"根据情况动态扩展图"这类需求,只能通过 SubGraph + 条件路由模拟,不够直接

版本迭代快 :LangGraph API 变化比较频繁,0.2 到 0.3 有几处 breaking change(Command 对象的引入就是一个),生产环境要锁好版本

到底什么时候用 LangGraph,什么时候不用

给一个直接的判断,省掉调研时间:

直接用 LangGraph

• 需要持久化对话状态、支持中途暂停/恢复的 Agent(human-in-the-loop 场景)

• 需要多工具并发调用,或者实现 map-reduce 模式(一个任务拆成多个子任务并发处理)

• 复杂多 Agent 协作系统,需要模块化和状态隔离

• 需要 time-travel 调试(回到历史某个状态重放)

LCEL 够了,不需要 LangGraph

• 无状态的单轮 LLM 调用,或者简单的 chain(提示词 → LLM → 解析)

• 不需要持久化、不需要 human-in-the-loop 的简单 ReAct Agent

• 快速原型,不想引入图结构的概念复杂度

应该考虑更重的调度框架(Temporal/Prefect)

• 任务执行时间超长(小时级),需要分布式队列和可靠的重试机制

• 不是 AI 工作流,而是通用的业务流程编排,AI 只是其中一步

• 需要细粒度的资源控制、并发限流、跨服务事务

一句话总结我的判断:做 Agent 系统,LangGraph 是目前最务实的选择,没有之一------但它是一个"代码级抽象",不是拖拽式平台,需要你真的理解它的执行模型才能用好。 Dify/Coze 的 workflow 更易上手,但灵活性有天花板;LangGraph 上手成本高,但一旦搞懂了,能做到的事情边界远大于前者。

下一步值得看的方向:LangGraph Server 的 HTTP 接口层实现------它是怎么把 Pregel 引擎包成对齐 OpenAI Assistants API 的服务的,以及 Multi-Agent Supervisor 模式在源码层面的具体实现,这两块是从"懂 LangGraph"到"用好 LangGraph"的关键跨越。

如果觉得有帮助,欢迎分享给同样在研究 AI 工具链的朋友。

相关推荐
花千树-0104 小时前
Claude Code / Codex 架构推测 + 可实现版本设计(从0到1复刻一个Agent系统)
人工智能·ai·架构·aigc·ai编程
是Smoky呢4 小时前
springAI+向量数据库(SimpleVectorStore)+RAG深度理解
spring·aigc·ai编程
程序员鱼皮5 小时前
刚刚 Claude Code 源码泄露!我扒出了 11 个隐藏秘密
ai·程序员·编程·ai编程·claude
sg_knight5 小时前
使用 Claude Code 写单元测试的实战方法
单元测试·log4j·ai编程
可观测性用观测云5 小时前
Claude Code 意外开源:我们看到了每一个企业级 Agent 都需要行为分析
ai编程·监控
用户323520373436 小时前
obra/superpowers 深度解析(完整版)
ai编程
喵个咪6 小时前
Apache Doris 4.x 在量化交易中的完整应用实践
后端·架构·ai编程
zybsjn6 小时前
Claude Code 的 12 大核心功能详解—— 每个功能新增 实操案例 + 详细操作步骤
ai编程
踩着两条虫7 小时前
VTJ.PRO 在线应用开发平台的项目模板(Web、H5、UniApp)
前端·低代码·ai编程