site logo

Marico's space

为 AI 代理设计 API:幂等性、机器可读错误与 202 + Webhooks

AI技术与应用 2026-10-05 14:48:53 8

最近在给一个 AI 代理项目对接第三方服务,踩了几个 API 设计上的坑,回头一看,发现问题几乎都是出在「API 其实是给人设计的,AI 代理根本读不懂」这个点上。写出来给有类似困扰的同学参考。

大多数 API 在设计时只考虑了两个使用者:一个是读文档的人,另一个是写完代码就不再动的人。AI 代理两边都不沾——它在运行时从机器可读的描述里发现接口,靠字段名匹配来填参数,遇到失败就自动重试,把五个调用串在一起跑也不需要人盯着每一步。给人用着顺手的设计,给代理用起来可能完全是另一个样子,直到系统里出现了重复扣款或者记录被删掉,才发现已经晚了。

好消息是:让 API 对代理友好的那些特性,其实就是那些早就存在的好设计原则。这篇整理六个最关键的点,配上具体的请求和响应示例。

1. 所有变更请求都要是幂等的

代理会重试你的调用。可能因为连接断了,可能因为上下文窗口在任务中途被压缩了,也可能因为用户说「再试一次」。如果 POST /charges 重试时会创建第二笔扣款,那代理迟早会搞出重复订单来。

在所有非 GET 操作上接受幂等键(Idempotency Key),并持久化结果:

POST /v1/refunds HTTP/1.1
Idempotency-Key: ord_8821.refund.2026-10-14.01
Content-Type: application/json {"order_id": "ord_8821", "amount": 4900, "reason": "duplicate_shipment"}

第一次请求正常处理。用同一个 key 重放时,返回存储好的响应,不管原请求是成功还是以已知方式失败。Key 需要按认证客户端隔离作用域,在文档化的时限后过期(常见下限是 24 小时)。在操作描述里明确写出来——懂幂等键的代理会自动生成稳定的 key。

2. 错误是数据,不是文字描述

人看到 "Something went wrong, please try again later." 会去开 Slack。代理看到这个,要么盲目重试,要么自己瞎编一个修复方案。RFC 9457 Problem Details 给错误赋予了类型、状态码,以及字段级验证详情稳定存放的位置:

{ "type": "https://errors.example.com/insufficient-inventory", "title": "Insufficient inventory", "status": 409, "detail": "Requested 20 units of SKU-7; only 3 are reserved for this account.", "instance": "/v1/orders", "retryable": false, "errors": [ { "field": "quantity", "code": "above_available_limit", "value": 20, "max": 3 } ]
}

三个信息决定代理下一步做什么,而且这三个都应该放在机器可读的响应体里:

  • 能否重试? 用布尔值比从状态码区间推断可靠得多。
  • 什么时候重试? 对于 429 和 503,遵守 Retry-After 秒数,不要自己猜。
  • 哪个参数有问题? 字段级错误让代理能修正并自我重新提问;一个泛泛的 400 只能让它瞎蒙。

3. 长任务从不阻塞请求

代理是急躁的调度器:如果一次调用耗时 45 秒,链路里某个地方就会超时然后重试。长耗时操作必须立刻返回一个任务句柄。202 Accepted 加上状态查询接口是最适合代理的形态:

HTTP/1.1 202 Accepted
Location: /v1/jobs/job_4f2a
Retry-After: 5 {"job_id": "job_4f2a", "status": "queued", "status_url": "/v1/jobs/job_4f2a"}
{ "job_id": "job_4f2a", "status": "succeeded", "result_url": "/v1/reports/rpt_91" }

终态需要一个穷举的枚举(queued、running、succeeded、failed、canceled),而且 failed 必须携带和同步调用相同格式的结构化错误。对于事件驱动的代理,除了轮询之外还要提供 Webhook,并在 OpenAPI 3.1 的 webhooks 对象里把它文档化,这样代理(或它的宿主)就能订阅而不是空转轮询。

4. Schema 要严格、明确、复用组件

代理在填表单。它能做到多好,完全取决于表单允许它做多好:

  • 在请求体上设 "additionalProperties": false,这样幻觉出来的字段会在边界被拒绝并返回清晰的错误,而不是被静默忽略。
  • 对闭合的值集合用 enum 或 const;绝对不要把状态编码成没文档化的整数。
  • 可选性要诚实。实际业务里必填的字段必须标为 required;代理会把可选字段当可以忽略的。
  • 用 type: ["string", "null"] 显式表达可空性,别再用老的 nullable 写法。
  • 在 components/schemas 里复用命名后的 Schema。创建、查询、列表返回同一个 Order,代理就只需要学一个概念而不是三个。

当同一套 Schema 同时驱动生成的 MCP 工具时,严格性的价值会叠加——工具的输入 Schema 就是 OpenAPI Schema,不存在第二份描述导致漂移的问题。

5. 分页和命名要无聊

花哨的 URL 和游标格式对人类(他们只点链接)来说零成本,对代理(它们要自己构造请求)来说代价不小。两条规则:

  • 每个集合都返回稳定的分页包装,使用不透明游标和明确的终止条件,永远不要返回无界限数组。
  • 操作行为遵循 HTTP 方法语义。GET 绝不变更数据,DELETE 是幂等的,PUT 是替换,PATCH 是更新。代理从 HTTP 语义推断意图;在这一点上让它们失望会产生最糟糕的 bug,因为调用「成功了」。

6. 描述行为,不只是形状

只列字段的 OpenAPI 文档等于让代理去猜它根本看不见的规则。描述里要带上会改变决策的操作事实:

paths: /v1/orders: post: summary: Create an order description: > Creates a pending order and reserves inventory for 15 minutes. Payment must be captured within that window or the reservation is released. Safe to retry with the same Idempotency-Key. parameters: - in: header name: Idempotency-Key required: true schema: { type: string, maxLength: 128 }

副作用、时间窗口、分层速率限制、排序保证(「事件可能乱序到达,按 sequence 排序」)——这些正是人类能从高级工程师那里获得的上下文,也是代理否则会幻觉出来的东西。

收益:一个描述,所有使用者

这六个实践到位之后,OpenAPI 文档就成了一份对非人类调用者真正完整的契约。从同一份源文件生成人类用的参考文档、代码用的类型化客户端、代理用的 MCP 工具,每个消费者看到的是同一套幂等规则、同一套错误分类、同一套严格 Schema。代理不再是一个特殊的集成问题——它只是一个设计良好的 API 的另一个客户端。