前一篇讲了 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 声明「哪些工具要拦截」
这是整个机制的开关。InterruptOnConfig 里 allowed_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="下单为高风险且不可逆操作,提交前需人工审批。",
)
}
把 tools 和 interrupt_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:怎么恢复?靠什么判断「这是恢复而不是新提问」?
恢复靠两样东西配合:
- 同一个
thread_id:定位到那条「停在中断点」的会话存档; 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 次生成答复 |
我们前端目前接了 approve 和 reject,edit / 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)------一套相对完整的「可控智能体」基础设施就搭好了。