【Agent工程】(6)------ 危险写操作与二次确认
文章目录
- [【Agent工程】(6)------ 危险写操作与二次确认](#【Agent工程】(6)—— 危险写操作与二次确认)
-
- [1. 写工具通过门禁不等于可以立即执行](#1. 写工具通过门禁不等于可以立即执行)
-
- [1.1 三类需要二次确认的典型写操作](#1.1 三类需要二次确认的典型写操作)
- [2. 注册表里的风险等级](#2. 注册表里的风险等级)
-
- [2.1 与卡片约束的关系](#2.1 与卡片约束的关系)
- [2.2 风险等级变更要走评审](#2.2 风险等级变更要走评审)
- [3. 两阶段执行与待确认队列](#3. 两阶段执行与待确认队列)
-
- [3.1 Agent 侧语义](#3.1 Agent 侧语义)
- [3.2 确认接口的最小约定](#3.2 确认接口的最小约定)
- [4. 贯穿示例:通知值班与删除工单](#4. 贯穿示例:通知值班与删除工单)
-
- [4.1 拒绝与降级](#4.1 拒绝与降级)
- [4.2 确认页应展示的最小信息](#4.2 确认页应展示的最小信息)
- [5. 可运行示意:pending 队列与确认执行](#5. 可运行示意:pending 队列与确认执行)
-
- [5.1 注册表与入队](#5.1 注册表与入队)
- [5.2 确认后再执行](#5.2 确认后再执行)
- [6. 与验收和审计对齐](#6. 与验收和审计对齐)
-
- [6.1 与 scope 门禁的顺序](#6.1 与 scope 门禁的顺序)
- [6.2 验收项扩展示例](#6.2 验收项扩展示例)
- [7. 四个常见误区](#7. 四个常见误区)
-
- [7.1 与模型提示词的边界](#7.1 与模型提示词的边界)
- [8. 适用边界](#8. 适用边界)
-
- [8.1 存量系统如何补二次确认](#8.1 存量系统如何补二次确认)
- [9. 术语速查](#9. 术语速查)
- [10. 小结与下一篇](#10. 小结与下一篇)
摘要 :注册表、作用域与调用前门禁可以拦住越权,但对外通知、删除、批量变更等写操作仍可能在一次模型提议后直接落地。本篇在工具注册表上增加风险等级,把高危写操作改成两阶段:提议入待确认队列,人工或策略确认后再执行。贯穿示例继续用工单助手。适合读完 【Agent工程】(5)------ 工具作用域与对象级授权、准备给不可逆副作用加确认层的工程师。读完可以独立完成:为高危工具配置 risk_level,并实现一版 pending 队列与确认接口。
1. 写工具通过门禁不等于可以立即执行
前几篇工具面已经做了三层约束:
- 注册表与允许清单 :能不能用这个
tool_id - 读写分离:写操作单独计数、单独策略
- 作用域 :参数里的
ticket_id是否在卡片范围内
工单升级场景里,notify_oncall 通常可以通过以上三道检查。工具合法、对象也合法,但通知值班 会把内容推到外部通道:措辞错误、工单号抄错、重复轰炸值班手机,后果往往比改一个 priority 字段更严重。delete_ticket 则属于不可逆操作,即便 scope 命中,也不应让模型一次 function call 直接删库。

工程上要再分一层:
| 阶段 | 回答的问题 |
|---|---|
| 调用前门禁 | 工具、对象、参数是否合法 |
| 二次确认 | 合法的高危写是否现在就要执行 |
普通写(改备注、改优先级)在卡片允许且 scope 命中后可以立即执行。高危写(对外通知、删除、批量变更)应挂起 ,等确认后再调用实现层。前文见 【Agent工程】(4)------ 工具注册表与读写分离、【Agent工程】(5)------ 工具作用域与对象级授权。
常见故障模式是:演示时值班同学坐在旁边口头说「可以发」,上线后同一句话写进模型回复就被当成确认。口头确认没有独立记录,验收与审计都无法核对。
1.1 三类需要二次确认的典型写操作
不必把所有 write 都做成 pending。工程上优先盯住三类:
| 类型 | 特征 | 工单示例 |
|---|---|---|
| 对外通知 | 内容离开系统边界 | notify_oncall、发短信 |
| 不可逆 | 删除或无法自动回滚 | delete_ticket |
| 批量变更 | 一次调用影响多对象 | batch_close_tickets |
update_ticket_priority 通常仍属 medium:在 scope 内、可回滚、不直接对外。若业务规定「升紧急必须值班知晓」,可以把 medium 在卡片里 override 成 human_required,但那是收紧而不是放松。
2. 注册表里的风险等级
在注册表条目上增加 risk_level (风险等级),与 side_effect、enabled 并列。建议先用四档,名称可以随团队调整,但语义要固定:

| risk_level | 含义 | 典型工具 | 默认策略 |
|---|---|---|---|
| low | 可逆、影响面小 | 添加内部备注 | 门禁通过即执行 |
| medium | 业务写、可回滚 | 改优先级 | 卡片允许即执行 |
| high | 对外副作用 | 通知值班 | 待确认后执行 |
| critical | 不可逆或极高风险 | 删除工单 | 默认禁用 + 必须人工 |
字段还可以带上 confirm_policy(确认策略),例如:
none:无需确认card_override:卡片显式声明auto_confirm: true时才跳过(仅适用于 medium)human_required:必须人工 approveblocked:与enabled=False配合,永不执行
底线规则 :critical 不允许被卡片放宽为自动执行。卡片可以收紧(例如 medium 也要求确认),不能比注册表更松。
2.1 与卡片约束的关系
任务卡片除了 allowed_tools、scope,还可以写:
json
{
"confirm_policy_overrides": {
"notify_oncall": "human_required"
},
"auto_confirm": false
}
auto_confirm: false 应作为工单类 Agent 的默认值:宁可多一道人工,也不要误通知。只有明确标注的低风险批量任务,才在卡片级允许特定工具跳过确认。
2.2 风险等级变更要走评审
risk_level 不是文档标签,而是运行时分支。把 notify_oncall 从 high 改成 medium,等于默认不再 pending。变更应:
- 在注册表 PR 里说明业务理由
- 同步更新卡片模板与验收项
- 在评测集里加一条「误通知」回归用例
反过来,线上若 pending 队列长期无人处理,优先加值班排班或超时降级,而不是把 high 一律改成 medium 省事。
3. 两阶段执行与待确认队列
高危写的运行时流程在门禁之后多两步:入队 与确认执行。

- 模型提议工具调用
- 调用前门禁(注册、启用、允许清单、schema、scope)
- 若
risk_level需要确认:写入 pending 队列,返回pending_id给 Agent,不调用 SDK - 人工或策略审批:
approve/reject - 仅
approve后,用快照参数执行实现层
队列条目建议包含:

| 字段 | 用途 |
|---|---|
| pending_id | 唯一编号,对外展示给值班确认 |
| tool_id, args | 拟执行调用快照,确认后不可被模型改写 |
| card_id | 关联任务卡片,便于核对目标 |
| requested_at, expires_at | 创建与超时时间 |
| status | pending / approved / rejected / expired |
| decided_by | 审批人或服务账号 |
超时 :pending 超过 expires_at 自动变为 expired,Agent 应走降级路径(例如标记「通知待人工补发」),而不是反复入队。
3.1 Agent 侧语义
门禁返回三类结果之一:
| 结果 | Agent 行为 |
|---|---|
| 直接执行成功 | 继续推理,读工具结果 |
| 拒绝(越权等) | 换方案或结束 |
| pending_created | 告知「已提交确认」,停止重复提议同一调用 |
pending_created 不是失败,但也不是完成。Agent 不应在同一轮里用略改参数再发一次 notify_oncall 来「碰运气」,否则会堆多条 pending。应在卡片预算里限制同一工具 pending 次数。
3.2 确认接口的最小约定
对外暴露 approve API 时,建议固定请求体:
json
{
"pending_id": "a1b2c3d4e5f6",
"action": "approve",
"decided_by": "oncall_zhang",
"comment": "措辞已核对"
}
拒绝时使用 "action": "reject" 并填写 reason_code。接口只接受 pending_id,不接受客户端重新上传 tool args,避免确认页被篡改参数。
4. 贯穿示例:通知值班与删除工单
继续工单升级。目标:将 T-1024 升为紧急并通知值班。

| 步骤 | 动作 |
|---|---|
| 1 | Agent 提议 update_ticket_priority(T-1024, urgent),medium,直接执行 |
| 2 | Agent 提议 notify_oncall(T-1024, message=...),high,生成 pending |
| 3 | 值班在确认页核对工单与措辞,点击 approve |
| 4 | 运行时按快照执行通知,回写 notified=True |
| 5 | 验收(第 3 篇)断言 priority 与 notify 状态 |
delete_ticket 在注册表里应是 critical + enabled=False。即便有人把它写进允许清单,门禁仍应在启用检查处拒绝;若将来启用,也必须 human_required 且双人复核,不在本篇最小示例里展开。
4.1 拒绝与降级
值班 reject 时,应记录原因码(例如 wording_unsafe、wrong_ticket)。Agent 收到 rejected 后可以:
- 修改 message 重新提议(生成新 pending,旧条目保持 rejected)
- 或标记任务降级:priority 已改,通知转人工电话
无论哪条路径,轨迹里都要保留 pending 与审批记录,验收时才能解释「为什么 priority 变了但没有 notified」。
4.2 确认页应展示的最小信息
值班同学不是审代码,确认页应一眼能判断该不该点 approve:
| 展示项 | 来源 |
|---|---|
| 工单号 | args 快照 |
| 拟执行工具 | tool_id + 展示名 |
| 通知正文 | message 快照 |
| 任务目标 | 卡片 goal 摘要 |
| 过期时间 | expires_at |
缺少卡片 goal 时,值班很难判断通知是否与任务一致。确认 UI 是安全链路的一部分,不是可有可无的前端装饰。
5. 可运行示意:pending 队列与确认执行
下面代码演示:门禁通过后,高危写入队;确认前不执行;approve 后用快照参数调用。
5.1 注册表与入队
python
from __future__ import annotations
import uuid
from datetime import datetime, timedelta, timezone
from typing import Any, Callable
REGISTRY: dict[str, dict[str, Any]] = {
"update_ticket_priority": {
"side_effect": "write",
"risk_level": "medium",
"confirm_policy": "none",
"enabled": True,
"input_schema": {"required": ["ticket_id", "priority"]},
},
"notify_oncall": {
"side_effect": "write",
"risk_level": "high",
"confirm_policy": "human_required",
"enabled": True,
"input_schema": {"required": ["ticket_id", "message"]},
},
"delete_ticket": {
"side_effect": "write",
"risk_level": "critical",
"confirm_policy": "blocked",
"enabled": False,
"input_schema": {"required": ["ticket_id"]},
},
}
PENDING: dict[str, dict[str, Any]] = {}
EXEC_LOG: list[dict[str, Any]] = []
def needs_confirmation(meta: dict[str, Any], card: dict[str, Any]) -> bool:
policy = (card.get("confirm_policy_overrides") or {}).get(
meta.get("tool_id", ""), meta.get("confirm_policy", "none")
)
if meta["risk_level"] == "critical":
return True
if policy == "human_required":
return True
if policy == "none":
return card.get("auto_confirm", False) is False and meta["risk_level"] == "high"
return False
def dispatch_tool(
tool_id: str,
args: dict[str, Any],
card: dict[str, Any],
executor: Callable[[str, dict[str, Any]], Any],
) -> dict[str, Any]:
meta = {**REGISTRY[tool_id], "tool_id": tool_id}
if not meta.get("enabled", False):
return {"ok": False, "reason": "disabled_tool", "tool_id": tool_id}
if needs_confirmation(meta, card):
pending_id = uuid.uuid4().hex[:12]
PENDING[pending_id] = {
"pending_id": pending_id,
"tool_id": tool_id,
"args": dict(args),
"card_id": card.get("card_id"),
"status": "pending",
"expires_at": datetime.now(timezone.utc) + timedelta(minutes=15),
}
return {"ok": True, "status": "pending_created", "pending_id": pending_id}
result = executor(tool_id, args)
EXEC_LOG.append({"tool_id": tool_id, "args": args, "result": result})
return {"ok": True, "status": "executed", "result": result}
def approve_pending(
pending_id: str,
decided_by: str,
executor: Callable[[str, dict[str, Any]], Any],
) -> dict[str, Any]:
item = PENDING.get(pending_id)
if not item or item["status"] != "pending":
return {"ok": False, "reason": "pending_not_found_or_done"}
if datetime.now(timezone.utc) > item["expires_at"]:
item["status"] = "expired"
return {"ok": False, "reason": "pending_expired"}
item["status"] = "approved"
item["decided_by"] = decided_by
result = executor(item["tool_id"], item["args"])
EXEC_LOG.append(
{
"tool_id": item["tool_id"],
"args": item["args"],
"result": result,
"pending_id": pending_id,
"decided_by": decided_by,
}
)
return {"ok": True, "status": "executed", "result": result}
if __name__ == "__main__":
card = {"card_id": "card-100", "auto_confirm": False}
def fake_exec(tool_id: str, args: dict[str, Any]) -> dict[str, Any]:
return {"tool_id": tool_id, "args": args, "ok": True}
r1 = dispatch_tool(
"update_ticket_priority",
{"ticket_id": "T-1024", "priority": "urgent"},
card,
fake_exec,
)
r2 = dispatch_tool(
"notify_oncall",
{"ticket_id": "T-1024", "message": "升级紧急,请接手"},
card,
fake_exec,
)
print("priority:", r1)
print("notify:", r2)
assert r1["status"] == "executed"
assert r2["status"] == "pending_created"
5.2 确认后再执行
python
if __name__ == "__main__":
card = {"card_id": "card-100", "auto_confirm": False}
def fake_exec(tool_id: str, args: dict[str, Any]) -> dict[str, Any]:
return {"tool_id": tool_id, "args": args, "ok": True}
pending = dispatch_tool(
"notify_oncall",
{"ticket_id": "T-1024", "message": "升级紧急,请接手"},
card,
fake_exec,
)
pid = pending["pending_id"]
assert len(EXEC_LOG) == 0
approved = approve_pending(pid, "oncall_zhang", fake_exec)
assert approved["ok"] is True
assert len(EXEC_LOG) == 1
assert EXEC_LOG[0]["decided_by"] == "oncall_zhang"
print("确认后执行完成,EXEC_LOG:", EXEC_LOG)
生产环境里 executor 应走统一 SDK 入口,仍然经过注册表门禁,不允许 confirm 页面直连后端绕过审计。
6. 与验收和审计对齐
第 3 篇验收区分自动项与人工项。二次确认不是替代验收,而是把高危写从自动链路里拆出来:
| 类型 | 本篇落地 |
|---|---|
| 自动 | priority 字段、pending 条数、EXEC_LOG 与 pending 状态一致 |
| 人工 | 通知措辞是否适合对外(可在 approve 页完成) |
轨迹建议记录:pending_id、status、审批人、耗时。验收脚本可以检查:
- 存在
notify_oncall时,必须有 approved pending 或合法降级标记 critical工具 EXEC_LOG 必须为空
审计查询应能回答:「谁在什么时间批准了对 T-1024 的通知,用的哪条 message 快照」。
6.1 与 scope 门禁的顺序
推荐顺序:注册与启用 → 允许清单 → schema → scope → 风险确认。scope 不通过时不应创建 pending,否则队列里堆满越权提议,增加值班负担。
6.2 验收项扩展示例
在第 3 篇与第 5 篇验收清单上,可追加:
| 检查点 | 期望 | 类型 |
|---|---|---|
| notify_pending | notify 对应 approved pending 或降级 | 自动 |
| no_orphan_pending | 任务结束无遗留 pending | 自动 |
| critical_never_executed | delete 等无 EXEC_LOG | 自动 |
| notify_wording | 对外措辞 | 人工(approve 页) |
这样自动项管状态一致性,人工项管对外表达是否合适,职责与第 3 篇三类验收项一致。
7. 四个常见误区

| 误区 | 典型表现 | 更稳妥的做法 |
|---|---|---|
| 模型自确认 | 回复「用户已同意」就执行 | 独立 approve 接口与记录 |
| 口头确认不落库 | 复盘无法核对 | pending 快照 + decided_by |
| pending 无超时 | 队列堆积、重复通知 | expires_at + expired 状态 |
| 卡片放宽 critical | 删除被自动执行 | critical 不可 auto_confirm |
还有一种隐蔽做法:把高危工具拆成「预览 + 确认」两个 tool_id,但确认工具不做参数绑定校验。正确做法是:确认操作必须引用 pending_id,执行层只认队列快照,不认模型新参数。
7.1 与模型提示词的边界
系统提示里写「删除前必须询问用户」不能替代 pending。模型询问用户后仍可能在同一轮 function call 里直接 delete。二次确认是运行时硬约束,提示词只是辅助表达。
8. 适用边界
本篇方法适合:
- 写工具已走注册表与 scope,仍有关键对外副作用
- 有值班或 On-call 界面可以点 approve
- 需要可审计的审批记录
本篇不覆盖:
- 基于业务角色的动态授权(只有工单负责人能批)------需接权限系统
- 跨 Agent 委托时的确认链------后续编排篇目展开
- 合规场景下的双人复核与电子签章------需单独流程设计
若团队规模很小、只有单用户本地 Agent,仍建议保留 pending 结构与日志接口,避免将来上多人值班时重写运行时。
8.1 存量系统如何补二次确认
已有 Agent 若高危写仍是「门禁通过即执行」,可以按三步迁移:
- 标 risk_level:先把 notify、delete 标 high / critical
- 只读旁路:notify 先写 pending 但不阻塞读路径,观察队列长度
- 接 approve 页:确认后再写 EXEC_LOG,验收改为查 pending 状态
迁移期允许人工在旧界面操作,但新调用必须走 pending_id,避免双轨执行。
9. 术语速查
| 术语 | 含义 |
|---|---|
| 风险等级 risk_level | 注册表中对写操作严重程度的分类 |
| 确认策略 confirm_policy | 是否需要人工或策略确认后再执行 |
| 待确认 pending | 已通过门禁但尚未执行的高危写提议 |
| pending_id | 待确认条目的唯一编号 |
| 两阶段执行 | 提议入队 → 确认 → 执行实现 |
| approve / reject | 人工或策略对 pending 的通过 / 拒绝 |
10. 小结与下一篇
工具面四篇递进关系可以概括为:
- 注册表:工具目录与读写属性
- 作用域:对象级授权
- 本篇:高危写的二次确认与 pending 队列
工程上请固定三条规则:
- risk_level 写在注册表,critical 不可被卡片自动执行
- 高危写先 pending,确认后用快照参数执行
- 轨迹与验收能核对 pending 与审批人
下一篇进入编排单元:单 Agent 如何把任务卡片、工具门禁与确认队列串成闭环,并在失败时重试或降级。
系列导航:
- 上一篇:【Agent工程】(5)------ 工具作用域与对象级授权
- 下一篇:【Agent工程】(7)------ 单 Agent 任务闭环(撰写中)