对LangGraph的invoke的一些理解

一、一次 invoke 的三个开关

一次 graph.invoke() 的调用形式不复杂,但真正决定执行路径的只有三项:

flowchart LR %% 踩坑:节点标签只放短句,长句会把宽度顶过 800px 上限 %% 踩坑:节点标签内不要出现半角双引号,部分平台会转义导致整图不渲染 A([invoke]) --> B[input] B --> B1[有值<br/>写入数据 回到 START] B --> B2[None<br/>不写数据 从 next 继续] A --> C[checkpoint_id] C --> C1[指定<br/>以该快照为基准] C --> C2[不指定<br/>以最新快照为基准] A --> D[Command resume] D --> D1[仅作用于挂起的节点<br/>作为 interrupt 的返回值]

三项各司其职,互不替代:

开关 作用范围 说明
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 复制品)
flowchart TB %% 踩坑:三种写法若画在同一张图里箭头会交叉,改用三行并列的文字块 %% 判据必须写进图里,否则读者只看最终状态无法区分 A 与 C A[A 继续执行<br/>invoke None<br/>受理 完成 出现 1 次] --> A2[链长 2 份] B[B 修改参数<br/>invoke 新输入<br/>受理 完成 出现 2 次] --> B2[链长 5 份] C[C 回到更早<br/>invoke None 加旧 id<br/>多出一份 fork] --> C2[两条支线并存] style A fill:#e6f4ea,stroke:#137333 style B fill:#fce8e6,stroke:#c5221f style C fill:#fce8e6,stroke:#c5221f

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

由此可以解释三个常见疑问:

  1. 为什么新建岔口是 step=1 而不是 0。 因为它的 parent 是 ...41523f(step=0),所以 0 + 1 = 1。同时,原链中 ...41fdff 的 parent 也是 ...41523f,两者的 step 相同,说明它们是兄弟关系。
  2. 为什么链条最后是 step=4。 新建岔口为 1,其后依次执行 受理、校验、回执 三步,每步加一,因此为 1 → 2 → 3 → 4。
  3. 「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 适用于「回到中断之前某一步重新决定」。


四、容易踩的坑

  1. 认为 invoke 带 checkpoint_id 等同于 fork。 该写法会把新输入并入旧快照,再从 START 重跑整图(§2.2 写法 B)。fork 需先用 update_state 修改状态,再 invoke(None)。
  2. 认为 input 会替换状态。 它交由该字段的 reducer 合并。示例中的 log 使用累加 reducer,因此新数据追加在后;换用其他 reducer,合并方式随之改变,input 本身无法决定。
  3. 认为 invoke(None) 一定会重跑。 若图已执行到终点(next 为空元组),该调用不产生任何效果,状态保持不变。
  4. 认为重跑后的状态会与原来不同。 若节点每次执行的结果一致,重跑后状态也一致(§2.2 写法 C 的最终状态与 A 相同)。判断是否重跑应看副作用(自增编号、时间戳、调用次数),而非状态本身。
  5. 手工构造 config。 仅有 checkpoint_id 不足够,还需 thread_id 与 checkpoint_ns,缺失会抛出 KeyError: 'checkpoint_ns'。稳妥做法是直接取 get_state_history() 返回记录的 .config。
  6. 认为 update_state 修改后会自行往下执行。 它只修改状态,不执行图;且其返回值才是新支线的位置,沿用旧 config 继续执行会使修改失效。
  7. 认为出错时检查点停在「出错节点的上一步」。 它停在一份写着「下一步执行出错节点」的快照 上:values 为出错节点开始执行前的状态,next 为出错节点,且未为这次失败的执行另立快照(§2.1)。
  8. 认为报错的节点可能写了一半状态。 实测中该节点未写入任何状态。但这是「先抛异常、后返回」写法的结果,并非框架保证 :节点一旦 return,写入即生效。应把副作用放在可重试的位置,不应依赖「抛异常自动回滚」。
  9. 认为中断与报错是同一类问题。 快照形状接近(next 均指向挂起的节点),但挂起的任务上分别是 error 与 interrupts(§3.1)。恢复方式因此不同。
  10. 认为中断恢复是「从 interrupt() 的下一行继续执行」。 实际是整个节点函数从头重新执行(§3.2),因此 interrupt() 之前的副作用会再次发生。
  11. 试图用 checkpoint_id 恢复中断。 实测无法完成:节点会重新执行但无人提供答复,于是再次中断,并多出一份 source=fork 的快照(§3.2 写法 E)。
  12. 认为 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 链递增,因此同一数字会在两条支线上各出现一次;分支不会覆盖历史,两条支线并存。
  • 判断是否真的发生了重跑,应看副作用(自增编号、时间戳、调用次数),而非最终状态。
相关推荐
方方洛2 小时前
ai-agent教程-04-记忆管理
人工智能·llm·agent
Web3&Basketball3 小时前
用 SGLang 决策端点做毫秒级 Agent 路由
python·大模型·agent·强化学习·多模态·推理
️公子3 小时前
决策模型开战:Jev / Decisions API / Strands Decider 的 ClosedSet、置信度闸门与混合 Agent
人工智能·大模型·软件工程·agent·决策模型
七夜zippoe3 小时前
Agent 自动化测试:怎么给“会自己决策“的程序写测试
ai·自动化·agent·测试·openclaw
SuperHeroWu73 小时前
Harness Engineering 实战:让 Coding Agent 持续、可靠地完成工程任务
agent·coding·工程·engineering·harness
liulilittle4 小时前
多智能体编排的三个点
ai·llm·agent·tools·opencode
七夜zippoe5 小时前
第一季·阶段总结:Agent 核心技能栈检查清单与实战自测
网络·ai·agent·核心技能·实战自测
一 铭13 小时前
Pi实战 05:本地模型 · MCP · 安全沙箱篇
人工智能·ai·agent·harness