AI 工程基础体系 · 第 98/100 篇。内容覆盖机器学习、深度学习与生成式 AI;模型、数据、评测、权限和成本会作为同一生产系统处理。

Agent 人工介入:确认点、升级、接管、恢复和责任边界

Agent 并不是“模型调用工具”的另一种说法。一个能够读取信息、规划步骤、调用工具并根据结果继续行动的 Agent,实际上是在代表某个主体改变外部世界:修改数据库、发送邮件、发布内容、创建工单、执行付款,或者改变另一个系统的权限。

因此,人工介入(Human-in-the-loop,HITL)不是在模型输出旁边加一个“是否同意”的按钮,而是要回答一组生产系统问题:

  • 哪些动作必须由人确认?
  • 什么时候应该升级,而不是继续等待?
  • 人工接管后,Agent 是否还能继续执行?
  • 人工拒绝、超时、系统崩溃后,如何恢复?
  • 出错时由模型、编排器、工具调用者、批准人还是业务主体负责?
  • 如何证明某次动作在当时得到了什么授权?

这篇文章把模型、数据、评测、权限和成本放在同一个生产系统中讨论。人工介入的目标不是让人审阅所有模型输出,而是让系统在风险、不可逆性和不确定性达到阈值时,把决策权交给合适的人,并且保留可验证的责任链。


一、先区分四种不同的人工介入

“人工介入”常被当成一个概念使用,实际上至少包含四种机制。

1. 确认点:执行前由人批准

**确认点(approval point)**是一个在具体动作执行前暂停流程的状态。系统向人展示待执行动作、参数、依据、风险和权限范围;人批准后,系统才允许执行。

例如:

Agent 生成退款请求
        ↓
系统校验退款金额、订单和权限
        ↓
等待财务人员批准
        ↓
调用退款 API

关键是“批准什么”必须是明确的动作,而不是泛泛地批准一段自然语言。

不可靠的确认:

Agent:我已经检查过订单,是否继续?

更可靠的确认:

动作:POST /refunds
订单:ORD-20250301-17
金额:¥8,000
原因:重复扣款
目标账户:原支付账户
影响:将产生不可逆的资金变动
依据:订单状态为 paid,支付记录存在两笔相同授权
批准有效期:10 分钟

确认应绑定到结构化动作或其不可变摘要。否则,Agent 在获得批准后改变了金额、目标对象或工具参数,原批准就不应继续有效。

2. 升级:当前执行者无法安全决策

**升级(escalation)**不是简单地把任务转发给另一个人,而是把“需要更高权限、更高专业性或更高责任等级的决策”转移给指定队列或角色。

典型触发条件包括:

  • 置信度低,且候选答案之间无法区分;
  • 任务超出 Agent 的权限范围;
  • 规则冲突,例如客户政策允许退款,但反欺诈规则禁止退款;
  • 金额、影响范围或敏感等级超过阈值;
  • 多次工具调用失败,继续重试可能造成副作用;
  • 检索资料互相矛盾,系统无法形成可审计依据;
  • 请求涉及法律、医疗、财务或安全责任;
  • 人工确认在规定时间内没有完成。

升级的核心是责任级别变化。如果只是排队等待同一个审批者,不一定叫升级;如果从一线客服转给财务主管、从普通工程师转给安全值班人员,则是升级。

3. 接管:人工成为当前执行者

**接管(takeover)**表示人工不只是批准某一步,而是暂时成为任务的主要执行者。人工可以查看上下文、修改计划、调用工具、暂停任务或终止任务。

接管适用于:

  • Agent 已经进入不稳定状态,需要人工重新规划;
  • 任务是开放式操作,无法预先枚举每个确认点;
  • 发生安全事件,需要先停止自动动作;
  • 复杂例外需要领域专家直接处理;
  • 需要与外部对象沟通,而不是执行固定 API。

接管后必须定义一个明确的控制权状态。如果人和 Agent 同时认为自己拥有执行权,就会出现“双重执行”:

Agent 认为“客户已批准退款”,准备调用 API
人工也在后台手动退款
两个动作最终都成功

这不是模型质量问题,而是并发控制和幂等性设计错误。

4. 恢复:从暂停、失败或接管状态安全回到流程

**恢复(recovery)**是系统在暂停、拒绝、超时、进程崩溃、工具部分成功或人工接管后,决定如何继续、补偿、回滚或终止。

恢复不是“让 Agent 再试一次”。必须先确定上一个动作的外部结果:

  • 没有发送出去,可以安全重试;
  • 请求已发送但响应丢失,必须查询状态,不能盲目重试;
  • 部分步骤已成功,需要继续未完成步骤或执行补偿;
  • 动作不可逆,只能记录事实并进入人工处理;
  • 人工拒绝,必须保存拒绝原因,不能在同一条件下自动绕过;
  • 确认超时,原批准不能自动延长,除非策略明确允许。

二、Agent 的执行对象不是“回答”,而是带权限的动作

普通聊天系统通常关注输出文本是否正确。Agent 系统还必须关注动作(action)

可以把一次执行建模为:

e=(u,c,p,a,r,τ)e = (u, c, p, a, r, \tau)

其中:

  • uu:任务主体,例如用户、业务部门或服务账户;
  • cc:上下文,包括输入、检索结果、历史状态和模型生成内容;
  • pp:权限与策略集合;
  • aa:准备执行的动作;
  • rr:动作结果;
  • τ\tau:时间和版本信息。

动作 aa 不只是工具名称,还应包含:

a=(tool,arguments,target,side-effect,scope)a = (\text{tool}, \text{arguments}, \text{target}, \text{side-effect}, \text{scope})

例如 send_email 的动作至少应区分:

{
  "tool": "send_email",
  "arguments": {
    "to": ["customer@example.com"],
    "subject": "退款处理结果",
    "body": "..."
  },
  "target": "customer@example.com",
  "side_effect": "external_communication",
  "scope": "one_customer"
}

同一个 Agent 生成“退款建议”和“执行退款请求”,风险完全不同。前者可能只改变内部草稿,后者会改变外部资产。

因此,人工确认的对象应是动作的权限化表示,而不是自然语言结论。


三、风险不是只由模型置信度决定

模型概率不能直接等价于业务安全性。一个模型对错误答案可能有很高的 token 概率,而一个低置信度的答案也可能只是无害的格式选择。

对动作 aa,可以定义一个简化的风险函数:

R(a)=P(harmx,a)I(a)+Cirreversible(a)+Cprivacy(a)R(a) = P(\text{harm} \mid x, a) \cdot I(a) + C_{\text{irreversible}}(a) + C_{\text{privacy}}(a)

其中:

  • P(harmx,a)P(\text{harm} \mid x, a):在上下文 xx 下动作造成损害的概率;
  • I(a)I(a):损害影响,例如金额、用户数、服务中断范围;
  • Cirreversible(a)C_{\text{irreversible}}(a):不可逆成本;
  • Cprivacy(a)C_{\text{privacy}}(a):泄露或越权处理敏感数据的成本。

这不是要求系统精确计算真实概率,而是说明确认策略必须同时考虑:

  1. 发生错误的可能性;
  2. 错误发生后的损失;
  3. 是否还能补救;
  4. 是否涉及法律、隐私或安全边界。

例如:

动作 可能的错误 可逆性 通常的控制
给内部文档生成摘要 遗漏信息 自动执行,抽样评测
创建草稿邮件 语气或事实错误 自动生成,发送前确认
发送外部邮件 收件人或内容错误 发送前确认或规则放行
修改生产配置 服务中断 中/低 双人确认、变更窗口、回滚
转移资金 金额或账户错误 强身份认证、二次审批、幂等键
删除数据 误删 禁止 Agent 直接执行,或强制人工接管

模型、数据、评测、权限和成本应一起看。降低模型成本而提高自动执行范围,可能增加人工复核和事故成本;增加检索数据量可能提高回答质量,也可能扩大敏感数据暴露范围;更换模型版本可能改变工具选择分布,从而改变人工审批量。


四、确认点应该放在哪里

确认点不是越多越安全。每增加一个确认点,就增加等待时间、人工成本和“无脑点击批准”的可能性。

可以把 Agent 的计划表示为一串动作:

P=(a1,a2,,an)P = (a_1, a_2, \ldots, a_n)

对每个动作定义:

  • sis_i:副作用等级;
  • rir_i:失败后的可恢复性;
  • did_i:数据敏感等级;
  • qiq_i:所需权限等级;
  • hih_i:人工确认成本。

当动作满足下式时,通常应设置确认点:

siSthresholdriRthresholddiDthresholdqi>Qagents_i \geq S_{\text{threshold}} \quad \lor \quad r_i \leq R_{\text{threshold}} \quad \lor \quad d_i \geq D_{\text{threshold}} \quad \lor \quad q_i > Q_{\text{agent}}

直觉是:副作用大、难以恢复、处理敏感数据或超过 Agent 权限的动作,都不能仅依靠模型继续执行。

确认点的三个位置

1. 工具调用前

这是最常见的方式:

模型决定调用工具
→ 策略引擎检查
→ 生成人工确认请求
→ 批准后调用工具

它能阻止高风险动作,但要求系统能准确描述最终参数。

2. 计划提交前

当一个计划包含多个相关动作时,可以先让人批准计划:

1. 查询订单
2. 生成退款草稿
3. 发送客户通知
4. 执行退款

这种方式减少确认次数,但有一个边界:批准计划不一定等于批准计划中未来所有具体参数。如果订单、金额或收件人会变化,执行前仍需重新校验。

3. 结果提交前

某些动作已经在沙箱中执行,人工只批准将结果写入生产系统:

Agent 在临时环境生成数据库迁移
→ 自动测试
→ 人工确认
→ 应用到生产库

这适合可预览、可验证的操作,但不适合已经造成外部副作用的动作。邮件一旦发送,不能靠“结果提交前确认”挽回。


五、一个完整的状态机

把人工介入实现为状态机,比在代码中散落 if approved 更可靠。

一个任务可以使用以下状态:

CREATED
  ↓
RUNNING
  ├── WAITING_APPROVAL
  ├── ESCALATED
  ├── HUMAN_TAKEOVER
  ├── WAITING_RETRY
  ├── SUCCEEDED
  ├── REJECTED
  ├── CANCELLED
  └── FAILED

状态含义必须互斥。例如:

  • WAITING_APPROVAL:Agent 仍是控制者,但被策略阻塞;
  • ESCALATED:任务等待更高权限或更专业的处理者;
  • HUMAN_TAKEOVER:人工持有控制权,Agent 不得自动调用外部工具;
  • WAITING_RETRY:系统已确定可以重试,但尚未执行重试;
  • FAILED:当前流程无法继续,可能需要补偿或人工介入;
  • CANCELLED:任务被明确终止,不应因为队列重放而重新执行。

可以将合法转换写成:

δ(state,event)new state\delta(\text{state}, \text{event}) \rightarrow \text{new state}

例如:

RUNNING + high_risk_action      → WAITING_APPROVAL
WAITING_APPROVAL + approve      → RUNNING
WAITING_APPROVAL + reject       → REJECTED
WAITING_APPROVAL + timeout      → ESCALATED
RUNNING + repeated_tool_failure → ESCALATED
RUNNING + takeover               → HUMAN_TAKEOVER
HUMAN_TAKEOVER + release         → RUNNING
RUNNING + completed              → SUCCEEDED

不合法的转换必须被系统拒绝:

REJECTED + retry
SUCCEEDED + execute_again
HUMAN_TAKEOVER + agent_tool_call
CANCELLED + queue_redelivery

其中最后一个尤其重要。消息队列至少一次投递时,旧消息可能在任务已取消后重新到达。消费者必须再次检查任务状态,而不能只相信消息内容。

stateDiagram-v2
    [*] --> CREATED
    CREATED --> RUNNING
    RUNNING --> WAITING_APPROVAL: 高风险动作
    WAITING_APPROVAL --> RUNNING: 批准且参数仍有效
    WAITING_APPROVAL --> REJECTED: 拒绝
    WAITING_APPROVAL --> ESCALATED: 超时或审批者无权限
    RUNNING --> HUMAN_TAKEOVER: 人工接管
    HUMAN_TAKEOVER --> RUNNING: 人工释放控制权
    RUNNING --> WAITING_RETRY: 可重试失败
    WAITING_RETRY --> RUNNING: 退避后重试
    RUNNING --> SUCCEEDED: 完成
    RUNNING --> ESCALATED: 不确定、越权或反复失败
    HUMAN_TAKEOVER --> CANCELLED: 人工终止
    ESCALATED --> HUMAN_TAKEOVER: 专家接管

核心路径不是“模型—人工—模型”,而是:

任务状态存储
   ↑       ↓
编排器 ← 策略引擎
   ↓
模型 ←→ 工具适配器 ←→ 外部系统
   ↑
人工界面 / 审批队列

所有关键决策都应通过状态存储和策略引擎落盘。人工界面不能直接调用业务工具绕过编排器,否则审计记录、权限检查和幂等控制会被破坏。


六、确认请求必须是不可混淆的授权对象

一个确认请求至少应包含以下字段:

{
  "approval_id": "apr_01J...",
  "task_id": "task_01J...",
  "action_hash": "sha256:...",
  "tool": "refund_payment",
  "arguments": {
    "order_id": "ORD-17",
    "amount": 8000,
    "currency": "CNY"
  },
  "reason": "检测到重复扣款",
  "evidence_refs": [
    "payment:pay-1",
    "payment:pay-2"
  ],
  "risk": {
    "side_effect": "financial",
    "reversibility": "low"
  },
  "requested_role": "finance_approver",
  "expires_at": "2025-03-01T10:10:00Z",
  "policy_version": "refund-policy-12",
  "model_version": "agent-model-2025-02"
}

其中 action_hash 是对规范化动作进行哈希后的摘要。批准时系统重新计算当前动作的哈希:

Hcurrent=SHA256(canonicalize(a))H_{\text{current}} = \operatorname{SHA256}(\operatorname{canonicalize}(a))

只有当:

Hcurrent=HapprovedH_{\text{current}} = H_{\text{approved}}

并且确认未过期、策略版本仍有效、批准人拥有对应角色时,才允许执行。

为什么不能只存一个布尔值

下面这种数据结构不足以审计:

{
  "approved": true
}

它无法回答:

  • 谁批准的?
  • 批准的是什么金额?
  • 批准时看到的证据是什么?
  • 模型或策略版本是什么?
  • 批准是否已经过期?
  • 批准之后参数是否被改过?
  • 批准人是否有权批准这个动作?

审批是授权事实,不是 UI 状态。


七、一个可运行的最小确认与幂等示例

下面的 Python 示例只使用标准库,演示确认点、动作哈希、过期、状态检查和幂等执行。它不是生产级支付系统,但可以直接运行来观察核心行为。

from __future__ import annotations

from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from hashlib import sha256
import json
import uuid


def now() -> datetime:
    return datetime.now(timezone.utc)


def canonical_json(value: dict) -> str:
    return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))


def action_hash(action: dict) -> str:
    return sha256(canonical_json(action).encode("utf-8")).hexdigest()


@dataclass
class Approval:
    approval_id: str
    task_id: str
    action: dict
    action_hash: str
    approver_role: str
    expires_at: datetime
    approved_by: str | None = None
    status: str = "PENDING"


@dataclass
class Task:
    task_id: str
    state: str = "WAITING_APPROVAL"
    executed_keys: set[str] = field(default_factory=set)


class ApprovalError(Exception):
    pass


def approve(approval: Approval, actor: str, actor_roles: set[str]) -> None:
    if approval.status != "PENDING":
        raise ApprovalError(f"approval is {approval.status}")
    if approval.approver_role not in actor_roles:
        raise ApprovalError("actor lacks required approval role")
    if now() >= approval.expires_at:
        approval.status = "EXPIRED"
        raise ApprovalError("approval expired")

    approval.approved_by = actor
    approval.status = "APPROVED"


def execute(task: Task, approval: Approval, current_action: dict) -> str:
    if task.state != "WAITING_APPROVAL":
        raise ApprovalError(f"task is not waiting for approval: {task.state}")
    if approval.status != "APPROVED":
        raise ApprovalError(f"approval is {approval.status}")
    if now() >= approval.expires_at:
        approval.status = "EXPIRED"
        raise ApprovalError("approval expired")

    # 防止批准后参数被替换
    if action_hash(current_action) != approval.action_hash:
        raise ApprovalError("approved action does not match current action")

    # 一个真实系统应把这个键持久化到数据库唯一索引中
    idempotency_key = f"{task.task_id}:{approval.approval_id}"
    if idempotency_key in task.executed_keys:
        return "ALREADY_EXECUTED"

    # 这里代表调用外部副作用 API。
    # 生产环境中应先在外部系统使用相同幂等键,再标记本地完成。
    task.executed_keys.add(idempotency_key)
    task.state = "SUCCEEDED"
    approval.status = "CONSUMED"
    return "EXECUTED"


if __name__ == "__main__":
    task = Task(task_id="task-" + uuid.uuid4().hex[:8])

    action = {
        "tool": "refund_payment",
        "arguments": {
            "order_id": "ORD-17",
            "amount": 8000,
            "currency": "CNY",
        },
    }

    approval = Approval(
        approval_id="apr-" + uuid.uuid4().hex[:8],
        task_id=task.task_id,
        action=action,
        action_hash=action_hash(action),
        approver_role="finance_approver",
        expires_at=now() + timedelta(minutes=10),
    )

    approve(approval, actor="alice", actor_roles={"finance_approver"})

    print(execute(task, approval, current_action=action))
    print(execute(task, approval, current_action=action))
    print(task.state)

预期输出类似:

EXECUTED
ALREADY_EXECUTED
SUCCEEDED

这个结果体现了三个约束:

  1. alice 必须具备 finance_approver 角色;
  2. 执行时动作参数必须与批准时的哈希一致;
  3. 同一个任务和批准请求重复到达时,不得产生第二次副作用。

示例中的 executed_keys 只是内存集合,进程重启后会丢失。生产环境必须将它放入持久化存储,并用唯一约束或原子插入防止并发执行。更进一步,外部支付系统也必须支持幂等键;仅在本地防重不能阻止网络超时后重复支付。


八、并发、超时和“结果未知”是人工介入最容易出错的地方

1. 双重审批

两个审批者同时点击批准,不应导致两次执行。审批状态转换必须是条件更新:

UPDATE approvals
SET status = 'APPROVED',
    approved_by = :actor,
    approved_at = CURRENT_TIMESTAMP
WHERE approval_id = :id
  AND status = 'PENDING'
  AND expires_at > CURRENT_TIMESTAMP;

受影响行数为:

  • 1:本次成功取得批准;
  • 0:已被其他人处理、已过期或不存在。

不能先 SELECT status,再单独 UPDATE,因为两个请求可能同时读到 PENDING

2. Agent 与人工同时操作

接管时应使用租约或版本号:

RUNNING(version=7)
→ HUMAN_TAKEOVER(owner=alice, version=8)

Agent 每次调用工具前检查:

当前状态仍为 RUNNING?
控制者仍为 agent?
版本号仍为 8?

如果其中一个条件不满足,就拒绝调用。仅仅在 UI 上显示“人工已接管”是不够的,工具网关也必须强制执行这个约束。

3. 审批超时不是批准

确认请求超过有效期后应进入 EXPIREDESCALATED。不能因为队列延迟、值班人员未登录或系统时钟不同步,就把沉默解释为同意。

有些低风险系统可以配置“超时自动拒绝”或“超时转人工队列”;高风险动作不应配置“超时自动批准”,除非业务责任人明确接受这种策略,并且有独立的法律和安全评估。

4. 工具超时不等于工具未执行

假设 Agent 调用支付服务:

客户端发送请求
→ 支付服务成功扣款
→ 响应在网络中丢失
→ Agent 收到 timeout

此时状态不是“失败且可以重试”,而是:

OUTCOME_UNKNOWN

恢复步骤应是:

  1. 使用原幂等键查询支付服务;
  2. 若查到成功,记录本地为成功;
  3. 若查到未执行,才允许重试;
  4. 若支付服务无法查询,升级给人工或进入对账流程;
  5. 在结果未确定前,禁止生成第二个支付请求。

这是分布式系统的事实,不是 Agent 特有的问题;自然语言规划反而会让错误重试更隐蔽。


九、升级策略:按原因、权限和时限路由

升级不能只设置一个“转人工”按钮。一个可用的升级事件至少需要记录:

{
  "task_id": "task-17",
  "reason_code": "TOOL_OUTCOME_UNKNOWN",
  "severity": "HIGH",
  "required_role": "payment_operations",
  "deadline": "2025-03-01T10:20:00Z",
  "attempts": 2,
  "last_error": "timeout after request accepted",
  "context_snapshot": "ctx-...",
  "suggested_action": "query payment provider by idempotency key"
}

按原因路由

  • POLICY_CONFLICT:交给业务规则负责人;
  • PERMISSION_DENIED:交给权限管理员或业务审批人;
  • SENSITIVE_DATA:交给隐私或安全人员;
  • TOOL_OUTCOME_UNKNOWN:交给外部系统运维或对账人员;
  • LOW_CONFIDENCE:交给领域专家;
  • PROMPT_INJECTION_SUSPECTED:交给安全响应人员;
  • BUDGET_EXCEEDED:交给成本负责人,而不是让 Agent 自动切换到无约束模式。

按时限路由

升级需要服务级别目标(SLA),但 SLA 不是“超过时限就继续执行”。例如:

0–5 分钟:分配给一线审批队列
5–15 分钟:通知值班主管
15 分钟后:冻结相关自动动作并创建高优先级事件

每一层都应明确允许的动作范围。主管可以批准更高金额,不代表可以修改安全策略或绕过身份验证。


十、接管不是把聊天窗口交给人

接管界面应展示可操作的状态,而不是只展示完整对话记录。至少需要:

  • 当前任务状态和控制者;
  • 已执行动作及结果;
  • 尚未执行的计划;
  • 工具参数和权限;
  • 证据来源与时间;
  • 模型、提示模板、策略和工具版本;
  • 失败、重试、超时和外部请求 ID;
  • 可能的补偿动作;
  • 人工修改前后的差异。

人工接管时应产生一个事件:

{
  "event": "CONTROL_TRANSFERRED",
  "from": "agent",
  "to": "alice",
  "reason": "policy_conflict",
  "at": "2025-03-01T10:05:00Z",
  "task_version": 8
}

人工修改 Agent 生成的计划也应记录为新版本,而不是覆盖原计划:

plan-v1:退款 8,000 元并通知客户
plan-v2:先冻结退款,核对支付渠道,再决定

这样才能在事故调查中区分:

  • 模型原本建议了什么;
  • 策略引擎阻止了什么;
  • 人工改变了什么;
  • 外部系统实际执行了什么。

十一、恢复策略必须按副作用分类

可以把失败动作分成三类。

A. 确定未执行

例如本地参数校验失败,工具网关在发出请求前拒绝。

处理方式:

修正参数或升级
→ 生成新的动作版本
→ 重新请求批准

不能沿用旧批准,因为动作可能已改变。

B. 确定已执行

例如外部系统返回成功,但本地保存结果时进程崩溃。

处理方式:

  1. 根据外部请求 ID 或幂等键查询;
  2. 将本地状态补记为成功;
  3. 不再次调用副作用工具;
  4. 继续后续步骤时重新检查依赖。

C. 结果未知

例如请求超时、连接断开或响应解析失败。

处理方式:

禁止盲目重试
→ 查询外部状态
→ 根据查询结果进入成功、未执行或人工对账

补偿不等于回滚

如果系统已经发送邮件,删除本地发送记录不能撤回邮件;如果已经扣款,写一条“退款成功”记录也不能证明钱已退回。

补偿动作是一个新的业务动作:

原动作:扣款
补偿动作:退款

补偿也可能失败,也需要幂等性、权限和确认点。


十二、MCP 在人工介入中的边界

Model Context Protocol(MCP)定义了模型应用与外部上下文、工具和资源之间的协议结构。其典型关系是:

Host(模型应用)
  ├── MCP Client
  │     └── MCP Server
  │           ├── Tools
  │           ├── Resources
  │           └── Prompts

MCP 的作用是标准化发现和调用能力,例如服务器可以暴露工具,客户端可以获取资源或提示模板。它并不自动替业务系统决定:

  • 哪些工具必须人工批准;
  • 谁拥有批准权限;
  • 批准是否满足双人规则;
  • 工具调用失败后如何补偿;
  • 某个动作由谁承担业务责任。

换句话说,协议层的工具可发现性不等于业务层的执行授权

MCP 规范包含能力协商和交互机制;某些版本的规范还定义了服务器请求客户端向用户发起 elicitation(信息采集或确认)的方式。但即使使用这种协议能力,最终的批准仍应由 Host 或业务编排层根据本地身份、权限、策略和审计要求决定。不能因为 MCP Server 请求了用户输入,就把它当成已经获得了可执行的业务授权。

一个安全的调用路径应类似:

Agent 选择 MCP tool
→ Host 根据工具元数据分类风险
→ 本地策略检查用户、租户、权限和预算
→ 必要时创建审批请求
→ 批准结果绑定工具名和参数摘要
→ Host 调用 MCP tool
→ 记录协议请求、响应和业务结果

MCP 工具描述可以帮助系统识别工具用途和参数,但工具描述本身是不可信的业务授权来源。生产系统应对工具实施允许列表、参数校验、超时、网络边界、速率限制和审计。

还需要区分两种身份:

  • 调用身份:MCP Client 或服务账户使用什么凭据访问服务器;
  • 责任主体:谁授权了这次动作,动作代表谁的业务意图。

服务账户成功调用工具,并不意味着某个人已经批准该业务动作。


十三、Agent 框架中的人工介入位置

Agent 框架通常会提供以下能力的组合:

  • 指令和上下文管理;
  • 工具调用;
  • 多 Agent handoff;
  • guardrails;
  • tracing;
  • 任务状态或运行上下文。

OpenAI 的 Agents Guide 将 Agent 系统描述为模型、工具、指令以及运行时控制机制的组合,并讨论了 guardrails、handoffs 和 tracing 等概念。这里需要区分:

  • guardrail:自动检查输入、输出或工具调用是否满足规则;
  • handoff:把任务转给另一个 Agent 或专门处理节点;
  • 人工审批:由具备身份和权限的人批准一个明确动作;
  • 人工接管:人取得任务控制权;
  • 人工复核:事后抽样检查,不能替代执行前授权。

一个 guardrail 可以阻止“向外部发送身份证号”,但它不能自动证明某个人批准了发送;一个 handoff 可以把客服 Agent 转给退款 Agent,但不等于财务审批;一个 trace 可以记录调用链,但不等于不可篡改的业务审计日志。

因此,在框架生命周期中,人工介入通常位于工具调用边界:

plan = agent.plan(context)

for action in plan.actions:
    policy_result = policy.check(
        principal=context.principal,
        action=action,
        budget=context.budget,
    )

    if policy_result.requires_human:
        approval = approval_store.create(action, policy_result)
        return RunPaused(
            reason="WAITING_APPROVAL",
            approval_id=approval.id,
        )

    result = tool_gateway.execute(action)
    context = context.apply(result)

return RunSucceeded(context)

恢复时不要重新从原始用户请求开始完整运行,因为这样可能重新生成不同计划并重复执行已有动作。应从持久化检查点恢复:

读取 task state
→ 读取已完成动作
→ 查询不确定动作的外部状态
→ 生成剩余计划
→ 对新动作重新做策略判断

如果框架自动重试模型调用,也必须保证模型重试不会绕过工具审批。模型可以重新生成解释或计划,但实际工具网关仍应以任务状态、动作哈希和权限检查为准。

由于 Agent 框架和 API 版本会变化,具体的 approvalhandoffguardrail 参数不能脱离所用 SDK 版本直接照抄。稳定的设计原则是:把人工审批作为持久化业务状态和工具网关策略,而不是只依赖某个 SDK 的内存回调。


十四、人工确认也会失败

1. 人看到的信息不足

只显示:

是否允许 Agent 继续?

会导致形式化批准。审批人无法判断金额、对象、证据和影响范围,点击批准只是批准了一个未知动作。

诊断方法是检查审批事件中是否能重建当时的动作;如果只能重建 Agent 的自然语言描述,审计信息不足。

2. 审批疲劳

如果低风险动作也频繁弹窗,人工会形成“全部批准”习惯,确认点的安全价值下降。

可以通过风险分层减少数量:

  • 低风险:自动执行并抽样评测;
  • 中风险:批量确认,但绑定明确范围;
  • 高风险:单动作确认;
  • 极高风险:双人审批或人工接管。

批量审批必须明确集合边界,例如“批准以下 20 封内部通知”,而不是“批准 Agent 接下来所有动作”。

3. 批准后上下文变化

库存、价格、账户状态或权限可能在审批后改变。动作哈希只能保证参数没有变化,不能保证外部事实没有变化。因此执行前仍应做时效性检查:

批准时余额:¥10,000
执行时余额:¥6,000

如果策略要求余额必须满足阈值,执行前重新校验失败时应拒绝动作并生成新的审批请求。

4. 人工输入被当成可信指令

人工界面中的备注、外部邮件或上传文档可能包含提示注入内容。人工审批不是绕过输入安全检查的通道。审批人填写的结构化字段应经过权限和格式校验;自由文本不能直接拼接成高权限工具参数。

5. 人工选择本身出错

人也会误读金额、选错账户或被社会工程攻击。因此高风险流程仍需要:

  • 强认证;
  • 清晰展示目标和金额;
  • 二次确认;
  • 双人规则;
  • 高风险操作的独立渠道验证;
  • 操作后对账。

HITL 降低的是自动化风险,不会把人为风险降为零。


十五、权限边界:谁能决定,谁能执行,谁承担后果

至少要区分三个角色:

1. 提议者

Agent 或模型生成动作建议。提议者可以拥有读取数据和生成计划的权限,但不一定拥有执行权限。

2. 批准者

人工确认动作符合业务规则。批准者的权限应绑定到动作类型、金额、租户和时间范围。

3. 执行者

工具网关或服务账户真正调用外部系统。执行者应验证批准结果,而不能仅凭 Agent 传来的“已批准”字段执行。

更严格的系统还需要第四个角色:

4. 责任主体

代表哪个用户、部门或法人承担业务后果。责任主体不一定是登录系统的操作员,也不一定是模型开发者。

可以用一个授权关系表示:

Allow(u,a,t)=Identity(u)Role(u,a)Policy(a,t)Approval(a)Fresh(a,t)\operatorname{Allow}(u, a, t) = \operatorname{Identity}(u) \land \operatorname{Role}(u, a) \land \operatorname{Policy}(a,t) \land \operatorname{Approval}(a) \land \operatorname{Fresh}(a,t)

其中 tt 是执行时间。缺少任一项,都不应视为完整授权。

责任不能由“模型说了什么”决定

以下责任划分更接近生产现实:

环节 主要责任
业务规则定义 业务负责人
模型和提示配置 Agent/模型工程团队
数据质量和访问范围 数据与系统负责人
工具副作用和接口语义 工具服务负责人
审批人是否有权限 业务审批体系
是否执行已批准动作 编排器和工具网关
外部系统最终状态 外部系统负责人及业务对账方
事故响应和补偿 事先指定的运营与安全团队

这不是说模型团队不对结果负责,而是不能把所有系统性问题都归因于“模型幻觉”。如果工具网关没有校验权限、数据库没有幂等约束、审批日志无法证明动作内容,那么事故根因在系统设计,而不只是模型输出。


十六、评测人工介入,而不只是评测模型回答

人工介入系统需要独立的评测指标。

安全相关指标

  • 高风险动作拦截率;
  • 越权调用阻断率;
  • 批准后参数变更检测率;
  • 重复副作用发生率;
  • 结果未知时的盲目重试率;
  • 过期批准被执行的次数;
  • 人工接管期间 Agent 误调用工具的次数。

人效相关指标

  • 平均审批等待时间;
  • 升级到正确队列的比例;
  • 单个任务人工操作时长;
  • 审批拒绝率及拒绝原因;
  • 审批后撤销或补偿比例;
  • 人工接管后成功恢复比例。

质量和成本的联合指标

可以把单个任务的期望成本表示为:

E[C]=Cmodel+Ctool+Chuman+PincidentCincidentE[C] = C_{\text{model}} + C_{\text{tool}} + C_{\text{human}} + P_{\text{incident}} \cdot C_{\text{incident}}

模型调用更便宜,不代表总成本更低。如果模型频繁产生需要人工处理的错误计划,ChumanC_{\text{human}} 会上升;如果降低人工审批范围导致事故概率上升,最后一项可能远大于模型成本。

评测集也应包含:

  • 正常低风险任务;
  • 高风险但合法的任务;
  • 权限不足任务;
  • 证据冲突任务;
  • 工具超时和重复投递;
  • 审批过期;
  • 人工拒绝;
  • 接管与释放;
  • 恶意提示注入;
  • 外部状态已改变的恢复场景。

十七、一个完整算例:退款 Agent 如何运行

假设客服 Agent 可以读取订单和支付记录,但不能直接退款。退款金额超过 1,000 元需要财务人员批准,超过 10,000 元需要两名财务人员批准。

第一步:读取数据

Agent 调用只读工具:

get_order(order_id)
get_payments(order_id)

这两个动作没有外部副作用,但仍要检查租户和客户数据权限。

返回:

订单金额:8,000 元
订单状态:已支付
支付记录:同一订单成功扣款两次

第二步:生成动作

Agent 生成:

refund_payment(
    order_id="ORD-17",
    amount=8000,
    currency="CNY",
    reason="duplicate_charge"
)

第三步:策略判断

因为金额大于 1,000 且退款具有资金副作用,策略引擎返回:

requires_approval = true
required_role = finance_approver
required_approvals = 1

任务进入:

WAITING_APPROVAL

Agent 此时可以生成给客户的内部草稿,但不能调用退款 API,也不能向客户承诺“退款已完成”。

第四步:人工查看

审批界面显示订单、两笔支付证据、金额、目标账户、政策版本和动作哈希。财务人员批准。

第五步:执行前重校验

工具网关检查:

  1. 任务仍为 WAITING_APPROVAL
  2. 批准状态为 APPROVED
  3. 当前动作哈希未改变;
  4. 批准未过期;
  5. 财务人员角色有效;
  6. 订单仍满足退款规则;
  7. 外部支付调用使用唯一幂等键。

第六步:处理超时

如果支付 API 超时,任务进入:

OUTCOME_UNKNOWN

系统查询:

GET /refunds/by-idempotency-key/{key}

如果返回退款成功,任务进入 SUCCEEDED;如果返回不存在,才可以重试;如果支付平台不可查询,则升级给支付运营人员。

反例:为什么不能让模型直接发送“退款成功”

模型可能在工具调用失败后根据计划继续生成:

已为您完成退款,预计原路返回。

这会把“计划完成”误写成“业务事实完成”。正确做法是把外部系统的确认结果作为事实来源:

退款状态 = succeeded

只有在工具或对账系统确认后,Agent 才能使用“已完成”这种事实性表述。


十八、实践中的边界判断

“所有工具调用都让人确认”并不安全

这会造成审批疲劳,也会把低风险操作的处理成本推高。更合理的是根据副作用、敏感性、可逆性和权限分层。

“只让人确认最终回答”通常太晚

如果 Agent 已经发送邮件、修改数据或调用支付接口,最终回答前的确认无法撤回副作用。确认点应位于副作用发生前。

“模型置信度高就不需要确认”是错误推论

置信度反映模型内部预测,不等于业务授权,也不等于事实正确。高置信度地调用错误账户仍然是高风险动作。

“人工接管后可以关闭所有自动检查”是错误设计

接管改变控制者,不应取消权限、数据访问、审计和工具安全检查。人工操作也应经过相同的工具网关,必要时使用更高但明确的权限。

“重试失败工具调用”不是通用恢复方案

必须先判断失败发生在请求发送前、发送后还是结果写入前。对于有副作用的操作,查询状态优先于重试。

“日志里记录了对话”不等于可审计

审计需要记录动作参数、身份、授权、策略版本、工具请求 ID、外部结果和状态转换。自然语言对话只能作为上下文,不能替代结构化事件。


十九、上线前应验证的最小闭环

一个真正可用的人工介入系统,至少应能通过以下测试:

  1. 高风险工具调用是否一定进入确认或接管状态?
  2. 确认请求展示的参数是否与最终执行参数完全一致?
  3. 批准者没有对应角色时,是否无法批准?
  4. 批准过期后,旧按钮和旧消息是否都不能执行?
  5. 两个并发批准是否只产生一次有效状态转换?
  6. 消息重复投递时,是否不会产生第二次副作用?
  7. 工具响应超时时,系统是否区分未执行和结果未知?
  8. Agent 在人工接管期间是否无法调用工具?
  9. 人工拒绝后,模型是否会因重试而绕过拒绝?
  10. 模型、策略、工具和审批事件是否能够按任务 ID 重建?
  11. 外部系统已成功、本地状态丢失时,是否可以对账恢复?
  12. 成本预算耗尽时,系统是停止、升级还是进入人工接管,是否有明确策略?

这些测试验证的不是某个模型版本的回答质量,而是控制权、授权和外部副作用是否被系统正确约束。


人工介入的核心不是“让人参与”,而是让系统明确记录并执行四件事:什么时候自动行动,什么时候暂停,谁有权接手,以及如何证明动作合法且只执行了一次

确认点解决执行前授权,升级解决能力和责任等级不足,接管解决开放式或异常任务的控制权转移,恢复解决失败和不确定结果,责任边界则把模型建议、系统执行、人工批准和业务后果分开。只有这几层同时成立,Agent 才不是一个会偶尔调用 API 的聊天程序,而是一个具备可控权限、可恢复状态和可审计责任链的生产系统。


系列导航与关联阅读

官方资料

本文依据研究论文、标准组织与主流框架官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。