Chapter 02 · 工作原理与设计权衡

工作原理与设计权衡 · 为什么这么设计,以及为什么 checkpoint 是杀手锏

上一章把状态图的积木铺开了——state、reducer、node、edge、compile、checkpointer 各是什么,已经清楚。这一章讲它为什么是这个样子:图为什么比 LCEL 的直线链多了回边和动态分支、reducer 为什么是字段级声明、以及为什么"每步把 state 存档"这一个 checkpoint 动作会变成续跑、time-travel、人在环、故障恢复四件事的共同地基。看不到放弃了哪些备选方案、付出了什么代价,就只是会用,不是懂。

本章要建立的心智模型

  • 图 = 直线链 + 回边 + 动态分支 + 可持有状态;多出来的这几样正是 LCEL 表达不了、手写循环又难管的那部分
  • reducer 是字段级的合并协议,把"并发写如何 merge"从临时加锁提升成一阶机制
  • checkpoint 每步存档是 threshold:续跑 / time-travel / 人在环 / 故障恢复都是读写同一份存档的副产品,不是四套独立功能
  • durable execution 有 sync / async / exit 三档 durability,是"一致性 vs 吞吐延迟"这条轴上的三个点
  • interrupt() 暂停—存档—等人工—从存档恢复,把"人在环"做成读写 checkpoint 的一个特例

2.0整体架构 · compile 一次、逐节点跑、每步存档

把这一章要讲的五个机制压进一张图:图在 compile() 时被冻结成可运行对象,运行时按"超级步(super-step)"逐节点推进,每个超级步结束都把整份 state 写进 checkpoint store。这条"每步存档"的竖线,是后面 s23 / s24 / s25 三节反复回来的同一根轴。

声明层 运行时 持久化层 StateGraph(声明) state + node + edge invoke / stream config 带 thread_id compile 冻结 进入循环 逐超级步执行循环 挑选被触发的 node 并行执行 reducer 合并输出 → 存档 → 下一超级步 / END Checkpoint store InMemorySaver / SqliteSaver / PostgresSaver key = (thread_id, checkpoint_id),存整份 state 快照 每个超级步都写 resume 读回
图 2.0运行时三层。读这张图盯三点:一,compile() 是声明层到运行时的单向冻结边界——冻结后才能做拓扑校验、注入 checkpointer。二,朱红那根竖线"每个超级步都写"是全章的引力中心,s23 之后的机制全都在它上面长出来。三,右侧那条虚线回边——续跑只是把上一步的快照读回来再往下跑,没有第二套机制。

2.1为什么用图 · vs LCEL 直线链 · vs 手写 while 循环

图比 LCEL 的 a | b | c 多了三样东西:回边(能跳回前面的 node 形成循环)、动态分支(运行时按 state 决定下一步,而不是编译时定死)、可持有的状态(跨节点、跨轮次累积的 state)。这三样正是 agent 控制流必需、而直线链结构上给不了的。

运行方式

LCEL 的 | 把 Runnable 串成一条有向无环管道:数据从左流到右,每个箭头只能指向后面、不能回指,分支结构在 compile 时就定死。LangGraph 把这条管道换成一张有向图:边可以回指(A 跑完跳回 B,形成循环),可以是 conditional edge(运行时调一个函数、按当前 state 返回下一个 node 名),节点之间共享一份持久 state。所以"让模型自己判断要不要再调一次工具"这种循环,在图里是一条回边;在直线链里结构上画不出来。

表 2.1 · 控制流载体备选方案
方案优势为什么没选 / 局限
LCEL 直线链(a | b | c)极简、调试直观、读代码一眼到底;串好默认带 stream / batch / retry无环——画不出回边(循环);分支只能编译时静态定死,运行时不能按 state 改道;没有跨节点累积的状态
手写 while 循环 + if 分支Python 原生,循环和分支想怎么写怎么写,零框架难观测(每步状态在局部变量里,外面看不见)、难恢复(进程一崩内存全丢,没有存档点)、难中断(没有干净的"暂停—等人工—接着跑"协议)
状态图(StateGraph)回边 + 动态分支 + 持久 state 三样齐全;每步状态进 checkpoint → 可观测 / 可恢复 / 可中断成为副产品选中——付出"要先声明 state 与 edge、比直线链多一层心智"的代价
带来的代价

图的代价是前置成本:写一条 LCEL 链三行就跑起来,写一张图要先声明 state(含每个并发字段的 reducer)、再连 edge、再 compile。对"prompt 进、文本出"这种纯直线任务,图是过度设计——这类场景该停在 LCEL。判断分界的那条线是 03 章的主题:不需要回边、不需要运行时改道、不需要跨步累积状态,就别上图。把图用在该用的地方,不是把所有东西都塞进图。

想一想

"让模型反复调工具直到它认为答完了"这个 agent 循环——为什么 LCEL 的 | 管道结构上做不出来,换成手写 while 又会缺什么?

展开答案(先停 10 秒再点)

LCEL 做不出:循环需要一条"回到模型节点"的回边,而 | 串出的是有向无环管道,箭头只能往后、不能回指——"反复"这个词在无环结构里没有对应物。次数还是运行时由模型决定的,编译时连展开成几段都不知道。

手写 while 缺三样:① 观测——每轮的中间 state 在局部变量里,外部要看进度得自己埋日志;② 恢复——循环跑到第 7 轮进程崩了,内存里的 state 全丢,没有"从第 6 轮的存档接着跑";③ 中断——想在某轮插入人工审批,得自己设计暂停信号、状态外存、恢复入口这一整套。状态图把这三样做成了内建:回边表达循环,checkpoint 提供观测和恢复点,interrupt 提供中断协议。后面 s23 / s25 正是讲这三样怎么从"每步存档"长出来。

2.2state + reducer 机制 · 并发写如何 merge

同一个超级步里若有多个并行 node 写同一个 state 字段,框架不靠加锁、不靠"后写覆盖"猜——它调这个字段声明的 reducer((老值, 新值) → 合并值)把多份写折叠成一份。reducer 是字段级的、声明式的,是一阶机制而不是临时手段。

运行方式

每个 state 字段在运行时是一个"带 reducer 的命名收件箱"。超级步期间所有 node 对这个字段的写入先塞进收件箱,超级步结束时框架调一次该字段的 reducer,把收件箱里的内容折叠回字段当前值。没挂 reducer 的字段走默认行为——覆盖(last-write-wins);挂了 Annotated[list, add] 就是拼接;挂了 add_messages(见 §1.1)就是按消息 id 做 upsert。合并语义被提升成字段上的一句声明,而不是散在各 node 里的加锁代码。

表 2.2 · 并发写合并机制备选方案
方案优势为什么没选 / 局限
不写 reducer,默认覆盖(last-write-wins)最简单,单线程下完全够用并发分支下会丢写——两个 node 同步写 messages,覆盖语义只能留一份,另一份消息丢了;这正是消息累积场景的致命点
运行时给字段加锁串行写不丢写,并发安全合并语义仍未定义(锁只保证不同时写,没说两份怎么合);还引入锁的复杂度与争用,把并发优势抵消
全局单一 reducer 树(Redux 风格)全局状态演变可预测、集中过度集中——每加一个字段都要改全局那棵 reducer 树,字段之间互相牵连,扩展性差
字段级 reducer(Annotated[T, fn])每个字段独立声明合并语义、partial update 自然、reducer 就是普通函数不发明新写法选中——付出"写并发字段时必须意识到 reducer 的存在"的代价

把覆盖与不写 reducer 的后果摆在一起看,最直观。下面这段把"漏写 reducer"和"正确声明"放进同一张图的两条并发分支(代码用于演示,未在本机运行,标 2026-06):

reducer_concurrent.py Python
from typing import Annotated, TypedDict
from operator import add
from langgraph.graph import StateGraph, START, END

class State(TypedDict):
    # 漏写 reducer 的字段:默认覆盖。两个并发分支同步写它 → 报错
    winner: str
    # 正确声明 reducer 的字段:并发写被 add 拼接,谁都不丢
    notes: Annotated[list[str], add]

def branch_a(state: State) -> dict:
    return {"winner": "A", "notes": ["a 跑过了"]}

def branch_b(state: State) -> dict:
    return {"winner": "B", "notes": ["b 跑过了"]}

def join(state: State) -> dict:
    return {"notes": [f"合并后共 {len(state['notes'])} 条"]}

graph = (
    StateGraph(State)
    .add_node("branch_a", branch_a)
    .add_node("branch_b", branch_b)
    .add_node("join", join)
    # 关键:a 和 b 都从 START 出发 → 同一超级步并行 → 同步写同一字段
    .add_edge(START, "branch_a")
    .add_edge(START, "branch_b")
    .add_edge("branch_a", "join")
    .add_edge("branch_b", "join")
    .add_edge("join", END)
    .compile()
)

# notes 有 add reducer:["a 跑过了", "b 跑过了"] 两条都在,join 看到 2 条
# winner 无 reducer 又被两个并发分支同步写 → 抛 InvalidUpdateError:
#   Can receive only one value per step. Use an Annotated key to handle multiple values.
result = graph.invoke({"winner": "", "notes": []})
带来的代价

字段级 reducer 的代价:程序员必须主动意识到 reducer。漏写 Annotated[..., add] 在单线程下毫无症状,一旦开了并发分支就直接抛 InvalidUpdateError——这是新手最常见的崩溃。这是框架的刻意选择:它不替你猜两份并发写该怎么合,而是把"没声明合并语义又并发写"判定成程序员错误、当场报错,而不是悄悄用某种策略合并、留下一个查不出的丢数据 bug。代价换来的是确定性——错误暴露在编码期,不是上线后。

想一想

上面这段把 branch_a 和 branch_b 改成串行(START → a → b → join),winner 仍然没有 reducer——还会报 InvalidUpdateError 吗?

展开答案(先停 10 秒再点)

不会。串行时 a 和 b 落在不同的超级步:a 在第一步写 winner="A"、存档,b 在第二步写 winner="B"、覆盖成 "B"、再存档。每个超级步对这个字段只有一份写,默认覆盖 reducer 完全能决策,最终 winner=="B"。报错只在同一超级步内多份写且字段无 reducer 时触发。

洞察:是否需要 reducer,取决于会不会在同一超级步里被多个 node 同时写,而不是字段类型。这也解释了为什么"加 Annotated reducer"是绝大多数并发崩溃的标准修法——把同一超级步的多份写定义清楚怎么合,报错就消失了。

2.3checkpoint 持久化机制 · 一个动作,四件事的地基

checkpointer 在每个超级步结束把整份 state 拍一张快照(StateSnapshot)存进 store,按 (thread_id, checkpoint_id) 寻址。续跑、time-travel 调试、人在环、故障恢复——这四件事不是四套独立功能,而是读写这同一串快照的四种方式。这是本章最该记住的一句。

运行方式

每个超级步推进完,checkpointer 调一次"存",把当前所有 state 字段的值打包成一个 StateSnapshot(含 values 当前值、next 下一步要跑的 node、config 里的 thread_id 和 checkpoint_id、parent_config 指向上一张快照)写进 store。同 thread_id 的快照串成一条时间线。除了超级步级的整份快照,框架还在 node(task)级别记录每个 node 完成时的写入——这是更深一层的细节:一个超级步里 fan-out 出多个 node,其中一个崩了重跑时,已成功 node 的写入是 durable 的、不会被重算。

下面这张图是全章的论证核心:四件看似不同的能力,箭头全部指回中间那串"每步存档的快照"。

checkpoint 快照串 每超级步一张 (thread_id, ckpt_id) 续跑 / 对话记忆 同 thread_id 接着追加 time-travel 调试 指定旧 ckpt_id 重放 人在环 (HITL) 暂停时落一张快照 故障恢复 从最后成功快照重启 同一份存档,四种读写方式——不是四套独立机制
图 2.1checkpoint 是杀手锏的真正含义。注意:四个角上的能力没有各自的存储——续跑是"同 thread_id 接着写",time-travel 是"挑一张旧快照重放",人在环是"暂停时也落一张快照",故障恢复是"从最后一张成功快照重启"。它们共用中心那串快照,所以做一份持久化等于一次拿到四样能力,这是 LangGraph 相对裸 agent 框架的结构性优势。
表 2.3 · checkpoint store 实现取舍
实现适用代价 / 局限
InMemorySaver本地实验、单测、Notebook 里跑 demo进程内 dict,重启即丢——名字里没有 "memory" 的持久含义,生产用它等于没有持久化
SqliteSaver / AsyncSqliteSaver单机、本地工作流、小规模部署单文件数据库,不适合多进程 / 分布式并发写
PostgresSaver / AsyncPostgresSaver生产、分布式、多副本——LangSmith 自身用的就是它选中(生产首选)——付出"要运维一个 Postgres"的代价;要选对 sync / async 变体匹配调用方式
带来的代价

"每步整份快照"的代价是存储与延迟开销:state 越大、超级步越多,写的字节数和 IO 次数越多。这正是 s24 要解决的——durability 模式让你在"每步都同步落盘"和"攒着异步落盘"之间选。另一处代价是寻址纪律:thread_id 是 LangGraph 里唯一的隔离边界,没有内建的用户 / 租户概念。生产里把它硬编码成 "default",所有人共用一条时间线,对话历史会互相串台。正确做法是把它当租户 key 设计,比如 thread_id = f"{user_id}:{session_id}"。

想一想

同一个 thread_id 连续 invoke 三次、每次都传初始 state {"count": 0},count 字段会一直从 0 重新开始吗?

展开答案(先停 10 秒再点)

不会。第一次 invoke 之后 store 里已有这个 thread 的快照;从第二次起,传进去的初始 state 被忽略——框架在最后一张 checkpoint 之上接着追加,count 从上次的值往下走,不是从 0。这经常让人困惑:"每次明明都传了 count=0,为什么它还在累积?"——因为续跑读的是存档,不是这次传进去的初始值。要真正重置,得换一个新的 thread_id。这正印证了 s23 的主线:续跑不过是"在同一串快照后面接着写"。

2.4durable execution · sync vs async durability 的取舍

既然每步都要存档,那"什么时候真正落盘"就成了一个可调的权衡。LangGraph 用 durability 参数(传给 invoke / stream)给三档:"sync"(每步同步落盘,最强一致、最慢)、"async"(异步落盘,快、有窄故障窗口)、"exit"(只在图结束时落盘,最快、中途崩了不可恢复)。这是"一致性 vs 吞吐 / 延迟"同一条轴上的三个点。

运行方式

三档的差别只在"落盘的时机相对于下一步执行":
"sync"——当前超级步的 checkpoint 必须先写进 store,下一步才开始。任何时刻崩溃,已跑完的步一定在盘上,可从最后一步精确恢复。代价是每步都卡一次 IO,吞吐和延迟都受影响。
"async"——checkpoint 的写入异步进行,同时下一步已经在跑。性能接近不落盘,但存在一个窄故障窗口:如果进程恰好在某些 checkpoint 还没刷进 store 时崩溃,这几步会丢,恢复时回到更早的一致点。
"exit"——只在图整体结束(成功、报错或被 interrupt 中断)时才落盘。中途完全不存,性能最好,但进程崩在中间 = 这次运行的中间状态全丢、无法续跑。

表 2.4 · durability 三档取舍
模式落盘时机一致性 / 故障窗口性能
"exit"仅图结束时最弱——中途崩则本次运行中间态全丢,不可恢复最快
"async"下一步执行的同时异步写较强——仅在"写未刷完即崩"的窄窗口丢几步快(生产常用默认权衡点)
"sync"下一步开始前同步写完最强——任意时刻崩都能从最后成功步精确恢复最慢(每步一次 IO 开销)
带来的代价 · 没有免费的一致性

这三档本身就是"代价段"——选哪一个都在拿某样东西换另一样。误区是把 "sync" 当默认猛用:对一个有大量短超级步的高吞吐 agent,每步同步落盘的 IO 会变成主要瓶颈。也别无脑选 "exit" 图快:跑长任务、跑要人工审批的流程时,中途崩溃不可恢复等于前功尽弃。更深一层的安全网在 node 级——即使在一个超级步内,已完成 node 的写入也是单独记录的,所以一个超级步里 fan-out 的多个 node、其中一个失败重跑时,已成功的那些不会被重算。把 durability 当一个按场景调的旋钮:审批 / 长流程偏 sync,高吞吐短步偏 async,一次性纯计算可考虑 exit。

想一想

一个跑几十个超级步的批量 agent,原本用 durability="sync",吞吐上不去。换成 "async" 之后吞吐明显改善——这次改动把什么风险引进来了?什么场景下这个风险不可接受?

展开答案(先停 10 秒再点)

引进来的是窄故障窗口:"async" 让 checkpoint 异步刷盘、下一步同时往前跑,于是存在一小段"某些 checkpoint 还没落到 store、进程已经崩了"的时间——崩在这个窗口里,最近几步会丢,恢复时退回更早的一致点。对纯只读 / 可重算的批处理,丢几步重跑一遍没代价,"async" 划算。但若某个 node 在 interrupt 之后做了不可逆副作用(已经扣过款、已经发过邮件),而记录这步"已完成"的 checkpoint 落在丢失窗口里,恢复后会把这步当没跑过重做一遍——重复扣款。这类不可逆副作用 + 强恢复要求的流程,要么用 "sync",要么在 node 里用 state 标志做幂等守卫(见 s25)。

2.5中断与人在环 · interrupt() 暂停—存档—等人工—恢复

在 node 内部调 interrupt(payload),graph 立即暂停、保存 checkpoint、把 payload 抛回调用方等人工;调用方决定后用 Command(resume=value) 重新 invoke,被中断的 node 从头整段重跑,到 interrupt() 这里返回 value 继续。人在环就是"读写同一串 checkpoint"的又一个特例。

运行方式

调 interrupt() 触发五步:① graph 在调用点挂起;② 当前 state 经 checkpointer 存档(复用 s23 那串快照,没有第二套存储);③ payload 返回给调用方(invoke 走 __interrupt__,流式走 stream.interrupts);④ graph 无限等待,直到外部 resume;⑤ 用 Command(resume=value) 恢复时,value 成为 interrupt() 的返回值传回 node。关键且反直觉的一点:恢复时运行时从 node 开头整段重跑,不是从 interrupt() 那一行续跑——所以 interrupt() 之前的代码会在每次 resume 时再执行一遍。这是"暂停—存档—等人工—恢复"四步全都建在 checkpoint 上的直接后果:存得下的是 state(可序列化的 dict),存不下的是函数调用栈,所以只能靠重跑 node、用 state 把走到哪一步表达出来。

表 2.5 · 暂停—恢复机制备选方案
方案优势为什么没选 / 局限
编译时静态暂停点(旧 interrupt_before=[...])无运行时开销、拓扑上可视只能在 node 边界停,不能 node 内任意位置暂停;不能按条件触发;payload 得从 state 反推
generator / 协程 yield 式暂停保留调用栈,恢复时在 yield 处续跑、不重复前面的代码调用栈无法序列化 → 跨进程 / 跨重启不可恢复 → 与"暂停状态要进 checkpoint 持久化"根本冲突
外部 callback / webhook 暂停极灵活,可对接任意外部审批系统把分布式回调的复杂度甩给框架使用者,大多数场景过度
interrupt() raise—存档,Command(resume=) 重跑 node暂停态全进可序列化的 checkpoint、跨重启可恢复、payload 任意 JSON、可 node 内任意位置 + 条件触发选中——付出"interrupt() 之前的代码会在 resume 时重跑"的代价

下面这张图把 s23 的 checkpoint、s25 的 interrupt、s22 的 reducer 串成一条人在环的完整往返——一份审批从暂停到带修改恢复,三个机制各出一份力:

node 内 interrupt() 挂起 + 抛 payload 存进 checkpoint 复用 s23 的快照串 暂停即存档 人工审批 / 修改 可改要删的表名等 payload 抛给人工 Command(resume, update) node 从头整段重跑 update 经 reducer 折回 state 决定后恢复 读回快照 → 注入 resume 值 → 接着往下跑
图 2.2人在环的一次完整往返。注意:左下的"存进 checkpoint"(朱红)就是 s23 那串快照,人在环没有新建任何存储;右下 Command 的 update 字段经 s22 的 reducer 折回 state(审批人改表名走的是按字段合并、不是覆盖)。少了 checkpoint 就找不回暂停点、少了 reducer 审批人就只能 yes/no 不能改——三个机制缺一不可。这正是 03 章对比里其他框架"做不了等小时级人工审批"的根因。
带来的代价 · 写 node 的纪律

因为 resume 时 node 从头整段重跑,interrupt() 之前的所有副作用(发邮件、扣款、写库、调外部 API)都会重复触发。三条纪律:① 把副作用放在 interrupt() 之后;② 能拆就把副作用拆进单独的 node;③ 必须放在前面又挪不动的,用 state 标志做幂等守卫——if not state.get("email_sent"): send_email(...); return {"email_sent": True}。还有一条:一个 node 里多次 interrupt() 时,恢复值与各次调用是严格按出现顺序(下标)匹配的,所以别按非确定数据条件性地跳过或循环 interrupt——顺序错位会把 A 的回答喂给 B。

2.6跨机制综合 · 一份"删库前审批"工作流

五个机制到这里都是单点。把它们拼起来看一个贴近生产的需求最能看清协同:agent 要调"删生产库表"工具时,弹给人审批;审批通过才执行;审批人可以修改具体要删哪些表。这条线把 s21–s25 几乎全用上了——下面这道综合题请先自己拆,再对答案。

想一想 · 跨机制综合

把上面这个"删库前审批"工作流,拆成本章哪几个机制的组合?每个机制在这里具体承担什么?哪些地方藏着会反咬一口的代价?

展开答案(先认真拆完再点)
  1. s21 用图:这是带审批回环 + 条件分支的流程(审批拒绝要回到规划、通过才往执行走),LCEL 直线链结构上做不出,必须用 StateGraph。节点:planner → approval_gate → execute。
  2. s22 reducer:state 里 proposed_tables(默认覆盖即可)、audit_log: Annotated[list, add](审计记录要拼接不能覆盖)。审批人改表名时,Command(resume=..., update={"proposed_tables": [...]}) 的 update 经 reducer 折回 state,execute 才拿得到改后的值。
  3. s23 checkpoint + thread_id:审批可能等几分钟到几小时,暂停点必须存档才找得回;thread_id 保证 A 用户的审批不会触发 B 用户的执行——它是唯一隔离边界,别硬编码。
  4. s24 durability:删库是不可逆副作用,要恢复必须可靠 → 这条流程偏向 "sync",或至少不能用会丢步的 "exit";否则恢复时可能把"已删"当没删重做。
  5. s25 interrupt:approval_gate 内调 interrupt({"tables": state["proposed_tables"]}) 暂停、抛表名给人工。
  6. 代价(必踩):真正的删表副作用必须放在 interrupt() 之后(在 execute 里),且 planner / approval_gate 不能有不可逆副作用——否则 resume 时 node 从头重跑会让它们再执行一次。这是 s25 的纪律与 s24 的故障窗口在同一处叠加的地方。

一句话收束:人在环 = 图(回环)+ checkpoint(找回暂停点)+ interrupt(触发暂停)+ reducer(折回审批修改),再用 durability 决定恢复有多可靠。任意拆掉一个,这条流程就缺一块。

§本章 self-check

先合上教程作答。直接展开 = 把这节当再读一遍。

  1. 图比 LCEL 直线链多出来的三样东西是什么?手写 while 循环又缺哪三样?
  2. state 字段不写 reducer 时默认行为是什么?什么具体条件下这个默认会触发 InvalidUpdateError?
  3. 为什么说"checkpoint 是杀手锏"?续跑 / time-travel / 人在环 / 故障恢复这四件事和 checkpoint 是什么关系?
  4. durability 的 "sync" 与 "async" 各自的落盘时机、一致性、性能如何取舍?"async" 引入了什么风险?
  5. interrupt() 恢复时 node 是从中断那一行续跑、还是从头重跑?这个选择的根本原因是什么?带来什么纪律?
  6. 跨机制综合:人在环由本章哪几个机制共同实现?任意去掉一个会发生什么?
答案(先做完再展开)
  1. 图多出:回边(循环)、动态分支(运行时按 state 改道)、可持有的跨节点状态。手写 while 缺:可观测(中间状态在局部变量里)、可恢复(崩了内存全丢、无存档点)、可中断(没有干净的暂停—等人工—恢复协议)。后三样在图里由 checkpoint + interrupt 内建提供。
  2. 默认行为是覆盖(last-write-wins)。触发条件不是字段类型,而是同一个超级步内被多个并行 node 同时写、且该字段没挂 reducer——框架无法决策保哪份,当场抛 InvalidUpdateError。串行写(落在不同超级步)则不报错,最后一次覆盖生效。修法是给字段加 Annotated[..., reducer] 定义清楚怎么合。
  3. 因为续跑 / time-travel / 人在环 / 故障恢复不是四套独立功能,而是读写同一串"每步存档的快照"的四种方式:续跑 = 同 thread_id 接着追加;time-travel = 挑一张旧 checkpoint_id 重放;人在环 = 暂停时也落一张快照、resume 时读回;故障恢复 = 从最后一张成功快照重启。做一份持久化等于一次拿到四样能力,这是它相对裸 agent 框架的结构性优势。
  4. "sync":下一步开始前同步把 checkpoint 写完 → 一致性最强(任意时刻崩都能从最后成功步精确恢复)、性能最慢(每步一次 IO)。"async":下一步执行的同时异步写 → 性能快、但有窄故障窗口(写未刷完即崩会丢最近几步,恢复退回更早的一致点)。风险即这个丢步窗口;对带不可逆副作用又要可靠恢复的流程不可接受。(还有更快但中途崩不可恢复的 "exit"。)
  5. 从头整段重跑,不是从中断那行续跑。根本原因:暂停态要进 checkpoint 持久化、跨重启可恢复,而能序列化的是 state(dict),函数调用栈序列化不了——所以只能重跑 node、用 state 表达"走到哪了"。带来的纪律:副作用必须放在 interrupt() 之后,或拆进单独 node,或用 state 标志做幂等守卫;多次 interrupt 时恢复值按下标严格匹配,不能条件性跳过。
  6. 人在环 = 图(提供审批回环)+ checkpoint(暂停时存档、找回暂停点)+ interrupt(在 node 内触发暂停并抛 payload)+ reducer(让 Command.update 折回 state),再由 durability 决定恢复可靠度。
    去掉 checkpoint:暂停点重启即丢,分钟级以上的等待无法可靠实现,退化成同进程同步等待。
    去掉 interrupt:只能用旧的 interrupt_before 在 node 边界静态停,不能 node 内任意位置、不能条件触发、不能带 payload。
    去掉 reducer:Command.update 无法干净折回 state,审批人只能 yes/no、不能修改要删的表名,退化成开关式审批。
进阶挑战 · 刚好够不着

给"审批超时自动降级"设计存档与恢复语义

把 s25 的审批流程再推一步:要求"人工审批超过 30 分钟没响应就自动按拒绝处理,并且整个过程崩溃后能恢复到正确状态"。基于本章理解回答:(a) 这个 30 分钟的等待计时,状态应该存在哪里才能跨进程崩溃存活——存在 node 的局部变量里行不行,为什么?(b) durability 该选哪一档,理由?(c) "超时降级"这个动作本身要不要走 interrupt() 的 resume 路径,还是另起一个 node 判定?写出你的节点拆分与 state 字段设计骨架(不需要能跑)。

提示(卡住再展开)

核心矛盾:计时这种"经过了多久"的信息,一旦放进 node 局部变量,进程崩了就丢——而本章反复强调能跨重启存活的只有进 checkpoint 的 state。所以要把"审批发起时间戳"写进 state(approval_started_at),让它进快照;恢复时拿"当前时间 − 存档里的发起时间"重新判断是否已超时,而不是依赖一个活着的计时器。

class S(TypedDict):
    proposed_tables: list[str]
    approval_started_at: float          # 进 checkpoint,崩了也在
    decision: str                       # "approved" / "rejected" / ""
    audit_log: Annotated[list, add]

# approval_gate:发起审批前先盖时间戳(注意幂等——只盖一次)
def approval_gate(state: S):
    if not state.get("approval_started_at"):
        state = {"approval_started_at": now()}   # 返回 partial,先存档
    # interrupt 抛给人工;resume 时此 node 从头重跑,
    # 上面的 if 守卫保证时间戳不会被重置
    answer = interrupt({"tables": state["proposed_tables"]})
    return {"decision": answer, "audit_log": ["human responded"]}

# 超时判定单独一个 node(不走 resume,而是恢复后由它重新算):
def timeout_check(state: S):
    if now() - state["approval_started_at"] > 1800 and not state["decision"]:
        return {"decision": "rejected", "audit_log": ["auto-rejected: timeout"]}
    return {}

(b) 选 "sync"(或至少非 "exit"):审批后接的是删库这类不可逆副作用,恢复必须可靠,不能容忍 "async" 的丢步窗口把"已审批/已超时"的状态丢掉。(c) "超时降级"不该塞进 resume——resume 是"人工真的回应了"的路径;超时是"人工没回应",更自然的是恢复后由独立的 timeout_check node 拿存档里的时间戳重新判定。两者都改写同一个 decision 字段,execute 只认 decision。