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 / 审批回调 / 配额上限
一道防线不够,要三道叠加,各管一段:
- dry-run(预演):不执行,只返回「如果执行会做什么」。适合上线前跑一遍计划、也适合模型自查。它是零成本的,因为什么都没发生。
- 审批回调(approve):不可逆动作执行前,调一个回调问「批不批」。回调可以弹 UI、发 Slack、走工单——关键是这个决定来自人,不是模型自己。
- 配额上限(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 通信 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。