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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

至少应暴露以下部分：

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

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

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

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

## 验证必须区分阻碍和警告

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

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

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

```json
{
  "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`。本地测试中预览看起来没有问题，但它仍可能消耗标识符、产生事件流量，或在生产环境留下残留数据。

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

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

```text
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 语义需要明确契约

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

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

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

```http
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`，也可以保存计划并返回标识符。不可解析的令牌能避免客户端把计划当成可编辑的授权。已存储的计划更方便检查大型影响集合，也更容易撤销批准。只要执行重新检查正确的条件，两种方式都可行。

响应可能包含：

```json
{
  "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 和预期版本，这样服务器就无需协调请求的第二份可变副本。

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

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

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

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

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

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

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

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

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

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

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

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

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

```json
{
  "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。
