571 个异构接口怎么归一成 Agent 契约

工程拆解:SandBase 如何把 571 个社媒 API(GET/POST/不同 body 类型)归一成一个 /v1/run 契约,含命名规则、body 包装和 fail-closed 校验。

先说结论 — 571 个社媒 API 操作,来自四个平台,HTTP 方法不同、参数位置不同、body 形状不同、命名规则不同。一个 adapter 层把它们全部归一到 POST /v1/run。本文讲四个最难的工程决策:命名归一、root body 包装、参数位置映射、fail-closed 校验。

你有 571 个 API 操作。有的是 GET + query 参数。有的是 POST + JSON 对象。有的是 POST + 顶层数组。还有一个是 POST + 可选顶层字符串。它们来自四个不同平台,命名规则各不相同。

你的 agent 不该关心这些。

Agent 调 POST /v1/run,发 {"model": "douyin/search/challenge-search-v1", "keyword": "AI创作"},拿回 JSON。底层是 GET 还是 POST、参数放 query 还是 body——那是 adapter 的事。

下面是我们怎么做的。

问题空间

维度发现的变体
HTTP 方法GET(255 个)、POST(316 个)
参数位置query string、path、JSON body、混合
Body 形状JSON 对象(309)、root 数组(4)、root 可选字符串(2)、无 body(255 GET)
命名模式fetch_video_search_resultget_unique_idhandler_hot_search
响应形状JSON 对象(归一后全部)

目标:agent 看到一个扁平命名空间、发一个 JSON body、收一个 JSON 响应。其他全部隐藏。

决策 1:命名归一

规则

canonical_name = {platform}/{normalized-channel-version}/{semantic-action}

步骤:

  1. 去掉外层路径前缀 /api/v1/
  2. 归一频道+版本:douyin/app/v3/app-v3
  3. 剥掉动作前缀:fetch_get_handler_ 开头的去掉
  4. 剥掉冗余平台 token:动作里重复平台名的去掉
  5. 转 kebab-case:multi_video_v2multi-video-v2

前后对比

上游路径归一名称
/api/v1/douyin/search/fetch_video_search_resultdouyin/search/video-search-result
/api/v1/tiktok/web/get_unique_idtiktok/web/unique-id
/api/v1/weibo/web/v2/fetch_hot_searchweibo/web-v2/hot-search
/api/v1/xiaohongshu/web/v3/fetch_hot_listxiaohongshu/web-v3/hot-list

为什么不直接用原始名?

  1. Agent 可读性douyin/search/video-search-result 一眼看出平台/频道/动作
  2. 防碰撞:571 个操作里多个平台可能都有 hot_search,平台前缀保证唯一
  3. 身份稳定:上游把 fetch_hot_search 改成 get_hot_search,归一名不变

571 个名称全部验证唯一(碰撞 = 0)。规则:碰撞了整批导入失败,不静默去重。

决策 2:Root body 包装

问题

/v1/run 期望 JSON 对象。但有的上游期望顶层数组或字符串。

方案:body 字段包装

// 顶层数组场景 — agent 发:
{"model": "douyin/app-v3/multi-video-v2", "body": ["id_1", "id_2", "id_3"]}
// adapter 拆包后发上游:
["id_1", "id_2", "id_3"]

// 可选顶层字符串 — agent 发:
{"model": "douyin/search/query-user", "body": "张三"}
// adapter 发上游:
"张三"

// 可选字符串省略 — agent 发:
{"model": "douyin/search/query-user"}
// adapter 发空 body

571 个操作中:4 个是 root 数组、2 个是 root 可选字符串、565 个是标准对象。元数据里用 root_body_type 字段标记。

决策 3:参数位置映射

Agent 永远发 JSON body。但 GET 操作需要 query 参数、有的需要 path 参数。

位置Agent 发什么Adapter 产出什么
queryJSON 字段URL query 参数
pathJSON 字段替换进 URL 路径
body(对象)JSON 字段POST body 里的字段
body(root 数组/字符串)body 字段拆包后作为 POST body

零值处理

false0、空数组是合法的参数值。Adapter 不能把”字段存在但值是 falsy”和”字段不存在”搞混。

// agent 发:
{"model": "...", "room_id": "123", "include_offline": false}
// adapter 必须产出:
// GET ...?room_id=123&include_offline=false
// 不是:GET ...?room_id=123(丢掉 false 会改变语义)

规则:只有真正缺失的字段才省略。nullfalse0""[] 全部传递。

决策 4:Fail-closed 校验

哲学

有疑问就拒绝。不静默透传未知字段、不猜缺失的必填参数、不耍小聪明。

条件行为理由
未知字段名拒绝 + 报错防止 typo 变成静默数据丢失
必填字段缺失拒绝 + 报错上游反正会失败,不如提前报清楚
Path 参数还有 {placeholder}拒绝说明必填 path 参数没给
Cookie/Header 参数拒绝安全:agent 不能注入 auth header
不支持的 body content type拒绝只支持 JSON

报错示例

// agent 发了一个不存在的字段:
{"model": "douyin/search/challenge-search-v1", "keyword": "AI", "typo_field": "x"}

// adapter 返回:
{"error": "unknown field 'typo_field' for douyin/search/challenge-search-v1. Valid: keyword, count, cursor"}

可操作的错误信息:告诉你哪个字段错了、合法的有哪些。Agent 可以自我纠正。

结果

从 agent 视角:

# 571 个操作用完全相同的方式调用
response = client.chat.completions.create(
    model="douyin/search/challenge-search-v1",
    messages=[],
    extra_body={"keyword": "AI创作", "count": 20}
)

不用想 HTTP 方法。不用想参数位置。不用想 body 形状。不用想鉴权。

571 个操作,一个契约。

接受的取舍

  1. 严格校验 = 开发初期报错多:但每个错误都自带修正信息(告诉你合法字段)。选可调试性而非宽容性。
  2. 命名归一丢失了原始标识符:你不能用上游的 operationId 直接调。但归一名更可读、更稳定。
  3. 全部 sync-only:数据 API 返回完整结果,没有部分响应可以流式。契约简单但不支持”先拿前 10 条”。
  4. 禁止 Cookie/Header 注入:有些上游接受可选 cookie。我们全部封死。安全优先于极少数场景的便利。

FAQ

为什么不直接透传原始 API?

因为 agent 调 10 个不同操作就得理解 10 套规则:哪些是 GET、哪些是 POST、参数放哪、错误长什么样。这种认知负担违背 agent 平台的本意。一个契约 = 一条代码路径 = 一个错误处理器。

上游加了新操作怎么办?

Importer 跑最新上游 spec,应用命名规则,验证唯一性(571 个必须保持无碰撞),生成新元数据。如果新操作和已有的名字冲突,整批失败,人工解决。

上游破坏性变更怎么处理?

如果上游改了参数 schema,adapter 校验会捕捉不匹配:agent 发旧参数会得到清晰错误,操作的元数据更新后才恢复。没有静默降级。

抖音接口全目录看 310 个抖音接口。微博+小红书看 微博小红书上线。为什么 agent 需要这种归一化看 社媒 API 选型