iT邦幫忙

2026 iThome 鐵人賽

DAY 18
0
Claude AI

從現場踩坑到 AI 工具 — IT Diagnostic Agent 開發實錄系列 第 18

Day 18 — OpenAI 相容 API:一次支援 Kimi、Groq、DeepSeek

  • 分享至 

  • xImage
  •  

系列:從現場踩坑到 AI 工具 — IT Diagnostic Agent 開發實錄


昨天的教訓反了

昨天整篇在講「不要假設 API 會相似」。

今天要講一個相反的情況:有一類 API 真的可以複製貼上,而且一個 adapter 就能支援四五家。

前提是你知道它為什麼可以。


OpenAI 相容是一種產業現象

/v1/chat/completions 這個端點路徑,加上 {model, messages, max_tokens} 這個 body 形狀,事實上已經成為 LLM API 的通用格式。

不是因為有標準組織訂了規範,而是因為 OpenAI 先做了、生態圈(SDK、框架、工具)都繞著它長出來,後進者發現**「相容 OpenAI 格式」的邊際成本遠低於「教育市場接受自己的格式」**。

所以現在的情況是:Kimi(Moonshot)、Groq、DeepSeek、Together、還有很多——都提供 OpenAI 相容端點。

這對 Adapter Pattern 來說是一個巨大的紅利。


一個 adapter,四家供應商

實際的實作:

async function callOpenAI(messages, systemPrompt){
  const p = providers.openai;
  if(!p.key) throw new Error('OpenAI API Key not set');
  const res = await fetch(p.endpoint + '/chat/completions', {
    method:'POST',
    headers:{'Content-Type':'application/json','Authorization':'Bearer '+p.key},
    body:JSON.stringify({
      model:p.model,
      max_tokens:1024,
      messages:[{role:'system',content:systemPrompt}, ...messages]
    })
  });
  const data = await res.json();
  if(data.error) throw new Error(data.error.message);
  return data.choices[0].message.content;
}

關鍵在第一行的 p.endpoint

openai: {
  key: localStorage.getItem('it_key_openai') || '',
  model: localStorage.getItem('it_model_openai') || 'gpt-4o-mini',
  endpoint: localStorage.getItem('it_ep_openai') || 'https://api.openai.com/v1'
}

端點是使用者可以改的。

這一個設計決定,讓這個 adapter 從「支援 OpenAI」變成「支援所有 OpenAI 相容服務」:

服務 端點填什麼
OpenAI https://api.openai.com/v1
Kimi Moonshot 的相容端點
Groq Groq 的相容端點
DeepSeek DeepSeek 的相容端點
任何自架的相容服務 你自己的 URL

一個函式,一個可編輯的欄位,覆蓋了整個 OpenAI 相容生態。

比對一下 Gemini:Gemini 需要一整個獨立的 adapter,因為它的八個維度都不一樣。而這一整個生態圈只需要一個。


systemPrompt 的第三種處理方式

順帶記錄一下三家對 systemPrompt 的處理,因為這個對比很有意思:

Claude — 頂層獨立欄位

{model, max_tokens, system: systemPrompt, messages}

Gemini — 獨立欄位但要包一層

{system_instruction:{parts:[{text:systemPrompt}]}, contents, generationConfig}

OpenAI — 塞進對話陣列的第一則

messages:[{role:'system',content:systemPrompt}, ...messages]

第三種在概念上其實比較弱:它把「規則」和「對話」放在同一個容器裡。

系統提示是整場對話的憲法,把它變成對話的第 0 則訊息,觀念上是混在一起了。實務上也有影響——在很長的對話裡,第 0 則訊息離當前上下文越來越遠。

Claude 那種頂層 system 欄位的設計,把規則和內容分開,我認為是更好的抽象。

但 OpenAI 那種做法贏了市場。 這是技術史上很常見的結果。


然後是 Ollama:連 adapter 都幾乎不用寫

當我要加本地模型支援時,發現 Ollama 也提供 OpenAI 相容端點。

於是 callOllama 長這樣:

async function callOllama(messages, systemPrompt){
  const p = providers.ollama;
  const res = await fetch(p.endpoint + '/v1/chat/completions', {
    method:'POST',
    headers:{'Content-Type':'application/json'},
    body:JSON.stringify({
      model:p.model,
      messages:[{role:'system',content:systemPrompt}, ...messages]
    })
  });
  const data = await res.json();
  if(data.error) throw new Error(data.error.message || JSON.stringify(data.error));
  return data.choices[0].message.content;
}

callOpenAI 幾乎一模一樣。差別只有三處:

  1. 沒有 Authorization header — 本機跑的模型不需要 Key
  2. 沒有 max_tokens — 本地模型的輸出長度由模型和 Ollama 設定決定
  3. 錯誤處理多了一段 fallback

第三點是這篇的重點。


「相容」不等於「行為一致」,我的程式碼留下了證據

看這一行:

if(data.error) throw new Error(data.error.message || JSON.stringify(data.error));

再比對 OpenAI 版:

if(data.error) throw new Error(data.error.message);

多了 || JSON.stringify(data.error)

這行 fallback 不是我為了寫得漂亮加的。它是被迫加的——因為 Ollama 回傳的錯誤,有時候 data.error 不是一個帶 .message 的物件。

如果沒有那段 fallback,錯誤訊息會顯示 undefined。使用者看到的是「發生錯誤:undefined」,等於什麼都沒說。

這五個字的 fallback,是「相容不等於一致」最具體的證據。

Ollama 相容了 OpenAI 的成功路徑——請求格式、回覆結構,都一樣。但錯誤路徑沒有完全相容

而錯誤路徑恰恰是使用者最需要清楚訊息的時候。


為什麼錯誤路徑最容易不相容

這個現象我後來想了一下,覺得有它的必然性。

當一家服務說「我們相容 OpenAI 格式」時,他們測的是什麼?

送一個正常請求,收到一個正常回覆。 這就是相容性測試的 80%。

錯誤情境有多少種?Key 錯誤、模型不存在、參數超限、服務忙碌、內容被過濾、網路中斷、模型還在載入……每一種的錯誤形狀可能都不一樣,而且很多是各服務特有的(Ollama 有「模型還沒 pull」這種 OpenAI 根本不存在的錯誤)。

所以「相容」在實務上幾乎總是指「快樂路徑相容」。

這對接 API 的人有一個直接的教訓:

複製貼上一個相容的 adapter 時,成功路徑可以信任,錯誤處理必須自己重新想一遍。


結構化地看這件事

相同 vs 不同

層面 相容程度
端點路徑
請求 body 形狀
成功回覆結構
認證方式 中(Ollama 完全不用)
錯誤回覆結構
特有錯誤類型 幾乎不相容

可控 vs 不可控

我控制不了各家怎麼回錯誤。我控制得了自己的錯誤處理要多防禦。

那行 || JSON.stringify(data.error) 就是把不可控的部分,用可控的方式接住。寧可顯示一坨難看的 JSON,也不要顯示 undefined。

因為難看的 JSON 使用者還能貼給我看,undefined 什麼資訊都沒有。


一個附帶的收穫

因為 Ollama 相容 OpenAI 格式,加本地模型支援的程式碼工作量非常小。

但這件事有一個誤導性:真正花時間的不是 adapter,是使用者要怎麼正確設定 Ollama。

CORS、防火牆、OLLAMA_HOST、HTTPS 混合內容限制——這些都不是程式碼問題,是環境問題。而環境問題不能靠寫程式解決,只能靠設計引導。

這就是為什麼我後來做了一整個確認 modal。 那是 Day 20 的主題。


今天的反思

昨天的教訓是「不要假設 API 相似」,今天的教訓看起來相反,其實是同一件事的另一面:

要知道相似性從哪裡來。

Gemini 跟 Claude 不相似,因為它們是獨立設計的。
Kimi 跟 OpenAI 相似,因為 Kimi 刻意去相容 OpenAI。

前者的相似是巧合(所以不可靠),後者的相似是承諾(所以可以依賴)。

而承諾有它的邊界。 那個邊界,通常就在錯誤處理上。


明天預告: 本地模型的技術路徑通了。但我一開始以為這只是一個「順便加上去的功能」。後來實際接觸企業需求,才發現這可能是整個工具最重要的一個功能。


作者:Rich Chang | IT 基礎建設工程師 | 越南・柬埔寨・台灣


上一篇
Day 17 — Claude Adapter 實作,以及 Gemini 看起來很像其實完全不一樣
下一篇
Day 19 — 為什麼企業比你想像中更在意本地模型
系列文
從現場踩坑到 AI 工具 — IT Diagnostic Agent 開發實錄22
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言