Yêu cầu đã thành công. Phản hồi là JSON hợp lệ. SDK vẫn im lặng. Thế nhưng ứng dụng lại sụp đổ.
Đây là câu chuyện về những gì xảy ra khi bạn coi việc thay đổi nhà cung cấp LLM như một thay đổi cấu hình thay vì một canh bạc về mặt cấu trúc. Bạn dán một base URL mới, thay đổi API key, và giữ nguyên request body vì tài liệu hứa hẹn một endpoint tương thích với OpenAI. Với một prompt "hello world" cơ bản, nó hoạt động. Bạn ăn mừng. Sau đó, lưu lượng truy cập thực tế ập đến, và các vết nứt bắt đầu lộ ra.
Ảo tưởng về sự tương thích về mặt giao thức
Sự tương thích ở lớp HTTP chỉ là bề nổi. Một mã trạng thái 200 và một thân JSON có nghĩa là máy chủ đã chấp nhận tin nhắn của bạn. Nó không có nghĩa là máy chủ suy nghĩ theo cùng một cách như máy chủ trước đó. Các endpoint tương thích với OpenAI chia sẻ cùng một cấu trúc yêu cầu, nhưng chúng không chia sẻ cùng một hợp đồng hành vi. Hai nhà cung cấp có thể tiếp nhận các payload giống hệt nhau nhưng lại trả về các câu trả lời khác biệt theo những cách tinh vi và mang tính hủy diệt.
Mã của bạn đưa ra các giả định. Bạn giả định message.content là một chuỗi vì trước đây nó luôn như vậy. Bạn giả định một tool call sẽ đến với JSON sạch sẽ, có thể phân tích được. Bạn giả định finish_reason báo hiệu đúng như những gì bạn nghĩ. Những giả định này vô hình cho đến khi chúng gây ra lỗi nghiêm trọng.
Hãy xem xét lỗi khiến mọi thứ bắt đầu:
const text = response.choices[0].message.content.trim();
Dòng này trông có vẻ vô hại. Nó đã hoạt động trong nhiều tuần. Sau đó, nhà cung cấp mới trả về một tool call. Vào khoảnh khắc đó, message.content không phải là một chuỗi rỗng. Nó là null. Payload thực tế nằm bên trong message.tool_calls, nhưng trình phân tích (parser) đã đi tiếp, gọi hàm .trim() trên một giá trị không tồn tại. API không báo lỗi. Lớp mạng không phàn nàn. Chính trình phân tích của bạn đã làm hỏng yêu cầu.
Nơi các nhà cung cấp âm thầm khác biệt
Những sự khác biệt không được thông báo trong changelog. Chúng nằm ở các lề của đối tượng phản hồi, chờ đợi các trường hợp biên (edge cases).
Định dạng tool-call. Một nhà cung cấp gửi các đối số của tool dưới dạng một đối tượng JSON đã được xác thực trước. Một nhà cung cấp khác lại gửi chúng dưới dạng một chuỗi đã được escape bên trong một trường. Nhà cung cấp thứ ba có thể chia nhỏ một tool call dài qua nhiều streaming deltas, buộc bạn phải đệm (buffer) các phần trước khi có thể kiểm tra xem cấu trúc có hợp lệ hay không. Nếu ứng dụng của bạn mong đợi một khối (blob) duy nhất có thể phân tích được, nó sẽ bị nghẽn.
Lý do kết thúc (Finish reasons). OpenAI sử dụng các chuỗi cụ thể như "stop", "length", "tool_calls", và "content_filter". Một nhà cung cấp tương thích có thể trả về "end_turn" hoặc đơn giản là bỏ qua trường này khi mô hình chạm giới hạn token. Nếu logic thử lại (retry) hoặc dự phòng (fallback) của bạn chờ đợi "length" để phát hiện việc bị cắt bớt, nó sẽ đứng yên trong khi người dùng thấy một câu trả lời chưa hoàn chỉnh.
Các trường sử dụng (Usage fields). Một số nhà cung cấp loại bỏ số lượng token khỏi các phản hồi streaming để giảm độ trễ vài mili giây. Những nhà cung cấp khác chỉ đính kèm thông tin sử dụng vào phần (chunk) cuối cùng, hoặc bỏ qua hoàn toàn trong các cuộc gọi không streaming. Nếu bạn tính phí khách hàng theo từng token và mã kế toán của bạn mong đợi usage.total_tokens tồn tại trong mọi đối tượng phản hồi, quy trình thanh toán của bạn sẽ âm thầm ghi nhận giá trị bằng không.
Hành vi streaming. Các sự kiện do máy chủ gửi (Server-sent events) đáng lẽ phải là tiêu chuẩn, nhưng các nhà cung cấp lại đẩy dữ liệu (flush buffers) với tần suất khác nhau. Ranh giới sự kiện cũng khác nhau. Một nhà cung cấp kết thúc luồng bằng tín hiệu [DONE]. Một nhà cung cấp khác ngắt kết nối một cách gọn gàng mà không có bất kỳ tín hiệu đánh dấu nào. Nếu client của bạn bị chặn để chờ một dấu hiệu kết thúc cụ thể, nó sẽ bị treo.
Lỗi và thời gian chờ (Errors and timeouts). Giới hạn tốc độ (rate limit) có thể xuất hiện dưới dạng mã 429 với header retry-after từ một nhà cung cấp, và là mã 502 mơ hồ từ một nhà cung cấp khác. Một số nhà cung cấp chấp nhận yêu cầu và sau đó im lặng trong hai phút trước khi xảy ra lỗi timeout mạng. OpenAI SDK sẽ không tự động chuẩn hóa những lỗi này thành các loại ngoại lệ (exception types) mà nhật ký (logs) của bạn mong đợi.
Phân tích phòng thủ cho các cấu trúc không thể dự đoán
Cách khắc phục không phải là tin tưởng vào schema. Cách khắc phục là coi mọi phản hồi đều là đối tượng cần nghi vấn.
Đừng giả định content là một chuỗi. Hãy kiểm tra nó trước khi bạn chạm vào.
const content = response.choices?.[0]?.message?.content;
const text = typeof content === "string" ? content.trim() : "";
Đừng giả định các đối số của tool là JSON hợp lệ. Mô hình đề xuất một hành động. Mã của bạn phải quyết định xem đề xuất đó có đủ an toàn để thực thi hay không. Hãy bao bọc mọi thao tác phân tích đối số của tool trong một khối try-catch. Nếu JSON.parse ném ra lỗi, hãy coi tool call đó là rác không đúng định dạng và chuyển nó đến trình xử lý lỗi (failure handler). Một dấu ngoặc bị ảo giác hoặc một dấu ngoặc kép bị thiếu không bao giờ được phép nổi lên như một ngoại lệ không được xử lý.
Nếu tool_calls tồn tại nhưng content bị thiếu, ứng dụng của bạn nên nhận ra một sự chuyển đổi trạng thái. Người dùng không nhận được phản hồi chat. Hệ thống nhận được một lệnh làm việc. Đó là hai con đường khác nhau, và bộ định tuyến (router) của bạn nên biết sự khác biệt đó trước khi nó cố gắng thao tác chuỗi.
Kiểm thử hành vi trước khi triển khai
Pinging the endpoint with a "hi" message proves the network works. It proves nothing about your application.
Before you redirect production traffic, run a targeted behavioral test suite against the new provider:
- Normal text response. Verify that
contentexists, is a string, and can be passed through your sanitization pipeline without casting errors. - Forced tool call. Set
tool_choiceto required. Confirm the provider honors it, and check whethercontentarrives asnull, an empty string, or a missing key. Each of those states needs its own handler. - Malformed tool arguments. Inject scenarios where the model returns broken JSON inside tool arguments. Ensure your parser rejects them gracefully instead of crashing the worker.
- Response near the token limit. Push the context window. Check the
finish_reason. If the provider returns something unexpected when truncation happens, your summarization or retry logic must know how to react.
These are integration tests, not unit tests. They exercise the real relationship between your code and the provider's personality. Pass them before you call the migration done.
Build an Internal Contract
Provider differences should stop at your network boundary. Do not let them leak into business logic.
Create a normalization layer that consumes the raw SDK response and emits an object your application actually owns. Map provider-specific eccentricities into a stable internal format. If Provider A returns tool arguments as strings and Provider B returns objects, your mapper flattens both into your own ToolRequest structure. If usage is missing, your mapper either estimates it or flags the gap, but it never lets undefined seep into your cost-tracking modules.
If finish_reason is nonstandard, translate it into your own enum of terminal states: COMPLETE, TRUNCATED, TOOL_CALL, FILTERED. Your app should decide what to do based on these clean abstractions, not by sniffing raw strings from a third-party server.
This layer turns provider swaps from a game of whack-a-mole into a single-file change. You rewrite the mapper, run the behavioral tests, and move on. Your application remains untouched.
A Dependency Upgrade, Not a Config Tweak
Switching LLM providers is not like swapping CDN endpoints. It is closer to changing your database from PostgreSQL to MySQL. You would never assume the same connection string means identical query behavior. You would test locking semantics, migration paths, and indexing quirks. LLMs deserve the same respect. They are probabilistic systems masquerading as standard APIs, and their responses carry assumptions about formatting, truncation, and control flow that can shatter your application without raising a single network error.
The bug was never in the connection. It was in the assumption that compatibility means sameness. It does not. Validate the shape. Test the edges. Own the contract.
Source: The Bug Only Happened After I Switched LLM Providers
Community: GyaanSetu AI on Telegram
