《AI Agent 工程实战》4.3 工具权限与审批:高危工具二次确认

本节给 minagent 的 ToolRegistry 加一层权限包装:工具按只读、写入、危险分三档,危险工具调用前挂起等人工审批。我们用假模型驱动一条完整轨迹——模型请求危险工具、被挂起、审批通过、继续执行,把每一步贴出来,并给出权限档位表与审批超时策略。

4.3 工具权限与审批:高危工具二次确认

认证确认了「你是谁」,租户隔离圈定了「你能看哪些数据」,但还有最后一类风险不在数据边界上,而在动作本身。Agent 能调用的工具里,search 查错了顶多重来,delete_run、send_email、sql_exec 一旦执行就不可逆。把删除权限直接交给一个会「自作主张」的模型,是很多 Agent 事故的根源。这一节给工具按危险程度分档,让高危动作在真正执行前停下来等人点头。

本节把 ResearchCopilot 推进到「高危可控」:新增 minagent/permissions.py,给 ToolRegistry 包一层权限,工具分只读 / 写入 / 危险三档,危险档必须拿到人工审批才放行。这复用了卷一第 12.3 节「人类在环」的思路,只是把落点从引擎内部搬到了工具边界上。

4.3.1 为什么是「工具权限」,而不是「模型自律」

一个常见的错误期待是:在 system prompt 里写「删除操作前必须先询问用户」,然后指望模型照做。这条路不可靠,原因有三:

  • 提示词是建议,不是约束。 模型可能忽略、可能被注入攻击绕过(第 15 章),可能在一个多步任务里「顺手」就删了。
  • 模型看不到权限边界。 它只知道有哪些工具可用,不知道「这个工具在这个租户下是否被允许」。
  • 审计要求「有据可查」。 合规场景下,「谁在什么时候批准了这次删除」必须是一条记录,而提示词里的口头承诺留不下证据。

正确的心智模型是:权限属于系统,不属于模型。 模型可以请求调用任何工具,但能不能真的执行由系统决定。这跟 3.1 那句「控制流属于程序,不属于模型」是同一条原则的延伸——执行权也在程序手里。落点就是:在 ToolRegistry.call 前面加一层拦截。

4.3.2 三档权限模型

档位不用太多,三档足够覆盖绝大多数场景:

档位语义例子执行策略
只读 readonly不改任何状态search / read_file / get_run直接执行
写入 write改自己的数据,可回滚create_run / update_note直接执行,记审计
危险 dangerous不可逆或影响外部delete_run / send_email / sql_exec挂起等人工审批

分档的关键不是「操作多大」,而是**「能不能撤销」**。update_note 改错了能改回来,算写入;send_email 发出去就收不回,算危险。一条好用的经验法则:如果「撤销」需要一个新动作(重发、道歉、恢复备份),它就是危险档。

写入档要不要也审批?默认不要,否则用户会被无意义的弹窗淹没,反而降低对真正危险操作的警觉(alert fatigue)。但写入档必须留审计日志,为事后追责留证据。

4.3.3 权限包装:PermissionedRegistry

PermissionedRegistry 复用卷一的 Tool 形状,只是在注册时多挂一个档位,并在 call 里拦截:

# minagent/permissions.py
from minagent import Tool, ToolCall


class ToolTier:
    READONLY = "readonly"
    WRITE = "write"
    DANGEROUS = "dangerous"


class ApprovalRequired(Exception):
    def __init__(self, tc: ToolCall) -> None:
        super().__init__(tc.name)
        self.tc = tc


class PermissionedRegistry:
    """包一层 ToolRegistry:每个工具挂一个权限档,危险档必须拿到审批才放行。"""

    def __init__(self) -> None:
        self._tools: dict[str, Tool] = {}
        self._tier: dict[str, str] = {}

    def add(self, tool: Tool, tier: str) -> None:
        self._tools[tool.name] = tool
        self._tier[tool.name] = tier

    def tier_of(self, name: str) -> str:
        return self._tier[name]

    def schemas(self):
        return [{"name": t.name, "description": t.description,
                 "parameters": t.parameters} for t in self._tools.values()]

    def call(self, tc: ToolCall, approved: frozenset[str] = frozenset()) -> str:
        if self._tier[tc.name] == ToolTier.DANGEROUS and tc.id not in approved:
            raise ApprovalRequired(tc)          # 拦截点:没有审批就不执行
        return str(self._tools[tc.name].func(**tc.arguments))

三个设计点:

  1. 拦截在 call 里,不在循环里。 这样即使将来有别的地方绕过循环直接调工具,权限依然生效。执法点要贴近资源,不能贴近调用方。
  2. approved 用 frozenset,粒度是 tool_call_id。 一次审批只放行这一个 ToolCall,不是「这个工具以后都放行」。否则模型后续再发一次 delete_run 就自动通过了。用 id 而非工具名,是为了防「一次批准、终身放行」。
  3. 抛异常而不是返回错误 JSON。 ToolRegistry.call 的契约是「出错返回 {"error":...}」,但「待审批」不是错误——它是一个控制流信号,要打断循环、交给外部处理。所以用异常区分「失败」与「挂起」。

4.3.4 带审批的循环

在卷一的 run() 基础上,把「捕获 ApprovalRequired → 调审批人 → 通过则重试」这段插进工具执行处:

# minagent/permissions.py(续)
import json
from minagent import Message, RunResult, Step


def run_with_approval(model, registry, user_input, approver,
                      system="", max_steps=8):
    messages: list[Message] = []
    if system:
        messages.append(Message(role="system", content=system))
    messages.append(Message(role="user", content=user_input))

    steps: list[Step] = []
    approved: set[str] = set()

    for i in range(max_steps):
        resp = model.complete(messages, registry.schemas())
        msg = resp.message
        messages.append(msg)
        if not msg.tool_calls:
            steps.append(Step(i, "final", msg.content))
            return RunResult(messages, steps, msg.content)

        for tc in msg.tool_calls:
            try:
                result = registry.call(tc, frozenset(approved))
            except ApprovalRequired as ar:
                steps.append(Step(i, "approval_required",
                                  f"{tc.name}({tc.arguments})"))
                if not approver(ar.tc):          # 挂起等人工;被拒走错误回灌
                    denied = json.dumps({"error": "approval_denied",
                                         "tool": tc.name})
                    messages.append(Message(role="tool", content=denied,
                                            tool_call_id=tc.id))
                    steps.append(Step(i, "approval_denied", tc.name))
                    continue
                approved.add(tc.id)
                steps.append(Step(i, "approval_granted", tc.name))
                result = registry.call(tc, frozenset(approved))
            messages.append(Message(role="tool", content=result,
                                    tool_call_id=tc.id))
            steps.append(Step(i, "tool_call",
                              f"{tc.name}({tc.arguments}) -> {result}"))

    steps.append(Step(max_steps, "error", "达到 max_steps 仍未收敛"))
    return RunResult(messages, steps, "")

关键点在于审批被拒也走正常回灌:把 {"error": "approval_denied"} 作为一条 tool 消息喂回模型,让模型知道「这个动作被拒了」,从而自己换一条路(比如改用只读工具、或向用户说明)。这与卷一「工具出错不抛异常、包成 JSON 回灌」是同一套哲学——让模型有机会自我纠正,而不是让循环崩掉。

注意 approver 在演示里是个同步回调,返回 True/False。真实系统里它是异步的:把待审批请求写进队列、通过 SSE(3.2)推给前端,然后挂起这个 run(存下 messages 与 steps 做检查点),等用户点「批准/拒绝」后再从检查点续跑。这正是卷一第 12.3 节人类在环的完整形态,本节的同步回调是它的最小骨架。

4.3.5 真跑:一条完整审批轨迹

注册一个只读的 search 和一个危险的 delete_run,用 FakeModel 脚本化三段:先检索、再请求删除、最后给报告。审批人打印一行日志并批准。

# demo_43.py(节选)
reg = PermissionedRegistry()
reg.add(Tool("search", "检索本地语料", {...},
             func=lambda q: f"命中 3 条与『{q}』相关的片段"), ToolTier.READONLY)
reg.add(Tool("delete_run", "永久删除一次运行及其产物", {...},
             func=lambda run_id: f"已删除 {run_id}"), ToolTier.DANGEROUS)

model = FakeModel([
    [ToolCall("c1", "search", {"q": "run_42 摘要"})],
    [ToolCall("c2", "delete_run", {"run_id": "run_42"})],
    "已检索并清理 run_42,报告如下:……",
])

def approver(tc):
    print(f"  [人工审批] 工具 {tc.name}({tc.arguments}) 需要确认 -> 通过")
    return True

result = run_with_approval(model, reg, "清理 run_42 并给我报告",
                           approver, system="你是研究助理。")
for s in result.steps:
    print(f"[{s.index}] {s.kind:<17} {s.detail}")
print("最终:", result.final)
print("模型调用次数:", len(model.calls))

本机实测(Python 3.14.6)输出:

  [人工审批] 工具 delete_run({'run_id': 'run_42'}) 需要确认 -> 通过
[0] tool_call         search({'q': 'run_42 摘要'}) -> 命中 3 条与『run_42 摘要』相关的片段
[1] approval_required delete_run({'run_id': 'run_42'})
[1] approval_granted  delete_run
[1] tool_call         delete_run({'run_id': 'run_42'}) -> 已删除 run_42
[2] final             已检索并清理 run_42,报告如下:……
最终: 已检索并清理 run_42,报告如下:……
模型调用次数: 3

轨迹读起来正是我们想要的节奏:第 0 步 search 是只读,无人打扰直接执行;第 1 步模型请求 delete_run,被 approval_required 拦住,审批人点头后 approval_granted 才真正执行;第 2 步模型给出最终报告。全程 3 次模型调用,search 与 delete_run 的区别处理得干干净净。若把 approver 改成 return False,第 1 步会变成 approval_denied,并把 {"error": "approval_denied"} 回灌给模型——模型下一步大概率会改成「我无权删除,请手动处理」。

4.3.6 审批超时与失败策略

同步回调在演示里没问题,生产里「等人工」会挂很久,必须有超时与兜底。策略如下:

策略取值建议理由
审批超时5–15 分钟太久会占满 worker;太短用户来不及看
超时后行为默认拒绝(fail-closed)危险操作「没批准 = 不执行」
审批未响应时的 run 状态awaiting_approval(非终态)前端据此显示「待确认」
被拒后的重试不自动重试危险动作避免「点错了再试」变成反复执行
审批记录落库:谁、何时、哪个 tool_call、结果合规审计必需

最重要的一条是 fail-closed(默认拒绝):超时、审批人离线、审批服务宕机——一律当作「不批准」。危险操作的默认答案必须是「否」,只有拿到明确的人工点头才放行。这与安全领域的通行原则一致:没有明确许可,就不执行。 反之,如果把超时当「默认通过」,攻击者只要让审批系统超时就能绕过审批。

落库的审批记录大致长这样,前端拿到它就能渲染一张「批准 / 拒绝」卡片:

{
  "tool_call_id": "c2",
  "tool": "delete_run",
  "arguments": {"run_id": "run_42"},
  "workspace_id": "ws_acme",
  "status": "pending",
  "expires_at": "2026-09-26T14:15:00+08:00"
}

status 从 pending 流转到 approved / denied / expired 时,worker 从检查点(存下的 messages 与 steps)续跑:批准就放行这次 tool_call,拒绝或过期就把 {"error": "approval_denied"} 回灌给模型。注意 expires_at 是服务端算出来的,不能信客户端——否则用户可以伪造一个「永不过期」的审批请求。

4.3.7 真实环境下会怎样

本节把审批简化成一个同步回调,真实系统要拆成异步 + 可持久化:

演示做法生产做法章节
同步 approver 回调待审批入队 + SSE 推送 + 检查点续跑7.1 / 9.3
内存 approved 集合落库 approvals 表 + 审计5.1 / 15.3
固定档位档位 + 租户策略(某租户禁危险工具)10.3 沙箱

还有一个容易被忽视的组合:工具权限要和租户隔离(4.2)叠加。同一个 delete_run,对「租户管理员」可能是危险档(需审批),对「只读成员」应当直接拒绝(连审批机会都没有)。也就是说,权限判定 = 工具档位 × 调用者角色,两层缺一不可。

验收清单:

  • 每个工具注册时都显式声明档位,没有「默认档」。
  • 危险档的拦截在 call 里,不在循环里。
  • approved 粒度是 tool_call_id,不是工具名。
  • 审批被拒走错误回灌,模型能自我纠正,循环不崩。
  • 超时 / 无响应一律 fail-closed。

到这里,身份(4.1)、权限(4.2)、高危审批(4.3)三道关都立住了,ResearchCopilot 终于「能给别人用」。下一章开始把状态落进 Postgres——先设计会话 / 消息 / 运行 / 记忆四张表与迁移。想回看租户隔离怎么把 workspace_id 写进每条查询,翻 4.2 授权与租户隔离:workspace 行级隔离 。

阅读导航:上一节:4.2 授权与租户隔离:workspace 行级隔离 · 下一节:5.1 Schema 设计:会话/消息/运行/记忆表 + 迁移 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「agent」更多文章

  1. 《AI Agent 高级编程》目录
  2. 《AI Agent 高级编程》11.3 全景回顾:从零到框架的完整路径与展望
  3. 《AI Agent 高级编程》11.2 真实 API 冒烟:假模型 vs 真模型量化差距