KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

09. 副作用工具怎样进入审批边界? — keel 龙骨

前面八章保证的是「调用被正确地执行」。这一章要保证另一件事:有些调用根本不该自动执行,即使参数完全合法。

前面八章保证的是「调用被正确地执行」。这一章要保证另一件事:有些调用根本不该自动执行,即使参数完全合法。

现场:一次"顺便"的通知

迭代第 12 周,有人给助手加了一个 notify_oncall(service_name):「值班同学希望能第一时间知道」。它没有单独评审,因为看起来和已有的查询工具差不多——都是一行函数调用。

周五下午发生的事:

  1. 模型在回答一个纯咨询问题时顺带调用了它,因为 tool 列表里它在,描述里写着「通知值班负责人」;
  2. 那次调用经过了完整的 schema 校验、业务校验,全部通过;
  3. 三位值班同学同时收到告警,其中两位正在休假;
  4. 事后复盘想回答「谁批准了这次调用」,答案是没有人——因为没有审批环节。

第 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 的注释写着「不读取模型自述的权限」——这不是洁癖,是这条边界的全部意义:模型的身份不等于用户的权限。

常见错误有三种:

  1. 参数里带 user_role="admin",策略用它判断 —— 谁都能填 admin;
  2. 系统提示里写「你只能访问自己租户的数据」—— 这是请求,不是授权;
  3. 历史记忆里记着「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 改一个字,审批校验必须失败,并且错误信息要能指出是摘要不匹配。

本章检查点

现在能解释什么

你能说清:工具对外部世界的影响必须作为契约字段参与策略;授权来自运行时上下文而不是模型输出;一次可靠的审批要能回放五个要素并用原 call_id 恢复;写操作同时需要审批与幂等两道防线。下一章把整个子系统放回它所属的位置——Harness。

上一章:多个工具和流式调用怎样不乱? · 下一章:把工具子系统放回 Harness

这一章只建立工具调用内部的副作用边界。完整的身份、授权、审批绑定、提示注入和沙箱见安全控制课程;动作获准后的重试、幂等、对账和补偿见现实世界执行课程。

进入 keel 阅读