上一篇实现了有状态、有限步数的单 Agent 循环,但“能执行”不等于“应该执行”。查询天气失败,通常只是一次普通错误;删除数据、发送邮件、修改权限等操作,则可能造成不可逆的业务影响。只要 Agent 能调用工具,就应该把高风险操作从普通自动执行路径中分离出来。本篇只聚焦一个核心问题:如何在工具真正执行前增加人工审批闸门。

为什么需要人工审批

模型输出本质上是不可信的建议。即使提示词写着“发送前请确认”,模型仍可能误解上下文、选错工具,或者被用户输入中的诱导内容影响。程序可以检查参数是否符合类型,却无法单独判断一次操作是否符合当前业务意图。

因此,工具执行至少要经过三层判断:工具是否在白名单中,参数是否通过校验,以及这次操作是否需要审批。查询只读数据可以自动执行;写入、外发和删除等有副作用的操作,默认应暂停,等待明确的人工决定。审批不是让人重新完成全部工作,而是让人确认“谁要对什么目标做什么事”。

先给工具声明风险等级

不要根据工具名称猜风险,也不要让模型返回一个 approved 字段就直接相信。风险策略应由应用代码维护,并和具体工具绑定。下面定义一个最小工具注册表:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
from dataclasses import dataclass
from typing import Any, Callable


@dataclass(frozen=True)
class Tool:
handler: Callable[..., str]
risk: str # read、write、destructive


def get_profile(user_id: str) -> str:
return f"用户 {user_id} 的资料(示例数据)"


def delete_draft(draft_id: str) -> str:
return f"已删除草稿 {draft_id}(示例操作)"


TOOLS: dict[str, Tool] = {
"get_profile": Tool(get_profile, "read"),
"delete_draft": Tool(delete_draft, "destructive"),
}

这里的函数只返回示例文本,不会访问真实系统,因此可以离线运行。真正的删除函数仍应在内部做权限和资源归属检查;风险等级只是调度策略,不能代替业务授权。

把审批请求做成明确的数据

审批界面或命令行提示必须展示可核对的信息,而不是只显示“Agent 想继续吗”。最小审批请求应包括工具名、经过校验的参数、风险级别和请求人。审批函数返回布尔值,拒绝或无法得到确认时都按拒绝处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
def request_approval(
tool_name: str,
arguments: dict[str, Any],
risk: str,
approver: str = "operator",
) -> bool:
print("\n需要人工审批")
print(f"审批人:{approver}")
print(f"风险:{risk}")
print(f"工具:{tool_name}")
print(f"参数:{arguments}")
answer = input("输入 APPROVE 执行,其他内容拒绝:")
return answer.strip() == "APPROVE"

生产系统不应把任意敏感参数原样打印到日志或审批页面,例如访问令牌和完整个人信息应脱敏。更重要的是,展示给审批人的参数必须是程序校验后的版本,不能直接复用模型的原始 JSON。否则审批人确认的内容,可能和最后执行的内容不一致。

在执行器中设置闸门

下面的执行函数模拟 Agent 已经提出一个动作。它先验证动作结构,再查白名单,最后按风险决定是否审批。顺序不能颠倒:未知工具不应该进入审批流程,未通过参数校验的动作也不应该让人替它背书。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
def validate_arguments(name: str, arguments: Any) -> dict[str, Any]:
if not isinstance(arguments, dict):
raise ValueError("参数必须是对象")

if name in {"get_profile", "delete_draft"}:
key = "user_id" if name == "get_profile" else "draft_id"
value = arguments.get(key)
if not isinstance(value, str) or not value.strip():
raise ValueError(f"{key} 必须是非空字符串")
return {key: value.strip()}

raise ValueError("未知工具")


def execute_action(action: dict[str, Any]) -> str:
if action.get("type") != "tool_call":
return "停止:动作类型不允许执行。"

name = action.get("name")
tool = TOOLS.get(name)
if tool is None:
return "停止:工具不在白名单中。"

try:
arguments = validate_arguments(name, action.get("arguments"))
except (TypeError, ValueError) as exc:
return f"停止:参数校验失败:{exc}"

if tool.risk != "read":
if not request_approval(name, arguments, tool.risk):
return "已拒绝:高风险操作没有得到人工批准。"

try:
return tool.handler(**arguments)
except (TypeError, ValueError) as exc:
return f"停止:工具执行失败:{exc}"


if __name__ == "__main__":
print(execute_action({
"type": "tool_call",
"name": "delete_draft",
"arguments": {"draft_id": "draft-001"},
}))

运行文件后,输入 APPROVE 才会调用删除函数;输入其他内容、直接回车或审批函数发生异常,都不会执行高风险工具。示例中的“删除”只是打印文本,所以不会改变文件或数据库。这个可运行的离线例子验证的是控制流程,而不是模拟真实删除结果。

审批还要防哪些绕过

第一,审批状态要绑定一次具体操作。不要让一次“同意删除草稿”永久授权同类操作,也不要只按工具名缓存批准结果;参数、用户、资源和有效期都应参与绑定。第二,执行前再次检查权限和资源状态。审批等待期间,用户权限可能变化,目标资源也可能已经被别人修改。

第三,设置超时和幂等策略。审批过期就拒绝,网络重试不能导致邮件重复发送或扣款重复发生。对于不可逆操作,最好采用“预览—审批—执行”的两阶段流程,并保留请求 ID、审批人、审批时间、决策和执行结果,便于审计。日志需要脱敏,但不能因为脱敏而丢失定位一次操作所需的关联信息。

第四,审批人不应被模型生成的文字催促或替代。审批页面应把事实字段结构化展示,例如目标资源、变更前后值和影响范围;模型生成的解释只能作为辅助说明。高风险场景还可以要求双人审批、限制可审批角色,或完全禁止 Agent 自动发起某些操作。

常见问题

只在提示词中要求确认可以吗? 不可以。提示词是行为指导,不是安全边界。真正的拦截必须位于工具执行前的程序路径中。

读操作是否永远不需要审批? 不是。读取个人资料、密钥或大范围数据也可能是高风险操作,应按数据敏感度和访问者权限单独分级。

模型说已经获得批准,能否跳过询问? 不能。批准必须来自应用信任的审批渠道,并关联到当前动作,模型输出只能携带请求,不能产生授权。

审批后还要校验参数吗? 要。审批确认的是结构化、规范化后的参数;执行前仍应做最终权限、状态和业务规则检查。

小结

人工审批的核心不是增加一个 input,而是建立不可绕过的职责边界:模型提出动作,程序验证动作,审批人确认高风险意图,工具最后执行。用工具注册表声明风险、用白名单限制能力、用规范化参数生成审批摘要,并让拒绝和超时默认安全,才能把 Agent 从“自动操作脚本”变成可控的应用组件。下一篇可以继续研究日志、Tracing 与可观测性,让每次决策和审批都能被定位与复盘。