系列:從現場踩坑到 AI 工具 — IT Diagnostic Agent 開發實錄
Claude adapter 寫完之後,我要接 Gemini。
當時的心態很輕鬆:都是 LLM API,都是送一段對話過去、拿一段文字回來,應該複製貼上改個網址就好。
我錯了。而且錯得很徹底。
兩家在八個維度上完全不一樣,沒有一個地方可以直接沿用。
async function callClaude(messages, systemPrompt){
const p = providers.claude;
if(!p.key) throw new Error('Claude API Key not set');
const res = await fetch('https://api.anthropic.com/v1/messages', {
method:'POST',
headers:{
'Content-Type':'application/json',
'x-api-key':p.key,
'anthropic-version':'2023-06-01',
'anthropic-dangerous-direct-browser-access':'true'
},
body:JSON.stringify({model:p.model, max_tokens:1024, system:systemPrompt, messages})
});
const data = await res.json();
if(data.error) throw new Error(data.error.message);
return data.content[0].text;
}
我覺得這段程式碼很乾淨,原因是 Anthropic 的 API 設計本身很直白:
messages 直接原封不動送過去data.content[0].text 拿特別是 system 那個欄位。 它把系統提示當成一個跟對話分開的一級概念,這在觀念上很清楚:systemPrompt 不是對話的一部分,它是整場對話的規則。
(考慮到我第三章整章都在講「systemPrompt 是對話憲法」,這個 API 設計選擇跟我的心智模型剛好吻合。)
async function callGemini(messages, systemPrompt){
const p = providers.gemini;
if(!p.key) throw new Error('Gemini API Key not set');
// Convert messages to Gemini format
const contents = messages.map(m => ({
role: m.role === 'assistant' ? 'model' : 'user',
parts: [{text: m.content}]
}));
const res = await fetch(
`https://generativelanguage.googleapis.com/v1beta/models/${p.model}:generateContent?key=${p.key}`, {
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({
system_instruction:{parts:[{text:systemPrompt}]},
contents,
generationConfig:{maxOutputTokens:1024}
})
});
const data = await res.json();
if(data.error) throw new Error(data.error.message);
return data.candidates[0].content.parts[0].text;
}
注意那行註解:// Convert messages to Gemini format。
那行註解就是重點。 Claude 版沒有任何轉換,messages 直接送。Gemini 版必須先重建整個資料結構。
我把兩邊逐項列出來:
| # | 維度 | Claude | Gemini |
|---|---|---|---|
| 1 | Key 放哪 | x-api-key header |
URL query string ?key= |
| 2 | 模型名放哪 | body 的 model 欄位 |
URL 路徑裡 models/{model}:generateContent |
| 3 | system prompt 形狀 | 頂層 system 字串 |
system_instruction:{parts:[{text}]} |
| 4 | 助理的 role 叫什麼 | assistant |
model |
| 5 | 訊息內容形狀 | content 字串 |
parts:[{text}] 陣列 |
| 6 | 訊息陣列叫什麼 | messages |
contents |
| 7 | max tokens 放哪 | 頂層 max_tokens |
generationConfig.maxOutputTokens |
| 8 | 回覆在哪 | data.content[0].text |
data.candidates[0].content.parts[0].text |
八項全中。沒有任何一項相同。
這個我第一次看到有點意外。
?key=${p.key}
API Key 出現在 URL query string 裡。這代表它會被寫進瀏覽器歷史紀錄、被寫進任何中間節點的存取日誌、被 Referer header 帶走。
Google 有他們的理由(相容性、簡化 CORS 處理),而且這是他們官方文件的做法。但從一個資安的角度看,header 比 query string 安全得多,這是基本常識。
有趣的對比是:Claude 需要一個名字裡寫著 dangerous 的 header 才能從瀏覽器呼叫,而 Gemini 直接就通,代價是 Key 放在 URL 裡。
兩家對「瀏覽器直連」這件事的態度,透露了不同的安全哲學。
models/${p.model}:generateContent
這是 REST 風格的設計:模型是一個資源,generateContent 是對它的動作。
從 API 設計的角度是合理的。但從 adapter 實作的角度,它意味著換模型要重組 URL,而不是改一個欄位。
如果你的程式碼有做 URL 快取或字串常數,這裡會咬你。
assistant vs modelrole: m.role === 'assistant' ? 'model' : 'user'
這一行是整段轉換裡最容易出錯的地方。
我的對話歷史內部統一用 assistant(因為 Claude 和 OpenAI 都用這個),到 Gemini 這裡要翻成 model。
如果漏掉這行轉換,API 不會清楚地告訴你「role 不合法」,它可能給你一個很模糊的錯誤,或是把整段對話當成使用者發言處理,然後回一個很怪的答案。
這種錯誤最難查,因為它不會 crash,只會讓品質變差。
data.candidates[0].content.parts[0].text
四層。而 Claude 是兩層 data.content[0].text。
candidates 這個命名反映了 Gemini 的設計:一次請求可以回多個候選答案。這是能力更強的介面設計。
但對只要一個答案的應用來說,這是每次都要付的四層稅。而且每一層都是一個可能 undefined 的地方——如果 API 回了空的 candidates(例如觸發安全過濾),這行就直接炸了。
寫完這兩個 adapter,我對「多模型支援」的認知變了。
我原本以為 LLM API 已經走向標準化,各家大同小異。
實際上:它們在概念上相似,在介面上毫無共通性。
概念相似的意思是:都有系統提示、都有對話歷史、都有生成參數、都回文字。任何一個工程師看完 Claude API 都能猜到 Gemini API 大概有哪些功能。
介面毫無共通性的意思是:那些功能對應到的欄位名稱、資料形狀、巢狀層級、放置位置,全部不一樣。
而寫程式的人要處理的是後者。
如果沒有 Day 16 那個統一契約,這八項差異會滲透到整個程式碼庫。
因為有了契約,這八項差異被壓縮在一個函式裡。
上層程式碼呼叫 callLLM(messages, systemPrompt),它不知道也不需要知道:
這是 Adapter Pattern 的價值最具體的一次體現:它把「八個維度都不一樣」這個混亂,關進了一個二十行的函式裡。
核心 vs 外部
核心是「取得一段診斷回覆」。欄位名稱、巢狀結構、Key 放哪裡,全部是外部細節。
Adapter 的職責就是在核心與外部之間畫一條線,並且獨自承受線外的所有混亂。
已成立 vs 假設
「LLM API 大同小異」是我的假設。逐行比對之後它被推翻了。
代價是我多花了時間,收穫是我不再假設下一家會很像。後來接 OpenAI 相容 API 的時候,我是先讀完文件才動手的。
工程師最貴的錯誤,往往不是技術能力不足,是用第一個案例的經驗去推論第二個案例。
我看完 Claude API 就以為自己懂了 LLM API。實際上我懂的是 Anthropic 對 LLM API 的一種設計選擇。
一個樣本推論不出模式。 這句話在排障上我很清楚——一台電腦壞了不代表是網路問題。但在寫程式時我照樣犯。
明天預告: Gemini 教會我不要假設相似。但接下來 OpenAI 相容 API 又給了我一個反向的教訓——這次真的可以複製貼上,而且一次支援好幾家。但「相容」不等於「行為一致」,我在 Ollama 的錯誤處理上留下了證據。
作者:Rich Chang | IT 基礎建設工程師 | 越南・柬埔寨・台灣