设计 Human-in-the-Loop 审批状态机¶
- ID:Q062
- 难度:进阶 / 系统设计 / 手撕设计
- 标签:Human-in-the-Loop、Approval、Risk Control、Pause/Resume、Authorization
同义问法¶
- Agent 执行危险操作前如何让人确认?
- Human-in-the-Loop 如何设计状态机?
- 审批通过后怎么恢复原任务?
- 如何防止审批内容和实际执行参数不一致?
一句话结论¶
Human-in-the-Loop 不是在前端弹一个“确认”按钮,而是把高风险动作转化为不可变审批请求,冻结动作参数和风险摘要,暂停 Run;审批结果经过鉴权、版本和过期校验后,Runtime 只执行被批准的那一个动作。
可视化图解¶
stateDiagram-v2
[*] --> PROPOSED
PROPOSED --> VALIDATED
VALIDATED --> WAITING_APPROVAL: high risk
VALIDATED --> EXECUTING: low risk
WAITING_APPROVAL --> REJECTED: reject
WAITING_APPROVAL --> REVALIDATING: approve
REVALIDATING --> WAITING_APPROVAL: resource changed
REVALIDATING --> EXECUTING: still valid
EXECUTING --> SUCCEEDED
EXECUTING --> FAILED
REJECTED --> [*]
SUCCEEDED --> [*]
FAILED --> [*]
一、哪些场景需要审批¶
常见触发条件:
- 删除、覆盖、发布、退款、转账等不可逆或高代价操作;
- 修改生产环境;
- 访问敏感数据;
- 工具参数超出常规范围;
- 模型置信度低且错误代价高;
- 策略要求双人复核;
- Agent 需要扩大权限或突破当前边界。
审批粒度应尽量落到具体动作,而不是批准“Agent 接下来随便做”。
二、审批请求数据结构¶
from dataclasses import dataclass
from typing import Any, Literal
@dataclass
class ApprovalRequest:
approval_id: str
run_id: str
step_id: str
tool_call_id: str
tool_name: str
frozen_arguments: dict[str, Any]
arguments_hash: str
risk_level: Literal["medium", "high", "critical"]
risk_summary: str
expected_effect: str
evidence_refs: list[str]
requested_by: str
required_roles: list[str]
required_approvals: int
status: Literal[
"pending", "approved", "rejected", "expired", "cancelled"
]
created_at: int
expires_at: int
policy_version: str
审批内容必须包含:
- 将执行什么工具;
- 参数是什么;
- 影响哪些资源;
- 为什么需要;
- 依据和风险;
- 审批有效期;
- 谁可以批准;
- 需要几个人批准。
三、状态机¶
RUNNING
→ RISK_DETECTED
→ APPROVAL_REQUESTED
→ WAITING_APPROVAL
├── approved → REVALIDATING
│ ├── valid → EXECUTING_APPROVED_ACTION
│ └── stale/changed → APPROVAL_REQUESTED
├── rejected → REPLAN / CANCELLED
├── expired → EXPIRED
└── run cancelled → CANCELLED
EXECUTING_APPROVED_ACTION
├── success → RUNNING
└── failure → ERROR_POLICY / REPLAN
审批后增加 REVALIDATING,因为等待期间外部状态、权限和任务目标可能变化。
四、审批前冻结动作¶
def create_approval(call, state, policy):
normalized_args = normalize_arguments(call.arguments)
args_hash = stable_hash(normalized_args)
request = ApprovalRequest(
approval_id=new_id(),
run_id=state.run_id,
step_id=state.current_step_id,
tool_call_id=call.call_id,
tool_name=call.tool_name,
frozen_arguments=normalized_args,
arguments_hash=args_hash,
risk_level=policy.risk_level(call),
risk_summary=policy.explain_risk(call, state),
expected_effect=describe_effect(call),
evidence_refs=current_evidence_refs(state),
requested_by="agent-runtime",
required_roles=policy.required_roles(call),
required_approvals=policy.required_approvals(call),
status="pending",
created_at=now(),
expires_at=policy.expiry(call),
policy_version=policy.version,
)
save_request_and_pause_run_atomically(request, state)
return request
冻结意味着审批后不能让模型悄悄换参数。如果参数变化,必须重新审批。
五、审批结果¶
@dataclass
class ApprovalDecision:
decision_id: str
approval_id: str
approver_id: str
approver_roles: list[str]
decision: Literal["approve", "reject"]
comment: str | None
decided_at: int
request_version: int
signature: str
处理逻辑:
def handle_approval(decision):
verify_signature(decision)
request = load_approval_for_update(decision.approval_id)
if request.status != "pending":
return "already_decided_or_expired"
verify_approver_identity(decision)
verify_required_role(decision, request)
verify_not_self_approval(decision, request)
record_decision_idempotently(decision)
if enough_rejections(request):
reject_request_and_resume_for_replan(request)
elif enough_approvals(request):
approve_request_and_enqueue_resume(request)
六、审批后重新校验¶
async def resume_after_approval(request):
state = load_run(request.run_id)
if now() > request.expires_at:
expire(request)
return
call = reconstruct_call(request)
if stable_hash(normalize_arguments(call.arguments)) != request.arguments_hash:
cancel_as_tampered(request)
return
auth = policy.authorize_again(
user=state.user,
tool=call.tool_name,
arguments=call.arguments,
)
if not auth.allowed:
invalidate_approval(request, "permission_changed")
return
preconditions = await verify_external_preconditions(call)
if not preconditions.passed:
invalidate_approval(request, "external_state_changed")
enqueue_replan(state.run_id)
return
await execute_approved_call(call, request.approval_id)
需要重查:
- 当前用户和审批人权限;
- 参数哈希;
- 外部资源版本;
- Run 是否仍有效;
- 策略是否更新;
- 审批是否过期。
七、TOCTOU 问题¶
Time-of-check to time-of-use:审批时看到的资源状态,到执行时可能已经变化。
例如:
解决:
- 审批请求记录目标资源版本;
- 执行前使用 compare-and-swap;
- 条件不满足则审批失效并重新规划;
- 不允许静默使用新状态继续执行。
八、多级审批¶
高风险场景可能要求:
审批规则由 Policy 决定,不应由模型决定。
@dataclass
class ApprovalPolicy:
required_roles: set[str]
minimum_distinct_approvers: int
forbid_requester_approval: bool
expires_in_seconds: int
防止:
- 同一个人重复批准;
- 请求人自批;
- 不具备角色的人批准;
- 过期批准被接受。
九、拒绝之后怎么办¶
拒绝不等于一定结束任务。拒绝结果应作为结构化事实返回:
{
"approval_status": "rejected",
"reason": "禁止在高峰期重启生产集群",
"allowed_alternatives": [
"只读诊断",
"生成操作方案",
"预约维护窗口"
]
}
Agent 可以:
- 重新规划低风险方案;
- 请求用户补充信息;
- 输出人工执行步骤;
- 终止任务。
不能不断重复申请同一个被拒绝的动作。
十、用户体验¶
审批页面应展示:
- 简洁人类可读说明;
- 精确机器参数;
- 影响范围;
- 风险与回滚方案;
- 证据;
- 过期时间;
- 批准与拒绝后会发生什么。
不要只显示模型生成的长篇解释,也不要只显示难懂 JSON。
十一、审计¶
记录完整链路:
审批记录不能被模型修改。
十二、面试口述版¶
Human-in-the-Loop 应设计成一套审批状态机,而不是一个确认按钮。Runtime 识别高风险工具后,创建不可变 ApprovalRequest,冻结工具名、规范化参数、参数哈希、目标资源版本、风险说明和证据,并将 Run 持久化为 waiting_approval。审批回调要校验身份、角色、去重、有效期和多级审批规则。批准后不能直接执行,还要重新校验参数哈希、权限、策略和外部资源版本,防止 TOCTOU;任何变化都使原审批失效。拒绝结果作为结构化约束交给 Agent 重规划,但不能重复申请同一动作。最终执行的必须是被批准的精确动作,而不是模型之后重新生成的另一个动作。