langgraph教程系列-07-让人介入-人在回路

本文是「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_idCommand(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__ 的结果,里面携带上面的字典;searchevaluate 和后续节点都没有执行。这一点很重要:暂停不是"先返回一个半成品答案",而是一次可恢复的工作流状态。

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 的所有 invokestream 都显式传递配置,避免把暂停和恢复误跑成两条新会话。

五、三种审批决定

本例把恢复值约定为 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_idCommand(resume=...) 交回批准、改写或拒绝决定。

这里要记住三件事:

  • 暂停依赖 checkpoint:没有持久化,就没有可靠恢复。
  • 恢复依赖 thread_id:初始调用和 resume 必须属于同一条 thread。
  • 暂停前保持无副作用:节点可能被重放,外部写入必须幂等。

不过,这个记忆仍局限在一条 thread 内。用户明天开一条新会话,助手还是会忘记他的偏好和今天的研究结论。下一篇,我们把记忆从 thread 里拿出来,交给 Store。

相关推荐
糖果店的幽灵9 小时前
大模型测评DeepEval快速入门-安全与通用指标详解
人工智能·安全·langgraph·大模型测评·deepeval
喜欢的名字被抢了2 天前
langgraph教程系列-06-让流程可暂停 - 持久化与 checkpoint
agent·langgraph
喜欢的名字被抢了2 天前
langgraph教程系列-05-给 agent 装上手脚 - 工具调用
agent·教程·langgraph
行者-全栈开发3 天前
电商工单智能分发 Agent 实战:腾讯混元 Hy3 + WorkBuddy + LangGraph 生产落地
langgraph·workbuddy·混元hy3·agent 办公·工单分发·mcp 工具链·电商中台
喜欢的名字被抢了4 天前
langgraph教程系列-03-状态如何在图里流动-reducer机制
langgraph
喜欢的名字被抢了5 天前
langgraph教程系列-01-为什么需要图-从链到图
教程·langgraph
梦想的颜色5 天前
2026 AI Agent 工程师完整技术图谱|从面试题「什么是本体 Ontology」切入,附精选面试题库
面试·知识图谱·langgraph·aiagent·大模型面试·本体·2026 面试真题
一只小bit5 天前
Agent 动态调控:模型、工具、提示词、输出、流模式
机器学习·langchain·llm·人机交互·langgraph
喜欢的名字被抢了5 天前
langgraph教程系列-02-让流程分叉-条件边与路由
教程·langgraph