用 LangGraph 做人工审批流的同学都踩过同一个坑:interrupt() 暂停之后,恢复时人工塞回什么值,图就照单全收——前端把 approved 传成了字符串 "nope"、少传了一个必填字段、多带了一坨脏数据,都要等下游节点炸了才发现。LangGraph 1.2.12(2026-09-21 发布)给 interrupt() 加上了 response_schema 参数,把这层校验补在了正确的位置:暂停时声明「恢复值必须长什么样」,恢复时框架先校验、校验通过的对象才会返回给你的节点;同时每个 Interrupt 对象都会把这份 schema 以 JSON Schema 形态透出,前端拿到就能渲染出带类型约束的审批表单。本篇把这条链路从定义、校验失败、多 interrupt 对位到客户端消费完整跑一遍,全部代码可直接复现。
interrupt()/human-in-the-loop 的 Python 开发者。全程本地可跑(MemorySaver),不需要外部服务。先理解:schema 的两种形态,行为完全不同
response_schema 接受两类东西,语义要分清:其一,传 Pydantic 模型类、TypedDict 或 dataclass——框架把它转成 JSON Schema 透出给客户端,并且在恢复时用 TypeAdapter 校验人工输入,校验通过后的对象(Pydantic 实例 / dataclass 实例)才是 interrupt() 的返回值;不合法直接抛 pydantic.ValidationError,脏数据进不了图的状态。其二,传裸 JSON Schema 字典——它只被原样透出给客户端(供渲染表单),恢复值不做校验,行为与老版本一致。也就是说:想要强约束就用强类型三件套,裸字典只是给前端画表单用的说明书。别选错了形态还以为有校验兜底。
升级注意。1.2.12 起 Interrupt 对象新增了 response_schema 字段(未声明时为 None):如果你的代码或测试对 interrupts 结构做了严格断言(比如逐字段比对 SDK 返回的字典),升级后断言会多出一个字段,需要同步更新;常规的 interrupt(value) 调用完全向后兼容,不用改。
Step 1:升级到 1.2.12,起一个会审批的图
pip install -U "langgraph>=1.2.12"
python -c "from importlib.metadata import version; print(version('langgraph'))"
确认版本不低于 1.2.12 后,搭一个最小审批图作为基线:一个节点在发布前调用 interrupt() 停车等人工审批,人工的恢复值直接写进图状态。旧版行为是恢复值原样返回——前端把 approved 传成 "yes"(字符串)或干脆漏传,节点代码要么静默拿到脏数据、要么在下游某个比较判断里炸出难看的类型错误。基线先跑一遍感受这个痛点,下一步用 schema 治它。
from typing_extensions import TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.memory import MemorySaver
from langgraph.types import Command, interrupt
class State(TypedDict):
answer: object
def review_node(state: State) -> State:
# 旧写法:interrupt({"question": "批准发布吗?"})——恢复值不校验
decision = interrupt({"question": "批准发布这份周报吗?"})
return {"answer": decision}
builder = StateGraph(State)
builder.add_node("review", review_node)
builder.add_edge(START, "review")
builder.add_edge("review", END)
graph = builder.compile(checkpointer=MemorySaver())
MemorySaver 只适合本地开发与单进程场景;生产用 SqliteSaver/PostgresSaver,本篇的 schema 用法对哪种 checkpointer 都一样。Step 2:加 response_schema,恢复值先过 Pydantic
from typing import Optional
from pydantic import BaseModel
from langgraph.types import Command, interrupt
class Approval(BaseModel):
approved: bool
note: Optional[str] = None
def review_node(state: State) -> State:
decision = interrupt(
{"question": "批准发布这份周报吗?"},
response_schema=Approval, # 关键:声明恢复值结构
)
# 走到这里说明校验已通过,decision 是 Approval 实例
return {"answer": decision}
config = {"configurable": {"thread_id": "t1"}}
graph.invoke({"answer": None}, config) # 跑到 interrupt 停车
result = graph.invoke(
Command(resume={"approved": True, "note": "数据口径已核对"}),
config,
)
print(result["answer"]) # Approval(approved=True, note='数据口径已核对')
变化全在恢复环节:Command(resume={...}) 给出的字典先被 TypeAdapter 按 Approval 校验,通过后校验后的对象作为 interrupt() 的返回值交给节点——你的下游代码从「防御式检查字典字段」变成「直接用强类型实例」,多余字段被剔除、缺字段当场报错。这是 1.2.12 对审批流最大的体验改善:把「人工输入合不合格」从下游节点的责任,上移到了恢复边界。
校验失败抛的是 ValidationError,不是静默忽略。恢复值不合法时 graph.invoke(...) 直接抛 pydantic.ValidationError;好消息是线程状态没有被污染,修正数值后再次 invoke(Command(resume=...)) 即可继续(Step 6 给出重试封装)。前端要把这类错误当成「表单填错了请重填」,而不是服务端 500。
Step 3:TypedDict / dataclass / 裸 JSON Schema 三形态对齐
from dataclasses import dataclass
class ApprovalDict(TypedDict):
approved: bool
@dataclass
class ApprovalData:
approved: bool
# 三种强类型写法等价:转 JSON Schema 透出 + 恢复时校验
interrupt("approve?", response_schema=ApprovalDict)
interrupt("approve?", response_schema=ApprovalData)
# 裸 JSON Schema dict:只透出、不校验,返回原始值
RAW = {"type": "object",
"properties": {"approved": {"type": "boolean"}}}
interrupt("approve?", response_schema=RAW)
官方测试覆盖了五种形态(无 schema、裸字典、Pydantic、TypedDict、dataclass),行为边界很清晰:前一种无约束,裸字典透出但不校验,后三种「透出 + 校验 + 返回实例」三合一。团队落地时建议固化一条规范——审批类 interrupt 一律用 Pydantic 模型(字段约束与自定义校验器能力最全),裸字典只留给「纯展示、下游自己兜底」的场景。另外注意返回值类型差异:Pydantic 形态恢复后拿到的是模型实例,裸字典形态拿到的还是原样字典,下游代码别写混。
{"approved": "nope"} 这类字符串布尔值去恢复——Pydantic 形态会报 ValidationError(官方测试里的原例),裸字典形态则原样通过;拿这个差异快速验证你的 schema 形态选对了没。Step 4:把 response_schema 透给前端,按 id 精确恢复
# 服务端:把暂停现场整理给前端
pending = graph.get_state(config).tasks[0].interrupts[0]
payload = {
"id": pending.id, # interrupt 的稳定 id
"value": pending.value, # 暂停时给的问题上下文
"schema": pending.response_schema, # JSON Schema,渲染表单用
}
# 前端提交后:按 id 恢复(比裸值恢复更适合多 interrupt 场景)
graph.invoke(
Command(resume={payload["id"]: {"approved": True}}),
config,
)
Interrupt 对象新增的 response_schema 字段让客户端自此可以「自渲染表单」:SDK 侧的线程模型里 interrupts 同样带这个字段(JSON Schema 字典),前端拿它就能生成带类型与必填约束的输入控件,不必前后端各维护一份字段定义。恢复方式也支持两种:Command(resume=值) 与 Command(resume={interrupt_id: 值})——前者适合单 interrupt 的简单流,后者按 id 精确对位,当一个节点里有多个暂停点、或同一线程里可能有多个待恢复项时,按 id 恢复更稳。
value 里别塞敏感原文。interrupt 的 value 与 response_schema 都会随状态接口暴露给客户端——待审批内容里有客户隐私或密钥时,先在服务端脱敏或改为传引用(id + 摘要),让前端按需换取。
Step 5:同一节点多个 interrupt,恢复值按序对位
def two_step_node(state: State) -> State:
topic = interrupt("先确认主题") # 无 schema:拿原始值
decision = interrupt("再走审批", response_schema=Approval) # 有 schema
return {"answer": [topic, decision]}
# 恢复值按「暂停顺序」逐个消费:invoke 一轮给一个
graph.invoke(Command(resume="Q3 复盘"), config) # 喂给 topic
graph.invoke(Command(resume={"approved": True}), config) # 喂给 decision
# 结果: {"answer": ["Q3 复盘", Approval(approved=True)]}
LangGraph 的 interrupt 恢复机制按调用顺序索引对位:同一节点里第 N 次调用的 interrupt(),消费恢复列表里的第 N 个值——这个规则在混搭 schema 时依然成立,每个暂停点各自按自己的 schema 校验自己那份恢复值(官方测试专门验证了「前一个无 schema、后一个带 schema」的组合:前一个返回原始值,后一个校验失败时抛 ValidationError、修正后重试成功)。写多步审批流时给每个 interrupt 的 value 里带上下文标记(如 {"step": "topic"}),前端能准确知道当前在等哪一个输入。
Step 6:生产化封装——校验失败的重试循环与升级通道
from pydantic import ValidationError
def resume_with_retry(graph, config, max_retry: int = 2):
pending = graph.get_state(config).tasks[0].interrupts[0]
for _ in range(max_retry):
raw = collect_human_input(pending) # 你的表单采集逻辑
try:
return graph.invoke(Command(resume=raw), config)
except ValidationError as e:
show_form_error(e) # 提示哪里没填对,重新采集
return escalate_to_admin(pending) # 超限转人工坐席
生产落地的三件套:错误即反馈——捕获 ValidationError 后把 Pydantic 的错误定位翻译成表单字段红字,让填写者当轮修正,线程状态全程无损;重试有上限——超过次数走升级通道(转人工坐席或管理员代批),别让填错的人无限循环;审计留痕——把每次恢复的原始值、校验结果、操作人记进日志,审批流的可追溯性要求在这里和传统工作流完全一致。升级到 1.2.12 后,把这三件事包成统一的 resume 工具函数,全项目的 interrupt 就都长一个样了。
schema 演进要向后兼容。已经在跑的线程带着旧结构恢复时,新加的必填字段会让旧现场全部校验失败——给 Approval 这类模型加字段时优先用带默认值的可选字段,破坏性变更配合版本化线程或迁移策略,别让升级卡死存量审批。
常见问题速查
| 你遇到的现象 | 大概率原因 & 解决 |
|---|---|
| 恢复时抛 ValidationError: approved | 恢复值与 schema 不符(如布尔传成字符串)。修正后重新 invoke,线程状态未被污染 |
| 传了裸 JSON Schema 却没校验 | 设计如此:裸 dict 只透出不校验。要强校验换 Pydantic/TypedDict/dataclass |
| 升级后测试断言失败 | Interrupt 新增 response_schema 字段(无 schema 时为 None),严格断言需同步更新 |
| interrupt() 返回的不是 Pydantic 实例 | 检查该暂停点的 response_schema 形态;只有强类型三件套才返回校验后的对象 |
| 多 interrupt 恢复值串位 | 节点代码变更导致暂停顺序变化。重建线程重跑,恢复值按新顺序对位 |
| 前端想自动生成审批表单 | 读 get_state().tasks[0].interrupts[0].response_schema,按 JSON Schema 渲染,恢复用 {id: value} |