程云来 / 杭州
Back to Blog·LangGraph
·About 11 min

LangGraph(一):先让流程有一条可解释的执行轨迹

从一条会中途失败的资料调研流程开始,建立 State、Node、Edge 和 compile 的最小模型,并跑通一个可验证的状态图。


先看一条会中途失败的流程

设想一个可复现的场景:16:00,用户提交“LangGraph1 持久化”,系统开始调研。它要拆分问题、检索官方资料、整理证据、生成草稿,最后等待人工审核。理想路径很直观:

正在绘制图表…
查看 Mermaid 源码
flowchart LR
    A[输入主题] --> B[拆分问题]
    B --> C[检索资料]
    C --> D[整理证据]
    D --> E[生成草稿]
    E --> F{人工审核}
    F -->|退回意见| E
    F -->|通过| G[发布文章]

Mermaid 大图

可滚动查看图表,点击缩放比例可恢复 100%。按 Esc 关闭。

审核退回会回到生成草稿,只有通过才进入发布。

真正让系统变复杂的不是步骤数量,而是时间线:16:03,第三个来源超时;16:05,草稿生成;16:06,页面进入“等待审核”;第二天,审核者才回复,浏览器还因为网络抖动重试了一次发布请求。此时我们必须知道已经完成了什么、当前停在哪里,以及下一次应该从哪个事实继续。

函数链把关键事实藏起来了

最初的实现可能只有几行普通 Python:

python
questions = split_questions(topic)
sources = collect_sources(questions)
evidence = organize_evidence(sources)
draft = write_draft(topic, evidence)
decision = ask_human(draft)

if decision == "通过":
    publish(draft)
else:
    draft = revise(draft, decision)

短流程用它没有问题,但一旦拆成多个 HTTP 请求,几个关键事实就没有地方保存。比如第一次请求结束时,内存里可能是:

text
topic    = "LangGraph 持久化"
sources  = ["官方文档 / persistence", "官方文档 / interrupts"]
draft    = "第 1 版草稿"
decision = 等待审核

如果服务在这时重启,这些局部变量和调用栈都会消失。第二天审核接口只收到 通过,却无法判断它对应哪个主题、哪一版草稿,也不知道检索是否已经完成。

“重启后找不到草稿”和“审核时不能一直挂住请求”看起来是两个问题,根源却相同:流程中的事实还停留在一次函数调用里。只要服务重启,或一次 HTTP 请求结束,主题、草稿和“等待审核”就没有一个可以继续读取的位置。

所以,拆分工作单元之前要先回答一个更基础的问题:这些单元共享哪些数据,谁可以修改它们,暂停或重试时又要保留什么。LangGraph 用 State 把这份运行状态写成明确的数据契约。

State 不是随手传递的字典

State 是一次运行的数据契约。它要回答三个具体问题:字段由哪个节点写入,后续哪个节点会读取,运行暂停或重试时是否需要保留。它不是一个用来暂存所有对象的垃圾桶:

python
from typing_extensions import TypedDict


class ResearchState(TypedDict, total=False):
    topic: str
    questions: list[str]
    sources: list[str]
    evidence: list[str]
    draft: str
    review: str

这份 State 可以用表格读懂:

字段谁写入什么时候需要
topic入口全流程
questions拆分节点检索节点
sources检索节点证据整理
draft生成节点审核和发布
review审核节点路由到修改或发布

表格只说明字段归属,真正运行时还要分清三件事:节点读到的 State、节点返回的局部更新,以及运行时合并更新后的 State。以一次输入为例:

正在绘制图表…
查看 Mermaid 源码
flowchart TB
    s0["State<br/>topic"]
    n1["split_questions<br/>读取:topic<br/>返回:questions"]
    s1["State<br/>topic + questions"]
    n2["collect_sources<br/>读取:questions<br/>返回:sources"]
    s2["State<br/>topic + questions + sources"]
    n3["write_draft<br/>读取:topic、questions、sources<br/>返回:draft"]
    s3["State<br/>topic + questions + sources + draft"]

    s0 -->|传入 State| n1
    n1 -->|合并局部更新| s1
    s1 -->|传入 State| n2
    n2 -->|合并局部更新| s2
    s2 -->|传入 State| n3
    n3 -->|合并局部更新| s3

    classDef state fill:#eff6ff,stroke:#2563eb,color:#0f172a
    classDef node fill:#fff7ed,stroke:#ea580c,color:#0f172a
    class s0,s1,s2,s3 state
    class n1,n2,n3 node

Mermaid 大图

可滚动查看图表,点击缩放比例可恢复 100%。按 Esc 关闭。

图中的 State 方框显示当前已有字段,Node 方框显示它实际读取的字段和返回的局部更新;箭头上的“合并局部更新”表示运行时把返回值写回 State,未被修改的字段继续保留。Node 不需要返回完整 State。

如果 collect_sources 超时,最后一份成功状态仍然包含 questions。系统可以据此只重试检索节点;如果把所有数据都藏在函数局部变量里,重试就只能从入口重新拆分问题、重新调用已经成功的步骤。列表是否追加、去重和限长仍然是业务规则,State 类型本身不会自动替你解决。

不过,State 只规定了流程中有哪些事实,并不决定谁在什么时候修改它。要把失败限制在“检索资料”这一步,还需要为计算划出边界,并声明这些边界之间的执行顺序。

Node、Edge 和 compile 各自负责什么

Node 是一个有明确输入和输出的工作单元。读者应该能回答“它读取哪些 State 字段,成功后写回哪些字段,失败时副作用有没有发生”。例如 split_questions 只读取 topic,只写回 questionscollect_sources 只读取 questions,只写回 sources

python
def split_questions(state: ResearchState) -> dict[str, list[str]]:
    topic = state["topic"]
    return {
        "questions": [
            f"{topic} 的核心抽象是什么?",
            f"{topic} 如何处理失败和恢复?",
        ]
    }


def collect_sources(state: ResearchState) -> dict[str, list[str]]:
    # 教学替身。生产实现要有来源白名单、超时和有限重试。
    return {"sources": [f"官方资料:{question}" for question in state["questions"]]}

Edge 只表达控制流。固定边说明必经路径,条件边说明根据状态选择哪条路;路由函数不应该顺便写数据库或发送消息:

python
from typing import Literal


def route_review(state: ResearchState) -> Literal["revise", "publish"]:
    return "publish" if state.get("review") == "通过" else "revise"

StateGraph 是声明结构的构建器,compile() 才得到可执行图。构建阶段只是在登记“有哪些节点、它们如何连接”;运行阶段才会按入口 State 调用节点并合并更新。compile() 成功只证明图结构可执行,不代表来源可信、发布幂等或数据已经脱敏:

python
from langgraph.graph import END, START, StateGraph


builder = StateGraph(ResearchState)
builder.add_node("split_questions", split_questions)
builder.add_node("collect_sources", collect_sources)
builder.add_edge(START, "split_questions")
builder.add_edge("split_questions", "collect_sources")
builder.add_edge("collect_sources", END)

graph = builder.compile()

这里先停在两个节点,是有意的。假设检索节点调用了三个来源,前两个成功、第三个超时:如果检索是独立节点,系统可以记录前两个结果并重试第三个;如果“拆题 → 检索 → 生成 → 发布”是一个大节点,失败时只能看到整个函数报错,不知道哪些调用已经成功,更无法安全重做。

现在,State 负责保存事实,Node 负责产生局部更新,Edge 负责决定下一步,compile() 把这些声明变成可执行对象。概念已经连起来,但还需要真正运行一次,确认节点返回的更新会按预期合并进 State。

用一个最小实验确认状态真的在流动

环境按 2026-08-24 的 LangGraph 1.x 文档组织,运行时请将依赖锁定到项目实际验证的版本:

bash
python -m venv .venv
source .venv/bin/activate
pip install "langgraph>=1,<2" "typing-extensions>=4.12"

下面的实验不调用模型,只为了确认入口、节点和返回 State 的关系:

python
from typing_extensions import TypedDict
from langgraph.graph import END, START, StateGraph


class QuestionState(TypedDict):
    question: str
    answer: str


def normalize(state: QuestionState) -> dict[str, str]:
    return {"question": state["question"].strip()}


def answer(state: QuestionState) -> dict[str, str]:
    return {"answer": f"收到问题:{state['question']}"}


builder = StateGraph(QuestionState)
builder.add_node("normalize", normalize)
builder.add_node("answer", answer)
builder.add_edge(START, "normalize")
builder.add_edge("normalize", "answer")
builder.add_edge("answer", END)

graph = builder.compile()
result = graph.invoke({"question": "  LangGraph 是什么?", "answer": ""})
print(result)

预期输出包含 {"question": "LangGraph 是什么?", "answer": "收到问题:LangGraph 是什么?"}。执行轨迹是:入口带空格,normalize 写回清理后的问题,answer 读取新值并写入答案,最后到达 END。如果只看到最终字符串而无法解释这三步,说明还没有建立 State 的心智模型。

可以再做一次失败验证:让 collect_sources 抛出 TimeoutError。症状是图在检索节点停止,原因是外部请求失败;当前版本的处理是保留已拆分的问题并把错误交给上层,验证方式是日志中能定位到 collect_sources,而不是得到一个伪造的草稿。

这套模型还没有解决什么

目前的图仍然依赖当前进程。进程结束后 State 会消失,人工审核也没有暂停点;外部写入如果失败,重跑节点可能产生重复发布。invoke 能验证一次调用,却不能替代持久化、恢复和幂等设计。

参考资料

Footnotes

  1. LangGraph 官方文档:用于编排带状态、分支和暂停恢复能力的长流程。