12_LangGraph状态图引擎_从Chain到Graph

概述

前面几篇我们已经学过 LCEL、Tool、结构化输出、create_agent() 和 Middleware。

如果你的任务只是:

text 复制代码
Prompt -> Model -> Parser

那么 LCEL 非常合适。

它简单、直观、组合性强:

python 复制代码
chain = prompt | model | parser
result = chain.invoke({"question": "LangGraph 是什么?"})

但真实 Agent 应用经常不是一条直线。

它可能是这样:

text 复制代码
用户问题
   |
   v
分类意图
   |
   +-- 普通问答 --> RAG 检索 --> 生成答案
   |
   +-- 数据查询 --> 生成 SQL --> 安全校验 --> 执行查询 --> 解释结果
   |
   +-- 高风险操作 --> 工具调用前人工审批 --> 执行动作
   |
   +-- 模型不确定 --> 反思修正 --> 再回答

这时你会遇到 Chain 不擅长表达的问题:

  • 分支很多,if/else 到处散落。
  • 某些节点要循环执行。
  • 多个节点要并行执行。
  • 某些节点要等待人工输入。
  • 中间状态要被持久化。
  • 失败后要从中断处恢复。
  • 需要清晰观测每一步做了什么。

LangGraph 解决的就是这个问题:

用图来描述复杂 LLM 应用的执行流程,把"状态、节点、边、分支、循环、恢复"变成一等公民。

Chain 适合线性流程,Graph 适合有分支、有循环、有状态、有恢复需求的复杂 Agent 工作流。

先建立心智模型:State、Node、Edge

LangGraph 最核心的三个概念是:

概念 中文理解 作用
State 状态 记录图运行过程中的共享数据
Node 节点 执行具体逻辑的函数
Edge 决定下一个执行哪个节点

可以把一个 LangGraph 程序理解成:

text 复制代码
State 是工作台
Node 是工人
Edge 是流程规则

例如一个"写作 Agent":

text 复制代码
State:
    topic: 文章主题
    draft: 初稿
    review: 审查意见
    final: 终稿

Nodes:
    plan_node: 生成提纲
    write_node: 写初稿
    review_node: 审查初稿
    revise_node: 修改文章

Edges:
    plan -> write
    write -> review
    review 如果通过 -> END
    review 如果不通过 -> revise -> review

这已经不是简单的 prompt | model | parser

它是一个带状态和决策的流程图。

LangGraph 的本质不是"更复杂的 Chain",而是"状态驱动的工作流引擎"。

最小 Graph:三个节点串起来

先写一个不调用大模型的最小例子,理解基本结构。

python 复制代码
from typing_extensions import TypedDict
from langgraph.graph import StateGraph, START, END


class State(TypedDict):
    topic: str
    outline: str
    draft: str
    final: str


def plan_node(state: State) -> dict:
    return {
        "outline": f"围绕《{state['topic']}》生成三段式提纲"
    }


def write_node(state: State) -> dict:
    return {
        "draft": f"根据提纲写初稿:{state['outline']}"
    }


def polish_node(state: State) -> dict:
    return {
        "final": f"润色后的文章:{state['draft']}"
    }


builder = StateGraph(State)

builder.add_node("plan", plan_node)
builder.add_node("write", write_node)
builder.add_node("polish", polish_node)

builder.add_edge(START, "plan")
builder.add_edge("plan", "write")
builder.add_edge("write", "polish")
builder.add_edge("polish", END)

graph = builder.compile()

result = graph.invoke({"topic": "LangGraph 状态图引擎"})
print(result["final"])

这段代码有固定套路:

text 复制代码
1. 定义 State
2. 定义 Node 函数
3. 创建 StateGraph
4. add_node 添加节点
5. add_edge 连接节点
6. compile 编译图
7. invoke 执行图

执行路径是:
#mermaid-svg-3lcCX4Toz90ynlOY{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-3lcCX4Toz90ynlOY .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-3lcCX4Toz90ynlOY .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-3lcCX4Toz90ynlOY .error-icon{fill:#552222;}#mermaid-svg-3lcCX4Toz90ynlOY .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-3lcCX4Toz90ynlOY .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-3lcCX4Toz90ynlOY .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-3lcCX4Toz90ynlOY .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-3lcCX4Toz90ynlOY .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-3lcCX4Toz90ynlOY .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-3lcCX4Toz90ynlOY .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-3lcCX4Toz90ynlOY .marker{fill:#333333;stroke:#333333;}#mermaid-svg-3lcCX4Toz90ynlOY .marker.cross{stroke:#333333;}#mermaid-svg-3lcCX4Toz90ynlOY svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-3lcCX4Toz90ynlOY p{margin:0;}#mermaid-svg-3lcCX4Toz90ynlOY .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-3lcCX4Toz90ynlOY .cluster-label text{fill:#333;}#mermaid-svg-3lcCX4Toz90ynlOY .cluster-label span{color:#333;}#mermaid-svg-3lcCX4Toz90ynlOY .cluster-label span p{background-color:transparent;}#mermaid-svg-3lcCX4Toz90ynlOY .label text,#mermaid-svg-3lcCX4Toz90ynlOY span{fill:#333;color:#333;}#mermaid-svg-3lcCX4Toz90ynlOY .node rect,#mermaid-svg-3lcCX4Toz90ynlOY .node circle,#mermaid-svg-3lcCX4Toz90ynlOY .node ellipse,#mermaid-svg-3lcCX4Toz90ynlOY .node polygon,#mermaid-svg-3lcCX4Toz90ynlOY .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-3lcCX4Toz90ynlOY .rough-node .label text,#mermaid-svg-3lcCX4Toz90ynlOY .node .label text,#mermaid-svg-3lcCX4Toz90ynlOY .image-shape .label,#mermaid-svg-3lcCX4Toz90ynlOY .icon-shape .label{text-anchor:middle;}#mermaid-svg-3lcCX4Toz90ynlOY .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-3lcCX4Toz90ynlOY .rough-node .label,#mermaid-svg-3lcCX4Toz90ynlOY .node .label,#mermaid-svg-3lcCX4Toz90ynlOY .image-shape .label,#mermaid-svg-3lcCX4Toz90ynlOY .icon-shape .label{text-align:center;}#mermaid-svg-3lcCX4Toz90ynlOY .node.clickable{cursor:pointer;}#mermaid-svg-3lcCX4Toz90ynlOY .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-3lcCX4Toz90ynlOY .arrowheadPath{fill:#333333;}#mermaid-svg-3lcCX4Toz90ynlOY .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-3lcCX4Toz90ynlOY .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-3lcCX4Toz90ynlOY .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-3lcCX4Toz90ynlOY .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-3lcCX4Toz90ynlOY .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-3lcCX4Toz90ynlOY .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-3lcCX4Toz90ynlOY .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-3lcCX4Toz90ynlOY .cluster text{fill:#333;}#mermaid-svg-3lcCX4Toz90ynlOY .cluster span{color:#333;}#mermaid-svg-3lcCX4Toz90ynlOY 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-3lcCX4Toz90ynlOY .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-3lcCX4Toz90ynlOY rect.text{fill:none;stroke-width:0;}#mermaid-svg-3lcCX4Toz90ynlOY .icon-shape,#mermaid-svg-3lcCX4Toz90ynlOY .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-3lcCX4Toz90ynlOY .icon-shape p,#mermaid-svg-3lcCX4Toz90ynlOY .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-3lcCX4Toz90ynlOY .icon-shape .label rect,#mermaid-svg-3lcCX4Toz90ynlOY .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-3lcCX4Toz90ynlOY .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-3lcCX4Toz90ynlOY .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-3lcCX4Toz90ynlOY :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} START
plan
write
polish
END

最小 LangGraph = 一个 State schema + 一组节点函数 + 一组边 + compile。

StateGraph:为什么先定义状态?

在 LangGraph 里,State 是所有节点共享的"运行时快照"。

每个节点都接收当前状态:

python 复制代码
def some_node(state: State) -> dict:
    ...

然后返回状态更新:

python 复制代码
return {"draft": "新的初稿"}

注意:节点通常不需要返回完整 state,只返回本节点要更新的字段。

例如当前状态是:

python 复制代码
{
    "topic": "LangGraph",
    "outline": "三段式提纲",
    "draft": "",
    "final": "",
}

某个节点返回:

python 复制代码
{"draft": "这里是初稿"}

合并后状态变成:

python 复制代码
{
    "topic": "LangGraph",
    "outline": "三段式提纲",
    "draft": "这里是初稿",
    "final": "",
}

没有被返回的字段会保留原值。

这和很多函数式 pipeline 不同。

在普通 Chain 里,你更关注:

text 复制代码
input -> output

在 LangGraph 里,你更关注:

text 复制代码
state -> partial update -> new state

LangGraph 节点返回的不是"最终结果",而是对共享状态的一次增量更新。

State Schema:TypedDictdataclass、Pydantic 怎么选?

LangGraph 的 state schema 常见写法有三类:

写法 优点 适合场景
TypedDict 轻量、清晰、性能好 大多数图
dataclass 可以定义默认值 需要默认状态值
Pydantic BaseModel 校验强 需要递归校验或严格数据模型

多数项目建议从 TypedDict 开始:

python 复制代码
from typing_extensions import TypedDict


class AgentState(TypedDict):
    question: str
    documents: list[str]
    answer: str

如果某些字段不是一开始就存在,可以用 NotRequired

python 复制代码
from typing_extensions import NotRequired, TypedDict


class AgentState(TypedDict):
    question: str
    documents: NotRequired[list[str]]
    answer: NotRequired[str]

如果你希望字段有默认值,可以考虑 dataclass

python 复制代码
from dataclasses import dataclass, field


@dataclass
class AgentState:
    question: str
    documents: list[str] = field(default_factory=list)
    answer: str = ""

使用 Pydantic 则要权衡性能:

python 复制代码
from pydantic import BaseModel, Field


class AgentState(BaseModel):
    question: str
    documents: list[str] = Field(default_factory=list)
    answer: str = ""

如果只是普通 Agent 流程,不要为了"看起来更严谨"一上来就用 Pydantic。

状态会在图执行中频繁读写,过重的数据校验可能带来额外开销。

默认用 TypedDict,需要默认值用 dataclass,确实需要强校验再用 Pydantic。

Reducer:状态更新到底怎么合并?

LangGraph 的 state 每个字段都有自己的合并规则。

默认规则很简单:

新值覆盖旧值。

例如:

python 复制代码
from typing_extensions import TypedDict


class State(TypedDict):
    count: int
    logs: list[str]

如果当前状态是:

python 复制代码
{"count": 1, "logs": ["start"]}

节点返回:

python 复制代码
{"logs": ["node_a finished"]}

默认合并后是:

python 复制代码
{"count": 1, "logs": ["node_a finished"]}

注意,logs 被覆盖了,不是追加。

如果你想追加,就要给字段声明 reducer:

python 复制代码
from operator import add
from typing import Annotated
from typing_extensions import TypedDict


class State(TypedDict):
    count: int
    logs: Annotated[list[str], add]

现在节点返回:

python 复制代码
{"logs": ["node_a finished"]}

会合并成:

python 复制代码
{"count": 1, "logs": ["start", "node_a finished"]}

Reducer 是 LangGraph 里非常关键但容易被忽略的概念。

尤其是以下字段:

  • 消息列表。
  • 检索结果。
  • 工具调用日志。
  • 多个并行节点的输出。
  • 反思循环中的历史记录。

都要认真决定是覆盖还是追加。

State 定义字段,Reducer 定义字段如何被更新。

消息状态:为什么推荐 add_messages

LLM 应用里最常见的状态字段是 messages

错误写法是:

python 复制代码
class State(TypedDict):
    messages: list

这样每次节点返回新的 messages,都可能覆盖历史消息。

你可能会改成:

python 复制代码
from operator import add
from typing import Annotated


class State(TypedDict):
    messages: Annotated[list, add]

这样可以追加,但仍然不完美。

因为对话消息有 id,有时你不是想追加一条新消息,而是想更新已有消息。

LangGraph 提供了专门的 reducer:add_messages

python 复制代码
from typing import Annotated
from typing_extensions import TypedDict
from langchain.messages import AnyMessage
from langgraph.graph.message import add_messages


class ChatState(TypedDict):
    messages: Annotated[list[AnyMessage], add_messages]

还可以直接用内置的 MessagesState

python 复制代码
from langgraph.graph import MessagesState


class ChatState(MessagesState):
    documents: list[str]

MessagesState 默认包含:

python 复制代码
messages: Annotated[list[AnyMessage], add_messages]

这对聊天 Agent 非常常用。

只要 state 里有聊天消息,优先用 MessagesStateadd_messages,不要裸写 list

Node:节点就是函数,但要保持单职责

LangGraph 的节点可以是普通 Python 函数。

它通常接收 state,返回 dict

python 复制代码
def retrieve_node(state: State) -> dict:
    docs = retriever.invoke(state["question"])
    return {"documents": docs}

节点也可以接收 runtime

python 复制代码
from dataclasses import dataclass
from langgraph.runtime import Runtime


@dataclass
class Context:
    user_id: str


def node_with_runtime(state: State, runtime: Runtime[Context]) -> dict:
    user_id = runtime.context.user_id
    return {"answer": f"当前用户是 {user_id}"}

一个节点应该尽量只做一类事情:

  • 分类节点只负责分类。
  • 检索节点只负责检索。
  • 生成节点只负责生成。
  • 审查节点只负责审查。
  • 工具节点只负责执行工具。

不要写成:

text 复制代码
mega_node:
    分类
    检索
    生成
    审查
    重试
    发邮件
    记日志

这样虽然能跑,但你会失去 Graph 的价值。

Graph 的价值在于:

  • 每一步可观察。
  • 每一步可测试。
  • 每一步可替换。
  • 每一步可恢复。
  • 每一步可单独加边界。

节点可以是普通函数,但设计上应该是"可命名、可测试、可观测"的步骤。

Edge:边决定流程怎么走

Edge 分两类:

类型 方法 适合场景
普通边 add_edge 固定从 A 到 B
条件边 add_conditional_edges 根据 state 动态决定下一个节点

普通边最简单:

python 复制代码
builder.add_edge("retrieve", "generate")

意思是:

text 复制代码
retrieve 执行完,一定执行 generate

条件边适合分支:

python 复制代码
from typing import Literal
from langgraph.graph import END


def route_after_review(state: State) -> Literal["revise", "__end__"]:
    if state["approved"]:
        return END
    return "revise"


builder.add_conditional_edges("review", route_after_review)

也可以把路由函数的返回值映射到节点名:

python 复制代码
def route_by_intent(state: State) -> str:
    return state["intent"]


builder.add_conditional_edges(
    "classify",
    route_by_intent,
    {
        "rag": "retrieve",
        "sql": "generate_sql",
        "chat": "chat",
    },
)

这段代码表达:

text 复制代码
classify 节点执行后
    如果 intent = rag  -> retrieve
    如果 intent = sql  -> generate_sql
    如果 intent = chat -> chat

官方文档里有一个重要建议:

同一个节点最好只选择一种路由机制:要么普通边,要么条件边 / Command。不要在同一节点上混用静态边和动态路由,否则执行路径会变得难推理。

普通边表达确定流程,条件边表达动态决策。

STARTEND:图的入口与出口

LangGraph 用两个特殊节点表达开始和结束:

  • START: 虚拟起点,表示用户输入进入图后第一个执行哪里。
  • END: 终点,表示流程结束。

常见写法:

python 复制代码
from langgraph.graph import START, END

builder.add_edge(START, "classify")
builder.add_edge("generate", END)

你也可能看到旧写法:

python 复制代码
builder.set_entry_point("classify")
builder.set_finish_point("generate")

这两种都能表达入口和出口。

但当前更推荐使用:

python 复制代码
builder.add_edge(START, "classify")
builder.add_edge("generate", END)

因为它更统一,所有路由都通过边来表达。

STARTEND 不是业务节点,而是用来声明图从哪里开始、在哪里结束的虚拟节点。

条件边实战:分类后走不同处理链路

下面写一个最小"问题路由"图。

目标:

  • 用户问知识库问题,走 RAG。
  • 用户问数据库问题,走 SQL。
  • 其他问题,走普通聊天。
python 复制代码
from typing import Literal
from typing_extensions import NotRequired
from typing_extensions import TypedDict
from langgraph.graph import StateGraph, START, END


class State(TypedDict):
    question: str
    route: NotRequired[Literal["rag", "sql", "chat"]]
    answer: NotRequired[str]


def classify_node(state: State) -> dict:
    question = state["question"]

    if "数据库" in question or "SQL" in question:
        return {"route": "sql"}
    if "文档" in question or "知识库" in question:
        return {"route": "rag"}
    return {"route": "chat"}


def rag_node(state: State) -> dict:
    return {"answer": f"从知识库检索后回答:{state['question']}"}


def sql_node(state: State) -> dict:
    return {"answer": f"生成并执行 SQL 后回答:{state['question']}"}


def chat_node(state: State) -> dict:
    return {"answer": f"直接聊天回答:{state['question']}"}


def route_by_classification(state: State) -> str:
    return state["route"]


builder = StateGraph(State)

builder.add_node("classify", classify_node)
builder.add_node("rag", rag_node)
builder.add_node("sql", sql_node)
builder.add_node("chat", chat_node)

builder.add_edge(START, "classify")
builder.add_conditional_edges(
    "classify",
    route_by_classification,
    {
        "rag": "rag",
        "sql": "sql",
        "chat": "chat",
    },
)
builder.add_edge("rag", END)
builder.add_edge("sql", END)
builder.add_edge("chat", END)

graph = builder.compile()

result = graph.invoke({"question": "帮我查一下知识库里的部署文档"})
print(result["answer"])

图结构如下:
#mermaid-svg-BJcufDAZ3YrrIhIR{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-BJcufDAZ3YrrIhIR .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-BJcufDAZ3YrrIhIR .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-BJcufDAZ3YrrIhIR .error-icon{fill:#552222;}#mermaid-svg-BJcufDAZ3YrrIhIR .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-BJcufDAZ3YrrIhIR .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-BJcufDAZ3YrrIhIR .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-BJcufDAZ3YrrIhIR .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-BJcufDAZ3YrrIhIR .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-BJcufDAZ3YrrIhIR .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-BJcufDAZ3YrrIhIR .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-BJcufDAZ3YrrIhIR .marker{fill:#333333;stroke:#333333;}#mermaid-svg-BJcufDAZ3YrrIhIR .marker.cross{stroke:#333333;}#mermaid-svg-BJcufDAZ3YrrIhIR svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-BJcufDAZ3YrrIhIR p{margin:0;}#mermaid-svg-BJcufDAZ3YrrIhIR .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-BJcufDAZ3YrrIhIR .cluster-label text{fill:#333;}#mermaid-svg-BJcufDAZ3YrrIhIR .cluster-label span{color:#333;}#mermaid-svg-BJcufDAZ3YrrIhIR .cluster-label span p{background-color:transparent;}#mermaid-svg-BJcufDAZ3YrrIhIR .label text,#mermaid-svg-BJcufDAZ3YrrIhIR span{fill:#333;color:#333;}#mermaid-svg-BJcufDAZ3YrrIhIR .node rect,#mermaid-svg-BJcufDAZ3YrrIhIR .node circle,#mermaid-svg-BJcufDAZ3YrrIhIR .node ellipse,#mermaid-svg-BJcufDAZ3YrrIhIR .node polygon,#mermaid-svg-BJcufDAZ3YrrIhIR .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-BJcufDAZ3YrrIhIR .rough-node .label text,#mermaid-svg-BJcufDAZ3YrrIhIR .node .label text,#mermaid-svg-BJcufDAZ3YrrIhIR .image-shape .label,#mermaid-svg-BJcufDAZ3YrrIhIR .icon-shape .label{text-anchor:middle;}#mermaid-svg-BJcufDAZ3YrrIhIR .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-BJcufDAZ3YrrIhIR .rough-node .label,#mermaid-svg-BJcufDAZ3YrrIhIR .node .label,#mermaid-svg-BJcufDAZ3YrrIhIR .image-shape .label,#mermaid-svg-BJcufDAZ3YrrIhIR .icon-shape .label{text-align:center;}#mermaid-svg-BJcufDAZ3YrrIhIR .node.clickable{cursor:pointer;}#mermaid-svg-BJcufDAZ3YrrIhIR .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-BJcufDAZ3YrrIhIR .arrowheadPath{fill:#333333;}#mermaid-svg-BJcufDAZ3YrrIhIR .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-BJcufDAZ3YrrIhIR .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-BJcufDAZ3YrrIhIR .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-BJcufDAZ3YrrIhIR .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-BJcufDAZ3YrrIhIR .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-BJcufDAZ3YrrIhIR .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-BJcufDAZ3YrrIhIR .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-BJcufDAZ3YrrIhIR .cluster text{fill:#333;}#mermaid-svg-BJcufDAZ3YrrIhIR .cluster span{color:#333;}#mermaid-svg-BJcufDAZ3YrrIhIR 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-BJcufDAZ3YrrIhIR .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-BJcufDAZ3YrrIhIR rect.text{fill:none;stroke-width:0;}#mermaid-svg-BJcufDAZ3YrrIhIR .icon-shape,#mermaid-svg-BJcufDAZ3YrrIhIR .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-BJcufDAZ3YrrIhIR .icon-shape p,#mermaid-svg-BJcufDAZ3YrrIhIR .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-BJcufDAZ3YrrIhIR .icon-shape .label rect,#mermaid-svg-BJcufDAZ3YrrIhIR .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-BJcufDAZ3YrrIhIR .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-BJcufDAZ3YrrIhIR .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-BJcufDAZ3YrrIhIR :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} rag
sql
chat
START
classify
rag
sql
chat
END

这个例子没有调用 LLM,但结构就是生产系统常见的路由骨架。

你可以把 classify_node 换成结构化输出模型:

python 复制代码
classification = classifier.invoke(state["question"])
return {"route": classification.route}

条件边把 if/else 从业务主流程里抽出来,让图结构直接表达分支逻辑。

循环:Agent 为什么天然适合 Graph?

Agent 的本质通常不是一次模型调用,而是循环:

text 复制代码
模型思考
   |
   v
是否需要工具?
   |
   +-- 是 --> 执行工具 --> 把结果写回状态 --> 再次思考
   |
   +-- 否 --> 输出最终答案

用 LangGraph 表达就是:
#mermaid-svg-p2hb8lD2ia3uCiLH{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-p2hb8lD2ia3uCiLH .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-p2hb8lD2ia3uCiLH .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-p2hb8lD2ia3uCiLH .error-icon{fill:#552222;}#mermaid-svg-p2hb8lD2ia3uCiLH .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-p2hb8lD2ia3uCiLH .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-p2hb8lD2ia3uCiLH .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-p2hb8lD2ia3uCiLH .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-p2hb8lD2ia3uCiLH .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-p2hb8lD2ia3uCiLH .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-p2hb8lD2ia3uCiLH .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-p2hb8lD2ia3uCiLH .marker{fill:#333333;stroke:#333333;}#mermaid-svg-p2hb8lD2ia3uCiLH .marker.cross{stroke:#333333;}#mermaid-svg-p2hb8lD2ia3uCiLH svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-p2hb8lD2ia3uCiLH p{margin:0;}#mermaid-svg-p2hb8lD2ia3uCiLH .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-p2hb8lD2ia3uCiLH .cluster-label text{fill:#333;}#mermaid-svg-p2hb8lD2ia3uCiLH .cluster-label span{color:#333;}#mermaid-svg-p2hb8lD2ia3uCiLH .cluster-label span p{background-color:transparent;}#mermaid-svg-p2hb8lD2ia3uCiLH .label text,#mermaid-svg-p2hb8lD2ia3uCiLH span{fill:#333;color:#333;}#mermaid-svg-p2hb8lD2ia3uCiLH .node rect,#mermaid-svg-p2hb8lD2ia3uCiLH .node circle,#mermaid-svg-p2hb8lD2ia3uCiLH .node ellipse,#mermaid-svg-p2hb8lD2ia3uCiLH .node polygon,#mermaid-svg-p2hb8lD2ia3uCiLH .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-p2hb8lD2ia3uCiLH .rough-node .label text,#mermaid-svg-p2hb8lD2ia3uCiLH .node .label text,#mermaid-svg-p2hb8lD2ia3uCiLH .image-shape .label,#mermaid-svg-p2hb8lD2ia3uCiLH .icon-shape .label{text-anchor:middle;}#mermaid-svg-p2hb8lD2ia3uCiLH .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-p2hb8lD2ia3uCiLH .rough-node .label,#mermaid-svg-p2hb8lD2ia3uCiLH .node .label,#mermaid-svg-p2hb8lD2ia3uCiLH .image-shape .label,#mermaid-svg-p2hb8lD2ia3uCiLH .icon-shape .label{text-align:center;}#mermaid-svg-p2hb8lD2ia3uCiLH .node.clickable{cursor:pointer;}#mermaid-svg-p2hb8lD2ia3uCiLH .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-p2hb8lD2ia3uCiLH .arrowheadPath{fill:#333333;}#mermaid-svg-p2hb8lD2ia3uCiLH .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-p2hb8lD2ia3uCiLH .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-p2hb8lD2ia3uCiLH .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-p2hb8lD2ia3uCiLH .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-p2hb8lD2ia3uCiLH .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-p2hb8lD2ia3uCiLH .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-p2hb8lD2ia3uCiLH .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-p2hb8lD2ia3uCiLH .cluster text{fill:#333;}#mermaid-svg-p2hb8lD2ia3uCiLH .cluster span{color:#333;}#mermaid-svg-p2hb8lD2ia3uCiLH 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-p2hb8lD2ia3uCiLH .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-p2hb8lD2ia3uCiLH rect.text{fill:none;stroke-width:0;}#mermaid-svg-p2hb8lD2ia3uCiLH .icon-shape,#mermaid-svg-p2hb8lD2ia3uCiLH .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-p2hb8lD2ia3uCiLH .icon-shape p,#mermaid-svg-p2hb8lD2ia3uCiLH .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-p2hb8lD2ia3uCiLH .icon-shape .label rect,#mermaid-svg-p2hb8lD2ia3uCiLH .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-p2hb8lD2ia3uCiLH .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-p2hb8lD2ia3uCiLH .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-p2hb8lD2ia3uCiLH :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} tools
end
START
model
should_continue
tools
END

这和上一篇 create_agent() 背后的执行循环是一致的。

手写 Graph 的好处是:

  • 你能控制每个节点的输入输出。
  • 你能决定什么时候继续循环。
  • 你能插入审查、限流、人工审批。
  • 你能把工具执行拆成多个节点。
  • 你能精确观测循环次数和状态变化。

只要流程里出现"判断后可能回到前一步",Graph 通常比 Chain 更自然。

手写一个极简 ReAct 图:模型节点 + 工具节点

下面写一个极简 ReAct 风格 Agent。

它包含两个节点:

  • llm_call: 调模型,决定是否调用工具。
  • tool_node: 执行工具,把工具结果写回 messages。
python 复制代码
from typing import Literal
from typing_extensions import Annotated, TypedDict

from langchain.chat_models import init_chat_model
from langchain.messages import AnyMessage, HumanMessage, SystemMessage, ToolMessage
from langchain.tools import tool
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages


@tool
def multiply(a: int, b: int) -> int:
    """Multiply two integers."""
    return a * b


tools = [multiply]
tools_by_name = {tool.name: tool for tool in tools}

model = init_chat_model("openai:gpt-4o-mini", temperature=0)
model_with_tools = model.bind_tools(tools)


class AgentState(TypedDict):
    messages: Annotated[list[AnyMessage], add_messages]


def llm_call(state: AgentState) -> dict:
    response = model_with_tools.invoke(
        [
            SystemMessage(
                content="你是一个数学助手。需要计算时调用工具,不要心算。"
            )
        ]
        + state["messages"]
    )
    return {"messages": [response]}


def tool_node(state: AgentState) -> dict:
    last_message = state["messages"][-1]
    results = []

    for tool_call in last_message.tool_calls:
        tool = tools_by_name[tool_call["name"]]
        observation = tool.invoke(tool_call["args"])
        results.append(
            ToolMessage(
                content=str(observation),
                tool_call_id=tool_call["id"],
            )
        )

    return {"messages": results}


def should_continue(state: AgentState) -> Literal["tools", "__end__"]:
    last_message = state["messages"][-1]

    if last_message.tool_calls:
        return "tools"

    return END


builder = StateGraph(AgentState)
builder.add_node("llm_call", llm_call)
builder.add_node("tools", tool_node)

builder.add_edge(START, "llm_call")
builder.add_conditional_edges("llm_call", should_continue)
builder.add_edge("tools", "llm_call")

agent = builder.compile()

result = agent.invoke(
    {"messages": [HumanMessage(content="12 乘以 9 等于多少?")]}
)

print(result["messages"][-1].content)

这就是一个手写版的 Agent loop。

你可以清楚看到:

  • 模型节点负责生成 AIMessage。
  • 工具节点负责执行 tool call。
  • 条件边负责判断继续工具还是结束。
  • add_messages 负责把每轮消息追加到状态里。

create_agent() 帮你自动组装了这个循环,手写 LangGraph 则让你完全掌控这个循环。

从 Chain 到 Graph:同一个需求的两种写法

假设你要做一个"生成文章并审查"的流程:

text 复制代码
生成初稿 -> 审查质量 -> 如果不合格就修改 -> 再审查

用 Chain 写,你可能会写成:

python 复制代码
draft = write_chain.invoke({"topic": topic})
review = review_chain.invoke({"draft": draft})

while not review["approved"]:
    draft = revise_chain.invoke({
        "draft": draft,
        "feedback": review["feedback"],
    })
    review = review_chain.invoke({"draft": draft})

这不是不能写。

问题是当流程变复杂后,你会把控制逻辑、状态管理、观测和恢复全堆在业务代码里。

用 Graph 写,结构会更明确:

text 复制代码
write -> review
          |
          +-- approved --> END
          |
          +-- rejected --> revise -> review

代码骨架:

python 复制代码
from typing import Literal
from typing_extensions import NotRequired, TypedDict
from langgraph.graph import StateGraph, START, END


class WritingState(TypedDict):
    topic: str
    draft: NotRequired[str]
    feedback: NotRequired[str]
    approved: NotRequired[bool]
    revision_count: NotRequired[int]


def write_node(state: WritingState) -> dict:
    return {
        "draft": f"围绕 {state['topic']} 写出的初稿",
        "revision_count": 0,
    }


def review_node(state: WritingState) -> dict:
    draft = state["draft"]

    if len(draft) > 30:
        return {"approved": True, "feedback": "质量通过"}

    return {
        "approved": False,
        "feedback": "内容太短,需要补充细节",
    }


def revise_node(state: WritingState) -> dict:
    count = state.get("revision_count", 0) + 1

    return {
        "draft": state["draft"] + f"\n第 {count} 次补充:增加案例和解释。",
        "revision_count": count,
    }


def route_after_review(state: WritingState) -> Literal["revise", "__end__"]:
    if state["approved"]:
        return END
    return "revise"


builder = StateGraph(WritingState)
builder.add_node("write", write_node)
builder.add_node("review", review_node)
builder.add_node("revise", revise_node)

builder.add_edge(START, "write")
builder.add_edge("write", "review")
builder.add_conditional_edges("review", route_after_review)
builder.add_edge("revise", "review")

graph = builder.compile()

这个结构一眼就能看出来:

  • 哪些步骤会执行。
  • 哪些步骤可能循环。
  • 循环退出条件在哪里。
  • 状态字段由谁产生。

Chain 把流程藏在代码控制流里,Graph 把流程显式画出来。

Command:当节点既要更新状态,又要决定下一步

条件边把"节点逻辑"和"路由逻辑"分开。

很多时候这很好。

但有些节点天然会同时做两件事:

text 复制代码
1. 计算新的状态
2. 根据计算结果决定下一步

这时可以用 Command

python 复制代码
from typing import Literal
from langgraph.graph import END
from langgraph.types import Command


def review_node(state: WritingState) -> Command[Literal["revise", "__end__"]]:
    if len(state["draft"]) > 100:
        return Command(
            update={"approved": True, "feedback": "通过"},
            goto=END,
        )

    return Command(
        update={
            "approved": False,
            "feedback": "内容太短,请补充案例",
        },
        goto="revise",
    )

然后图里就不需要单独写 add_conditional_edges("review", ...)

python 复制代码
builder.add_node("review", review_node)

Command 常见于:

  • 审批节点。
  • 路由节点。
  • 人工中断恢复后的分支。
  • 工具执行后直接跳转。
  • 子图返回父图。

不过不要滥用。

如果你的路由逻辑很复杂,或者希望图结构更直观,把路由函数单独写出来通常更清晰。

Command 适合把"状态更新 + 下一步跳转"合并在一个节点返回值里。

并行执行:多个下游节点可以同时跑

LangGraph 支持一个节点连接多个下游节点。

如果一个节点有多个出边,下游节点可以在下一轮 super-step 中并行执行。

例如文章审查:

text 复制代码
draft
   |
   +-- check_facts
   |
   +-- check_style
   |
   +-- check_security
          |
          v
      merge_review

示例代码:

python 复制代码
from operator import add
from typing import Annotated
from typing_extensions import TypedDict
from langgraph.graph import StateGraph, START, END


class ReviewState(TypedDict):
    draft: str
    issues: Annotated[list[str], add]
    final_decision: str


def check_facts(state: ReviewState) -> dict:
    return {"issues": ["事实检查:未发现明显问题"]}


def check_style(state: ReviewState) -> dict:
    return {"issues": ["风格检查:标题可以更具体"]}


def check_security(state: ReviewState) -> dict:
    return {"issues": ["安全检查:未发现敏感信息"]}


def merge_review(state: ReviewState) -> dict:
    return {
        "final_decision": "\n".join(state["issues"])
    }


builder = StateGraph(ReviewState)
builder.add_node("check_facts", check_facts)
builder.add_node("check_style", check_style)
builder.add_node("check_security", check_security)
builder.add_node("merge_review", merge_review)

builder.add_edge(START, "check_facts")
builder.add_edge(START, "check_style")
builder.add_edge(START, "check_security")

builder.add_edge(
    ["check_facts", "check_style", "check_security"],
    "merge_review",
)
builder.add_edge("merge_review", END)

graph = builder.compile()

这里 issues 必须用追加 reducer。

否则三个并行节点都写 issues,最后可能互相覆盖。

并行节点写同一个 state 字段时,必须认真设计 reducer。

Super-step:LangGraph 怎么执行图?

LangGraph 的执行模型受 Pregel 思想影响,可以粗略理解成一轮一轮的 super-step。

不用把这个概念想得太复杂。

它大概表达:

text 复制代码
第 1 轮:执行所有当前活跃节点
第 2 轮:执行上一轮发出更新后被激活的节点
第 3 轮:继续执行新的活跃节点
...
直到没有节点需要继续执行

对于线性图:

text 复制代码
A -> B -> C

它基本就是顺序执行。

对于并行图:

text 复制代码
      -> B ->
A ---       ---> D
      -> C ->

BC 可以处在同一个 super-step。

理解 super-step 的意义在于:

  • 你会知道为什么并行节点要靠 reducer 合并状态。
  • 你会知道为什么 checkpoint 通常发生在步骤边界。
  • 你会知道为什么节点内部副作用要考虑幂等。

LangGraph 不是简单递归调用节点,而是按活跃节点和状态更新逐步推进图执行。

Checkpoint:节点副作用要幂等

LangGraph 支持 checkpoint,也就是在执行过程中保存状态。

这对以下场景非常关键:

  • 人工审批。
  • 中断后恢复。
  • 长任务继续执行。
  • 失败后重试。
  • 多轮对话记忆。

但是 checkpoint 有一个工程细节必须注意:

如果图从某个 checkpoint 恢复,某个节点可能会从头重新执行。

这意味着节点里的副作用要幂等。

危险写法:

python 复制代码
def create_order_node(state: State) -> dict:
    order_id = external_api.create_order(state["payload"])
    return {"order_id": order_id}

如果这个节点因为恢复被执行两次,可能会创建两个订单。

更稳的写法:

python 复制代码
def create_order_node(state: State) -> dict:
    idempotency_key = state["request_id"]
    order = external_api.create_order(
        state["payload"],
        idempotency_key=idempotency_key,
    )
    return {"order_id": order.id}

对于外部写操作,建议:

  • 使用幂等 key。
  • 先查再写。
  • 使用 upsert。
  • 把不可逆操作放到人工审批之后。
  • 在 state 中记录外部系统返回的业务 ID。

Graph 可以恢复执行,但你的外部副作用必须能承受重放。

可视化:把图画出来

LangGraph 的一个重要优势是可以可视化。

在 Notebook 里可以这样:

python 复制代码
from IPython.display import Image, display

display(Image(graph.get_graph().draw_mermaid_png()))

也可以打印 Mermaid:

python 复制代码
print(graph.get_graph().draw_mermaid())

对于复杂 Agent,建议每次改图结构后都看一眼:

  • 是否有孤立节点。
  • 是否有意外分支。
  • 是否存在无法到达 END 的循环。
  • 是否同一个节点混用了静态边和动态边。
  • 是否高风险工具路径经过了审批节点。

图不是为了好看,而是为了降低复杂系统的认知成本。

能画出来的 Agent,才更容易被调试、审查和交接。

实战:手写"思考 -> 执行 -> 反思"三阶 Agent

现在写一个更接近真实 Agent 的三阶段工作流:

text 复制代码
think  思考:分析问题,制定行动
act    执行:根据行动调用工具或生成草稿
reflect 反思:判断是否满意,不满意就回到 think

为了让代码容易理解,我们先用规则模拟 LLM。

状态设计:

python 复制代码
from typing import Literal
from typing_extensions import NotRequired, TypedDict


class AgentState(TypedDict):
    question: str
    plan: NotRequired[str]
    action_result: NotRequired[str]
    critique: NotRequired[str]
    final_answer: NotRequired[str]
    approved: NotRequired[bool]
    iteration: NotRequired[int]

字段含义:

  • question: 用户问题。
  • plan: 思考阶段生成的计划。
  • action_result: 执行阶段产生的结果。
  • critique: 反思阶段的审查意见。
  • final_answer: 最终答案。
  • approved: 反思是否通过。
  • iteration: 循环次数,防止无限循环。

节点实现:

python 复制代码
from typing import Literal
from typing_extensions import NotRequired, TypedDict
from langgraph.graph import StateGraph, START, END


class AgentState(TypedDict):
    question: str
    plan: NotRequired[str]
    action_result: NotRequired[str]
    critique: NotRequired[str]
    final_answer: NotRequired[str]
    approved: NotRequired[bool]
    iteration: NotRequired[int]


def think_node(state: AgentState) -> dict:
    iteration = state.get("iteration", 0) + 1

    return {
        "iteration": iteration,
        "plan": (
            f"第 {iteration} 轮计划:先识别问题类型,"
            f"再给出结构化回答。问题是:{state['question']}"
        ),
    }


def act_node(state: AgentState) -> dict:
    plan = state["plan"]

    if state["iteration"] == 1:
        return {
            "action_result": f"根据计划执行,但回答还比较粗糙。plan={plan}"
        }

    return {
        "action_result": f"根据反思意见补充细节后执行。plan={plan}"
    }


def reflect_node(state: AgentState) -> dict:
    result = state["action_result"]
    iteration = state["iteration"]

    if iteration >= 2:
        return {
            "approved": True,
            "critique": "答案已经包含必要细节,可以结束。",
            "final_answer": result,
        }

    return {
        "approved": False,
        "critique": "答案过于粗糙,需要补充步骤和边界条件。",
    }


def route_after_reflect(state: AgentState) -> Literal["think", "__end__"]:
    if state["approved"]:
        return END
    return "think"


builder = StateGraph(AgentState)
builder.add_node("think", think_node)
builder.add_node("act", act_node)
builder.add_node("reflect", reflect_node)

builder.add_edge(START, "think")
builder.add_edge("think", "act")
builder.add_edge("act", "reflect")
builder.add_conditional_edges("reflect", route_after_reflect)

graph = builder.compile()

result = graph.invoke({"question": "如何设计一个可恢复的客服 Agent?"})
print(result["final_answer"])

图结构:
#mermaid-svg-JWXzSrUxKWJg5kZx{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-JWXzSrUxKWJg5kZx .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-JWXzSrUxKWJg5kZx .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-JWXzSrUxKWJg5kZx .error-icon{fill:#552222;}#mermaid-svg-JWXzSrUxKWJg5kZx .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-JWXzSrUxKWJg5kZx .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-JWXzSrUxKWJg5kZx .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-JWXzSrUxKWJg5kZx .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-JWXzSrUxKWJg5kZx .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-JWXzSrUxKWJg5kZx .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-JWXzSrUxKWJg5kZx .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-JWXzSrUxKWJg5kZx .marker{fill:#333333;stroke:#333333;}#mermaid-svg-JWXzSrUxKWJg5kZx .marker.cross{stroke:#333333;}#mermaid-svg-JWXzSrUxKWJg5kZx svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-JWXzSrUxKWJg5kZx p{margin:0;}#mermaid-svg-JWXzSrUxKWJg5kZx .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-JWXzSrUxKWJg5kZx .cluster-label text{fill:#333;}#mermaid-svg-JWXzSrUxKWJg5kZx .cluster-label span{color:#333;}#mermaid-svg-JWXzSrUxKWJg5kZx .cluster-label span p{background-color:transparent;}#mermaid-svg-JWXzSrUxKWJg5kZx .label text,#mermaid-svg-JWXzSrUxKWJg5kZx span{fill:#333;color:#333;}#mermaid-svg-JWXzSrUxKWJg5kZx .node rect,#mermaid-svg-JWXzSrUxKWJg5kZx .node circle,#mermaid-svg-JWXzSrUxKWJg5kZx .node ellipse,#mermaid-svg-JWXzSrUxKWJg5kZx .node polygon,#mermaid-svg-JWXzSrUxKWJg5kZx .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-JWXzSrUxKWJg5kZx .rough-node .label text,#mermaid-svg-JWXzSrUxKWJg5kZx .node .label text,#mermaid-svg-JWXzSrUxKWJg5kZx .image-shape .label,#mermaid-svg-JWXzSrUxKWJg5kZx .icon-shape .label{text-anchor:middle;}#mermaid-svg-JWXzSrUxKWJg5kZx .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-JWXzSrUxKWJg5kZx .rough-node .label,#mermaid-svg-JWXzSrUxKWJg5kZx .node .label,#mermaid-svg-JWXzSrUxKWJg5kZx .image-shape .label,#mermaid-svg-JWXzSrUxKWJg5kZx .icon-shape .label{text-align:center;}#mermaid-svg-JWXzSrUxKWJg5kZx .node.clickable{cursor:pointer;}#mermaid-svg-JWXzSrUxKWJg5kZx .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-JWXzSrUxKWJg5kZx .arrowheadPath{fill:#333333;}#mermaid-svg-JWXzSrUxKWJg5kZx .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-JWXzSrUxKWJg5kZx .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-JWXzSrUxKWJg5kZx .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-JWXzSrUxKWJg5kZx .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-JWXzSrUxKWJg5kZx .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-JWXzSrUxKWJg5kZx .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-JWXzSrUxKWJg5kZx .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-JWXzSrUxKWJg5kZx .cluster text{fill:#333;}#mermaid-svg-JWXzSrUxKWJg5kZx .cluster span{color:#333;}#mermaid-svg-JWXzSrUxKWJg5kZx 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-JWXzSrUxKWJg5kZx .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-JWXzSrUxKWJg5kZx rect.text{fill:none;stroke-width:0;}#mermaid-svg-JWXzSrUxKWJg5kZx .icon-shape,#mermaid-svg-JWXzSrUxKWJg5kZx .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-JWXzSrUxKWJg5kZx .icon-shape p,#mermaid-svg-JWXzSrUxKWJg5kZx .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-JWXzSrUxKWJg5kZx .icon-shape .label rect,#mermaid-svg-JWXzSrUxKWJg5kZx .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-JWXzSrUxKWJg5kZx .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-JWXzSrUxKWJg5kZx .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-JWXzSrUxKWJg5kZx :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} not approved
approved
START
think
act
reflect
END

这个三阶 Agent 的关键不是示例里的字符串拼接,而是结构:

  • think 只负责规划。
  • act 只负责执行。
  • reflect 只负责评价。
  • 条件边负责决定继续还是结束。
  • iteration 防止无限循环。

如果换成真实 LLM:

  • think_node 可以调用模型生成计划。
  • act_node 可以调用工具或执行 RAG。
  • reflect_node 可以用结构化输出返回 approvedcritique

"思考 -> 执行 -> 反思"是 Graph 表达 Agent 循环控制的经典模式。

给三阶 Agent 加上 LLM 结构化输出

真实项目里,reflect_node 不应该只靠字符串判断。

可以让模型返回结构化审查结果:

python 复制代码
from pydantic import BaseModel, Field


class Reflection(BaseModel):
    approved: bool = Field(description="当前结果是否可以作为最终答案")
    critique: str = Field(description="如果不通过,说明需要改进什么")

然后在反思节点里使用:

python 复制代码
reflection_model = model.with_structured_output(Reflection)


def reflect_node(state: AgentState) -> dict:
    reflection = reflection_model.invoke(
        [
            {
                "role": "system",
                "content": "你是一个严格的答案审查员。",
            },
            {
                "role": "user",
                "content": (
                    f"用户问题:{state['question']}\n"
                    f"当前答案:{state['action_result']}\n"
                    "请判断当前答案是否足够具体、准确、可执行。"
                ),
            },
        ]
    )

    update = {
        "approved": reflection.approved,
        "critique": reflection.critique,
    }

    if reflection.approved:
        update["final_answer"] = state["action_result"]

    return update

这和第 09 篇结构化输出是连起来的。

Graph 负责流程,结构化输出负责让流程决策稳定。

不要让模型自由发挥返回:

text 复制代码
我觉得差不多可以了

而是让它返回:

json 复制代码
{
  "approved": true,
  "critique": "答案已经覆盖关键步骤"
}

Graph 的条件边最好基于结构化字段决策,而不是解析自然语言。

何时用 create_agent(),何时手写 LangGraph?

很多人学到这里会纠结:

既然可以手写 Graph,那还要 create_agent() 吗?

要。

二者不是替代关系。

场景 推荐
普通工具调用 Agent create_agent()
快速做一个可用助手 create_agent()
只需要加摘要、HITL、限流等通用能力 create_agent() + Middleware
流程有明确业务节点 手写 LangGraph
有复杂分支、循环、并行、审批 手写 LangGraph
多个 Agent 作为子流程协作 手写 LangGraph
要精确控制每一步状态和恢复 手写 LangGraph

可以这样理解:

text 复制代码
create_agent() = 官方预制的 Agent Graph
手写 LangGraph = 你自己定义 Graph

前者快,后者可控。

真实项目里经常混用:

text 复制代码
外层业务流程:手写 LangGraph
某个节点内部:调用 create_agent() 创建的子 Agent

例如:

text 复制代码
classify_ticket
   |
   +-- faq_agent       使用 create_agent
   |
   +-- refund_workflow 手写 Graph + HITL
   |
   +-- bug_report      手写 Graph

简单 Agent 用 create_agent(),复杂业务编排用手写 LangGraph。

常见问题一:State 里存了太多格式化 Prompt

错误做法:

python 复制代码
class State(TypedDict):
    user_input: str
    prompt_for_classifier: str
    prompt_for_writer: str
    prompt_for_reviewer: str

这样会让状态越来越脏。

更好的原则是:

State 存原始数据和中间结果,Prompt 在节点内部按需格式化。

例如:

python 复制代码
class State(TypedDict):
    user_input: str
    classification: str
    draft: str
    review: str

然后在节点里:

python 复制代码
def classify_node(state: State) -> dict:
    prompt = f"请分类用户问题:{state['user_input']}"
    result = model.invoke(prompt)
    return {"classification": result.content}

这样做的好处是:

  • 改 Prompt 不影响 state schema。
  • trace 里能看到干净的数据。
  • 下游节点可以用不同方式格式化同一份数据。
  • 状态持久化更稳定。

State 是数据层,不是 Prompt 垃圾桶。

常见问题二:忘记 reducer,导致状态被覆盖

这是新手最常见的坑。

你以为自己在追加日志:

python 复制代码
class State(TypedDict):
    logs: list[str]

多个节点都返回:

python 复制代码
return {"logs": ["xxx finished"]}

结果最后只剩最后一次更新。

正确写法:

python 复制代码
from operator import add
from typing import Annotated


class State(TypedDict):
    logs: Annotated[list[str], add]

对于 messages

python 复制代码
from langgraph.graph import MessagesState


class State(MessagesState):
    route: str

凡是"多次写入同一字段"的 state key,都要先想清楚 reducer。

常见问题三:图里有循环,但没有退出条件

危险结构:

text 复制代码
think -> act -> reflect -> think

如果 reflect 永远不通过,图会一直跑。

至少要有一个退出保护:

python 复制代码
def route_after_reflect(state: AgentState):
    if state["approved"]:
        return END

    if state.get("iteration", 0) >= 3:
        return END

    return "think"

也可以在运行时配置 recursion limit。

但更好的方式是业务上就明确:

  • 最多反思几轮。
  • 超过后返回当前最佳答案。
  • 或升级到人工处理。
  • 或输出"无法可靠完成"。

所有 Agent 循环都必须有业务退出条件。

常见问题四:在节点里做不可重放副作用操作

节点里可以调用外部系统,但要谨慎。

高风险动作包括:

  • 发邮件。
  • 扣款。
  • 退款。
  • 删除数据。
  • 修改权限。
  • 创建订单。
  • 执行写 SQL。

这些动作不要直接混在普通推理节点里。

建议:

  • 单独做 action node。
  • action node 前加审批。
  • 使用 idempotency key。
  • 把外部系统返回 ID 写入 state。
  • 失败时能明确重试或补偿。

Graph 让流程可恢复,但不可逆动作必须自己做幂等和审批。

一张图总结 LangGraph 工作方式

#mermaid-svg-Jpa4QL28n14mWDEi{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-Jpa4QL28n14mWDEi .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Jpa4QL28n14mWDEi .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Jpa4QL28n14mWDEi .error-icon{fill:#552222;}#mermaid-svg-Jpa4QL28n14mWDEi .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Jpa4QL28n14mWDEi .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Jpa4QL28n14mWDEi .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Jpa4QL28n14mWDEi .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Jpa4QL28n14mWDEi .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Jpa4QL28n14mWDEi .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Jpa4QL28n14mWDEi .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Jpa4QL28n14mWDEi .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Jpa4QL28n14mWDEi .marker.cross{stroke:#333333;}#mermaid-svg-Jpa4QL28n14mWDEi svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Jpa4QL28n14mWDEi p{margin:0;}#mermaid-svg-Jpa4QL28n14mWDEi .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-Jpa4QL28n14mWDEi .cluster-label text{fill:#333;}#mermaid-svg-Jpa4QL28n14mWDEi .cluster-label span{color:#333;}#mermaid-svg-Jpa4QL28n14mWDEi .cluster-label span p{background-color:transparent;}#mermaid-svg-Jpa4QL28n14mWDEi .label text,#mermaid-svg-Jpa4QL28n14mWDEi span{fill:#333;color:#333;}#mermaid-svg-Jpa4QL28n14mWDEi .node rect,#mermaid-svg-Jpa4QL28n14mWDEi .node circle,#mermaid-svg-Jpa4QL28n14mWDEi .node ellipse,#mermaid-svg-Jpa4QL28n14mWDEi .node polygon,#mermaid-svg-Jpa4QL28n14mWDEi .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Jpa4QL28n14mWDEi .rough-node .label text,#mermaid-svg-Jpa4QL28n14mWDEi .node .label text,#mermaid-svg-Jpa4QL28n14mWDEi .image-shape .label,#mermaid-svg-Jpa4QL28n14mWDEi .icon-shape .label{text-anchor:middle;}#mermaid-svg-Jpa4QL28n14mWDEi .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-Jpa4QL28n14mWDEi .rough-node .label,#mermaid-svg-Jpa4QL28n14mWDEi .node .label,#mermaid-svg-Jpa4QL28n14mWDEi .image-shape .label,#mermaid-svg-Jpa4QL28n14mWDEi .icon-shape .label{text-align:center;}#mermaid-svg-Jpa4QL28n14mWDEi .node.clickable{cursor:pointer;}#mermaid-svg-Jpa4QL28n14mWDEi .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-Jpa4QL28n14mWDEi .arrowheadPath{fill:#333333;}#mermaid-svg-Jpa4QL28n14mWDEi .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-Jpa4QL28n14mWDEi .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-Jpa4QL28n14mWDEi .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Jpa4QL28n14mWDEi .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-Jpa4QL28n14mWDEi .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Jpa4QL28n14mWDEi .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-Jpa4QL28n14mWDEi .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-Jpa4QL28n14mWDEi .cluster text{fill:#333;}#mermaid-svg-Jpa4QL28n14mWDEi .cluster span{color:#333;}#mermaid-svg-Jpa4QL28n14mWDEi 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-Jpa4QL28n14mWDEi .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-Jpa4QL28n14mWDEi rect.text{fill:none;stroke-width:0;}#mermaid-svg-Jpa4QL28n14mWDEi .icon-shape,#mermaid-svg-Jpa4QL28n14mWDEi .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Jpa4QL28n14mWDEi .icon-shape p,#mermaid-svg-Jpa4QL28n14mWDEi .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-Jpa4QL28n14mWDEi .icon-shape .label rect,#mermaid-svg-Jpa4QL28n14mWDEi .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Jpa4QL28n14mWDEi .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-Jpa4QL28n14mWDEi .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-Jpa4QL28n14mWDEi :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是

定义 State Schema
编写 Node 函数
用 add_node 注册节点
用 add_edge / add_conditional_edges 连接流程
compile 编译图
invoke / stream 执行
节点返回 partial update
Reducer 合并 State
还有活跃节点?
结束并返回最终 State

这张图背后的关键点:

  • 图执行围绕 state 推进。
  • 节点只返回状态更新。
  • 边决定下一个节点。
  • reducer 决定状态怎么合并。
  • compile() 之后图才可运行。
  • invoke() 返回最终状态。

总结

本文从 Chain 讲到 Graph,核心是想建立一个判断标准:

当 LLM 应用开始出现分支、循环、并行、状态持久化、人工审批和恢复需求时,就应该考虑 LangGraph。

需要记住这些结论:

  • StateGraph 是 LangGraph Graph API 的核心入口。
  • State 是共享状态,节点读 state、返回 partial update。
  • Node 是执行逻辑的函数,应该保持单职责。
  • Edge 决定流程走向,普通边用于固定流程,条件边用于动态分支。
  • STARTEND 是图的虚拟入口和出口。
  • Reducer 决定同一个 state 字段如何合并多次更新。
  • 聊天消息优先用 MessagesStateadd_messages
  • 有循环就必须有退出条件。
  • 有外部副作用就必须考虑幂等。
  • create_agent() 是预制 Graph,手写 LangGraph 是自定义 Graph。