Permintaan berjaya. Respons adalah JSON yang sah. SDK kekal senyap. Namun, aplikasi tetap runtuh.
Ini adalah kisah tentang apa yang berlaku apabila anda menganggap pertukaran pembekal LLM sebagai perubahan konfigurasi dan bukannya satu pertaruhan struktur. Anda memasukkan URL asas yang baharu, menukar kunci API, dan mengekalkan badan permintaan yang serupa kerana dokumentasi menjanjikan titik akhir (endpoint) yang serasi dengan OpenAI. Untuk prom "hello world" yang asas, ia berfungsi. Anda meraikannya. Kemudian trafik sebenar masuk, dan segala-galanya mula retak.
Ilusi Keserasian Wire
Keserasian pada lapisan HTTP adalah cetek. Kod status 200 dan badan JSON bermakna pelayan telah menerima mesej anda. Ia tidak bermakna pelayan berfikir dengan cara yang sama seperti pelayan sebelumnya. Titik akhir yang serasi dengan OpenAI berkongsi bentuk permintaan yang sama, tetapi mereka tidak berkongsi kontrak tingkah laku. Dua pembekal boleh menerima muatan (payload) yang serupa tetapi mengembalikan jawapan yang berbeza dalam cara yang halus dan merosakkan.
Kod anda membuat andaian. Anda mengandaikan message.content adalah satu string kerana ia sentiasa begitu sebelum ini. Anda mengandaikan panggilan alat (tool call) tiba dengan JSON yang bersih dan boleh diurai (parseable). Anda mengandaikan finish_reason menandakan apa yang anda fikir ia tandakan. Andaian ini tidak kelihatan sehinggalah ia menjadi fatal.
Pertimbangkan kegagalan yang memulakan segalanya:
const text = response.choices[0].message.content.trim();
Baris ini kelihatan tidak berbahaya. Ia berfungsi selama berminggu-minggu. Kemudian pembekal baharu mengembalikan satu panggilan alat. Pada saat itu, message.content bukan lagi string kosong. Ia adalah null. Muatan sebenar berada di dalam message.tool_calls, tetapi pengurai (parser) telah pun bergerak ke hadapan, memanggil .trim() pada sesuatu yang tiada. API tidak mengeluarkan ralat. Lapisan rangkaian tidak merungut. Pengurai anda sendiri yang membunuh permintaan tersebut.
Di Mana Pembekal Berbeza Secara Senyap
Perbezaan ini tidak diumumkan dalam log perubahan (changelogs). Ia tersembunyi di pinggiran objek respons, menunggu kes-kes ekstrem (edge cases).
Pemformatan panggilan alat (Tool-call formatting). Satu pembekal menghantar argumen alat sebagai objek JSON yang telah disahkan terlebih dahulu. Pembekal lain menghantarnya sebagai string yang di-escape di dalam satu medan. Pembekal ketiga mungkin membahagikan panggilan alat yang panjang merentasi beberapa delta penstriman (streaming deltas), memaksa anda untuk menyimpan buffer bagi setiap bahagian sebelum anda dapat melihat sama ada strukturnya sah. Jika aplikasi anda menjangkakan satu blok data yang boleh diurai, ia akan tersangkut.
Sebab tamat (Finish reasons). OpenAI menggunakan string khusus seperti "stop", "length", "tool_calls", dan "content_filter". Pembekal yang serasi mungkin mengembalikan "end_turn" atau sekadar meninggalkan medan tersebut apabila model mencapai had token. Jika logik cubaan semula (retry) atau sandaran (fallback) anda menunggu "length" untuk mengesan pemotongan (truncation), ia akan terbiar sementara pengguna melihat jawapan yang tidak lengkap.
Medan penggunaan (Usage fields). Sesetengah pembekal membuang jumlah token daripada respons penstriman untuk mengurangkan kependaman (latency) sebanyak beberapa milisaat. Yang lain hanya menambah penggunaan pada bahagian (chunk) terakhir, atau langsung tidak menyertakannya dalam panggilan bukan penstriman. Jika anda mengenakan caj kepada pelanggan mengikut token dan kod perakaunan anda menjangkakan usage.total_tokens wujud dalam setiap objek respons, saluran pengebilan anda akan merekodkan nilai sifar secara senyap.
Tingkah laku penstriman (Streaming behavior). Acara dihantar pelayan (Server-sent events) sepatutnya menjadi standard, namun pembekal mengosongkan buffer pada kekerapan yang berbeza. Sempadan acara (event boundaries) berbeza-beza. Satu pembekal menamatkan penstriman dengan isyarat [DONE]. Pembekal lain memutuskan sambungan dengan bersih tanpa sebarang penanda (sentinel). Jika klien anda menyekat (blocks) menunggu penanda penutup tertentu, ia akan tergantung (hang).
Ralat dan masa tamat (Errors and timeouts). Had kadar (rate limit) mungkin tiba sebagai 429 dengan pengepala retry-after daripada satu pembekal, dan sebagai 502 yang samar daripada pembekal lain. Sesetengah pembekal menerima permintaan dan kemudian berdiam diri selama dua minit sebelum berlaku masa tamat rangkaian. SDK OpenAI tidak akan menukarkan ralat ini secara ajaib kepada jenis pengecualian (exception types) yang dijangkakan oleh log anda.
Penguraian Defensif untuk Bentuk yang Tidak Dijangka
Penyelesaiannya bukan dengan mempercayai skema tersebut. Penyelesaiannya adalah dengan menganggap setiap respons sebagai sesuatu yang mencurigakan.
Jangan andaian content adalah satu string. Periksanya sebelum anda menggunakannya.
const content = response.choices?.[0]?.message?.content;
const text = typeof content === "string" ? content.trim() : "";
Jangan andaikan argumen alat adalah JSON yang sah. Model mencadangkan satu tindakan. Kod anda mesti memutuskan sama ada cadangan itu cukup selamat untuk dilaksanakan. Bungkus setiap penguraian argumen alat dalam try-catch. Jika JSON.parse mencetuskan ralat, anggap panggilan alat tersebut sebagai sampah yang rosak dan hantarkannya ke pengendali kegagalan (failure handler). Kurungan yang berhalusinasi atau pembuka kata yang hilang tidak sepatutnya muncul sebagai pengecualian yang tidak dikendalikan.
Jika tool_calls wujud tetapi content tiada, aplikasi anda harus mengenali peralihan keadaan (state transition). Pengguna tidak mendapat balasan sembang. Sistem mendapat pesanan kerja. Itu adalah dua laluan yang berbeza, dan penghala (router) anda harus mengetahui perbezaannya sebelum ia cuba melakukan manipulasi string.
Ujian Tingkah Laku Sebelum Anda Melancarkan
Menghantar ping ke endpoint dengan mesej "hi" membuktikan rangkaian berfungsi. Ia tidak membuktikan apa-apa tentang aplikasi anda.
Sebelum anda mengalihkan trafik produksi, jalankan suite ujian tingkah laku yang disasarkan terhadap pembekal baharu:
- Respons teks normal. Sahkan bahawa
contentwujud, merupakan satu string, dan boleh melalui saluran sanitasi anda tanpa ralat penukaran (casting errors). - Panggilan alat paksaan (Forced tool call). Tetapkan
tool_choicekepada required. Sahkan pembekal mematuhinya, dan semak sama adacontenttiba sebagainull, string kosong, atau kunci yang hilang. Setiap keadaan tersebut memerlukan pengendali (handler) tersendiri. - Argumen alat yang tidak sah (Malformed tool arguments). Suntik senario di mana model mengembalikan JSON yang rosak di dalam argumen alat. Pastikan parser anda menolaknya dengan lancar dan bukannya menyebabkan worker terhenti (crash).
- Respons berhampiran had token. Tolak had tetingkap konteks (context window). Semak
finish_reason. Jika pembekal mengembalikan sesuatu yang tidak dijangka apabila pemotongan (truncation) berlaku, logik ringkasan atau cubaan semula (retry logic) anda mesti tahu cara untuk bertindak balas.
Ini adalah ujian integrasi, bukan ujian unit. Ia menguji hubungan sebenar antara kod anda dan personaliti pembekal. Luluskan ujian ini sebelum anda menganggap migrasi telah selesai.
Bina Kontrak Dalaman
Perbezaan pembekal harus berhenti di sempadan rangkaian anda. Jangan biarkan ia bocor ke dalam logik perniagaan.
Cipta lapisan normalisasi yang mengambil respons SDK mentah dan mengeluarkan objek yang dimiliki oleh aplikasi anda. Petakan keunikan khusus pembekal ke dalam format dalaman yang stabil. Jika Pembekal A mengembalikan argumen alat sebagai string dan Pembekal B mengembalikan objek, pemeta (mapper) anda akan meratakan kedua-duanya ke dalam struktur ToolRequest anda sendiri. Jika penggunaan (usage) hilang, pemeta anda sama ada menganggarkannya atau menandakan jurang tersebut, tetapi ia tidak akan membiarkan undefined meresap ke dalam modul penjejakan kos anda.
Jika finish_reason adalah bukan standard, terjemahkannya ke dalam enum keadaan terminal anda sendiri: COMPLETE, TRUNCATED, TOOL_CALL, FILTERED. Aplikasi anda harus memutuskan apa yang perlu dilakukan berdasarkan abstraksi bersih ini, bukan dengan mengesan string mentah daripada pelayan pihak ketiga.
Lapisan ini mengubah pertukaran pembekal daripada permainan "whack-a-mole" kepada perubahan fail tunggal. Anda menulis semula pemeta, menjalankan ujian tingkah laku, dan selesai. Aplikasi anda kekal tidak terjejas.
Naik Taraf Kebergantungan, Bukan Sekadar Ubah Suai Konfigurasi
Menukar pembekal LLM tidak sama seperti menukar endpoint CDN. Ia lebih menyerupai menukar pangkalan data anda daripada PostgreSQL kepada MySQL. Anda tidak akan sesekali menganggap bahawa string sambungan yang sama bermaksud tingkah laku pertanyaan (query behavior) yang serupa. Anda akan menguji semantik penguncian (locking semantics), laluan migrasi, dan keunikan pengindeksan. LLM layak menerima penghormatan yang sama. Ia adalah sistem probabilistik yang menyamar sebagai API standard, dan responsnya membawa andaian tentang pemformatan, pemotongan (truncation), dan aliran kawalan yang boleh merosakkan aplikasi anda tanpa mencetuskan satu pun ralat rangkaian.
Pepijat tersebut tidak pernah terletak pada sambungan. Ia terletak pada andaian bahawa keserasian bermaksud kesamaan. Ia tidak begitu. Sahkan bentuknya. Uji bahagian tepinya (edges). Miliki kontrak tersebut.
Sumber: The Bug Only Happened After I Switched LLM Providers
Komuniti: GyaanSetu AI on Telegram
