高级 📋 6 个步骤 第 486 / 487 篇

LangGraph interrupt() 新增 response_schema:让人工审批输入在恢复前过一道结构化校验

LangGraph 1.2.12 实操:response_schema 声明恢复值结构、Pydantic/TypedDict/dataclass 校验语义、ValidationError 重试、按 id 恢复与前端表单自渲染。

2026.09.29· 18 分钟阅读· 约 2723 字· 🚦 LangGraph / ✅ 人工审批

用 LangGraph 做人工审批流的同学都踩过同一个坑:interrupt() 暂停之后,恢复时人工塞回什么值,图就照单全收——前端把 approved 传成了字符串 "nope"、少传了一个必填字段、多带了一坨脏数据,都要等下游节点炸了才发现。LangGraph 1.2.12(2026-09-21 发布)给 interrupt() 加上了 response_schema 参数,把这层校验补在了正确的位置:暂停时声明「恢复值必须长什么样」,恢复时框架先校验、校验通过的对象才会返回给你的节点;同时每个 Interrupt 对象都会把这份 schema 以 JSON Schema 形态透出,前端拿到就能渲染出带类型约束的审批表单。本篇把这条链路从定义、校验失败、多 interrupt 对位到客户端消费完整跑一遍,全部代码可直接复现。

🎯 适合人群:已经用 LangGraph 搭过带 checkpointer 的图、用过 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,起一个会审批的图

1 装依赖,先复现「无校验」的旧烦恼
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

2 声明结构 → 校验 → 拿到的是实例不是裸字典
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 三形态对齐

3 校验语义由形态决定,团队要统一口径
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 精确恢复

4 暂停现场就是一张现成的表单说明书
# 服务端:把暂停现场整理给前端
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,恢复值按序对位

5 混搭场景:无 schema 的问一句,带 schema 的审一批
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:生产化封装——校验失败的重试循环与升级通道

6 把 ValidationError 变成表单红字,而不是 500
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}
← 返回教程中心