阅读需 8 分钟

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

异步 API 作业需要持久状态、幂等性、轮询、取消确认、结果检查和审计记录,才能让 AI 代理可靠地运行工作流。

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

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

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

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

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

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

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

最小记录可以是这样:

{
  "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/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 请求头,另一些要求请求字段。使用文档规定的形式,并生成具有足够熵的令牌,避免不相关的操作发生碰撞。在原始操作得到解决前,始终保留同一个令牌。

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 没有提供指引,就使用带抖动的有上限指数退避。具体上限取决于业务需要多快得到答案,以及服务商的速率限制,但整体节奏应避免同步爆发。

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

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

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

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

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

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

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

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

在保险库层面停止调用
保险库锁定后,Sallyport 会拒绝创建、查询状态、取消和获取结果的调用。

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

一种实用的本地模型是:

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、操作者、时间、之前的本地状态、之后的本地状态、触发请求或响应,以及原因。一条精简记录就足够:

{
  "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,调用文档规定的取消端点。即使响应只说服务器接受了请求,也要持久化保存它。

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

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

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

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

获取结果是一个独立操作

让作业凭据留在代理之外
Sallyport 会为创建请求注入 API 凭据,代理始终拿不到密钥。

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

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

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

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

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

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

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

分离推理与 API 访问
Sallyport 自己执行 HTTP 操作,只把结果返回给代理。

为每个操作使用 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,它们都是同一操作的子事件。

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

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

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 发送两次。使用过期租约重启。如果工作流无法解释并恢复每一种情况,就还没准备好发起昂贵或有重大影响的作业。

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

常见问题

什么是异步 API 作业?

异步 API 作业会先启动任务,在任务完成前就返回。创建响应应提供一个持久的作业标识符,以及之后查询当前状态的方式。整个工作流都应使用这个标识符,而不是把它当成一次性的回执。

如何避免重复提交异步作业?

为一次逻辑请求生成一个幂等令牌,并在重试同一个创建操作时始终复用这个令牌。服务器必须把令牌绑定到最初接受的请求,并返回之前的结果,而不是创建第二个作业。请求内容发生变化后,不要继续使用原令牌。

代理应该多久查询一次作业状态?

没有适用于所有场景的固定间隔。服务器提供 Retry-After 时,优先遵循它,否则使用带抖动的指数退避,并设置最长等待时间。让一群代理每秒查询一次,通常是客户端设计偷懒,而不是有效的监控。

创建后台作业后,代理应该保存什么?

代理需要一条持久记录,其中包含作业 ID、请求指纹、幂等令牌、当前状态、重试次数和截止时间。应在第一次网络调用之前保存这条记录,而不是等成功响应后再保存。没有这条记录,进程重启就会把不确定性变成重复工作。

代理能安全地取消异步作业吗?

取消请求的作用是要求服务停止工作,但它无法抹去已经完成的工作。代理应记录取消请求的事实,持续查询直到服务报告终止状态,并收集 API 提供的部分结果或错误详情。收到取消请求已被接受的响应,不等于什么都没有发生。

异步作业的终止状态包括什么?

终止状态是指作业不会再发生变化的状态,例如 succeeded、failed、cancelled 或 expired。终止状态不一定意味着存在可用结果。应查看 API 契约,确认失败或取消的作业是否会返回诊断信息、部分输出,或者什么都不返回。

创建作业超时后,代理应该怎么做?

超时只说明客户端停止等待。在再次提交之前,代理应使用保存的作业 ID 查询,或者在 API 支持时使用同一个幂等令牌重放请求。因为第一次响应超时就发送全新的创建请求,是生产环境出现重复作业的常见原因。

我既需要 trace ID,也需要作业 ID 吗?

在创建、状态查询、取消和结果获取之间使用同一个 trace ID,并把 API 返回的作业 ID 与它一起记录下来。trace ID 用于连接自己的日志,作业 ID 用于标识服务商那边的作业。事故跨越多个进程和服务时,两者都需要。

长时间运行的作业,Webhook 比轮询更好吗?

回调可以减少查询次数,但不能取消对状态端点的需求。回调可能延迟、重复到达,甚至完全丢失,因此代理仍需核对最终状态。回调更新本地记录前,必须验证其真实性。

操作网关能管理完整的作业工作流吗?

凭据保管和工作流正确性是两个不同的问题。Sallyport 可以让 API 凭据不进入代理,同时执行 HTTP 调用,但代理仍然需要幂等性、状态存储、截止时间和协调逻辑。不要期待操作网关替你定义远程 API 的作业语义。

Sallyport

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

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