Human-in-the-Loop-何时让Agent暂停等待人工确认
*Human-in-the-Loop 决策流程——基于风险评估决定自动执行、请求澄清还是人工审批*
Human-in-the-Loop:何时让 Agent 暂停等待人工确认
1.1 HitL 的设计哲学:自主性与责任之间的边界
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 的第一步是明确哪些操作属于"不可逆或高代价"范畴。
| 操作类型 | 示例 | 可逆性 | 建议策略 |
|---|---|---|---|
| 只读查询 | SELECT、GET、搜索 | 完全可逆 | 自动执行 |
| 创建(可删除) | 创建文件、创建草稿 | 基本可逆 | 自动执行 |
| 修改(有备份) | 更新配置(有版本历史) | 可恢复 | 自动执行,可选审计 |
| 外部通信 | 发邮件、发 Slack 消息 | 不可撤回 | 人工确认 |
| 破坏性操作 | 删除数据、清空表 | 不可逆 | 强制人工确认 |
| 财务操作 | 下单、转账、充值 | 不可逆 | 强制人工确认 |
| 生产部署 | deploy、重启服务 | 影响大 | 强制人工确认 |
1.3 三种 Interrupt 模式
LangGraph(LangChain 旗下专门用于构建有状态 Agent 工作流的框架,天然支持中断和恢复机制)将 HitL 的中断点分为三种模式,对应不同的业务场景:
1.3.1 interrupt_before:执行前确认
适用场景:高风险操作(删除、发送、部署)。Agent 完成规划、准备调用工具时暂停,等待人工审批后再执行。
用户请求 → Agent 规划 → [暂停:展示计划给人类] → 人类批准 → 工具执行 → 返回结果
1.3.2 interrupt_after:执行后审查
适用场景:敏感内容输出(生成的文案、法律文件、面向用户的回复)。操作已完成,但结果需要人工审核后才能对外发布。
用户请求 → Agent 执行 → 工具调用完成 → [暂停:展示结果给人类] → 人类审核 → 对外发布
1.3.3 on_error:出错时介入
适用场景:Agent 遇到无法自行处理的异常(工具连续失败、出现矛盾信息)。人类介入提供额外上下文或决策。
用户请求 → Agent 执行 → 工具连续失败 → [暂停:报告困境] → 人类提供指引 → 继续执行
1.4 LangGraph 实现:interrupt_before 与 interrupt_after
LangGraph 的 interrupt 机制依赖 Checkpointer——每次中断时将 Graph 的完整状态序列化保存,恢复时从断点继续,不需要重新执行之前的步骤。
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 中断与恢复的完整交互流程
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 应暂停并提问,而非猜测后错误执行。
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 设计:
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 的触发条件应随角色动态调整。
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
# 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)推送
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
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 在能力范围内尽量自动执行,在触及人类才能承担的责任时优雅地暂停。
几个关键设计原则:
- 明确风险边界:写代码前先列出所有工具,逐一标注风险等级,而非事后补充审批逻辑。
- 使用 Checkpointer:LangGraph 的 interrupt/resume 依赖状态持久化,生产环境使用 PostgreSQL 或 Redis Checkpointer,不用 MemorySaver。
- 超时保护:等待人工确认要设置超时,超时自动取消操作,避免 Agent 无限期挂起。
- 审计日志:每次人工决策(批准/拒绝)都要记录操作者、时间、理由,为合规审计提供依据。
- 渐进式自动化:初期对更多操作要求确认,积累数据后逐步扩大自动执行范围。
下一章进入 Python 基础实践:如何为 AI 应用编写高质量的测试。