KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
09. 副作用工具怎样进入审批边界? — keel 龙骨
前面八章保证的是「调用被正确地执行」。这一章要保证另一件事:有些调用根本不该自动执行,即使参数完全合法。
前面八章保证的是「调用被正确地执行」。这一章要保证另一件事:有些调用根本不该自动执行,即使参数完全合法。
现场:一次"顺便"的通知
迭代第 12 周,有人给助手加了一个 notify_oncall(service_name):「值班同学希望能第一时间知道」。它没有单独评审,因为看起来和已有的查询工具差不多——都是一行函数调用。
周五下午发生的事:
- 模型在回答一个纯咨询问题时顺带调用了它,因为 tool 列表里它在,描述里写着「通知值班负责人」;
- 那次调用经过了完整的 schema 校验、业务校验,全部通过;
- 三位值班同学同时收到告警,其中两位正在休假;
- 事后复盘想回答「谁批准了这次调用」,答案是没有人——因为没有审批环节。
第 2 条是关键:这套链路一切正常。缺的是把「这个工具会不会影响外部世界」变成策略输入,而不是留在代码注释里。
effect:三类影响,三种默认值
read 查事件、读配置 可自动执行
write 创建工单、改业务状态 通常需要幂等,多数要审批
external 发邮件、付款、控制设备 默认不自动执行
effect 不是给模型看的标签,它是 Harness 的输入,牵动五件事:自动执行门槛、审批要求、重试策略、审计强度和隔离等级。同一个网络错误,在 read 上只是慢一点,在 external 上是「到底发不发得出去」的问题。
本课程的判定写在 executor.py 里:
if definition.effect is not ToolEffect.READ and not context.allow_side_effects:
return ToolResult.failure(call, ..., code="approval_required", ...)
注意判断用的是 is not READ——除白名单外一律需要授权,而不是「列出需要审批的那几个」。后者会漏掉新加的工具:默认放行意味着每加一个新工具都要有人记得去登记风险。
权限是"主体—动作—资源"三元组
class ExecutionContext(BaseModel):
run_id: str
principal: str # 谁在运行
tenant_id: str # 作用在哪一个租户
allow_side_effects: bool = False
这三件事都不能从模型输出里读出来。ToolRunner.run() 里 context 是从运行时配置构造的,ExecutionContext 的注释写着「不读取模型自述的权限」——这不是洁癖,是这条边界的全部意义:模型的身份不等于用户的权限。
常见错误有三种:
- 参数里带
user_role="admin",策略用它判断 —— 谁都能填 admin; - 系统提示里写「你只能访问自己租户的数据」—— 这是请求,不是授权;
- 历史记忆里记着「Alice 说过以后不用审批」—— 记忆、模型输出、工具参数都是不完全可信输入,授权只能来自当前策略上下文。
审批不是一个布尔值
一次可放心的审批记录至少要回答五个问题:批准了哪个工具、哪些参数(应存参数摘要,不是原样存全量)、谁批准、何时批准、多久有效,并且要关联 run_id 与 call_id。
只有 approved=true 就恢复执行,等于允许拿着一个批准标记去放行任意一次调用。正确的流程是:执行器用原始 call_id 重新查一次这条审批记录,确认匹配后才执行。
跑一遍:暂停与恢复
python courses/foundation/tool-calling/course/project/examples/08_offline_loop.py
--- ④ 副作用工具 → 等待审批 ---
status = waiting_for_approval answer/error = approval_required
[tool.requested] run-95546bc4d6da:step-0:call-0
[tool.failed ] run-95546bc4d6da:step-0:call-0 code=approval_required
steps_used = 1(脚本第二步没有被消费)
steps_used = 1 是这一节最该记住的一行:运行停在原地,后续脚本没有被消费。也就是说暂停不是「跳过这一步继续跑」,而是这次运行进入了另一个状态,需要外部事件(批准)才有资格继续。
再看 examples/09_offline_advanced.py 第 ⑤ 组,它把"恢复"这件事在 Executor 层做实:
--- ⑤ 审批暂停 → 同一 call_id 恢复 ---
审批前 ok = False code = approval_required
call_id 仍是 call-approve-1
批准后 ok = True data = {'ticket_id': 'TCK-001', 'title': '调查登录故障', 'details': '根据 INC-001 的证据创建工单'}
恢复前后同一个 call_id:True
再执行一次(幂等兜底)ticket_id = TCK-001
三次下来工单总数 = 1
最后两行值得多看一眼:即使审批后链路重复投递了一次,工单仍然只有一张。写操作的边界是双保险——审批管"能不能做",幂等管"做了几次"。少任何一层,第 07 章那起两张工单的事故就会换一个路径重演。
prompt 不是策略:一张对照表
| 想达成的约束 | 写进 prompt 的后果 | 应该放在哪里 |
|---|---|---|
| 只能访问本租户数据 | 模型可以绕过;无法审计 | 策略引擎 + 数据层过滤 |
| 危险动作要先问人 | 取决于模型心情 | effect + 审批环 |
| 一次最多调用 3 次工具 | 无法保证 | max_steps / 预算 |
| 不要把密钥写进参数 | 大部分时候有用 | 仍是必要的纵深防御,但不能作为控制点 |
最后一行是诚实的例外:prompt 可以作为纵深防御的一层,只是不能成为控制点。判断标准很简单——「这条约束被违反时,有没有一处代码会拦下来?」
失败注入
改 executor.py / contracts.py 后重跑 08_offline_loop.py 与 09_offline_advanced.py:
| 改法 | 观察什么 | 为什么危险 |
|---|---|---|
effect is ToolEffect.EXTERNAL 才算需要审批 |
create_ticket 直接通过 |
白名单变黑名单,新工具默认放行 |
从 call.arguments 里读 user_role 判权限 |
模型自填 admin 就能过 | 身份取自不可信输入 |
| 审批记录只匹配工具名,不匹配参数摘要 | 换个参数也能用旧批准 | 批准的边界被放大 |
恢复时生成新的 call_id |
事件链断掉 | 审批与调用无法对应,审计失效 |
生产替换点
| 教学实现 | 生产替换 |
|---|---|
ExecutionContext.allow_side_effects |
策略引擎(OPA / Cedar / 自研),按租户、主体、资源实时判定 |
approval_required 常量 |
可回放的审批记录 + 有效期 + 审批人身份 |
effect 写在 ToolDefinition 里 |
集中的元数据服务,发布时校验 effect 与实现一致 |
| 同步审批等待 | 异步暂停/恢复:持久化运行状态,外部事件驱动继续 |
单租户 tenant-demo |
每次执行都携带租户维度,数据层做兜底过滤 |
练习与验收
练习:给 create_ticket 增加一个 approver 字段的审批记录结构(工具、参数摘要、审批人、时间、有效期、call_id),写断言验证「参数变了但 call_id 没变」时必须重新审批。
验收标准:把 details 改一个字,审批校验必须失败,并且错误信息要能指出是摘要不匹配。
本章检查点
- 为什么
effect判断要写成「不是 read 就要审批」,而不是「列出需要审批的 effect」? steps_used = 1说明暂停时发生了什么?如果改成 2,链路意味着什么?- 运行身份
principal为什么不能由模型输出或工具参数提供? - 已经有了幂等键,还需要审批吗?反过来呢?
现在能解释什么
你能说清:工具对外部世界的影响必须作为契约字段参与策略;授权来自运行时上下文而不是模型输出;一次可靠的审批要能回放五个要素并用原 call_id 恢复;写操作同时需要审批与幂等两道防线。下一章把整个子系统放回它所属的位置——Harness。
上一章:多个工具和流式调用怎样不乱? · 下一章:把工具子系统放回 Harness
这一章只建立工具调用内部的副作用边界。完整的身份、授权、审批绑定、提示注入和沙箱见安全控制课程;动作获准后的重试、幂等、对账和补偿见现实世界执行课程。