本文是「LangGraph 教程系列」第 7 篇。写作时基于 langgraph 1.2.10、langchain 1.3.14、langchain-openai 1.4.1、Python 3.12+。配套代码仓库 https://github.com/wxj006007/deep-research-assistant ,本篇对应 tag
v2.1。
上一篇给研究助手接上了 checkpoint。现在每一步的状态都有记录,能够回放,也能按 thread 隔离会话。但它仍有一个很实际的问题:它会自己一路跑完。
规划出一条检索词,就立刻搜索;评估说资料不够,就自己再搜一轮。可研究任务里有些决定不该由模型独自做。比如第一条查询跑偏了、某个问题本来就不该继续、或者用户想亲自改写检索方向。
有了 checkpoint,图终于具备了停下来的基础。这一篇就让它在关键决策点停下来,等人。
一、v2 撞的墙:能保存,不等于能协作
checkpoint 保存的是 state 快照。它解决的是"刚才发生了什么"和"进程停下来后怎样恢复"。但单靠保存不能回答另一个问题:恢复时谁来决定下一步?
如果把所有选择都写进条件边,图依旧是全自动的;如果把人工审批写在图外,应用层又得自己维护半截流程、当前状态和恢复逻辑。真正的"人在回路"需要把暂停点放进图本身。
LangGraph 的 interrupt() 正是这个暂停点。节点执行到它时,会把当前执行挂起、依赖 checkpointer 保存状态,并把一个结构化请求交给调用方。调用方不是重新 invoke 一遍初始输入,而是带着同一个 thread_id 用 Command(resume=...) 交回人工决定。
二、v2.1 的图结构
这一版让人审核每轮的检索计划。首次查询先审;资料不足、准备下一轮查询时,也会再次回到同一个审核节点。
#mermaid-svg-2QAQoQrATJL3C9kg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-2QAQoQrATJL3C9kg .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-2QAQoQrATJL3C9kg .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-2QAQoQrATJL3C9kg .error-icon{fill:#552222;}#mermaid-svg-2QAQoQrATJL3C9kg .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-2QAQoQrATJL3C9kg .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-2QAQoQrATJL3C9kg .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-2QAQoQrATJL3C9kg .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-2QAQoQrATJL3C9kg .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-2QAQoQrATJL3C9kg .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-2QAQoQrATJL3C9kg .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-2QAQoQrATJL3C9kg .marker{fill:#333333;stroke:#333333;}#mermaid-svg-2QAQoQrATJL3C9kg .marker.cross{stroke:#333333;}#mermaid-svg-2QAQoQrATJL3C9kg svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-2QAQoQrATJL3C9kg p{margin:0;}#mermaid-svg-2QAQoQrATJL3C9kg .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-2QAQoQrATJL3C9kg .cluster-label text{fill:#333;}#mermaid-svg-2QAQoQrATJL3C9kg .cluster-label span{color:#333;}#mermaid-svg-2QAQoQrATJL3C9kg .cluster-label span p{background-color:transparent;}#mermaid-svg-2QAQoQrATJL3C9kg .label text,#mermaid-svg-2QAQoQrATJL3C9kg span{fill:#333;color:#333;}#mermaid-svg-2QAQoQrATJL3C9kg .node rect,#mermaid-svg-2QAQoQrATJL3C9kg .node circle,#mermaid-svg-2QAQoQrATJL3C9kg .node ellipse,#mermaid-svg-2QAQoQrATJL3C9kg .node polygon,#mermaid-svg-2QAQoQrATJL3C9kg .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-2QAQoQrATJL3C9kg .rough-node .label text,#mermaid-svg-2QAQoQrATJL3C9kg .node .label text,#mermaid-svg-2QAQoQrATJL3C9kg .image-shape .label,#mermaid-svg-2QAQoQrATJL3C9kg .icon-shape .label{text-anchor:middle;}#mermaid-svg-2QAQoQrATJL3C9kg .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-2QAQoQrATJL3C9kg .rough-node .label,#mermaid-svg-2QAQoQrATJL3C9kg .node .label,#mermaid-svg-2QAQoQrATJL3C9kg .image-shape .label,#mermaid-svg-2QAQoQrATJL3C9kg .icon-shape .label{text-align:center;}#mermaid-svg-2QAQoQrATJL3C9kg .node.clickable{cursor:pointer;}#mermaid-svg-2QAQoQrATJL3C9kg .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-2QAQoQrATJL3C9kg .arrowheadPath{fill:#333333;}#mermaid-svg-2QAQoQrATJL3C9kg .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-2QAQoQrATJL3C9kg .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-2QAQoQrATJL3C9kg .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-2QAQoQrATJL3C9kg .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-2QAQoQrATJL3C9kg .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-2QAQoQrATJL3C9kg .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-2QAQoQrATJL3C9kg .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-2QAQoQrATJL3C9kg .cluster text{fill:#333;}#mermaid-svg-2QAQoQrATJL3C9kg .cluster span{color:#333;}#mermaid-svg-2QAQoQrATJL3C9kg div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-2QAQoQrATJL3C9kg .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-2QAQoQrATJL3C9kg rect.text{fill:none;stroke-width:0;}#mermaid-svg-2QAQoQrATJL3C9kg .icon-shape,#mermaid-svg-2QAQoQrATJL3C9kg .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-2QAQoQrATJL3C9kg .icon-shape p,#mermaid-svg-2QAQoQrATJL3C9kg .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-2QAQoQrATJL3C9kg .icon-shape .label rect,#mermaid-svg-2QAQoQrATJL3C9kg .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-2QAQoQrATJL3C9kg .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-2QAQoQrATJL3C9kg .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-2QAQoQrATJL3C9kg :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 批准或改写
拒绝
继续
完成
START
规划查询
人工审核检索计划
执行搜索
取消研究
评估资料
综合回答
END
和第6篇相比,多出的不是一套 Web 表单,而是一个普通节点 review。前端、CLI 或企业审批系统只需要理解它抛出的 payload,再把决定送回来;图仍然负责状态和流转。
三、暂停:interrupt() 不是异常,也不是最终结果
审批节点先准备一个给人看的 payload,然后调用 interrupt():
python
from langgraph.types import interrupt
def review_plan_node(state: HitlResearchState) -> dict:
decision = interrupt(
{
"type": "research_plan_review",
"question": state["question"],
"proposed_query": state["current_query"],
"round": state.get("round", 0) + 1,
}
)
# 只有恢复后才会运行到这里
...
第一次运行时,decision 还没有值。图会返回包含 __interrupt__ 的结果,里面携带上面的字典;search、evaluate 和后续节点都没有执行。这一点很重要:暂停不是"先返回一个半成品答案",而是一次可恢复的工作流状态。
payload 用字典而不是一段提示语,有两个好处:调用方能稳定地按 type 渲染审批界面,服务端也不必从自然语言里猜出用户改了什么。
四、恢复:同一条 thread,交回一个 Command
先用明确的 thread_id 发起任务:
python
thread_config = {"configurable": {"thread_id": "research-42"}}
paused = graph.invoke(
{"question": "LangGraph 的 interrupt 和 checkpoint 有什么关系?"},
config=thread_config,
)
assert "__interrupt__" in paused
第6篇已经介绍过 thread_id,这里它变成了正确恢复的前提。首次 invoke 和恢复 invoke 都必须传入同一份配置:
python
from langgraph.types import Command
result = graph.invoke(
Command(resume={"action": "approve"}),
config=thread_config,
)
Command(resume=...) 表示"把这个值作为刚才那个 interrupt() 的返回值"。它不是新的用户问题,因此不要再把初始 question 传一遍;图会从 checkpoint 中找到暂停点和当时的 state。
第6篇的演示代码创建了 thread_config,但部分 stream() 调用没有传入它。v2.1 的所有 invoke 和 stream 都显式传递配置,避免把暂停和恢复误跑成两条新会话。
五、三种审批决定
本例把恢复值约定为 action 加可选 query。这是应用和图之间的小协议:清晰、可校验,也容易替换成表单提交。
5.1 批准原计划
python
Command(resume={"action": "approve"})
节点返回 review_status="approved",图进入 search。原先的 current_query 不变。
5.2 改写查询
python
Command(
resume={
"action": "edit",
"query": "LangGraph interrupt Command resume 审批流程",
}
)
审核节点验证新查询非空,再更新 current_query,随后进入搜索节点。因此 executed_queries 记录的是人改过后的查询,而不是模型最初的建议。
5.3 拒绝并取消
python
Command(resume={"action": "reject"})
节点把 cancelled=True 写入 state,条件边改走 cancel 节点。它返回一条明确的取消说明并结束图,不再访问搜索工具。取消也是一种正常业务结果,不应该依靠抛异常来表达。
对应的条件边很短:
python
def route_after_review(state: HitlResearchState) -> Literal["search", "cancel"]:
return "cancel" if state.get("cancelled") else "search"
六、一个容易踩的坑:interrupt 前的代码会重放
恢复时,LangGraph 可能从该节点开头重新执行,直到再次到达 interrupt();这能保证状态一致,但也意味着 interrupt 前的副作用可能重复发生。
所以 review_plan_node 在暂停前只做两件事:读取 state、构造 payload。不要在这里发邮件、扣费、写数据库或调用一次性外部 API。如果确实要写入外部系统,应把操作放到恢复后,并以业务 id 做幂等保护。
这条原则也解释了为什么审批决定由调用方提供:模型图负责暂停与状态恢复,业务系统负责安全地保存"谁在什么时候批准了什么"。
七、跑起来
代码在 src/v2_1_hitl.py:
bash
python -m src.v2_1_hitl
示例依次演示批准、改写和拒绝。运行时会先打印 __interrupt__,之后用同一 thread 恢复:
text
【暂停】 (Interrupt(value={'type': 'research_plan_review', ...}),)
【批准后完成】 ...
【改写后完成】 ['LangGraph interrupt Command resume 审批流程', ...]
【拒绝】 研究已按人工要求取消,未执行后续检索。
✓ 三个 thread 的 checkpoint 独立保存。
如果想检查暂停时确实写入了 checkpoint,可以调用:
python
history = list(graph.get_state_history(thread_config))
print(len(history))
不同 thread_id 得到的是独立历史;同一个 thread_id 才能恢复到同一次审批。
八、本篇小结
v2 有了时间线,却还是一台自动机;v2.1 的解法是在检索计划节点加入 interrupt()。调用方得到结构化审批请求,再以同一 thread_id 和 Command(resume=...) 交回批准、改写或拒绝决定。
这里要记住三件事:
- 暂停依赖 checkpoint:没有持久化,就没有可靠恢复。
- 恢复依赖 thread_id:初始调用和 resume 必须属于同一条 thread。
- 暂停前保持无副作用:节点可能被重放,外部写入必须幂等。
不过,这个记忆仍局限在一条 thread 内。用户明天开一条新会话,助手还是会忘记他的偏好和今天的研究结论。下一篇,我们把记忆从 thread 里拿出来,交给 Store。