轮询循环看似四行代码实则编码六个假设:状态字段路径、终止值、结果位置、轮询间隔、超时边界、失败语义。更换provider这些假设大多独立失效,搜索替换版会死循环。
轮询循环看起来只有四行代码,但其中编码了至少六个假设:哪个字段存放状态、哪些值意味着终止、结果存在哪里、轮询频率是多少、何时放弃,以及一次失败的轮询意味着什么。切换 Provider 会使这些假设大部分同时失效,这就是为什么用搜索替换方式迁移这个循环会在已经完成的任务上永远空转。
在动代码之前先把假设写下来,因为每一个假设在新 API 里都是独立的问题、独立答案:
状态存在于响应对象的已知路径。字段名不同——一个公开的批量 API 叫它 status,另一个叫它 processing_status;如果对象包装在 data envelope 里,它的位置也不同。
它的某个子集的值是终态。其他所有值意味着继续查询。
当它是终态时,结果可以从同一个对象中读取。这一条经常是假的,而且是最昂贵的那种假设失效。
轮询间隔对 Provider 可接受。轮询频率受速率限制约束,紧凑循环在长任务上消耗的请求配额可能比任务本身还多。
有一个总时间上限。没有上限的话,这个循环就是一个无人值守进程,它的寿命可以超过任务本身、超过部署、超过答案的有用生命周期。
一次报错的轮询和一次返回"仍在运行"的轮询是不同的。把短暂的 503 与宣布任务失败混为一谈,会把一次临时错误变成认定的任务失败。
隐蔽的失败不是重命名字段——字段改名会立即抛错,一分钟就能修好。真正隐蔽的失败是终态集合的大小不同。
OpenAI 的 Batch API 让任务经历 validating、in_progress 和 finalizing 之后才到达 completed,也可能以 failed、expired 或 cancelled 结束;每个请求的结果在 request_counts 中计数,响应体存在 output_file_id 指定的文件中,失败内容存在 error_file_id 中(OpenAI batch API reference)。Anthropic 的 Message Batches 使用 processing_status,状态包括 in_progress、canceling 和 ended,其中 ended 是唯一的终态,每个请求的结果从 results_url 的结果流中读取(Anthropic message batches reference)。
把第一个循环通过重命名字段迁移到第二个,会正确终止,然后对一个每个请求都报错的批次报告成功,因为在这种形状下任务状态只说明处理停止了。反向迁移的循环会把 finalizing 当作未知状态。两种情况都不会被只走 happy path 的测试捕获。
状态值和字段名是任何 API 中变化最快的部分。在你写循环的当天要读两边的最新参考;上面的形状是这两个参考在撰写时的描述,在此列出是为了说明结构差异,而不是作为可复制的值。
Provider 会公布推荐的轮询间隔,它随预期任务持续时间而变化:不到一分钟完成的任务是秒级,多小时窗口的任务是几十秒或分钟级。把旧间隔移植到有不同任务画像的新 API,要么烧掉速率限制,要么给一个 30 秒就完成的任务凭空增加 10 分钟延迟。
跨两者都适用的形状是带完全抖动的上限指数退避,加上独立的挂钟截止时间:
interval(n) = random_between(base, min(cap, base * 2**n))
base 从 Provider 文档化的最小值开始,或 1-2s
cap 你能容忍的最长间隔,超过这个就感知不到完成了
jitter 在整个范围内随机化,这样由一次批量提交启动的
N 个 worker 不会保持同步
deadline 挂钟时间,与尝试次数无关
截止时间是挂钟而不是最大尝试次数,是最常被搞错的部分。指数增长下,"20 次后停止"对你选择的每个 base 和 cap 都是不同的时间长度,所以对间隔的调优修改会悄无声息地改变超时时间。用 Provider 文档化的任务窗口设置截止时间,并将其设为时长。
在这里完全抖动比普通重试逻辑更重要。在一个循环中提交一百个任务并在固定间隔轮询每个,会产生每间隔一次就同步的一百个请求;在整个窗口内随机化使它们分散开。与在 429 上正确退避的逻辑相同,只是应用在状态端点而非推理端点。
在修改任何东西之前,把 Provider 特定的部分提取成一个小接口:submit、一次轮询、把状态分类为 pending / done / failed / cancelled,以及获取结果。循环本身——计时、截止时间、错误处理——变成 Provider 无关的,每次切换不再需要重写。
把 classify 函数写成对新 Provider 文档化值的穷尽匹配,带一个显式的 default 分支,记录日志并将未知值视为 pending。永远不要把未知当作 done。
把终态和成功分开。对于形状中唯一终态不携带结果的情况,classify 函数必须返回 done-with-unknown-outcome,而下一步必须读取每个请求的结果。把它做成一个独立函数,这样就不能被跳过。
从新 API 文档化的任务窗口设置 base、cap 和 deadline,而不是从旧数字搬过来。在注释中记录每个值的来源,这样下一个人才能分清调优值和继承值。
把轮询错误和任务失败分开处理。状态端点上的 5xx、超时或 429 递增连续错误计数并重试;只有文档化的失败状态才会使任务失败。给错误计数设置自己的上限,这样永久损坏的端点仍然能终止。
在第一次轮询之前持久化任务记录,并在每次终态转换时更新它,这样进程重启后恢复轮询而不是丢失任务。只存在内存中的循环在部署时会丢失任务。
四个测试,每个用 stubbed client 实现都很便宜,每个覆盖一种否则只在生产环境才会出现的故障。第一,未知状态值:循环必须继续轮询并记录日志,不能崩溃也不能成功。第二,终态但子请求失败:任务必须报告为部分失败而不是成功。第三,三次连续的 5xx 响应然后一次成功:循环必须存活下来,成功的轮询不能计入错误预算。第四,一个永不终止的任务:截止时间必须触发并产生可区分的超时结果而不是通用失败,因为对它的操作响应不同——你去查看 Provider 的状态页。
如果新 Provider 也提供 Webhooks,通常正确的最终状态是两者兼有:Webhooks 作为快速路径,轮询作为低频协调扫描,处理那些通知从未到达的任务。这意味着同一个终态分类从两个入口点使用,这进一步说明它应该是一个函数而不是循环里的一个分支——通知那半边参见映射 Webhook payload 字段。
Mapping Webhook and Callback Payload Fields Between Providers
Testing Exponential Backoff on a 429 From an LLM Provider
Batch APIs: Half Price if You Can Wait