अनुरोध सफल रहा। प्रतिक्रिया वैध JSON थी। SDK शांत रहा। फिर भी एप्लिकेशन क्रैश हो गया।
यह उस घटना की कहानी है जो तब होती है जब आप LLM प्रोवाइडर को बदलने को एक संरचनात्मक जोखिम (structural gamble) के बजाय केवल एक कॉन्फ़िगरेशन परिवर्तन की तरह मानते हैं। आप एक नया base URL पेस्ट करते हैं, API key बदलते हैं, और request body को बिल्कुल वैसा ही रखते हैं क्योंकि डॉक्यूमेंटेशन एक OpenAI-compatible एंडपॉइंट का वादा करता है। एक बुनियादी "hello world" प्रॉम्प्ट के लिए, यह काम करता है। आप जश्न मनाते हैं। फिर वास्तविक ट्रैफिक आता है, और दरारें दिखने लगती हैं।
वायर कम्पैटिबिलिटी का भ्रम
HTTP लेयर पर कम्पैटिबिलिटी सतही होती है। 200 status code और एक JSON body का मतलब है कि सर्वर ने आपका संदेश स्वीकार कर लिया है। इसका मतलब यह नहीं है कि सर्वर भी उसी तरह सोचता है जैसे पिछला वाला सोचता था। OpenAI-compatible एंडपॉइंट्स एक ही तरह के request shape साझा करते हैं, लेकिन वे एक ही व्यवहार संबंधी अनुबंध (behavioral contract) साझा नहीं करते हैं। दो प्रोवाइडर एक जैसे पेलोड (payloads) ले सकते हैं और ऐसे उत्तर दे सकते हैं जो सूक्ष्म लेकिन विनाशकारी तरीकों से अलग हों।
आपका कोड धारणाएँ (assumptions) बनाता है। आप मान लेते हैं कि message.content एक स्ट्रिंग है क्योंकि पहले हमेशा ऐसा ही था। आप मान लेते हैं कि टूल कॉल एक साफ, पार्स करने योग्य JSON के साथ आता है। आप मान लेते हैं कि finish_reason वही संकेत देता है जो आप समझते हैं। ये धारणाएँ तब तक अदृश्य रहती हैं जब तक कि वे घातक न हो जाएँ।
उस क्रैश पर विचार करें जिसने सब कुछ शुरू किया:
const text = response.choices[0].message.content.trim();
यह लाइन मासूम लगती है। यह हफ्तों तक काम करती रही। फिर नए प्रोवाइडर ने एक टूल कॉल लौटाया। उस क्षण, message.content एक खाली स्ट्रिंग नहीं था। यह null था। वास्तविक पेलोड message.tool_calls के अंदर था, लेकिन पार्सर पहले ही आगे बढ़ चुका था, और कुछ भी न होने पर .trim() कॉल कर रहा था। API ने कोई एरर नहीं दिया। नेटवर्क लेयर ने कोई शिकायत नहीं की। आपके अपने पार्सर ने ही रिक्वेस्ट को खत्म कर दिया।
जहाँ प्रोवाइडर्स चुपचाप अलग हो जाते हैं
ये अंतर चेंजलॉग (changelogs) में खुद को घोषित नहीं करते हैं। वे रिस्पॉन्स ऑब्जेक्ट के हाशिये (margins) में बैठे रहते हैं, एज केस (edge cases) का इंतज़ार करते हैं।
टूल-कॉल फॉर्मेटिंग (Tool-call formatting)। एक प्रोवाइडर टूल आर्गुमेंट्स को प्री-वैलिडेटेड JSON ऑब्जेक्ट के रूप में भेजता है। दूसरा उन्हें एक फ़ील्ड के अंदर एस्केप की गई स्ट्रिंग (escaped string) के रूप में भेजता है। तीसरा शायद एक लंबे टूल कॉल को कई स्ट्रीमिंग डेल्टा (streaming deltas) में विभाजित कर दे, जिससे आपको यह देखने से पहले कि क्या स्ट्रक्चर वैध है, चंक्स (chunks) को बफर करना पड़ेगा। यदि आपका एप्लिकेशन एक एकल पार्स करने योग्य ब्लॉक की अपेक्षा करता है, तो वह विफल हो जाएगा।
फिनिश रीज़न्स (Finish reasons)। OpenAI "stop", "length", "tool_calls", और "content_filter" जैसे विशिष्ट स्ट्रिंग्स का उपयोग करता है। एक कम्पैटिबल प्रोवाइडर "end_turn" लौटा सकता है या जब मॉडल टोकन सीमा तक पहुँच जाता है तो बस उस फ़ील्ड को छोड़ सकता है। यदि आपका रिट्राय (retry) या फॉलबैक लॉजिक ट्रंकेशन (truncation) का पता लगाने के लिए "length" का इंतज़ार करता है, तो वह तब तक खाली बैठा रहेगा जब तक उपयोगकर्ता को आधा-अधूरा उत्तर दिखाई देता है।
यूसेज फील्ड्स (Usage fields)। कुछ प्रोवाइडर्स लेटेंसी (latency) को कम करने के लिए स्ट्रीमिंग रिस्पॉन्स से टोकन काउंट हटा देते हैं। अन्य केवल अंतिम चंक (final chunk) में यूसेज जोड़ते हैं, या नॉन-स्ट्रीमिंग कॉल्स में इसे पूरी तरह से छोड़ देते हैं। यदि आप ग्राहकों से प्रति टोकन शुल्क लेते हैं और आपका अकाउंटिंग कोड उम्मीद करता है कि हर रिस्पॉन्स ऑब्जेक्ट में usage.total_tokens मौजूद हो, तो आपका बिलिंग पाइपलाइन चुपचाप शून्य रिकॉर्ड करेगी।
स्ट्रीमिंग व्यवहार (Streaming behavior)। सर्वर-सेंट इवेंट्स (Server-sent events) मानक होने चाहिए, फिर भी प्रोवाइडर्स अलग-अलग फ्रीक्वेंसी पर बफ़र्स को फ्लश करते हैं। इवेंट की सीमाएँ (boundaries) अलग-अलग होती हैं। एक प्रोवाइडर [DONE] सिग्नल के साथ स्ट्रीम को समाप्त करता है। दूसरा बिना किसी सेंटिनल (sentinel) के कनेक्शन को साफ़ तरीके से काट देता है। यदि आपका क्लाइंट किसी विशिष्ट क्लोजिंग मार्कर का इंतज़ार करते हुए ब्लॉक हो जाता है, तो वह हैंग हो जाएगा।
त्रुटियाँ और टाइमआउट (Errors and timeouts)। एक प्रोवाइडर से रेट लिमिट एक प्रोवाइडर से retry-after हेडर के साथ 429 के रूप में आ सकती है, और दूसरे से एक अस्पष्ट 502 के रूप में। कुछ प्रोवाइडर अनुरोध स्वीकार करते हैं और फिर नेटवर्क टाइमआउट से पहले दो मिनट तक शांत हो जाते हैं। OpenAI SDK जादुई रूप से इन्हें उन एक्सेप्शन टाइप्स (exception types) में सामान्य नहीं करेगा जिनकी आपके लॉग्स अपेक्षा करते हैं।
अनिश्चित स्वरूपों के लिए डिफेंसिव पार्सिंग (Defensive Parsing)
समाधान स्कीमा (schema) पर भरोसा करना नहीं है। समाधान हर प्रतिक्रिया को एक संदिग्ध की तरह मानना है।
यह न मानें कि content एक स्ट्रिंग है। इसे छूने से पहले इसकी जाँच करें।
const content = response.choices?.[0]?.message?.content;
const text = typeof content === "string" ? content.trim() : "";
यह न मानें कि टूल आर्गुमेंट्स वैध JSON हैं। मॉडल एक क्रिया का प्रस्ताव करता है। आपके कोड को यह तय करना होगा कि क्या वह प्रस्ताव निष्पादित करने के लिए पर्याप्त सुरक्षित है। हर टूल आर्गुमेंट पार्स को try-catch में लपेटें। यदि JSON.parse एरर देता है, तो टूल कॉल को खराब (malformed) कचरा मानें और उसे फेलियर हैंडलर (failure handler) पर भेज दें। एक गलत ब्रैकेट या गायब उद्धरण (quote) को कभी भी अनहैंडल्ड एक्सेप्शन के रूप में ऊपर नहीं आना चाहिए।
यदि tool_calls मौजूद है लेकिन content गायब है, तो आपके एप्लिकेशन को स्टेट ट्रांज़िशन (state transition) को पहचानना चाहिए। उपयोगकर्ता को चैट रिप्लाई नहीं मिला। सिस्टम को एक वर्क ऑर्डर मिला। ये दो अलग-अलग रास्ते हैं, और आपके राउटर को स्ट्रिंग मैनिपुलेशन करने की कोशिश करने से पहले अंतर पता होना चाहिए।
डिप्लॉय करने से पहले व्यवहार संबंधी परीक्षण (Behavioral Tests)
"hi" संदेश के साथ endpoint को ping करना यह साबित करता है कि नेटवर्क काम कर रहा है। यह आपके एप्लिकेशन के बारे में कुछ भी साबित नहीं करता है।
प्रोडक्शन ट्रैफिक को रीडायरेक्ट करने से पहले, नए प्रोवाइडर के खिलाफ एक लक्षित (targeted) behavioral test suite चलाएं:
- Normal text response. सत्यापित करें कि
contentमौजूद है, एक string है, और बिना किसी casting error के आपके sanitization pipeline से गुजर सकता है। - Forced tool call.
tool_choiceको required पर सेट करें। पुष्टि करें कि प्रोवाइडर इसका पालन करता है, और जांचें कि क्याcontentnull, एक खाली string, या एक missing key के रूप में आता है। इनमें से प्रत्येक स्थिति (state) के लिए अपने स्वयं के handler की आवश्यकता होती है। - Malformed tool arguments. ऐसे परिदृश्य (scenarios) डालें जहाँ मॉडल tool arguments के अंदर टूटा हुआ JSON लौटाता है। सुनिश्चित करें कि आपका parser worker को क्रैश करने के बजाय उन्हें शालीनता से (gracefully) रिजेक्ट कर देता है।
- Response near the token limit. context window को उसकी सीमा तक ले जाएं।
finish_reasonकी जांच करें। यदि truncation होने पर प्रोवाइडर कुछ अप्रत्याशित (unexpected) लौटाता है, तो आपके summarization या retry logic को पता होना चाहिए कि कैसे प्रतिक्रिया देनी है।
ये integration tests हैं, unit tests नहीं। वे आपके कोड और प्रोवाइडर के व्यक्तित्व (personality) के बीच वास्तविक संबंध का परीक्षण करते हैं। माइग्रेशन पूरा होने से पहले इन्हें पास करें।
एक Internal Contract बनाएं
प्रोवाइडर के अंतर आपके नेटवर्क बाउंड्री पर ही रुक जाने चाहिए। उन्हें business logic में लीक न होने दें।
एक normalization layer बनाएं जो raw SDK response को ग्रहण करे और एक ऐसा object दे जिसे आपका एप्लिकेशन वास्तव में नियंत्रित करता है। प्रोवाइडर-विशिष्ट विसंगतियों (eccentricities) को एक स्थिर आंतरिक प्रारूप (stable internal format) में मैप करें। यदि Provider A tool arguments को strings के रूप में लौटाता है और Provider B objects लौटाता है, तो आपका mapper दोनों को आपके अपने ToolRequest structure में समतल (flatten) कर देता है। यदि usage गायब है, तो आपका mapper या तो इसका अनुमान लगाता है या कमी को चिह्नित (flag) करता है, लेकिन यह कभी भी undefined को आपके cost-tracking modules में रिसने नहीं देता है।
यदि finish_reason गैर-मानक (nonstandard) है, तो इसे अपने स्वयं के terminal states के enum में अनुवादित करें: COMPLETE, TRUNCATED, TOOL_CALL, FILTERED| आपका ऐप इन स्वच्छ एब्स्ट्रैक्शन (clean abstractions) के आधार पर निर्णय लेना चाहिए, न कि किसी तीसरे पक्ष (third-party) के सर्वर से raw strings को सूंघकर (sniffing)।
यह layer प्रोवाइडर बदलने की प्रक्रिया को 'whack-a-mole' के खेल से बदलकर एक सिंगल-फ़ाइल बदलाव बना देती है। आप mapper को फिर से लिखते हैं, behavioral tests चलाते हैं, और आगे बढ़ जाते हैं। आपका एप्लिकेशन अप्रभावित रहता है।
एक Dependency Upgrade, न कि एक Config Tweak
LLM प्रोवाइडर्स को बदलना CDN endpoints को बदलने जैसा नहीं है। यह अपने डेटाबेस को PostgreSQL से MySQL में बदलने के करीब है। आप कभी भी यह मानकर नहीं चलेंगे कि एक ही connection string का मतलब समान query behavior है। आप locking semantics, migration paths, और indexing quirks का परीक्षण करेंगे। LLMs भी उसी सम्मान के पात्र हैं। वे मानक APIs के रूप में छद्म रूप धारण करने वाले probabilistic systems हैं, और उनके जवाबों में formatting, truncation, और control flow के बारे में ऐसी धारणाएं होती हैं जो बिना किसी नेटवर्क एरर के आपके एप्लिकेशन को तहस-नहस कर सकती हैं।
बग कभी भी कनेक्शन में नहीं था। यह इस धारणा में था कि compatibility का मतलब समानता है। ऐसा नहीं है। आकार (shape) को मान्य करें। किनारों (edges) का परीक्षण करें। कॉन्ट्रैक्ट को अपनाएं।
Source: The Bug Only Happened After I Switched LLM Providers
Community: GyaanSetu AI on Telegram
