1. 本章目标 #
第 43 章 §5 用过一次 interrupt 模式:权限规则命中后暂停,等人批准。那是权限系统的一个分支。这一章讲的是通用的审批机制——任何工具调用都可以拦下来等人处置。
这件事的价值不用多说:智能体删库、发错邮件、调错支付接口,一次就够了。但审批做得粗糙也很致命——把所有工具都拦一遍,人就成了瓶颈,智能体的价值直接归零。
所以这一章的重点不只是「怎么拦」,还有「怎么只拦该拦的」(§7 的条件拦截)和「批准之外的三种处置」(§5)。
学完你应能:
- 用一行
interrupt_on={"write_file": True}拦下工具调用(§3) - 读懂中断数据结构,知道为什么
checkpointer是硬要求(§3.2、§4) - 熟练使用四种处置:批准、改参、拒绝、代答(§5)
- 知道
reject带不带理由是两种不同语义(§5.3) - 用
when只拦真正危险的调用,用description给审批人足够信息(§7) - 处理并行工具调用的批量审批,避开数量不匹配的报错(§8)
- 知道子智能体里的审批也在顶层处理(§9)
- 判断哪些工具值得拦(§10)
前置依赖: 第 26 章(LangGraph 的 interrupt 与 Command resume——本章就是它的封装)、第 40 章(checkpointer)、第 43 章 §5(权限的 interrupt 模式)、第 47 章(子智能体,§9 会用到)。
参考文档:Human-in-the-loop
建议阅读顺序: §5 是核心,四种处置的字段名和语义差异必须准确。§7 是让审批可用的关键。§2 的两个坑建议先看——它们会让你的第一次尝试直接报错。
本章验证环境:deepagents 0.7.12、langchain 1.2.x。全部实验零 token。
2. 两个会让你第一次就报错的坑 #
先把两个坑摆出来,因为它们太容易撞上了。
2.1. resume 的载荷必须是 {"decisions": [...]} #
很多教程和早期文档里的写法是这样的:
# 报错的写法
agent.invoke(Command(resume=[{"type": "accept"}]), cfg)TypeError: list indices must be integers or slices, not str翻源码就明白了:
decisions = interrupt(hitl_request)["decisions"]它要的是一个带 decisions 键的字典,不是裸列表:
# 正确写法
agent.invoke(Command(resume={"decisions": [{"type": "approve"}]}), cfg)2.2. 处置类型叫 approve,不叫 accept #
老版本 LangChain 用 accept,现在是 approve:
DecisionType = Literal['approve', 'edit', 'reject', 'respond']是四种,不是常见说法里的三种。 用错名字会报 ValueError: Unexpected human decision。
这两个坑加起来,把「照文档抄一遍」变成了必然失败。记住这一条就够了:
Command(resume={"decisions": [{"type": "approve"}]})
3. 最小可用的审批 #
3.1. 一行配置 #
"""最小审批:拦下 write_file。"""
from deepagents import create_deep_agent
from langgraph.checkpoint.memory import InMemorySaver
agent = create_deep_agent(
model="deepseek:deepseek-v4-flash",
interrupt_on={"write_file": True}, # 就这一行
checkpointer=InMemorySaver(), # 必需,见 §4
)
cfg = {"configurable": {"thread_id": "demo"}}
out = agent.invoke({"messages": [{"role": "user", "content": "写份报告到 /report.md"}]}, cfg)
print("__interrupt__" in out) # True,跑到 write_file 就停了interrupt_on 是 create_deep_agent 的参数,值为 True 表示用默认配置拦这个工具。
3.2. 中断时拿到什么 #
itr = out["__interrupt__"][0] # 是个 list,取第一个
print(itr.value){
"action_requests": [
{
"name": "write_file",
"args": {"file_path": "/report.md", "content": "初稿内容"},
"description": "Tool execution requires approval\n\nTool: write_file\nArgs: {'file_path': '/report.md', 'content': '初稿内容'}"
}
],
"review_configs": [
{
"action_name": "write_file",
"allowed_decisions": ["approve", "edit", "reject", "respond"]
}
]
}两个部分:
action_requests:模型想干什么。name+args是原始工具调用,description是给审批人看的说明文本review_configs:这次审批允许哪些处置。默认四种全开
Interrupt 对象本身还有个 id(比如 6ecb5d82354289993bc50dfc075dc97d),做 Web 审批界面时用来做幂等键。
3.3. 中断发生在工具执行之前 #
这点必须确认清楚——拦下来的时候,工具到底跑没跑?
print("文件系统:", list(out.get("files", {}) or {}))
print("消息:", [(type(m).__name__, str(m.content)[:20]) for m in out["messages"]])
print("next 节点:", agent.get_state(cfg).next)文件系统: [] ← 工具还没跑
消息: [('HumanMessage', '写'), ('AIMessage', '')] ← 没有 ToolMessage
next 节点: ('HumanInTheLoopMiddleware.after_model',)三个证据都指向同一结论:
中断点在
after_model——模型已经决定要调工具,但工具一行代码都没执行。
这就是审批有意义的前提。如果中断发生在工具执行之后,那批准与否都无所谓了。
4. checkpointer 是硬要求 #
不带 checkpointer 会怎样?中断能发生,但恢复不了:
agent = create_deep_agent(model=..., interrupt_on={"write_file": True}) # 无 checkpointer
out = agent.invoke({"messages": [...]})
print("__interrupt__" in out) # True,能中断
agent.invoke(Command(resume={"decisions": [{"type": "approve"}]}))RuntimeError: Cannot use Command(resume=...) without checkpointer道理很直白:审批意味着进程可以等上几分钟甚至几小时,中间的状态必须存下来。checkpointer 就是存状态的地方。
这也意味着审批必须配 thread_id——恢复时靠它找回现场。生产环境用 AsyncPostgresSaver 之类的持久化实现,InMemorySaver 一重启就全丢了。
5. 四种处置 #
四种处置的字段定义(从源码里读出来的 TypedDict):
| 处置 | 字段 | 工具执行吗 |
|---|---|---|
approve |
只有 type |
执行,用原参数 |
edit |
type + edited_action |
执行,用改后的参数 |
reject |
type + message(可选) |
不执行 |
respond |
type + message(必填) |
不执行,用 message 当结果 |
5.1. approve:批准 #
r = agent.invoke(Command(resume={"decisions": [{"type": "approve"}]}), cfg)文件: ['/report.md']
HumanMessage '写份报告'
AIMessage '' -> [('write_file', {'file_path': '/report.md', 'content': '初稿'})]
ToolMessage 'Updated file /report.md'
AIMessage '我知道了。'工具按原参数执行,流程继续。
5.2. edit:改写入参 #
字段名是 edited_action(写成 action 会报 KeyError: 'edited_action'):
r = agent.invoke(Command(resume={"decisions": [{
"type": "edit",
"edited_action": {
"name": "write_file",
"args": {"file_path": "/report_v2.md", "content": "人工改过的内容"},
},
}]}), cfg)文件: ['/report_v2.md']
AIMessage '' -> [('write_file', {'file_path': '/report_v2.md', 'content': '人工改过的内容'})]
ToolMessage 'Updated file /report_v2.md'注意 AIMessage 里的 tool_calls:显示的是改后的参数。
edit会改写对话历史里的工具调用,不是追加一条修正。
模型回头看历史,看到的是「我调用了 /report_v2.md」——它不知道自己原本写的是 /report.md。这个设计避免了模型对着「我错了、人改了」的历史绕圈子,但也意味着审批日志得靠 checkpointer 的历史快照去查,对话历史里留不下痕迹。
5.3. reject:拒绝(带不带理由是两种语义) #
不带 message:
{"type": "reject"}'User rejected the tool call for `write_file` with id c0. The tool was not executed.
Do not retry this tool call unless the user explicitly requests it.'带 message:
{"type": "reject", "message": "这个路径不对,报告要放到 /reports/ 下。"}'User rejected the tool call for `write_file` with reason: 这个路径不对,报告要放到 /reports/ 下。'注意最后那句「不要重试」在带理由的版本里消失了。
这个差异很讲究:
- 不带理由 = 「别干这个,也别再试」,用于彻底否决
- 带理由 = 「这样不行,按我说的调整」,模型会拿着理由重试
所以想让智能体改正后继续,必须给理由。光说 reject 它就停在那了。
5.4. respond:代替工具作答 #
{"type": "respond", "message": "别写文件,先给我看大纲。"}文件: []
AIMessage '' -> [('write_file', {...})]
ToolMessage '别写文件,先给我看大纲。' ← 直接就是人说的话
AIMessage '我知道了。'工具不执行,ToolMessage 的内容就是你给的那句话。 从模型的角度看,它调了个工具、工具返回了这段文字。
reject 和 respond 的区别在于框架措辞:reject 会包上「用户拒绝了」的外壳,respond 是原文直接注入。
用 respond 的场景:
# 智能体想查数据库,但你手上已经有答案了
{"type": "respond", "message": "不用查了,Q3 营收是 4200 万,同比 +18%。"}这相当于人工代替工具返回结果,能省掉一次真实调用。
6. 自定义提示文本 #
默认的 description 是拼出来的:
Tool execution requires approval
Tool: write_file
Args: {'file_path': '/report.md', 'content': '初稿内容'}第一行来自 description_prefix。要改就得显式装 middleware:
"""自定义审批提示前缀。"""
from langchain.agents.middleware import HumanInTheLoopMiddleware
agent = create_deep_agent(
model=...,
middleware=[HumanInTheLoopMiddleware(
interrupt_on={"write_file": True, "delete": True},
description_prefix="【需要审批】以下操作会修改生产数据",
)],
checkpointer=InMemorySaver(),
)【需要审批】以下操作会修改生产数据
Tool: write_file
Args: {'file_path': '/a.md', 'content': 'x'}HumanInTheLoopMiddleware 的签名只有两个参数:
HumanInTheLoopMiddleware(interrupt_on: dict[str, bool | InterruptOnConfig], *,
description_prefix: str = 'Tool execution requires approval')用 interrupt_on= 参数和显式装这个 middleware 是等价的,后者只是多了个改前缀的机会。
7. 精细控制:InterruptOnConfig #
把 True 换成配置字典,可以精细控制。四个字段:
| 字段 | 类型 | 作用 |
|---|---|---|
allowed_decisions |
list[DecisionType] |
限制这个工具允许哪些处置 |
description |
str 或三参数回调 |
自定义说明文本 |
args_schema |
dict |
给审批界面用的参数 schema |
when |
单参数回调 | 条件拦截:返回 True 才拦 |
7.1. when:只拦真正危险的调用 #
这是让审批实际可用的关键。全拦 write_file 太粗暴——写临时草稿也要人点一下,没人受得了。
"""只有写 /tmp 之外的路径才需要审批。"""
def only_outside_tmp(req) -> bool:
p = req.tool_call["args"].get("file_path", "")
return not p.startswith("/tmp/")
agent = create_deep_agent(
model=...,
interrupt_on={"write_file": {
"allowed_decisions": ["approve", "reject"],
"when": only_outside_tmp,
}},
checkpointer=InMemorySaver(),
)实测:
写 /tmp/scratch.md 触发审批: False 文件: ['/tmp/scratch.md']
写 /reports/final.md 触发审批: True 文件: []草稿直接放行,正式报告才拦。这才是能用的审批策略。
再看一个更贴近业务的:
"""按金额分级:小额自动过、大额必须审批。"""
def big_refund_only(req) -> bool:
return req.tool_call["args"].get("amount", 0) > 1000
interrupt_on = {"issue_refund": {"allowed_decisions": ["approve", "reject"],
"when": big_refund_only}}7.2. 两个回调的签名不一样 #
这是个很容易踩的坑:同一个配置字典里的两个回调,签名不同。
when 用 1 参数: 中断=True ← 正确
when 用 3 参数: TypeError: <lambda>() missing 2 required positional arguments# when:单参数,收 ToolCallRequest
def when(req: ToolCallRequest) -> bool: ...
# description:三参数
def description(tool_call: ToolCall, state: AgentState, runtime: Runtime) -> str: ...description 拿到 state 是有用的——可以把对话上下文放进审批提示里:
"""审批提示里带上足够的判断信息。"""
def describe(tool_call, state, runtime) -> str:
a = tool_call["args"]
return (f"即将写入文件\n"
f" 路径: {a.get('file_path')}\n"
f" 内容长度: {len(a.get('content', ''))} 字符\n"
f" 当前对话轮数: {len(state.get('messages', []))}\n"
f"确认继续?")即将写入文件
路径: /reports/q3.md
内容长度: 120 字符
当前对话轮数: 2
确认继续?审批提示的质量直接决定审批的质量。 默认那个 Args: {...} 在参数是一万字正文时完全没法看——审批人只会闭着眼点批准,那审批就成了摆设。
7.3. allowed_decisions 是强制的 #
限制了就真的不能用:
interrupt_on={"write_file": {"allowed_decisions": ["approve", "reject"]}}review_configs: [{"action_name": "write_file", "allowed_decisions": ["approve", "reject"]}]
用了不在白名单里的 edit:
ValueError: Unexpected human decision: {...}. Decision type 'edit' is not allowed用处是约束审批界面。比如支付类操作只允许「批准 / 拒绝」,不允许人工改金额——改金额这个权限太大了,应该走另一套流程。
8. 并行工具调用的批量审批 #
模型一次发多个工具调用时,审批是批量的。让模型同时发三个调用(两个 write_file + 一个 read_file),只拦 write_file:
action_requests 数量: 2
write_file {'file_path': '/a.md', 'content': 'A'}
write_file {'file_path': '/b.md', 'content': 'B'}
review_configs 数量: 2只有被拦的工具进 action_requests,read_file 不在里面。
处置时按顺序一一对应:
r = agent.invoke(Command(resume={"decisions": [
{"type": "approve"}, # 对应 /a.md
{"type": "reject", "message": "/b.md 不该写"}, # 对应 /b.md
]}), cfg)文件: ['/a.md']
ToolMessage 'User rejected the tool call for `write_file` with reason: /b.md 不该写'
ToolMessage 'Updated file /a.md'一个批准一个拒绝,各自生效。
8.1. 数量必须匹配 #
决策数量不匹配(2 个调用只给 1 个决策):
ValueError: Number of human decisions (1) does not match number of hanging tool calls (2).做审批界面时,decisions 数组长度必须等于 action_requests 长度,一个都不能少。
8.2. 并行执行的顺序陷阱 #
上面那个实验里还有个值得一提的现象。第三个调用是 read_file /a.md,而 /a.md 正是同一批里 write_file 要写的:
ToolMessage "Error: File '/a.md' not found"读失败了,即使写操作成功了。因为同一批工具调用是并行执行的,read_file 跑的时候 write_file 的结果还没落到文件系统里。
这跟审批没关系,是并行工具调用本身的语义。但审批场景下更容易碰到——人在中间停了很久,容易以为「先写的肯定写完了」。有依赖关系的操作不要放在同一批。
9. 子智能体的审批冒泡到顶层 #
子智能体也能配 interrupt_on(第 47 章 §5.5 说它默认继承)。那中断在哪里处理?
sub = SubAgent(name="w", description="写手", system_prompt="你是写手。",
interrupt_on={"write_file": True})
agent = create_deep_agent(model=..., subagents=[sub], checkpointer=InMemorySaver())
out = agent.invoke({"messages": [{"role": "user", "content": "开始"}]}, cfg)顶层收到 __interrupt__: True
action_requests: [{"name": "write_file", "args": {"file_path": "/sub.md", "content": "子写的"}, ...}]
批准后文件: ['/sub.md']
主智能体最后一句: '主收尾。'在顶层处理。 一次 Command(resume=...) 之后,子智能体继续跑完、把报告交回主智能体、主智能体收尾——整条链路自动恢复。
这个设计很合理:审批人面对的是一个系统,不该关心中断发生在第几层。但做界面时要注意——action_requests 里没有「这是谁发起的」这个信息。要区分的话,得靠 description 回调自己拼进去(比如在子智能体的配置里写死来源标识)。
10. 哪些工具值得拦 #
审批的成本是人的时间,拦错了就是灾难。判断标准是「这个操作能不能自动撤销」:
| 类型 | 例子 | 拦不拦 | 理由 |
|---|---|---|---|
| 不可逆的外部写 | 发邮件、发短信、推送通知 | 必拦 | 发出去就收不回来 |
| 资金操作 | 退款、扣款、开票 | 必拦 | 涉钱,且常有合规要求 |
| 破坏性操作 | delete、清空表、删分支 |
必拦 | 数据没了就没了 |
| 生产环境写入 | 改配置、发版、改数据库 | 必拦 | 影响面大 |
| 对外发布 | 发工单回复、发公告 | 看场景 | 关乎品牌口碑 |
沙箱内的 execute |
跑脚本 | 看隔离程度 | 沙箱够严就不用拦 |
| 读操作 | read_file、ls、grep、检索 |
不拦 | 没有副作用 |
| 临时文件写入 | 写 /tmp/、写草稿 |
不拦 | 用 when 排除掉 |
| 内部状态 | write_todos |
不拦 | 纯规划,无外部影响 |
一个完整的配置示例:
"""生产环境的审批配置:分级拦截。"""
from deepagents import create_deep_agent
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
def risky_path(req) -> bool:
"""草稿区随便写,其他路径要审批。"""
p = req.tool_call["args"].get("file_path", "")
return not p.startswith(("/tmp/", "/drafts/"))
def describe_write(tool_call, state, runtime) -> str:
a = tool_call["args"]
return (f"写入文件:{a.get('file_path')}\n"
f"内容长度:{len(a.get('content', ''))} 字符\n"
f"内容开头:{a.get('content', '')[:200]}")
def big_amount(req) -> bool:
return req.tool_call["args"].get("amount", 0) > 1000
agent = create_deep_agent(
model="deepseek:deepseek-v4-flash",
tools=[send_email, issue_refund],
interrupt_on={
# 不可逆的外部动作:全拦,且不许人工改参数
"send_email": {"allowed_decisions": ["approve", "reject"]},
# 资金操作:大额才拦
"issue_refund": {"allowed_decisions": ["approve", "reject"],
"when": big_amount},
# 删除:全拦
"delete": True,
# 文件写入:只拦正式区,且给足信息
"write_file": {"allowed_decisions": ["approve", "edit", "reject"],
"description": describe_write,
"when": risky_path},
},
checkpointer=checkpointer, # 生产环境用持久化实现
)三个设计要点:
send_email不给edit:邮件正文让人在审批框里改,容易改出问题,不如拒绝后让智能体重写issue_refund用when分级:小额自动过,否则审批人会被淹没write_file给edit:改个路径、改个别字是很自然的需求
10.1. 审批和权限怎么分工 #
第 43 章的权限系统也能 interrupt。两者的分工:
权限 interrupt |
interrupt_on |
|
|---|---|---|
| 作用对象 | 只有文件操作 | 任何工具 |
| 判断依据 | 路径 glob 匹配 | 工具名 + 可选的 when 回调 |
| 表达能力 | 路径规则 | 任意 Python 条件 |
| 和沙箱后端 | 互斥(第 43 章 §6.3) | 不冲突 |
结论很清晰:
文件路径级的管控用权限,业务语义级的管控用
interrupt_on。
用了沙箱后端时权限系统不可用,interrupt_on 就成了唯一选择。
11. 实战约定与坑 #
- resume 载荷必须是
{"decisions": [...]},裸列表报TypeError: list indices must be integers or slices, not str(§2.1) - 处置类型叫
approve不叫accept,共四种:approve/edit/reject/respond(§2.2) checkpointer是硬要求:不带能中断但 resume 会报RuntimeError。生产环境用持久化实现(§4)- 中断点在
after_model,工具一行都没执行——这是审批有意义的前提(§3.3) edit的字段名是edited_action,写成action报KeyError(§5.2)edit会改写对话历史里的工具调用,模型看不到「原本是什么」。审批日志要靠 checkpointer 历史查(§5.2)reject带不带理由是两种语义:不带会附「不要重试」,带了则让模型按理由调整。想让它改正后继续就必须给理由(§5.3)respond的message是必填的,内容会原样成为ToolMessage(§5.4)when是单参数回调,description是三参数回调。 同一个配置字典里签名不同(§7.2)- 审批提示的质量决定审批的质量。 默认的
Args: {...}在参数很长时完全没法看,用description回调整理(§7.2) allowed_decisions是强制的,用了白名单外的处置报ValueError(§7.3)decisions数量必须等于action_requests数量,否则报ValueError: Number of human decisions ... does not match(§8.1)- 只有被拦的工具进
action_requests,没配拦截的不出现(§8) - 有依赖关系的操作不要放同一批并行调用——同批里读不到同批写的结果(§8.2)
- 子智能体的审批在顶层处理,一次 resume 恢复整条链路。但
action_requests里没有来源信息,要区分得自己拼进description(§9) - 拦截的判断标准是「能不能自动撤销」:不可逆外部动作、资金、删除、生产写入必拦;读操作和临时文件不拦(§10)
- 用
when做分级,别全拦。全拦会让人成为瓶颈,智能体的价值直接归零(§7.1) - 敏感操作不给
edit:让人在审批框里改邮件正文或支付金额,风险比拒绝重做更大(§10) interrupt_on和权限interrupt分工:路径级用权限,业务语义级用interrupt_on。用沙箱后端时只能用后者(§10.1)- 改审批提示前缀要显式装
HumanInTheLoopMiddleware,description_prefix不是create_deep_agent的参数(§6)
12. 练习 #
跑通四种处置。 用 §3 那个最小配置,分别用
approve/edit/reject/respond恢复四次(各用不同的thread_id),对比最终的文件系统和消息列表。验证 reject 的两种语义。 一次不带
message、一次带,把两条ToolMessage原文打出来,找出「不要重试」那句的差异。然后接真模型,看它在两种情况下是否重试。踩字段名的坑。 故意把
edited_action写成action,确认报KeyError。再把 resume 载荷写成裸列表,确认报TypeError。做分级审批。 定义一个
issue_refund(amount)工具,用when让 1000 以下自动过、以上要审批。分别用 500 和 5000 调用,确认只有后者中断。优化审批提示。 让智能体写一份两千字的报告,先看默认
description有多难读,再用三参数description回调改成「路径 + 长度 + 前 200 字」的格式。批量审批。 让模型一次发三个
write_file,用decisions数组分别处置(批准、改参、拒绝)。然后故意只给两个决策,确认报数量不匹配。子智能体审批。 给子智能体配
interrupt_on,确认中断出现在顶层invoke的返回里,且一次 resume 能让整条链路跑完。(选做)Web 审批闭环。 用
interrupt.id做幂等键,把action_requests存进数据库,做一个简单的审批页面:列出待审项、点批准/拒绝后调Command(resume=...)。注意换成持久化的 checkpointer。
13. 本章小结 #
interrupt_on={"tool_name": True}是最小写法,可以拦任何工具(不限于文件操作)。- 两个必踩的坑:resume 载荷必须是
{"decisions": [...]};类型叫approve不叫accept。 checkpointer是硬要求,因为审批意味着进程要等人——状态必须存下来。- 中断点在
after_model:模型决定了要调工具,但工具还没执行。 - 中断数据有两部分:
action_requests(模型想干什么)和review_configs(允许怎么处置)。 - 四种处置:
approve原样执行、edit改参执行、reject不执行、respond用人给的话当工具结果。 edit的字段名是edited_action,且它会改写历史里的工具调用,模型看不到原始版本。reject带理由和不带理由是两种语义:不带附「不要重试」,带了让模型按理由调整。想要它改正后继续就必须给理由。when回调是让审批可用的关键:只拦真正危险的调用,草稿和临时文件直接放行。- 两个回调签名不同:
when(req)单参数,description(tool_call, state, runtime)三参数。 - 审批提示的质量决定审批的质量——默认格式在长参数下没法看,会让审批人闭眼点批准。
- 并行调用批量审批:只有被拦的进
action_requests,decisions数量必须严格匹配。 - 子智能体的审批冒泡到顶层,一次 resume 恢复整条链路。
- 拦截标准是「能不能自动撤销」:不可逆外部动作、资金、删除、生产写入必拦;读操作不拦。
- 敏感操作不给
edit,拒绝后让智能体重做比人工改参更安全。 - 和权限系统的分工:路径级管控用权限,业务语义级用
interrupt_on;用沙箱后端时只能用后者。