অনুরোধটি সফল হয়েছিল। রেসপন্সটি ছিল একটি বৈধ JSON। SDK কোনো ত্রুটি দেখায়নি। তবুও অ্যাপ্লিকেশনটি ভেঙে পড়ল।

এটি সেই গল্পের যেখানে আপনি একটি LLM প্রোভাইডার পরিবর্তনকে একটি কাঠামোগত ঝুঁকি হিসেবে না দেখে কেবল একটি কনফিগারেশন পরিবর্তন হিসেবে বিবেচনা করেন। আপনি একটি নতুন base URL পেস্ট করেন, API key পরিবর্তন করেন এবং রিকোয়েস্ট বডি একই রাখেন কারণ ডকুমেন্টেশন একটি OpenAI-compatible endpoint-এর প্রতিশ্রুতি দেয়। একটি সাধারণ "hello world" প্রম্পটের জন্য এটি কাজ করে। আপনি আনন্দিত হন। তারপর যখন আসল ট্রাফিক আসে, তখন সমস্যাগুলো প্রকট হয়ে ওঠে।

ওয়্যার কম্প্যাটিবিলিটির বিভ্রম

HTTP লেয়ারে সামঞ্জস্যতা বা কম্প্যাটিবিলিটি খুবই অগভীর। একটি 200 status code এবং একটি JSON বডি মানে হলো সার্ভার আপনার মেসেজটি গ্রহণ করেছে। এর মানে এই নয় যে সার্ভারটি আগেরটির মতো একইভাবে চিন্তা করে। OpenAI-compatible endpoint-গুলো রিকোয়েস্টের গঠন (shape) একই রকম হলেও তাদের আচরণের চুক্তি (behavioral contract) এক নয়। দুটি প্রোভাইডার একই রকম পেলোড গ্রহণ করতে পারে এবং এমন উত্তর দিতে পারে যা সূক্ষ্ম অথচ ধ্বংসাত্মকভাবে ভিন্ন।

আপনার কোড কিছু ধারণা বা অনুমান (assumptions) করে নেয়। আপনি ধরে নেন যে message.content একটি string কারণ আগে সব সময় তাই ছিল। আপনি ধরে নেন যে একটি tool call পরিষ্কার এবং parseable JSON সহ আসবে। আপনি ধরে নেন যে finish_reason ঠিক সেই সংকেতই দেয় যা আপনি আশা করছেন। এই অনুমানগুলো ততক্ষণ অদৃশ্য থাকে যতক্ষণ না সেগুলো মারাত্মক কোনো সমস্যা তৈরি করে।

সেই ক্র্যাশটির কথা ভাবুন যা এই সবকিছুর শুরু করেছিল:

const text = response.choices[0].message.content.trim();

এই লাইনটি দেখতে নিরীহ মনে হয়। এটি কয়েক সপ্তাহ কাজ করেছিল। তারপর নতুন প্রোভাইডার একটি tool call প্রদান করে। সেই মুহূর্তে, message.content কোনো খালি string ছিল না; এটি ছিল null। আসল পেলোডটি message.tool_calls-এর ভেতরে ছিল, কিন্তু পার্সার ইতিমধ্যে পরবর্তী ধাপে চলে গিয়েছিল এবং কোনো কিছুর ওপর .trim() কল করার চেষ্টা করছিল। API কোনো ত্রুটি দেখায়নি। নেটওয়ার্ক লেয়ার কোনো অভিযোগ করেনি। আপনার নিজস্ব পার্সারই রিকোয়েস্টটিকে নষ্ট করে দিয়েছে।

যেখানে প্রোভাইডাররা নিঃশব্দে ভিন্ন হয়ে যায়

এই পার্থক্যগুলো চ্যানজলগে (changelogs) প্রকাশ করা হয় না। এগুলো রেসপন্স অবজেক্টের প্রান্তসীমায় বসে থাকে এবং কোনো এজ কেস (edge case) আসার জন্য অপেক্ষা করে।

Tool-call formatting. একজন প্রোভাইডার tool arguments পাঠায় একটি pre-validated JSON অবজেক্ট হিসেবে। অন্যজন সেগুলোকে একটি ফিল্ডের ভেতরে escaped string হিসেবে পাঠায়। তৃতীয় কেউ হয়তো একটি দীর্ঘ tool call-কে একাধিক streaming delta-তে বিভক্ত করে দেয়, যার ফলে গঠনটি বৈধ কি না তা দেখার আগেই আপনাকে চঙ্কগুলো (chunks) বাফার করতে হয়। আপনার অ্যাপ্লিকেশন যদি একটি মাত্র parseable blob আশা করে, তবে সেটি কাজ করা বন্ধ করে দেবে।

Finish reasons. OpenAI "stop", "length", "tool_calls", এবং "content_filter" এর মতো নির্দিষ্ট স্ট্রিং ব্যবহার করে। একটি compatible provider "end_turn" রিটার্ন করতে পারে অথবা মডেলটি যখন টোকেন সীমার (token ceiling) কাছাকাছি পৌঁছায় তখন ফিল্ডটি বাদ দিতে পারে। আপনার retry বা fallback লজিক যদি truncation শনাক্ত করার জন্য "length"-এর জন্য অপেক্ষা করে, তবে ব্যবহারকারী একটি অর্ধেক সম্পন্ন উত্তর দেখার সময় আপনার কোডটি নিষ্ক্রিয় হয়ে বসে থাকবে।

Usage fields. কিছু প্রোভাইডার ল্যাটেন্সি (latency) কমাতে স্ট্রিমিং রেসপন্স থেকে টোকেন কাউন্ট বাদ দিয়ে দেয়। অন্যরা শুধুমাত্র শেষ চঙ্কে (chunk) usage যোগ করে, অথবা নন-স্ট্রিমিং কলের ক্ষেত্রে এটি পুরোপুরি বাদ দেয়। আপনি যদি গ্রাহকদের প্রতি টোকেন হিসেবে চার্জ করেন এবং আপনার অ্যাকাউন্টিং কোড প্রতিটি রেসপন্স অবজেক্টে usage.total_tokens থাকার আশা করে, তবে আপনার বিলিং পাইপলাইন নিঃশব্দে শূন্য (zero) রেকর্ড করবে।

Streaming behavior. Server-sent events স্ট্যান্ডার্ড হওয়ার কথা, তবুও প্রোভাইডাররা ভিন্ন ভিন্ন ফ্রিকোয়েন্সিতে বাফার ফ্লাশ (flush) করে। ইভেন্টের সীমানা (boundaries) ভিন্ন ভিন্ন হয়। একজন প্রোভাইডার [DONE] সিগন্যালের মাধ্যমে একটি স্ট্রীম শেষ করে। অন্যজন কোনো সেন্টিনেল (sentinel) ছাড়াই সংযোগটি সুন্দরভাবে বিচ্ছিন্ন করে দেয়। আপনার ক্লায়েন্ট যদি একটি নির্দিষ্ট ক্লোজিং মার্কারের জন্য অপেক্ষা করে, তবে এটি হ্যাং (hang) করবে।

Errors and timeouts. একজন প্রোভাইডার থেকে রেট লিমিট (rate limit) একটি 429 স্ট্যাটাস কোড এবং retry-after হেডার হিসেবে আসতে পারে, আবার অন্যজন থেকে এটি একটি অস্পষ্ট 502 হিসেবে আসতে পারে। কিছু প্রোভাইডার রিকোয়েস্ট গ্রহণ করে এবং তারপর নেটওয়ার্ক টাইমআউটের আগে দুই মিনিট চুপচাপ থাকে। OpenAI SDK জাদুকরীভাবে এগুলোকে আপনার লগ প্রত্যাশা করে এমন exception টাইপে রূপান্তরিত করবে না।

অপ্রত্যাশিত গঠনের জন্য ডিফেন্সিভ পার্সিং

সমাধান স্কিমা (schema)-কে বিশ্বাস করা নয়। সমাধান হলো প্রতিটি রেসপন্সকে একজন সন্দেহভাজন হিসেবে বিবেচনা করা।

content একটি string হবে বলে ধরে নেবেন না। এটি ব্যবহার করার আগে পরীক্ষা করে নিন।

const content = response.choices?.[0]?.message?.content;
const text = typeof content === "string" ? content.trim() : "";

Tool arguments বৈধ JSON হবে বলে ধরে নেবেন না। মডেল একটি কাজের প্রস্তাব দেয়। আপনার কোডকে সিদ্ধান্ত নিতে হবে যে সেই প্রস্তাবটি কার্যকর করার জন্য যথেষ্ট নিরাপদ কি না। প্রতিটি tool argument parse করার কোডকে একটি try-catch ব্লকের মধ্যে রাখুন। যদি JSON.parse ত্রুটি দেয়, তবে tool call-টিকে ত্রুটিপূর্ণ (malformed) হিসেবে গণ্য করুন এবং একটি failure handler-এ পাঠান। একটি ভুল ব্র্যাকেট বা একটি মিসিং কোট যেন কখনো unhandled exception হিসেবে সামনে না আসে।

যদি tool_calls থাকে কিন্তু content না থাকে, তবে আপনার অ্যাপ্লিকেশনকে একটি স্টেট ট্রানজিশন (state transition) চিনতে হবে। ব্যবহারকারী কোনো চ্যাট রিপ্লাই পাননি; সিস্টেম একটি কাজের অর্ডার (work order) পেয়েছে। এগুলো দুটি ভিন্ন পথ, এবং আপনার রাউটারকে স্ট্রিং ম্যানিপুলেশন করার আগেই এই পার্থক্যটি বুঝতে হবে।

ডেপ্লয় করার আগে বিহেভিয়ারাল টেস্ট

একটি "hi" মেসেজ দিয়ে এন্ডপয়েন্ট পিং করা মানে নেটওয়ার্ক কাজ করছে তা প্রমাণিত হয়। কিন্তু এটি আপনার অ্যাপ্লিকেশন সম্পর্কে কিছুই প্রমাণ করে না।

প্রোডাকশন ট্রাফিক রিডাইরেক্ট করার আগে, নতুন প্রোভাইডারের বিরুদ্ধে একটি টার্গেটেড বিহেভিয়ারাল টেস্ট স্যুট (behavioral test suite) চালান:

  • স্বাভাবিক টেক্সট রেসপন্স। যাচাই করুন যে content বিদ্যমান আছে, এটি একটি স্ট্রিং এবং কাস্টিং এরর (casting errors) ছাড়াই আপনার স্যানিটাইজেশন পাইপলাইনের (sanitization pipeline) মধ্য দিয়ে পাস করা যাচ্ছে।
  • ফোর্সড টুল কল (Forced tool call)। tool_choice কে required হিসেবে সেট করুন। প্রোভাইডার এটি মেনে চলছে কি না তা নিশ্চিত করুন, এবং content হিসেবে null, একটি খালি স্ট্রিং, নাকি একটি মিসিং কি (missing key) আসছে তা পরীক্ষা করুন। এই প্রতিটি অবস্থার জন্য আলাদা হ্যান্ডলার প্রয়োজন।
  • ভুল বা ম্যালফর্মড টুল আর্গুমেন্ট (Malformed tool arguments)। এমন পরিস্থিতি তৈরি করুন যেখানে মডেল টুল আর্গুমেন্টের ভেতরে ভাঙা JSON রিটার্ন করে। নিশ্চিত করুন যে আপনার পার্সার (parser) ওয়ার্কার ক্র্যাশ করার পরিবর্তে সেগুলোকে মার্জিতভাবে (gracefully) প্রত্যাখ্যান করে।
  • টোকেন লিমিটের কাছাকাছি রেসপন্স। কনটেক্সট উইন্ডোকে (context window) পূর্ণ করার চেষ্টা করুন। finish_reason পরীক্ষা করুন। যদি ট্রাঙ্কেশন (truncation) হওয়ার সময় প্রোভাইডার অপ্রত্যাশিত কিছু রিটার্ন করে, তবে আপনার সামারাইজেশন বা রিট্রাই লজিককে জানতে হবে কীভাবে প্রতিক্রিয়া জানাতে হয়।

এগুলো ইন্টিগ্রেশন টেস্ট (integration tests), ইউনিট টেস্ট (unit tests) নয়। এগুলো আপনার কোড এবং প্রোভাইডারের আচরণের (personality) মধ্যে প্রকৃত সম্পর্কটি পরীক্ষা করে। মাইগ্রেশন সম্পন্ন হয়েছে বলার আগে এগুলো পাস করুন।

একটি ইন্টারনাল কন্ট্রাক্ট (Internal Contract) তৈরি করুন

প্রোভাইডারের পার্থক্যগুলো আপনার নেটওয়ার্ক বাউন্ডারিতেই থেমে যাওয়া উচিত। সেগুলোকে বিজনেস লজিকে (business logic) মিশতে দেবেন না।

একটি নরমালাইজেশন লেয়ার (normalization layer) তৈরি করুন যা র (raw) SDK রেসপন্স গ্রহণ করবে এবং এমন একটি অবজেক্ট প্রদান করবে যা আপনার অ্যাপ্লিকেশনটি প্রকৃতপক্ষে ব্যবহার করে। প্রোভাইডার-নির্দিষ্ট ভিন্নতাগুলোকে একটি স্থিতিশীল ইন্টারনাল ফরম্যাটে ম্যাপ করুন। যদি প্রোভাইডার A টুল আর্গুমেন্ট হিসেবে স্ট্রিং রিটার্ন করে এবং প্রোভাইডার B অবজেক্ট রিটার্ন করে, তবে আপনার ম্যাপার উভয়কেই আপনার নিজস্ব ToolRequest স্ট্রাকচারে রূপান্তরিত করবে। যদি ইউসেজ (usage) তথ্য না থাকে, তবে আপনার ম্যাপার হয় এটি অনুমান করবে অথবা গ্যাপটি চিহ্নিত করবে, কিন্তু এটি কখনোই আপনার কস্ট-ট্র্যাকিং মডিউলে undefined ঢুকতে দেবে না।

যদি finish_reason স্ট্যান্ডার্ড না হয়, তবে সেটিকে আপনার নিজস্ব টার্মিনাল স্টেট (terminal states) এনামে (enum) রূপান্তর করুন: COMPLETE, TRUNCATED, TOOL_CALL, FILTERED। আপনার অ্যাপ্লিকেশনের সিদ্ধান্ত নেওয়া উচিত এই পরিষ্কার অ্যাবস্ট্রাকশনগুলোর (abstractions) ওপর ভিত্তি করে, কোনো থার্ড-পার্টি সার্ভার থেকে র (raw) স্ট্রিং দেখে নয়।

এই লেয়ারটি প্রোভাইডার পরিবর্তন করার প্রক্রিয়াটিকে একটি অনিশ্চিত ও জটিল কাজ থেকে একটি সহজ সিঙ্গেল-ফাইল চেঞ্জে পরিণত করে। আপনি শুধু ম্যাপারটি পুনরায় লিখবেন, বিহেভিয়ারাল টেস্টগুলো চালাবেন এবং কাজ শেষ করবেন। আপনার অ্যাপ্লিকেশন অপরিবর্তিত থাকবে।

একটি ডিপেন্ডেন্সি আপগ্রেড, কনফিগ টুইক (Config Tweak) নয়

LLM প্রোভাইডার পরিবর্তন করা CDN এন্ডপয়েন্ট পরিবর্তনের মতো নয়। এটি আপনার ডেটাবেস PostgreSQL থেকে MySQL-এ পরিবর্তন করার মতো। আপনি কখনোই ধরে নেবেন না যে একই কানেকশন স্ট্রিং মানে একই রকম কুয়েরি বিহেভিয়ার। আপনি লকিং সিম্যান্টিক্স (locking semantics), মাইগ্রেশন পাথ এবং ইনডেক্সিংয়ের খুঁটিনাটি পরীক্ষা করবেন। LLM-এর ক্ষেত্রেও একই শ্রদ্ধার প্রয়োজন। এগুলো হলো প্রবাবিলিস্টিক সিস্টেম (probabilistic systems) যা স্ট্যান্ডার্ড API হিসেবে ছদ্মবেশ ধারণ করে, এবং তাদের রেসপন্সে ফরম্যাটিং, ট্রাঙ্কেশন এবং কন্ট্রোল ফ্লো সম্পর্কে এমন কিছু ধারণা থাকে যা একটি মাত্র নেটওয়ার্ক এরর ছাড়াই আপনার অ্যাপ্লিকেশনকে পুরোপুরি অকেজো করে দিতে পারে।

বাগটি কখনোই কানেকশনে ছিল না। এটি ছিল এই ধারণায় যে সামঞ্জস্যতা (compatibility) মানেই অভিন্নতা। তা নয়। এর গঠন (shape) যাচাই করুন। প্রান্তিক অবস্থাগুলো (edges) পরীক্ষা করুন। কন্ট্রাক্টটি নিজের নিয়ন্ত্রণে রাখুন।


Source: The Bug Only Happened After I Switched LLM Providers

Community: GyaanSetu AI on Telegram