为什么我们不再使用 SDK

@alvinsng
英语2天前 · 2026年7月14日
536K
820
66
62
1.2K

TL;DR

Alvin Sng 解释了为何他的团队用自定义 HTTP 客户端替换了供应商 SDK,从而改善了调试体验、统一了可观测性并消除了依赖冗余。

我们停止使用 Stripe、WorkOS 和 Slack 的客户端 SDK,并且正在逐步迁移其余部分。取而代之的是,我们通过一个名为 HttpBaseClient 的小型封装类直接调用它们的 REST API。

这听起来有些倒退。SDK 本意是为开发者节省时间。但你一定听说过 AI 正在将更多代码移植到原生运行。同样的理念也适用于 SDK。为什么?

  • SDK 隐藏了底层细节,比如 HTTP 响应头和原始响应体——而这正是 Agent 在调试问题或向上游团队提供调查信息时所必需的关键信息。
  • SDK 期望获得规范的响应,但现实中,防火墙、负载均衡器和网关可能会返回格式错误的响应,这就掩盖了我们所需的调试细节。
  • SDK 绕过了重试、错误处理和可观测性等集中化入口点的强制约束,使得 Agent 可以自行编写其模式。
  • SDK 通常臃肿不堪,通常是从 OpenAPI 自动生成的,而我们只使用了其中极少的端点。

想想 SDK 的起源。它们之所以诞生,是因为公司希望让集成变得简单:发布一个共享库,节省客户时间,隐藏重复的集成工作。但 AI 改变了这一成本曲线。如今,集成一个 SDK 的工作量往往与直接调用 HTTP API 相差无几,而且编写一个专门用于我们所用端点的 REST 客户端既便宜又能提供更好的统一可观测性。

无休止的打地鼠游戏

Alvin Sng - inline image

如此"美好"的 JSON 响应

这就是我们 Sentry 错误仪表盘曾经的样子。我们会发现少量请求因某个代码路径失败,然后我们单独修补那条路径以帮用户处理 SDK 的意外。每天都有另一条代码路径触发意外错误;我们又去修补它,游戏永无止境。

SDK 在一种常见的生产故障模式中很脆弱:服务器表示出了问题,但响应并非 SDK 预期的 JSON 格式。

一个过载的 Nginx 网关返回了意想不到的 HTML。Cloudflare 拦截了一个请求。防火墙弹出限速页面。供应商的 API 通常返回 JSON,但其前面的组件并不总是供应商 API。

当这种情况发生时,许多 SDK 会尝试解析响应,但只会抛出一个通用的解析错误,然后丢弃有用的信息。原始响应体消失了。状态文本被埋没。HTTP 头通常不能以便捷的方式返回。如果供应商支持要求我们提供一个来自 HTTP 头的请求 ID,我们只能自认倒霉,因为 SDK 没有返回它。

Agent 滥用了 SDK 的直接性

我们的客户端 SDK 调用简直是一片混乱。Stripe 有一种模式,WorkOS 有另一种,Slack 有它自己的怪癖。其他集成则混合使用原始 fetch、SDK 调用、一次性重试逻辑,或者根本没有重试逻辑。

SDK 让错误的方式看起来很容易。Agent 可以直接访问供应商客户端,在任何路由中调用 stripe.customers.create(...)。这看起来很有成效,但它绕过了应该存放认证、重试、指标、日志和错误转换的共享位置。我们的代码库中散落着这样的闭包包装:

typescript
1const response = await catchRateLimitError(() =>
2 stripe.customers.retrieve(stripeCustomerId)
3);

如果你漏掉了某处,忘记封装 SDK 调用,那就惨了。Stripe 的限速和 WorkOS 的限速对我们的产品来说意思相同:上游要求我们放慢速度。但在类型层面,它们是完全不同的对象。有些代码捕获了 SDK 特定的异常,有些捕获了通用的 Error,有些什么也没捕获。结果就是 Sentry 打地鼠:在一个调用点修复一个 429,然后在另一处等待同类故障。

SDK 臃肿

NPM 包 openaianthropic-ai/sdk 是由 Stainless 根据 OpenAPI 规范自动生成的。Stripe 也是根据其 OpenAPI 规范生成的。这就是如何为大型 API 在数十种编程语言中维护公共 SDK 的规模化方式。

但优秀的公共 SDK 必须为所有人服务。我们的后端不需要所有人的 SDK。它只需要我们使用的八个 Stripe 端点、WorkOS 用户和组织端点,以及我们实际调用的 Slack 方法。Stripe 大小为 6.5 MB,workos-inc/node 为 6.9 MB,slack/web-api 为 7.7 MB,linear/sdk 为 34 MB。更极端的,googleapis 达到了 198 MB。

生成的 SDK 带来了整个平台:数百个方法、重载、分页辅助函数、重试行为、环境检测、兼容性垫片,以及不能删除的旧接口,因为某个地方的某个人仍然依赖它们。在我们自己的后端中,这种通用性通常是臃肿的。更糟糕的是,它挡在了我们和网络连接之间。

我们忘记了 HTTP API 就是 API 契约

人们有时会把 HTTP API 当作较低级别的实现细节,而把 SDK 当作真正的稳定接口。公共 REST API 是一个契约。 供应商不能随意破坏它。SDK 作者也明白这一点,因为他们无法强制每个客户升级。许多客户在生产环境中运行着多年前的 SDK 版本,这意味着旧的网络协议必须继续工作。

我们自己的 HttpBaseClient

HttpBaseClient 是我们用来替代客户端 SDK 的方案。Provider 子类提供供应商特定的部分:基础 URL、认证头、内容类型、错误映射,以及我们实际使用端点的精确方法。HttpBaseClient 负责其余部分:序列化、解析、传输错误、结构化日志、指标、状态映射和持续时间追踪。这统一了可观测性,使得每个供应商都遵循一致的标准。以下是简化后的结构:

typescript
1abstract class HttpBaseClient<TEndpoint extends string> {
2 protected abstract readonly baseUrl: string;
3
4 protected constructor(private readonly dependency: string) {}
5
6 protected abstract buildAuthHeaders(): Promise<Record<string, string>>;
7
8 protected async request<TBody, TResponse>(config: {
9 method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
10 path: string;
11 endpoint: TEndpoint;
12 body?: TBody;
13 }): Promise<TResponse> {
14 const url = `${this.baseUrl}${config.path}`;
15 const headers = await this.buildAuthHeaders();
16 const labels = {
17 dependency: this.dependency,
18 endpoint: config.endpoint,
19 method: config.method,
20 };
21 const start = performance.now();
22
23 logInfo('上游请求开始', labels);
24
25 try {
26 const response = await callWithMetrics(
27 () =>
28 fetch(url, {
29 method: config.method,
30 headers,
31 body: config.body === undefined ? undefined : JSON.stringify(config.body),
32 }),
33 this.dependency,
34 labels
35 );
36
37 const body = await parseBody(response);
38 if (!response.ok) throw this.mapHttpError(response, body);
39
40 logInfo('上游请求成功', {
41 ...labels,
42 statusCode: response.status,
43 durationMs: performance.now() - start,
44 });
45
46 return body as TResponse;
47 } catch (cause) {
48 logWarn('上游请求失败', {
49 ...labels,
50 durationMs: performance.now() - start,
51 cause,
52 });
53 throw cause;
54 }
55 }
56}
57
58// 然后 Stripe 封装变得简洁明了:
59enum StripeEndpoint {
60 CustomersCreate = 'stripe/customers/create',
61}
62
63class StripeHttpClient extends HttpBaseClient<StripeEndpoint> {
64 protected readonly baseUrl = 'https://api.stripe.com';
65
66 constructor(private readonly apiKey: string) {
67 super(ClientVendor.Stripe);
68 }
69
70 createCustomer(body: { email: string; name: string }) {
71 return this.request<typeof body, Stripe.Customer>({
72 method: 'POST',
73 path: '/v1/customers',
74 endpoint: StripeEndpoint.CustomersCreate,
75 body,
76 });
77 }
78
79 // 更多端点在此添加。
80}

窍门就在于此。这个类并不试图模拟整个 Stripe,而是模拟我们希望每个供应商调用都具备的 HTTP 行为。Stripe 仍然使用表单编码,WorkOS 仍然使用 Bearer 认证和 JSON 主体,Slack 仍然有它奇怪的 ok: false 行为(即使 HTTP 状态码为 200)。但后端其余部分看到的是一致的形式。

你可以在这里查看我们 HttpBaseClient 的更长版本,为了便于阅读,已修改为通用版本。

我们仍然使用 SDK 的地方

我们当前的方法是混合的:运行时使用自己的 HTTP 客户端,但在类型仍然能节省时间的地方保留 SDK。StripeHttpClient 可以返回 Stripe.Customer,SlackHttpClient 可以借用 slack/web-api 的参数类型,WorkOS 类型仍可描述网络响应。

我预计随着时间推移,我们也会逐步淘汰这一点。随着 AI 在生成和维护我们所需的精确请求和响应类型方面越来越强,为了类型而保留整个 SDK 包的理由会越来越弱。但运行时行为是最痛苦的部分,所以迁移从这里开始。

当 SDK 本身就是产品边界,而不仅仅是 REST 的封装时,我们仍然使用 SDK。最明显的例子是可观测性。对于 Sentry,SDK 处理运行时插桩、错误捕获、作用域传播、发布元数据以及我们不想自己实现的集成。这与使用供应商 SDK 作为普通后端 HTTP 调用的薄客户端是不同的。

并非所有 API 都是基于 HTTP 的 REST,这没问题。数据库调用就是一个很好的例子。我们的底层抽象是 BaseClient:它为每个客户端提供相同的指标、日志和错误处理契约,同时允许子类根据其传输方式覆盖"fetch"的含义。

我们的前进方向

开发者将把 API 文档视为真正的集成指南,而把 SDK 视为参考实现:认证、负载、分页、重试和边界情况的模板。下一版本的"发布 SDK"可能是"发布 Agent 技能",教会 Agent 如何正确调用 API、复用恰当的模式,并避免将每个运行时调用推入供应商包中。

一键保存

使用 YouMind AI 深度阅读爆款文章

保存原文、追问细节、总结观点,并在一个 AI 工作空间里把爆款文章沉淀成可复用笔记。

了解 YouMind
写给创作者

把你的 Markdown 变成干净的 𝕏 文章

图片上传、表格、代码块,往 𝕏 上手动重排太痛苦。YouMind 把整篇 Markdown 一键转成干净、可直接发布的 𝕏 文章草稿。

试试 Markdown 转 𝕏

更多可拆解样本

近期爆款文章

探索更多爆款文章