我们停止使用 Stripe、WorkOS 和 Slack 的客户端 SDK,并且正在逐步迁移其余部分。取而代之的是,我们通过一个名为 HttpBaseClient 的小型封装类直接调用它们的 REST API。
这听起来有些倒退。SDK 本意是为开发者节省时间。但你一定听说过 AI 正在将更多代码移植到原生运行。同样的理念也适用于 SDK。为什么?
- SDK 隐藏了底层细节,比如 HTTP 响应头和原始响应体——而这正是 Agent 在调试问题或向上游团队提供调查信息时所必需的关键信息。
- SDK 期望获得规范的响应,但现实中,防火墙、负载均衡器和网关可能会返回格式错误的响应,这就掩盖了我们所需的调试细节。
- SDK 绕过了重试、错误处理和可观测性等集中化入口点的强制约束,使得 Agent 可以自行编写其模式。
- SDK 通常臃肿不堪,通常是从 OpenAPI 自动生成的,而我们只使用了其中极少的端点。
想想 SDK 的起源。它们之所以诞生,是因为公司希望让集成变得简单:发布一个共享库,节省客户时间,隐藏重复的集成工作。但 AI 改变了这一成本曲线。如今,集成一个 SDK 的工作量往往与直接调用 HTTP API 相差无几,而且编写一个专门用于我们所用端点的 REST 客户端既便宜又能提供更好的统一可观测性。
无休止的打地鼠游戏

如此"美好"的 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(...)。这看起来很有成效,但它绕过了应该存放认证、重试、指标、日志和错误转换的共享位置。我们的代码库中散落着这样的闭包包装:
1const response = await catchRateLimitError(() =>2 stripe.customers.retrieve(stripeCustomerId)3);
如果你漏掉了某处,忘记封装 SDK 调用,那就惨了。Stripe 的限速和 WorkOS 的限速对我们的产品来说意思相同:上游要求我们放慢速度。但在类型层面,它们是完全不同的对象。有些代码捕获了 SDK 特定的异常,有些捕获了通用的 Error,有些什么也没捕获。结果就是 Sentry 打地鼠:在一个调用点修复一个 429,然后在另一处等待同类故障。
SDK 臃肿
NPM 包 openai 和 anthropic-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 负责其余部分:序列化、解析、传输错误、结构化日志、指标、状态映射和持续时间追踪。这统一了可观测性,使得每个供应商都遵循一致的标准。以下是简化后的结构:
1abstract class HttpBaseClient<TEndpoint extends string> {2 protected abstract readonly baseUrl: string;34 protected constructor(private readonly dependency: string) {}56 protected abstract buildAuthHeaders(): Promise<Record<string, string>>;78 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();2223 logInfo('上游请求开始', labels);2425 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 labels35 );3637 const body = await parseBody(response);38 if (!response.ok) throw this.mapHttpError(response, body);3940 logInfo('上游请求成功', {41 ...labels,42 statusCode: response.status,43 durationMs: performance.now() - start,44 });4546 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}5758// 然后 Stripe 封装变得简洁明了:59enum StripeEndpoint {60 CustomersCreate = 'stripe/customers/create',61}6263class StripeHttpClient extends HttpBaseClient<StripeEndpoint> {64 protected readonly baseUrl = 'https://api.stripe.com';6566 constructor(private readonly apiKey: string) {67 super(ClientVendor.Stripe);68 }6970 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 }7879 // 更多端点在此添加。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、复用恰当的模式,并避免将每个运行时调用推入供应商包中。





