# 状态端点轮询：代理如何在没有循环的情况下等待

启动外部任务的代理不能一直追问“现在完成了吗”，直到服务商、预算或人类中的某一方放弃。状态端点轮询需要一份明确的契约：什么算作进展、下一次请求什么时候可以发出、等待何时结束，以及之后由谁决定如何处理。

我见过看似无害的状态检查变成数百次调用，因为任务标识符有效，端点持续返回 HTTP 200，却没人告诉代理，在截止时间之后“running”已经不再是可接受的答案。解决办法不是设计更聪明的调度，而是把等待变成有边界的动作，并为它准备证据和升级路径。

## 运行中状态代表可以等待，不代表可以行动

非终态的任务状态只允许之后再次观察。它不允许代理获取输出、启动依赖任务、重试原始提交，或扩大自身权限。

这个区别很重要，因为异步 API 往往会为每种状态都返回成功的 HTTP 响应。下面这样的响应只说明状态端点工作正常，并不说明任务成功了。

```json
{
  "job_id": "exp_71c",
  "state": "running",
  "updated_at": "2025-04-18T10:24:00Z"
}
```

把 HTTP 结果和任务结果当作两条独立事实。前者回答“服务商是否响应了这次请求？”，后者回答“工作流现在可以继续吗？”团队经常把两者混为一谈，结果代理下载了不完整的导出文件，或发布了根本不存在的结果。

为每个服务商集成写一张小型状态表。不要从 `status` 这样的字段名推断含义，不同服务商会用同一个词表示完全不同的生命周期。一张有用的表应包含这些类别：

- **待处理状态**允许之后再次请求状态，例如 `queued`、`running` 或 `processing`。
- **成功状态**允许执行明确指定的后续动作，例如获取结果 URL。
- **失败状态**会停止任务，并保留服务商返回的错误。
- **取消和过期状态**会停止任务。除非人明确要求，否则不要重新提交。
- **未知状态**会停止任务，因为集成无法安全判断 `paused`、`awaiting_review` 或刚新增的值是否可以继续等待。

还要检查状态响应中是否存在矛盾。任务说自己 `succeeded`，却没有必需的结果引用，这时不能消费它。任务在自己报告的过期时间之后仍显示 `running`，需要升级处理，而不是继续相信轮询。

把任务 ID、服务商账户上下文、原始请求指纹和预期终态放在一起保存。如果代理失去这种关联，重启后可能轮询错误的任务，或误把更早请求创建的任务当成当前任务。

## 固定间隔会形成同步压力

固定间隔在代码里看起来整齐，在一组代理中却表现糟糕。如果五十个代理在相近时间提交任务，而且每十秒轮询一次，它们往往会成群访问服务商。短暂故障之后，这些请求群仍会存在，因为每个代理都按照同一个时钟重试。

使用逐渐增加的延迟，并加入随机抖动。只有在服务商通常很快完成任务，或会立即提供最新状态时，才从较短延迟开始。每次收到待处理响应后增加等待时间，设置上限，并让每次运行采用不同的实际延迟。

一个实用的计划可以采用以下规则：

```text
base_delay = 5 seconds
max_delay = 120 seconds
attempt = number of completed polls
raw_delay = min(max_delay, base_delay * 2^attempt)
actual_delay = random value between 50% and 100% of raw_delay
```

随机范围很重要。5、10、20、40、80 秒这样的确定性序列，只是把同步推迟到更宽的波次。完全随机抖动在某些系统中也有效，也就是随机值可以从零一直取到上限。我更喜欢在任务轮询中设置下限，因为某次运行如果反复选到接近零的延迟，就会开始像重试风暴。

不要不考虑任务持续时间就套用通用退避计划。通常一分钟内完成的文档转换适合较早观察状态。会报告预计完成时间的批量导出，不应因为代理感觉无事可做就每隔几秒查询一次。

如果 API 提供 `next_check_at`、`poll_after_seconds` 或类似字段，把它当作服务商建议，但要先验证。拒绝负数、超过操作截止时间的荒谬长等待，以及无法解析的时间戳。代理可以在指示时间或自身截止时间到来之前等待，以先到者为准。

延迟并不意味着代理一定会在那个确切时刻发出请求。本地进程可能休眠、重启、失去连接，或者很晚才恢复。唤醒后先检查截止时间是否已过。不要为了补偿错过的间隔而一次性发送多次状态请求。

## 截止时间和请求预算可以捕获不同的故障

每次轮询都需要一个现实时间截止时间和一个状态请求最大次数。如果服务商位于远程或不稳定，还要为传输失败单独设置上限。

截止时间控制工作流最多可以处于未解决状态多久。它能防止代理因为 API 仍然说 `queued`，就让一个陈旧任务一直活到周末。应根据延迟的业务影响、服务商记录的保留期限，以及应由人做决定的时间来选择截止时间。不要只从平均运行时长推导它，平均值会掩盖那些卡住的任务。

请求预算控制代理对服务商及所用凭据施加的压力。即使时间缓慢流逝，它也能捕获调度错误；如果 API 按调用计费，还能限制成本。预算应包括网络状态不明确之后发出的状态调用。如果不把这些调用计入预算，代理可能在说服自己只尝试了几次的同时耗尽服务商配额。

失败预算的职责更窄。统计连接拒绝、DNS 错误、TLS 失败和阻止代理获知任务状态的 5xx 响应。一次超时不能证明任务失败。连续一小时重复同一个失败请求，也不能证明代理有耐心。

在第一次轮询之前，先持久化保存一条类似下面的操作记录：

```json
{
  "operation_id": "report-export-2025-04-18-01",
  "provider_job_id": "exp_71c",
  "started_at": "2025-04-18T10:20:00Z",
  "deadline_at": "2025-04-18T11:00:00Z",
  "max_status_requests": 12,
  "max_transport_failures": 3,
  "status_requests_used": 0,
  "transport_failures_used": 0,
  "last_known_state": "queued"
}
```

这些数值只是示例，不是每个服务商的默认值。四十分钟内检查十二次可能适合缓慢的导出任务。对通常三秒完成的任务来说，这很可笑；对要求客户端每十五分钟检查一次的任务来说，这又很危险。

在请求之前检查截止时间，不要只在请求之后检查。否则进程晚醒后可能多发一次未经授权的调用。在安排请求前立即检查请求预算，并在传输前立即递增计数。进程如果在安排和发送之间崩溃，这个顺序就很重要。你希望偶尔出现未使用的预留，而不是出现一条看不见的额外请求。

## 停止条件必须可执行，而不是停留在愿望层面

“如果耗时太长就停止”是给人看的备注，不是代理可以执行的条件。应把停止条件定义为针对当前记录和响应的谓词。

只有在服务商报告了被接受的终态成功状态，并且下一步所需的每个字段都通过验证时，才能以成功停止。如果下一步要下载文件，就在宣布成功前验证文件引用。如果下一步会影响另一个系统，就在执行之前记录终态响应。

出现以下情况时以失败停止：服务商报告终态失败、响应无法解析，或状态不在集成允许列表中。对未知状态的处理，应和拒绝授权一样严肃。服务商可能在没有通知的情况下新增 `needs_payment`、`manual_review` 或 `blocked` 状态。猜测它们的意思是“继续等待”，会把对方的一次软件变更变成你这边的无尽循环。

截止时间到达时，即使状态刚刚变化，也要以超时停止。代理应报告最后观察到的状态，但不能因为看到了看似进展的变化，就给自己再授予一个完整的等待周期。如果可以接受更长等待，就应以新的截止时间做出一次新的授权决定。

下一次状态请求会超过允许次数时，以预算耗尽停止。不要仅仅因为代理重启、切换会话或收到新提示，就重置计数。服务商看到的是一个调用方和一个任务，而不是你的内部进程边界。

如果状态请求超时且失败预算已经耗尽，以交付不明确停止。代理无法知道服务商是否收到了该请求，但状态请求应该可以安全地重复。如果端点每次 GET 都会改变状态、收费或刷新文件，它从实际运维角度看就不是状态端点。应把它当作操作，并要求更强的控制。

团队在这里经常犯一个代价高昂的错误：以为 GET 就一定无害。HTTP 从语义上将 GET 定义为安全方法，也就是说客户端没有请求改变状态。这是服务器应遵守的契约，不是 URL 自带的神奇属性。在把调用归类为观察操作之前，应使用测试账户并查阅服务商文档验证其行为。

## 遵守服务商已经发送的协议信号

HTTP 有一些信号，应该立即改变代理的轮询行为。因为循环有自己的计时器就忽略这些信号，对服务商不友好，通常也会让恢复变慢。

RFC 9110 将 `Retry-After` 定义为客户端发起后续请求前至少应等待的时间提示。该字段可以包含秒数，也可以包含 HTTP 日期。两种形式都要解析。状态请求收到 503 和 `Retry-After: 120` 时，不要使用通常的三十秒退避并提前重试。只要操作截止时间允许，就至少等待两分钟。

RFC 6585 定义了 HTTP 429，即请求过多，并说明响应可以包含 `Retry-After`。由于服务商可以省略这个标头，代理仍需要自己的退避机制。没有指导信息的 429 应显著增加延迟，并计入已定义的失败预算或限流预算。持续收到 429 的任务应升级处理，不能无限拉长计时器。

对于 202 Accepted，要检查响应正文和标头中是否有位置、任务 ID 以及明确的状态资源。不要根据提交端点猜测 URL。有些服务商使用结果位置，有些使用状态位置，还有些会在之后返回最终表示。应遵循文档规定的契约。

对于 404，不要总是假设任务从未存在。刚提交的任务可能存在最终一致性问题，而已完成的任务可能在保留期结束后消失。具体哪种解释合理，取决于服务商原始契约。如果文档行为无法解释这一结果，就将其归类为集成失败，并带着任务 ID 和时间戳升级处理。

对于 401 或 403，停止轮询。使用同一凭据重试授权失败只会制造噪声，还可能触发服务商防御机制。人需要检查访问权限、凭据轮换、账户范围或网关配置。轮询动作到此结束。

对于 5xx 响应和网络失败，使用传输失败预算。保留响应代码、请求时间戳以及服务商请求 ID。这些细节能帮助支持人员或操作员区分丢失的响应和任务失败。

## 升级应该请求一个决定，而不是倾倒一份日志

代理需要一个明确的节点，在那里停止等待，并把情况交给人或另一个明确获得授权的工作流。升级不是代理已经尝试所有可能性之后附加的一条装饰性通知。

让升级原因具备机器可读性。可以使用 `deadline_exceeded`、`request_budget_exhausted`、`rate_limited`、`unknown_state`、`authorization_denied` 或 `provider_failure` 等类别。每个类别都应决定允许的下一步动作。看到 `unknown_state` 的人可以批准暂时暂停，同时让别人检查服务商变更。看到 `authorization_denied` 的人则不应批准另一个完全相同的请求。

升级消息应包含足够的上下文，让人无需接触机密信息就能做决定：

```text
External job needs a decision
Operation: report-export-2025-04-18-01
Provider job: exp_71c
Last state: running
Elapsed time: 40 minutes
Status requests: 12 of 12
Last HTTP result: 200 at 10:58 UTC
Stopped because: request_budget_exhausted
Safe options: extend waiting once, cancel at provider, inspect provider console
```

不要把选择写成“继续吗？”。这会诱使操作员批准一个没有边界的循环。提供有限的选项。“延长十五分钟，再检查四次”说明了成本，并创建了新的限制。“在服务商处取消”只有在集成有记录明确的取消操作，而且操作员了解其影响时才能出现。

如果最终结果的有效窗口很短，应更早升级。部署验证在部署窗口关闭后才到达，可能不值得继续轮询。税务文件导出则可能值得等待服务商故障结束。优先级不能只由技术状态决定，因此当业务截止时间和服务商任务截止时间不同时，要分别记录这两个截止时间。

除非服务商提供幂等能力，而且你持久化保存了幂等令牌，否则不要在超时后自动重新提交。状态轮询失败不能证明原始任务失败。重新提交可能创建重复发票、重复邮件、重复部署或相互竞争的导出任务。人们会把这称为“自我修复”，直到真正需要收拾残局的那一刻。

## 轮询控制器需要持久化状态和唯一所有者

可靠的轮询控制器会持久化记录，并确保同一时间只有一个工作进程拥有某个任务。如果缺少这两个条件，重启恢复和并行代理就会产生重复检查，或执行互相矛盾的后续动作。

所有者可以是进程租约、数据库锁，或适合你环境的其他持久化机制。工作进程死亡时，机制必须能够过期；新所有者发送任何请求前，必须重新加载完整记录。第二个代理进程存在后，普通的内存布尔值就不再代表所有权。

每次有意义的事件之后都要持久化：提交任务、安排下一次检查、发送状态请求、收到响应、状态转换和升级。不必为每条调试细节单独建立日志，但必须恢复那些会影响权限和预算的事实。

下面的伪代码展示了能防止大多数意外循环的顺序：

```text
load operation
if operation is terminal or escalated:
    exit
if current_time >= operation.deadline_at:
    record timeout and escalate
    exit
if operation.status_requests_used >= operation.max_status_requests:
    record budget exhaustion and escalate
    exit
if current_time < operation.next_poll_at:
    schedule wakeup and exit

acquire ownership lease
reload operation
increment status_requests_used and persist
send one status request
persist response metadata

if response has terminal success and required result fields are valid:
    record success
else if response has terminal failure or unknown state:
    record stop reason and escalate
else if response requires waiting:
    calculate next_poll_at with provider guidance, backoff, and jitter
    persist next_poll_at
else:
    record integration failure and escalate
```

获得所有权后再次加载记录是有意为之。这个工作进程等待租约时，另一个工作进程可能已经完成了任务。跳过这一步，最终就会出现两个代理获取或发布同一个结果的情况。

睡眠时不要持有租约。只在更新记录和发送一次请求期间持有租约，前提是你的所有权设计允许安全地这样做。整个外部任务运行期间都持有租约，会在笔记本休眠或进程死亡时造成孤儿问题。

从代理角度看，状态端点应保持只读。把获取输出、取消任务和向下游发布分别作为独立操作，并为它们各自建立记录。把这些动作全部塞进一个轮询函数，正是一个无害的计时器变成隐藏工作流引擎的原因。

## 人工批准应位于升级边界

只要日常轮询没有超出原先批准的操作、凭据范围、截止时间和请求预算，大多数情况都不需要人点击批准。每次读取都要求批准，会让人学会不看内容就点击，也不会带来安全收益。

当代理请求新的权限边界时，才需要批准：比原定截止时间更长的等待、更高的请求预算、取消调用、重新提交、使用不同凭据，或使用未通过验证的输出。这些都是对操作的变更，不是普通观察。

Sallyport 可以在 MCP 代理能够使用时，把 API 凭据留在代理之外，由应用执行状态调用并返回结果。它的每次调用密钥设置适合这样的操作：每次使用特定凭据，包括状态请求，都需要明确批准。但这个设置无法修复没有边界的轮询设计。

根据后果选择批准模式。低风险的服务商状态调用可以放在每个会话的授权范围内。会暴露敏感任务元数据的高权限状态 API，可能需要每次调用批准或更窄的凭据。无论采用哪种方式，都应先定义控制器的限制，再决定如何授权请求。

不要让提示决定延长截止时间是否无害。请求应说明当前状态、经过的时间、已尝试的调用次数、拟增加的预算，以及继续的预期影响。这样，审核人就能拒绝会错过发布窗口或产生过时数据的延期。

## 审计等待的决定，而不只是审计请求

请求日志可以告诉你代理调用了 `/jobs/exp_71c`，却不能告诉你这次调用是否符合轮询计划，代理是否忽略了 `Retry-After`，或任务是否已经超过截止时间。

为每次调用记录控制器的决定：当前状态、下一次计划时间、延迟来源、请求次数、截止时间、响应代码、解析出的任务状态和最终决定。延迟来源可以是 `provider_retry_after`、`provider_poll_hint`、`local_backoff` 或 `manual_extension`。这个小字段能在事故后省下大量争论。

一段有用的审计记录应该像一个故事：

```text
10:20:00 submitted job exp_71c, deadline 11:00:00, budget 12
10:20:05 polled, state queued, next poll 10:20:14 from local backoff
10:20:14 polled, state running, next poll 10:20:31 from local backoff
10:20:31 received 503, Retry-After 120, next poll 10:22:31 from provider guidance
10:22:31 polled, state running, next poll 10:23:48 from local backoff
10:58:00 polled, state running, request budget exhausted, escalated
```

这样的记录能让糟糕的控制器一目了然。如果时间戳显示每秒发出一个请求，而服务商要求延迟，你就有了证据。如果代理声称任务超时，但最后状态是 `succeeded`，你也有了可以修复的具体问题。

Sallyport 会从加密、哈希链式审计日志中记录代理会话和单次调用，`sp audit verify` 可以在离线状态下对密文验证这条链。这类证据只有在你自己的操作记录同时解释每次观察调用为何发生时，才最有价值。

不要用任务最终是否完成来衡量轮询质量。应衡量每个任务是否都能到达终态或有边界的升级，代理是否遵守服务商的等待指示，以及操作员能否还原一项有争议的决定。对于一个失控循环，我会做的第一个控制器改动很简单：在代理发出第一次状态请求之前，先持久化截止时间和请求预算。
