给智能体装上「刹车」:中断(Interrupts)与人工审批全解析

前一篇讲了 Checkpoint(存档),这一篇讲它的「亲兄弟」------中断(Interrupts) 。我们给铁矿石智能体加了一个真实可用的场景:下单前必须人工审批。本文把它的使用场景、怎么用、完整执行流程、调用了几次大模型、靠什么判断中断与恢复,全部讲透,并配上流程图和项目里的真实代码。


1. 中断是什么,和 Checkpoint 什么关系

大模型驱动的智能体可以「自己决定调用工具」。但有些工具的动作是高风险、不可逆的(下单、转账、发邮件、删数据......),不能让它全自动执行。

中断 就是给智能体装的「刹车」:当它准备执行某个敏感工具时,先暂停,把当前状态存成 checkpoint,等人类点了「批准 / 拒绝」之后再决定要不要继续。
#mermaid-svg-gYILH56ggHwH3jqx{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-gYILH56ggHwH3jqx .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-gYILH56ggHwH3jqx .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-gYILH56ggHwH3jqx .error-icon{fill:#552222;}#mermaid-svg-gYILH56ggHwH3jqx .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-gYILH56ggHwH3jqx .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-gYILH56ggHwH3jqx .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-gYILH56ggHwH3jqx .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-gYILH56ggHwH3jqx .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-gYILH56ggHwH3jqx .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-gYILH56ggHwH3jqx .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-gYILH56ggHwH3jqx .marker{fill:#333333;stroke:#333333;}#mermaid-svg-gYILH56ggHwH3jqx .marker.cross{stroke:#333333;}#mermaid-svg-gYILH56ggHwH3jqx svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-gYILH56ggHwH3jqx p{margin:0;}#mermaid-svg-gYILH56ggHwH3jqx .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-gYILH56ggHwH3jqx .cluster-label text{fill:#333;}#mermaid-svg-gYILH56ggHwH3jqx .cluster-label span{color:#333;}#mermaid-svg-gYILH56ggHwH3jqx .cluster-label span p{background-color:transparent;}#mermaid-svg-gYILH56ggHwH3jqx .label text,#mermaid-svg-gYILH56ggHwH3jqx span{fill:#333;color:#333;}#mermaid-svg-gYILH56ggHwH3jqx .node rect,#mermaid-svg-gYILH56ggHwH3jqx .node circle,#mermaid-svg-gYILH56ggHwH3jqx .node ellipse,#mermaid-svg-gYILH56ggHwH3jqx .node polygon,#mermaid-svg-gYILH56ggHwH3jqx .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-gYILH56ggHwH3jqx .rough-node .label text,#mermaid-svg-gYILH56ggHwH3jqx .node .label text,#mermaid-svg-gYILH56ggHwH3jqx .image-shape .label,#mermaid-svg-gYILH56ggHwH3jqx .icon-shape .label{text-anchor:middle;}#mermaid-svg-gYILH56ggHwH3jqx .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-gYILH56ggHwH3jqx .rough-node .label,#mermaid-svg-gYILH56ggHwH3jqx .node .label,#mermaid-svg-gYILH56ggHwH3jqx .image-shape .label,#mermaid-svg-gYILH56ggHwH3jqx .icon-shape .label{text-align:center;}#mermaid-svg-gYILH56ggHwH3jqx .node.clickable{cursor:pointer;}#mermaid-svg-gYILH56ggHwH3jqx .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-gYILH56ggHwH3jqx .arrowheadPath{fill:#333333;}#mermaid-svg-gYILH56ggHwH3jqx .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-gYILH56ggHwH3jqx .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-gYILH56ggHwH3jqx .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-gYILH56ggHwH3jqx .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-gYILH56ggHwH3jqx .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-gYILH56ggHwH3jqx .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-gYILH56ggHwH3jqx .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-gYILH56ggHwH3jqx .cluster text{fill:#333;}#mermaid-svg-gYILH56ggHwH3jqx .cluster span{color:#333;}#mermaid-svg-gYILH56ggHwH3jqx 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-gYILH56ggHwH3jqx .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-gYILH56ggHwH3jqx rect.text{fill:none;stroke-width:0;}#mermaid-svg-gYILH56ggHwH3jqx .icon-shape,#mermaid-svg-gYILH56ggHwH3jqx .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-gYILH56ggHwH3jqx .icon-shape p,#mermaid-svg-gYILH56ggHwH3jqx .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-gYILH56ggHwH3jqx .icon-shape .label rect,#mermaid-svg-gYILH56ggHwH3jqx .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-gYILH56ggHwH3jqx .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-gYILH56ggHwH3jqx .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-gYILH56ggHwH3jqx :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 否

模型决定调用工具
该工具需要审批吗?
直接执行工具
interrupt 暂停

存档到 checkpoint
等待人工决定
Command resume 恢复

关键前提:中断必须有 checkpointer 。因为「暂停---等待---恢复」依赖把状态存下来再读回来,没有存档就无法恢复。我们项目 Web 端用的是 AsyncSqliteSaver,所以中断可以跨请求、甚至跨进程重启继续等待。


2. 适用场景

场景 说明 本项目对应例子
人工审批 高风险/不可逆动作执行前等人批准 下单、调用收费 API、发报告给客户
审核工具参数 模型给的参数可能不对,人改了再执行 核对合约号、方向、手数
编辑中间结果 暂停后人工修改 state 再继续 修正供需假设、库存数字
补充缺失信息 执行中信息不够,停下来问人 没说看哪个合约 → 反问
长流程关卡 多步任务的关键节点设卡 建模前先确认数据质量
开发调试 在某节点前暂停单步检查 LangGraph Studio 的逐步调试

判断标准很简单:只要「让 AI 全自动做」风险太高,或它必须等人给东西,就用中断。

我们落地的就是第一种------下单审批


3. 如何使用(三步)

第 1 步:定义一个需要审批的工具

13:29:mystu/buildagent/agent/tools.py 复制代码
@tool
def place_order(contract: str, side: str, quantity: int) -> str:
    """提交一笔铁矿石期货下单(模拟,高风险不可逆操作,执行前需人工审批)。

    Args:
        contract: 合约代码,例如 i2601、i2605。
        side: 交易方向,buy(买入/做多)或 sell(卖出/做空)。
        quantity: 下单手数(正整数)。

    Returns:
        下单回报字符串。
    """
    side_cn = {"buy": "买入", "sell": "卖出"}.get(side.lower(), side)
    return (
        f"✅ 订单已提交(模拟):{side_cn} {quantity} 手 {contract},"
        f"状态=已成交,成交回报号=SIM-{contract}-{side.upper()}-{quantity}。"
    )

第 2 步:用 interrupt_on 声明「哪些工具要拦截」

这是整个机制的开关。InterruptOnConfigallowed_decisions 指定允许的处置方式(批准 / 编辑 / 拒绝):

18:28:mystu/buildagent/agent/deepagent.py 复制代码
# 智能体可用的工具
_TOOLS = [place_order]

# 人在回路(HITL)配置:列出哪些工具在执行前必须人工审批。
# place_order 是高风险下单操作,因此拦截,允许「批准 / 编辑 / 拒绝」三种处置。
_INTERRUPT_ON: dict[str, bool | InterruptOnConfig] = {
    "place_order": InterruptOnConfig(
        allowed_decisions=["approve", "edit", "reject"],
        description="下单为高风险且不可逆操作,提交前需人工审批。",
    )
}

toolsinterrupt_on 一起交给 create_deep_agent,并确保带上 checkpointer

50:64:mystu/buildagent/agent/deepagent.py 复制代码
def build_agent(checkpointer: BaseCheckpointSaver | None = None):
    """构建使用 DeepSeek 的铁矿石价格预测 deep agent(带多轮对话记忆)。

    checkpointer:对话状态存储器。
      - 不传:使用 InMemorySaver(进程内记忆,重启即丢,适合 CLI / 测试)。
      - 传入 AsyncSqliteSaver 等:把各 thread_id 的对话持久化到磁盘,重启不丢。
    """
    system_prompt = load_iron_ore_forecast_prompt()
    return create_deep_agent(
        model=build_model(),
        tools=_TOOLS,
        system_prompt=system_prompt,
        interrupt_on=_INTERRUPT_ON,
        checkpointer=checkpointer or InMemorySaver(),
    )

第 3 步:让提示词知道它能下单

在系统提示词里加一段「工具能力」说明,模型才会在用户要求时主动调用 place_order

text 复制代码
# 工具能力
- 你可以调用 `place_order` 工具提交模拟的铁矿石期货下单(参数:合约 contract、方向 side=buy/sell、手数 quantity)。
- 当用户明确要求下单(如"帮我买入 100 手 i2601")时,调用该工具;参数不全时先向用户问清合约、方向、手数。
- 下单属高风险操作,系统会在执行前要求人工审批,你只需正常发起调用即可。

到此,能力就接上了。剩下的是「怎么把中断暴露给前端、怎么把人的决定送回去」。


4. 完整执行流程(含大模型调用次数)

以「用户说:帮我买入 100 手 i2601 → 人工批准」为例,整条链路是两次 HTTP 请求
Checkpointer DeepSeek HITL 中间件 Agent 图 FastAPI Checkpointer DeepSeek HITL 中间件 Agent 图 FastAPI #mermaid-svg-Kf48VPfeSN6WZ1iv{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-Kf48VPfeSN6WZ1iv .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Kf48VPfeSN6WZ1iv .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Kf48VPfeSN6WZ1iv .error-icon{fill:#552222;}#mermaid-svg-Kf48VPfeSN6WZ1iv .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Kf48VPfeSN6WZ1iv .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Kf48VPfeSN6WZ1iv .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Kf48VPfeSN6WZ1iv .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Kf48VPfeSN6WZ1iv .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Kf48VPfeSN6WZ1iv .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Kf48VPfeSN6WZ1iv .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Kf48VPfeSN6WZ1iv .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Kf48VPfeSN6WZ1iv .marker.cross{stroke:#333333;}#mermaid-svg-Kf48VPfeSN6WZ1iv svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Kf48VPfeSN6WZ1iv p{margin:0;}#mermaid-svg-Kf48VPfeSN6WZ1iv .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-Kf48VPfeSN6WZ1iv text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-Kf48VPfeSN6WZ1iv .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-Kf48VPfeSN6WZ1iv .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-Kf48VPfeSN6WZ1iv .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-Kf48VPfeSN6WZ1iv .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-Kf48VPfeSN6WZ1iv #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-Kf48VPfeSN6WZ1iv .sequenceNumber{fill:white;}#mermaid-svg-Kf48VPfeSN6WZ1iv #sequencenumber{fill:#333;}#mermaid-svg-Kf48VPfeSN6WZ1iv #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-Kf48VPfeSN6WZ1iv .messageText{fill:#333;stroke:none;}#mermaid-svg-Kf48VPfeSN6WZ1iv .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-Kf48VPfeSN6WZ1iv .labelText,#mermaid-svg-Kf48VPfeSN6WZ1iv .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-Kf48VPfeSN6WZ1iv .loopText,#mermaid-svg-Kf48VPfeSN6WZ1iv .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-Kf48VPfeSN6WZ1iv .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-Kf48VPfeSN6WZ1iv .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-Kf48VPfeSN6WZ1iv .noteText,#mermaid-svg-Kf48VPfeSN6WZ1iv .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-Kf48VPfeSN6WZ1iv .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-Kf48VPfeSN6WZ1iv .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-Kf48VPfeSN6WZ1iv .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-Kf48VPfeSN6WZ1iv .actorPopupMenu{position:absolute;}#mermaid-svg-Kf48VPfeSN6WZ1iv .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-Kf48VPfeSN6WZ1iv .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-Kf48VPfeSN6WZ1iv .actor-man circle,#mermaid-svg-Kf48VPfeSN6WZ1iv line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-Kf48VPfeSN6WZ1iv :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} ① POST /chat ------ 发起下单 命中 interrupt_on→ interrupt() 暂停 渲染审批卡片,等待人工(可跨请求/跨重启等待) ② POST /chat/resume ------ 人点「批准」 🔵🔵 全程共调用大模型 2 次中断 / 恢复本身不耗模型 用户/前端 message:买入 100 手 i26011ainvoke(消息, thread_id)2🔵 第 1 次调用大模型3决定调用 place_order(i2601, buy, 100)4工具执行前拦截5存档暂停点(checkpoint)6__interrupt__(待审批)7status = interrupt(含工具与参数)8thread_id + decision = approve9ainvoke(Command(resume=...), 同一 thread_id)10读回暂停点11执行 place_order(不调模型)12🔵 第 2 次调用大模型13据成交回报生成最终答复14最终回答15status = ok(含 data)16 用户/前端

一共调用了几次大模型?

批准这条路:2 次。

  • 第 1 次(请求①里):模型判断要下单、生成工具调用 → 然后被中断截停,工具还没执行。
  • 第 2 次(请求②里):工具执行完,模型拿到「成交回报」再生成给用户看的自然语言答复。

中断本身不消耗大模型调用------暂停和恢复都只是读写 checkpoint。

拒绝 / 直接答复这两条路:同样是 2 次。 拒绝时工具不执行,但「拒绝原因」会作为工具结果回灌给模型,模型再调用 1 次生成答复。


5. 三个核心问题答疑

Q1:通过什么来判断「是否中断」?

interrupt_on 背后注入的 HumanInTheLoopMiddleware 判断,判定发生在「模型已经决定调用工具」之后、「工具真正执行」之前:
#mermaid-svg-qdnVQ6ClfU5R97Wh{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-qdnVQ6ClfU5R97Wh .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-qdnVQ6ClfU5R97Wh .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-qdnVQ6ClfU5R97Wh .error-icon{fill:#552222;}#mermaid-svg-qdnVQ6ClfU5R97Wh .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-qdnVQ6ClfU5R97Wh .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-qdnVQ6ClfU5R97Wh .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-qdnVQ6ClfU5R97Wh .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-qdnVQ6ClfU5R97Wh .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-qdnVQ6ClfU5R97Wh .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-qdnVQ6ClfU5R97Wh .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-qdnVQ6ClfU5R97Wh .marker{fill:#333333;stroke:#333333;}#mermaid-svg-qdnVQ6ClfU5R97Wh .marker.cross{stroke:#333333;}#mermaid-svg-qdnVQ6ClfU5R97Wh svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-qdnVQ6ClfU5R97Wh p{margin:0;}#mermaid-svg-qdnVQ6ClfU5R97Wh .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-qdnVQ6ClfU5R97Wh .cluster-label text{fill:#333;}#mermaid-svg-qdnVQ6ClfU5R97Wh .cluster-label span{color:#333;}#mermaid-svg-qdnVQ6ClfU5R97Wh .cluster-label span p{background-color:transparent;}#mermaid-svg-qdnVQ6ClfU5R97Wh .label text,#mermaid-svg-qdnVQ6ClfU5R97Wh span{fill:#333;color:#333;}#mermaid-svg-qdnVQ6ClfU5R97Wh .node rect,#mermaid-svg-qdnVQ6ClfU5R97Wh .node circle,#mermaid-svg-qdnVQ6ClfU5R97Wh .node ellipse,#mermaid-svg-qdnVQ6ClfU5R97Wh .node polygon,#mermaid-svg-qdnVQ6ClfU5R97Wh .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-qdnVQ6ClfU5R97Wh .rough-node .label text,#mermaid-svg-qdnVQ6ClfU5R97Wh .node .label text,#mermaid-svg-qdnVQ6ClfU5R97Wh .image-shape .label,#mermaid-svg-qdnVQ6ClfU5R97Wh .icon-shape .label{text-anchor:middle;}#mermaid-svg-qdnVQ6ClfU5R97Wh .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-qdnVQ6ClfU5R97Wh .rough-node .label,#mermaid-svg-qdnVQ6ClfU5R97Wh .node .label,#mermaid-svg-qdnVQ6ClfU5R97Wh .image-shape .label,#mermaid-svg-qdnVQ6ClfU5R97Wh .icon-shape .label{text-align:center;}#mermaid-svg-qdnVQ6ClfU5R97Wh .node.clickable{cursor:pointer;}#mermaid-svg-qdnVQ6ClfU5R97Wh .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-qdnVQ6ClfU5R97Wh .arrowheadPath{fill:#333333;}#mermaid-svg-qdnVQ6ClfU5R97Wh .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-qdnVQ6ClfU5R97Wh .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-qdnVQ6ClfU5R97Wh .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-qdnVQ6ClfU5R97Wh .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-qdnVQ6ClfU5R97Wh .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-qdnVQ6ClfU5R97Wh .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-qdnVQ6ClfU5R97Wh .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-qdnVQ6ClfU5R97Wh .cluster text{fill:#333;}#mermaid-svg-qdnVQ6ClfU5R97Wh .cluster span{color:#333;}#mermaid-svg-qdnVQ6ClfU5R97Wh 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-qdnVQ6ClfU5R97Wh .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-qdnVQ6ClfU5R97Wh rect.text{fill:none;stroke-width:0;}#mermaid-svg-qdnVQ6ClfU5R97Wh .icon-shape,#mermaid-svg-qdnVQ6ClfU5R97Wh .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-qdnVQ6ClfU5R97Wh .icon-shape p,#mermaid-svg-qdnVQ6ClfU5R97Wh .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-qdnVQ6ClfU5R97Wh .icon-shape .label rect,#mermaid-svg-qdnVQ6ClfU5R97Wh .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-qdnVQ6ClfU5R97Wh .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-qdnVQ6ClfU5R97Wh .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-qdnVQ6ClfU5R97Wh :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 没有

不在

模型节点输出 AIMessage
有 tool_calls 吗?
直接产出最终回答
工具名在 interrupt_on 里?
直接执行工具
interrupt 抛出 HITLRequest

图暂停并存档

所以「是否中断」取决于两个条件同时成立 :① 模型决定调用某工具;② 该工具名出现在 interrupt_on 配置中。与「问什么内容」无关------只跟「要不要动那个被标记的工具」有关。

Q2:后端怎么知道「这次返回是中断,而不是正常结束」?

ainvoke 返回值里的 __interrupt__ 字段。我们封装了一个解析函数:

120:138:mystu/buildagent/agent/deepagent.py 复制代码
def _interrupt_from_result(result: Any) -> dict[str, Any] | None:
    """从 ainvoke 返回值里解析出待审批的中断信息(没有则返回 None)。

    被 interrupt_on 拦截的工具会让图暂停,返回值带有 `__interrupt__`,其载荷形如
    {"action_requests": [{"name", "args", "description"}], "review_configs": [...]}。
    """
    if not isinstance(result, dict):
        return None
    interrupts = result.get("__interrupt__")
    if not interrupts:
        return None
    value = interrupts[0].value
    requests = value.get("action_requests") if isinstance(value, dict) else None
    action = requests[0] if requests else {}
    return {
        "tool": action.get("name"),
        "args": action.get("args", {}),
        "description": action.get("description", ""),
    }

再统一包装成「要么待审批、要么最终回答」两种结构,前端据此渲染:

141:150:mystu/buildagent/agent/deepagent.py 复制代码
def _build_chat_result(result: Any, thread_id: str) -> dict[str, Any]:
    """把 agent 执行结果包装成统一结构:要么需要审批,要么是最终回答。"""
    pending = _interrupt_from_result(result)
    if pending:
        return {"status": "interrupt", "thread_id": thread_id, **pending}
    return {
        "status": "ok",
        "thread_id": thread_id,
        "data": _extract_text(result["messages"][-1].content),
    }

流式接口拿不到完整返回值,所以另用 aget_state(...).interrupts 在流结束后查一次有没有挂起的中断(见 aget_pending_interrupt)。

Q3:怎么恢复?靠什么判断「这是恢复而不是新提问」?

恢复靠两样东西配合:

  1. 同一个 thread_id:定位到那条「停在中断点」的会话存档;
  2. Command(resume=...):告诉 LangGraph「这是对挂起中断的答复」,而不是一条新消息。

LangGraph 收到 Command(resume=...) 后,会从该 thread 的最新 checkpoint 里发现有一个挂起的 interrupt(),于是把 resume 的值作为 interrupt() 的返回值送回去,中间件据此决定执行 / 跳过 / 回灌工具。

170:189:mystu/buildagent/agent/deepagent.py 复制代码
async def aresume(
    thread_id: str, decision: str, message: str | None = None
) -> dict[str, Any]:
    """对处于中断(等待审批)的会话提交人工决定,并继续执行。

    decision:
      - approve:批准,执行工具
      - reject:拒绝执行(可附 message 说明原因,反馈给模型)
      - respond:不执行工具,直接以 message 作为工具结果回给模型
    返回结构同 achat(可能再次中断)。
    """
    payload: dict[str, Any] = {"type": decision}
    if decision in ("reject", "respond") and message is not None:
        payload["message"] = message
    config = {"configurable": {"thread_id": thread_id}}
    result = await get_agent().ainvoke(
        Command(resume={"decisions": [payload]}),
        config=config,
    )
    return _build_chat_result(result, thread_id)

一句话区分:普通提问 = ainvoke({"messages":[...]}, config)恢复中断 = ainvoke(Command(resume=...), config)。两者都带 thread_id,差别在第一个参数。


6. 后端服务层全貌

非流式入口 achat:发起调用后用 _build_chat_result 判定是「待审批」还是「最终回答」。

153:167:mystu/buildagent/agent/deepagent.py 复制代码
async def achat(message: str, thread_id: str | None = None) -> dict[str, Any]:
    """非流式问答。

    返回统一结构:
      - {"status": "ok", "data": 回答文本, "thread_id"}
      - {"status": "interrupt", "tool", "args", "description", "thread_id"}  需人工审批
    传入相同的 thread_id 即可在多次调用间保持对话记忆。
    """
    config = _build_config(thread_id)
    tid = config["configurable"]["thread_id"]
    result = await get_agent().ainvoke(
        {"messages": [{"role": "user", "content": message}]},
        config=config,
    )
    return _build_chat_result(result, tid)

流式场景下,主流程只吐文本 token;中断信息在流结束后单独查一次再补发:

192:206:mystu/buildagent/agent/deepagent.py 复制代码
async def aget_pending_interrupt(thread_id: str) -> dict[str, Any] | None:
    """读取某会话当前是否有待审批的中断(用于流式结束后补发审批事件)。"""
    config = {"configurable": {"thread_id": thread_id}}
    state = await get_agent().aget_state(config)
    interrupts = getattr(state, "interrupts", None) or ()
    if not interrupts:
        return None
    value = interrupts[0].value
    requests = value.get("action_requests") if isinstance(value, dict) else None
    action = requests[0] if requests else {}
    return {
        "tool": action.get("name"),
        "args": action.get("args", {}),
        "description": action.get("description", ""),
    }

7. 接口层

/chat 直接透传结构化结果;新增 /chat/resume 提交人工决定;/chat/stream 在流末补发 interrupt 事件:

23:56:mystu/controller/api.py 复制代码
@router.post("/chat")
async def chat(request: apireq.ChatRequest) -> dict[str, Any]:
    """非流式问答:一次性返回完整回答,或返回需人工审批的中断。

    返回:
      - {"status": "ok", "data": 回答, "thread_id"}
      - {"status": "interrupt", "tool", "args", "description", "thread_id"}
    """
    return await achat(request.message, request.thread_id)


@router.post("/chat/resume")
async def chat_resume(request: apireq.ResumeRequest) -> dict[str, Any]:
    """对处于中断(等待审批)的会话提交人工决定并继续执行。"""
    return await aresume(request.thread_id, request.decision, request.message)


@router.post("/chat/stream")
async def chat_stream(request: apireq.ChatRequest) -> StreamingResponse:
    """流式问答:以 SSE(text/event-stream)逐片段返回回答。

    若触发需审批的工具,流结束后会补发一条 `interrupt` 事件,前端据此弹出审批卡片。
    """

    async def event_generator():
        try:
            async for token in astream_chat(request.message, request.thread_id):
                yield f"data: {json.dumps({'token': token}, ensure_ascii=False)}\n\n"
            # 流结束后,检查这条会话是否停在了「等待审批」的中断上
            if request.thread_id:
                pending = await aget_pending_interrupt(request.thread_id)
                if pending:
                    payload = {"interrupt": {"thread_id": request.thread_id, **pending}}
                    yield f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
        except Exception as exc:  # noqa: BLE001 - 把异常作为事件回传前端
            yield f"data: {json.dumps({'error': str(exc)}, ensure_ascii=False)}\n\n"
        finally:
            yield "data: [DONE]\n\n"

8. 前端:审批卡片与「批准 / 拒绝」

前端拿到 status === "interrupt" 就渲染审批卡片,点按钮后调用 /chat/resume,把后续结果作为新消息展示(可能再次触发审批):

339:359:mystu/static/index.html 复制代码
      const decide = async (decision) => {
        approveBtn.disabled = true;
        rejectBtn.disabled = true;
        resultEl.style.display = 'block';
        resultEl.textContent = decision === 'approve' ? '已批准,执行中...' : '已拒绝,处理中...';
        let message = null;
        if (decision === 'reject') {
          message = prompt('拒绝原因(可留空):') || '用户拒绝执行该操作';
        }
        try {
          const resp = await fetch('/agent/api/chat/resume', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ thread_id: intr.thread_id || threadId, decision, message }),
          });
          if (!resp.ok) throw new Error('HTTP ' + resp.status);
          const data = await resp.json();
          resultEl.textContent = decision === 'approve' ? '✓ 已批准' : '✕ 已拒绝';
          // 把后续结果作为一条新的 AI 消息展示(可能再次需要审批)
          const next = addMessage('ai', '', '');
          handleResult(next, data);
        } catch (err) {

9. 四种人工决定(decision)

恢复时通过 Command(resume={"decisions": [{"type": ...}]}) 提交,类型有四种:

decision 含义 额外字段 大模型后续
approve 批准,按原参数执行工具 执行工具后调 1 次生成答复
edit 改参数后再执行 edited_action 同上(用改后的参数)
reject 拒绝执行 message(原因,可选) 把拒绝信息回灌,调 1 次生成答复
respond 不执行,直接用一段话当作工具结果 message(必填) 把该内容回灌,调 1 次生成答复

我们前端目前接了 approverejectedit / respond 后端已支持,加按钮即可。


10. 中断 × Checkpoint × thread_id 的三角关系

#mermaid-svg-j8Vhw1vvQZg3eJ2M{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-j8Vhw1vvQZg3eJ2M .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-j8Vhw1vvQZg3eJ2M .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-j8Vhw1vvQZg3eJ2M .error-icon{fill:#552222;}#mermaid-svg-j8Vhw1vvQZg3eJ2M .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-j8Vhw1vvQZg3eJ2M .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-j8Vhw1vvQZg3eJ2M .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-j8Vhw1vvQZg3eJ2M .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-j8Vhw1vvQZg3eJ2M .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-j8Vhw1vvQZg3eJ2M .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-j8Vhw1vvQZg3eJ2M .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-j8Vhw1vvQZg3eJ2M .marker{fill:#333333;stroke:#333333;}#mermaid-svg-j8Vhw1vvQZg3eJ2M .marker.cross{stroke:#333333;}#mermaid-svg-j8Vhw1vvQZg3eJ2M svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-j8Vhw1vvQZg3eJ2M p{margin:0;}#mermaid-svg-j8Vhw1vvQZg3eJ2M .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-j8Vhw1vvQZg3eJ2M .cluster-label text{fill:#333;}#mermaid-svg-j8Vhw1vvQZg3eJ2M .cluster-label span{color:#333;}#mermaid-svg-j8Vhw1vvQZg3eJ2M .cluster-label span p{background-color:transparent;}#mermaid-svg-j8Vhw1vvQZg3eJ2M .label text,#mermaid-svg-j8Vhw1vvQZg3eJ2M span{fill:#333;color:#333;}#mermaid-svg-j8Vhw1vvQZg3eJ2M .node rect,#mermaid-svg-j8Vhw1vvQZg3eJ2M .node circle,#mermaid-svg-j8Vhw1vvQZg3eJ2M .node ellipse,#mermaid-svg-j8Vhw1vvQZg3eJ2M .node polygon,#mermaid-svg-j8Vhw1vvQZg3eJ2M .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-j8Vhw1vvQZg3eJ2M .rough-node .label text,#mermaid-svg-j8Vhw1vvQZg3eJ2M .node .label text,#mermaid-svg-j8Vhw1vvQZg3eJ2M .image-shape .label,#mermaid-svg-j8Vhw1vvQZg3eJ2M .icon-shape .label{text-anchor:middle;}#mermaid-svg-j8Vhw1vvQZg3eJ2M .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-j8Vhw1vvQZg3eJ2M .rough-node .label,#mermaid-svg-j8Vhw1vvQZg3eJ2M .node .label,#mermaid-svg-j8Vhw1vvQZg3eJ2M .image-shape .label,#mermaid-svg-j8Vhw1vvQZg3eJ2M .icon-shape .label{text-align:center;}#mermaid-svg-j8Vhw1vvQZg3eJ2M .node.clickable{cursor:pointer;}#mermaid-svg-j8Vhw1vvQZg3eJ2M .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-j8Vhw1vvQZg3eJ2M .arrowheadPath{fill:#333333;}#mermaid-svg-j8Vhw1vvQZg3eJ2M .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-j8Vhw1vvQZg3eJ2M .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-j8Vhw1vvQZg3eJ2M .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-j8Vhw1vvQZg3eJ2M .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-j8Vhw1vvQZg3eJ2M .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-j8Vhw1vvQZg3eJ2M .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-j8Vhw1vvQZg3eJ2M .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-j8Vhw1vvQZg3eJ2M .cluster text{fill:#333;}#mermaid-svg-j8Vhw1vvQZg3eJ2M .cluster span{color:#333;}#mermaid-svg-j8Vhw1vvQZg3eJ2M 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-j8Vhw1vvQZg3eJ2M .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-j8Vhw1vvQZg3eJ2M rect.text{fill:none;stroke-width:0;}#mermaid-svg-j8Vhw1vvQZg3eJ2M .icon-shape,#mermaid-svg-j8Vhw1vvQZg3eJ2M .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-j8Vhw1vvQZg3eJ2M .icon-shape p,#mermaid-svg-j8Vhw1vvQZg3eJ2M .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-j8Vhw1vvQZg3eJ2M .icon-shape .label rect,#mermaid-svg-j8Vhw1vvQZg3eJ2M .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-j8Vhw1vvQZg3eJ2M .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-j8Vhw1vvQZg3eJ2M .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-j8Vhw1vvQZg3eJ2M :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Command resume + 同 thread_id
thread_id

定位是哪条会话
Checkpoint

存下暂停点状态
Interrupt

暂停并抛出待审批载荷
读回状态继续执行

  • thread_id:决定「在哪条会话里暂停 / 恢复」;
  • Checkpoint:把暂停点存下来,让等待可以跨请求、跨重启;
  • Interrupt:暂停的动作本身,并把「要审批什么」抛给外部。

三者缺一不可------这也是为什么开篇强调「中断必须配 checkpointer」。


11. 小结

  • 中断 = 智能体的「刹车」,用于高风险动作前的人工把关,本质是 HITL(人在回路)。
  • 用法三步:定义工具 → interrupt_on 声明拦截 → 带 checkpointer 构图。
  • 判断是否中断:模型要调用的工具命中 interrupt_on 时由 HITL 中间件 interrupt() 暂停。
  • 后端识别中断靠返回值 __interrupt__(流式靠 aget_state().interrupts)。
  • 恢复靠 Command(resume=...) + 同一个 thread_id ;与新提问的唯一区别是 ainvoke 的第一个参数。
  • 一笔「下单→批准→成交答复」全程调用大模型 2 次,中断本身不耗模型。

至此,这个智能体已经具备:多轮记忆(Checkpoint)、可视化回滚(Studio 时间旅行)、以及人工审批(Interrupts)------一套相对完整的「可控智能体」基础设施就搭好了。

相关推荐
l1t2 小时前
DeepSeek总结的使用实体-组件-系统和基于存在性处理进行Python编程39-40
开发语言·python
曾阿伦2 小时前
Python 搭建简易HTTP服务
开发语言·python·http
MIUMIUKK2 小时前
从语法层面,看懂 Python 的特殊处
java·开发语言·python
着迷不白2 小时前
第一部分:认识python
开发语言·python
hujinyuan201603 小时前
2026年3月 中国电子学会青少年软件编程(Python)三级考试试卷 真题及答案
java·python·算法
开开心心就好3 小时前
支持多显示器的Windows高效分屏工具
运维·python·科技·游戏·计算机外设·ocr·powerpoint
YXWik64 小时前
图片 OCR 文字提取 (Python + AI 模型(ModelScope))
人工智能·python·ocr
Thecozzy4 小时前
写文档教 AI 用代码
开发语言·python
Hanniel4 小时前
装饰器 (中): 进阶篇,解锁框架级玩法
开发语言·python