权限与 Human-in-the-Loop v05

权限与 Human-in-the-Loop v05

v04(ch17)我们给产品装上了 Web 前端:浏览器消费 SSE 事件流,把工具调用渲染成卡片。但有一个关键问题被一路「先跑起来再说」地搁置了:Agent 想删文件,谁来拦?

ch14 我们说过工具集只放「纯函数、无副作用」的成员,把 delete_filerun_shell 这类危险工具推迟到「权限层出现的那一章」,就是本章。我们给产品加 v05:权限与 Human-in-the-Loop

上一章(ch15)设计事件协议时把 approval_request / approval_result 两型先定义、后实现,「协议是契约,契约先于实现稳定」的伏笔,本章兑现。

本章目标

读完本章并做完配套练习后,你应该能够:

概念与动机:为什么需要人在环里

先看一个「错误答案」:把全部工具交给 Agent

假设我们没有权限层。v04 的产品里再加两个工具:

registry.register({
  name: 'delete_file',
  description: 'Delete a file from disk.',
  parameters: [{ name: 'path', type: 'string', description: 'File path.' }],
}, async (args) => { await rm(args.path, { force: true }); return 'deleted'; });

用户说「帮我清理一下项目里的旧文件」。模型很热心:列出几个文件,挑了个它判断为「旧的」的,删了。文件不可恢复。用户其实只想删掉临时目录里那几个,模型把 notes/ 下三个月的笔记也当成「旧文件」了。

这不是模型「坏」。它是over-eager(过拟合意图):它理解了你的目标、真心想帮忙,但把「清理」的边界推到了你授权之外。Anthropic 在一篇讲 Claude Code auto mode 的工程文章 里把这种失败分类为四类,其中三类都发生在「模型没做错什么但做过头了」:

  1. over-eager 行为:理解了目标但越界(删了判断为「碍事」的文件);
  2. 诚实失误:误解了影响半径(以为文件是测试用的,其实是共享的);
  3. prompt injection:文件/网页里藏着指令,劫持了模型;
  4. 模型自身对齐问题(现实中较少见)。

无论哪一类,防线都指向同一件事:在破坏性动作执行之前,让一个不在这条推理链上的人确认一下

审批疲劳:为什么不能「每个动作都问」

那「所有工具都要求确认」行不行?也不行。Anthropic 的同一篇文章给了一个扎心的数字:用户会批准 93% 的权限提示。当每一次文件写入、每一次命令运行都要点「允许」,用户很快就麻木了,扫一眼、随手点掉,审批变成了形式。这叫审批疲劳(approval fatigue):审批频率越高,单次审批的质量越低,真正危险的请求反而更容易被放行。

所以正确的设计不是「全问」或「全放」,而是按风险分级:低风险高频的操作自动放行(否则 Agent 每走一步都要打断你),只有真正有破坏潜力的动作才叫人。分级越准,被打断的次数越少,每一次打断越值得认真对待,这正是本章三档设计的动机。

参照:业界怎么分级

我们借鉴的是「风险分级 + 执行前审批 + 暂停/恢复」这个机制;实现细节(事件协议、回调注入、审计)是我们自己的。

风险分级与三模式

三档语义

档位含义主循环行为典型工具
auto无副作用或只读直接执行,零交互get_timeget_weatheraddread_file
suggest有轻微副作用approval_request(risk=suggest)提示但不阻断,随即执行write_note(写 scratch 笔记)
approve破坏性、不可逆、影响他人approval_request(risk=approve)并暂停,等人类决定:批准→执行;拒绝→跳过(tool_resultdenied 标记)delete_filerun_shell、生产部署

分级判断一句话:这个动作执行后,如果它不该被执行,后果能撤销吗? 能撤销(写个临时文件)→ suggest 或更低;不可撤销(删文件)或影响范围超出当前会话(改共享配置、发消息、推生产)→ approve。注意 suggest 不等于「弱化版 approve」,它不等待,只是给前端一个展示「接下来会写文件」的机会,用户看到提示想拦的话,可以事后纠正(或者干脆把该工具改成 approve)。

flowchart LR
  subgraph tools["工具分类"]
    R["只读 / 无副作用<br/>get_time · get_weather · add"]
    W["轻微副作用<br/>write_note"]
    D["破坏性 / 不可逆<br/>delete_file"]
  end

  subgraph tiers["风险分级(spec.risk)"]
    A["auto<br/>直接执行"]
    S["suggest<br/>提示但不阻断"]
    P["approve<br/>暂停 · 等人批准"]
  end

  subgraph gate["审批门"]
    G1["批准 → 执行"]
    G2["拒绝 → 跳过(denied)"]
  end

  R --> A
  W --> S
  D --> P
  P -->|getApproval 回调| G1
  P -->|getApproval 回调| G2

风险放在哪:工具的 spec 上,而不是模型手里

风险是策略(policy),不是模型知识。我们把 risk 字段放在工具注册表的 spec 上:

// lib/tools.mjs
registry.register(
  {
    name: 'delete_file',
    description: 'Delete a file from disk. DESTRUCTIVE and irreversible.',
    parameters: [{ name: 'path', type: 'string', description: 'File path.' }],
    risk: 'approve', // 不可逆:主循环会先问人类
  },
  async (args) => { /* ... */ },
);

模型看到的工具列表里没有 riskmodelTools() 只输出 name/description/schema)。这样模型无法「说服」策略层放行:它连风险档位都不知道,自然也无法在 prompt 里辩称「这个删除其实很安全」。分级是注册表作者(人)的决定,不是模型的参数。

审批双向流协议

事件形状(ch15 定稿,本章激活)

v05 在 ch15 的七型事件里激活两型,并给 tool_result 加了一个可选标记:

{ type: 'approval_request', sessionId, requestId, toolName, args, risk }
{ type: 'approval_result',  sessionId, requestId, approved, reason? }
{ type: 'tool_result',      sessionId, callId, ok, result?, error?, denied? }

一次审批的完整事件序列

approve 级工具调用,callId 配对不变式依然成立(每个 tool_call_start 恰好对应一个 tool_result):

tool_call_start  callId, name, args          ← 循环决定要调用这个工具
approval_request requestId, toolName, args, risk:'approve'  ← 暂停,等人
approval_result  requestId, approved, reason?               ← 决策回来(审计)
tool_result      callId, ok / ok:false + denied:true       ← 执行结果 / 拒绝标记

注意顺序:tool_call_start 表示「循环决定了要调用」,随后立刻 approval_request 进入等待,前端看到这两型就知道「这个工具要审批,先别执行」。审批回来后的 approval_resulttool_result 告诉前端最终结果。

双向流:前端回传答案

审批是双向的:事件流把 approval_request 推给前端(下行),前端把人类的决定通过 POST /approvals 送回服务器(上行)。服务端是两半之间的桥:

方法路径做什么
POST/approvals回传审批结果 { requestId, approved, reason? };未知/已解决的 requestId 返回 404
GET/approvals操作审计:所有历史决策(requestId / 工具 / 参数 / 风险 / 决定 / 理由 / 时间)
sequenceDiagram
  participant LLM as mock LLM
  participant LOOP as lib/loop.mjs
  participant SRV as lib/server.mjs
  participant UI as 前端 / 终端
  participant HUM as 人类

  LLM->>LOOP: tool_calls: [delete_file({path})]
  LOOP->>SRV: emit tool_call_start → approval_request
  SRV-->>UI: SSE: approval_request(requestId, toolName, args, risk)
  LOOP->>SRV: await getApproval(request)(挂起,等待中)
  UI->>HUM: 展示审批卡片/终端提问:批准?
  HUM-->>UI: 批准 / 拒绝(附理由)
  UI->>SRV: POST /approvals { requestId, approved, reason }
  SRV-->>LOOP: resolve 挂起的 promise(决策 + 理由)
  LOOP->>SRV: emit approval_result(审计事件)→ tool_result
  SRV-->>UI: SSE: approval_result + tool_result(ok / denied)

两个值得停下来的设计点:

  1. 主循环不知道「前端」存在。loop 只做一件事:await getApproval(request),一个注入的异步回调。谁提供这个回调、回调内部是终端提问、HTTP 挂起表、还是测试脚本,loop 一概不知。这是 ch14 分层红线的加强版:core 通过注入拿到「人的决定」,不 import 界面
  2. 暂停期间 SSE 连接不能断。loop 挂着等审批,可能等几秒到几分钟;服务端的 15 秒注释行保活(ch15 的 keep-alive)在这时候才显出价值。连接活着,审批结果回来时事件才能推出去。

手写实现:把权限层装进产品

一、工具层:risk 字段 + 审批硬校验(lib/tools.mjs)

executeTool 加一个硬校验approve 级工具只有拿到「匹配的审批记录」才执行,否则返回 denied 数据,不运行 impl

export async function executeTool(registry, name, args, options = {}) {
  const entry = registry.get(name);
  if (!entry) return { ok: false, error: `unknown tool: ${name}` };

  const risk = entry.spec.risk ?? 'auto';
  if (risk === 'approve') {
    const approved = options.requestId != null && options.approvedRequestIds?.has(options.requestId);
    if (!approved) {
      return { ok: false, denied: true, error: `tool ${name} requires approval (risk=approve); call denied` };
    }
  }

  try {
    return { ok: true, result: await entry.impl(args) };
  } catch (err) {
    return { ok: false, error: `Error: ${err.message}` };
  }
}

两层防护(纵深防御)由此成型:交互门在主循环(会问人、会暂停),硬门executeTool(不认人、只认记录)。任何绕过交互门的调用方(一个忘了审批的集成、一个 bug、未来的某个界面),在硬门前都拿不到执行权。安全默认拒绝(fail closed):拿不出审批记录 = 不执行,而不是「可能没事,放行吧」。

(顺带一个实现细节:impl 可能是异步的,executeToolawait 它,否则返回的是 Promise,序列化成 {},异步错误还会逃出 error-as-data。ch15 的工具全是同步的,这一章引入 delete_file 才暴露这个问题。)

二、主循环:审批门(lib/loop.mjs)

loop 的改造集中在 act 阶段:每个 tool call 先查 riskOf(tools, name)approve 级走审批门(permission gate)

for (const call of response.toolCalls) {
  emit({ type: 'tool_call_start', callId: call.id, name: call.name, args: call.arguments });
  const risk = riskOf(tools, call.name);

  if (risk === 'approve') {
    const requestId = `req_${call.id}`;
    const request = { requestId, toolName: call.name, args: call.arguments, risk };
    emit({ type: 'approval_request', ...request });

    // THE PAUSE:等人。没注入回调 = fail closed(默认拒绝)。
    const decision = getApproval
      ? await getApproval(request)
      : { approved: false, reason: 'no approver configured (fail closed)' };

    emit({ type: 'approval_result', requestId, approved: decision.approved, reason: decision.reason });

    if (!decision.approved) {
      // 拒绝 → 跳过执行,模型能看到为什么(这是它恢复的依据)
      const error = `denied by user${decision.reason ? `: ${decision.reason}` : ''}`;
      messages.push({ role: 'tool', tool_call_id: call.id, content: error });
      emit({ type: 'tool_result', callId: call.id, ok: false, denied: true, error });
      continue;
    }

    // 批准 → 带着审批记录执行(executeTool 会再验一次,纵深防御)
    const outcome = await executeTool(tools, call.name, call.arguments,
      { requestId, approvedRequestIds: new Set([requestId]) });
    /* ... 成功/失败处理与 ch15 一致 ... */
    continue;
  }

  if (risk === 'suggest') {
    emit({ type: 'approval_request', requestId: `req_${call.id}`, toolName: call.name, args: call.arguments, risk });
    // 提示而已,不等待、不问人
  }

  const outcome = await executeTool(tools, call.name, call.arguments);
  /* ... 同 ch15 ... */
}

三个关键点:

  1. 「暂停」就是 await getApproval(request)。Promise 不 resolve,循环就停在这——HITL的「人在环里」在代码里的全部形态就是这一行。没有魔法。
  2. 拒绝 ≠ 崩溃,拒绝 ≠ 静默。被拒的调用变成一条 tool_resultok:false, denied:true),消息历史里多了一条「denied by user: …」。模型下一轮能看到它,从而恢复:道歉、换目标、或者向用户确认意图。拒绝是数据,不是错误,和 ch08 的 error-as-data 一脉相承。
  3. approval_result 本身就是审计事件。谁批了什么、为什么,已经落在事件流里(服务端的 GET /approvals 再做一份落库副本)。不需要单独的审计模块。

三、服务端:审批中枢(lib/server.mjs)

服务端实现 getApproval 的方式是「挂起表」:pending Map 存 requestId → resolvePOST /approvals 到达时取出并 resolve:

function createApprovalHub() {
  const pending = new Map(); // requestId -> { resolve, request }
  const log = [];            // 审计:已解决的请求,按决策顺序

  return {
    getApproval(request) {
      return new Promise((resolve) => pending.set(request.requestId, { resolve, request }));
    },
    resolveApproval({ requestId, approved, reason }) {
      const entry = pending.get(requestId);
      if (!entry) return false;                 // 未知/已解决:不能重复生效
      pending.delete(requestId);
      log.push({ requestId, toolName: entry.request.toolName, args: entry.request.args,
                 risk: entry.request.risk, approved, reason, at: new Date().toISOString() });
      entry.resolve({ approved, reason });      // loop 从这里醒来
      return true;
    },
    approvals() { return log.slice(); },
  };
}

POST /approvals 处理器只是把 body 转交给 resolveApprovalGET /approvals 返回 approvals()审批状态(挂起表)和会话状态一样,是界面层的状态。loop 无状态,删掉 server,core 照常可测(这也是 ch15 坑四「状态塞进 core」的延续)。

四、跑起来看两条分支

demo 用确定性 mock 模型驱动两轮对话:第一轮请求 delete_file(触发审批门),第二轮按审批结果给终答。两个会话分别扮演「批准」和「拒绝」的人类:

--- session s-approve: POST /chat { text: "请把 demo-notes/scratch.txt 删掉。" } ---
[1] event: tool_call_start
    data: {"sessionId":"s-approve","type":"tool_call_start","callId":"call_delete","name":"delete_file","args":{"path":"demo-notes/scratch.txt"}}
[2] event: approval_request
    data: {"sessionId":"s-approve","type":"approval_request","requestId":"req_call_delete","toolName":"delete_file","args":{"path":"demo-notes/scratch.txt"},"risk":"approve"}

· human decides: POST /approvals { requestId: "req_call_delete", approved: true }
[3] event: approval_result
    data: {"sessionId":"s-approve","type":"approval_result","requestId":"req_call_delete","approved":true,"reason":"临时文件,可以删"}
[4] event: tool_result
    data: {"sessionId":"s-approve","type":"tool_result","callId":"call_delete","ok":true,"result":"deleted demo-notes/scratch.txt"}
[5] event: text_delta
    data: {"sessionId":"s-approve","type":"text_delta","delta":"已删除 demo-notes/scratch.txt。"}
[6] event: done
    data: {"sessionId":"s-approve","type":"done","usage":{"inputTokens":42,"outputTokens":24}}

批准分支:审批通过 → 工具真的执行了(文件被删)→ 模型确认删除。拒绝分支走完全一样的协议,区别只在决策:

--- session s-deny: POST /chat { text: "请把 demo-notes/important.txt 删掉。" } ---
[2] event: approval_request
    data: {"sessionId":"s-deny","type":"approval_request","requestId":"req_call_delete","toolName":"delete_file","args":{"path":"demo-notes/important.txt"},"risk":"approve"}

· human decides: POST /approvals { requestId: "req_call_delete", approved: false, reason: "重要文件" }
[3] event: approval_result
    data: {"sessionId":"s-deny","type":"approval_result","requestId":"req_call_delete","approved":false,"reason":"重要文件,不能删"}
[4] event: tool_result
    data: {"sessionId":"s-deny","type":"tool_result","callId":"call_delete","ok":false,"denied":true,"error":"denied by user: 重要文件,不能删"}
[5] event: text_delta
    data: {"sessionId":"s-deny","type":"text_delta","delta":"好的,已跳过删除 demo-notes/important.txt。"}
[6] event: done
    data: {"sessionId":"s-deny","type":"done","usage":{"inputTokens":42,"outputTokens":24}}

拒绝分支:tool_resultdenied: true,错误串带人类理由;文件未被删除;模型读到「denied by user」后给出了「好的,已跳过删除」:拒绝没有中断整轮对话,Agent 正常恢复。两个会话的事件流互不串扰(sessionId 分桶,ch15 的机制),审计日志 GET /approvals 能查到两条决策:

{
  "approvals": [
    { "requestId": "req_call_delete", "toolName": "delete_file",
      "args": { "path": "demo-notes/scratch.txt" }, "risk": "approve",
      "approved": true,  "reason": "临时文件,可以删",  "at": "…" },
    { "requestId": "req_call_delete", "toolName": "delete_file",
      "args": { "path": "demo-notes/important.txt" }, "risk": "approve",
      "approved": false, "reason": "重要文件,不能删", "at": "…" }
  ]
}

常见坑与失败模式

坑一:死等审批。 审批门挂起后,如果前端/人类永远不回答(连接断了、界面关了、人走了),loop 就永远停在那——资源挂着、会话卡死、后面的用户请求排队。这是「暂停」机制最直接的风险。解法有三层:审批超时(到时默认拒绝或升级)、待审批状态可持久化(OpenAI Agents SDK 的做法是把暂停的运行序列化成 RunState,存下来、换进程也能恢复并继续,OpenAI Agents SDK · Human-in-the-loop)、以及服务端对挂起请求的清理与告警。我们 v05 的实现是「无限等」(教学优先);真产品里至少要有一个超时。

坑二:超时策略的默认值。 超时到了怎么办?「放行」是危险默认:人类没回答就当批准,等于把审批门(permission gate)变成摆设;「拒绝」是安全默认:没确认就不做,用户事后可以重新要求。本章的 fail-closed 原则一直贯彻到这里:超时 = 拒绝,并像正常拒绝一样记录进审计(带 reason 如 approval timed out)。只有对「确认过的高频安全操作」才考虑短超时自动放行,而且那是显式配置,不是默认。

坑三:拒绝后不恢复。 拒绝被静默吞掉(不发 tool_result、不给模型反馈)是最糟的:模型以为工具成功了,继续往下编答案;或者反复请求同一个被拒的删除,把人烦死。正确做法(本章实现)是拒绝 = 数据:模型能读到「denied by user: 重要文件,不能删」,于是道歉、换目标、或向用户确认。拒绝必须可见、必须可解释。

坑四:审批逻辑/界面塞进 core。 loop 里直接写 readline.question()fetch('/approvals'),core 从此认识界面,换个界面(终端 → Web → 测试)就要改 core。红线不变:core 只认注入的 getApproval 签名;终端提问(cli.mjs)、HTTP 挂起表(server.mjs)、脚本决策(测试)都是界面层的实现。判断标准一句话:把 server 和 cli 删掉,loop 还能不能跑、能不能测?(ch15 坑四的同一个问题,这次问的是审批。)

坑五:getApproval 只返回 boolean,把理由丢了。 审计要记「为什么」,被拒的模型要知道「为什么」。如果回调只返回 true/false,理由就只能靠猜。所以我们的回调签名是 Promise<{ approved, reason? }>:决策和理由一起回来,一起进事件流、一起进审计。没有理由的审批决策,审计里只是一行没有灵魂的 true/false。

坑六:按工具名批准,而不是按调用批准。 「用户批准过 delete_file,所以这轮的所有 delete_file 都放行」:一次批准变成对整个工具的长期授权,审批门名存实亡。我们的 requestId 精确到每一次调用:批了 delete_file(demo-notes/scratch.txt) 不等于批了 delete_file(demo-notes/important.txt)。想要「这次会话里这个工具都放行」是显式策略(如「记住本次选择」),而不是默认行为。

小结

下一章(ch19)做安全与沙箱 v06:工作目录约束与路径逃逸防护、Shell 隔离执行、prompt injection 缓解。权限层管「要不要做」,沙箱管「能做多坏」,两者叠加才是完整防线。再下一章(ch20)做可观测性与成本 v07done.usage 和审批审计都会变成看板上的指标。先把本章练习做完:亲手实现风险判定、审批门、审计日志这三层。

延伸阅读

完成阅读,去做练习 →