1. 本章目标 #

第 43 章 §5 用过一次 interrupt 模式:权限规则命中后暂停,等人批准。那是权限系统的一个分支。这一章讲的是通用的审批机制——任何工具调用都可以拦下来等人处置。

这件事的价值不用多说:智能体删库、发错邮件、调错支付接口,一次就够了。但审批做得粗糙也很致命——把所有工具都拦一遍,人就成了瓶颈,智能体的价值直接归零。

所以这一章的重点不只是「怎么拦」,还有「怎么只拦该拦的」(§7 的条件拦截)和「批准之外的三种处置」(§5)。

学完你应能:

前置依赖: 第 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"]
    }
  ]
}

两个部分:

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,      # 生产环境用持久化实现
)

三个设计要点:

  1. send_email 不给 edit:邮件正文让人在审批框里改,容易改出问题,不如拒绝后让智能体重写
  2. issue_refund 用 when 分级:小额自动过,否则审批人会被淹没
  3. write_file 给 edit:改个路径、改个别字是很自然的需求

10.1. 审批和权限怎么分工 #

第 43 章的权限系统也能 interrupt。两者的分工:

权限 interrupt interrupt_on
作用对象 只有文件操作 任何工具
判断依据 路径 glob 匹配 工具名 + 可选的 when 回调
表达能力 路径规则 任意 Python 条件
和沙箱后端 互斥(第 43 章 §6.3) 不冲突

结论很清晰:

文件路径级的管控用权限,业务语义级的管控用 interrupt_on。

用了沙箱后端时权限系统不可用,interrupt_on 就成了唯一选择。

11. 实战约定与坑 #

  1. resume 载荷必须是 {"decisions": [...]},裸列表报 TypeError: list indices must be integers or slices, not str(§2.1)
  2. 处置类型叫 approve 不叫 accept,共四种:approve / edit / reject / respond(§2.2)
  3. checkpointer 是硬要求:不带能中断但 resume 会报 RuntimeError。生产环境用持久化实现(§4)
  4. 中断点在 after_model,工具一行都没执行——这是审批有意义的前提(§3.3)
  5. edit 的字段名是 edited_action,写成 action 报 KeyError(§5.2)
  6. edit 会改写对话历史里的工具调用,模型看不到「原本是什么」。审批日志要靠 checkpointer 历史查(§5.2)
  7. reject 带不带理由是两种语义:不带会附「不要重试」,带了则让模型按理由调整。想让它改正后继续就必须给理由(§5.3)
  8. respond 的 message 是必填的,内容会原样成为 ToolMessage(§5.4)
  9. when 是单参数回调,description 是三参数回调。 同一个配置字典里签名不同(§7.2)
  10. 审批提示的质量决定审批的质量。 默认的 Args: {...} 在参数很长时完全没法看,用 description 回调整理(§7.2)
  11. allowed_decisions 是强制的,用了白名单外的处置报 ValueError(§7.3)
  12. decisions 数量必须等于 action_requests 数量,否则报 ValueError: Number of human decisions ... does not match(§8.1)
  13. 只有被拦的工具进 action_requests,没配拦截的不出现(§8)
  14. 有依赖关系的操作不要放同一批并行调用——同批里读不到同批写的结果(§8.2)
  15. 子智能体的审批在顶层处理,一次 resume 恢复整条链路。但 action_requests 里没有来源信息,要区分得自己拼进 description(§9)
  16. 拦截的判断标准是「能不能自动撤销」:不可逆外部动作、资金、删除、生产写入必拦;读操作和临时文件不拦(§10)
  17. 用 when 做分级,别全拦。全拦会让人成为瓶颈,智能体的价值直接归零(§7.1)
  18. 敏感操作不给 edit:让人在审批框里改邮件正文或支付金额,风险比拒绝重做更大(§10)
  19. interrupt_on 和权限 interrupt 分工:路径级用权限,业务语义级用 interrupt_on。用沙箱后端时只能用后者(§10.1)
  20. 改审批提示前缀要显式装 HumanInTheLoopMiddleware,description_prefix 不是 create_deep_agent 的参数(§6)

12. 练习 #

  1. 跑通四种处置。 用 §3 那个最小配置,分别用 approve / edit / reject / respond 恢复四次(各用不同的 thread_id),对比最终的文件系统和消息列表。

  2. 验证 reject 的两种语义。 一次不带 message、一次带,把两条 ToolMessage 原文打出来,找出「不要重试」那句的差异。然后接真模型,看它在两种情况下是否重试。

  3. 踩字段名的坑。 故意把 edited_action 写成 action,确认报 KeyError。再把 resume 载荷写成裸列表,确认报 TypeError。

  4. 做分级审批。 定义一个 issue_refund(amount) 工具,用 when 让 1000 以下自动过、以上要审批。分别用 500 和 5000 调用,确认只有后者中断。

  5. 优化审批提示。 让智能体写一份两千字的报告,先看默认 description 有多难读,再用三参数 description 回调改成「路径 + 长度 + 前 200 字」的格式。

  6. 批量审批。 让模型一次发三个 write_file,用 decisions 数组分别处置(批准、改参、拒绝)。然后故意只给两个决策,确认报数量不匹配。

  7. 子智能体审批。 给子智能体配 interrupt_on,确认中断出现在顶层 invoke 的返回里,且一次 resume 能让整条链路跑完。

  8. (选做)Web 审批闭环。 用 interrupt.id 做幂等键,把 action_requests 存进数据库,做一个简单的审批页面:列出待审项、点批准/拒绝后调 Command(resume=...)。注意换成持久化的 checkpointer。

13. 本章小结 #

  1. interrupt_on={"tool_name": True} 是最小写法,可以拦任何工具(不限于文件操作)。
  2. 两个必踩的坑:resume 载荷必须是 {"decisions": [...]};类型叫 approve 不叫 accept。
  3. checkpointer 是硬要求,因为审批意味着进程要等人——状态必须存下来。
  4. 中断点在 after_model:模型决定了要调工具,但工具还没执行。
  5. 中断数据有两部分:action_requests(模型想干什么)和 review_configs(允许怎么处置)。
  6. 四种处置:approve 原样执行、edit 改参执行、reject 不执行、respond 用人给的话当工具结果。
  7. edit 的字段名是 edited_action,且它会改写历史里的工具调用,模型看不到原始版本。
  8. reject 带理由和不带理由是两种语义:不带附「不要重试」,带了让模型按理由调整。想要它改正后继续就必须给理由。
  9. when 回调是让审批可用的关键:只拦真正危险的调用,草稿和临时文件直接放行。
  10. 两个回调签名不同:when(req) 单参数,description(tool_call, state, runtime) 三参数。
  11. 审批提示的质量决定审批的质量——默认格式在长参数下没法看,会让审批人闭眼点批准。
  12. 并行调用批量审批:只有被拦的进 action_requests,decisions 数量必须严格匹配。
  13. 子智能体的审批冒泡到顶层,一次 resume 恢复整条链路。
  14. 拦截标准是「能不能自动撤销」:不可逆外部动作、资金、删除、生产写入必拦;读操作不拦。
  15. 敏感操作不给 edit,拒绝后让智能体重做比人工改参更安全。
  16. 和权限系统的分工:路径级管控用权限,业务语义级用 interrupt_on;用沙箱后端时只能用后者。