【高级】SmartWriter v0.15:人工审校与协作 --- interrupt() API、Command 动态路由与审批链路深度实战
前言
- 核心痛点:当 AI Agent 拥有自主决策能力后,如何在关键节点引入人类判断?如何在不中断整体工作流的前提下实现"人机协作"?本文深入解决 LangGraph 人机协同的核心问题
- 前置知识:需要掌握 LangGraph StateGraph 基础、State 类型系统、条件边与节点编排(本系列 v0.11、v0.12)
- 系列阶段:高级篇 第 5 篇(总第 15 篇)
- 收获能力:读完可掌握 interrupt() API 原理与实战、Command 动态路由机制、断点续传与状态恢复、多种人机协作模式的设计与落地
目录
- 一、技术背景与演进逻辑
- 二、核心原理深度解析
- 2.1 人机协作的本质:为什么 Agent 需要人类介入
- 2.2 LangGraph 的 HITL 架构:interrupt + Command + Checkpointer 三位一体
- 2.3 interrupt() 的底层机制:异常驱动的执行暂停
- 三、核心模块详解
- 3.1 interrupt() API:中断时机与 payload 设计
- 3.2 Command 对象:动态路由与状态更新的统一入口
- 3.3 Checkpointer 与 thread_id:断点续传的基石
- 3.4 并行中断与多中断处理
- 四、技术优缺点与适用场景
- 五、实战落地
- 5.1 SmartWriter 人工审校状态图完整实现
- 5.2 三种人机协作模式实战
- 5.3 工具级中断:发布前审批链路
- 5.4 生产避坑经验
- 六、全文总结
一、技术背景与演进逻辑
1.1 从全自动到人机协作:Agent 发展的必然阶段
在本系列前四篇高级文章中,我们为 SmartWriter 构建了完整的 Agent 化引擎:
| 版本 | 核心能力 | 自主程度 |
|---|---|---|
| v0.11 | StateGraph 状态类型系统 | 全自动 |
| v0.12 | Send API 并行扇出与 Reducers | 全自动 |
| v0.13 | Think-Act-Observe 循环 | 全自动 |
| v0.14 | 多策略 Skill 注册调度 | 全自动 |
| v0.15 | 人工审校与协作 | 半自动 |
一个关键问题浮出水面:当 Agent 越来越自主,谁来保证质量?
在实际生产中,纯全自动的 Agent 系统面临三大挑战:
- 质量失控风险:LLM 生成的内容可能存在事实错误、风格偏差、敏感信息泄露,全自动发布后果严重
- 信任缺失问题:用户对"黑盒"决策缺乏信任,尤其是涉及对外发布的场景
- 灵活性不足:有些决策本质上需要人类判断(如品牌调性、法律合规),Agent 无法独立完成
因此,"人机协作"不是退步,而是 Agent 系统走向成熟的标志。
1.2 传统方案的缺陷
在 LangGraph 出现之前,实现人机协作通常有以下方案:
方案一:外部轮询
text
[Agent 生成] → [写入数据库] → [轮询检查审批状态] → [继续执行]
缺陷:轮询浪费资源、延迟高、状态同步复杂。
方案二:回调中断
text
[Agent 执行] → [回调通知前端] → [等待前端回调] → [恢复执行]
缺陷:需要自建回调服务、错误处理困难、无法跨进程持久化。
方案三:两阶段提交
text
[阶段一:生成待审批内容] → [人工审批] → [阶段二:基于审批结果继续]
缺陷:需要拆分为两个独立流程,状态管理复杂,无法表达"从断点继续"。
这三种方案的共同缺陷是:缺乏统一的执行暂停-恢复原语。开发者需要自行实现状态保存、断点定位、执行恢复等底层逻辑,代码复杂度高且容易出错。
1.3 LangGraph 的解法:原生 interrupt() + Command
LangGraph 从架构层面解决了这个问题,提供了三个核心原语:
interrupt()函数:在节点任意位置暂停执行,将控制权交给人类Command对象:恢复执行时传递人类决策,同时支持动态路由Checkpointer:自动保存/恢复图状态,实现透明的断点续传
这三者构成了完整的"人机协作"基础设施,开发者只需关注业务逻辑,无需关心底层的状态管理和执行恢复。
二、核心原理深度解析
2.1 人机协作的本质:为什么 Agent 需要人类介入
人机协作的核心思想可以用一句话概括:让 Agent 做它擅长的(生成、分析、检索),让人做人擅长的(判断、决策、审批)。
从控制论的角度看,人机协作是一个带外部输入的闭环控制系统:
text
[Agent 执行]
│
├──→ [生成内容/分析结果]
│
↓
[中断点:等待人类输入]
│
├──→ [通过] → [继续执行/发布]
│
├──→ [驳回] → [终止/回退]
│
└──→ [修改] → [更新状态后继续]
这个闭环的关键在于:中断点是确定性的,但中断后的走向是动态的 。这就是 LangGraph interrupt() + Command 设计的哲学基础。
2.2 LangGraph 的 HITL 架构:interrupt + Command + Checkpointer 三位一体
LangGraph 的 Human-in-the-Loop(HITL)架构由三个核心组件协同工作:
text
[HITL 架构]
│
├── interrupt() ─── 执行层:暂停图执行,返回 payload 给调用者
│ │
│ ├── 在节点函数内任意位置调用
│ ├── 接受 JSON-serializable 的 payload
│ └── 通过特殊异常机制实现暂停
│
├── Command ──────── 控制层:恢复执行 + 动态路由 + 状态更新
│ │
│ ├── Command(resume=value) 恢复执行
│ ├── Command(goto=node) 动态路由
│ └── Command(update={...}) 状态更新
│
└── Checkpointer ── 持久层:保存/恢复图状态快照
│
├── InMemorySaver:开发测试
├── SqliteSaver:单机生产
└── PostgresSaver:分布式生产
三者的协作流程:
text
[首次执行]
│
├── 1. 图从 START 开始执行
├── 2. 执行到 interrupt() 调用
├── 3. Checkpointer 保存当前状态快照
├── 4. interrupt() 的 payload 返回给调用者
└── 5. 图执行暂停,等待恢复
[恢复执行]
│
├── 1. 调用者传入 Command(resume=value)
├── 2. Checkpointer 加载上次保存的状态快照
├── 3. 从 interrupt() 调用所在节点的开头重新执行
├── 4. interrupt() 返回 Command 中的 resume 值
└── 5. 节点继续执行后续逻辑
2.3 interrupt() 的底层机制:异常驱动的执行暂停
interrupt() 的实现原理非常精巧:它通过抛出一个特殊异常来暂停执行。
text
[节点函数执行]
│
├── 代码行 1 ──→ 正常执行
├── 代码行 2 ──→ 正常执行
├── interrupt(payload) ──→ 抛出 GraphInterrupt 异常
│ │
│ ├── 异常向上传播
│ ├── LangGraph 运行时捕获异常
│ ├── 调用 Checkpointer 保存状态
│ └── 将 payload 封装为 Interrupt 对象返回
│
├── 代码行 3 ──→ 不执行(已被中断)
└── return ────→ 不执行
这个设计有两个重要的推论:
推论一:resume 时节点从头重新执行
由于异常中断了节点的执行,恢复时无法从异常点继续(Python 不支持从任意异常点恢复执行)。因此 LangGraph 的做法是:从节点函数的开头重新执行 ,但这次 interrupt() 调用会直接返回 resume 值,不再抛出异常。
text
[首次执行] [恢复执行]
│ │
├── line 1 ──→ 执行 ├── line 1 ──→ 再次执行
├── line 2 ──→ 执行 ├── line 2 ──→ 再次执行
├── interrupt() ──→ 暂停 ├── interrupt() ──→ 返回 resume 值
├── line 3 ──→ 未执行 ├── line 3 ──→ 执行
└── return ──→ 未执行 └── return ──→ 执行
推论二:interrupt 前的副作用会被重复执行
因为 resume 时节点从头执行,interrupt() 之前的代码(包括副作用操作)会再次运行。因此,interrupt() 之前的副作用必须是幂等的。
三、核心模块详解
3.1 interrupt() API:中断时机与 payload 设计
3.1.1 基本用法
interrupt() 函数接受一个 JSON-serializable 的值作为 payload,暂停执行并将其返回给调用者:
python
from langgraph.types import interrupt
def approval_node(state: WriterState):
# 暂停执行,将草稿内容发送给审校者
decision = interrupt({
"action": "review_draft",
"draft": state["draft"],
"word_count": len(state["draft"]),
"message": "请审校以下写作草稿,确认发布或提出修改意见"
})
# resume 后 decision 获得人类的决策
return {"approval_status": decision}
3.1.2 Payload 设计最佳实践
payload 是传递给人类的信息,设计得好坏直接影响用户体验:
| 设计原则 | 说明 | 示例 |
|---|---|---|
| 包含上下文 | 让审校者有足够的信息做决策 | {"draft": ..., "outline": ..., "references": ...} |
| 明确操作选项 | 告诉审校者可以做什么 | {"options": ["approve", "reject", "edit"]} |
| 结构化数据 | 便于前端渲染 | 使用 dict 而非纯字符串 |
| 避免过大 | payload 会序列化存储,过大会影响性能 | 不要传整个文档库 |
3.1.3 中断时机选择
并非所有节点都需要中断,选择中断时机需要考虑:
text
[SmartWriter 写作流程]
│
├── 研究阶段 ──→ 全自动(无需中断)
│
├── 大纲生成 ──→ 可选中断(让用户确认大纲结构)
│
├── 正文撰写 ──→ 全自动(AI 独立完成)
│
├── 内容审校 ──→ 强制中断(必须人工确认)
│
├── 事实核查 ──→ 条件中断(仅在发现疑点时中断)
│
└── 发布操作 ──→ 强制中断(对外操作必须审批)
3.2 Command 对象:动态路由与状态更新的统一入口
Command 是 LangGraph 中一个强大的控制原语,它有两个截然不同的使用场景:
3.2.1 场景一:作为节点返回值(动态路由 + 状态更新)
节点函数可以返回 Command 对象,同时指定下一个执行节点和状态更新:
python
from langgraph.types import Command
from typing import Literal
def review_node(state: WriterState) -> Command[Literal["publish", "revise", "reject"]]:
decision = interrupt({
"draft": state["draft"],
"message": "审校意见?"
})
if decision["action"] == "approve":
return Command(
goto="publish",
update={"approval_status": "approved", "reviewer": decision["reviewer"]}
)
elif decision["action"] == "edit":
return Command(
goto="revise",
update={"revision_notes": decision["notes"], "draft": decision.get("edited_draft", state["draft"])}
)
else:
return Command(
goto="reject",
update={"approval_status": "rejected", "reject_reason": decision["reason"]}
)
Command 的优势在于:将路由决策和状态更新合二为一,避免了传统方案中"先返回状态更新,再通过条件边路由"的两步操作。
3.2.2 场景二:作为 invoke/stream 的输入(恢复执行)
当用于恢复被 interrupt() 暂停的执行时,Command 只使用 resume 参数:
python
from langgraph.types import Command
# 恢复执行,传递人类的决策
result = graph.invoke(
Command(resume={"action": "approve", "reviewer": "张三"}),
config={"configurable": {"thread_id": "thread-123"}}
)
重要区分 :Command(resume=...) 是唯一用于 invoke()/stream() 输入的 Command 模式。其他参数(update、goto)仅用于从节点函数返回。
3.2.3 Command 的完整参数
| 参数 | 用途 | 使用场景 |
|---|---|---|
resume |
传递 interrupt 的恢复值 | 作为 invoke/stream 的输入 |
goto |
指定下一个执行的节点 | 作为节点函数的返回值 |
update |
更新图状态 | 作为节点函数的返回值 |
graph |
指定操作的目标图(用于子图) | 子图间通信 |
3.3 Checkpointer 与 thread_id:断点续传的基石
3.3.1 Checkpointer 的作用
Checkpointer 是 LangGraph 的状态持久化层,它在以下时机自动工作:
text
[Checkpointer 工作时机]
│
├── 图执行开始时 ──→ 加载最近的状态快照(如果有)
│
├── 每个节点执行完成后 ──→ 保存新的状态快照
│
├── interrupt() 调用时 ──→ 保存中断状态快照
│
└── 图执行结束时 ──→ 保存最终状态快照
3.3.2 thread_id:会话的持久化指针
thread_id 是 Checkpointer 的核心标识,它标识一个独立的执行会话:
python
config = {"configurable": {"thread_id": "smartwriter-session-001"}}
# 首次执行 --- 创建新会话
result1 = graph.invoke({"topic": "AI 安全"}, config=config)
# 恢复执行 --- 继续同一会话
result2 = graph.invoke(Command(resume="approved"), config=config)
# 新会话 --- 使用不同的 thread_id
result3 = graph.invoke({"topic": "Rust 编程"}, config={"configurable": {"thread_id": "smartwriter-session-002"}})
3.3.3 生产级 Checkpointer 选型
| Checkpointer | 存储 | 适用场景 | 持久性 |
|---|---|---|---|
| InMemorySaver | 内存 | 开发测试 | 重启丢失 |
| SqliteSaver | SQLite 文件 | 单机生产 | 文件持久化 |
| PostgresSaver | PostgreSQL | 分布式生产 | 数据库持久化 |
python
# 开发环境
from langgraph.checkpoint.memory import InMemorySaver
checkpointer = InMemorySaver()
# 单机生产环境
from langgraph.checkpoint.sqlite import SqliteSaver
import sqlite3
conn = sqlite3.connect("checkpoints.db", check_same_thread=False)
checkpointer = SqliteSaver(conn)
# 分布式生产环境
from langgraph.checkpoint.postgres import PostgresSaver
checkpointer = PostgresSaver(conn_string="postgresql://user:pass@localhost/db")
3.4 并行中断与多中断处理
当使用 Send API 进行并行扇出时,多个节点可能同时触发 interrupt()。LangGraph 提供了优雅的多中断处理机制。
3.4.1 多中断的触发场景
text
[并行写作扇出]
│
├── 章节 A 写作节点 ──→ interrupt("章节 A 请审校")
│
├── 章节 B 写作节点 ──→ interrupt("章节 B 请审校")
│
└── 章节 C 写作节点 ──→ interrupt("章节 C 请审校")
→ 三个 interrupt 同时触发,需要一次性恢复所有
3.4.2 使用 resume map 恢复多个中断
python
from langgraph.types import Command
# 首次执行,并行节点触发多个 interrupt
stream = graph.stream_events(input_data, config=config, version="v3")
_ = stream.output
# stream.interrupts 包含所有待恢复的中断
print(stream.interrupts)
# > (Interrupt(value='审校章节A', id='uuid-1'), Interrupt(value='审校章节B', id='uuid-2'))
# 构建 resume map:interrupt_id → resume_value
resume_map = {
i.id: {"action": "approve", "notes": f"{i.value} 通过"}
for i in stream.interrupts
}
# 一次性恢复所有中断
resumed = graph.stream_events(Command(resume=resume_map), config=config, version="v3")
print(resumed.output)
四、技术优缺点与适用场景
4.1 技术优势
| 优势 | 说明 |
|---|---|
| 原生支持 | 无需自建暂停/恢复机制,LangGraph 内置完整支持 |
| 状态透明 | Checkpointer 自动保存状态,开发者无需关心序列化 |
| 灵活路由 | Command 支持动态路由,审校结果可决定多种后续路径 |
| 并行友好 | 原生支持多节点同时中断与批量恢复 |
| 流式集成 | 与 stream_events 无缝集成,支持实时展示中断状态 |
| 子图兼容 | 中断可穿透子图,父子图共享中断恢复机制 |
4.2 现存局限
| 局限 | 说明 | 应对策略 |
|---|---|---|
| 节点重执行 | resume 时节点从头执行,interrupt 前的副作用会重复 | 将副作用放在 interrupt 之后,或保证幂等 |
| 不支持 try/except | interrupt 内部抛异常,不能被 catch | 将 interrupt 与错误处理代码分离 |
| 中断顺序固定 | 多个 interrupt 的匹配是索引顺序的,不能条件跳过 | 保持 interrupt 调用顺序一致 |
| payload 限制 | 必须 JSON-serializable,不能传函数/类实例 | 使用简单类型和字典 |
| Checkpointer 依赖 | 必须配置 Checkpointer 才能使用 interrupt | 生产环境使用持久化存储 |
4.3 生产适用场景
场景一:内容发布审批
text
[AI 生成内容] → [人工审校] → [通过/驳回/修改] → [发布/重写]
适用于博客发布、邮件发送、社交媒体发布等对外操作。
场景二:金融交易确认
text
[AI 分析行情] → [生成交易建议] → [人工确认] → [执行交易]
适用于涉及资金操作的场景,必须有人工确认环节。
场景三:敏感操作审批
text
[AI 识别问题] → [生成修复方案] → [人工审批] → [执行修复]
适用于数据库变更、生产环境配置修改等高风险操作。
4.4 禁忌场景
| 场景 | 原因 |
|---|---|
| 实时性要求极高的系统 | interrupt 等待人类响应,延迟不可控 |
| 纯自动化批处理 | 无人值守场景无需人工介入 |
| 高频调用的热路径 | interrupt + Checkpointer 有额外开销 |
五、实战落地
5.1 SmartWriter 人工审校状态图完整实现
下面我们实现 SmartWriter v0.15 的完整人工审校流程。这个状态图包含研究、写作、审校、修改、发布五个阶段,其中审校阶段使用 interrupt() 实现人工介入。
5.1.1 状态定义
python
from typing import TypedDict, Annotated, Literal, Optional
from langgraph.graph import StateGraph, START, END
from langgraph.types import interrupt, Command
from langgraph.checkpoint.memory import InMemorySaver
import operator
class WriterState(TypedDict):
# 输入
topic: str
requirements: str
# 研究阶段
research_notes: str
# 写作阶段
outline: str
draft: str
word_count: int
# 审校阶段
approval_status: Literal["pending", "approved", "rejected", "revision_needed"]
review_notes: str
reviewer: str
revision_count: int
# 发布阶段
published: bool
publish_url: str
# 消息历史
messages: Annotated[list[str], operator.add]
5.1.2 节点实现
python
import os
from langchain_openai import ChatOpenAI
llm = ChatOpenAI(model="gpt-4o", temperature=0.7)
def research_node(state: WriterState) -> dict:
"""研究阶段 --- 全自动"""
response = llm.invoke(
f"请对以下主题进行深度研究,整理关键要点:
"
f"主题:{state['topic']}
"
f"要求:{state['requirements']}"
)
return {
"research_notes": response.content,
"messages": [f"[研究] 完成 {state['topic']} 的资料收集"]
}
def outline_node(state: WriterState) -> dict:
"""大纲生成 --- 全自动"""
response = llm.invoke(
f"基于以下研究资料,生成文章大纲:
{state['research_notes']}
"
f"主题:{state['topic']}
要求:{state['requirements']}"
)
return {
"outline": response.content,
"messages": ["[大纲] 生成文章大纲"]
}
def writing_node(state: WriterState) -> dict:
"""正文撰写 --- 全自动"""
response = llm.invoke(
f"根据以下大纲撰写完整文章:
{state['outline']}
"
f"研究资料:
{state['research_notes']}"
)
draft = response.content
return {
"draft": draft,
"word_count": len(draft),
"revision_count": state.get("revision_count", 0),
"messages": [f"[写作] 完成初稿,字数 {len(draft)}"]
}
def review_node(state: WriterState) -> Command:
"""人工审校节点 --- 使用 interrupt() 实现人机协作"""
# 构建审校 payload,包含足够的上下文信息
review_payload = {
"action": "review_draft",
"topic": state["topic"],
"word_count": state["word_count"],
"revision_count": state.get("revision_count", 0),
"draft_preview": state["draft"][:500] + "..." if len(state["draft"]) > 500 else state["draft"],
"full_draft": state["draft"],
"outline": state["outline"],
"options": ["approve", "reject", "edit"],
"message": "请审校 SmartWriter 生成的写作草稿"
}
# 中断执行,等待人类审校
decision = interrupt(review_payload)
# 根据审校结果动态路由
if decision["action"] == "approve":
return Command(
goto="publish",
update={
"approval_status": "approved",
"reviewer": decision.get("reviewer", "anonymous"),
"review_notes": decision.get("notes", ""),
"messages": [f"[审校] 通过 --- 审校者:{decision.get('reviewer', 'anonymous')}"]
}
)
elif decision["action"] == "edit":
return Command(
goto="revise",
update={
"approval_status": "revision_needed",
"review_notes": decision.get("notes", ""),
"reviewer": decision.get("reviewer", "anonymous"),
"draft": decision.get("edited_draft", state["draft"]),
"messages": [f"[审校] 需要修改 --- 反馈:{decision.get('notes', '')}"]
}
)
else:
return Command(
goto="end_rejected",
update={
"approval_status": "rejected",
"reviewer": decision.get("reviewer", "anonymous"),
"review_notes": decision.get("reason", ""),
"messages": [f"[审校] 驳回 --- 原因:{decision.get('reason', '')}"]
}
)
def revise_node(state: WriterState) -> dict:
"""修改节点 --- 基于审校意见修改"""
response = llm.invoke(
f"请根据审校意见修改以下文章:
"
f"原文:
{state['draft']}
"
f"审校意见:
{state['review_notes']}"
)
return {
"draft": response.content,
"word_count": len(response.content),
"revision_count": state.get("revision_count", 0) + 1,
"approval_status": "pending",
"messages": [f"[修改] 第 {state.get('revision_count', 0) + 1} 次修改完成"]
}
def publish_node(state: WriterState) -> dict:
"""发布节点 --- 全自动(已通过审校)"""
return {
"published": True,
"publish_url": f"https://blog.example.com/{state['topic'].replace(' ', '-')}",
"messages": [f"[发布] 文章已发布"]
}
def end_rejected_node(state: WriterState) -> dict:
"""驳回终止节点"""
return {
"published": False,
"messages": ["[终止] 文章被驳回,流程结束"]
}
5.1.3 状态图编排
python
# 限制修改次数的路由函数
def after_revise_route(state: WriterState) -> str:
"""修改后重新进入审校,但最多修改 3 次"""
if state.get("revision_count", 0) >= 3:
return "publish" # 超过 3 次强制发布
return "review"
# 构建状态图
builder = StateGraph(WriterState)
# 添加节点
builder.add_node("research", research_node)
builder.add_node("outline", outline_node)
builder.add_node("writing", writing_node)
builder.add_node("review", review_node)
builder.add_node("revise", revise_node)
builder.add_node("publish", publish_node)
builder.add_node("end_rejected", end_rejected_node)
# 添加边
builder.add_edge(START, "research")
builder.add_edge("research", "outline")
builder.add_edge("outline", "writing")
builder.add_edge("writing", "review")
# review 节点通过 Command 动态路由,不需要 add_edge
builder.add_conditional_edges("revise", after_revise_route, ["review", "publish"])
builder.add_edge("publish", END)
builder.add_edge("end_rejected", END)
# 编译图
checkpointer = InMemorySaver()
graph = builder.compile(checkpointer=checkpointer)
5.1.4 状态图结构
text
[SmartWriter v0.15 人工审校状态图]
│
├── START
│ │
│ ↓
├── research ──→ [全自动] 收集研究资料
│ │
│ ↓
├── outline ───→ [全自动] 生成文章大纲
│ │
│ ↓
├── writing ───→ [全自动] 撰写文章初稿
│ │
│ ↓
├── review ────→ [人工介入] interrupt() 暂停,等待审校
│ │
│ ├── approve ──→ publish ──→ END
│ │
│ ├── edit ─────→ revise ──→ review(循环,最多 3 次)
│ │
│ └── reject ───→ end_rejected ──→ END
│
└── 审校决策通过 Command(goto=...) 动态路由
5.2 三种人机协作模式实战
根据人类介入的程度,人机协作可以分为三种模式:
5.2.1 模式一:全自动模式(Agent 主导,人类兜底)
text
[Agent 全流程执行]
│
├── 研究 → 大纲 → 写作 → 润色 → 发布
│
└── 仅在异常时通知人类(错误告警)
适用场景:低风险内容、内部文档、草稿保存。
5.2.2 模式二:半自动模式(人机交替,关键节点审批)
text
[Agent 执行] → [人类审批] → [Agent 继续]
│
├── 研究(自动)→ 大纲(自动)→ 写作(自动)
├── 审校(人工)→ 发布(自动)
│
└── 典型模式:interrupt() 在审校节点暂停
适用场景:对外发布内容、客户邮件、社交媒体帖子。
5.2.3 模式三:人工主导模式(Agent 辅助,人类决策)
text
[人类决策] → [Agent 执行] → [人类确认]
│
├── 人类指定主题 → Agent 研究
├── 人类确认大纲 → Agent 写作
├── 人类审校修改 → Agent 润色
└── 人类确认发布 → Agent 发布
适用场景:高风险内容、法律文件、公开声明。
5.2.4 模式选型决策树
text
[选择人机协作模式]
│
├── 内容风险等级?
│ │
│ ├── 低 ──→ 全自动模式
│ │
│ ├── 中 ──→ 半自动模式
│ │
│ └── 高 ──→ 人工主导模式
│
├── 发布渠道?
│ │
│ ├── 内部 ──→ 全自动模式
│ │
│ └── 外部 ──→ 至少半自动模式
│
└── 错误容忍度?
│
├── 可修复 ──→ 全自动模式
│
└── 不可修复 ──→ 人工主导模式
5.3 工具级中断:发布前审批链路
除了在节点级别使用 interrupt(),还可以在工具函数内部使用中断。这使得审批逻辑与工具本身绑定,可复用性更强。
5.3.1 带审批的发布工具
python
from langchain.tools import tool
from langgraph.types import interrupt
@tool
def publish_article(title: str, content: str, platform: str) -> str:
"""发布文章到指定平台(带人工审批)"""
# 在执行发布前中断,等待人工确认
approval = interrupt({
"action": "publish_approval",
"title": title,
"content_length": len(content),
"platform": platform,
"content_preview": content[:200] + "...",
"message": f"确认将文章「{title}」发布到 {platform}?"
})
if approval.get("action") == "approve":
# 实际发布逻辑
return f"文章「{title}」已成功发布到 {platform}"
elif approval.get("action") == "edit_title":
# 允许修改标题后发布
new_title = approval.get("new_title", title)
return f"文章「{new_title}」已成功发布到 {platform}"
else:
return "发布已取消"
@tool
def send_notification(to: str, subject: str, body: str) -> str:
"""发送通知(带人工审批)"""
approval = interrupt({
"action": "notification_approval",
"to": to,
"subject": subject,
"body_preview": body[:100] + "...",
"message": f"确认发送通知给 {to}?"
})
if approval.get("approved", False):
return f"通知已发送给 {to}"
return "通知发送已取消"
5.3.2 工具级中断的优势
text
[工具级中断 vs 节点级中断]
│
├── 工具级中断
│ │
│ ├── 审批逻辑与工具绑定,可复用
│ ├── LLM 自然调用工具,中断自动触发
│ ├── 不同工具可有不同的审批策略
│ └── 适合:多工具协作、MCP 工具链
│
└── 节点级中断
│
├── 审批逻辑在节点层面,集中管理
├── 中断点明确,流程清晰
├── 适合:线性流程、单工具场景
└── 与 Command 动态路由配合更灵活
5.4 生产避坑经验
5.4.1 坑一:interrupt 前的副作用重复执行
问题描述:resume 时节点从头执行,如果 interrupt 前有写数据库操作,会产生重复记录。
错误示例:
python
def bad_node(state: WriterState):
# 错误:在 interrupt 前执行非幂等操作
audit_id = db.create_audit_log({
"action": "draft_review",
"timestamp": datetime.now()
})
# resume 时会再次 create,产生重复记录
decision = interrupt({"draft": state["draft"]})
return {"approved": decision}
正确做法:
python
def good_node(state: WriterState):
# 正确:使用幂等操作
db.upsert_audit_log(
key=f"draft-review-{state['topic']}",
status="pending"
)
decision = interrupt({"draft": state["draft"]})
# 或者将副作用放在 interrupt 之后
db.update_audit_log(
key=f"draft-review-{state['topic']}",
status="completed",
decision=decision
)
return {"approved": decision}
5.4.2 坑二:try/except 包裹 interrupt
问题描述 :interrupt() 通过异常暂停执行,如果被 try/except 捕获,中断机制失效。
错误示例:
python
def bad_node(state: WriterState):
try:
decision = interrupt({"draft": state["draft"]}) # 异常被 catch
except Exception as e:
print(f"Error: {e}") # interrupt 异常被吞掉
decision = "default"
return {"approved": decision}
正确做法:
python
def good_node(state: WriterState):
# 将 interrupt 放在 try/except 之外
decision = interrupt({"draft": state["draft"]})
# 错误处理放在 interrupt 之后
try:
process_decision(decision)
except Exception as e:
print(f"处理决策时出错: {e}")
return {"approved": decision}
5.4.3 坑三:while True 循环中的 interrupt
问题描述 :在循环中调用 interrupt() 会导致恢复时指数级重执行。
错误示例:
python
def bad_node(state: WriterState):
while True:
answer = interrupt("请输入年龄:")
if isinstance(answer, int) and answer > 0:
break
# 每次 resume 都会重跑所有之前的循环迭代
正确做法:使用条件边实现循环验证。
python
def collect_age_node(state: WriterState):
question = state.get("pending_question") or "请输入年龄:"
answer = interrupt(question)
if isinstance(answer, int) and answer > 0:
return {"age": answer, "pending_question": None}
return {"pending_question": f"'{answer}' 不是有效年龄,请输入正整数"}
def route_age(state: WriterState):
return END if state.get("age") is not None else "collect_age"
5.4.4 坑四:interrupt 顺序不一致
问题描述:多个 interrupt 的恢复值按索引匹配,顺序变化会导致值错位。
错误示例:
python
def bad_node(state: WriterState):
name = interrupt("姓名?")
if state.get("needs_age"):
age = interrupt("年龄?") # 条件调用,顺序不稳定
city = interrupt("城市?")
return {"name": name, "city": city}
正确做法:
python
def good_node(state: WriterState):
name = interrupt("姓名?")
age = interrupt("年龄?")
city = interrupt("城市?")
return {"name": name, "age": age, "city": city}
5.4.5 坑五:thread_id 管理不当
问题描述:使用错误的 thread_id 恢复,会丢失中断状态或创建新会话。
最佳实践:
python
# 使用稳定的 thread_id(如用户 ID + 任务 ID)
thread_id = f"user-{user_id}-task-{task_id}"
config = {"configurable": {"thread_id": thread_id}}
# 首次执行
result = graph.invoke(input_data, config=config)
# 恢复执行 --- 必须使用相同的 thread_id
result = graph.invoke(Command(resume=decision), config=config)
# 存储 thread_id 到数据库,便于后续恢复
db.save_task_thread(task_id=task_id, thread_id=thread_id)
六、全文总结
6.1 核心原理回顾
| 核心概念 | 作用 | 关键要点 |
|---|---|---|
| interrupt() | 暂停执行 | 异常驱动、节点重执行、幂等要求 |
| Command | 动态路由 | goto + update 合一、resume 恢复 |
| Checkpointer | 状态持久化 | thread_id 会话隔离、快照存储 |
| payload | 人类输入载体 | JSON-serializable、包含上下文 |
6.2 设计哲学
- 中断是原语,不是模式:LangGraph 将中断提升为一等公民,开发者无需自行实现暂停/恢复逻辑
- 路由即返回:Command 将路由决策和状态更新合二为一,简化了节点间的控制流
- 状态即持久:Checkpointer 自动管理状态快照,断点续传对开发者透明
- 人机对等:人类不是"外部调用者",而是 Agent 工作流中的一个"特殊节点"
6.3 SmartWriter v0.15 的意义
通过引入 interrupt() 和 Command,SmartWriter 从一个"全自动写作 Agent"升级为"人机协作写作平台":
text
[SmartWriter 演进路线]
│
├── v0.11 ──→ 状态图引擎(StateGraph)
│
├── v0.12 ──→ 并行写作(Send API + Reducers)
│
├── v0.13 ──→ 自主 Agent(Think-Act-Observe)
│
├── v0.14 ──→ 多策略调度(Skill 体系)
│
└── v0.15 ──→ 人机协作(interrupt + Command)
│
└── 为 v1.0 全链路可观测奠定基础
人机协作不是倒退,而是 Agent 系统成熟的标志。当 AI 能够自主完成 80% 的工作,并在关键节点寻求人类确认时,这才是真正的"智能"。
本期专栏更新说明
本文为《LangChain & LangGraph 从入门到精通》专栏第一季(SmartWriter 贯穿案例)持续迭代内容,专栏长期更新 LCEL 编排引擎、LangGraph 状态图与 Agent 架构、MCP/A2A 协议集成、GraphRAG、流式处理与 LLMOps 生产实践,一次订阅,永久持续更新。第一季完结后将开启第二季,以全新贯穿案例重新从入门螺旋。