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_result、get_unique_id、handler_hot_search |
| 响应形状 | JSON 对象(归一后全部) |
目标:agent 看到一个扁平命名空间、发一个 JSON body、收一个 JSON 响应。其他全部隐藏。
决策 1:命名归一
规则
canonical_name = {platform}/{normalized-channel-version}/{semantic-action}
步骤:
- 去掉外层路径前缀
/api/v1/ - 归一频道+版本:
douyin/app/v3/→app-v3 - 剥掉动作前缀:
fetch_、get_、handler_开头的去掉 - 剥掉冗余平台 token:动作里重复平台名的去掉
- 转 kebab-case:
multi_video_v2→multi-video-v2
前后对比
| 上游路径 | 归一名称 |
|---|---|
/api/v1/douyin/search/fetch_video_search_result | douyin/search/video-search-result |
/api/v1/tiktok/web/get_unique_id | tiktok/web/unique-id |
/api/v1/weibo/web/v2/fetch_hot_search | weibo/web-v2/hot-search |
/api/v1/xiaohongshu/web/v3/fetch_hot_list | xiaohongshu/web-v3/hot-list |
为什么不直接用原始名?
- Agent 可读性:
douyin/search/video-search-result一眼看出平台/频道/动作 - 防碰撞:571 个操作里多个平台可能都有
hot_search,平台前缀保证唯一 - 身份稳定:上游把
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 产出什么 |
|---|---|---|
query | JSON 字段 | URL query 参数 |
path | JSON 字段 | 替换进 URL 路径 |
body(对象) | JSON 字段 | POST body 里的字段 |
body(root 数组/字符串) | body 字段 | 拆包后作为 POST body |
零值处理
false、0、空数组是合法的参数值。Adapter 不能把”字段存在但值是 falsy”和”字段不存在”搞混。
// agent 发:
{"model": "...", "room_id": "123", "include_offline": false}
// adapter 必须产出:
// GET ...?room_id=123&include_offline=false
// 不是:GET ...?room_id=123(丢掉 false 会改变语义)
规则:只有真正缺失的字段才省略。null、false、0、""、[] 全部传递。
决策 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 个操作,一个契约。
接受的取舍
- 严格校验 = 开发初期报错多:但每个错误都自带修正信息(告诉你合法字段)。选可调试性而非宽容性。
- 命名归一丢失了原始标识符:你不能用上游的 operationId 直接调。但归一名更可读、更稳定。
- 全部 sync-only:数据 API 返回完整结果,没有部分响应可以流式。契约简单但不支持”先拿前 10 条”。
- 禁止 Cookie/Header 注入:有些上游接受可选 cookie。我们全部封死。安全优先于极少数场景的便利。
FAQ
为什么不直接透传原始 API?
因为 agent 调 10 个不同操作就得理解 10 套规则:哪些是 GET、哪些是 POST、参数放哪、错误长什么样。这种认知负担违背 agent 平台的本意。一个契约 = 一条代码路径 = 一个错误处理器。
上游加了新操作怎么办?
Importer 跑最新上游 spec,应用命名规则,验证唯一性(571 个必须保持无碰撞),生成新元数据。如果新操作和已有的名字冲突,整批失败,人工解决。
上游破坏性变更怎么处理?
如果上游改了参数 schema,adapter 校验会捕捉不匹配:agent 发旧参数会得到清晰错误,操作的元数据更新后才恢复。没有静默降级。
抖音接口全目录看 310 个抖音接口。微博+小红书看 微博小红书上线。为什么 agent 需要这种归一化看 社媒 API 选型。


