我們已停止使用 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 也是從 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 的方案。提供者的子類別負責供應商特定的部分:基礎 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('upstream request starting', 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('upstream request succeeded', {41 ...labels,42 statusCode: response.status,43 durationMs: performance.now() - start,44 });4546 return body as TResponse;47 } catch (cause) {48 logWarn('upstream request failed', {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 仍然在 HTTP 200 下返回奇怪的 ok: false 行為。但我們後端的其餘部分看到的是統一一致的形態。
你可以在這裡查看更多關於 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、重用正確的模式,並避免將每個執行時呼叫都推送到供應商套件中。





