《AI Agent 高级编程》7.3 安全与成本:不可逆动作沙箱与人工确认

7.1 点错坐标、7.2 点错按钮,后果可能只是一次空操作,也可能是一笔真实转账。本节把「不可逆动作」单独拎出来:先给风险三级分级表,再讲 dry-run / 审批回调 / 配额上限三道防线,并用一个可运行的最小沙箱真跑「高危动作二次确认」——批准转账、拒绝发信、配额拦下删除,最后实测截图体积随分辨率的增长。

7.3 安全与成本:不可逆动作沙箱与人工确认

7.1 里模型点空只是白做,7.2 里点错按钮可能只是读错数据——但把这两套能力接到真实系统上,同一个「点一下」可能变成一笔转账、一封已发出的邮件、一个删掉的账号。工具调用时代,我们靠参数校验和权限清单兜底;到了 Computer Use,动作变成了「对界面的副作用」,校验不了、撤不回来。这一节专门处理这类动作。

本节把「不可逆动作」从普通动作里切出来,给它一套沙箱:先按风险分级,再用 dry-run 预演、审批回调二次确认、配额上限三道闸拦住它。全部用可运行的 Python 沙箱演示,真跑「批准 / 拒绝 / 超额」三条路径,最后实测截图体积随分辨率增长的成本曲线。本节不连接任何真实支付、邮件或账号系统,副作用全部落在内存里的一个假执行器。

7.3.1 什么动作算「不可逆」

判据只有一条:这个动作做完之后,能不能不借助外部(人、对方、备份)就撤销?

  • 可撤销:写草稿、改配置(有历史)、加一条带 deleted_at 的记录——出错了改回来就行。
  • 不可逆:转账、发送邮件/消息、删除无备份的数据、提交表单、发布内容——一旦发生,世界的状态就变了。

关键认知:不可逆与否是动作的属性,不是工具的属性。同一个 write_file 工具,写临时文件可撤销,覆盖唯一一份生产数据不可逆。所以风险分级必须挂在具体动作实例上,而不是工具名上。

7.3.2 风险三级分级表

级别判定例子默认策略
READ不改任何状态读余额、查页面、截图自动放行,无限制
WRITE改状态但可撤销存草稿、改标签、写缓存放行,记审计日志
IRREVERSIBLE改状态且不可撤销转账、发送、删除、发布必须审批 + 配额上限

分级的价值在于把有限的审批预算花在刀刃上。如果每个动作都要人确认,人很快会变成「无脑点批准」,审批就失去了意义——这和数据安全里「告警疲劳」是同一个病。只对第三级强制审批,前两级自动放行,人才会对真正危险的弹窗认真看。

7.3.3 三道防线:dry-run / 审批回调 / 配额上限

一道防线不够,要三道叠加,各管一段:

  1. dry-run(预演):不执行,只返回「如果执行会做什么」。适合上线前跑一遍计划、也适合模型自查。它是零成本的,因为什么都没发生。
  2. 审批回调(approve):不可逆动作执行前,调一个回调问「批不批」。回调可以弹 UI、发 Slack、走工单——关键是这个决定来自人,不是模型自己。
  3. 配额上限(quota):一次运行里最多执行 N 个不可逆动作。就算审批回调被绕过(bug、被注入操纵),配额是最后一道硬闸。

三道防线的关系是层层递减的信任:dry-run 不信任任何东西;审批信任「人会看」;配额信任「就算前面都失守,损失也有上限」。

7.3.4 真跑:一个最小不可逆动作沙箱

下面这个沙箱是所有副作用的唯一入口——想改世界,必须走 Sandbox.run。它把三道防线收在一个类里:

from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum


class Risk(Enum):
    READ = "read"                  # 只读,随便跑
    WRITE = "write"                # 可撤销(如改草稿)
    IRREVERSIBLE = "irreversible"  # 不可逆(转账/删除/发送)


@dataclass
class Action:
    name: str
    risk: Risk
    args: dict = field(default_factory=dict)

    def summary(self) -> str:
        return f"{self.name}({', '.join(f'{k}={v!r}' for k, v in self.args.items())})"


class Sandbox:
    """执行动作的唯一入口。所有副作用都必须经过这里。"""

    def __init__(self, approve, max_irreversible: int = 2, dry_run: bool = False):
        self.approve = approve            # approve(action) -> bool,人工确认回调
        self.max_irreversible = max_irreversible
        self.dry_run = dry_run
        self.used = 0                     # 已执行的不可逆动作数
        self.log: list[str] = []

    def run(self, action: Action) -> str:
        if self.dry_run:
            self._say(f"DRY-RUN  {action.summary()}  [risk={action.risk.value}]")
            return "previewed"
        if action.risk is Risk.READ:
            self._say(f"ALLOW    {action.summary()}  (只读,自动放行)")
            return "done"
        if action.risk is Risk.WRITE:
            self._say(f"ALLOW    {action.summary()}  (可撤销)")
            return "done"
        # 以下只处理 IRREVERSIBLE
        if self.used >= self.max_irreversible:
            self._say(f"BLOCK    {action.summary()}  (配额用尽 "
                      f"{self.used}/{self.max_irreversible})")
            return "blocked:quota"
        if not self.approve(action):
            self._say(f"DENY     {action.summary()}  (人工确认拒绝)")
            return "blocked:denied"
        self.used += 1
        self._say(f"EXECUTE  {action.summary()}  (已确认,配额 {self.used}/"
                  f"{self.max_irreversible})")
        return "done"

    def _say(self, line: str) -> None:
        self.log.append(line)
        print(line)

注意 run 的返回是一个字符串状态(done / blocked:quota / blocked:denied),不抛异常。和卷一 ToolRegistry.call 一样:拦截结果要回灌给模型,让模型知道「这一步被拦了、原因是什么」,从而调整计划,而不是让整个循环崩掉。

7.3.5 真跑输出:批准、拒绝、超额三条路径

给沙箱一个动作计划:读余额(放行)→ 转账(应批准)→ 发邮件(应拒绝)→ 删账号(应被配额拦)。审批回调脚本化,代表一个会判断的人:

PLAN = [
    Action("get_balance", Risk.READ, {"account": "acme"}),
    Action("transfer_money", Risk.IRREVERSIBLE, {"to": "vendor", "amount": 500}),
    Action("send_email", Risk.IRREVERSIBLE, {"to": "ceo@x.com"}),
    Action("delete_account", Risk.IRREVERSIBLE, {"id": "acme"}),
]


def approve(action: Action) -> bool:
    print(f"  >> 请求确认: {action.summary()} -> ", end="")
    ok = action.name in {"transfer_money", "delete_account"}  # 只批转账和删号
    print("批准" if ok else "拒绝")
    return ok

真跑(Python 3.14.6):

=== dry-run 预演(不产生任何副作用)===
DRY-RUN  transfer_money(to='vendor', amount=500)  [risk=irreversible]

=== 真实执行 ===
ALLOW    get_balance(account='acme')  (只读,自动放行)
  >> 请求确认: transfer_money(to='vendor', amount=500) -> 批准
EXECUTE  transfer_money(to='vendor', amount=500)  (已确认,配额 1/2)
  >> 请求确认: send_email(to='ceo@x.com') -> 拒绝
DENY     send_email(to='ceo@x.com')  (人工确认拒绝)
  >> 请求确认: delete_account(id='acme') -> 批准
EXECUTE  delete_account(id='acme')  (已确认,配额 2/2)

结果: 执行不可逆动作 2 个,配额 2

逐行读:get_balance 是只读,直接放行;transfer_money 走审批,人批准,执行并消耗 1 个配额;send_email 走审批,人拒绝,动作被拦下;delete_account 人批准了,但配额已经用满 2/2——就算人点了批准,配额还是把它拦住了。这正是三道防线的意义:任何一道单独失守都不至于出事。dry-run 那一行则展示了预演:只打印计划,什么都没执行。

7.3.6 审批回调的三个设计要点

上面的 approve 是脚本化的,真实实现要处理三件事:

要点问题做法
同步 vs 异步人可能几分钟后才看到默认同步阻塞并设超时;长任务走异步审批 + 持久化(见 6.1 )
超时默认人没响应怎么办默认拒绝(fail-closed),绝不默认放行
上下文充分人凭什么批弹窗必须带动作摘要 + 影响面(金额、收件人、目标),不能只写「Agent 想执行一个动作」

第三点最容易被忽视。如果审批弹窗只写「批准?」,人无法判断,只能盲批——审批就退化成了橡皮图章。所以 Action.summary() 把参数拼进描述里,就是为了让审批人看到「转账 500 给 vendor」这种可判断的信息。这和 3.2 Claude Agent SDK 的 allow/deny 是同一套思路:权限决策必须基于动作的具体内容,而非动作的名字。

7.3.7 成本侧:截图体积随分辨率增长

Computer Use 的另一半问题是成本。7.1 说过每步一张截图,这里实测一下体积随分辨率怎么涨。用 PIL 合成三种分辨率的界面截图,量 PNG / JPEG 字节数(Python 3.14.6 / Pillow 12.3.0):

def make(w, h, n_items):
    img = Image.new("RGB", (w, h), "white")
    d = ImageDraw.Draw(img)
    for i in range(n_items):
        y = 12 + i * 18
        d.rectangle([10, y, w - 10, y + 14], outline="black")
        d.text((16, y + 2), f"row {i}: item-{i:04d}", fill="black")
    buf = BytesIO(); img.save(buf, format="PNG")
    return len(buf.getvalue())

实测结果:

分辨率         元素数     PNG(B)    JPEG(B)   PNG base64
640x400    8       8743      25418     11660
1280x800   20      23672     106187    31564
1920x1080  40      47892     287175    63856

两个可直接用的结论:

  • 体积随分辨率快速增长。从 640×400 到 1920×1080,像素数涨了约 8 倍,PNG 字节数从 8.7KB 涨到 47.9KB(约 5.5 倍)。进上下文前还要 base64 编码,再膨胀约 1/3(1920×1080 的 PNG base64 后是 63856 字符)。分辨率越高,每一步的 token 账单越贵。
  • PNG 常常比 JPEG 小。这张合成界面大部分是白底加线条,PNG 的行程编码压得极好(47.9KB),JPEG 反而涨到 287KB。别想当然以为 JPEG 更省——对「大片纯色 + 锐利线条」的 UI 截图,PNG 往往是更优选择。

降低成本的三个手段,按性价比排序:降分辨率(先缩到 1280 宽再截)、改观察口径(7.2 的 ARIA snapshot 用文本替代像素)、只在必要时截图(只在动作后需要验证时截,不是每步都截)。

7.3.8 把风险分级接进工具注册表

沙箱本身不判断风险,风险由动作实例带进来。最省事的接法是给每个工具登记一个默认风险,运行时再按参数上浮:

TOOL_RISK = {
    "read_page": Risk.READ,
    "save_draft": Risk.WRITE,
    "transfer_money": Risk.IRREVERSIBLE,
    "send_email": Risk.IRREVERSIBLE,
    "delete_account": Risk.IRREVERSIBLE,
}


def classify(tool_name: str) -> Risk:
    """未登记的工具默认按最高风险处理(fail-safe)。"""
    return TOOL_RISK.get(tool_name, Risk.IRREVERSIBLE)

真跑(Python 3.14.6):

read_page     -> read
save_draft    -> write
unknown_tool  -> irreversible

最后一行是重点:未登记的工具默认按 IRREVERSIBLE 处理。这叫 fail-safe 默认值——新增工具时忘了登记,代价是「多要一次审批」,而不是「悄悄放行了一个危险动作」。安全设计里,默认值的方向比默认值本身更重要:宁可误拦,不可漏放。

7.3.9 让渡与边界

维度你得到的你让渡的
安全三道闸叠加,单点失守不致命每步不可逆动作都要人确认,吞吐量下降
可测沙箱是唯一入口,可审计可回放绕过沙箱的代码路径变成隐形的安全洞
成本配额给损失设了上限配额太紧会误伤正常任务,要调
责任审批记录留下「谁批的」审批疲劳下,人可能盲批——责任仍在你

沙箱替你做了拦截、记录、限流;你让渡的是自动化程度——有人确认,就快不起来。真实生产里通常按动作金额 / 影响面做分级审批:小额转账自动放行、大额强制人批,把审批预算花在真正要紧的地方。

本章从「看屏幕」讲到「点屏幕」再到「拦住危险的点击」。下一章 8.1 A2A 协议 会换一个维度:单个 Agent 的能力讲完了,多个 Agent 之间怎么协作、怎么把「浏览器 Agent」当成一个可被别的 Agent 调用的服务。想回看持久化执行如何让长任务的审批不丢状态,见 6.1 为什么需要持久化执行 。

阅读导航:上一节:7.2 浏览器 Agent:Playwright 工具化完成网页取数 · 下一节:8.1 A2A 协议:Agent Card/Task/消息,起两个本地 Agent 通信 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「agent」更多文章

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