审批门与人工复核

适用版本:ChengOS v0.1.0+ | 最后核对:2026-08-12 | 来源:crates/cheng-core/src/domain/approval.rscrates/cheng-core/src/services/approval_gate.rscrates/cheng-nodes/src/nodes/builtin/utils/approver.rscrates/cheng-api/src/services/approval_reaper.rs

有两种不同的机制把人放进回路:

审批 评审
问的问题 「我可以做这件事吗?」 「这个改动可以接受吗?」
提问时机 动作之前 工作暂存之后
状态 waiting_for_review waiting_for_review
触发者 审批门 工具返回 requires_review: true
例子 执行一条 SSH 命令 智能体对文档的修改

两者都会持久地挂起执行。它们也都不是可以靠换个 API 调法绕过的界面约定——审批门是在节点内部阻塞的。

审批门

审批门是一个协议原语,被所有高风险节点共享——SSH、HTTP 写操作、MCP、代码执行——也被你可以自行放到画布上的 utils/approver 节点使用。

调用方
  │
  ▼
 是否命中白名单? ──── 是 ──► 返回 Approved(whitelist_hit = true)
  │ 否
  ▼
 订阅审批总线                    ◀─ 先订阅,绝不漏事件
  │
  ▼
 持久化执行 → waiting_for_review + ApprovalContext
  │
  ▼
 发布 ApprovalRequested          ◀─ 渲染审批卡片的那个 WS 事件
  │
  ▼
 等待决策或持久轮询,带超时
  │
  ▼
 持久化执行 → running,清除 ApprovalContext
  │
  ▼
 发布 ApprovalResolved
  │
  ▼
 已批准 && scope == session ──► on_session_approved 钩子(写入白名单)

这些步骤的顺序本身就是设计。 先订阅再持久化,消除了「用户手快,事件在接收端挂上之前就发出去了」的竞态。先持久化再发布,消除了「用户点了批准而 API 因状态仍是 running 而拒绝」的竞态。任一顺序搞错,你就会得到一个罕见且难以复现的挂起。

决策还会被记录在审批上下文里,而不只是广播出去:总线是内存中的广播通道,否则一个在无人订阅时发布的决策就会永久丢失。

风险等级与决策

风险等级被刻意定义为有序的 low < medium < high < critical,这样就能用普通的比较运算符把分级后的风险与配置的阈值相比。默认是 medium

决策approvedrejected(反馈会回到大语言模型用于重新规划)、或 skip(跳过这个动作并返回一段反馈文字)。

范围once(默认)或 session——session 会把该动作写入白名单,在本次会话余下的时间内生效。

结果里记录的结局approvedrejectedskippedtimed_out,外加一个 whitelist_hit 标记,用来区分「用户说了同意」和「先前的规则命中了」。

审批节点

utils/approver 让你把一道门放在画布的任何位置。

输入 含义
action_name 正在被授权的是什么,例如 HTTP POST to api.stripe.com
risk_level low / medium / high / critical
description 在卡片里展示给用户
param_summary 参数的 JSON 摘要,显示在卡片里
extra_whitelist 额外的、逗号分隔的自动批准动作名
timeout_secs 默认 300;超时后自动拒绝

输出既有 approved 布尔值,也有一个单独的、运行时条件性的「已批准」信号端口,它只在获批时才存在。把危险动作接在那个端口之后,遇到拒绝、超时或策略否决时它就会被自动跳过——不需要额外的条件节点,因为输入为 null 意味着「不走这条路」

做决定

REST

POST /executions/:id/approve
{ "request_id": "…", "decision": "approved|rejected|skip", "scope": "once|session", "reason": "…" }

approve/reject 会作为 approved/rejected 的别名被接受。未知的决策返回 400,而不是悄悄取默认值。

在界面上:对话与编辑器中的审批卡片。

从外部渠道~~y 批准,~~n [原因] 拒绝。~~y xxx 会被刻意拒绝——第一个版本不接受任何读起来像有条件批准的东西,因为在一道安全门前,「同意,但是……」没有明确含义。见渠道路由

审批清道夫

这套握手有三个参与者:阻塞在节点内的审批门、写着 waiting_for_review 的执行行,以及用户的决策。如果审批门在没有释放该行的情况下消失了——进程重启、任务 panic、节点在它脚下被取消——这行就会永远停在那里,而用户面对的审批卡片按钮只会返回 NO_ACTIVE_APPROVAL_WAITER

清道夫堵上了这个洞。任何超过自身 timeout_secs 再加一段宽限期仍未结束的审批,都会被转为 timeout

环境变量 默认 含义
APPROVAL_REAPER_ENABLED true 设为 false 可完全关闭清扫
APPROVAL_REAPER_INTERVAL_SECS 120 清扫间隔
APPROVAL_REAPER_GRACE_SECS 300 在各请求 timeout_secs 之外的额外余量

活着的审批门总会在 timeout_secs 内释放自己的等待,因此宽限期让清道夫不去碰健康的审批。状态迁移对状态和 request_id 同时做比较并设置,因此同一瞬间落地的决策总是赢过清道夫。

评审

评审是另一种形态:工作已经做完并暂存在某处,由人来决定要不要留下它。

当某个工具返回 requires_review: true 时,引擎把执行转入 waiting_for_review 并持久地挂起。最典型的例子是文档 Shadow 会话:智能体的修改暂存在 Shadow 中,差异可按块评审,执行在决策之后恢复。

评审是逐项的,这正是 Shadow 会话可能以 partially_accepted 收场的原因——五处修改采纳三处,其余继续挂起。相比之下,审批只是一次是或否。

恢复被挂起的执行走恢复接口,而不是发一条新的对话消息:

POST /executions/:id/pause
POST /executions/:id/resume

用门来设计

  • 门要尽量放在靠后的位置。 批准「执行一条 shell 命令」毫无意义;批准那条具体的命令、并在 param_summary 里带上参数,才有意义。
  • session 范围要省着用。 它会让这道门在本次会话余下的时间里彻底噤声。
  • timeout_secs 要匹配你的值守方式。 默认 300 秒假定有人在盯着;一个跑通宵的批处理要么需要更长的窗口,要么根本不该设门。
  • 内容类的事情优先用评审而不是审批。 事前问「我可以改这份文档吗」等于什么都没告诉用户;事后把差异摆出来则说明了一切。

下一步

© 版权声明
THE END
喜欢就支持一下吧
点赞11 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容