# 异步 API 作业：可追踪的代理工作流

发起长时间运行 API 请求的代理需要的是一套工作流，而不是一个不断调用端点、直到看起来完成为止的循环。创建、观察、取消和获取结果各自都有不同的失败方式。如果把它们压缩成一个提示词和几次重试，你最终一定会遇到重复提交、丢失已完成结果，或者误称取消已经生效的情况。

难点不在于发出 HTTP 请求，而在于代理重启、网络超时、服务商宕机，以及远程服务已经开始工作后有人要求“停止”时，仍然保留最初的意图。围绕一条持久的本地作业记录来设计，并让每一次外部调用都能在之后回答清楚：我们请求了什么，哪个远程作业负责执行，观察到了什么状态，接下来做了什么？

## 创建请求必须建立归属关系

创建端点会启动一个异步操作，在操作进入终止状态前就返回。第一次响应必须给代理足够的信息，让它能够继续工作，而不必重复发送请求。设计良好的 API 通常会返回作业 ID、初始状态，以及状态 URL 或用于获取作业的端点约定。

不要因为 socket 已连接，或者客户端在发送字节后超时，就认定请求已被接受。唯一有用的证明是服务器响应，或者之后的查询能够把原始逻辑请求与某个作业绑定起来。恰恰在客户端最希望得到简单答案的时候，网络传递最不确定。

在代理发出请求前，为每个逻辑操作生成一个本地操作 ID。这是你的标识符，不是服务商的标识符。将它与请求正文或正文的标准化指纹、目标、幂等令牌、时间戳，以及授权执行该工作的调用方一起保存。在发送请求前，先持久化写入这条记录。

最小记录可以是这样：

```json
{
  "operation_id": "op_01J7Q5X4D4PA3D",
  "request_fingerprint": "sha256:4f8b...",
  "idempotency_token": "idem_5b5c76c7",
  "remote_job_id": null,
  "state": "create_pending",
  "created_at": "2025-03-08T14:22:11Z",
  "create_deadline": "2025-03-08T14:24:11Z",
  "result_deadline": "2025-03-08T15:22:11Z"
}
```

指纹可以抓住一个细微但常见的错误：代理重试创建调用时改动了参数。这是一个新的操作，即使人们会把它描述成“同一个任务”。与某种请求形状绑定的令牌，不能悄悄授权另一种请求。

创建响应可能是：

```http
HTTP/1.1 202 Accepted
Location: /v1/jobs/job_7ad2
Content-Type: application/json

{
  "job_id": "job_7ad2",
  "state": "queued",
  "status_url": "/v1/jobs/job_7ad2"
}
```

响应一到，就用 `remote_job_id`、观察到的状态和响应元数据更新持久记录。完成这一步后，代理才能进入观察阶段。如果 API 返回的是同步成功结果，也要在同一个操作下记录。工作流应同时支持这两条路径，但不要假装它们含义相同。

## 幂等性用于应对传递不确定性，不是用于所有重试

幂等令牌告诉服务器，同一个逻辑创建请求被重复传递时，不得产生重复工作。它不会让所有请求都变得安全，也无法修复一个从未在创建端点实现幂等性的 API。

令牌应按照服务商的契约放进创建请求。有些 API 接受 `Idempotency-Key` 请求头，另一些要求请求字段。使用文档规定的形式，并生成具有足够熵的令牌，避免不相关的操作发生碰撞。在原始操作得到解决前，始终保留同一个令牌。

```http
POST /v1/reports HTTP/1.1
Content-Type: application/json
Idempotency-Key: idem_5b5c76c7
X-Trace-ID: tr_0830d3

{
  "account": "acct_218",
  "range": {"start": "2025-02-01", "end": "2025-02-28"},
  "format": "csv"
}
```

安全的重试顺序很明确：

1. 生成令牌，并持久化操作记录。
2. 使用该令牌发送创建请求。
3. 如果响应丢失或客户端超时，使用完全相同的请求和完全相同的令牌重试。
4. 如果服务器返回原始作业，保存其 ID 并继续。
5. 如果需要不同的输入，在可能的情况下先关闭或取消旧操作，然后创建新的记录和令牌。

这个区别很重要，因为代理在推理时很自然地会改写请求。日期范围、目标、账户或输出格式发生变化，产生的效果就变了。此时复用令牌，会让客户端和服务器陷入争议：严谨的服务器会拒绝不匹配的请求，不够严谨的服务器则可能返回已经不符合代理意图的旧响应。

HTTP 标准清楚地说明了相关区别。RFC 9110 将幂等方法定义为：重复发送相同请求时，其预期效果与发送一次相同的方法。POST 默认不是幂等的。服务商可以为 POST 端点增加幂等行为，但客户端必须把它视为明确的应用契约，而不是 HTTP 本身的规则。

一种常见的错误建议是：“POST 只重试一次。”重试次数并不是重点。一次重复提交就可能发送一笔付款、配置一个环境，或启动昂贵的批处理。只要截止时间和服务商指引允许，就可以重试创建请求，但必须使用能让服务器识别原始操作的令牌。

## 超时意味着作业状态未知

创建超时不代表服务拒绝了请求，只代表代理没有在自己的截止时间前收到明确答复。服务器可能已经接受请求，可能仍在处理，也可能从未收到请求。

这正是薄弱代理工作流的问题所在。代理发送创建请求，等待三十秒后没有收到响应，于是带着新的令牌再次发送请求。现在有两个报告在运行。第二个可能先完成，直到有人比较费用、导出文件或下游变更时，问题才会暴露出来。

保留明确的 `create_pending` 状态。调用出现模糊失败时，记录错误类别、时间戳和尝试次数，但不要丢弃操作。然后使用 API 的协调路径。不同服务商提供的方式各不相同：

- 使用同一个幂等令牌重试，可能会返回原始接受响应。
- 列表或搜索端点可能支持按客户端请求引用筛选。
- 状态查询可能接受客户端提供的操作 ID。
- 服务商可能记录了按请求标识符查询近期创建操作的方法。

如果这些方式都不存在，API 就无法在响应丢失时，为客户端提供可靠的至多一次创建语义。在设计中应明确说明这一点。你可以通过本地 outbox 和克制的重试来减少重复，但无法证明某次重试没有创建更多工作。

如果服务器使用同一个令牌返回了一个陌生的作业 ID，应把它视为需要处理的契约违规。不要覆盖旧 ID。保留两条响应记录，停止该操作的自动活动，并要求人工决定。悄悄选择其中一个，正是审计记录变成虚构故事的方式。

使用能区分通信时间和工作时间的截止时间。创建截止时间规定代理会尝试建立远程作业 ID 多久，结果截止时间规定业务流程会等待完成多久。作业可能经历短暂的创建响应超时，却仍有数小时可以完成。把两者合并成一个计时器，会让代理放弃本可恢复的工作，或在错误的时机重试。

## 轮询需要退避、归属和停止时间

当一个持久工作流拥有作业，并且每次轮询都记录一次观察结果时，轮询是安全的。如果多个代理运行重新发现同一个作业并分别轮询，轮询就会变成滥用。

把远程作业 ID 放在一条记录中，并为当前负责观察的进程分配租约。租约可以是带过期时间的数据库行、带可见性规则的队列消息，或其他持久并发控制机制。工作进程崩溃后，后续工作进程可以在租约过期后接管。没有归属关系，重试和重启就会成倍增加状态调用次数。

API 返回 `Retry-After` 时，应遵守它。如果 API 没有提供指引，就使用带抖动的有上限指数退避。具体上限取决于业务需要多快得到答案，以及服务商的速率限制，但整体节奏应避免同步爆发。

```text
attempt 1: wait a randomized interval near 2 seconds
attempt 2: wait a randomized interval near 4 seconds
attempt 3: wait a randomized interval near 8 seconds
later attempts: keep increasing until the configured cap
```

不要根据代理的文字总结计算下一次延迟。把下一次轮询时间存进作业记录。这样，重启后的工作进程可以继续原来的计划，操作人员也能清楚解释代理为什么在等待。

状态响应只能更新观察到的事实。例如：

```json
{
  "job_id": "job_7ad2",
  "state": "running",
  "updated_at": "2025-03-08T14:26:40Z",
  "progress": {"completed": 146, "total": 500}
}
```

记录 `state`、服务商提供的时间戳、获取时间、原始响应引用和下一步操作。不要把模糊的进度字段变成作业一定会完成的承诺。服务商经常延迟报告进度，或者按批次报告。进度对操作人员有帮助，但终止状态才控制工作流。

设置结果截止时间，并把截止时间到期视为一种状态，而不是忘记作业的借口。`result_timed_out` 表示代理因为约定到期而停止自动轮询，不代表远程作业已经停止。如果操作有实际成本或副作用，应保留之后协调所需的信息，并决定是否适合取消。

Webhook 可以降低延迟，但不能取消状态循环。服务商可能重复发送回调、乱序发送，或完全发送失败。按照服务商文档验证回调，在有事件 ID 时使用它去重，更新同一条作业记录，并在宣布成功前进行最后一次状态读取。

## 状态转换必须拒绝一厢情愿的判断

作业状态机可以保护工作流，避免代理过度解读措辞。在把工具接入 API 前，先定义本地状态和允许的转换。远程服务使用的名称各不相同，但你的记录必须让不确定性清晰可见。

一种实用的本地模型是：

```text
create_pending -> accepted -> observing -> result_collecting -> succeeded
create_pending -> create_unknown -> reconciliation
accepted or observing -> cancel_requested -> cancelling -> cancelled
accepted or observing -> failed
observing -> result_timed_out
```

这些箭头是规则，不只是文档中的示意图。没有证据时，工作进程必须拒绝状态转换。它不能因为看到进度值为 100 就标记 `succeeded`，也不能因为发送了 `DELETE /jobs/job_7ad2` 就标记 `cancelled`。除非远程 API 明确支持重试或恢复操作，并且新操作被单独记录，否则不能从 `failed` 回到 `observing`。

将远程状态和本地状态分开。`cancel_requested` 描述本地事实：代理发出了取消请求，正在等待确认。`cancelled` 描述远程事实：服务报告了终止的取消状态。这个小小的区别，能在事故处理中避免大量混乱。

为状态转换保留只追加历史。每条记录需要包含操作 ID、操作者、时间、之前的本地状态、之后的本地状态、触发请求或响应，以及原因。一条精简记录就足够：

```json
{
  "at": "2025-03-08T14:29:02Z",
  "actor": "worker-3",
  "from": "observing",
  "to": "cancel_requested",
  "cause": "human_request:req_91af",
  "remote_job_id": "job_7ad2"
}
```

不要把单个可变的 `status` 字段当成唯一记录。它只能告诉你工作流现在相信什么，却不能告诉你五分钟前为什么这么相信。当远程 API 后来返回令人意外的状态时，历史记录能说明是服务商发生了变化、代理重复了调用，还是操作人员介入了。

## 取消需要确认，也需要划定损害边界

取消是要求停止后续工作。它无法撤销服务商已经提交的工作，而且有些服务商允许作业完成与取消请求同时到达的竞争情况。工作流应以此现实为基础设计。

当人员或策略决定停止作业时，先记录取消意图，包括谁提出请求、原因和预期效果。然后使用保存的远程作业 ID，调用文档规定的取消端点。即使响应只说服务器接受了请求，也要持久化保存它。

取消后继续轮询。通常可接受的终止结果包括 `cancelled`、`succeeded` 和 `failed`。取消请求发出后却得到完成结果，并不自动意味着错误。这可能只是作业在几秒前已经越过提交点的真实结果。工作流必须准确报告这个顺序，而不是为了符合预期结果而改写历史。

有些操作需要单独设置损害边界，而不能只依靠取消。如果导出作业写入文件，取消可能留下部分文件。如果配置作业创建资源，取消可能留下已经创建的一部分资源。API 契约应说明是否提供清理、回滚或部分结果详情。如果没有，就把取消视为运营控制，而不是事务。

不要让每个轮询工作进程重复发送取消调用。保存 `cancel_requested`，在服务商允许时让取消操作具备幂等性，并由租约持有者负责后续工作。重复一个无害请求会浪费容量，重复一个带副作用的取消操作则可能扰乱远程审计日志。

取消截止时间也很有帮助。经过合理且有文档说明的等待后，转入 `cancellation_unconfirmed`，而不是声称取消成功。带上远程作业 ID、trace ID、请求历史和服务商请求 ID 进行升级处理。这样，人员或服务商支持团队无需从聊天消息中重建过程，就能看到实际顺序。

## 获取结果是一个独立操作

终止成功状态意味着远程工作完成，但不保证结果已经获取、验证、保存或交付给下一个系统。应把结果获取作为单独记录的操作。

首先使用 API 提供的作业 ID 或结果引用获取结果。在服务商提供这些信息时，验证预期内容类型、结构、校验和、大小或记录数。把结果引用和验证结果保存到作业记录中。如果结果很大，应保存持久位置和完整性数据，而不是把不透明的内容复制到事件日志中。

然后判断获取本身是否需要幂等性。许多结果端点只是安全读取，另一些端点会生成临时下载、消耗一次性文件，或把作业标记为已交付。应阅读契约。代理如果把每个 `GET` 都当成无害操作，仍可能触发服务商特有的状态变化。

不要只用成功的 HTTP 状态作为验证。报告端点可能返回一个格式有效但包含错误行的文件。图像批处理可能返回带失败项的清单。数据导出可能完成，却遗漏了 API 说明调用方无权访问的记录。应根据最初创建作业的业务预期进行验证。

对于批处理，如果服务商支持，应记录每个项目的结果。一个终止的作业可能包含 498 个成功项目和 2 个失败项目。简单地称其为“成功”，会迫使下一个代理重新从结果内容中发现部分失败。本地最终状态可以保持成功，同时在结果摘要中记录数量和失败项目引用列表。

只有在结果获取符合契约后，才关闭操作。`succeeded` 应意味着预期结果已经可用，并按照你的规则完成验证。如果服务商已经完成作业，但结果获取失败，应使用 `result_unavailable` 或 `result_validation_failed` 等独立的本地状态。远程作业可能已经完成，但你的工作流还没有完成。

## trace ID 连接操作，审计记录确立事实

为每个操作使用 trace ID，并在 API 接受自定义请求头时，将它发送到创建、状态读取、取消和结果获取调用中。知道远程作业 ID 后，立即把它与 trace ID 配对。trace ID 用于连接你系统内部的事件，作业 ID 让服务商找到自己的工作项。

不要混用这两个标识符。trace ID 不应变成幂等令牌，因为一个操作可能包含多次请求，而每次请求有不同的重试规则。作业 ID 也不应成为你的授权记录，因为它是在你决定执行之后才由服务商生成的。

审计记录应回答日志经常无法回答的问题：哪个代理进程发起了操作，哪项人工批准覆盖了它，使用哪个凭据执行了什么操作，以及之后是否有人编辑过历史记录。在创建调用前写入简洁的意图记录，之后追加观察结果。保留请求 ID 和经过清理的响应元数据。不要把 bearer token、密码、私钥或完整的敏感负载放进通用日志。

Sallyport 可以在不向代理暴露已保存凭据的情况下执行 HTTP API 调用，其会话和单次调用会提供一条可验证的防篡改记录，可使用 `sp audit verify` 验证。这能保护凭据保管和操作证据，但你的工作流仍需要自己的操作记录，因为只有它知道 `job_7ad2` 是否属于所请求的业务任务。

只有每个组件都一致记录 trace，trace 在糟糕的一天才真正有用。把它放进本地作业记录、代理执行上下文、允许使用时的请求头、允许使用时的审计注释和操作人员工单中。不要为每次轮询伪造新的 trace，它们都是同一操作的子事件。

## 一个参考循环可以处理常见失败

下面的工作流把关键决策都明确写出来。它假设服务商提供幂等的创建契约、状态端点和取消端点。可以调整端点名称，但不要删除持久化状态转换。

```text
load operation by local operation ID

if no operation exists:
    create and persist record with fingerprint and idempotency token

if remote job ID is absent:
    send create with the stored token
    if response confirms job ID:
        persist job ID and move to observing
    if response is ambiguous:
        move to create_unknown and reconcile using the stored token
    if response rejects request definitively:
        move to failed

while local state requires observation and result deadline has not passed:
    acquire lease for the operation
    read remote status
    append the observation
    if cancellation was requested and remote state is nonterminal:
        send cancellation once and record the attempt
    if remote state is terminal:
        collect and validate result if appropriate
        persist final local state
    otherwise:
        persist next poll time and release lease

if the deadline expires before a terminal observation:
    move to result_timed_out and preserve the reconciliation record
```

这个循环没有神奇的重试次数，因为重试限制取决于服务商、操作成本和调用方的截止时间。但它有一条更重要的规则：每次重试都指向一个已保存的操作，每次对外可见的动作都会改变该操作的历史。

在交给自主代理前，先通过故障注入测试它。让服务器接受创建请求后丢弃响应。在保存作业 ID、但还没安排第一次轮询前终止工作进程。在取消竞争期间返回终止结果。把同一个 webhook 发送两次。使用过期租约重启。如果工作流无法解释并恢复每一种情况，就还没准备好发起昂贵或有重大影响的作业。

第一个实现任务并不光鲜：创建持久的操作记录，并拒绝在没有这条记录时发出创建请求。这个约束会迫使代理保留意图，让防止重复成为可能，也会在远程系统表现不完美时，为所有人提供一份基于事实的记录。
