Typography

学习笔记

Deep Agents 学习07 Human in the loop

发布于 # Deep Agents

只要 Agent 真正具备执行能力,就迟早会遇到一个绕不过去的问题:哪些动作可以自动完成,哪些动作必须在人类确认后再继续。Deep Agents 并没有把这个问题简化成一个“确认按钮”,而是把审批做成了一条可暂停、可恢复、可编排的执行链路,这也是它能进入真实业务流程的重要前提。

为什么值得关注

人工审批的价值不在于让 Agent 变慢,而在于把“直接执行”改造成“先暂停、可审查、再恢复”的受控流程。只有这样,高风险动作才不会因为一次错误判断就立刻落地。

这套机制尤其适合控制这些会产生真实后果的动作:

它的核心价值不是让 Agent 变慢,而是把“自动执行”改造成“可中断、可审查、可恢复”的执行流程。


这套机制解决什么问题

默认情况下,Agent 一旦决定调用某个工具,就会直接执行。

这在低风险场景下没有问题,但在高风险场景里会带来明显风险:

人工审批的作用就是:

可以把它理解成:

审批执行流

先用这条执行链路看一眼中断到底发生在什么位置,再往下看 interrupt_on 和恢复执行的代码,会更容易把整个机制串起来。

graph LR
    Agent[Agent] --> Check{Interrupt?}
    Check --> |no| Execute[Execute]
    Check --> |yes| Human{Human}

    Human --> |approve| Execute
    Human --> |edit| Execute
    Human --> |reject| Cancel[Cancel]

    Execute --> Agent
    Cancel --> Agent

这张图对应的就是整套审批机制的核心路径:先判断是否命中 interrupt_on,命中后暂停交给人类,再根据决策恢复或取消。


核心配置入口:interrupt_on

人工审批通过 interrupt_on 参数配置。

它的本质是一个字典:

最基础示例

from langchain.tools import tool
from deepagents import create_deep_agent
from langgraph.checkpoint.memory import MemorySaver

@tool
def delete_file(path: str) -> str:
    """Delete a file from the filesystem."""
    return f"Deleted {path}"

@tool
def read_file(path: str) -> str:
    """Read a file from the filesystem."""
    return f"Contents of {path}"

@tool
def send_email(to: str, subject: str, body: str) -> str:
    """Send an email."""
    return f"Sent email to {to}"

checkpointer = MemorySaver()

agent = create_deep_agent(
    model="google_genai:gemini-3.1-pro-preview",
    tools=[delete_file, read_file, send_email],
    interrupt_on={
        "delete_file": True,
        "read_file": False,
        "send_email": {"allowed_decisions": ["approve", "reject"]},
    },
    checkpointer=checkpointer
)

这个配置表达了什么


interrupt_on 的三种写法

写法一:True

"delete_file": True

表示:

默认通常包含:

写法二:False

"read_file": False

表示:

写法三:显式配置可选决策

"send_email": {"allowed_decisions": ["approve", "reject"]}

表示:

这种方式最适合按风险等级做细分控制。


三种决策类型

人工审批阶段,通常有三种决策。

approve

含义:

适合:

edit

含义:

适合:

reject

含义:

适合:


如何按风险等级配置工具

真正开始接业务时,最关键的问题通常不是“要不要审批”,而是“哪些动作该审批到什么程度”。如果所有工具都一刀切,体验会非常差;如果放得太开,又容易把高风险动作直接交给模型。按风险分层配置,基本就是在效率和控制之间找平衡。

高风险工具

高风险工具的共同特点是:一旦执行错误,后果往往已经发生,而且通常很难靠后续补救完全消除。例如:

建议:

配置示例:

interrupt_on = {
    "delete_file": {"allowed_decisions": ["approve", "edit", "reject"]},
    "send_email": {"allowed_decisions": ["approve", "edit", "reject"]},
}

中风险工具

中风险工具往往不是绝对不能自动执行,而是更适合用“要么照计划执行,要么直接拦下”这种简单判断。例如:

建议:

原因:

配置示例:

interrupt_on = {
    "write_file": {"allowed_decisions": ["approve", "reject"]},
}

极低风险工具

只读类工具通常更适合保持低摩擦。如果每一次读取和搜索都要审批,整个 Agent 的工作流很快就会被打断得非常碎。例如:

建议:

配置示例:

interrupt_on = {
    "read_file": False,
    "list_files": False,
}

特殊场景:必须批准,不能拒绝

也有一些流程约束非常强的场景,系统设计上就是不允许“模糊通过”或“跳过这一步”。虽然这不是最常见的配置,但在某些强审批链里确实会出现:

配置示例:

interrupt_on = {
    "critical_operation": {"allowed_decisions": ["approve"]},
}

这种配置适合非常强流程约束的场景,但要谨慎使用。


为什么 checkpointer 是必需项

一旦把审批理解成“中途暂停,再从原地继续”,checkpointer 的必要性就会非常直观。它不是为了让系统更高级,而是为了让“停下来之后还能继续”这件事真正成立。

人工审批本质上是一种“暂停后恢复”的工作流。

流程是:

  1. Agent 运行到某个工具调用点
  2. 因为命中 interrupt_on,系统暂停
  3. 当前执行状态需要被保存
  4. 等待人类输入决策
  5. 再从中断点恢复执行

如果没有 checkpointer

所以人工审批场景里,checkpointer 不是可选优化,而是必需组件。

最基础写法

from langgraph.checkpoint.memory import MemorySaver

checkpointer = MemorySaver()

然后传给 Agent:

agent = create_deep_agent(
    ...,
    checkpointer=checkpointer,
)

如何处理一次中断

从接入角度看,真正需要你写代码处理的地方,就是这里:Agent 不是直接返回最终结果,而是先把一个“待审批状态”交还给你。你的应用需要读取这个状态、展示给人、收集决策,再把决策送回去。

当 Agent 触发人工审批时,它不会直接完成任务,而是返回一个中断结果。

你需要做的是:

  1. 检查是否发生中断
  2. 读取待审批动作
  3. 生成用户决策
  4. Command(resume=...) 恢复执行

完整示例

from langchain_core.utils.uuid import uuid7
from langgraph.types import Command

config = {"configurable": {"thread_id": str(uuid7())}}

result = agent.invoke(
    {"messages": [{"role": "user", "content": "Delete the file temp.txt"}]},
    config=config,
    version="v2",
)

if result.interrupts:
    interrupt_value = result.interrupts[0].value
    action_requests = interrupt_value["action_requests"]
    review_configs = interrupt_value["review_configs"]

    config_map = {cfg["action_name"]: cfg for cfg in review_configs}

    for action in action_requests:
        review_config = config_map[action["name"]]
        print(f"Tool: {action['name']}")
        print(f"Arguments: {action['args']}")
        print(f"Allowed decisions: {review_config['allowed_decisions']}")

    decisions = [
        {"type": "approve"}
    ]

    result = agent.invoke(
        Command(resume={"decisions": decisions}),
        config=config,
        version="v2",
    )

print(result.value["messages"][-1].content)

为什么一定要用同一个 thread_id

恢复执行时,必须使用和第一次调用完全相同的 thread_id

因为:

如果你换了 thread_id

正确做法

config = {"configurable": {"thread_id": "my-thread"}}

result = agent.invoke(input, config=config, version="v2")
result = agent.invoke(Command(resume={...}), config=config, version="v2")

必须保持一致的内容

恢复时至少要保持:


为什么示例里使用 version="v2"

人工审批和中断恢复流程依赖中断信息在结果对象中的稳定表现形式。

在处理这类执行控制逻辑时,应显式使用:

version="v2"

这样可以确保:


如何读取中断信息

result.interrupts 不为空时,说明发生了中断。

中断结果里最关键的通常有两个字段:

action_requests

它表示:

review_configs

它表示:

通常会先把二者拼起来,形成“动作 + 决策范围”的完整视图,再展示给用户。


多个工具同时触发审批时怎么办

如果一次执行里同时调用了多个需要审批的工具,它们通常会被打包进同一个 interrupt。

这意味着:

示例

config = {"configurable": {"thread_id": str(uuid7())}}

result = agent.invoke(
    {"messages": [{
        "role": "user",
        "content": "Delete temp.txt and send an email to admin@example.com"
    }]},
    config=config,
    version="v2",
)

if result.interrupts:
    interrupt_value = result.interrupts[0].value
    action_requests = interrupt_value["action_requests"]

    assert len(action_requests) == 2

    decisions = [
        {"type": "approve"},
        {"type": "reject"}
    ]

    result = agent.invoke(
        Command(resume={"decisions": decisions}),
        config=config,
        version="v2",
    )

这里最容易出错的地方

决策列表 decisions 的顺序必须严格对应 action_requests 的顺序。

也就是说:

顺序错了,就会把错误决策作用到错误工具上。

一个稳妥写法

if result.interrupts:
    interrupt_value = result.interrupts[0].value
    action_requests = interrupt_value["action_requests"]

    decisions = []
    for action in action_requests:
        decision = get_user_decision(action)
        decisions.append(decision)

    result = agent.invoke(
        Command(resume={"decisions": decisions}),
        config=config,
        version="v2",
    )

如何修改工具参数后再执行

如果某个工具允许 edit,就可以在批准执行前改参数。

示例

if result.interrupts:
    interrupt_value = result.interrupts[0].value
    action_request = interrupt_value["action_requests"][0]

    print(action_request["args"])

    decisions = [{
        "type": "edit",
        "edited_action": {
            "name": action_request["name"],
            "args": {"to": "team@company.com", "subject": "...", "body": "..."}
        }
    }]

    result = agent.invoke(
        Command(resume={"decisions": decisions}),
        config=config,
        version="v2",
    )

edit 时必须注意什么

编辑后的动作里,必须显式带上:

不要只改局部字段而忽略其他参数,除非你的上层逻辑已经确保参数补全。

edit 最适合哪些场景

例如:


子代理中的人工审批

子代理也可以参与人工审批,而且有两种层面。


子代理层面一:对子代理工具调用做审批

每个子代理都可以有自己的 interrupt_on 配置。

如果子代理里也定义了同名工具,那么它自己的 interrupt_on 可以覆盖主 Agent 的配置。

示例

agent = create_deep_agent(
    model="google_genai:gemini-3.1-pro-preview",
    tools=[delete_file, read_file],
    interrupt_on={
        "delete_file": True,
        "read_file": False,
    },
    subagents=[{
        "name": "file-manager",
        "description": "Manages file operations",
        "system_prompt": "You are a file management assistant.",
        "tools": [delete_file, read_file],
        "interrupt_on": {
            "delete_file": True,
            "read_file": True,
        }
    }],
    checkpointer=checkpointer
)

这个例子说明什么

主 Agent:

子代理 file-manager

这意味着审批策略可以按角色细分,而不必全局一刀切。


子代理层面二:工具内部直接调用 interrupt()

除了“对工具调用本身做审批”,还可以在工具逻辑内部直接调用 interrupt(),实现更细粒度的人机协同。

这种方式适合:

示例

from langchain.agents import create_agent
from langchain_anthropic import ChatAnthropic
from langchain.messages import HumanMessage
from langchain.tools import tool
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.types import Command, interrupt

from deepagents.graph import create_deep_agent
from deepagents.middleware.subagents import CompiledSubAgent

@tool(description="Request human approval before proceeding with an action.")
def request_approval(action_description: str) -> str:
    """Request human approval using the interrupt() primitive."""
    approval = interrupt({
        "type": "approval_request",
        "action": action_description,
        "message": f"Please approve or reject: {action_description}",
    })

    if approval.get("approved"):
        return f"Action '{action_description}' was APPROVED. Proceeding..."
    else:
        return f"Action '{action_description}' was REJECTED. Reason: {approval.get('reason', 'No reason provided')}"


def main():
    checkpointer = InMemorySaver()
    model = ChatAnthropic(
        model_name="claude-sonnet-4-6",
        max_tokens=4096,
    )

    compiled_subagent = create_agent(
        model=model,
        tools=[request_approval],
        name="approval-agent",
    )

    parent_agent = create_deep_agent(
        model="google_genai:gemini-3.1-pro-preview",
        checkpointer=checkpointer,
        subagents=[
            CompiledSubAgent(
                name="approval-agent",
                description="An agent that can request approvals",
                runnable=compiled_subagent,
            )
        ],
    )

    thread_id = "test_interrupt_directly"
    config = {"configurable": {"thread_id": thread_id}}

    result = parent_agent.invoke(
        {
            "messages": [
                HumanMessage(
                    content="Use the task tool to launch the approval-agent sub-agent. "
                    "Tell it to use the request_approval tool to request approval for 'deploying to production'."
                )
            ]
        },
        config=config,
        version="v2",
    )

    if result.interrupts:
        interrupt_value = result.interrupts[0].value

        result2 = parent_agent.invoke(
            Command(resume={"approved": True}),
            config=config,
            version="v2",
        )

这类方式适合什么

适合:

interrupt_on 的区别

二者可以同时存在。


实践中的设计建议

先按风险分层,再决定是否审批

不要一股脑把所有工具都设成审批。

更合理的分法是:

高风险

建议:

中风险

建议:

低风险

建议:


审批界面应该展示什么

如果你要自己做审批 UI,至少应给用户展示:

如果是 edit 场景,最好还能展示:

这样审批才真正可用,而不是机械点按钮。


常见错误与排查

配了 interrupt_on 但没有触发审批

常见原因:

排查方向:

触发中断后无法恢复

常见原因:

解决:

多个工具审批时,决策应用错位

常见原因:

解决:

edit 后执行参数不完整

常见原因:

解决:

子代理的审批行为和主代理不一致

常见原因:

解决:

工具内部 interrupt() 后流程卡住

常见原因:

解决:


验收标准

可以用下面的标准判断人工审批链路是否真正跑通:


推荐实操顺序

建议按这个顺序接入人工审批:

  1. 先给一个高风险工具配置 interrupt_on
  2. 加上 checkpointer
  3. 跑通单工具审批
  4. 再测试 approverejectedit
  5. 再测试多工具一起审批
  6. 再考虑子代理层面的审批覆盖
  7. 最后再考虑工具内部 interrupt() 这种更细粒度流程

关键要点


写在最后

人工审批的本质,是把 Agent 的关键工具调用从“自动直接执行”改造成“先暂停、再确认、再恢复”的可控执行流。

可以把它理解成三层能力:

当这三层设计清楚后,Agent 才真正具备进入真实业务流程的可控性。