LangGraph 深度解析:从链到图,把 Agent 的循环画出来

0 阅读22分钟

这是「近五年 AI 技术演进」系列深挖的第六篇。前五篇把地基铺完了:全景、RAG、Function Calling、Tool、MCP。这一篇正式进入编排层。当模型会说话、会查资料、会表达调用意图、工具也能被标准地发现之后,剩下的问题只有一个:"反复调工具直到干完"这个循环,用什么写。 一句话定位:用图结构编排 Agent 循环的框架。 全文约 5500 字,含一段可以直接跑的 ReAct Agent 代码。

封面:直线与循环图的对比(AI 生成)

一、链的尽头,是一堵墙

先说清楚"链"到底是个什么东西。

LangChain 里的 Chain(以及后来的 LCEL)本质上是一条有向无环的调用管道:A 的输出喂给 B,B 的输出喂给 C,prompt | model | parser 就是最短的一条。写起来确实优雅,一行竖线串起整个流程,像 Unix 的管道。

但"有向无环"这四个字,已经把它能做的事说完了。它能往前走,不能回头,也不能自己决定去哪。

平时写业务代码,这个限制不明显。做文本摘要、做翻译、做格式转换——流程是固定的,人是提前知道每一步该干嘛的,链完全够用。

一旦开始做 Agent,三件事会轮流撞上来:

第一件,失败要重试。 模型输出了一段不合法的 JSON,你想让它带着错误信息再试一次。在链里,第 3 步出错,你只能把整条链从头再跑一遍——因为链里没有"从第 3 步退回到第 2 步"这条路。于是你在外面套一个 for 循环,套着套着发现自己在手写一个状态机。

第二件,走哪条路要看模型说了什么。 用户问"帮我查下订单",先判断这是查询、投诉还是退款;三种情况走三套流程。链里的 RunnableBranch 能在编译时定下几条支路,但判断条件写在代码里。而 Agent 的判断是运行时从模型输出里长出来的——模型说"我还需要再查一次物流",这件事写代码的人事先不知道。

第三件,也是致命的:做几步才知道要不要继续。 "查天气 → 发现要查的其实是另一个城市 → 再查一次 → 发现要对比两个城市 → 再查一次"。循环几次,取决于中间结果。这不是分支,分支是有限条路选一条;这是回环,同一段逻辑可以走 N 遍。

三件事凑齐,你就明白了:链把"下一步做什么"在写代码时就定死了,而 Agent 的下一步是在运行时才长出来的。

更麻烦的还在后面。这些补丁一旦叠上去,代码会开始变形:for retry in range(3) 套着 if needs_more: 套着 try/except,中间还夹着几个 session_state 变量。三个月后你回来看,会发现这段代码已经没人能说清它到底会走几条路——它不再是一个流程,而是一堆流程的残骸。真正让团队决定换框架的,往往不是"跑不通",而是"看不懂"。

链 vs 图:单向流水线与可回环的网络结构对比

二、从链到图:把 while 和 if 搬进数据结构

手写 Agent 的人,最后都会写出类似这样一段代码:

while True:
    resp = model.invoke(messages)
    if not resp.tool_calls:
        break
    messages.append(resp)
    for call in resp.tool_calls:
        messages.append(run_tool(call))

就这么几行,能跑。问题不在能不能跑,在于这个循环是一段藏起来的控制流:你没法在中间停下、没法存档、没法在出错的那一步单独重试、也没法在监控里看到"现在走到第几步了"。它是一段黑盒。

LangGraph 做的事,说白了只有一句:把这个 while 和里面的 if,从代码里搬出来,变成一张可以检查、可以存档、可以中断的数据结构。

搬出来之后,原来的东西各自有了名字:

  • 循环体里的变量(messages)→ State
  • 循环体里干活的函数(model.invoke、run_tool)→ Node
  • if not resp.tool_calls: break → 条件边
  • 循环回到开头 → 回边
  • 循环的退出 → END

于是"链"变成"图"。差别不只是画法好看:控制流一旦变成数据,它就能被序列化、被持久化、被人在半路改掉。这才是 LangGraph 真正的价值,不是图,是"图可以被停下来"。

三、三个核心概念

LangGraph 的 API 表面上有十几个方法,但真正需要理解的只有三个名词。剩下的都是这三个的排列组合。

State / Node / Edge 三概念解剖:共享状态与节点读写

3.1 State:整张图共享的一份数据

State 是一个 TypedDict(也可以用 dataclass 或 Pydantic),声明这张图运行时有哪些字段。

from typing import Annotated
from typing_extensions import TypedDict
from langgraph.graph.message import add_messages

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

关键在那个 Annotated[list, add_messages]。

默认的合并策略是"覆盖"。 节点返回 {"steps": 3},框架就把 State 里的 steps 改成 3。这对标量没问题,但对列表是灾难:模型节点返回一条新消息,如果按覆盖处理,前面所有对话历史就没了。

所以列表字段必须挂一个 reducer——一个告诉框架"两个值怎么合并"的函数。add_messages 就是官方给的 reducer:追加、去重(按消息 id)、并按 id 更新已有消息。没有它,你的多轮对话在第二轮就会失忆。

这里有个容易忽略的推论:并行分支必须用 reducer。 如果你让一个节点同时扇出到三个节点(Send 或一条边连多条边),三个节点会在同一轮里各自返回增量,然后统一合并。没有 reducer 的字段,三个结果会互相覆盖,最后谁赢取决于执行顺序——这种 bug 极难复现。

还有一个认知要先摆正:State 不是"全局变量",它是"每一步之后的快照"。 每个 super-step 结束时,框架把当前 State 存一份(如果配了 checkpointer)。节点拿到的永远是上一轮结束时的只读视图,它只能通过返回值表达"我要改什么"。这个约束让整个流程可回放——这是后面 Checkpointer 和人在回路的全部前提。

设计 State 有个很实用的经验:字段不是越多越好,而是"必须跨节点传递的才放"。 临时变量留在节点内部就好,不要什么都往 State 里塞。原因很直接——State 每个 super-step 都会被序列化一次,字段越多,checkpoint 越大,回放越慢,而且一旦某个字段只在一个节点里用,你以后想改它就得先确认没有别的节点在读。能塞进消息里的就别开新字段,因为 messages 已经有 reducer、有标准结构、还能直接被模型看见,是最省事的那块共享内存。真正值得单独开字段的,通常是"流程控制用的计数器"和"需要人看的中间产物",比如 steps、draft、approval_status。

3.2 Node:一个函数,一次动作

节点就是一个普通函数,签名 (state) -> dict。返回的 dict 是增量,不是完整的 State。

def call_model(state: AgentState) -> dict:
    resp = model_with_tools.invoke(state["messages"])
    return {"messages": [resp]}       # 只说要改 messages

框架负责把 {"messages": [resp]} 用 add_messages 合并进去,steps 之类的其他字段原样保留。

节点里放什么,框架不管:一次模型调用、一次数据库查询、一段 Python 计算、甚至另一个编译好的图(叫子图)。它只要求两件事:

  1. 不要改传进来的 state。 你要返回新值,不要 state["x"] = 1。改了也不会报错,但会让回放和并行出问题。
  2. 尽量幂等。 同一个节点可能被重跑——尤其是中断恢复的时候(后面会讲)。发邮件、扣款这类副作用,要么放在能确认只执行一次的位置,要么自己加幂等键。

节点名会出现在追踪、可视化、以及 get_state() 的返回值里。起个能看懂的名字,调试时省下的时间远超你想的。

3.3 Edge:普通边管顺序,条件边管决策

边决定"这个节点跑完,下一个是谁"。

普通边是无条件跳转:add_edge("tools", "agent"),tools 跑完一定回 agent。它负责把节点串成流水线。

条件边是 Agent 的灵魂:

def should_continue(state) -> str:
    last = state["messages"][-1]
    return "tools" if getattr(last, "tool_calls", None) else "end"

builder.add_conditional_edges(
    "agent",
    should_continue,                       # 路由函数:读 state,返回一个 key
    {"tools": "tools", "end": END},        # 映射表:key → 下一个节点
)

它做三件事:跑一个路由函数、拿到一个字符串、拿这个字符串去映射表里查下一个节点。

为什么说它是灵魂?因为这一个函数,就是"Agent 什么时候停"的全部定义。 模型不再是"生成一段文本",它是"生成一个决定流程走向的信号"。你把判断逻辑写在路由函数里,流程的拓扑就从静态变成了动态——同一张图,这次跑 2 轮,下次跑 7 轮。

三种边:普通边、条件边、回边,以及 recursion_limit

顺便说清三件常被误解的事:

第一,边不搬运数据。 很多人第一反应是"边是管道,数据从这条边流过去"。不是。数据永远只走 State,边只负责路由。理解这一点,你才不会在边上找"怎么传参"。

第二,条件边可以指回上游。 这就是回边,就是循环。add_conditional_edges("agent", ..., {"tools": "tools", "end": END}) 加上 add_edge("tools", "agent"),一个 ReAct 循环就成立了。

第三,循环必须有刹车。 LangGraph 用 recursion_limit 限制单次调用的 super-step 数,1.0.6 之后默认 1000。一轮 ReAct 大约消耗 2 步(模型 1 步 + 工具 1 步),所以 1000 差不多等于 500 轮。听起来够用,但模型要是卡在两个工具之间来回撞,它会在给出结论前先把额度烧光。生产里显式设置 recursion_limit,并且捕获 GraphRecursionError,别让它变成一个没人处理的 500。

四、写一个真正能跑的 ReAct Agent

理论讲完,动手。下面这段 90 行左右,pip install langgraph langchain-openai 之后填上 API Key 就能跑。

版本提醒:LangGraph 1.0 在 2025 年 10 月发布,并承诺到 2.0 之前不破坏兼容。网上大量 0.x 教程里的 create_react_agent 已被高层封装 create_agent 取代,MemorySaver 也改了名叫 InMemorySaver(旧名仍是别名)。下面的代码按 1.x 写,手搓 StateGraph,跨版本最稳。

# react_agent.py —— 一个完整可运行的 ReAct Agent(LangGraph 1.x)
# 依赖:pip install langgraph langchain-openai
from typing import Annotated, Literal
from typing_extensions import TypedDict

from langchain_core.messages import ToolMessage
from langchain_core.tools import tool
from langchain_openai import ChatOpenAI
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import START, END, StateGraph
from langgraph.graph.message import add_messages

# ---------- 1. 工具:普通函数加个装饰器,签名自动变 JSON Schema ----------
@tool
def get_weather(city: str) -> str:
    """查询指定城市的当前天气。city 用中文城市名,例如「杭州」。"""
    db = {"杭州": "多云 22℃,东南风 2 级", "北京": "晴 18℃,微风"}
    return db.get(city, f"没有 {city} 的天气数据")

@tool
def query_orders(status: str) -> str:
    """按状态查询订单。status 只能是 pending 或 shipped。"""
    db = {"pending": ["SO-1024 ¥2680", "SO-1025 ¥899"],
          "shipped": ["SO-1026 ¥15600"]}
    rows = db.get(status, [])
    return f"共 {len(rows)} 条:" + ";".join(rows) if rows else f"没有 {status} 的订单"

TOOLS = [get_weather, query_orders]
TOOL_MAP = {t.name: t for t in TOOLS}          # 名字 → 工具,执行时按名查找

# ---------- 2. State:整张图共享的一份数据 ----------
class AgentState(TypedDict):
    messages: Annotated[list, add_messages]    # reducer:追加,不是覆盖
    steps: int                                 # 自己记轮数,方便兜底

# ---------- 3. 模型节点:想一步 ----------
model = ChatOpenAI(model="gpt-4o-mini", temperature=0)
model_with_tools = model.bind_tools(TOOLS)     # 把工具清单交给模型

def call_model(state: AgentState) -> dict:
    """把历史消息喂给模型,它要么说人话,要么给出 tool_calls。"""
    resp = model_with_tools.invoke(state["messages"])
    return {"messages": [resp], "steps": state.get("steps", 0) + 1}

# ---------- 4. 工具节点:做一步 ----------
def call_tools(state: AgentState) -> dict:
    """执行最后一条 AIMessage 里的所有 tool_calls,一条结果一条 ToolMessage。"""
    last = state["messages"][-1]
    results = []
    for call in last.tool_calls:               # 一次可能要求调多个工具
        fn = TOOL_MAP.get(call["name"])
        try:
            out = fn.invoke(call["args"]) if fn else f"未知工具:{call['name']}"
        except Exception as e:                 # 失败也回填,让模型自己纠错
            out = f"工具执行失败:{e}"
        results.append(ToolMessage(content=str(out), tool_call_id=call["id"]))
    return {"messages": results}

# ---------- 5. 条件边:Agent 的灵魂 ----------
def should_continue(state: AgentState) -> Literal["tools", "end"]:
    """只看最后一条消息:带 tool_calls 就去执行,不带就结束。"""
    return "tools" if getattr(state["messages"][-1], "tool_calls", None) else "end"

# ---------- 6. 组装成图 ----------
builder = StateGraph(AgentState)
builder.add_node("agent", call_model)          # 节点:一次模型调用
builder.add_node("tools", call_tools)          # 节点:一次工具执行

builder.add_edge(START, "agent")               # 入口
builder.add_conditional_edges(                 # 条件边:运行时分流
    "agent",
    should_continue,
    {"tools": "tools", "end": END},
)
builder.add_edge("tools", "agent")             # 回边:这就是循环

app = builder.compile(checkpointer=InMemorySaver())   # 带检查点编译

# ---------- 7. 调用 ----------
if __name__ == "__main__":
    config = {"configurable": {"thread_id": "demo-1"}, "recursion_limit": 12}
    out = app.invoke(
        {"messages": [("user", "杭州现在天气怎么样?顺便看下还有多少待发货订单。")],
         "steps": 0},
        config,
    )
    for m in out["messages"]:
        calls = getattr(m, "tool_calls", None)
        print(f"[{m.type}] {str(m.content)[:60]}",
              f"→ {[c['name'] for c in calls]}" if calls else "")

跑起来会发生什么:

第一次进 agent,模型看到两个工具,决定同时调 get_weather(杭州) 和 query_orders(pending) 返回两条 tool_calls。条件边看到 tool_calls,路由到 tools。tools 逐个执行,产出两条 ToolMessage。回边把控制权交回 agent。第二次进 agent,模型手上已经有天气和订单,直接输出一段人话,没有 tool_calls。条件边路由到 END。总共 4 个 super-step。

这段代码里,每一部分在干什么:

部分代码作用
Statemessages + add_messages唯一的共享数据,只增不改
模型节点call_model读全部历史,产出一条 AIMessage
工具节点call_tools读 tool_calls,执行,产 ToolMessage
条件边should_continue决定"再来一轮"还是"收工"
回边add_edge("tools", "agent")把直线掰成环
编译compile(checkpointer=...)产出可 invoke 的 app

ReAct Agent 循环图:模型节点 → 条件边 → 工具节点 → 回到模型

注意一个细节:recursion_limit=12。这不是随便写的。一轮循环 2 步,加上首尾,12 步大约允许 5 轮工具调用——足够正常任务,又能让失控的 Agent 早点停下来。宁可让它早死,也不要让它悄悄花掉两百次模型调用。

调试这张图,别只会看最终输出。app.stream(input, config, stream_mode="updates") 会每跑完一个节点就吐一次增量,你能清楚看到"第几轮、哪个节点、往 State 里写了什么";app.get_state(config) 则能随时拿到当前快照和 next 字段——next 告诉你"如果现在继续,下一个要跑谁"。线上排查 Agent 为什么绕圈,靠的就是这两句。

如果跑起来发现它一直在 agent 和 tools 之间来回,八成是两件事之一:工具返回了空字符串(模型以为没查到,就一直重试),或者工具描述写得让模型误判(比如 query_orders 的说明里没写清 status 只能取哪两个值)。这两个坑,第三篇和第四篇里都踩过,换到图里也一样会踩——图能管住流程,管不住工具的描述质量。

五、Checkpointer:让 Agent 可以被暂停

前面提了好几次"存一份"。现在说清楚这是什么。

进程会死。用户会关页面。审批要等人。Agent 跑一次可能十几分钟。如果状态只在内存里,上面任何一件事发生,这次执行就永久丢了——而且是从第一步重来。

Checkpointer 就是给每一步拍快照。 每个 super-step 结束后,框架把当前的 State、下一步该去哪个节点、以及一些元数据写进存储。这份快照叫 checkpoint,一串 checkpoint 挂在同一个 thread_id 下面。

app = builder.compile(checkpointer=InMemorySaver())

config = {"configurable": {"thread_id": "user-42"}}
app.invoke({"messages": [("user", "帮我查下订单")]}, config)   # 第一次
app.invoke({"messages": [("user", "那发货了吗")]}, config)     # 第二次,自动带上历史

两个 API,换来三样东西:

断点续跑。 进程崩了,用同一个 thread_id 再 invoke 一次,它从最近一个 checkpoint 接着跑。你不用写任何恢复逻辑——恢复逻辑是框架送的。

多轮对话记忆。 很多人第一次用 LangGraph 会问"记忆怎么存"。答案是:历史就是 checkpoint 链。同一个 thread 下的 State 会累积,你什么都不用做。这也是为什么 1.0 之后不再需要单独的 memory 组件。

时间旅行。 app.get_state_history(config) 能拿到这条 thread 的所有快照,每个带 checkpoint_id。你可以把 checkpoint_id 塞进 config,从任意一个历史点重新跑——调试时极其好用:模型在第三步做了个蠢决定,你不用从头复现,直接回到第三步换句话再跑。

Checkpointer:每步存档、thread_id 与三种用途

存储怎么选:InMemorySaver 只在内存里,进程一退全没,只适合 notebook 和单测。SqliteSaver 适合本地开发。生产用 PostgresSaver(pip install langgraph-checkpoint-postgres),第一次用记得调 setup() 建表,另外 thread_id 长度要控制在 255 字符以内。

还有一句要提醒:checkpoint 里存的是完整对话和工具返回结果,等于把你所有的 prompt、用户输入、业务数据都落了一份盘。做数据合规的时候,这张表要跟业务主表一个待遇。

最后一个细节,做多 Agent 时很关键:子图不会自己开一个 thread。 你把一张编译好的图当成节点塞进父图,它默认跟父图共享同一个 checkpointer 和同一个 thread_id——也就是说,父子两层的 checkpoint 会写进同一条链里。想让子图独立存档、独立重跑,要么在 compile() 时给子图单独配 saver,要么用 namespace 把它隔离出来。这件事不搞清楚,debug 的时候你会看到一条长得离谱的历史,分不清哪一步是谁跑的。

六、人在回路:在不可逆的那一步交回方向盘

Checkpointer 最实用的一个副产品,是让"停下来等人"变成一件自然的事。

早期做法是在 compile() 时写 interrupt_before=["tools"]——跑之前就声明"到 tools 之前停一下"。问题是它太硬:每次都在同一个地方停,停几次也不知道,停的时候也拿不到上下文。等于在流水线上焊了个闸门。

现在的做法是在节点内部主动中断:

from langgraph.types import interrupt, Command

def approve_and_send(state: AgentState) -> dict:
    draft = state["draft"]
    # 执行到这里直接停住,把这个问题抛给外面的人
    decision = interrupt({"action": "send_email",
                          "to": state["to"], "body": draft})

    if not decision.get("approved"):
        return {"status": "rejected"}

    send_email(state["to"], draft)     # 副作用放在 interrupt 之后
    return {"status": "sent"}

外面这样接:

config = {"configurable": {"thread_id": "mail-1"}}

app.invoke({"messages": [("user", "给客户发一封延期说明")]}, config)  # 跑到 interrupt 停住
# ……人可以走开,进程可以退出,第二天回来……
app.invoke(Command(resume={"approved": True}), config)               # 带着答案继续

interrupt() 会把问题抛出去并让整个图暂停,同时把状态落盘。线程此时不占任何资源——它不需要挂着一个进程等,也不需要开着长连接。"等人"和"等定时器"在这套模型里是同一件事,这是设计上最聪明的一处。

人在回路:interrupt 暂停、人工审批、Command 恢复

四个坑,一个比一个重要:

① 恢复时,节点是从头重跑的。 interrupt() 恢复后不是从下一行继续,而是把整个节点函数再执行一遍,只是这次 interrupt() 直接返回你传进来的值。所以 interrupt() 上面的代码会跑两次。副作用必须放在它下面,或者自己加幂等键。这条是最容易出事的一条:有人在 interrupt() 上面写了扣款,恢复时扣了两次。

② 必须有 checkpointer。 没有它,暂停那一刻状态就丢了,interrupt() 直接抛异常。

③ 别用 try/except 包住 interrupt()。 它靠异常机制中断流程,你一个宽泛的 except Exception 会把中断吞掉,变成"跑一半报错"。

④ 恢复值就是 interrupt() 的返回值。 不要再额外存一份"待审批"记录在别处——两份状态一定会不同步。

七、什么时候该用,什么时候别用

这是最该讲清楚的一节,因为 LangGraph 现在是"人人都说该用"的状态,而大部分 Agent 根本不需要它。

Anthropic 在《Building Effective Agents》里的建议是对的:从最简单的方案开始,只有当它明显不够用时,才加一层。按这个思路,问自己四个问题:

① 一步就能答完吗? 那就直接调模型。连链都不用。

② 需要模型反复调工具(循环)吗? 不需要,一条链或者几个 if 就够。

③ 中途要停下来等人、或者跨进程续跑吗? 需要,这就是 LangGraph 的主场。Checkpointer + interrupt() 这一套,手写至少一个月,而且很难写对。

④ 需要复杂分支、并行扇出、子图嵌套吗? 需要,图的表达力确实比代码直观,改起来也安全。

四个都答"否"——就用一段 while 循环自己写。你比框架更懂自己的场景,多引入一层依赖只会多一层调试成本。

选型决策:四个问题判断该不该上 LangGraph

一张表,把链和图摆在一起:

维度链(Chain / LCEL)图(LangGraph)
表达能力有向无环,线性或静态分支有向可循环,任意拓扑
循环不支持,只能在外面套 while原生支持(回边)
分支编译期定死,靠 RunnableBranch运行时决定,条件边读 State
状态管理值在管道里流动,无共享状态显式 State + reducer,可持久化
中断与恢复不支持Checkpointer + interrupt()
可观测性调用树、回调每一步都是命名节点,可追踪、可回放
适用场景单轮、固定流程、数据处理管道多步决策、要人等、长任务、多 Agent 协作

这张表最右边那列,其实也在提醒一件事:图不是链的替代品,是链的下一层。 一个真实系统里两者经常同时存在——入口做意图识别用的是链,识别完之后按意图进不同的图;图里某个节点内部又调了一条链来做格式清洗。别把"上 LangGraph"理解成"把所有链都改写成图",那是纯粹的自找麻烦。

反过来,也允许自己从图上退下来。上线三个月,发现某个图的循环从来没超过一轮、条件边永远走同一个分支,那就说明这个 Agent 其实是条链,拆掉它省下的维护成本比省下的代码行数多得多。架构该跟着真实流量走,不是跟着趋势走。

八、框架是语法,模式是文法

最后一个容易混的问题:LangGraph 和 Orchestrator 是什么关系?

它们不在同一个维度上,所以不是二选一。

LangGraph 是框架——它规定你的代码长什么样:StateGraph、add_node、add_conditional_edges、compile。换掉它,这些行要重写。

Orchestrator 是模式——它规定"谁负责决定、谁负责干活":一个主脑拆任务、分派给若干 Worker、汇总结果。模式里没有一个 import,它住在你的架构图和分工里。换掉框架,这个模式一点没变。

所以正确的用法是叠着用:Orchestrator 模式里的每一个 Worker,都可以是 LangGraph 里的一个节点,或者一整张子图(subgraph)。 主脑本身也是一张图——它的条件边负责"派给谁",子图负责"这个 worker 怎么干"。

框架与模式:LangGraph 管代码形态,Orchestrator 管职责分工

顺便把"模式"这个词说得再具体一点。常见的编排模式其实就这么几种,而且每一种在图上的形状都不一样:Orchestrator 是一个主节点扇出到若干子节点再汇总,形状像扇子;Router 是一个条件边分流到不同处理节点,形状像岔路口;Evaluator-Optimizer 是生成节点 + 评审节点之间来回转的一条回边,形状是个环(本质就是一个 ReAct 循环的变体)。你把这些模式认出来之后会发现,LangGraph 的 API 只是给这几种形状提供了画法,选型时该想的是"我这个业务属于哪种形状",而不是"我该不该用 LangGraph"。

顺带说清一个高频困惑:官方的 create_agent 和手搓 StateGraph 是什么关系?create_agent 就是官方替你搓好的那张图(模型节点 + 工具节点 + 条件边 + 回边),外加一层 middleware 机制让你在固定位置挂钩子。它适合"标准 Agent 循环",你想改拓扑的时候再往下走一层。 两者不是新旧关系,是封装层次的关系。

九、写在最后

回到最开始那个 while 循环。

它没有错。它只是把控制流藏进了代码里——藏进去之后,你没法暂停它、没法存档、没法在监控里看见它、也没法让一个人在半路插手。

LangGraph 做的事,是把这段控制流摊开画在纸上:状态是什么、有哪几步、什么条件下走哪条路、什么时候停。摊开之后,图就变成了一份可以被工具读、被人改、被系统存档的资产。代价是你得比写 while 多花半天时间画清楚这张图——而这半天,通常会在第一次线上事故里赚回来。

放回系列里看:Prompt 是怎么和模型说话,RAG 是它的外挂知识,Function Calling 是它表达意图的语法,Tool 是它的手脚,MCP 是让手脚插得上任何机器的接口,而 LangGraph 是让这只手脚能动起来、能反复动、还能被人叫停的那套编排。 再往后,多个 Agent 之间怎么协作(A2A)、能力怎么打包分发(Skills)、运行时怎么把这些全串起来(Harness),是后面几篇的事。

如果只记一句话:链回答"按顺序做什么",图回答"什么时候该回头"。 Agent 和流水线的全部差别,就在这个"回头"上。