
千笔-AIWritePaper · https://www.aiwritepaper.com
把「人审」塞进 Agent,最常见翻车是进程一重启就丢现场,或审批接口返回后图从错误节点重跑。LangGraph 的答案不是另写一套工作流引擎,而是两块拼在一起:Checkpointer 把线程状态落成可恢复快照 ,interrupt() 在节点任意位置动态暂停 ,恢复时用同一个 thread_id 把 Command(resume=...) 的值交回 interrupt() 调用点。本文只复述官方 Persistence、Memory 与 Interrupts 文档里能对照的行为,并落到可核对的最小代码与检查清单。

图:编译挂上持久化 checkpointer → 带 thread_id 运行 → interrupt 暂停并表面 payload → Command(resume) 恢复;底部为官方四条硬规则摘要。
目标说明
读完你应能独立完成五件事:
- 分清 Checkpointer (线程内短期记忆 / 图状态快照)与 Store(跨线程长期键值),人审恢复主要靠前者。
- 在生产编译图时挂上 PostgresSaver / SqliteSaver 等持久化实现,而不是把
InMemorySaver直接上线。 - 在节点里调用
interrupt(payload),用stream_events(..., version="v3")读取stream.interrupted/stream.interrupts,或用invoke看__interrupt__。 - 用相同
thread_id与Command(resume=...)续跑,并理解「恢复时节点会从开头重跑」。 - 对照官方规则:勿裸
try/except吞中断、勿打乱 interrupt 顺序、payload 可序列化、中断前副作用幂等。
规格钉死(来自官方文档):
- Checkpointer 持久化单线程图状态;用途含对话续写、HITL、时间旅行、故障恢复。
- Store 持久化应用自定义数据,跨线程;适合用户偏好与共享知识。
thread_id是游标:复用则续状态,换新值等于新线程空状态。interrupt()暂停执行、写入 checkpoint、无限等待外部输入;恢复值成为该次interrupt()的返回值。- 静态
interrupt_before/interrupt_after适合调试,不推荐作为生产 HITL 主路径。 - Agent Server 场景下,服务端可托管持久化,应用侧仍需理解 thread 与 resume 语义。
适用场景与边界
适合做成可恢复人审
- 发邮件、改库、转账、调用付费 API 前必须人点同意,或改参数后再执行。
- 审改 LLM 草稿、工具参数后再进下游节点。
- 并行分支各自
interrupt,一次用 interrupt id 映射批量 resume。 - 进程会重启、要隔夜审批:状态必须在数据库或文件型 checkpointer 里。
- 需要「时间旅行」:按 checkpoint 查看历史状态,再决定是否从某点续跑。
不该指望 Checkpointer + interrupt 单独搞定
- 跨用户、跨会话的偏好与事实库:用 Store,不是把一切塞进线程 checkpoint。
- 无 checkpointer 的「纯函数图」:
interrupt无法持久等待。 - 把人审当成同步 HTTP 里死等:应把暂停态暴露给 UI,审批回调再 resume。
- 用裸
MemorySaver/InMemorySaver冒充生产:重启即丢。 - 开放式、无法 JSON 化的「把整个 UI 组件塞进 interrupt」:序列化会失败。
风险提示
Postgres 下 thread_id 过长会撞列宽,官方建议控制在 255 字符内,或用 UUID / 哈希。长对话 checkpoint 会膨胀,延迟与存储成本上升,需保留策略或定期清理。子图有独立 checkpoint 命名空间时,父图未必立刻看见子图写入;跨图共享数据优先考虑 Store。不可信来源的图定义同样可能诱导危险工具调用,人审节点不能省略权限模型。
机制:Checkpointer 与 interrupt 如何咬合
Checkpointer:线程级快照
编译时传入 checkpointer:
python
from langgraph.checkpoint.memory import InMemorySaver # 仅开发
# 生产见官方:PostgresSaver / SqliteSaver / Redis / Mongo 等
checkpointer = InMemorySaver()
graph = builder.compile(checkpointer=checkpointer)
config = {"configurable": {"thread_id": "approval-123"}}
每次步进后状态可落盘。人审暂停时,运行时保存当前图状态 ,之后即使进程退出,只要 checkpointer 还在且 thread_id 不变,就能续。官方 Persistence 文档把 Checkpointer 与 Store 对照成「短线程记忆 vs 长跨线程记忆」,HITL 首先钉前者。
生产示例骨架(Postgres,需安装对应包并 setup()):
python
from langgraph.checkpoint.postgres import PostgresSaver
with PostgresSaver.from_conn_string(DB_URI) as checkpointer:
# checkpointer.setup() # 首次
graph = builder.compile(checkpointer=checkpointer)
interrupt:动态断点
python
from langgraph.types import interrupt, Command
def approval_node(state):
decision = interrupt({
"question": "Approve this action?",
"details": state["action_details"],
})
return {"approved": bool(decision)}
发生了什么:执行挂起 → checkpoint 写入 → payload 出现在 stream.interrupts(或 invoke 的 __interrupt__)→ 等待 → Command(resume=...) 把值送回 interrupt()。
推荐驱动方式(官方强调 event streaming):
python
stream = graph.stream_events(inputs, config=config, version="v3")
_ = stream.output
if stream.interrupted:
# 把 stream.interrupts 展示给 UI
resumed = graph.stream_events(
Command(resume=True), config=config, version="v3"
)
final = resumed.output
交互式 HITL 循环可以一边消费 stream.messages 做 token 展示,一边在 paused 时收集人输入再 resume,直到 stream.interrupted 为假。
为何「节点会整段重跑」
恢复不是从 interrupt 下一行字节码续跑,而是重新进入该节点 。因此 interrupt 之前的代码会再执行一遍。这直接导出官方 Rules of interrupts:副作用必须幂等或后移;多个 interrupt 的顺序必须稳定;不能用裸 except 吞掉暂停用的特殊异常。
审批、审改、工具内暂停
常见模式:
- 批准/拒绝 :
interrupt返回布尔或枚举,节点用Command(goto="proceed"|"cancel")路由。 - 审改状态 :
interrupt返回编辑后的文本,写回 state。 - 工具内 interrupt :在
@tool里暂停,审批时可覆盖to/subject/body再真正发送。 - 校验人输入 :每节点只调用一次
interrupt,非法则更新pending_question,条件边绕回;禁止单节点while True多段 interrupt。
与静态断点的区别
| 类型 | 触发 | 用途 |
|---|---|---|
interrupt() |
代码内、可条件 | 生产 HITL、按业务暂停 |
interrupt_before/after |
编译或调用时按节点名 | 调试单步 |
步骤:最小可恢复审批图
- 选持久化 checkpointer ,首次调用
setup()(Postgres 等需迁移)。 - State 只放可序列化字段;审批细节用 dict,不要塞函数对象或处理器实例。
- 审批节点 里先
interrupt,再根据返回值路由或写状态。 - 副作用 :发邮件、插审计日志尽量放在 interrupt 之后,或做成幂等 upsert。
- 驱动循环 :消费
stream_events,未 interrupted 则结束;否则收集人输入再Command(resume=...)。 - 并行多 interrupt :resume 时传
{interrupt_id: value}映射,避免对错问题。 - 观测 :用
graph.get_state(config)/get_state_history核对暂停时的values与next,便于排障。
示意(结构对齐官方 Full example,省略业务细节):
python
def approval_node(state):
decision = interrupt({
"question": "Approve this action?",
"details": state["action_details"],
})
return Command(goto="proceed" if decision else "cancel")
可验证检查清单
| 检查项 | 通过标准 |
|---|---|
| 持久化 | 杀掉进程后,同 thread_id 仍能 resume 到待审状态 |
| 表面 | stream.interrupted is True 且 payload 与传入一致 |
| 续跑 | resume 后 interrupt() 返回值等于 Command(resume=...) |
| 幂等 | 故意多次走 interrupt 前路径,外部副作用不重复脏写 |
| 规则 | 无裸 except 包 interrupt;同节点 interrupt 顺序稳定 |
| 边界 | thread_id 长度合规;开发/生产 checkpointer 类型可区分 |
| 历史 | get_state_history 能看到暂停前后的快照链 |
本地可用 Sqlite 文件型 checkpointer 做冒烟:跑到 interrupt → 退出解释器 → 新进程加载同 DB 与 thread → resume。把通过/失败记成表格,比口头「我觉得可以恢复」更有用。
踩坑
| 踩坑 | 后果 | 改法 |
|---|---|---|
| 生产用 InMemory | 重启丢审 | 换 Postgres/Sqlite/Redis 等 |
try/except Exception 包 interrupt |
暂停异常被吞,图「假继续」 | 只捕业务异常类型 |
interrupt 前 create 非幂等记录 |
每次 resume 复制脏数据 | upsert 或副作用后移 |
| 条件跳过某个 interrupt | resume 索引错位 | 顺序固定或拆节点 |
| 换新 thread_id 当「重试」 | 另起空线程 | 重试必须复用原 id |
| 把 HITL 做成静态 interrupt_before | 难按业务条件暂停 | 改用 interrupt() |
| checkpoint 永不清理 | 存储与延迟恶化 | 保留策略 / 定期删除旧线程 |
resume 时传 Command(update=...) 当输入 |
语义不符官方约定 | 人审续跑用 Command(resume=...) |
生产编排补充:把暂停态交给外部系统
只在笔记本里 invoke 两次,还不算生产。常见编排是:
- API 收到用户请求,创建或复用
thread_id,启动stream_events。 - 若
interrupted,把interruptspayload 写入任务表,状态标为waiting_human,HTTP 立刻返回「待审」。 - 审核员在后台点同意/拒绝/编辑,回调服务用同一
thread_id调Command(resume=...)。 - 续跑完成后再通知用户;失败则保留 checkpoint,禁止「换个 id 重试」装作同一单。
这样人审等待可以是小时或天级,而不用占着工作进程。Checkpointer 此时就是工单系统的状态后端之一。注意:UI 展示的文案应来自你传入 interrupt 的 JSON,而不是事后拼出来的模糊提示,否则审核员看不到模型当时真正要做什么。
并发方面,同一 thread_id 上应串行化 resume,避免两个审核员同时提交导致状态竞争。并行节点上的多个 interrupt,则按官方所述用 id→值映射一次性恢复,减少半恢复态。
和「自己写个审批表」比,图状态机赢在哪
自己做审批表也能存「待同意」,但很难自动对齐「图跑到哪一节点、通道版本、待执行任务」这些运行时细节。LangGraph 把这些放进 checkpoint 元数据,恢复时运行时知道 next 是谁。对 Agent 这种分支多、工具多的系统,用图状态机比把审批逻辑散落在各处 if 更不容易漏边。代价是你必须遵守 interrupt 规则,并把序列化边界划清。
若业务只需一次性人工确认且无复杂分支,轻量工单可能更简单;一旦出现「改参数再跑工具」「多专家会签」「失败后从中段续」,Checkpointer + interrupt 的收益会明显高于散装 if。
小抄:上线前十问
- 生产 checkpointer 类型是什么,连接串是否在密钥管理里?
setup()/ 迁移是否纳入发布流程?- 每个业务单的
thread_id如何生成,是否可能超过长度限制? - 暂停态如何暴露给前端,超时如何提醒审核员?
- resume 接口是否鉴权到「只能审自己的单」?
- interrupt payload 是否避免泄露敏感全文到日志?
- 节点内 interrupt 前是否还有非幂等写?
- 并行 interrupt 是否测试过 id 映射 resume?
- checkpoint 保留多久,谁有权
delete_thread? - 故障演练:杀进程后能否从待审态恢复?
十问都有书面答案,再宣布「我们支持人审」不迟。
总结
生产级人审不是「多弹一个确认框」,而是可恢复状态机 :Checkpointer 记住线程快照,interrupt() 在业务点暂停,同 thread_id + Command(resume=...) 把人的决定注回图。先把持久化与四条 interrupt 规则做对,再谈 UI 与并行审批。文档入口:LangGraph Persistence、Human-in-the-loop(Interrupts)、Memory。把检查清单跑通一次,比堆更多节点名更接近「生产级」。
把人审做成可恢复状态机之后,产品经理与安全同学才有共同语言:暂停态可查询,恢复动作可审计,线程标识可追踪。这比在演示视频里「人工点一下继续」更接近可运营系统。
选工具时也可以把本文当验收单:候选框架若声称支持 HITL,就问三句。第一,暂停态是否写入可独立于进程的存储。第二,恢复是否保证业务 id 不变。第三,节点重入时如何避免重复副作用。三句都答得清,再谈可视化与模板。答不清,多半只是演示级确认框。