# 如何诚实记录已取消的智能体操作

把每次已取消的智能体操作都标记为“失败”的审计日志是在撒谎。智能体可能只是停止了等待，但数据库行可能已经存在，部署可能已经开始，或者远程命令可能仍在连接消失后继续运行。

解决办法不是再增加一长串失败代码，而是建立一个结果模型，将网关观察到的情况与目标端实际发生的事情分开。调查人员需要区分：操作从未离开机器，目标端拒绝了操作，操作完成并返回了确认，还是操作的实际效果仍然未知。

## 调用方的结果不等于操作的结果

调用方只能看到操作的一小部分：提交工作并等待响应。操作本身会经过多个系统，而这些系统可能没有共同的时钟、相同的进程生命周期，也没有可靠的返回路径。当调用方取消、断开连接或达到截止时间时，它只知道自己的等待发生了什么，不一定知道远程效果的最终结果。

对于改变状态的工作，这一区分尤其重要。创建工单、发起退款、应用基础设施变更、删除对象、轮换访问令牌以及运行远程命令，都可能在响应丢失后留下持续存在的后果。如果日志因为智能体进程先退出就写入 `failed`，后续重试可能创建第二个工单、发起第二次退款，或者将破坏性命令运行两遍。

读取操作也需要同样诚实地记录，尽管风险不同。取消一次获取可能只会返回不完整的视图，智能体随后可能据此做出错误决定。它通常不会自行改变外部世界。但 POST、PATCH、DELETE 或远程 shell 命令可能会。

每条记录都要将以下三件事分开：

- **调用方处置结果：** 已完成、已取消、已断开连接或已超时。
- **分发证据：** 从未开始、已在本地开始、数据已交给传输层，或远程端已确认收到。
- **效果结果：** 无效果、成功、被拒绝、部分完成或效果未知。

团队常常因为单一状态列更方便，就把第一个字段和第三个字段合并。直到发生事故复盘，这种方便才会结束。那时，总得有人解释为什么“请求已取消”旁边会出现一个生产环境中明明存在的对象。

实际规则很直接：只有证据排除了执行的可能性，才能写入 **无效果**。只要操作仍有可能执行，而你又没有可信结果，就写入 **效果未知**。未知不是日志中令人尴尬的缺口，而是分布式操作的观察路径中断后，最准确的结果。

## 记录证据边界，不要猜测故事

每个操作都需要一个明确的边界。超过这个边界后，网关就不能再诚实地保证什么都没有发生。可以把它称为分发边界。对于 HTTP，这个边界可能出现在请求提交到连接并交给操作系统时，也可能出现在上游服务确认接受请求时。对于 SSH，它可能出现在辅助程序通过已认证通道发送命令请求时。

不要假装一个 `sent=true` 布尔值就能解决问题。本地写入可能会被缓冲。传输库可能在对端应用读取数据前就报告写入完成。对端可能收到请求并应用变更，但响应在返回途中丢失。日志应描述现有的最强证据，而不是把实现细节变成证明。

一条有用的操作记录应包含不可变的标识符，以及按顺序排列的观察结果。下面这个紧凑的结构既适用于 API 调用，也适用于命令执行：

```json
{
  "action_id": "act_01JQ7M4V6K",
  "session_id": "ses_01JQ7M2Y8A",
  "channel": "http",
  "intent": {
    "method": "POST",
    "target": "api.example.internal/v1/releases",
    "request_fingerprint": "sha256:...",
    "idempotency_token": "release_01JQ7M4V6K"
  },
  "observations": [
    {"at": "2026-07-22T16:40:01Z", "kind": "authorized"},
    {"at": "2026-07-22T16:40:02Z", "kind": "dispatch_started"},
    {"at": "2026-07-22T16:40:03Z", "kind": "transport_write_completed"},
    {"at": "2026-07-22T16:40:33Z", "kind": "caller_deadline_exceeded"}
  ],
  "caller_disposition": "timed_out",
  "effect_outcome": "unknown_effect",
  "outcome_basis": "response_not_observed_after_dispatch"
}
```

请求指纹可以标识尝试执行的内容，而不会把 bearer 凭据、原始请求正文或包含秘密的命令参数放进日志。这个标识符在重试和后续协调过程中必须保持稳定。如果操作人员无法将原始请求、重试请求和最终的远程对象关联起来，审计记录就无法回答真正重要的问题。

`dispatch_started` 和 `transport_write_completed` 有着重要区别。前者表示网关开始了操作，后者表示本地传输层接受了出站数据。两者都不能说明远程应用执行了该操作。如果实现无法区分它们，就记录较弱的事实，并在结果依据中说明这一点。

## 分发前取消可能意味着无效果

取消可以支持“无效果”的结论，但前提是网关在将操作提交到外部通道之前就完成了取消。这是最清楚的情况：智能体在等待本地授权时撤回请求，在保险库解锁前取消，在 HTTP 请求开始前取消，或者在 SSH 命令交给传输辅助程序前取消。

审计条目应说明为什么这个结论是安全的。单独写“已取消”并不能告诉调查人员取消发生在哪个阶段。要记录阶段和本地证据点。

```json
{
  "action_id": "act_01JQ7P1N2R",
  "caller_disposition": "canceled",
  "effect_outcome": "no_effect",
  "outcome_basis": "cancellation_observed_before_dispatch",
  "last_observed_stage": "awaiting_authorization"
}
```

当本地门禁在任何外部请求开始前拒绝操作时，也可以使用这个结果。缺少审批、保险库锁定、会话被撤销或本地请求无效，都可能产生无效果，只要网关从未分发工作。日志应区分被拒绝的请求和被取消的请求，因为它们分别说明了人工控制和智能体行为的不同情况，但两者都可以安全地表示目标端没有看到任何内容。

一旦你已经创建连接并开始写入，就不要仅仅因为传输调用返回取消错误而使用这个标签。许多库会在多种路径中使用同一个错误值：排队时取消、写入期间取消、等待响应头时取消，或读取正文时取消。这些情况不能混为一谈。

传播取消有实际用途，但它不是时光机。gRPC 的取消指南指出，客户端取消表示不再需要 RPC 结果，并建议服务器停止工作，将取消继续传递给下游工作。这是良好的资源管理，但不能证明先前的副作用已经撤销，也无法撤销已经独立提交的写入。

如果接收服务提供了与操作标识符关联的专用取消端点，就将其记录为第二个操作。只有当服务对原始操作给出可信说明时，第二个操作的结果才能改变原始效果结果。一个在网络中断后才返回的尽力而为取消请求，会产生另一个效果未知的操作，不会凭空清理第一个操作。

## 分发后的超时意味着效果未知

截止时间是本地等待上限，不是远程执行的判决。一旦操作越过分发边界，只要协议确认或后续检查没有证明更多情况，超时就必须默认为效果未知。

HTTP 很容易在这里被误解，因为状态标签听起来像最终结论。RFC 9110 说明，408 表示服务器在愿意等待的时间内没有收到完整的请求消息，504 表示网关没有及时收到上游服务器的响应。这两种说法都描述了特定观察者和特定交换过程，不能证明另一个系统没有处理已经收到的数据。

设想一个智能体以 30 秒截止时间发送 `POST /v1/releases`。API 验证请求、插入发布记录、要求部署控制器启动部署，然后在生成响应时卡住。30 秒后，智能体看到超时，但发布记录已经存在。若没有幂等令牌就重试，可能会创建另一个发布，尽管智能体记录中把第一次调用标记为“失败”。

真实的记录应当如下：

```json
{
  "caller_disposition": "timed_out",
  "effect_outcome": "unknown_effect",
  "outcome_basis": "deadline_after_transport_write_no_remote_receipt",
  "recovery_required": "lookup_by_idempotency_token"
}
```

不要用 `failed` 作为未知的快捷写法。只有在你能确定事实时，才使用失败结果：远程服务返回了验证错误，命令返回非零退出状态，连接在任何请求离开网关前都无法建立，或者本地授权决定拒绝执行。只要可能已经发生出站分发，超时就达不到这个标准。

当远程端提供了证据时，部分完成应有独立的结果。批处理 API 和脚本经常会在失败前完成一部分工作。如果服务返回已完成对象 ID 列表，随后又返回错误，就记录 `partial_effect`，在策略允许的情况下保留对象 ID，并记录服务声明的原因。简单地称为“失败”会掩盖操作人员必须完成的清理工作。

## 确认丢失需要单独记录

确认丢失发生在接收方可能已经执行操作之后，但网关收到最终确认之前。这种情况足够常见，应记录为命名观察结果，而不是笼统的网络错误。

通常的过程在最后一刻才出现异常：

1. 网关授权并分发操作。
2. 远程服务接受操作，并执行或排队处理请求的工作。
3. 响应延迟、连接断开，或本地进程退出。
4. 即使远程系统可能已经有持久证据，网关仍然没有完成的持久证明。

第一个错误，是在后续查询成功后覆盖原始条目。这样会让日志看起来像是网关从一开始就知道结果。调查人员需要看到两件事：最初调用在没有确认的情况下结束，以及后续协调找到了匹配的远程结果。

应追加一条观察结果：

```json
{
  "action_id": "act_01JQ7M4V6K",
  "reconciliation": {
    "at": "2026-07-22T16:43:10Z",
    "method": "GET /v1/operations/release_01JQ7M4V6K",
    "remote_reference": "op_8f2c",
    "result": "succeeded"
  },
  "effect_outcome": "succeeded",
  "outcome_basis": "remote_operation_lookup"
}
```

原始调用方处置结果仍然是 `timed_out`，不要将其改写为 `completed`。调用方确实超时了，系统后来也确实发现远程操作成功了。这两件事可以同时成立，并不矛盾。

远程确认是否可信，取决于关联是否可靠。根据对象名称、当前时间戳或智能体提供的自然语言来匹配，证据都很弱。优先使用目标端接受的幂等令牌、在长时间运行的工作开始前返回的操作 ID，或目标端保证对请求唯一的服务商请求 ID。如果目标端都不提供，就使用范围严格限定的回读查询，并记录为什么它足够，或者为什么结果仍然存在歧义。

例如，发现一个名为 `build-bot` 的新用户，并不能证明是哪个创建请求产生了它。发现一个对象中存储的请求令牌与原始操作令牌相同，证据就强得多。这一区别决定了你能否安全重试。

## 幂等性让恢复变成检查，而不是赌博

幂等性不是事后附加的重试许可，而是在分发前建立的契约。客户端提供稳定令牌，服务保证带有相同令牌的重复请求指向同一个逻辑操作，而不是产生新的效果。

对于每个改变状态的 HTTP 集成，都要向服务负责人直接询问四个问题：

- 它是否接受调用方提供的幂等令牌？
- 令牌重复时，哪些请求字段必须保持一致？
- 它会保留令牌到结果的映射多久？
- 响应丢失后，调用方能否获取原始结果？

如果答案含糊，就不要把自动重试宣传为安全。“我们通常会去重”不是契约。缓存驱逐、区域故障转移或请求解析器变化，都可能让这个假设变成重复工作。

如果目标端不支持幂等性，就在可能的地方拆分风险操作。先创建带有唯一外部引用的持久草稿，验证草稿，再针对返回的标识符发出不可逆命令。这样不能让所有操作都变得安全，但能在昂贵或破坏性步骤之前建立协调点。

部署流程可以说明这种区别。一个同时创建并启动发布的一次性端点，几乎没有空间应对确认丢失。两次调用的流程则可以使用操作 ID 作为外部引用创建发布，超时后查询该引用，并在调用方拿到已知发布 ID 后只启动一次。额外的一次调用，通常比解释一次意外的生产变更便宜。

不要为同一操作的自动重试生成新的幂等令牌。新令牌声明这次重试是一个新的逻辑请求。人在有意重复工作时，这可能是正确的，但在恢复过程中会破坏去重。保留操作记录中的原始令牌，并让重试引用其父操作 ID。

幂等性契约也有助于应对事故。调查人员只需提出一个具体问题：“目标端对令牌 X 做出了什么决定？”没有这个契约，他们只能从时间戳、日志和名称中推断意图。这样既慢又容易出错，保留期限一过，甚至可能根本无法推断。

## SSH 连接关闭与命令完成是两回事

SSH 也容易造成类似误解，因为会话关闭让人感觉命令停止了。事实并非如此。网络中断可能切断客户端，而远程进程仍在其父 shell、监督程序或服务管理器下继续运行。反过来，进程可能已经结束，但客户端错过了退出报告。

RFC 4254 将通道关闭、文件结束和退出状态视为不同的协议事件。它建议远程命令终止时返回退出状态，但没有把通道关闭变成“已收到退出状态”的证明。规范还说明，对等方会交换关闭消息，然后各方才认为通道已关闭。这只能说明 SSH 通道的状态，不能说明远程命令是否在通道断开前修改了文件系统或服务。

将 SSH 证据记录为不同的观察结果：

```json
{
  "channel": "ssh",
  "observations": [
    {"kind": "command_request_sent"},
    {"kind": "stdout_received", "bytes": 1840},
    {"kind": "connection_lost"}
  ],
  "caller_disposition": "disconnected",
  "effect_outcome": "unknown_effect",
  "outcome_basis": "no_exit_status_or_remote_process_identity"
}
```

如果你在命令输出之后收到有效退出状态和完整的通道关闭，就有较强证据表明命令进程已经结束，但这仍不能绝对证明它触发的每个外部副作用都已完成。脚本可能成功提交异步工作，然后在该工作完成前以零状态退出。如果远程系统为你提供了 ID，就将命令结果记录为成功，并将外部作业建模为独立操作。

智能体运行命令前，必须先制定恢复计划。优先选择会打印或写入持久操作标识符的命令。重启服务时，使用已知单元名称查询服务管理器，并比较前后状态。执行数据库迁移时，检查迁移记录表，不要只相信终端输出。执行文件操作时，检查内容哈希以及版本或代数标记，而不只是检查路径是否存在。

不要把发送给本地辅助程序的信号当成远程进程已停止的证明。信号可能在远程分发前到达，也可能在远程命令完成后到达，还可能在连接断开后到达。将信号记录为调用方或网关事件，只有远程端提供证据时才改变效果结果。

## 调查应从时间顺序开始，而不是从最终标签开始

调查人员应该能够在不猜测哪一行日志先出现的情况下，重建一次结果不确定的操作。这需要稳定的操作 ID、会话 ID、网关内部单调递增的事件顺序，以及观察事件的时间戳。墙上时钟有助于关联，但不同系统的时钟可能漂移。不要把整个结论建立在两个时钟精确到毫秒一致的基础上。

一次好的调查应按顺序提出这些问题：

1. 谁授权了操作，哪个智能体进程发出了请求？
2. 网关是否越过了分发边界？
3. 分发后发生了哪些传输观察结果？
4. 是否收到了可信的远程确认？
5. 如果没有，什么协调查询可以识别原始逻辑操作？

这个顺序可以避免一种常见的坏习惯：先搜索远程日志，找到一个相似事件，就宣布那是答案。应从预期执行的操作及其关联信息开始，然后判断远程证据是否确实匹配这次操作。

审计记录必须能够抵抗悄无声息的修改。如果操作人员可以在不保留旧状态和更新依据的情况下，将 `unknown_effect` 改成 `succeeded`，这条记录就成了声明，而不是证据。只追加记录、哈希链和离线验证，都能让事后编辑更难隐藏。它们不能证明每个远程系统都说了真话，但可以保留网关在何时观察到什么，以及后来何时得知了更多信息。

Sallyport 将智能体运行记录和单独的操作记录保存在同一个加密哈希链审计日志派生出的不同日志中，`sp audit verify` 可以对密文离线验证哈希链。这种设计在这里很有用，因为后续协调可以作为新事实记录，而无需抹去原始的超时或断开连接。

不要为了改善事故响应，就把包含秘密的请求正文放进审计轨迹。保存经过清理的目标、请求指纹、已批准的操作标识符、响应分类，以及协调所需的最少远程引用。一个通过泄露凭据来解决某次调查的日志，本身就会制造下一次事故。

## 结果名称应驱动安全的智能体行为

只有当运行时针对每种结果做出不同响应时，结果分类才有意义。如果每个非成功结果都会触发立即重试，详细的审计标签就只是装饰。

使用以下操作规则：

- 只有当原始意图仍获授权且仍然有效时，才可在 `no_effect` 后自动重试。
- 只有在智能体修改了无效输入，或人工解决了所报告的冲突后，才可在 `rejected` 后重试。
- 对任何改变状态的操作，都要先协调 `unknown_effect`，再进行重试。
- 将 `partial_effect` 视为清理或继续执行的任务，而不是从头开始。
- 如果协调无法识别唯一匹配的远程操作，就升级处理。

最后一条规则最容易让团队失去耐心。他们希望智能体继续向前执行。对于读取请求，这种本能通常可以理解，但对于会花钱、改变访问权限、修改生产环境或删除数据的操作，这样做就很鲁莽。未解决的操作应持续可见，直到人工或可靠的远程查询填补证据缺口。

审批流程也不应隐藏这一区别。批准操作的人批准的是一次尝试，不是结果未知后可以无限重试。如果下一次尝试可能产生第二个效果，就要明确显示它是对一次结果不确定操作的重试，并在风险需要时要求重新做出决定。

在增加更多通道或更强的自主行为之前，先建立这个模型。智能体第一次改变重要内容后发生的超时，不是发现审计日志只有两个结果，也就是成功和客户端碰巧看到的结果的好时机。
