在AI Agent系统中,人类审批不应只是一个布尔标志,而应绑定到「目标地址、负载内容和执行条件」这一精确操作范围,防止AI擅自扩展已审批的操作。
用户批准了一封客服邮件。在工作人员发送之前,AI 智能体重写了正文并添加了另一位收件人。应用程序仍然显示 Approved = true。
人类审批是执行边界处的一种授权控制。它必须将决策绑定到确切的操作:目标地址、负载和执行条件。该标志并不能说明用户接受的是哪个版本。
我会在添加审批按钮之前设计这套协议。一旦审批者点击了它,工作人员需要有足够的存储信息来完成工作,而无需再问模型用户是什么意思。
假设一个客服助手可以读取工单、起草回复并发送。公司政策要求客服主管审查外发回复,因为他们可以代表公司做出承诺。应用程序强制执行这一要求。
授权首先确定请求者可以提议哪些操作,以及可以对哪些资源进行操作。然后应用程序策略选择 Allow、Deny 或 RequireApproval。审批是授权的一部分:客服主管可以授权一项请求者无法单独发送的回复。该决策必须保持在操作的允许范围内,不能覆盖硬策略拒绝。此示例使用一名审批者。需要多名审批者的工作流需要单独的决策和聚合策略结果。
更广泛的强制执行设计在《AI 功能信任边界》中有详细说明。本文追踪的是一个操作经过审查和调度的完整过程。
有用的人类决策是:这个回复是否应该发给这个客户。常规的授权查询不需要中断。审批提示涵盖了这种区别,尽管读取导出受保护数据的操作仍然需要更严格的控制。
助手应该在请求之前完成允许的准备工作和验证。这意味着要提供包含已解析收件人和预期附件的回复。在回复尚不存在时就问"我可以发送回复吗?"会使关键细节悬而未决。
将提案存储为不可变的操作修订版本,并将其用于审查屏幕和执行器。这样应用程序可以将提交给提供商的内容和目标与审批者批准的内容进行对比检查。下游处理和收件人的邮件客户端仍然可以更改已投递邮件或其呈现形式。
对于一个假设的工单,审批者可能看到:
这些字段来自存储的操作。生成的解释可以帮助审批者理解它们,但绝不能替换或隐藏它们。审查屏幕需要安全的文本渲染和对附件的访问。
将附件存储为不可变版本或受保护快照。在审查前完成可能更改已批准详情的转换。后续转换(如提供商页脚或跟踪链接重写)需要在审批范围内有固定行为,或在文档中说明其在范围外的处理方式。仅凭文件名无法将审批绑定到文件内容。
显示实际地址以及显示名称。标题收件人和 SMTP 或 API 信封可能不同,因此审查必须涵盖两者。如果审批取决于各个收件人,请在审查前将可变组或别名解析为固定目标。仅批准一个组地址无法确定其成员后续会是哪些人。
这遵循了 OWASP 交易授权速查表中的交易审查原则:显示重要操作数据,保护其免受修改,并在服务器端强制执行决策。
审批记录需要一个稳定的 ID 和对不可变操作修订版本的引用。记录请求者和租户、策略版本、审批者要求和决策。将 DecideBy 和 ExecuteBy 记录为绝对 UTC 时间戳。显示审批者的时区和偏移量。保持逻辑操作 ID 在多次调度该修订版本的尝试中稳定。
执行器部署必须保留存储操作的意义。当无法保证向后兼容解释时,记录操作类型和模式或处理程序契约版本。审查客户端提交标识符,服务器加载存储的修订版本,而不是接受替换负载。
模型提供候选内容。认证上下文提供身份和租户范围。应用程序策略设置审批要求。审查请求仅识别存储的操作和决策:
public enum ReviewDecision
{
Approve,
Reject
}
public sealed record SubmitReview(
Guid ApprovalId,
long ExpectedOperationRevision,
ReviewDecision Decision);
此 DTO 勾勒了请求。端点必须验证决策,在认证租户内加载记录,并授权审批者,包括任何禁止自我审批的规则。客户端既不提供审批者身份,也不提供替换的工具参数。
仅在记录处于待定状态、操作修订版本匹配且服务器时间严格在 DecideBy 之前时接受决策。在写入时强制执行此规则,并使用单独的审批记录并发控制。ExpectedOperationRevision 标识有效载荷,而不是工单版本、策略版本或并发令牌。并发批准和拒绝提交最多只能提交一个决策。知道审批 ID 并不授予使用它的权限。
如果审批者编辑了正文,请保存新的操作修订版本并取代旧的审批请求。呈现最终编辑的有效载荷供确认。明确的"保存并批准"流程仅在将决策绑定到审批者所见的精确编辑修订版本时才能合并这些步骤。
批准后,工作人员直接加载该修订版本。要求模型从对话历史重构邮件会创建另一个提案。
审批者可能在重启后回复。持久化待定请求,返回控制权而不是保持模型请求开放,并提供用于检查其状态的页面或端点。为恢复的执行提供自己的运行时预算。
CommitForDispatch 是将一个逻辑操作 admit 到调度过程的持久化业务转换。工作人员稍后认领单个尝试。这使得工作人员拥有已通过此关卡授权的工作的所有权:
决策:
Pending -> Approved | Rejected | DecisionExpired | Superseded | Withdrawn
CommitForDispatch 要求:
decision == Approved
且操作修订版本仍然当前且完整
且服务器时间 < ExecuteBy
且当前策略和资源条件允许执行
批准决策即使在 ExecuteBy 已过或新提案取代其操作修订版本后仍保留在历史记录中。如果操作尚未被 admit,它将无法再通过此关卡。记录仍然显示谁批准了它。DecisionExpired 表示没有人及时做出决定。Withdrawn 表示授权请求者在未提交替换内容的情况下撤回了待定审查。
撤回待定审查可防止后续 admit。如果撤回终止了提议的操作,其逻辑状态变为 Cancelled。审查仍保持 Withdrawn 状态以表明没有审批者做出决定。
截止时间可以不同:10:14 批准可能允许在 10:19 admit,但不允许在 10:20 admit。在每个转换时同步检查各自的截止时间。延迟的清理作业不得延长任何一个截止时间。
ExecuteBy 限制的是 admit,而不是提供商接收请求的时间。在 10:19 admit 的操作可能在 10:20 之后仍处于排队中。如果新鲜度必须保持到提供商交接,则强制执行单独的截止时间,并在第一次提供商调用开始之前进行相关的新鲜度检查。如果没有人批准,回复将保持未发送状态。提醒无法授权它。
10:00,客服主管批准了工单版本 17 的回复。10:02,另一名员工更正了客户的电子邮件地址。10:03,工作人员获取了已批准的发送。
存储的决策仍然是有用的历史记录。工作人员现在必须确定它是否可以在批准的条件下执行。
在 admit 之前,应用程序应该:
加载已批准的操作并检查其完整性、修订版本、决策和 ExecuteBy 截止时间。
通过受信任的租户路径重新加载受影响的资源,并应用配置的请求者和审批者授权规则。
比较相关资源版本并评估当前策略,包括它是否仍接受记录的批准。
将 CommitForDispatch 与共享其事务存储的批准和相关资源检查原子化地持久化。
在此示例中,相关的工单更改会阻止旧的发送并需要刷新提案。不要在旧地址的批准下静默发送到新地址。如果您选择比整行工单更窄的版本检查,请文档化哪些字段会使审批失效,并确保对其的更改会推进该版本。
对于这个支持工作流,我要求请求者和审核者在准入提交之前都保持其相关授权。另一个工作流可能将请求和审批接受为历史行为,然后在组织或服务授权下执行。每个身份都需要一条明确的策略规则,执行者需要获得行动授权。当前的硬性拒绝仍然会停止执行。
当审批和相关资源状态共享一个事务存储时,准入事务可以条件性地检查两者。当业务影响和结果也存在于该事务中时,它们可以一起提交。EF Core 并发令牌使更新以原始版本为条件,并报告冲突。它们不会将另一个服务的状态拉入事务。盲目重试冲突并使用新值会丢弃已批准的先决条件。
如果工单属于另一个服务,使用其条件操作或预留支持(如果有的话)。本地准入事务无法原子性地验证那个远程工单或独立的身份服务。没有协调机制,时间检查到时间使用窗口仍然存在。记录这个风险,如果策略无法容忍,则限制或阻止操作。
发送邮件是外部操作,即使工单和审批共享数据库。在这个例子中,CommitForDispatch 将调度修复版本的业务决策提交。通过该点协调冲突的本地更改。如果策略必须允许在提供商交接之前取消,调度路径需要一个额外的协调取消检查。单独的队列记录无法提供这种保证。
一旦调度开始,撤销审批无法可靠地撤回邮件。UI 应该报告实际的执行状态,并仅提供系统仍可兑现的取消或恢复操作。
审批不会使重试安全
两次点击"批准"不得创建两个逻辑调度。记录一个决策,准入操作一次,当请求重复时返回其现有状态。每个工作单元尝试在该逻辑操作下获得一个独立的 ID。
逻辑操作:
NotStarted --CommitForDispatch--> InProgress
NotStarted -> Blocked
NotStarted -> Cancelled
InProgress -> Succeeded | Failed | OutcomeUnknown
InProgress -> Blocked | Cancelled(在第一次提供商交接之前)
OutcomeUnknown -> Succeeded | Failed(新证据)
OutcomeUnknown -> InProgress(允许安全重试)
Succeeded 表示提供商已确认接受已批准的请求。邮箱投递和退信跟踪是独立的。SMTP 在 RFC 5321 第 6.1 节中也区分了接受与后续投递或失败通知。Failed 表示失败已确定,工作流将不再尝试。明确失败的网络尝试本身不会使逻辑操作失败。当逻辑操作仍处于 InProgress 时,可以运行新的尝试。
Blocked 表示策略、新鲜度或先决条件阻止了第一次提供商调用。Cancelled 记录了在交接之前成功完成的支持取消。两种转换都必须与调度协调,以确保没有工作单元仍可启动该调用。一旦调用可能被接受,策略变更或取消请求无法建立任一结果。将未解决的调用保持在 OutcomeUnknown 直到证据确定发生了什么。
假设提供商接受了请求,而工作单元在保存响应之前崩溃。重启后,本地状态无法确定接受情况。盲目调度另一次尝试可能会再次提交邮件。工作单元所有权无法解决这种不确定性:过期的租约不是提供商调用已停止的证明。
恢复需要持久化的提供商证据或幂等性契约,使另一次尝试安全。在提供商的范围和保留窗口内,重用逻辑操作的幂等性密钥和相同 payload。新的尝试 ID 不得成为新密钥。发件箱无法提供提供商缺少的去重功能。
保存的提供商引用或可查询的客户端操作 ID 可以确定发生了什么。丢失的响应也可能包含了唯一可用的引用。没有证据或安全的幂等性,保持 OutcomeUnknown 并停止自动重试。调查可能永远无法解决它。
如果策略允许,ExecuteBy 之前的准入可以在之后允许安全恢复相同的逻辑操作。当前的硬性拒绝可以阻止未来的尝试,但无法撤销提供商可能已接受的调用,也无法将 OutcomeUnknown 更改为 Blocked。
《重试不是恢复策略》更详细地介绍了尝试恢复。在这个例子中,审批记录确立了调度回复的权限。它无法告诉重启的工作单元丢失响应的那个请求是否被提供商接受。
测试点击之后会发生什么
我会直接测试审批服务和执行器,测试中不包含模型。提示变更不应影响这些结果中的任何一个:
为了诊断,记录审批、逻辑操作和尝试 ID 以及修订版本、策略版本、身份、截止日期和状态转换。敏感 payload 保存在受保护存储中并保持适当保留期,而不是将邮件正文复制到通用应用日志中。
还要检查审核体验。跟踪等待时间、编辑和拒绝,以及错过 ExecuteBy 的审批。这些观察有助于识别一个没有人能跟上的队列。单独的审批率几乎无法说明是否有人有足够的上下文来审核该操作。
人工审批何时有用
当授权人员可以在后果发生前评估具体操作时使用。技术支持负责人可以检查承诺的更换是否合理,以及回复内容是否表达了公司的真实意图。给审核者足够的上下文和时间来做决定,并定义当没有人响应时应用程序的行为。
不要将其添加到已有明确自动执行策略覆盖的日常工作中。不要为策略禁止的操作提供审批按钮。如果审核者无法检查 payload 或理解其效果,请缩小操作范围或将执行保持在已建立的手动流程中。
对于现有的 AI 智能体功能,从一个有影响的工具开始。写下一个人将审核的确切操作,什么变更会使该决策失效,以及执行者如何证明它正在执行已批准的修订。然后测试更改的 payload 和工作单元崩溃:第一个必须阻止旧审批,第二个必须保留足够的状态来调查或安全地恢复原始操作。
Human-in-the-Loop Agents: When the AI Must Ask Before Acting
Use approval for side effects, not for every tool call
Separate prompts from authorization
OWASP: Transaction Authorization Cheat Sheet
Microsoft Learn: Handling concurrency conflicts in EF Core
IETF: RFC 5321, section 6.1: Reliable delivery and replies by email