跳转至

HITL:高风险动作交给人确认

HITL 是 Human-in-the-Loop 的缩写。意思很简单:

Agent 可以准备动作,但高风险动作必须人批准了才执行。

旅游助手可以自动查天气、搜景点、估路线。但下面这些事不能默默干:

  • 订票扣款
  • 发邮件给酒店
  • 取消已有预订
  • 提交签证信息

Policy 能挡住"不允许的调用",Guardrails 能挡住"危险的内容"。但它们都不能替用户做决定。"这张 680 块的票要不要买?"——只有人能确认。


没有 HITL 会怎么坏

问题 表面看起来 实际风险
Agent 自动下单 用户说"帮我看看",Agent 理解成"帮我订" 扣了钱、退不了
Agent 发邮件确认 回复很快 内容有误,发给了错误的收件人
Agent 取消预订"帮用户整理" 看起来贴心 不可逆操作,用户并没有这个意图

流程

flowchart TD
  A["Agent 提出高风险动作"] --> D["检测:是否需要审批?"]
  D -->|不需要| E["直接执行"]
  D -->|需要| R["创建审批请求"]
  R --> H["展示给人:工具、参数、原因、影响"]
  H -->|批准| T["执行工具"]
  H -->|拒绝| P["Agent 换计划或通知用户"]
  T --> O["记录结果"]
  P --> O2["记录拒绝原因"]

完整代码

完整的 detect → pause → confirm → continue/cancel 流程。模拟了旅游助手订票、发邮件、查天气三种调用。

from __future__ import annotations
import json
from dataclasses import dataclass, field
from datetime import datetime


# ========== 审批请求 ==========

@dataclass
class ApprovalRequest:
    request_id: str
    tool: str
    args: dict
    reason: str        # 为什么需要审批
    impact: str        # 执行后会发生什么
    deny_impact: str   # 拒绝后会发生什么
    created_at: str = field(default_factory=lambda: datetime.now().strftime("%Y-%m-%d %H:%M:%S"))


class NeedsApproval(Exception):
    """工具调用需要人工审批时抛出。"""
    def __init__(self, request: ApprovalRequest):
        self.request = request


# ========== 审批规则 ==========

# 哪些工具需要审批,以及审批的描述
APPROVAL_RULES: dict[str, dict] = {
    "book_ticket": {
        "reason": "订票会产生实际扣款",
        "impact": "将从绑定的支付方式扣款,生成不可撤销的订单",
        "deny_impact": "不会扣款,Agent 会提供替代方案或等待用户指示",
    },
    "send_email": {
        "reason": "发邮件不可撤回",
        "impact": "邮件将发送给指定收件人,发送后无法撤回",
        "deny_impact": "邮件不会发送,Agent 会展示邮件草稿供用户修改",
    },
    "cancel_order": {
        "reason": "取消订单可能不可逆",
        "impact": "订单将被取消,退款按平台规则处理",
        "deny_impact": "订单保持不变",
    },
}

# 已批准的请求(真实系统应该持久化)
approved_requests: set[str] = set()
denied_requests: set[str] = set()


def make_request_id(tool: str, args: dict) -> str:
    """为一次工具调用生成唯一的请求 ID。"""
    args_key = json.dumps(args, sort_keys=True, ensure_ascii=False)
    return f"{tool}:{hash(args_key)}"


def check_approval(tool: str, args: dict) -> ApprovalRequest | None:
    """检查工具调用是否需要审批。如果需要且未审批,抛出 NeedsApproval。"""

    rule = APPROVAL_RULES.get(tool)
    if rule is None:
        return None  # 不需要审批

    request_id = make_request_id(tool, args)

    if request_id in approved_requests:
        return None  # 已经批准过

    if request_id in denied_requests:
        raise NeedsApproval(ApprovalRequest(
            request_id=request_id,
            tool=tool,
            args=args,
            reason="此请求之前被拒绝过",
            impact=rule["impact"],
            deny_impact=rule["deny_impact"],
        ))

    request = ApprovalRequest(
        request_id=request_id,
        tool=tool,
        args=args,
        reason=rule["reason"],
        impact=rule["impact"],
        deny_impact=rule["deny_impact"],
    )
    raise NeedsApproval(request)


def show_approval_request(request: ApprovalRequest) -> None:
    """展示审批请求给人看。"""
    print(f"\n  {'='*50}")
    print(f"  需要人工确认")
    print(f"  {'='*50}")
    print(f"  请求 ID:  {request.request_id}")
    print(f"  工具:     {request.tool}")
    print(f"  参数:     {json.dumps(request.args, ensure_ascii=False)}")
    print(f"  原因:     {request.reason}")
    print(f"  执行影响: {request.impact}")
    print(f"  拒绝影响: {request.deny_impact}")
    print(f"  时间:     {request.created_at}")


def human_decision(request: ApprovalRequest, approve: bool) -> None:
    """模拟人做出决定。"""
    if approve:
        approved_requests.add(request.request_id)
        print(f"  决定: 批准")
    else:
        denied_requests.add(request.request_id)
        print(f"  决定: 拒绝")


# ========== 模拟工具 ==========

def get_weather(city: str) -> dict:
    return {"city": city, "temperature": "22°C", "condition": "多云"}

def book_ticket(city: str, ticket_type: str, price: float) -> dict:
    return {"status": "已预订", "city": city, "ticket_type": ticket_type, "price": price, "order_id": "ORD-20250601-001"}

def send_email(to: str, subject: str, body: str) -> dict:
    return {"status": "已发送", "to": to, "subject": subject}


TOOL_MAP = {
    "get_weather": get_weather,
    "book_ticket": book_ticket,
    "send_email": send_email,
}


# ========== Agent 执行循环(带 HITL)==========

def execute_with_hitl(tool: str, args: dict, simulated_approval: bool | None = None) -> dict:
    """
    尝试执行工具。如果需要审批,暂停并展示审批请求。
    simulated_approval: 模拟人的决定(True=批准, False=拒绝, None=不需要审批)
    """
    print(f"\nAgent 请求调用: {tool}({json.dumps(args, ensure_ascii=False)})")

    try:
        check_approval(tool, args)
    except NeedsApproval as e:
        show_approval_request(e.request)

        if simulated_approval is None:
            print(f"  等待人工决定...")
            return {"status": "pending_approval", "request_id": e.request.request_id}

        human_decision(e.request, simulated_approval)

        if not simulated_approval:
            return {"status": "denied", "reason": e.request.deny_impact}

        # 批准后重新检查(这次会通过)
        try:
            check_approval(tool, args)
        except NeedsApproval:
            return {"status": "denied", "reason": "审批状态异常"}

    # 执行工具
    func = TOOL_MAP.get(tool)
    if func is None:
        return {"error": f"未知工具: {tool}"}

    result = func(**args)
    print(f"  执行结果: {json.dumps(result, ensure_ascii=False)}")
    return result


# ========== 演示 ==========

def demo():
    print("=" * 60)
    print("HITL 人工确认流程演示")
    print("=" * 60)

    # 场景 1:查天气——不需要审批,直接执行
    print("\n--- 场景 1:查天气(无需审批)---")
    execute_with_hitl("get_weather", {"city": "杭州"})

    # 场景 2:订票——需要审批,人批准
    print("\n--- 场景 2:订票(需要审批 → 批准)---")
    execute_with_hitl(
        "book_ticket",
        {"city": "杭州", "ticket_type": "standard", "price": 280},
        simulated_approval=True,
    )

    # 场景 3:发邮件——需要审批,人拒绝
    print("\n--- 场景 3:发邮件(需要审批 → 拒绝)---")
    execute_with_hitl(
        "send_email",
        {"to": "hotel@example.com", "subject": "预订确认", "body": "确认入住..."},
        simulated_approval=False,
    )

    # 场景 4:同样的订票参数再调一次——已经批准过,直接执行
    print("\n--- 场景 4:同参数订票重试(已批准,直接执行)---")
    execute_with_hitl(
        "book_ticket",
        {"city": "杭州", "ticket_type": "standard", "price": 280},
    )


if __name__ == "__main__":
    demo()

运行输出:

============================================================
HITL 人工确认流程演示
============================================================

--- 场景 1:查天气(无需审批)---

Agent 请求调用: get_weather({"city": "杭州"})
  执行结果: {"city": "杭州", "temperature": "22°C", "condition": "多云"}

--- 场景 2:订票(需要审批 → 批准)---

Agent 请求调用: book_ticket({"city": "杭州", "ticket_type": "standard", "price": 280})

  ==================================================
  需要人工确认
  ==================================================
  请求 ID:  book_ticket:-6915588706498002498
  工具:     book_ticket
  参数:     {"city": "杭州", "ticket_type": "standard", "price": 280}
  原因:     订票会产生实际扣款
  执行影响: 将从绑定的支付方式扣款,生成不可撤销的订单
  拒绝影响: 不会扣款,Agent 会提供替代方案或等待用户指示
  时间:     2025-06-01 14:30:00
  决定: 批准
  执行结果: {"status": "已预订", "city": "杭州", "ticket_type": "standard", "price": 280, "order_id": "ORD-20250601-001"}

--- 场景 3:发邮件(需要审批 → 拒绝)---

Agent 请求调用: send_email({"to": "hotel@example.com", "subject": "预订确认", "body": "确认入住..."})

  ==================================================
  需要人工确认
  ==================================================
  请求 ID:  send_email:4471429671498920498
  工具:     send_email
  参数:     {"to": "hotel@example.com", "subject": "预订确认", "body": "确认入住..."}
  原因:     发邮件不可撤回
  执行影响: 邮件将发送给指定收件人,发送后无法撤回
  拒绝影响: 邮件不会发送,Agent 会展示邮件草稿供用户修改
  时间:     2025-06-01 14:30:00
  决定: 拒绝

--- 场景 4:同参数订票重试(已批准,直接执行)---

Agent 请求调用: book_ticket({"city": "杭州", "ticket_type": "standard", "price": 280})
  执行结果: {"status": "已预订", "city": "杭州", "ticket_type": "standard", "price": 280, "order_id": "ORD-20250601-001"}

代码走读

核心机制:detect → pause → confirm → continue/cancel

  1. APPROVAL_RULES 定义哪些工具需要审批。不在这个表里的工具(如 get_weather)直接放行。
  2. check_approval 检查两件事:这个工具需不需要审批?如果需要,之前批没批过?
  3. 没批过就抛 NeedsApproval 异常。异常里带着完整的 ApprovalRequest——包括工具、参数、原因、影响。
  4. 人看到这些信息后做决定。批准了就存进 approved_requests,下次同样的调用不再问。

审批请求里必须包含什么

不要只展示"批准/拒绝"两个按钮。人需要看到:

  • 要执行什么工具、什么参数
  • 为什么需要审批
  • 执行了会怎样
  • 拒绝了会怎样

信息不够,审批就变成橡皮图章。人会习惯性点批准,然后出事。

重试逻辑

场景 4 展示了一个重要细节:同样参数的 book_ticket 第二次调用时,因为已经在 approved_requests 里了,直接放行。真实系统里这个"已批准"状态需要持久化,否则 Agent 重启后又会卡住。


什么时候用

  • 工具会付款、下单、取消、删除、发消息。
  • 合规要求人工确认(比如金融、医疗)。
  • Agent 还在灰度阶段,需要限制自主权。
  • Guardrail 触发后需要人来判断是不是误报。

什么时候不需要

不要给所有操作都加审批。查天气、搜景点这种只读操作不需要人确认。审批太多会导致审批疲劳——人习惯了点"批准",真正危险的操作也批过去了。

只给不可逆的、有成本的、有外部影响的操作加审批。


常见坑

现象 修法
每一步都要审批 用户烦了,无脑点批准 只拦高风险操作
审批请求信息不足 人看不懂在批什么 包含工具、参数、原因、影响
批准后 Agent 重试失败 中断→恢复流程没设计好 持久化审批状态,设计 resume 机制
假设审批一定会通过 拒绝后 Agent 不知道怎么继续 处理 deny 分支:换计划或告知用户
不记录审批历史 出事以后查不到谁批的 日志记录:谁、什么时候、批了什么

读完以后

Policy、Guardrails、HITL 让单次运行更安全。但你改了 prompt、改了工具描述、加了新规则之后,怎么知道有没有破坏之前能跑通的任务?

下一节 Eval Harness 讲怎么用固定任务集做回归检查。