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

发起长时间运行 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"
}
安全的重试顺序很明确:
- 生成令牌,并持久化操作记录。
- 使用该令牌发送创建请求。
- 如果响应丢失或客户端超时,使用完全相同的请求和完全相同的令牌重试。
- 如果服务器返回原始作业,保存其 ID 并继续。
- 如果需要不同的输入,在可能的情况下先关闭或取消旧操作,然后创建新的记录和令牌。
这个区别很重要,因为代理在推理时很自然地会改写请求。日期范围、目标、账户或输出格式发生变化,产生的效果就变了。此时复用令牌,会让客户端和服务器陷入争议:严谨的服务器会拒绝不匹配的请求,不够严谨的服务器则可能返回已经不符合代理意图的旧响应。
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 时使用它去重,更新同一条作业记录,并在宣布成功前进行最后一次状态读取。
状态转换必须拒绝一厢情愿的判断
作业状态机可以保护工作流,避免代理过度解读措辞。在把工具接入 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,调用文档规定的取消端点。即使响应只说服务器接受了请求,也要持久化保存它。
取消后继续轮询。通常可接受的终止结果包括 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,它们都是同一操作的子事件。
一个参考循环可以处理常见失败
下面的工作流把关键决策都明确写出来。它假设服务商提供幂等的创建契约、状态端点和取消端点。可以调整端点名称,但不要删除持久化状态转换。
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 的作业语义。