Agent 调用了您的视频转码接口。该接口返回了 202 Accepted 和一个任务 ID。但 Agent 根本不知道 202 在您的系统里意味着什么,它直接汇报转码已完成,并继续执行下一步——读取一个甚至还不存在的文件。
耗时操作会以一种特定的方式使 Agent 崩溃。同步调用有一个显式的契约:发送请求、等待、获取响应。而异步调用则将这一过程拆分为“开始”和“结束”,而两者的间隔正是 Agent 容易混淆的地方。它们会过早声明成功,在紧密循环中轮询上千次,或者在长达六分钟的时间里一直保持阻塞状态,占用着会话轮次。
本指南将介绍如何设计异步契约以便 Agent 能够遵循,何时该轮询、何时该移交,如何编写工具来规范模型的行为,以及如何测试包括慢响应和失败案例在内的整个路径。我们关于 Agent 错误恢复的博文涵盖了 API 调用失败的情况;而本文则重点关注那些响应缓慢但最终成功的调用。
当您需要验证 Agent 如何处理一个耗时四分钟然后失败的任务时,Apifox 就能派上用场——您绝对不想在生产环境中才发现这类问题。
AI Coding 交流群
如果你也在用 AI 写代码,或者正在研究 Cursor、Claude Code 这些工具,欢迎加入以下交流群。群里平时会聊一些 AI 编程的实际用法、开发工作流,还有各种新工具和新玩法。
为什么 Agent 无法妥善处理异步操作
以下三个习惯是导致大部分问题的原因。
模型会将 2xx 视为已完成。202 表示请求已被接受并等待处理,而 HTTP 语义规范 明确指出处理可能尚未完成。在普通请求/响应流量上训练出来的模型,往往倾向于将任何 2xx 状态码都解读为已完成,除非响应内容中以文字形式明确说明并非如此。
循环的成本非常高。如果 Agent 在其推理循环内部进行轮询,每一次检查都会消耗一个模型轮次,加上之前对话的 Token。对于一个耗时四分钟的任务,每两秒轮询一次就是 120 个轮次,这要么会耗尽上下文窗口,要么会超出预算。我们关于如何避免将工具响应写入上下文窗口的文章,解释了为什么这部分的消耗累积速度会远超人们的预期。
Agent 会跟丢任务。启动任务并返回任务 ID 的工具创建了 Agent 必须维护的状态。如果该 ID 夹在冗长对话的中间,可能会因为上下文压缩而被遗漏,导致 Agent 忘记自己还有一个正在进行的任务。
设计响应,避免模型产生误判
最有效的解决方案是措辞,而不是架构。无论您的状态码是什么,都要在 body 中明确说明发生了什么以及接下来该怎么做。
{
"status": "processing",
"job_id": "job_7f21c",
"message": "The transcode has STARTED and is NOT complete. Do not report success. Check status with getJobStatus(job_id) after at least 30 seconds.",
"poll_after_seconds": 30,
"estimated_duration_seconds": 240,
"status_url": "/v1/jobs/job_7f21c"
}
对于人类 API 消费者来说,这显得有些累赘。但它的目标是模型,而相比于让模型从状态码中推断含义,模型遵循 response body 中明确指令的可靠性要高得多。起作用的有三个细节:短语“not complete”(未完成)、指定的下一个工具以及最小等待时间。
Google 关于长期运行操作的 AIP-151 为此描述了一种清晰的资源形态,即通过一个包含 done、error 和 response 字段的单 Operation object。借鉴该结构可以让你在每个慢速接口中保持一致的表现,这非常重要,因为 Agent 一旦学会了一种轮询模式,就能处理所有类似的接口。
保持状态响应同样简单直接:
{
"job_id": "job_7f21c",
"status": "processing",
"done": false,
"progress_percent": 45,
"elapsed_seconds": 108,
"poll_after_seconds": 45,
"message": "Still processing. Do not proceed to the next step."
}
在任务完成时,如果结果较小,则以内联(inline)方式直接返回结果,这样 Agent 就不需要进行第三次调用:
{
"job_id": "job_7f21c",
"status": "succeeded",
"done": true,
"result": { "output_url": "https://cdn.example.com/out/7f21c.mp4", "duration_seconds": 372 }
}在模型外部进行轮询,而不是在内部
最核心的实现选择是:将等待过程放在你的工具包装器(wrapper)中,而不是放在 Agent 的推理循环中。
import time
def start_and_await_transcode(client, source_url, max_wait=600):
job = client.post("/v1/transcode", json={"source_url": source_url}).json()
job_id = job["job_id"]
delay = job.get("poll_after_seconds", 5)
waited = 0
while waited < max_wait:
time.sleep(delay)
waited += delay
status = client.get(f"/v1/jobs/{job_id}").json()
if status.get("done"):
if status["status"] == "succeeded":
return {"status": "succeeded", "result": status["result"]}
return {"status": "failed", "error": status.get("error")}
delay = min(int(delay * 1.5), 60)
return {
"status": "timed_out",
"job_id": job_id,
"message": f"Still running after {max_wait}s. Job {job_id} continues in the background.",
}
从模型侧来看,这只是一个耗时较长并返回最终答案的工具调用。上下文中不会出现轮询循环,不会遗忘任务 ID,也不会产生 120 次交互轮数。退避(backoff)机制使请求数量保持在合理范围内,而上限设置则可以防止卡住的任务使运行永久挂起。在调整这些参数之前,亚马逊关于超时、重试和抖动退避的专文是非常值得阅读的参考资料。
遵循两个规则可以确保其安全性。一是始终设定等待时间上限,二是超时时始终返回 job ID,以便 agent 或人工后续检查。切勿返回含糊不清的结果:succeeded、failed 和 timed_out 是三种截然不同的结果,模型应该看到这三个不同的词。
对于执行时间以小时而非分钟计的任务,在包装器内进行轮询就失去了意义。此时,更合适的模式是使用两个工具:一个用于启动任务,另一个用于检查状态,并结合在对话之外对未完成任务的持久化记录,以防因上下文压缩而丢失信息。存储 job_id、其所属的任务以及启动时间,并让 agent 在每次运行开始时读取该列表。
何时 Webhook 是更好的选择
轮询简单且通用。回调效率更高,但运行起来更费功夫。我们的 Webhook 与轮询对比文章中详细介绍了这种权衡,而针对 agent 的具体场景,选择范围则会更窄。
当任务耗时在数秒到数分钟之间、agent 需要等待结果才能继续,或者你无法托管公共接口时,请使用轮询。大多数 agent 的工作负载都属于这一类。
当任务耗时数小时、agent 触发任务后即可继续其他工作,或者有大量并发任务且逐个轮询非常浪费资源时,请使用 Webhook。这需要付出实际的成本:你需要一个公共接收端、签名验证、重试处理,以及在回调到达时唤醒 agent 的方法。我们关于设计可靠 Webhook 和 Webhook 签名验证的指南涵盖了这些基础工作。
还有一种折中方案值得了解。通过 server-sent events(SSE)流式传输任务进度可以提供推送语义,且无需公共接口,因为客户端保持着连接。它适用于有人工监督的交互式 agent,我们关于使用 SSE 流式传输 API 响应的指南涵盖了具体实现。
无论选择哪种方式,完成路径都必须是幂等的。Webhook 会重试,轮询会产生竞态条件,而看到两次 “succeeded” 的 agent 不应该将下游步骤启动两次。我们关于 AI agent 幂等性的文章介绍了保障其安全的关键要素。
不仅要测试快速路径,还要测试慢速路径
异步 Bug 极易隐藏,因为测试环境运行速度很快。在生产环境中需要 4 分钟的任务,在本地存根(stub)上只需 200 毫秒即可完成,因此 agent 永远无法经历实际会遇到的状态。
有四个场景值得刻意构建。
真正缓慢的任务。 对状态接口进行 mock,使其在排在前面的几次调用中返回 processing,之后再返回 succeeded。这可以验证包装器是否进行了轮询、退避并最终返回。在 Apifox 中,你可以通过根据请求次数或控制参数而变化的 mock 来驱动此过程,从而使每次运行相同测试的结果都保持一致。

延迟失败的任务。 返回三次 processing,然后返回带有错误 body 的 failed。Agent 必须报告失败,而不是将完成的轮询误认为是已完成的任务。如果处理不当,这种情况会导致数据静默丢失。
超时。 让 mock 持续返回 processing 并超过封装器的上限时间,然后断言该工具返回 timed_out 且保留完整的任务 ID,而不是抛出异常或返回虚假的成功。
重复完成。 通过 webhook 重试或竞态轮询两次交付成功状态,并断言下游步骤只运行一次。
将这四种情况保存为测试场景,以便它们可以在 CI 中运行。它们的运行成本极低,并且能够捕获因有人缩短超时时间或吞掉错误而导致的回归问题。更广泛的方法可以在我们的 API 契约测试指南中找到。
暴露该问题的三种任务
报表生成。 一个财务 Agent 请求季度导出。这需要 90 秒。在使用原生工具时,Agent 只是获取一个任务 ID,宣布报表已准备就绪,然后向用户提供一个失效的下载链接。而使用阻塞式封装器时,它会等待 90 秒并返回真实的 URL。相同的 API,截然相反的结果,唯一的区别在于等待发生的地点。
批量导入。 一个运营 Agent 上传了 20,000 条记录。导入运行了 8 分钟,并在第 14,000 行处部分失败。这种情况是对简单成功校验机制的考验:任务已经结束,因此状态确实为 done(为 true),但结果中包含了一组被拒绝的行。应当明确返回部分结果及数量,并让 Agent 在继续下一步之前读取这些内容。
模型与构建流水线。 Agent 触发了一个需要 40 分钟的训练运行或 CI 构建。在这种情况下,封装器内轮询是错误的设计;该运行会导致当前轮次占用时间过长。应该启动任务,将 ID 记录在持久化存储中,结束当前轮次,并让定时检查或回调来唤醒后续流程。我们关于多 Agent 交接和上下文传递的文章中,详细介绍了如何在不同运行之间转移状态而不会丢失。
定义部分结果的结构
长期运行的任务通常结束于成功与失败之间的某种中间状态,而双状态模型会迫使你对此做出妥协。请明确定义第三种状态:
{
"job_id": "job_a11f",
"status": "completed_with_errors",
"done": true,
"summary": { "processed": 20000, "succeeded": 19860, "failed": 140 },
"errors_url": "/v1/jobs/job_a11f/errors?limit=50",
"message": "Import finished. 140 rows failed and were not written. Review errors before reporting success."
}
在该 payload 中,有两点至关重要。数量信息是内联的,因此 Agent 无需进行另一次调用即可做出决策。失败的行被置于带有限制(limit)的 URL 之后,这样 140 个错误 object 就不会在未经请求的情况下直接塞进上下文中。
必须有人关注停滞的任务
超时路径以返回任务 ID 和“工作仍在运行”的消息而结束。这是正确的返回值,且只有当它传递给人工时才是有用的。
如果 agent 是你自己的服务,请将其路由到你团队已经在监控的任何队列中。如果 agent 是处理分配任务的编码运行时,运行它的平台通常会有地方来接收这些任务。在 HiFox 中,因受阻而结束的运行会保持在其“任务(Task)”中,并保留其执行状态和结果,而“收件箱(Inbox)”则会将需要人工回复或审核的项与普通更新区分开来。关键不在于具体的工具,而在于“仍在运行,稍后检查”需要有一个负责人,否则它就会变成“无人检查”。

简短的检查清单
- 每个慢速接口都返回一个任务 ID、一个状态 URL 以及一条用通俗语言说明工作尚未完成的消息。
- 状态响应包含一个布尔类型的
done字段,而不仅仅是需要模型去解析的字符串。 - 轮询逻辑存在于工具封装器中,并带有指数退避机制和硬上限。
- 超时会返回任务 ID,以便工作可以恢复而不是丢失。
- 成功、失败和超时是三个不同的返回值。
- 对于任何耗时超过几分钟的任务,正在执行的任务都会在对话之外进行记录。
- 无论信号是通过轮询还是通过回调到达,完成处理逻辑都是幂等的。
- 针对慢速、后期失败、超时和重复完成的场景,都保存了相应的测试。
只要处理好响应的措辞和封装器,长时间运行的操作就不再是 Agent 的特例。它调用工具、等待并获取答案,这是它最擅长处理的约定。下载 Apifox,在编写测试的同时构建慢速任务的 mock。
常见问题解答
异步启动时,API 应该返回 202 还是 200? 202 Accepted 是更准确的状态码,它向标准客户端表明处理尚未完成。对于 Agent 而言,不要仅仅依赖状态码,因为模型最容易可靠读取的是 body。建议两者结合使用。
在放弃之前,工具封装器应该等待多久? 将上限设置得略高于接口实际可能的最坏情况,通常是二到十分钟。超过这个时间,封装器阻塞单轮对话的时间就太长了,此时采用“稍后检查”工具会是更好的设计。
我应该使用多大的轮询间隔? 如果服务端提供了 poll_after_seconds 提示,就以此为起点,然后以大约 1.5 的系数进行退避,并将上限设在 60 秒左右。固定的 1 秒轮询会浪费请求,并可能触发频率限制,这在我们关于“超出速率限制”的指南中有详细介绍。
Agent 在等待时可以做一些有用的事情吗? 只有当你的编排器支持并发工具调用时才可以。如果支持,可以先启动任务,执行其他独立工作,然后检查状态。如果不支持,阻塞式封装器比手动编写的调度器更简单、更不容易出错。
如何阻止 agent 过早宣布成功? 在响应 body 中以文字形式说明,暴露一个布尔类型的 done 字段,并使完成工具成为呈现结果的唯一位置。如果启动响应中不包含任何结果,模型就无需报告任何输出结果。
Webhook 适用于在笔记本电脑上运行的 agent 吗? 无法直接使用,因为没有公开的接口。开发时请使用隧道,如我们关于使用 Webhook 服务测试 localhost API 的指南中所示,或者在 agent 运行在可寻址的位置之前,坚持采用轮询方式。
开发必备:API 全流程管理神器 Apifox
介绍完上文的内容,我想额外介绍一个对开发者同样重要的效率工具 —— Apifox。作为一个集 API 文档、调试、设计、测试、Mock、自动化测试于一体的工具,Apifox 是目前提升研发效率的首选。
如果你正在开发项目,不妨试试其极其友好的界面设计,它完全兼容 Postman 和 Swagger 数据格式,导入数据非常方便,,即使是新手也能很快上手,点击这里即可注册使用。

值得一提的是,除了个人和常规团队使用,针对有高安全合规要求、或需要在内网环境协作的企业,Apifox 还提供了深度定制的私有化部署方案。
获取专属报价与部署方案
详细的私有化部署系统架构与安全白皮书
针对您公司规模的专属报价单
免费的 1v1 专属产品演示 (Demo) 机会