一、一次 invoke 的三个开关
一次 graph.invoke() 的调用形式不复杂,但真正决定执行路径的只有三项:
三项各司其职,互不替代:
| 开关 | 作用范围 | 说明 |
|---|---|---|
input |
是否写入数据、是否回到 START |
传值即视为一次新的调用 |
checkpoint_id |
以哪一份快照为基准 | 决定从哪一份开始往下执行 |
Command(resume=...) |
挂起节点的 interrupt() 返回值 |
不属于数据,不写入状态 |
其中最容易混淆的是后两项。checkpoint_id 只能表达「从哪一份快照往下执行」,无法提供 interrupt() 所需的返回值。 原因见 §4.2:next 指向的节点会整段重新执行,interrupt() 在重新执行时才会去接收答复。
1.1 判断依据只有一条:next
无需记忆「有 / 无」的排列组合。只需查看最新一份快照的 next 是否为空元组。
next非空:图中尚有节点未执行完毕。next为空:本次执行已到终点。
这一判断决定了本次调用应当传什么,具体见 §2.2。
二、场景一:订单流程在校验环节失败
2.1 现场
自拟三个节点:受理 → 校验 → 回执。其中 校验 需要调用下游服务,第一次调用失败并抛出 RuntimeError。失败后,该线程的检查点链如下:
text
step= 1 loop next=('校验',) cid=...7019c6 values={'log': ['用户下单', '受理 完成']} ← 最新
step= 0 loop next=('受理',) cid=...1357fd values={'log': ['用户下单']}
step=-1 input next=('__start__',) cid=...58a0d9 values={'log': []}
从最新一份快照(...7019c6)可以读出三项事实,它们直接决定了本次调用的写法:
| 读到的内容 | 含义 | 对参数的结论 |
|---|---|---|
next = ('校验',) |
图未执行完毕,停在 校验 |
以最新快照为基准即可,无需指定 checkpoint_id |
values 中没有 校验 的产出 |
上一次执行未写入任何状态 | 重新执行不会产生重复写入,无需提供 input |
挂起的任务上是 error 而非 interrupts |
属于异常中断,没有等待答复 | 无需使用 Command |
因此本次调用的写法是:
python
app.invoke(
None, # 不提供输入
{"configurable": {"thread_id": "order-1"}}, # 只指定线程,不带 checkpoint_id
)
结论:该写法不是为了「重试」而设计的特殊用法,而是上述三项事实的唯一推论。 报错重试之所以只需传 None,是因为「从停下的地方继续」这件事已经由最新快照本身表达清楚了。
2.2 三种重试诉求与对应写法
同一现场下,开发者的诉求无非三种,参数由诉求推出,而非从组合表中挑选:
| 诉求 | 调用写法 | 受理 是否重跑 |
校验 执行次数 |
新增快照 |
|---|---|---|---|---|
| 接着刚才那一步继续 | invoke(None, config) |
否 | 再执行 1 次,成功 | 2 份,均为 loop |
| 修改参数后重新执行 | invoke({"log": ["参数改成加急"]}, config) |
是 | 再执行 1 次 | 5 份,均为 loop;新输入并入状态 |
| 回到更早的快照重来 | invoke(None, step=0那份.config) |
是 | 再执行 1 次 | 多一份 source=fork 的复制品 |
三种写法执行完毕后的最终状态如下。判断走了哪一条路,不应只看最终状态,而应看同一节点的产出出现了几次:
text
A 继续执行 : ['用户下单', '受理 完成', '校验 完成(第2次才成功)', '回执 完成']
B 改参数 : ['用户下单', '受理 完成', '参数改成加急', '受理 完成', '校验 完成(第2次才成功)', '回执 完成']
↑ 新输入并入 ↑ 受理 执行了第二次
C 回到更早 : ['用户下单', '受理 完成', '校验 完成(第2次才成功)', '回执 完成']
(最终状态与 A 相同,区别在链上多出一份 step=1 的 fork 复制品)
2.3 三种写法的完整对照(含链条)
我自己写了一份 invoke-scene.html,分别对应三种写法。每张图的上半部分是本次调用的关键结果,下半部分是执行完毕后的完整检查点链,每个节点下方均标出它的 parent 及 step 的推导过程。
写法 A:invoke(None, config),只重跑停在的那一步

适用情形:故障属于重试一次即可通过的类型。该调用不会重跑 受理,也不会追加需求之外的数据。
写法 B:invoke(新输入, config),修改参数并重新执行

代价体现在状态中:受理 完成 出现了两次。传入 input 等同于声明这是一次新的调用,框架会将输入并入状态,再从 START 重新执行。「修改参数」与「只重试一步」是两件事,无法在一次调用中同时完成。
写法 C:invoke(None, 旧快照.config),回到更早的快照重新执行

链上会出现两个 step=1 :一个是原有的 loop 快照,一个是重跑新建的 fork 岔口,两者 parent 相同,属于兄弟快照。原有链条不受影响。仅当需要修改更早步骤的执行结果时才应选择此写法;单纯重试无须如此。
2.4 step 的编号规则
写法 C 的结果中 step 的变化较易产生疑问,此处单独说明。规则只有一条:
step= 该快照的parent的step+ 1。
它不是「在原链上的序号」,也不是「被选中那份的序号」。逐条核对上图中的链条:
| cid | parent |
parent 的 step |
该快照的 step |
|---|---|---|---|
...41523f |
...32cccc |
-1 | 0 |
...c7548e(新建岔口) |
...41523f |
0 | 1 |
...5472d1 |
...c7548e |
1 | 2 |
...38050d |
...5472d1 |
2 | 3 |
...349f90 |
...38050d |
3 | 4 |
...41fdff(原链) |
...41523f |
0 | 1 |
由此可以解释三个常见疑问:
- 为什么新建岔口是
step=1而不是0。 因为它的parent是...41523f(step=0),所以0 + 1 = 1。同时,原链中...41fdff的parent也是...41523f,两者的step相同,说明它们是兄弟关系。 - 为什么链条最后是
step=4。 新建岔口为 1,其后依次执行受理、校验、回执三步,每步加一,因此为1 → 2 → 3 → 4。 - 「fork 之后 step 一定是几」没有固定答案。 新建岔口的
step取决于被点名那份的parent。实测三种 fork 点:
| 被点名的快照 | 其 step |
新建岔口的 parent |
新建岔口的 step |
|---|---|---|---|
step=0 那份 |
0 | step=-1 那份 |
1 |
step=1 那份 |
1 | step=0 那份 |
2 |
step=2 那份(终态) |
2 | step=1 那份 |
3 |
需要特别指出:新建岔口的 parent 始终是「被点名那份的 parent」,而不是被点名那份本身。 这正是新岔口与被点名快照并列(同父兄弟)而非挂在其下的原因,也解释了为何两者的 step 相同。
三、场景二:退款需要人工确认
3.1 现场
本场景的图结构与上一章相同,区别在于 回执 节点内部包含一行 interrupt("退款金额 399 元,确认吗?")。执行到该行时图会停住,此时链为四份快照(step=-1 到 2)。
停住的那份快照具有三个特征:
| 读到的内容 | 含义 | 对参数的结论 |
|---|---|---|
next = ('回执',) |
图未执行完毕,停在 回执 |
无需指定 checkpoint_id |
挂起的任务上是 interrupts,含一个 id 与提示语 |
正在等待答复,答复尚未产生 | 必须使用 Command(resume={该 id: 答复}) |
values 中没有答复 |
答复不是状态数据,需交回给 interrupt() |
无需提供 input |
该场景与报错场景在快照形状上几乎一致,唯一区别在于挂起的任务上携带的是 interrupts 而不是 error。恢复方式也因此不同:报错使用 invoke(None),中断必须使用 Command。
python
st = app.get_state(config)
中断id = st.interrupts[0].id # 从挂起的任务上读取
app.invoke(
Command(resume={中断id: "同意"}), # 按 id 送回答复
{"configurable": {"thread_id": "refund-1"}},
)
3.2 两种恢复写法的完整对照
写法 D:Command(resume={中断id: 答复}),正确写法

回执 节点会从函数第一行重新执行 ,走到 interrupt() 时本次取得答复「同意」,随后继续向下执行。因此节点内 interrupt() 之前的代码(读取配置、扣减库存、外部请求)都会再次执行,这是「副作用需要幂等」这一要求的由来。
写法 E:只带 checkpoint_id,无法完成恢复

该写法同样会重新执行节点,但本次没有任何答复 ,执行到 interrupt() 仍会停住,未能向前推进,并在链上多留一份内容相同的复制品。
结论:checkpoint_id 只能表达「从哪一份快照往下执行」,无法表达「中断接收什么值」。 若要回答当前中断,只有 Command(resume=...) 一条路;带 checkpoint_id 的 replay 适用于「回到中断之前某一步重新决定」。
四、容易踩的坑
- 认为
invoke带checkpoint_id等同于 fork。 该写法会把新输入并入旧快照,再从START重跑整图(§2.2 写法 B)。fork 需先用update_state修改状态,再invoke(None)。 - 认为
input会替换状态。 它交由该字段的 reducer 合并。示例中的log使用累加 reducer,因此新数据追加在后;换用其他 reducer,合并方式随之改变,input本身无法决定。 - 认为
invoke(None)一定会重跑。 若图已执行到终点(next为空元组),该调用不产生任何效果,状态保持不变。 - 认为重跑后的状态会与原来不同。 若节点每次执行的结果一致,重跑后状态也一致(§2.2 写法 C 的最终状态与 A 相同)。判断是否重跑应看副作用(自增编号、时间戳、调用次数),而非状态本身。
- 手工构造
config。 仅有checkpoint_id不足够,还需thread_id与checkpoint_ns,缺失会抛出KeyError: 'checkpoint_ns'。稳妥做法是直接取get_state_history()返回记录的.config。 - 认为
update_state修改后会自行往下执行。 它只修改状态,不执行图;且其返回值才是新支线的位置,沿用旧 config 继续执行会使修改失效。 - 认为出错时检查点停在「出错节点的上一步」。 它停在一份写着「下一步执行出错节点」的快照 上:
values为出错节点开始执行前的状态,next为出错节点,且未为这次失败的执行另立快照(§2.1)。 - 认为报错的节点可能写了一半状态。 实测中该节点未写入任何状态。但这是「先抛异常、后返回」写法的结果,并非框架保证 :节点一旦
return,写入即生效。应把副作用放在可重试的位置,不应依赖「抛异常自动回滚」。 - 认为中断与报错是同一类问题。 快照形状接近(
next均指向挂起的节点),但挂起的任务上分别是error与interrupts(§3.1)。恢复方式因此不同。 - 认为中断恢复是「从
interrupt()的下一行继续执行」。 实际是整个节点函数从头重新执行(§3.2),因此interrupt()之前的副作用会再次发生。 - 试图用
checkpoint_id恢复中断。 实测无法完成:节点会重新执行但无人提供答复,于是再次中断,并多出一份source=fork的快照(§3.2 写法 E)。 - 认为
Command(resume=...)的 id 不匹配会报错。 不会。仅答复部分中断时,未答复的继续挂起;给出不存在的 id 等同于未做任何事。
五、小结
- 一次
invoke的执行路径由三个开关 决定:input管「是否写入数据、是否回到START」,checkpoint_id管「以哪份快照为基准」,Command(resume=...)管「挂起节点接收什么值」。 - 判断本次应当传什么,只需查看最新快照的
next:非空表示图未执行完毕,为空表示已到终点。 - 一个线程只可能处于三种状态:停住未完成 (
next非空)、已执行完毕 (next为空)、全新线程 (仅有一份step=-1的输入快照)。 - 三种重试诉求对应三种写法:继续执行 →
invoke(None, config);修改参数 →invoke(新input, config);回到更早 →invoke(None, 旧快照.config)。 - 停住只有两种原因:报错或中断。 两者快照形状接近,区别在挂起的任务上携带
error还是interrupts。 - 报错重试:
invoke(None)的含义是「把next指向的节点再执行一次」,之前的节点不会重跑,新增快照均为普通loop。 - 中断恢复:必须使用
Command(resume=...);节点整段重新执行,interrupt()本次才取得答复;多个中断按 id 逐一答复,未答复的继续挂起。 - 两种中断在恢复时都会整段重新执行该节点,因此「重试接口幂等」与「中断前的副作用幂等」属于同一类要求。
- Replay 是从某份快照的
next继续执行;Fork 是在 Replay 之前用update_state修改该快照。 两者区别仅在于是否先修改状态。 step= 该快照的parent的step+ 1 ,它沿parent链递增,因此同一数字会在两条支线上各出现一次;分支不会覆盖历史,两条支线并存。- 判断是否真的发生了重跑,应看副作用(自增编号、时间戳、调用次数),而非最终状态。