阅读需 8 分钟

让 Dry-run 端点约束 AI 编程代理

Dry-run 端点让 AI 编程代理在执行写入前预览计划中的变更、受影响资源和验证失败。

让 Dry-run 端点约束 AI 编程代理

AI 编程代理不应该通过执行变更,才发现这个变更是否有效。这是懒惰的 API 设计,而自动化会迅速放大它的代价。代理重试、分支和转向下一个任务的速度,可能比操作人员还原一次意外的权限修改、部分迁移或范围错误的删除快得多。

只有当预览操作能够足够准确地预测一次具体执行,让人或代理据此决定是否继续时,它才真正值得存在。只返回「有效」的响应不是计划。遗漏级联更新的差异,比没有差异更糟,因为它会制造虚假的信心。

实用的设计目标很简单:提交拟议的写入操作,根据当前状态和正常业务规则进行评估,返回计划中的影响和失败原因,然后让执行拒绝过期或已被修改的计划。这比添加 dryRun=true 需要更多考虑,但也能让代理在请求人批准前,先修复无效请求。

预览必须描述确切的写入操作

Dry-run 端点必须接受与执行相同的有效意图,并计算该确切意图产生的影响。如果 POST /memberships 可以授予角色、发送邀请、将成员加入计费组并写入审计记录,那么预览就需要报告执行将产生的每一项影响。

团队经常发布一个只检查 JSON 结构和必填字段的「验证」端点。它有自己的用途,但并不能预览写入操作。它无法告诉调用方,请求的角色与现有角色冲突、目标账户已被暂停,或者邀请会消耗有限的席位。如果它只做这些检查,就称它为验证端点。

这个区别很重要,因为代理会把成功调用当作证据。只有验证结果、随后再执行的流程,会让代理看不到依赖当前状态的决策部分。完整预览则会同时评估请求和当前环境。

在设计预览前,先为每个可写操作用一句话写下执行契约:

给定此输入和观察到的目标版本,执行将创建、更新、删除或触发以下明确列出的影响。

这句话会暴露模糊行为。「更新项目设置」范围太宽。「将 retention_days 从 30 改为 14,为 18 个活跃项目重新计算过期时间,并拒绝处于法律保留状态的项目」才给了预览一个可测试、可返回的目标。

好的预览会保留操作本身的语义。不要因为真实列表返回起来麻烦,就把批量删除变成一个模糊的数量。服务能够确定实际资源时,不要使用「可能影响」。如果资源集合太大,无法直接返回,就返回总数、有上限的样本,以及让调用方在执行前查看完整集合的游标或报告引用。

计划需要身份、范围和后果

不知道哪些记录会变、如何变化,人就无法判断「12 条记录将发生变化」意味着什么。计划中的操作应该以程序和人都能检查的形式,明确其输入、目标范围和后果。

对于单个资源的更新,字段级差异通常很有效。部署可能需要列出镜像、环境、配置版本、重启行为和健康检查。计费变更可能需要旧费用、新费用、生效日期,以及客户是否会收到通知。输出应匹配领域,而不要强行让每种操作都变成 JSON Patch 数组。

至少应暴露以下部分:

  • 操作名称和明确的预览状态。
  • 每个受影响资源的稳定标识符,以及服务支持版本控制时的资源版本。
  • 每项有意义变更的原值和拟议值。
  • 次级影响,例如任务、通知、访问权限变化或计算出的费用。
  • 警告、执行阻碍,以及可能改变结果的假设。

「有意义」需要判断。原始数据库时间戳很少能帮助审批人。新分配的所有者、扩大的群组成员范围或计划删除的资源则非常重要。应先展示语义层面的结果,需要时再提供更底层的细节。

预览还需要区分直接影响和派生影响。假设代理降低了团队的存储配额。直接变化是一个配额字段,派生结果可能是冻结三个现有项目的上传功能。把这个结果埋在通用警告下,会让操作看起来比实际更安全。应将其放在单独的 effects 数组中,并说明原因。

对不确定性也要同样精确。预览可以说明执行将查询外部税务服务,或将工作安排到稍后运行。如果服务尚未计算出最终税额,就不应声称税额已经确定。使用假设记录,说明依赖项,以及没有它时执行是否仍能继续。

验证必须区分阻碍和警告

预览应该明确告诉代理什么会阻止执行、什么值得审查,以及什么只是提供背景信息。混淆这些类别,必然会导致糟糕的重试和审批疲劳。

阻碍意味着在已评估的条件下,服务会拒绝执行。代理应该修复输入、获得缺失的权限,或停止操作。警告意味着执行可以继续,但合理的操作人员可能希望检查其后果。背景信息只提供信息,不暗示存在危险。

返回结构化错误,不要让代理解析散文式文字。下面这个结构刻意保持普通:

{
  "mode": "preview",
  "executable": false,
  "validation": [
    {
      "severity": "error",
      "code": "version_conflict",
      "path": "/if_match",
      "message": "Project prj_184 is at revision 73, not revision 71.",
      "blocks_execution": true,
      "repair": "Fetch the current project and create a new preview."
    },
    {
      "severity": "warning",
      "code": "member_count_change",
      "message": "The group will gain 42 members through nested groups.",
      "blocks_execution": false
    }
  ]
}

稳定代码让代理能够选择应对方式。遇到 version_conflict,它可以获取当前版本;遇到 legal_hold_active,它不能负责任地自行编造修复方案。message 是给审查操作的人看的,两者都应保留。

不要把每个意外情况都标成警告。凡是总要有人修改请求的警告,都应该是错误。反过来,也不要因为 API 发现了一个不寻常但被允许的情况,就阻止执行。团队害怕遗漏问题,常常把所有警告都变成阻碍,结果代理提交的预览永远无法完成,除非有人手动清理。这样的接口只是在做表面工作。

实用的判断很直接:如果执行收到相同输入并面对相同状态,它会运行吗?如果会,报告警告或背景信息。如果不会,报告错误。授权失败应与领域验证分开,因为它们说明的是不同问题,也需要不同的修复方式。

Dry run 不能在调用方不知情时写入

预览必须避免持久化外部影响,包括开发人员容易忽略的「维护性」操作。创建「临时」行、预留库存、增加用户可见的序列号、排入 webhook、发送邮件或更新最后访问时间,都会违背请求可以安全检查的预期。

这个问题会出现在成熟服务中,因为执行代码常常围绕便利性逐渐膨胀。创建处理程序可能一开始就分配标识符,在验证前写入待处理记录,并在事务提交前调用事件发布器。后来有人只在最终插入语句外包一层 if preview。本地测试中预览看起来没有问题,但它仍可能消耗标识符、产生事件流量,或在生产环境留下残留数据。

把预览执行视为应用服务中的独立模式,而不是控制器中的一个条件。这个模式可以调用共享的解析、授权、策略和计划生成函数,但必须通过这样的接口处理写入和外部发送:要么生成拟议影响,要么让请求失败。

实用的实现边界可以这样划分:

parse request
  -\u003e authorize caller
  -\u003e load consistent current state
  -\u003e validate business rules
  -\u003e build plan
  -\u003e preview: return plan
  -\u003e execute: apply plan in a transaction, then publish committed effects

顺序很重要。如果数据库支持事务,就应根据执行所依赖的同一批读取来生成计划。如果某个依赖无法参与事务,就把待发生的交互作为明确影响报告出来,并为失败设计补偿操作。假装外部调用具有事务性,并不会让它真的具有事务性。

审计记录也需要做出决定。你可能希望记录调用方请求过预览,这很合理,但应将该事件写入明确独立的审计路径,并确保它不会触发为已完成变更设计的工作流。不要把「已预览」和「已授予权限」放在一起,再期待下游消费者自行推断区别。

测试时要验证没有发生什么,而不只是验证输出。预览请求前后,应确认相关表、出站队列、对象存储、邮件测试接收器和下游 webhook 接收器都没有变化。单元测试很少能发现这些问题,围绕一次性环境编写的集成测试则可以。

HTTP 语义需要明确契约

限制高影响密钥
每次使用选定的 API 和 SSH 密钥时,都要求 Touch ID 或点击确认。

HTTP 没有通用的 dry-run 方法,假装存在这样的方法会造成互操作问题。RFC 9110 将 GETHEADOPTIONSTRACE 定义为安全方法,意思是客户端不会请求状态变更。它并没有说带查询参数的 POST 是安全的,也没有把 dryRun 定义为标准请求控制项。

因此,端点设计者必须让模式在请求和响应中都清晰可见。复杂写入的规划需要请求体,也可能需要成本较高的评估,所以使用 POST 通常仍然合适。关键是客户端、日志和人都能区分预览与执行,而不必猜测。

对于简单操作,明确的请求体字段易于阅读,也不容易丢失:

POST /v1/projects/prj_184/memberships/plan
Content-Type: application/json

{
  "subject_id": "usr_92",
  "role": "admin",
  "if_match": "73"
}

当规划拥有自己的输出、生命周期或权限时,专门的 /plan 端点很合适。它也能避免查询标志带来的常见失败:生成的客户端遗漏标志,代理在缓存配置中忽略标志,或者调用方复制 URL 时出错,最终执行了写入。如果选择带有 mode 字段的单一端点,那么对于意外执行代价很高的操作,应拒绝缺失或未知的值。

返回的响应类型不能让人误认为它就是已经执行的资源。即使附加了 preview: true 字段,用资源形状的响应体返回 201 Created 仍然是糟糕的预览响应。即时计划使用 200 OK,只有在规划本身异步运行时才使用 202 Accepted。在响应体中加入 mode: "preview",如果 API 使用有类型的媒体类型,还应设置明确的内容类型。

除非你理解所有影响预览的输入,包括调用方身份和授权,否则不要缓存预览。最安全的默认值是 Cache-Control: no-store。过期计划不只是一张旧页面,它可能会引导代理执行一次现在已经影响不同资源集合的写入。

不要滥用 OPTIONS 来完成这项工作。RFC 9110 使用它描述通信选项,而不是用任意请求体模拟写入。把它强行用于此目的,会让库、安全控制以及期待普通 HTTP 行为的人感到困惑。

执行必须证明计划仍然有效

预览可能在执行前的间隔内失效。其他用户可能编辑了记录,定时任务可能已经运行,授权可能已经过期,或者代理在读取响应后修改了请求。这是典型的检查时与使用时之间的问题,令人安心的预览并不能消除它。

应将计划绑定到已评估的请求、读取过的资源版本、调用方身份和较短的过期时间。服务器可以返回经过签名的不透明 plan_token,也可以保存计划并返回标识符。不可解析的令牌能避免客户端把计划当成可编辑的授权。已存储的计划更方便检查大型影响集合,也更容易撤销批准。只要执行重新检查正确的条件,两种方式都可行。

响应可能包含:

{
  "mode": "preview",
  "plan_id": "plan_7f4c",
  "expires_at": "2025-06-18T14:05:00Z",
  "request_digest": "sha256:...",
  "read_revisions": [
    {"resource": "projects/prj_184", "revision": "73"}
  ],
  "executable": true
}

执行时,服务必须验证调用方、摘要、过期时间和资源版本。然后,它必须以原子方式应用已批准的计划,或者在写入事务中重新生成计划并与已批准的计划比较。如果无法保证两者等价,就应使用 plan_stale 拒绝请求,并要求重新预览。

不要允许代理先为一个主体预览请求,再用另一个主体的请求体执行同一个计划 ID。更好的做法是让执行只接受计划 ID 和预期版本,这样服务器就无需协调请求的第二份可变副本。

有些变更无法获得有意义的保证。发送消息的计划可能因为收件人地址片刻后发生变化而不再合适。调用第三方服务的计划可能依赖一个在调用前发生变化的价格。应在输出中说明这些情况,在不可逆操作前立即重新验证,并在差异重要时要求重新决策。

代理工作流需要在执行前明确停下

批准实际执行
在人类批准后,才允许代理发送预览中描述的 HTTP 或 SSH 操作。

代理应把预览视为决策依据,而不是自动运行写入的许可。它需要明确规则,知道什么时候可以执行、什么时候应该修复请求,以及什么时候必须把计划交给人。

最可靠的工作流包含四个动作:

  1. 在预览模式下提交拟议写入,并附带幂等引用和预期资源版本。
  2. 如果响应包含阻碍,就停止,然后只修复响应指出的字段,或者向人询问缺失的意图。
  3. 当操作越过团队的批准边界时,展示计划中的影响和警告。
  4. 只执行返回的计划,并在计划仍然有效时执行,然后将执行结果与预览分开记录。

批准应关注后果,而不是原始 JSON 转储。决定是否授予访问权限的人想看到主体、角色、通过群组展开触达的资源以及持续时间,而不应从满是 ID 的请求体中自行推断影响。

不要让代理为每个无害操作都预览,也不要为每个警告都请求批准。那样会产生一堆没人阅读的卡片。应在应用中定义有意义的边界:不可逆操作、访问权限变更、金钱相关操作、外部通信、大范围资源集合,以及服务标记为不确定的影响。代理可以在授予它的权限范围内,执行规模较小且容易理解的变更。

Sallyport 可以要求人在代理实际发出 HTTP 或 SSH 调用前做决定,而 API 的预览则为这个决定提供具体依据。两种控制解决的是不同问题:一个控制进程是否可以行动,另一个解释目标服务会做什么。

一次失败的批量变更说明为什么摘要不够

假设代理接到任务,要从生产支持群组中移除承包商。它找到一个匹配 37 个账户的过滤条件并提交预览。服务返回 count: 37valid: true,以及一条关于继承成员关系可能发生变化的通用说明。操作人员认为请求的结果很普通,于是批准了操作。

执行会移除这 37 个账户的直接成员关系。其中 4 个账户通过嵌套群组继续保留访问权限。另有 6 个账户因为服务同时移除了关联授权而失去独立的值班权限。通知任务向 37 个人发送访问权限已变更的消息。现在,操作人员必须弄清哪些影响是预期的,哪些影响被隐藏了,以及通知是否准确描述了实际访问状态。

从最狭义的角度看,这个预览在技术上没有说假话。它没有承诺过滤条件只找出了承包商。但它仍然是糟糕的接口,因为用户需要的是成员关系图和影响清单,服务返回的却只是一个数量。

更好的响应会按后果组织结果:

{
  "mode": "preview",
  "operation": "remove_group_members",
  "selected": 37,
  "effects": [
    {"type": "direct_membership_removed", "count": 37},
    {"type": "access_retained_via_nested_group", "subjects": ["usr_8", "usr_19", "usr_31", "usr_44"]},
    {"type": "on_call_entitlement_removed", "subjects": ["usr_2", "usr_7", "usr_11", "usr_24", "usr_29", "usr_35"]},
    {"type": "notification_queued", "count": 37}
  ],
  "validation": [
    {
      "severity": "warning",
      "code": "access_outcome_varies",
      "message": "Four selected subjects retain group-derived access."
    }
  ]
}

对于更大的批次,正确的响应可能包含可下载报告或分页详情。重点不是强迫人阅读数千行,而是在写入前让异常和不可逆的结果变得可见。

这个例子还揭示了一个常见的糟糕建议:「只为破坏性操作使用 dry run。」团队会重复这句话,是因为删除看起来危险,而预览需要工程投入。但权限授予、配置编辑或通知造成的影响范围,可能比删除更大。应根据后果和可逆性决定是否支持预览,而不是根据 HTTP 方法或数据库操作决定。

测试必须比较预览影响与实际执行影响

让批准匹配操作后果
普通调用使用每会话授权,不可逆写操作则要求逐调用批准。

如果测试只证明预览端点返回 200 响应,端点最终会逐渐失效。它的核心承诺是等价性:当状态和请求相同时,报告的影响必须与执行结果一致。

编写成对测试。设置一个测试固定数据,发起预览,捕获规范化计划,重置固定数据,执行相同意图,再将执行日志与预测的影响集合进行比较。忽略无法合理匹配的字段,例如服务器时间戳或生成的关联 ID。不要忽略创建的资源、变化的值、发布的事件、通知或出站调用。

属性测试有助于发现过滤条件和批量操作的问题。生成一组状态不同的资源,根据谓词请求预览,在全新副本中执行,然后断言选中集合和最终状态一致。这类测试能找出棘手情况,例如规划查询连接了一张表,而写入查询连接了另一张表。

专门保留一项测试来检查预览副作用。为邮件、webhook、队列和支付提供商使用虚拟适配器,只要预览模式调用它们,就让测试失败。然后至少针对真实持久化层运行一次集成测试,因为 ORM 刷新或触发器可能在应用代码看起来没有问题时仍然写入数据。

最后,专门测试过期计划。先预览一次变更,再通过另一个请求修改资源,然后执行旧计划。服务应该拒绝它。如果系统因为差异「看起来还算接近」就应用旧计划,最终一定会覆盖其他人的工作。

预览是 API 能力,不是跳过控制的借口

预览端点可以减少意外,但不能取代授权、并发检查、事务设计、幂等性、审计轨迹,或对需要审查的操作进行人工复核。没有权限的调用方不应通过探测预览来获得受保护资源的详细地图。重复执行请求的调用方也不应因为计划令牌有效,就产生两次相同的副作用。

从排练或生产环境中最让团队受伤的写入操作开始。列出每个直接和间接影响,实现一个能够报告这些影响的计划,并让执行拒绝过期计划。然后编写成对测试,证明预览与执行结果一致。如果你无法在写入运行前说明它会做什么,那么系统中真正危险的部分不是代理,而是 API。

常见问题

什么是 dry-run 端点?

Dry run 是一种评估拟议操作并返回预期结果的请求,同时不会改变目标系统。实用的响应应包含计划执行的操作、受影响的资源、差异或等价计划、验证结果,以及所采用的假设。

Dry run 和只读 API 调用是同一回事吗?

不是。读取请求只报告当前状态,而预览会计算某个具体写入操作的结果。如果代理想修改设置,预览必须评估这个确切设置及其依赖项,而不只是读取当前配置。

哪些代理操作需要预览端点?

在有影响的写操作前使用 dry run,例如部署、基础设施变更、权限变更、数据迁移、破坏性清理和外部通知。对于没有副作用的简单操作,例如创建一个隔离的草稿,不必为了形式添加预览。

预览请求需要授权吗?

预览应使用与执行相同的授权边界,但如果你有意允许用户只做规划而不写入,也可以使用独立权限。不要因为请求带有 dry-run 标记,就暴露敏感的当前状态数据、隐藏的资源名称或大范围的资源清单。

Dry-run 响应中的验证错误应该如何呈现?

返回包含稳定代码、JSON 路径、面向人的消息,以及标明执行是否会被阻止的字段的机器可读错误列表。代理需要结构化信息来修复请求,人则需要足够清晰的文字来判断拟议操作是否合理。

Dry run 仍然可能产生副作用吗?

只要端点避免所有持久化副作用,并把隐藏的准备性写入视为错误,dry run 就可以保持安全。应测试预留、时间戳更新、生成的记录、限流消耗、排队任务、邮件、webhook,以及可能意外触发下游工作的审计条目。

代理应该能够执行旧的预览吗?

可以,但执行必须绑定计划标识符、目标版本、过期时间和执行者身份。执行时重新计算计划,或者在这些输入发生变化时拒绝执行。缺少这些检查的已存储预览,只会给人造成虚假的安全感。

应该使用 dryRun 查询参数,还是单独的端点?

当操作足够复杂,值得拥有独立资源时,可以使用明确的字段,例如 "mode": "preview",或使用专门的预览路由。避免使用容易被库、缓存或代理规则悄悄丢弃的宽松查询参数。请求和响应都必须让预览状态一目了然。

干净的差异能保证操作成功吗?

不能。差异看起来干净,并不代表操作一定拥有权限、不会与当前记录冲突、不会超过限制,也不代表它使用的版本仍然有效。可信的预览必须使用与执行相同的规则和当前状态进行验证。

如果 API 不支持 dry run,Sallyport 能提供吗?

Sallyport 可以围绕代理实际发出的 HTTP 或 SSH 操作设置批准流程,但如果你希望人先检查拟议变更,目标服务仍然需要提供真实可靠的预览。网关无法推断 API 没有报告的领域影响。

Sallyport

Sallyport 替你的 AI 智能体执行 API 调用和 SSH 命令。密钥留在你 Mac 上的本地密钥库里;每次运行由你批准,每个操作都落入一份密封的审计日志。

© 2026 Sallyport · 依据 Apache-2.0 开源 · Oleg Sotnikov