课程0基础Agent开发课 / Agent基础 / Human-in-the-Loop-何时让Agent暂停等待人工确认
— 28 min read

Human-in-the-Loop-何时让Agent暂停等待人工确认

*Human-in-the-Loop 决策流程——基于风险评估决定自动执行、请求澄清还是人工审批*

Human-in-the-Loop:何时让 Agent 暂停等待人工确认

1.1 HitL 的设计哲学:自主性与责任之间的边界

完全可逆
读取 · 查询

可逆但有成本
创建资源 · 写入数据

不可逆
删除 · 发送 · 部署

低影响

高影响

批准

拒绝

超时

Agent 计划执行操作

操作可逆性?

✅ 自动执行

业务影响大小?

🔴 强制人工确认

是否在授权范围?

⏸️ 暂停,等待审批

执行操作

取消,记录原因

自动取消,发出警报

Human-in-the-Loop 决策流程——基于风险评估决定自动执行、请求澄清还是人工审批

全自动化是 AI Agent 的终极目标,但不是任何场景的当下选择。理解 Human-in-the-Loop 的设计哲学,需要先想清楚两个问题:为什么 Agent 不能完全自主?哪些决策人类必须保留控制权?

自主决策的前提:错误可以被纠正

Agent 做决策会犯错——不是偶尔,而是系统性的概率事件。LLM 会幻觉,会误解模糊指令,会在信息不充分时做出次优选择。只要错误发生时可以被发现和纠正,自主决策是合理的。

问题在于:当 Agent 执行了"删除数据库中所有过期记录"之后,数据已经消失,无法"发现并纠正"——纠正的代价是备份恢复,而备份恢复可能已经覆盖了其他变更。这类操作的特点是不可撤销性:执行之后,代价已经发生,无论后续如何处理,那个时间点的错误已经造成了影响。

人类需要保留控制权的根本原因

存在两类场景,必须保留人类控制权:

第一类:不可撤销操作。删除数据、发送邮件、部署代码到生产环境、向外部系统提交订单——这些操作一旦执行,无法回退。Agent 的"我以为应该删除"不能成为正当理由,因为后果由系统和用户承担。

第二类:超出授权范围的决策。Agent 的"授权范围"是设计时隐含定义的。当 Agent 计划做的事情超出了原本预期的边界——比如自动向 100 个客户发送通知邮件,而原本只期望它起草一封邮件——这个决策超出了"机器自主"的合理范围,需要人来确认。

HitL 的设计原则:精准插入,而非全程监控

HitL 不是"凡事都要确认"——那样 Agent 就失去了自动化的意义。设计原则是:

  • 只读操作和可逆操作:自动执行,无需确认
  • 不可逆操作和超出授权范围的操作:必须暂停确认
  • 介于两者之间:根据业务影响大小和审计要求决定

好的 HitL 设计让 Agent 在能力范围内高度自主,在触及责任边界时优雅暂停——而不是处处设卡,让自动化变成半自动化。


全自动执行是 AI Agent 的理想状态,但现实中存在一类操作,其代价是"不可撤销的"——删除数据库记录、发出邮件、生产环境部署、向外部系统下单。对这类操作,让 Agent 自主决策并不比让一个新员工独立操作生产系统更安全。

Human-in-the-Loop(HitL)是解决这个问题的架构模式:在 Agent 执行流程的关键节点插入人工审批,既保留自动化的效率,又保留人类对高风险操作的控制权。

1.2 不可逆操作的风险分类

并非所有操作都需要人工确认。设计 HitL 的第一步是明确哪些操作属于"不可逆或高代价"范畴。

完全可逆
读取、查询

可逆但有成本
创建资源、写入数据

不可逆
删除、发送、部署

低影响

高影响

批准

拒绝

超时

Agent 计划执行操作

操作可逆性?

自动执行

业务影响大小?

必须人工确认

是否在授权范围?

暂停,等待审批

执行操作

取消,记录原因

自动取消,发出警报

操作类型 示例 可逆性 建议策略
只读查询 SELECT、GET、搜索 完全可逆 自动执行
创建(可删除) 创建文件、创建草稿 基本可逆 自动执行
修改(有备份) 更新配置(有版本历史) 可恢复 自动执行,可选审计
外部通信 发邮件、发 Slack 消息 不可撤回 人工确认
破坏性操作 删除数据、清空表 不可逆 强制人工确认
财务操作 下单、转账、充值 不可逆 强制人工确认
生产部署 deploy、重启服务 影响大 强制人工确认

1.3 三种 Interrupt 模式

LangGraph(LangChain 旗下专门用于构建有状态 Agent 工作流的框架,天然支持中断和恢复机制)将 HitL 的中断点分为三种模式,对应不同的业务场景:

1.3.1 interrupt_before:执行前确认

适用场景:高风险操作(删除、发送、部署)。Agent 完成规划、准备调用工具时暂停,等待人工审批后再执行。

code
用户请求 → Agent 规划 → [暂停:展示计划给人类] → 人类批准 → 工具执行 → 返回结果

1.3.2 interrupt_after:执行后审查

适用场景:敏感内容输出(生成的文案、法律文件、面向用户的回复)。操作已完成,但结果需要人工审核后才能对外发布。

code
用户请求 → Agent 执行 → 工具调用完成 → [暂停:展示结果给人类] → 人类审核 → 对外发布

1.3.3 on_error:出错时介入

适用场景:Agent 遇到无法自行处理的异常(工具连续失败、出现矛盾信息)。人类介入提供额外上下文或决策。

code
用户请求 → Agent 执行 → 工具连续失败 → [暂停:报告困境] → 人类提供指引 → 继续执行

1.4 LangGraph 实现:interrupt_before 与 interrupt_after

LangGraph 的 interrupt 机制依赖 Checkpointer——每次中断时将 Graph 的完整状态序列化保存,恢复时从断点继续,不需要重新执行之前的步骤。

python
from typing import Annotated, TypedDict
import json
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.memory import MemorySaver
from langgraph.types import interrupt, Command
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage, AIMessage, ToolMessage

# 定义 Agent 状态
class AgentState(TypedDict):
    messages: list
    pending_tool_call: dict | None  # 待确认的工具调用
    user_approved: bool | None       # 人类的审批结果

# 工具定义(危险操作)
def delete_database_records(table: str, condition: str) -> str:
    """实际执行数据库删除(此处为模拟)"""
    return f"Deleted records from {table} WHERE {condition}"

def send_notification_email(to: str, subject: str, body: str) -> str:
    """发送邮件(此处为模拟)"""
    return f"Email sent to {to}"

DANGEROUS_TOOLS = {"delete_database_records", "send_notification_email"}

llm = ChatOpenAI(model="gpt-4o")
tools = [delete_database_records, send_notification_email]
llm_with_tools = llm.bind_tools(tools)


# Node 1:LLM 推理节点
def agent_node(state: AgentState) -> AgentState:
    response = llm_with_tools.invoke(state["messages"])
    return {"messages": state["messages"] + [response], "pending_tool_call": None}


# Node 2:工具执行前的审批节点(interrupt_before 模式)
def approval_gate(state: AgentState) -> AgentState:
    """
    检查最新 AI 消息是否包含危险工具调用。
    如果包含,暂停并等待人类审批。
    """
    last_message = state["messages"][-1]

    if not hasattr(last_message, "tool_calls") or not last_message.tool_calls:
        return state

    for tool_call in last_message.tool_calls:
        if tool_call["name"] in DANGEROUS_TOOLS:
            # 关键:调用 interrupt(),Graph 执行暂停
            # interrupt() 的参数会传递给等待审批的客户端
            human_decision = interrupt({
                "type": "approval_required",
                "tool_name": tool_call["name"],
                "tool_args": tool_call["args"],
                "message": (
                    f"Agent 计划执行高风险操作:\n"
                    f"工具:{tool_call['name']}\n"
                    f"参数:{json.dumps(tool_call['args'], ensure_ascii=False, indent=2)}\n"
                    f"请批准(approve)或拒绝(reject)。"
                ),
            })

            # human_decision 是恢复时传入的值
            if human_decision != "approve":
                # 拒绝:插入一条工具结果消息,告知 LLM 操作被取消
                rejection_msg = ToolMessage(
                    content=f"Operation cancelled by human reviewer. Reason: {human_decision}",
                    tool_call_id=tool_call["id"],
                )
                return {
                    "messages": state["messages"] + [rejection_msg],
                    "user_approved": False,
                }

    return {"user_approved": True, **state}


# Node 3:工具执行节点
def tool_executor(state: AgentState) -> AgentState:
    last_message = state["messages"][-1]
    new_messages = []

    if hasattr(last_message, "tool_calls"):
        for tool_call in last_message.tool_calls:
            # 根据工具名称分发执行
            tool_map = {
                "delete_database_records": delete_database_records,
                "send_notification_email": send_notification_email,
            }
            tool_fn = tool_map.get(tool_call["name"])
            if tool_fn:
                result = tool_fn(**tool_call["args"])
            else:
                result = f"Unknown tool: {tool_call['name']}"

            new_messages.append(ToolMessage(
                content=str(result),
                tool_call_id=tool_call["id"],
            ))

    return {"messages": state["messages"] + new_messages}


# 路由函数:判断是否有工具调用
def should_use_tools(state: AgentState) -> str:
    last_message = state["messages"][-1]
    if hasattr(last_message, "tool_calls") and last_message.tool_calls:
        return "approval_gate"
    return END


# 构建 Graph
builder = StateGraph(AgentState)
builder.add_node("agent", agent_node)
builder.add_node("approval_gate", approval_gate)
builder.add_node("tool_executor", tool_executor)

builder.add_edge(START, "agent")
builder.add_conditional_edges("agent", should_use_tools)
builder.add_edge("approval_gate", "tool_executor")
builder.add_edge("tool_executor", "agent")

# 关键:必须使用 Checkpointer 才能支持 interrupt/resume
checkpointer = MemorySaver()
graph = builder.compile(checkpointer=checkpointer)

1.4.1 中断与恢复的完整交互流程

python
import uuid

def run_with_approval(user_request: str) -> None:
    """演示完整的 interrupt → 人工审批 → resume 流程"""

    # 每次对话使用唯一的 thread_id,保证状态隔离
    thread_id = str(uuid.uuid4())
    config = {"configurable": {"thread_id": thread_id}}

    print(f"=== 开始执行,Thread: {thread_id[:8]} ===")

    # 第一次运行:可能在中断点暂停
    for chunk in graph.stream(
        {"messages": [HumanMessage(content=user_request)]},
        config=config,
        stream_mode="values",
    ):
        # 检查是否有中断
        if "__interrupt__" in chunk:
            interrupt_data = chunk["__interrupt__"][0].value
            print("\n[!] Agent 请求人工审批:")
            print(interrupt_data["message"])

            # 模拟人类审批决策(实际场景通过 API/UI 获取)
            decision = input("\n请输入决策(approve / reject原因):").strip()

            # 恢复执行:通过 Command 传入审批结果
            print("\n=== 恢复执行 ===")
            for resume_chunk in graph.stream(
                Command(resume=decision),
                config=config,  # 必须使用相同的 thread_id
                stream_mode="values",
            ):
                if "messages" in resume_chunk:
                    last = resume_chunk["messages"][-1]
                    if hasattr(last, "content") and isinstance(last.content, str):
                        print(f"Agent: {last.content}")
            return

    # 无中断,直接完成
    state = graph.get_state(config)
    final_message = state.values["messages"][-1]
    print(f"Agent: {final_message.content}")


# 测试:触发审批流程
run_with_approval("删除 users 表中 status='inactive' 的所有记录,这些账号已超过3年未登录")

1.5 Agent 主动请求澄清:双向交互设计

HitL 不仅包含"人类审批 Agent 的操作",也包含"Agent 主动向人类请求信息"。当任务描述模糊、存在歧义时,Agent 应暂停并提问,而非猜测后错误执行。

python
from enum import Enum

class InterruptReason(str, Enum):
    APPROVAL_REQUIRED = "approval_required"
    CLARIFICATION_NEEDED = "clarification_needed"
    ERROR_RECOVERY = "error_recovery"

def clarification_node(state: AgentState) -> AgentState:
    """
    当 Agent 检测到任务描述存在关键歧义时,主动请求澄清。
    与 approval_gate 不同,这里是 Agent 主动发起中断,而非系统检测危险操作。
    """
    last_message = state["messages"][-1]

    # 检查 LLM 是否在回复中表达了需要澄清的意图
    # 实际实现可通过结构化输出判断
    if hasattr(last_message, "content") and "NEED_CLARIFICATION:" in last_message.content:
        question = last_message.content.split("NEED_CLARIFICATION:")[1].strip()

        user_response = interrupt({
            "type": InterruptReason.CLARIFICATION_NEEDED,
            "question": question,
            "context": "Agent needs additional information to proceed safely.",
        })

        # 将用户回答注入消息历史,让 Agent 继续推理
        return {
            "messages": state["messages"] + [
                HumanMessage(content=f"Clarification: {user_response}")
            ]
        }

    return state

对应的 System Prompt 设计:

python
SYSTEM_PROMPT = """
You are a helpful assistant with access to powerful tools.

IMPORTANT RULES:
1. For ambiguous requests, output "NEED_CLARIFICATION: <your question>" before using tools.
2. Never assume the scope of destructive operations—always clarify the exact target.
3. When you identify a dangerous operation, describe exactly what will happen before proceeding.

Examples of when to ask for clarification:
- "Delete old records" → Ask: which table? What defines 'old'? Is there a backup?
- "Send email to the team" → Ask: which team? What address? Is this urgent?
"""

1.6 权限分级设计

实际系统中,不同用户角色对应不同的操作权限,HitL 的触发条件应随角色动态调整。

python
from dataclasses import dataclass
from enum import Enum

class UserRole(str, Enum):
    VIEWER = "viewer"       # 只读
    EDITOR = "editor"       # 可写,不可删
    ADMIN = "admin"         # 全权限,高危操作仍需确认
    SUPER_ADMIN = "super"   # 全权限,无需确认

@dataclass
class PermissionPolicy:
    """权限策略:定义哪些操作需要确认"""
    auto_execute: set[str]       # 可自动执行的操作
    confirm_required: set[str]   # 需要确认的操作
    forbidden: set[str]          # 禁止执行的操作

PERMISSION_POLICIES: dict[UserRole, PermissionPolicy] = {
    UserRole.VIEWER: PermissionPolicy(
        auto_execute={"read_file", "search_database", "get_user_profile"},
        confirm_required=set(),
        forbidden={"delete_records", "send_email", "deploy", "execute_sql"},
    ),
    UserRole.EDITOR: PermissionPolicy(
        auto_execute={"read_file", "search_database", "write_file", "create_record"},
        confirm_required={"send_email", "update_record"},
        forbidden={"delete_records", "deploy"},
    ),
    UserRole.ADMIN: PermissionPolicy(
        auto_execute={"read_file", "search_database", "write_file", "create_record", "update_record"},
        confirm_required={"send_email", "delete_records", "deploy"},
        forbidden=set(),
    ),
    UserRole.SUPER_ADMIN: PermissionPolicy(
        auto_execute={"read_file", "search_database", "write_file", "create_record",
                      "update_record", "send_email", "delete_records", "deploy"},
        confirm_required=set(),
        forbidden=set(),
    ),
}

def check_permission(
    tool_name: str,
    user_role: UserRole,
) -> tuple[bool, bool]:
    """
    返回:(is_allowed, requires_confirmation)
    """
    policy = PERMISSION_POLICIES[user_role]
    if tool_name in policy.forbidden:
        return False, False
    if tool_name in policy.confirm_required:
        return True, True
    return True, False  # auto_execute 或未明确列出的工具默认允许

1.7 前端与后端的通信协议

HitL 涉及长时间等待(用户可能 5 分钟后才看到审批请求),因此前后端通信需要支持异步等待。两种主流方案:

1.7.1 方案一:轮询 + REST API

python
# FastAPI 后端示例
from fastapi import FastAPI
from pydantic import BaseModel
import asyncio

app = FastAPI()
pending_approvals: dict[str, dict] = {}  # thread_id → 中断数据

@app.post("/agent/run")
async def start_agent(request: dict):
    thread_id = str(uuid.uuid4())
    # 异步启动 Agent(可能中断)
    asyncio.create_task(run_agent_async(request["query"], thread_id))
    return {"thread_id": thread_id, "status": "running"}

@app.get("/agent/{thread_id}/status")
async def get_status(thread_id: str):
    """前端轮询此接口,检查是否有待审批项"""
    if thread_id in pending_approvals:
        return {
            "status": "waiting_approval",
            "approval_data": pending_approvals[thread_id],
        }
    # 检查是否完成...
    return {"status": "running"}

class ApprovalDecision(BaseModel):
    decision: str  # "approve" 或 拒绝原因

@app.post("/agent/{thread_id}/approve")
async def approve_action(thread_id: str, body: ApprovalDecision):
    """人类提交审批决策"""
    config = {"configurable": {"thread_id": thread_id}}
    # 恢复 LangGraph 执行
    graph.invoke(Command(resume=body.decision), config=config)
    del pending_approvals[thread_id]
    return {"status": "resumed"}

1.7.2 方案二:SSE(Server-Sent Events)推送

python
from fastapi.responses import StreamingResponse
import asyncio

@app.get("/agent/{thread_id}/stream")
async def stream_events(thread_id: str):
    """
    SSE 端点:Agent 状态变更实时推送给前端。
    中断时推送 approval_required 事件,前端弹出确认对话框。
    """
    async def event_generator():
        config = {"configurable": {"thread_id": thread_id}}

        async for event in graph.astream_events(
            None,  # 已有 thread_id,无需重新输入
            config=config,
            version="v2",
        ):
            if event["event"] == "on_chain_start":
                yield f"data: {json.dumps({'type': 'step_start', 'node': event['name']})}\n\n"
            elif event["event"] == "on_chain_end":
                yield f"data: {json.dumps({'type': 'step_end', 'node': event['name']})}\n\n"
            elif "__interrupt__" in event.get("data", {}):
                interrupt_data = event["data"]["__interrupt__"][0].value
                yield f"data: {json.dumps({'type': 'approval_required', **interrupt_data})}\n\n"

    return StreamingResponse(event_generator(), media_type="text/event-stream")

1.8 实战:带危险操作确认的文件管理 Agent

python
import os
import shutil

# 文件操作工具(区分安全与危险)
def list_files(directory: str) -> list[str]:
    """列出目录下的文件(安全操作)"""
    return os.listdir(directory)

def read_file_content(path: str) -> str:
    """读取文件内容(安全操作)"""
    with open(path) as f:
        return f.read()

def write_file(path: str, content: str) -> str:
    """写入文件(中等风险,如文件已存在则覆盖)"""
    with open(path, "w") as f:
        f.write(content)
    return f"Written to {path}"

def delete_file(path: str) -> str:
    """删除文件(高风险,不可逆)"""
    os.remove(path)
    return f"Deleted {path}"

def delete_directory(path: str) -> str:
    """删除目录(极高风险,不可逆)"""
    shutil.rmtree(path)
    return f"Deleted directory {path}"

# 工具风险分级配置
TOOL_RISK_LEVELS = {
    "list_files": "safe",
    "read_file_content": "safe",
    "write_file": "medium",      # 覆盖时需提示
    "delete_file": "dangerous",  # 必须确认
    "delete_directory": "critical",  # 必须确认,加倍警示
}

def build_file_manager_agent() -> tuple:
    """构建文件管理 Agent,内置 HitL 审批"""

    class FileAgentState(TypedDict):
        messages: list
        working_directory: str

    def file_agent_node(state: FileAgentState):
        tools_schema = [
            # 此处定义工具 schema...
        ]
        llm = ChatOpenAI(model="gpt-4o")
        response = llm.invoke(state["messages"])
        return {"messages": state["messages"] + [response]}

    def file_approval_gate(state: FileAgentState):
        last = state["messages"][-1]
        if not hasattr(last, "tool_calls"):
            return state

        for tc in last.tool_calls:
            risk = TOOL_RISK_LEVELS.get(tc["name"], "safe")
            if risk in ("dangerous", "critical"):
                warning = "⚠️ 极高风险" if risk == "critical" else "高风险"
                decision = interrupt({
                    "type": "approval_required",
                    "risk_level": risk,
                    "tool_name": tc["name"],
                    "tool_args": tc["args"],
                    "warning": warning,
                    "message": (
                        f"{warning} 操作需要确认\n"
                        f"操作:{tc['name']}\n"
                        f"目标:{tc['args']}\n"
                        f"此操作不可撤销,请谨慎确认。"
                    ),
                })
                if decision != "approve":
                    return {
                        "messages": state["messages"] + [
                            ToolMessage(
                                content=f"Operation cancelled: {decision}",
                                tool_call_id=tc["id"],
                            )
                        ]
                    }
        return state

    return file_agent_node, file_approval_gate

1.9 小结

Human-in-the-Loop 的设计核心是风险感知的自动化:让 Agent 在能力范围内尽量自动执行,在触及人类才能承担的责任时优雅地暂停。

几个关键设计原则:

  1. 明确风险边界:写代码前先列出所有工具,逐一标注风险等级,而非事后补充审批逻辑。
  2. 使用 Checkpointer:LangGraph 的 interrupt/resume 依赖状态持久化,生产环境使用 PostgreSQL 或 Redis Checkpointer,不用 MemorySaver。
  3. 超时保护:等待人工确认要设置超时,超时自动取消操作,避免 Agent 无限期挂起。
  4. 审计日志:每次人工决策(批准/拒绝)都要记录操作者、时间、理由,为合规审计提供依据。
  5. 渐进式自动化:初期对更多操作要求确认,积累数据后逐步扩大自动执行范围。

下一章进入 Python 基础实践:如何为 AI 应用编写高质量的测试。

本页目录