LLM 工程手册LLM ENGINEERING

CHAPTER 02状态编排 · ORCHESTRATION

LangGraph 状态编排

用状态图构建可靠的有状态 LLM 应用:循环、分支、持久化、人机协同与时间旅行。这是 create_agent 背后的运行时引擎,也是从"调用模型"到"构建系统"的关键一步。

开始阅读 难度进阶 先修第一章 3小时
02.1

为什么需要 LangGraph

LCEL 的链是有向无环图(DAG):数据只能一路向前。但真实的 LLM 应用充满循环与不确定:模型可能要反复调用工具、流程可能中途需要人工确认、失败可能需要重试。这些都不是 DAG 能表达的。

链(DAG)擅长

固定的加工流水线:检索、改写、生成、解析。每一步执行一次,顺序已知,无回退。

图(含循环)擅长

迭代决策:模型决定下一步、执行工具、观察结果、再决定。循环次数未知,路径由数据驱动。

LangGraph 把应用建模为一张状态机:节点是处理步骤,边是转移规则,状态在节点间流转并被自动持久化。它不是一个新框架的"另一种链",而是一套运行时,围绕四个生产主题构建:

主题一

可靠性(持久化)

每个执行步骤自动存档(checkpoint)。进程崩溃后从断点恢复,长任务可以跑几小时甚至几天。

主题二

可控性(人机协同)

任何节点都能暂停执行,等待人工输入后继续。审批、纠错、补全信息都是同一机制。

主题三

可解释性(时间旅行)

完整历史状态可回放、可分支重试。改一个参数从中间重跑,不必从头再来。

主题四

可交互性(流式)

token 级、节点级、自定义事件级的多粒度流式输出,覆盖产品需要的全部实时反馈。

与 LangChain 的关系

LangChain 1.x 的 create_agent 就构建在 LangGraph 上:你不必了解图 API 也能用智能体,但当需求超出预置能力(自定义循环、多阶段工作流、精细的状态管理),下探到 LangGraph 是官方设计的扩展路径。两套 API 在 1.x 后保持稳定:图原语(状态、节点、边)与执行模型不再破坏性变更。

02.2

核心概念:State / Node / Edge

整个 LangGraph 只有三类一等公民。把它们的关系想清楚,剩下的都是细节。

State(状态)
一个 TypedDict 或 Pydantic 模型,定义了全图共享的数据结构。每个节点读它、改它,图的执行就是状态的演化史。
Node(节点)
一个普通 Python 函数或 Runnable:输入当前状态,返回状态更新(只写要改的字段,不返回全量状态)。
Edge(边)
节点间的转移规则。普通边固定去向;条件边由路由函数根据当前状态决定下一个节点,这是分支与循环的来源。
START / END
虚拟入口与出口。START 连向第一个节点;流转到 END 意味着本次执行结束。
Reducer(合并器)
字段级的更新策略:新值如何与旧值合并。默认"覆盖",add_messages 是"追加"。下一节详解。
Checkpointer(存档器)
在每个"超步"(superstep)后保存状态快照。是持久化、人机协同与时间旅行的共同地基。
State 全局共享状态对象 START Node A(函数) 读取状态,返回更新 Node B(Runnable) 可以是任意 Runnable Node C 循环回到 A 的例子 END 条件边:按状态路由
模型全貌:状态是中心数据结构,节点读写状态,边(实线或条件函数)决定流转。循环与分支都由条件边表达。
心智模型

把 LangGraph 图想象成"带自动存档的状态机":状态是唯一的真相来源,节点是无状态的纯函数(输入状态、输出补丁),所有复杂性都放在状态结构与路由函数里。这个模型让多节点协作、并行执行、故障恢复天然成立。

02.3

构建第一个图

用最简单的两节点线性图走完整生命周期:定义状态、注册节点、连边、编译、调用。

from typing import TypedDict
from langgraph.graph import StateGraph, START, END

# 1. 定义状态:全图共享的数据契约
class State(TypedDict):
    topic: str          # 输入:选题
    outline: str        # 中间产物:大纲
    article: str        # 输出:成稿

# 2. 节点:纯函数,返回"要更新的字段"
def make_outline(state: State) -> dict:
    return {"outline": f"《{state['topic']}》三段式大纲"}

def write_article(state: State) -> dict:
    return {"article": f"正文按大纲展开:{state['outline']}"}

# 3. 组装图
builder = StateGraph(State)
builder.add_node("outline", make_outline)
builder.add_node("write", write_article)
builder.add_edge(START, "outline")       # 入口
builder.add_edge("outline", "write")     # 固定边
builder.add_edge("write", END)           # 出口

# 4. 编译:得到可调用的 Runnable
graph = builder.compile()

# 5. 调用
result = graph.invoke({"topic": "向量数据库选型"})
print(result["article"])

五个要点值得逐条咀嚼:

  • 节点只返回增量。make_outline 只写 outline 字段,框架负责把补丁合入全局状态。返回整个状态虽然也能跑(覆盖合并),但会让并行节点互相踩踏。
  • 编译产生 Runnable。compile() 返回的对象支持 invoke / stream / batch,与 LangChain 生态无缝互操作。
  • 输入是初始状态。不需要的字段可以不传(TypedDict 不强校验),但节点访问不存在的键会报 KeyError,状态契约要设计好。
  • 缺 checkpointer 时无记忆。两次 invoke 互不相干,状态不保留。要记忆见第 6 节。
  • 画图调试。graph.get_graph().draw_mermaid() 输出 Mermaid 文本,贴进任意渲染器即可查看图结构。

内置状态:MessagesState

对话类应用的 90% 场景只需要一个"消息列表"状态。LangGraph 内置了 MessagesState,它等价于自己写 Annotated[list, add_messages]

from langgraph.graph import StateGraph, MessagesState, START
from langgraph.graph.message import add_messages
from typing import Annotated, TypedDict

# 手写等价形式,理解 MessagesState 到底做了什么
class MyMessagesState(TypedDict):
    messages: Annotated[list, add_messages]   # add_messages = 追加式 Reducer

# 大多数时候直接用内置的
from langgraph.graph import StateGraph, MessagesState

builder = StateGraph(MessagesState)
builder.add_node("chat", lambda state: {"messages": [...]})
builder.add_edge(START, "chat")
graph = builder.compile()

add_messages 的行为不只是 append:新消息带相同 id 时执行替换(用于编辑、重试),否则追加。这正是智能体历史能被安全更新的原因。

02.4

状态设计:Reducer 详解

节点返回的“补丁”如何合入全局状态?答案是字段级的 Reducer。理解它,并行节点才能安全协作而不互相踩踏。

默认合并语义是覆盖:新值直接替换旧值。这对单写者字段足够,但当两个并行节点同时写 notes,覆盖语义要么丢数据、要么直接抛错。Reducer 就是为此而生:给每个字段显式声明“合并策略”,框架在补丁合入时自动调用它。

import operator
from typing import Annotated, TypedDict
from langgraph.graph.message import add_messages

def merge_scores(left: float, right: float) -> float:
    """并行节点各产出置信度,合并时取平均。"""
    return round((left + right) / 2, 4)

class ResearchState(TypedDict):
    topic: str
    # 无 Annotated = 默认覆盖(确认只有一个写者时使用)
    final_answer: str
    # 列表追加:两个来源节点各返回若干条,自动拼接不丢
    notes: Annotated[list[str], operator.add]
    # 自定义合并:并行评分取平均
    score: Annotated[float, merge_scores]
    # 消息历史:追加 + 同 id 替换
    messages: Annotated[list, add_messages]
目标行为写法典型场景
覆盖(默认)field: str单写者字段:最终答案、阶段标记、路由依据
列表追加Annotated[list, operator.add]并行检索结果汇总、执行日志、候选清单
消息追加 / 替换Annotated[list, add_messages]对话历史、智能体消息流
自定义Annotated[T, my_fn]去重合并、取最大值、加权平均、按 id 更新
InvalidUpdateError

同一超步内两个节点更新同一字段、且该字段没有 Reducer 时,LangGraph 直接抛 InvalidUpdateError。这不是 bug 而是保护:框架拒绝在“丢一半数据”和“随机选胜者”之间替你做决定。解法二选一:给字段声明 Reducer,或重新设计让各节点只写自己的字段。

三条设计纪律

  1. 先数写者。设计每个字段时先问:谁会写它?只有一个节点写,覆盖即可;两个以上(尤其可能并行),必须显式声明 Reducer。
  2. 并行节点写独立分区。让每个并行分支写自己的字段(如 notes_a / notes_b),汇总交给下游节点完成。这比在共享字段上做复杂合并更清晰,get_state 的结果也更好读。
  3. 状态保持最小。状态在每个超步后都会被序列化存档,塞入大对象(整页 HTML、大文件内容)会拖慢 checkpoint 速度并膨胀存储。大载荷放外部存储,状态里只留控制流需要的键。
02.5

分支与循环:条件边

普通边是“必经之路”,条件边是“岔路口”。路由函数读状态、返回去向,分支与循环由此而来。

add_conditional_edges(源节点, 路由函数, [候选去向]):源节点每次执行完毕,框架调用路由函数,用返回值决定下一个节点。返回值可以是单个节点名、节点名列表(同时进入多个节点)或 END。用 Literal 标注返回类型,静态检查器就能帮你发现拼错的节点名:

from typing import Literal

def route_by_quality(state: State) -> Literal["revise", "publish"]:
    if state["score"] < 0.8:
        return "revise"
    return "publish"

builder.add_conditional_edges("review", route_by_quality, ["revise", "publish"])
write(写稿) 产出 draft review(审查) 计算 score publish score 及格,出稿 score 不及格:打回重写 循环次数受 recursion_limit 约束(默认 25 个超步)
写稿与审查的循环:score 达标走 publish 出图;不达标回到 revise 重写,直到及格或触发 GraphRecursionError。
recursion_limit:循环的保险丝

图默认最多执行 25 个超步,超过即抛 GraphRecursionError。这根“保险丝”防止路由函数永远不满足退出条件时的死循环。调大它之前先确认循环真的可能需要更多轮;无限放开的 limit 在生产环境等于慢性事故。修改方式:graph.invoke(inputs, {"recursion_limit": 50})

在节点内部动态路由:Command(goto=)

条件边把“路由决策”放在边声明处,但有时决策依赖节点内部新算出的值。此时节点可以直接返回 Command,同时携带“去哪”与“状态更新”两件事:

from typing import Literal
from langgraph.types import Command

def review(state: State) -> Command[Literal["revise", "publish"]]:
    score = evaluate(state["draft"])
    if score < 0.8:
        # 去向 + 一次状态更新,二合一
        return Command(goto="revise", update={"score": score, "rounds": state["rounds"] + 1})
    return Command(goto="publish", update={"score": score})
两者怎么选

路由只依赖已有状态,用条件边,图结构更直观、可视化更清晰;路由依赖节点内新产生的信息(模型输出、工具结果),用 Command(goto=),省去“先写状态再路由”的两跳。注意二者不能在同一节点上混用:声明了条件边的节点不应再返回 goto。

02.6

持久化与 Checkpointer

给图装上“自动存档”:每个超步后保存状态快照。崩溃恢复、跨调用记忆、人机协同全部建立在这层地基上。

编译时传入 checkpointer,图就从“无状态函数”变成“有状态服务”。三档实现对应三个阶段:

from langgraph.checkpoint.memory import MemorySaver
from langgraph.checkpoint.sqlite import SqliteSaver
from langgraph.checkpoint.postgres import PostgresSaver

# 开发期:内存存档,进程退出即失
graph = builder.compile(checkpointer=MemorySaver())

# 单机持久化:SQLite 文件,重启不丢
with SqliteSaver.from_conn_string("checkpoints.db") as saver:
    graph = builder.compile(checkpointer=saver)

# 生产:Postgres(异步用 AsyncPostgresSaver,用法一致)
with PostgresSaver.from_conn_string("postgresql://user:pwd@host/db") as saver:
    saver.setup()                       # 首次运行建表,之后可省
    graph = builder.compile(checkpointer=saver)

thread_id:会话的钥匙

存档按 thread(会话线程)组织。调用时在 config 里给出 thread_id,同一 thread 的每次调用都会自动恢复上次状态。这就是“记忆”的全部实现,没有任何额外代码:

config = {"configurable": {"thread_id": "conv-9812"}}

# 第一轮:告诉它名字
graph.invoke({"messages": [{"role": "user", "content": "我叫林可"}]}, config)

# 第二轮:只传新消息,历史已被自动恢复
graph.invoke({"messages": [{"role": "user", "content": "我叫什么?"}]}, config)
# 模型能正确回答“林可”
# 检查某个线程的当前状态
snap = graph.get_state(config)
print(snap.values)    # 当前完整状态(含全部消息)
print(snap.next)      # 下一步待执行的节点;非空 = 图正停在中断处
生产环境的 thread_id 设计

thread_id 是隔离单位:一个用户的一个会话对应一个 thread_id,推荐 user_id + session_id 组合。绝不要用自增序号跨用户复用(记忆串线事故的头号来源);也不要让单个用户长期只有一条 thread(上下文无限增长直到 token 上限,配合第 1 章的 SummarizationMiddleware 或定期开新 thread 截断)。同时设计归档策略:checkpoints 表会持续增长,需要定期清理不活跃的 thread。

02.7

人机协同:interrupt 与 Command

任何节点都能暂停整张图,等人类回复后从同一位置继续。审批门、工具确认、信息补全,全部是同一套机制。

核心是一个函数 interrupt():节点执行到它时,图立即暂停,当前状态连同“问人类的题目”一起存档;进程甚至可以退出。之后任意时刻(另一个进程、另一台机器),用 Command(resume=...) 把人类的答复送回去,图从暂停处继续:

from langgraph.types import interrupt, Command

def human_review(state: State) -> dict:
    # 中断点:图在此暂停,payload 会原样交给调用方展示给人类
    decision = interrupt({
        "question": "是否执行转账?",
        "amount": state["amount"],
        "payee": state["payee"],
    })
    # 只有 resume 之后才会走到这里,decision 就是人类的答复
    if decision == "approve":
        return {"status": "已执行转账"}
    return {"status": "已拒绝"}

config = {"configurable": {"thread_id": "tx-1001"}}

# 第一次调用:停在 human_review,返回值带 __interrupt__ 信息
result = graph.invoke({"amount": 500, "payee": "张三"}, config)
print(result["__interrupt__"])     # 人类看到题目,去审批……

# 审批通过后恢复执行(可以发生在另一个进程)
graph.invoke(Command(resume="approve"), config)
plan 组装请求 human_review interrupt() 暂停 人工审核 图外:人类决策 execute 拿到答复后执行 END 暂停:状态已存档 Command(resume="approve") 关键:恢复时 human_review 从头重新执行,interrupt() 直接返回人类的答复值
人机协同全流程:interrupt 暂停并存档,人类在图外决策,Command(resume=...) 送回结果后,图从该节点开头重放,interrupt 调用点返回答复值。
最重要的机制:节点重放

resume 后,包含 interrupt() 的节点会从头重新执行,interrupt 调用点直接返回答复值(不会重新提问)。推论:写在 interrupt 之前的代码会跑两遍。因此中断节点里不要放不可重复的副作用(发邮件、扣款);要么把副作用放在 interrupt 之后的代码里,要么先做幂等设计。这是新手最常踩的坑。

NodeInterrupt:校验式中断

另一类场景是“输入不合法,必须人介入”:节点内主动抛出 NodeInterrupt("原因"),图同样暂停,但语义是“异常暂停”。常配合工具参数校验使用:模型生成的参数不满足硬约束时,把人拉回来修正而不是默默重试。

与第 1 章的关系

LangChain 1.x 的 HumanInTheLoopMiddleware 就是基于这套机制封装的:它拦截工具调用、生成中断、等你批准。中间件帮你处理了暂停与恢复的细节,但当你需要自定义审批界面、多级审批、按金额分流审批人时,直接用 interrupt 写自己的审批节点更灵活。

02.8

时间旅行

每个 checkpoint 都是可回滚的存档点。回放历史、从中间重跑、分叉出平行宇宙,都是对存档的简单读取。

get_state_history(config) 倒序列出该线程的全部快照,每个快照都有唯一的 checkpoint_id

history = list(graph.get_state_history(config))
for snap in history:
    ckpt = snap.config["configurable"]["checkpoint_id"]
    print(ckpt, snap.next, len(snap.values.get("messages", [])))

回放:从历史点重跑

# 选一个历史快照,不传输入(None = 从该点继续执行)
to_replay = history[3]
graph.invoke(None, to_replay.config)
# 该线程被“回退”到那个时刻,之后的执行从这里重新展开

分叉:带新输入走向另一条路

old_ckpt = history[3].config["configurable"]["checkpoint_id"]
fork_config = {"configurable": {
    "thread_id": "fork-branch-a",     # 新线程:不影响原对话
    "checkpoint_id": old_ckpt,          # 但从旧存档点出发
}}
graph.invoke({"messages": [{"role": "user", "content": "换个角度重写"}]}, fork_config)
# 原线程 history[3] 之后的历史保持不变,两条时间线互不干扰
调试利器

执行结果不对时,回放到出问题前的快照,改用 stream_mode="debug" 重跑,逐超步观察状态演化,比加 print 高效得多。

产品能力

“重新生成第 3 步”、“回到上一版方案继续聊”这类功能,本质都是 fork:前端把用户操作翻译成带 checkpoint_id 的 invoke 调用。

时间旅行的边界

checkpoint 只保存状态,不保存外部世界的副作用(已发出的邮件、已写入的第三方系统)。回放某一步意味着重新执行它之后的节点:如果那些节点有副作用,副作用会再次发生。把副作用节点设计成幂等,或用 interrupt 人工把关。

02.9

高级编排:Send / 子图 / 函数式 API

三个进阶工具:Send 解决运行时才知的动态并行,子图解决复用与分层,函数式 API 解决“图太别扭”的场景。

Send:map-reduce 式动态并行

条件边的候选去向在编译期固定,但“检索 17 个主题”这种并行度只有运行时才知道。Send 允许路由函数为每个元素动态分发一个节点实例:

from langgraph.types import Send

def fan_out(state: State) -> list[Send]:
    # topics 有几个就分发几份,并行度运行时才确定
    return [Send("search", {"topic": t}) for t in state["topics"]]

def search(payload: dict) -> dict:
    # 注意:Send 的载荷直接成为节点输入,不经过全局 State
    return {"notes": [f"{payload['topic']} 的检索结果"]}   # notes 声明 operator.add

builder.add_conditional_edges("plan", fan_out, ["search"])
builder.add_edge("search", "aggregate")   # 全部 search 实例结束后汇入 aggregate
Send 的三个要点

一,载荷直接作为节点输入(不套全局 State),目标节点按自己的输入契约写;二,同一超步内所有实例并发执行,全部完成后才走下一步;三,每个实例的返回补丁分别经 Reducer 合并,所以汇总字段必须声明 operator.add 之类的追加语义。

子图:图作为节点

编译后的图本身是 Runnable,可以直接 add_node 挂进父图,实现分层复用:父图管流程,子图管一个完整阶段。父子状态按同名字段自动衔接:

research = research_builder.compile()      # 一张完整的研究子图
writer = writer_builder.compile()          # 一张完整的写作子图

parent = StateGraph(ParentState)
parent.add_node("research", research)      # 图直接当节点
parent.add_node("writer", writer)
parent.add_edge(START, "research")
parent.add_edge("research", "writer")
parent.add_edge("writer", END)

# 父图 State 必须包含子图需要的同名字段(如 notes、messages);
# 子图对这些字段的更新经 Reducer 合并回父图状态

当父图与子图状态结构差异大时,改用“节点内调用”模式:写一个普通函数节点,在函数里 invoke 子图并做字段转换。显式的胶水代码比隐式的同名魔法更好调试。

函数式 API:@entrypoint 与 @task

有些流程用图表达很别扭:深嵌套循环、运行时动态生成的控制流。函数式 API 让你写普通 Python,同时保留存档、恢复与流式能力:

from langgraph.func import entrypoint, task

@task
def search(q: str) -> str:
    return retriever.invoke(q)

@task
def write(notes: list[str]) -> str:
    return llm.invoke("根据以下笔记写报告:\n" + "\n".join(notes)).content

@entrypoint(checkpointer=saver)      # 存档依然可用,interrupt 也支持
def report(inputs: dict) -> dict:
    found = [search(q).result() for q in inputs["questions"]]
    if not any(found):              # 普通 Python 条件,不用建边
        found = [search("默认主题").result()]
    return {"report": write(found).result()}

@task 划出持久化边界:崩溃恢复时已完成的任务直接读缓存结果,不重跑;.result() 阻塞等待结果(异步版用 await)。@entrypoint 标记工作流入口,checkpointer 参数与图 API 完全一致。

维度StateGraph(图 API)函数式 API
心智模型显式状态机,先画图再写代码普通函数,先写逻辑再要持久化
最适合多节点协作、复杂路由、需要可视化动态控制流、深嵌套循环、从脚本平滑迁移
持久化粒度每个超步一个 checkpoint每个 task 边界一个存档
两者关系可混用:task 内部可以 invoke 一张图,图的节点里也可以调 task
02.10

流式输出

五种 stream mode 覆盖从“打字机效果”到“全量调试”的全部需求,一次调用还可同时订阅多种。

模式产出内容典型用途
values每个超步后的完整状态快照需要全量中间结果、简单调试
updates每个超步的增量补丁(节点名 → 更新)步骤日志、进度指示、审计
messages(消息块, 元数据)元组,token 级打字机效果的实时回复
custom节点内 writer() 主动发出的自定义事件业务级进度、阶段通知
debug全部内部事件,最详细排查框架级行为
# 节点级:updates,看每一步谁改了什么
for chunk in graph.stream(inputs, config, stream_mode="updates"):
    for node, update in chunk.items():
        print(f"[{node}] {update}")

# token 级:messages(消息块与元数据成对产出)
for msg_chunk, meta in graph.stream(inputs, config, stream_mode="messages"):
    print(msg_chunk.content, end="", flush=True)

# 一次订阅多种模式:收到 (mode, chunk) 二元组
for mode, chunk in graph.stream(inputs, config,
                                stream_mode=["updates", "messages"]):
    if mode == "messages":
        ...   # 渲染 token
    else:
        ...   # 更新步骤面板

自定义事件:get_stream_writer

模型的 token 流不等于业务进度。节点内用 get_stream_writer() 把任意 JSON 可序列化对象发到 custom 通道,前端就能收到“正在检索第 3 个主题”这类业务事件:

from langgraph.config import get_stream_writer

def research(state: State) -> dict:
    writer = get_stream_writer()
    writer({"stage": "searching", "progress": 0.3})
    notes = do_search(state["topic"])
    writer({"stage": "summarizing", "progress": 0.8})
    summary = do_summarize(notes)
    writer({"stage": "done", "progress": 1.0})
    return {"notes": notes, "summary": summary}

# 调用侧:stream_mode="custom" 接收这些事件
for event in graph.stream(inputs, config, stream_mode="custom"):
    print(event)    # {'stage': 'searching', 'progress': 0.3} ...
选型建议

面向终端用户的产品界面:messages(回复流)+ custom(业务进度)几乎总是最佳组合;面向运维与调试:updates 看步骤,问题不明朗时再上 debug。避免在生产中默认开 values:每步都传全量状态,大状态下开销可观。

02.11

从零构建 ReAct 智能体

不依赖任何 prebuilt,三十行代码手写智能体主循环。写完这一节,create_agent 对你不再是黑盒。

ReAct 智能体的本质是一张两节点循环图:模型节点决定“回答还是调工具”,工具节点执行并回填结果,直到模型给出纯文本回答。你已经掌握的全部原语——MessagesState、条件边、ToolNode——正好凑齐:

from typing import Literal
from langchain.chat_models import init_chat_model
from langchain_core.tools import tool
from langgraph.graph import StateGraph, MessagesState, START, END
from langgraph.prebuilt import ToolNode

@tool
def add(a: int, b: int) -> int:
    """两数相加。"""
    return a + b

@tool
def multiply(a: int, b: int) -> int:
    """两数相乘。"""
    return a * b

tools = [add, multiply]
llm = init_chat_model("openai:gpt-4o-mini").bind_tools(tools)

def call_model(state: MessagesState) -> dict:
    # 模型节点只做一件事:带着全部历史调用模型
    return {"messages": [llm.invoke(state["messages"])]}

def should_continue(state: MessagesState) -> Literal["tools", "__end__"]:
    last = state["messages"][-1]
    if last.tool_calls:        # 模型生成了工具调用请求
        return "tools"
    return END                 # 纯文本回答,循环结束

builder = StateGraph(MessagesState)
builder.add_node("agent", call_model)
builder.add_node("tools", ToolNode(tools))   # 预置节点:执行工具并回填结果
builder.add_edge(START, "agent")
builder.add_conditional_edges("agent", should_continue, ["tools", END])
builder.add_edge("tools", "agent")           # 工具结果送回模型,形成循环

graph = builder.compile(checkpointer=MemorySaver())

config = {"configurable": {"thread_id": "demo-1"}}
result = graph.invoke(
    {"messages": [{"role": "user", "content": "先算 23 乘 7,再加 100"}]},
    config,
)
for msg in result["messages"]:
    print(msg.type, ":", msg.content)
用户消息 进入 START agent(模型) 看全部历史,决定下一步 tools(ToolNode) 执行调用,回填结果 最终回答 无 tool_calls,走向 END 有 tool_calls 观察结果,重新决策 纯文本
ReAct 主循环:模型请求工具 → ToolNode 执行并回填 → 模型观察结果再决策。“先乘后加”会转两圈,因为模型一次只调一步。
与 create_agent 的关系

LangChain 1.x 的 create_agent 内部就是这张图,外加中间件钩子与预置能力。日常直接用 create_agent;需要非 ReAct 形态的工作流、多阶段审批、并行分支、自定义状态字段、或穿插非 LLM 节点时,回到本章手写图。理解这张图后,智能体框架的“循环次数”“工具选择”“历史裁剪”都能被你诊断与定制。

02.12

常见陷阱与 FAQ

GraphRecursionError:先别急着调大 recursion_limit

触发说明循环没到退出条件。先用 stream_mode="updates" 重跑,看是哪个节点在反复执行:路由条件永不满足、模型反复调用同一工具、interrupt 的 resume 值类型不对,都是常见病因。修好根因后,如果流程确实需要更多步(比如长任务工具链),再按需调大 {"recursion_limit": N}

为什么两次 invoke 之间状态“失忆”了?

两个检查点:编译时是否传了 checkpointer;每次调用是否用同一个 thread_id。任一缺失都等于“新会话”。另外注意 MemorySaver 只在进程内存活,重启即清零,跨进程要用 SqliteSaver 或 PostgresSaver。

两个用户的对话互相串线,怎么回事?

几乎一定是 thread_id 复用:同一 thread_id 被两个会话使用,它们会读写同一份状态。用 user_id + session_id 生成全局唯一的 thread_id,并在会话结束、超时后不再复用旧值。

interrupt 恢复后,为什么 interrupt 之前的代码又执行了一遍?

这是设计:resume 会重放整个节点,interrupt() 调用点直接返回人类的答复。代价是节点开头的代码会跑两次,副作用必须治理:要么放在 interrupt 之后,要么幂等设计(带唯一键去重)。

报错提示 checkpointer 与调用方式不匹配(sync / async 混用)?

同步图配同步存档(SqliteSaver / PostgresSaver + invoke),异步图配异步存档(AsyncSqliteSaver / AsyncPostgresSaver + ainvoke)。不要在一个进程里混用两种事件循环;FastAPI 等异步框架里优先全套 async。

状态里放了自定义对象,存档时报“无法序列化”?

checkpointer 用 JSON 序列化状态,自定义类实例、数据库连接、模型客户端都不能直接放。大对象存外部(数据库、对象存储),状态里只放引用 id;确实需要自定义类型时,用支持 serde 的 BaseStore(跨 thread 的键值存储)而不是 State。

有了 create_agent,什么场景还值得手写图?

典型信号:流程不是“模型+工具”循环(多阶段审批、先检索再写作再审校);需要自定义状态字段(不只是 messages);需要并行分支与汇总;需要把非 LLM 节点(规则引擎、爬虫)编进主流程;需要在精确位置插入 interrupt。满足任意一条,手写图都比改造智能体更直接。

02.13

实战练习与资源

以下练习按难度递增,建议全部完成:每个都对应本章一个核心机制,做完即掌握。

延伸资源