Agent 审批系统:高风险命令、数据库迁移与生产发布门禁
Agent 能自主执行命令、修改代码、部署服务——但不是每个操作都应该自动通过。本文设计一套生产级 Agent 审批系统,覆盖审批策略、审批事件、权限角色、超时处理和审批后的执行上下文恢复,让 Agent 在"自主"和"受控"之间找到精确平衡。
目录
一、为什么 Agent 需要审批
在上一篇文章中,我们设计了 Agent 执行器的命令代理(Command Proxy),将 Agent 发起的命令按风险分为 SAFE、MODERATE、HIGH、FORBIDDEN 四个等级。SAFE 和 MODERATE 级别的命令可以自动放行,FORBIDDEN 直接拒绝——但 HIGH 级别的命令需要一个关键机制:人工审批。
1.1 没有审批的 Agent 平台等于定时炸弹
先看一个真实场景链路:
2026-06-01 09:15 Agent 接到任务:"优化数据库查询性能"
2026-06-01 09:16 Agent 分析慢查询,发现需要添加联合索引
2026-06-01 09:17 Agent 执行 ALTER TABLE orders ADD INDEX idx_user_date (user_id, created_at)
2026-06-01 09:17 索引构建锁表 45 秒,线上订单服务 503
2026-06-01 09:18 Agent 觉得索引不够,执行 DROP INDEX idx_old_status 删除旧索引
2026-06-01 09:18 旧索引被删除,依赖该索引的报表查询全部超时这条链路中,Agent 每一步都是"正确的"——它确实在优化性能。但问题在于:数据库 DDL 操作属于高风险变更,应该有审批门禁。如果有一个审批系统,在第 3 步暂停执行、等待 DBA 确认,整个事故就不会发生。
1.2 审批不是限制 Agent,而是保护团队
很多开发者对"审批"有抵触——觉得它拖慢了 Agent 的自主性。但换一个角度:审批系统保护的不是代码,而是团队的信任和协作边界。
| 场景 | 无审批 | 有审批 |
|---|---|---|
| Agent 修改核心模块 | 直接改,Reviewer 事后发现 | 暂停,Reviewer 确认后执行 |
| Agent 执行数据库迁移 | 直接跑 migration,可能锁表 | DBA 审批后在低峰期执行 |
| Agent 部署到生产环境 | 直接 deploy,无人知晓 | 运维审批 + 灰度发布 |
| Agent 删除 Git 分支 | 直接删,可能误删 release 分支 | 分支保护规则拦截,人工确认 |
审批系统的核心设计目标是:在 Agent 的自主性和团队的安全边界之间,建立一套可编程、可审计、可回退的决策机制。
二、审批系统总体架构
审批系统不是一个独立的微服务,而是嵌入在执行器 Command Proxy 中的一个决策层。它的架构如下:
Agent 发起命令
│
▼
┌─────────────────────┐
│ Command Proxy │
│ (命令分级) │
└──────────┬──────────┘
│
┌──────────▼──────────┐
│ Risk Classification │
│ SAFE → 直接执行 │
│ MODERATE → 审计执行 │
│ FORBIDDEN → 拒绝 │
│ HIGH → 进入审批流程 │
└──────────┬──────────┘
│ (HIGH)
▼
┌─────────────────────┐
│ Approval Engine │
│ │
│ 1. 匹配审批策略 │
│ 2. 生成审批事件 │
│ 3. 通知审批人 │
│ 4. 暂停执行器 │
└──────────┬──────────┘
│
┌──────────▼──────────┐
│ Approval Gate │
│ │
│ approved → 恢复执行 │
│ rejected → 拒绝+记录│
│ timeout → 按策略 │
└─────────────────────┘三个核心组件各司其职:
- Approval Engine(审批引擎):根据命令类型、风险等级、发起人、目标环境等维度匹配审批策略,生成审批事件
- Approval Gate(审批门禁):阻塞执行器等待审批结果,处理超时和拒绝
- Approval Store(审批存储):持久化所有审批事件,支持审计和回放
三、审批策略引擎
审批策略是整套系统的灵魂。它决定了哪些操作需要审批、谁来审批、超时怎么处理。
3.1 策略配置格式
我们用 YAML 定义审批策略,支持多维度匹配和优先级排序:
# approval-policies.yaml
policies:
# 策略 1:数据库 DDL 操作需要 DBA 审批
- name: "database-ddl"
priority: 10
match:
command_patterns:
- "ALTER TABLE*"
- "DROP TABLE*"
- "CREATE INDEX*"
- "DROP INDEX*"
- "npm run migrate*"
- "python manage.py migrate*"
risk_level: "high"
approval:
required_roles: ["dba", "tech-lead"]
min_approvers: 1
timeout_secs: 1800 # 30 分钟
timeout_action: "reject" # 超时视为拒绝
scope: "per-command" # 每条命令单独审批
# 策略 2:生产环境部署需要运维审批
- name: "production-deploy"
priority: 20
match:
command_patterns:
- "kubectl apply*"
- "helm upgrade*"
- "docker push*prod*"
- "npm run deploy*"
target_env: "production"
approval:
required_roles: ["devops", "sre"]
min_approvers: 1
timeout_secs: 3600 # 1 小时
timeout_action: "reject"
scope: "per-task" # 同一任务的部署命令共享审批
# 策略 3:Git 高风险操作需要 Tech Lead 审批
- name: "git-dangerous"
priority: 15
match:
command_patterns:
- "git push --force*"
- "git push*main"
- "git push*master"
- "git branch -D*"
- "git reset --hard*"
approval:
required_roles: ["tech-lead"]
min_approvers: 1
timeout_secs: 600
timeout_action: "reject"
scope: "per-command"
# 策略 4:敏感文件修改需要安全团队审批
- name: "sensitive-files"
priority: 25
match:
file_patterns:
- "**/auth/**"
- "**/crypto/**"
- "**/.env*"
- "**/secrets/**"
- "**/Dockerfile"
approval:
required_roles: ["security"]
min_approvers: 1
timeout_secs: 1800
timeout_action: "escalate" # 超时升级到更高级别
scope: "per-command"
# 默认策略:未匹配的高风险命令
- name: "default-high-risk"
priority: 100
match:
risk_level: "high"
approval:
required_roles: ["tech-lead", "admin"]
min_approvers: 1
timeout_secs: 900
timeout_action: "reject"
scope: "per-command"3.2 策略匹配算法
策略引擎按优先级从高到低匹配,第一个命中的策略生效:
import fnmatch
from dataclasses import dataclass
from typing import Optional
@dataclass
class ApprovalRequest:
"""审批请求"""
task_id: str
command: str
risk_level: str
target_files: list[str]
target_env: str
requested_by: str
agent_id: str
@dataclass
class ApprovalPolicy:
"""审批策略"""
name: str
priority: int
match_rules: dict
approval_config: dict
class PolicyEngine:
"""审批策略引擎"""
def __init__(self, policies: list[ApprovalPolicy]):
# 按优先级排序,数字小的先匹配
self.policies = sorted(policies, key=lambda p: p.priority)
def match(self, request: ApprovalRequest) -> Optional[ApprovalPolicy]:
"""找到第一个匹配的策略"""
for policy in self.policies:
if self._matches(policy, request):
return policy
return None
def _matches(self, policy: ApprovalPolicy, req: ApprovalRequest) -> bool:
rules = policy.match_rules
# 命令模式匹配
if "command_patterns" in rules:
cmd_match = any(
fnmatch.fnmatch(req.command, pat)
for pat in rules["command_patterns"]
)
if not cmd_match:
return False
# 文件模式匹配
if "file_patterns" in rules:
file_match = any(
fnmatch.fnmatch(f, pat)
for f in req.target_files
for pat in rules["file_patterns"]
)
if not file_match:
return False
# 风险等级匹配
if "risk_level" in rules:
if req.risk_level != rules["risk_level"]:
return False
# 目标环境匹配
if "target_env" in rules:
if req.target_env != rules["target_env"]:
return False
return True3.3 Scope Override:同一任务的命令聚合
审批策略中有一个容易忽略的字段 scope:
- per-command:每条命令独立审批。适用于数据库 DDL——每条 ALTER 都应该单独确认
- per-task:同一任务内的同类命令共享审批。适用于部署场景——审批一次 deploy,后续的 health check、rollback 准备不需要重复审批
class ApprovalSessionManager:
"""管理审批会话,支持 scope override"""
def __init__(self, store):
self.store = store
def get_or_create_session(
self, request: ApprovalRequest, policy: ApprovalPolicy
) -> str:
"""获取或创建审批会话,返回 session_id"""
scope = policy.approval_config.get("scope", "per-command")
if scope == "per-task":
# 查找同一任务是否已有同策略的审批会话
existing = self.store.find_session(
task_id=request.task_id,
policy_name=policy.name,
status="active"
)
if existing:
return existing.session_id
# 创建新会话
return self.store.create_session(
task_id=request.task_id,
policy_name=policy.name,
command=request.command,
requested_by=request.requested_by
)四、审批事件与权限模型
4.1 审批事件数据模型
每一次审批请求都会生成一个审批事件,记录完整的决策链路:
CREATE TABLE approval_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID NOT NULL, -- 审批会话 ID
task_id UUID NOT NULL, -- 关联任务 ID
policy_name VARCHAR(64) NOT NULL, -- 匹配的策略名称
-- 审批内容
command TEXT NOT NULL, -- 请求执行的命令
risk_level VARCHAR(16) NOT NULL, -- 风险等级
context JSONB, -- 附加上下文(diff、影响范围等)
-- 审批流程
status VARCHAR(16) NOT NULL DEFAULT 'pending',
-- pending → approved / rejected / timeout / escalated
requested_by VARCHAR(64) NOT NULL, -- 请求人(Agent ID 或用户)
approved_by VARCHAR(64), -- 审批人
approved_at TIMESTAMPTZ, -- 审批时间
reason TEXT, -- 审批意见(批准理由或拒绝原因)
-- 超时控制
timeout_at TIMESTAMPTZ NOT NULL, -- 超时时间
timeout_action VARCHAR(16) NOT NULL DEFAULT 'reject',
-- 审计
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 查询索引
CREATE INDEX idx_approval_task ON approval_events (task_id, status);
CREATE INDEX idx_approval_pending ON approval_events (status, timeout_at)
WHERE status = 'pending';4.2 权限角色模型
审批系统使用基于角色的权限控制(RBAC),角色和权限通过配置文件管理:
# approval-roles.yaml
roles:
tech-lead:
can_approve:
- "database-ddl"
- "git-dangerous"
- "default-high-risk"
can_reject: true
can_escalate: true
dba:
can_approve:
- "database-ddl"
can_reject: true
can_escalate: false
devops:
can_approve:
- "production-deploy"
can_reject: true
can_escalate: true
sre:
can_approve:
- "production-deploy"
can_reject: true
can_escalate: true
security:
can_approve:
- "sensitive-files"
can_reject: true
can_escalate: true
admin:
can_approve: ["*"] # 可以审批所有策略
can_reject: true
can_escalate: true4.3 审批 API
审批人通过 REST API 或 Webhook 进行审批操作:
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
app = FastAPI()
class ApprovalDecision(BaseModel):
decision: str # "approved" | "rejected" | "escalated"
approver: str # 审批人 ID
reason: str = "" # 审批意见
@app.post("/api/approvals/{event_id}/decide")
async def submit_decision(event_id: str, decision: ApprovalDecision):
"""提交审批决策"""
event = await store.get_event(event_id)
if not event:
raise HTTPException(404, "审批事件不存在")
if event.status != "pending":
raise HTTPException(409, f"审批已结束,当前状态: {event.status}")
# 验证审批人权限
policy = await get_policy(event.policy_name)
role = await get_user_role(decision.approver)
if not can_approve(role, policy.name):
raise HTTPException(403, "无权审批此策略")
# 更新审批事件
await store.update_event(event_id, {
"status": decision.decision,
"approved_by": decision.approver,
"approved_at": "NOW()",
"reason": decision.reason,
})
# 通知执行器恢复或终止
await notify_executor(event.task_id, decision.decision)
return {"status": decision.decision, "event_id": event_id}五、超时处理与审批后执行上下文
5.1 超时处理策略
审批请求可能长时间无人响应。每个策略都配置了 timeout_action:
| 超时动作 | 说明 | 适用场景 |
|---|---|---|
reject |
超时自动拒绝 | 数据库 DDL、Git 操作 |
escalate |
升级到更高级别审批人 | 安全敏感文件 |
approve |
超时自动批准(慎用) | 低风险但必须有人知晓的操作 |
notify_and_wait |
再次通知并继续等待 | 关键部署 |
超时检测由一个后台定时任务驱动:
import asyncio
from datetime import datetime, timezone
class ApprovalTimeoutWatcher:
"""审批超时检测器"""
def __init__(self, store, check_interval: int = 30):
self.store = store
self.check_interval = check_interval
async def run(self):
"""后台循环检测超时"""
while True:
now = datetime.now(timezone.utc)
expired = await self.store.find_expired(now)
for event in expired:
await self._handle_timeout(event)
await asyncio.sleep(self.check_interval)
async def _handle_timeout(self, event):
action = event.timeout_action
if action == "reject":
await self.store.update_event(event.id, {
"status": "timeout",
"reason": f"审批超时({event.timeout_at}),自动拒绝"
})
await notify_executor(event.task_id, "rejected")
await notify_requester(event, "timeout_reject")
elif action == "escalate":
# 升级到 admin 角色
await self.store.update_event(event.id, {
"status": "pending",
"policy_name": "escalated-to-admin",
"timeout_at": "NOW() + interval '30 minutes'"
})
await notify_role("admin", event, "escalated")
elif action == "notify_and_wait":
# 再次通知,延长超时
await self.store.update_event(event.id, {
"timeout_at": "NOW() + interval '30 minutes'"
})
await notify_approvers(event, "reminder")5.2 审批后的执行上下文恢复
审批通过后,执行器需要从暂停状态恢复执行。这里有一个关键设计:审批结果要注入 Agent 的执行上下文。
class ExecutorResumer:
"""审批后恢复执行器"""
async def resume(self, task_id: str, decision: str, context: dict):
"""恢复被审批暂停的任务"""
executor = await get_executor(task_id)
if decision == "approved":
# 将审批意见注入 Agent 上下文
approval_context = {
"type": "approval_result",
"status": "approved",
"approver": context["approver"],
"reason": context.get("reason", ""),
"additional_instructions": context.get("instructions", "")
}
# 如果审批人有附加指令,Agent 需要遵守
# 例如:"索引创建请在业务低峰期执行" → Agent 应等待或调整执行策略
await executor.inject_context(approval_context)
await executor.resume_command()
elif decision == "rejected":
# 拒绝时,Agent 需要知道原因并调整策略
rejection_context = {
"type": "approval_result",
"status": "rejected",
"reason": context.get("reason", "审批被拒绝"),
"suggestion": context.get("suggestion", "")
}
await executor.inject_context(rejection_context)
# Agent 收到拒绝后应该:
# 1. 跳过当前命令,尝试替代方案
# 2. 如果没有替代方案,标记任务失败
await executor.skip_and_continue()5.3 审批事件通知
审批系统需要多种通知渠道来确保审批人能及时响应:
# notification-channels.yaml
notifications:
pending_approval:
channels:
- type: slack
target: "#approvals"
template: |
🔔 Agent 审批请求
任务: {{task_title}}
命令: {{command}}
风险: {{risk_level}}
策略: {{policy_name}}
[批准]({{approve_url}}) | [拒绝]({{reject_url}})
- type: email
target: "{{approver_emails}}"
subject: "[Agent 审批] {{policy_name}} - {{task_title}}"
- type: webhook
url: "https://internal-tools.example.com/agent-approvals"
method: POST
timeout_warning:
# 超时前 5 分钟发提醒
trigger_before_secs: 300
channels:
- type: slack
target: "{{approver_dm}}"
template: "⏰ 审批即将超时:{{command}}"六、真实经验与踩坑
6.1 审批风暴:批量任务导致审批人崩溃
场景:Agent 接到一个大型重构任务,涉及 30 个文件的修改,每个文件都匹配了 sensitive-files 策略。审批人在 10 分钟内收到了 30 条审批通知。
问题:审批人不可能逐条审查 30 个命令,要么全部批准(失去审批意义),要么全部拒绝(阻塞工作)。
解决:引入 scope: per-task 聚合 + 批量审批 UI。同一任务内同策略的命令自动聚合为一个审批请求,审批人看到的是"Agent 要对 auth 模块做 12 处修改,这是完整的 diff",而不是 12 条独立的命令。
# 批量审批:审批人看到聚合后的完整变更
{
"session_id": "sess-001",
"task_title": "重构认证模块",
"policy": "sensitive-files",
"commands_count": 12,
"files_affected": ["auth/login.py", "auth/session.py", ...],
"full_diff": "...(完整 diff)...",
"decision": "approved",
"reason": "变更合理,测试覆盖充分"
}6.2 审批上下文缺失导致 Agent 反复请求
场景:DBA 拒绝了 Agent 的 DROP INDEX 请求,Agent 收到拒绝后换了一种写法 ALTER TABLE ... DROP CONSTRAINT,再次触发审批。
问题:Agent 不知道拒绝的原因,只知道自己被拒了,于是尝试绕过。
解决:拒绝时必须附带结构化的拒绝原因和建议,Agent 会将拒绝原因作为约束条件继续工作。
{
"decision": "rejected",
"reason": "该索引被报表服务依赖,删除会导致报表超时",
"suggestion": "请先添加替代索引后再删除旧索引,建议索引: idx_user_date_status",
"constraints": [
"不要删除 idx_old_status 索引",
"新索引必须覆盖 user_id + created_at 的查询"
]
}6.3 超时自动拒绝在低峰期不适用
场景:Agent 在凌晨 2 点触发了一个数据库迁移审批,超时设置 30 分钟。没有人会在凌晨审批,结果自动拒绝了。第二天开发者手动重新提交。
问题:固定超时 + reject 策略在非工作时间不友好。
解决:增加"工作时间感知"——超时计时只在工作时间内累计。凌晨 2 点的请求,超时从早上 9 点开始计算。
from datetime import time
def effective_timeout(request_time, timeout_secs, work_hours=(time(9, 0), time(18, 0))):
"""计算考虑工作时间的有效超时时间"""
current = request_time.time()
start, end = work_hours
if current < start:
# 工作开始前,从工作开始时间算起
delta = (datetime.combine(request_time.date(), start) - request_time).total_seconds()
return timeout_secs + delta
elif current > end:
# 工作结束后,从明天工作开始时间算起
tomorrow = request_time.date() + timedelta(days=1)
delta = (datetime.combine(tomorrow, start) - request_time).total_seconds()
return timeout_secs + delta
else:
return timeout_secs七、参数说明表
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
policy.priority |
Integer | 100 |
策略优先级,数字越小越先匹配 |
policy.match.command_patterns |
List | — | 命令通配符列表,支持 * 和 ? |
policy.match.file_patterns |
List | — | 文件路径通配符列表 |
policy.match.risk_level |
String | — | 匹配的风险等级 |
policy.match.target_env |
String | — | 匹配的目标环境 |
approval.required_roles |
List | — | 可以审批的角色列表 |
approval.min_approvers |
Integer | 1 |
最少审批人数 |
approval.timeout_secs |
Integer | 900 |
审批超时秒数 |
approval.timeout_action |
String | reject |
超时动作:reject / escalate / approve / notify_and_wait |
approval.scope |
String | per-command |
审批范围:per-command / per-task |
notification.channels |
List | — | 通知渠道配置(slack / email / webhook) |
work_hours.start |
Time | 09:00 |
工作时间开始,非工作时间的超时暂停计算 |
work_hours.end |
Time | 18:00 |
工作时间结束 |
八、落地检查清单
- 策略覆盖完整:所有 HIGH 级别命令都有对应的审批策略,不存在"漏网之鱼"
- 策略优先级正确:高优先级策略(DDL、部署)先于默认策略匹配
- 审批暂停:Agent 发起需审批的命令后,执行器暂停等待,不会跳过继续执行后续命令
- 审批恢复:审批通过后,执行器从暂停位置恢复执行,且审批意见已注入 Agent 上下文
- 拒绝处理:审批拒绝后,Agent 收到结构化拒绝原因和建议约束,不会尝试绕过
- 超时机制:超时检测器正常运行,超时动作按策略执行(reject / escalate)
- 工作时间感知:非工作时间的审批请求,超时计时从下一个工作日开始
- 批量聚合:同一任务内同策略的命令可以聚合为一个审批请求
- 通知到达:审批通知通过 Slack / Email / Webhook 等渠道送达审批人
- 权限控制:只有拥有对应角色的用户可以审批,越权操作返回 403
- 审计完整:每个审批事件记录完整的请求、策略、决策、审批人、时间和原因
- 回放能力:可以从审批事件表回溯任意任务的审批历史