别让 Agent 用昨天的“同意”执行今天的版本

55 阅读5分钟

凌晨 2 点,发布任务读到 approved=true,把一篇活动稿发了出去。

审计记录显示负责人确实点过同意。但她批准后,正文被改过,封面被替换过,目标账号也从测试号切到了正式号。布尔值没有撒谎,只是它从来没说清楚:那次同意究竟针对哪个版本。

GitHub 最近给 Copilot code review 增加了 PR approval。官方设计里有两个很值得借鉴的细节:

  1. Copilot 的 approval assessment 只是“建议可以批准”,本身不计入合并要求;
  2. 真正 approval 默认关闭,启用后如果又推送新提交,原批准会失效并要求重新评审。

这其实在提醒我们:模型判断、权限授权和外部执行,是三种不同能力。

一份批准应绑定对象、证据、策略与有效期

“通过”至少有三种身份

先别急着设计审批表,先问谁在说“通过”。

type ReviewDecision =
  | { kind: "assessment"; by: "agent"; result: "pass" | "fail" | "uncertain" }
  | { kind: "authorization"; by: Principal; action: ActionScope }
  | { kind: "execution"; by: Executor; result: ExecutionResult };

Assessment 表示当前证据下的专业判断。Authorization 表示某个有权限的主体允许执行特定动作。Execution 则记录动作是否真的发生,以及平台返回了什么。

把它们合并成 approved 会产生两个越权:

  • Agent 判断“看起来没问题”,被系统误当成“有权批准”;
  • 人批准了版本 A,执行器却把授权用于版本 B。

Approval 更像租约,不像勋章

一个批准对象应该是短期、可验证、可失效的:

interface ApprovalLease {
  id: string;
  subjectId: string;
  subjectVersion: string;
  evidenceHash: string;
  policyVersion: string;
  approverId: string;
  action: {
    name: "publish" | "pay" | "deploy" | "send";
    target: string;
    constraints: Record<string, unknown>;
  };
  issuedAt: string;
  expiresAt: string;
}

租约的意思不是一定要 30 分钟过期,而是授权只能在约定条件下成立。一旦对象、证据、策略、账户或动作范围变化,它就不再有效。

版本应该包含什么

对代码来说,subjectVersion 可以是 commit SHA。对内容发布,可以是下面这份 manifest 的哈希:

{
  "platform": "juejin",
  "accountId": "writer-001",
  "titleHash": "sha256:...",
  "bodyHash": "sha256:...",
  "images": [
    { "role": "cover", "sha256": "..." },
    { "role": "body", "sha256": "..." }
  ],
  "tags": ["人工智能"],
  "visibility": "public"
}

注意不要直接哈希一段随意序列化的 JSON。标签顺序、换行风格和对象属性顺序可能制造假变更。先 canonicalize,再计算摘要。

function contentVersion(m: PublishManifest): string {
  return sha256(stableJson({
    platform: m.platform,
    accountId: m.accountId,
    title: m.title.trim(),
    body: m.body.replace(/\r\n/g, "\n").trimEnd(),
    images: [...m.images].sort(byRole).map(i => [i.role, i.sha256]),
    tags: [...m.tags].sort(),
    visibility: m.visibility,
  }));
}

不要签发一张“全平台通行证”

权限范围也必须进入租约。

批准“把文章 X 发布到掘金账号 A”,不能自然扩展为:

  • 同步到 CSDN;
  • 换成另一个账号;
  • 把私密草稿改成公开;
  • 修改正文后继续发布;
  • 自动开启平台自己的多端分发。

GitHub 允许管理员用文件路径限制 Copilot approval 是否计入合并要求,背后的思路相同:授权的价值来自边界,而不是来自一句抽象的“允许”。

可以定义一份简单策略:

policy: content-publish-v4
rules:
  - risk: low
    action: prefill
    approval: none
  - risk: medium
    action: publish
    approval: content-owner
    invalidateOn: [content, assets, account, visibility]
  - risk: high
    action: cross-platform-publish
    approval: content-owner-and-admin
    autoRenew: false

预填和正式发布不是同一个权限。让 Agent 填好编辑器,不代表它可以忽略页面状态直接提交。

执行前重新证明“还是那一版”

审批完成到执行之间可能隔几秒,也可能隔几小时。执行器必须重新读取真实目标:

async function executeWithLease(lease: ApprovalLease) {
  const current = await captureCurrentSubject();

  if (current.version !== lease.subjectVersion) {
    throw new DomainError("APPROVAL_SUBJECT_CHANGED");
  }
  if (current.evidenceHash !== lease.evidenceHash) {
    throw new DomainError("APPROVAL_EVIDENCE_CHANGED");
  }
  if (current.policyVersion !== lease.policyVersion) {
    throw new DomainError("APPROVAL_POLICY_CHANGED");
  }
  if (Date.now() >= Date.parse(lease.expiresAt)) {
    throw new DomainError("APPROVAL_EXPIRED");
  }

  const claimed = await claimOnce(lease.id, current.version);
  if (!claimed) throw new DomainError("APPROVAL_ALREADY_CONSUMED");

  return performAndVerify(lease.action);
}

claimOnce 要做条件更新或唯一约束,避免两个定时任务同时消费一份批准。performAndVerify 则必须回读真实结果。点击按钮不是成功证据;如果平台没有明确状态,就停在 RESULT_UNKNOWN,只做只读查询,不再次点击。

对象变化后,旧批准必须失效并重新评审

一个够用的状态机

ASSESSED
  └─> WAITING_APPROVAL
        └─> APPROVED
              ├─> EXECUTING ─> SUCCEEDED
              │                ├─> FAILED
              │                └─> RESULT_UNKNOWN
              └─> STALE_APPROVAL ─> WAITING_APPROVAL

会触发 STALE_APPROVAL 的事件至少包括:

  • 对象内容或素材变了;
  • 目标账号或收件人变了;
  • 可见范围、金额、环境变了;
  • 权限策略升级或收紧;
  • 租约超过有效期;
  • 关键证据无法重新取得。

格式空格、同哈希文件重新上传等确定性等价变化,可以由规则重新计算并留痕。无法确定是否等价时,不要再调用一个模型“猜它应该没关系”。

审计日志要能回答四个问题

发生争议时,日志至少要回答:

  1. 批准的是哪个对象版本?
  2. 批准者当时看到了哪些证据?
  3. 使用的是哪一版权限策略?
  4. 最终执行了什么动作,平台如何证明结果?

因此不要只记录:

{ "task": "publish-42", "approved": true }

至少记录 subjectVersionevidenceHashpolicyVersionapproverIdactionScope 和结果证据。

我们在 Tipkay 里的对应取舍

这也是我们做 Tipkay 博客发布助手时坚持的一条边界:研究、写作、配图和平台预填可以由岗位助手接力完成,但最终提交前仍要回读真实页面,确认账号、标题、正文、图片、标签、分类和可见范围。

工具调用成功只说明“尝试填过”,不说明页面里的当前版本已经满足交付条件。把页面状态变成证据,再让授权落到这份证据上,才是一条可追责的交付链。

Tipkay 面向小微企业、一人公司和小团队,提供按实际使用量计费的垂类 AI 员工。我们更关心的不是堆多少 Agent,而是每个岗位能否接手一类工作,并在交接处留下清楚的责任边界。

上线前测这五个反例

  • 批准后修改标题,旧租约是否立即失效;
  • 批准后更换账号,执行器是否阻断;
  • 两个 worker 同时领取,是否只有一个成功;
  • 点击提交后网络超时,是否进入 RESULT_UNKNOWN 而非自动重试;
  • 权限策略升级后,旧租约是否需要重新授权。

这五个反例能过,再谈“自动审批”。否则 Agent 的“同意”,很可能只是一张没有写版本号的空白支票。

参考资料: