Quay lại blog
Kỹ thuật·24 tháng 5, 2026·Đọc 9 phút

Kunavo chuyển dự phòng như thế nào: bốn lớp giữa lỗi upstream và client của bạn

Toàn bộ đường đi của cơ chế chuyển dự phòng, cùng hằng số phía sau mỗi con số: bộ ngắt mạch bỏ qua kênh đang lỗi trước khi gửi yêu cầu, tối đa ba bước chuyển theo chi phí trong một yêu cầu, cổng stream từ chối xác nhận phản hồi 200 chuyển thành lỗi và cơ chế dự phòng bốn phút cho upstream không bao giờ phản hồi.

Mọi bài viết về gateway mà bạn đọc đều trích dẫn một tỷ lệ khả dụng. Bài viết này thì không, vì chúng tôi không công bố SLA và muốn mô tả một cơ chế mà bạn có thể kiểm tra thay vì một con số mà bạn không thể kiểm chứng. Sau đây là toàn bộ đường đi giữa lúc upstream trả về lỗi và lúc client của bạn nhìn thấy lỗi đó: bốn lớp, bốn hằng số và lý do mỗi hằng số có giá trị như vậy.

Tại sao một upstream duy nhất là chưa đủ

Một nhà cung cấp mô hình có hai mươi phút hoạt động kém là chuyện bình thường. Sự cố khu vực, bộ giới hạn tốc độ được siết chặt, tình trạng thiếu công suất ở một mô hình mới ra mắt — không điều nào trong số này là bất thường, và tất cả đều vô hình với bạn cho đến khi tỷ lệ lỗi của chính bạn tăng lên. Nếu ứng dụng của bạn gọi đến một endpoint duy nhất, khả dụng của bạn chính là khả dụng của endpoint đó và từ phía client bạn không thể làm gì ngoài việc thử lại; đến lúc đó người dùng đã phải chờ.

Giải pháp thay thế là có một nơi khác để chuyển đến. Kunavo phân giải một chuỗi định tuyến cho mỗi mô hình — tối đa ba kênh độc lập có thể phục vụ mô hình đó, được sắp xếp theo chi phí — và bốn lớp bên dưới là cách một yêu cầu di chuyển qua chuỗi đó mà bạn không cần tự viết logic thử lại.

Lớp 1: bộ ngắt mạch (trước khi gửi yêu cầu)

Chuyển đổi dự phòng rẻ nhất là chuyển đổi không bao giờ tốn một vòng khứ hồi. Mỗi tiến trình gateway duy trì số lỗi trong năm phút gần nhất cho từng kênh. Ba lỗi trong khoảng thời gian đó khiến kênh được xem là mở: trong sáu mươi giây tiếp theo, kênh bị loại khỏi chuỗi trước khi bất kỳ yêu cầu nào được gửi đi.

provider-health.ts
// Layer 1, simplified from lib/providers/provider-health.ts.
// A channel that has been failing is skipped BEFORE we send anything,
// so the cost of avoiding it is zero — no request, no timeout, no wait.
const WINDOW_MS   = 300_000;  // failures older than 5 minutes are forgotten
const FAIL_THRESHOLD = 3;     // 3 failures inside that window opens the circuit
const COOLDOWN_MS = 60_000;   // and the channel is bypassed for 60 seconds

function isOpen(channel: string, now = Date.now()): boolean {
  const s = state.get(channel);
  return s != null && s.openUntil > now;
}

function recordFailure(channel: string, now = Date.now()) {
  const s = state.get(channel) ?? { failures: [], openUntil: 0 };
  s.failures = s.failures.filter((t) => now - t < WINDOW_MS);
  s.failures.push(now);
  if (s.failures.length >= FAIL_THRESHOLD) s.openUntil = now + COOLDOWN_MS;
}

Đây là lớp duy nhất thực sự tức thời, và cần nói chính xác lý do: không phải chúng tôi đang đo nhanh. Quyết định đã được đưa ra tối đa năm phút trước bởi một yêu cầu trước đó đã nhận lỗi thay cho bạn. Trạng thái được lưu theo từng tiến trình, nên mỗi máy học độc lập — trong lưu lượng thực tế, một kênh xấu sẽ ngắt trên tất cả máy trong khoảng vài giây gần nhau, còn kênh chỉ gặp sự cố ngắn sẽ tự phục hồi khi thời gian làm mát kết thúc.

Lớp 2: chuyển đổi dự phòng trong yêu cầu (tối đa ba bước)

Khi một kênh có vẻ khỏe mạnh nhưng vẫn trả về lỗi, yêu cầu sẽ chuyển sang kênh tiếp theo trong chuỗi. Giới hạn là ba bước, ROUTING_CHAIN_MAX, và chuỗi được sắp xếp theo chi phí, nên lần thử đầu tiên luôn là kênh rẻ nhất có thể phục vụ mô hình.

dispatch.ts
// Layer 2. The routing chain is resolved from the catalog, cost-ordered,
// and capped at ROUTING_CHAIN_MAX hops (3). It is static configuration, not
// a learned weighting: you can read the exact chain for any model in the
// admin UI, and it is the same chain for every customer.
async function dispatch(req: ChatRequest) {
  const chain = routingChain(req.model)          // up to 3 channels, cheapest first
    .filter((c) => !isOpen(c.id));               // layer 1 removes the known-bad

  let lastError: UpstreamError | null = null;
  for (const channel of chain) {
    try {
      return await callUpstream(channel, req);
    } catch (err) {
      if (!isRetryable(err)) throw err;          // a 400 is yours, not ours
      recordFailure(channel.id, Date.now());
      lastError = err;
    }
  }
  throw lastError ?? new Error("all channels exhausted");
}

Lỗi phía client không được chuyển đổi dự phòng. Lỗi 400 do yêu cầu sai định dạng hoặc lỗi 404 do mô hình không tồn tại sẽ giống nhau trên mọi kênh, nên thử lại ba lần chỉ khiến bạn chờ lâu gấp ba cho cùng một thông báo. Chỉ các lỗi upstream có thể thử lại mới đi qua chuỗi.

Chi phí thực tế của lớp này là một vòng khứ hồi thất bại cho mỗi bước. Nếu một kênh chấp nhận kết nối rồi trả về 503 sau hai giây, bạn đã chờ hai giây trước khi lần thử thứ hai bắt đầu. Đó là lý do thực sự khiến lớp 1 quan trọng: nó ngăn một kênh liên tục thất bại thu thêm khoản phí hai giây đó từ mọi yêu cầu.

Lớp 3: cổng luồng (30 giây)

Luồng khiến chuyển đổi dự phòng trở nên khó hơn theo một cách dễ bỏ sót. Upstream có thể trả về 200 OK, mở một luồng rồi chỉ sau đó mới phát ra sự kiện lỗi. Khi sự kiện đến, một proxy ngây thơ đã gửi cho client của bạn header và một số byte, nên không còn cách nào chuyển tiếp — yêu cầu đã gắn với một kênh sẽ không trả lời nó.

stream-gate.ts
// Layer 3, from lib/providers/stream-gate.ts. An upstream can answer 200 OK
// and then put the error inside the stream. Committing that to your client
// means you get a truncated answer and we cannot fail over any more, so the
// gateway holds the first frames back until it knows which it is.
export const STREAM_GATE_TIMEOUT_MS = 30_000;

// Outcomes:
//   a decisive content frame  -> commit, stream the rest through untouched
//   a decisive error frame    -> discard, fall through to the next channel
//   nothing decisive in 30s   -> commit anyway (a slow model is not an error)

Vì vậy gateway không quyết định dựa trên mã trạng thái. Nó giữ các khung mở đầu cho đến khi thấy điều dứt khoát: nội dung thực, khi đó luồng được xác nhận và từ đó chuyển tiếp nguyên trạng; hoặc lỗi, khi đó chưa có gì đến với bạn và yêu cầu tiếp tục đi xuống chuỗi. Ba mươi giây là giới hạn cho quyết định đó, tồn tại dành cho các mô hình suy luận thực sự cần thời gian dài trước khi phát token đầu tiên. Sau thời điểm đó, chúng tôi xác nhận, vì mô hình chậm không đồng nghĩa với mô hình hỏng.

Lớp 4: thời gian chờ header (240 giây)

Lớp cuối cùng là lớp không ai thích. Nếu upstream chấp nhận kết nối rồi hoàn toàn không trả về header phản hồi, yêu cầu sẽ treo và thứ duy nhất kết thúc nó là thời gian chờ — UPSTREAM_HEADERS_TIMEOUT_MS, bốn phút.

Bốn phút là thời gian chờ dài, và đó là chủ ý. Việc tạo video và hình ảnh thực sự có thể mất vài phút; thời gian chờ đủ ngắn để tạo cảm giác phản hồi nhanh trong chat sẽ hủy những tác vụ video vốn sẽ thành công. Upstream bị treo cũng hiếm hơn so với upstream báo lỗi rõ ràng, và các trường hợp báo lỗi rõ ràng đã được xử lý ở lớp 1 đến lớp 3. Chúng tôi thà chậm trong trường hợp hiếm gặp còn hơn sai trong trường hợp thường gặp.

Nếu ứng dụng của bạn cần giới hạn chặt hơn mức này, hãy đặt thời gian chờ ở phía client. Thời gian chờ của chúng tôi là biện pháp dự phòng cuối, không phải mục tiêu độ trễ.

Điều chúng tôi không tuyên bố

  • Không có con số chuyển đổi dự phòng tính bằng mili giây. Lớp 1 không tốn vòng khứ hồi vì quyết định được đưa ra trước yêu cầu của bạn. Lớp 2 đến lớp 4 tốn đúng khoảng thời gian upstream thất bại cần để thất bại. Không con số nào trong đó có thể được đưa lên trang tiếp thị một cách trung thực.
  • Không có tỷ lệ khả dụng. Không có SLA và không có cơ chế tín dụng, nên một con số sẽ chỉ là trang trí. Trang trạng thái hiển thị tỷ lệ thành công mà chúng tôi thực sự quan sát được, bao gồm cả những khoảng thời gian tỷ lệ đó không tốt.
  • Không âm thầm thay thế mô hình. Chuyển đổi dự phòng di chuyển yêu cầu giữa các kênh phục vụ cùng một mô hình. Chúng tôi không bao giờ trả lời bằng mô hình rẻ hơn mô hình bạn yêu cầu — đây là thông lệ bạn nên hỏi trực tiếp bất kỳ gateway nào, vì nó không thể nhìn thấy từ phản hồi.

Điều này có nghĩa gì khi bạn xây dựng trên đó

Phần lớn có nghĩa là bạn có thể xóa vòng lặp thử lại. Các lỗi upstream có thể thử lại đã được thử lại qua các kênh độc lập trước khi bạn nhìn thấy lỗi, vì vậy việc client thử lại lỗi 5xx từ chúng tôi là thử lại thứ đã được thử ba lần. Hãy giữ thời gian chờ của bạn và bỏ backoff.

Điều duy nhất đáng để bạn tự xử lý là 402 insufficient balance, vốn hoàn toàn không phải trường hợp chuyển đổi dự phòng — không có số lần thử lại nào làm đầy lại ví. Xem tài liệu tham chiếu lỗi để biết trạng thái nào đáng thử lần thứ hai và trạng thái nào là kết thúc.