読んだゲートウェイの記事はすべて可用性の割合を引用しています。この文書にそれがないのは、SLAを公開しておらず、確認できない数字よりも確認可能な仕組みを説明したいからです。以下では、アップストリームがエラーを返してからクライアントがエラーを受け取るまでの経路全体を説明します。4つのレイヤー、4つの定数、そして各定数がその値である理由です。
なぜ単一のアップストリームでは不十分なのか
モデルプロバイダーに20分間ほど問題が起きるのは珍しくありません。地域障害、レートリミッターの強化、新しく公開されたモデルの容量逼迫など、どれも特殊な事象ではなく、自分のエラー率が動くまで利用者からは見えません。アプリケーションが1つのエンドポイントと通信する場合、可用性はそのエンドポイントの可用性そのものであり、クライアント側でできることはリトライだけです。しかし、その時点ではユーザーはすでに待たされています。
代わりに、別の行き先を用意します。Kunavoはモデルごとにルーティングチェーンを解決します。つまり、そのモデルを提供できる独立したチャネルを最大3つ、コスト順に並べます。以下の4つのレイヤーは、リトライロジックを自分で書かなくても、リクエストがそのチェーンを通過する仕組みです。
レイヤー1:サーキットブレーカー(リクエスト送信前)
最も安価なフェイルオーバーは、ラウンドトリップを一度も発生させないものです。各ゲートウェイプロセスは、チャネルごとに直近5分間の失敗数を保持します。その期間内に3回失敗すると、チャネルはオープン状態とみなされ、次の60秒間はリクエストをディスパッチする前にチェーンから除外されます。
// 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;
}これが本当に即時に動作する唯一のレイヤーです。その理由を正確に説明すると、何かを高速に測定しているわけではありません。判断は最大5分前に、あなたに代わって失敗を引き受けた以前のリクエストによって行われています。状態はプロセス単位なので、各マシンが独立して学習します。実際のトラフィック下では、問題のあるチャネルはすべてのマシンで互いに数秒の差でトリップし、短時間だけ不調だったチャネルはクールダウンが終わると自動的に回復します。
レイヤー2:リクエスト内フェイルオーバー(最大3ホップ)
正常に見えたチャネルがそれでもエラーを返した場合、リクエストはチェーン内の次のチャネルにフォールスルーします。上限は3ホップ、ROUTING_CHAIN_MAXで、チェーンはコスト順に並んでいるため、最初の試行は常に、そのモデルを提供できる最も安価なチャネルになります。
// 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");
}クライアントエラーではフェイルオーバーしません。形式不正なリクエストに対する400や、存在しないモデルに対する404は、どのチャネルからも同じ回答になるため、3回リトライしても同じメッセージを3倍の時間待つだけです。チェーンを進むのは、リトライ可能なアップストリーム障害だけです。
このレイヤーで発生する実質的なコストは、ホップごとに1回の失敗したラウンドトリップです。チャネルが接続を受け付けた後、2秒たってから503を返した場合、2回目の試行が始まるまでその2秒を待つことになります。レイヤー1が重要なのはこのためです。確実に失敗し続けるチャネルが、すべてのリクエストに対して2秒分の待ち時間を課すのを防ぎます。
レイヤー3:ストリームゲート(30秒)
ストリーミングでは、見落としやすい形でフェイルオーバーが難しくなります。アップストリームが200 OKを返してストリームを開いた後、初めてエラーイベントを送信する場合があります。それが届く頃には、単純なプロキシはすでにクライアントへヘッダーと一部のバイトを送信しており、もはやフォールスルーできません。応答しないチャネルにリクエストがコミットされているためです。
// 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)そのため、ゲートウェイはステータスコードだけではコミットしません。決定的なものを確認するまで開始フレームを保持します。実際のコンテンツを確認した場合はストリームをコミットし、その後は変更せずに中継します。エラーを確認した場合は、何もあなたの元に届いていないため、リクエストはチェーンを下ります。この判断の上限が30秒です。これは、最初のトークンを出すまで正当に長時間考える推論モデルのために設けられています。それを超えたらコミットします。遅いモデルは壊れているわけではないからです。
レイヤー4:ヘッダータイムアウト(240秒)
最後のレイヤーは、誰も好まないものです。アップストリームが接続を受け付けた後、応答ヘッダーをまったく返さない場合、リクエストはハングします。それを終わらせる唯一のものがタイムアウト、つまりUPSTREAM_HEADERS_TIMEOUT_MS、4分です。
4分は待つには長い時間ですが、意図的に設定しています。動画や画像の生成には正当に数分かかることがあり、チャットで応答性を感じられるほど短いタイムアウトでは、成功するはずだった動画ジョブをキャンセルしてしまいます。また、ハングするアップストリームは、明確に失敗するアップストリームよりもまれで、明確な失敗はすでにレイヤー1~3で処理されています。まれなケースで遅くなることを、一般的なケースで誤ることより優先します。
自分のアプリケーションでこれより厳しい上限が必要なら、クライアント側のタイムアウトを設定してください。私たちのタイムアウトは保険であり、レイテンシー目標ではありません。
私たちが主張していないこと
- ミリ秒単位のフェイルオーバー値はありません。 レイヤー1は判断がリクエストより前に行われるため、ラウンドトリップを消費しません。レイヤー2~4は、失敗するアップストリームが失敗するまでにかかった時間と正確に同じだけコストがかかります。どちらも、マーケティングページに正直に掲載できる数字ではありません。
- 可用性の割合はありません。 SLAもクレジット制度もないため、数字は飾りになってしまいます。ステータスページには、良好でなかった期間も含め、実際に観測した成功率を表示します。
- モデルの無断置き換えはありません。 フェイルオーバーは、同じモデルを提供するチャネル間でリクエストを移動させます。要求したモデルより安価なモデルで応答することはありません。これは応答からは見えないため、どのゲートウェイにも直接確認する価値のある慣行です。
これを基盤として構築する場合の意味
主に、リトライループを削除できるということです。リトライ可能なアップストリームエラーは、あなたが失敗を確認する前に独立したチャネル間ですでにリトライされるため、こちらからの5xxに対するクライアント側のリトライは、すでに3回試されたものを再試行することになります。タイムアウトは維持し、バックオフは削除してください。
自分で処理する価値があるのは402 insufficient balanceだけです。これはフェイルオーバーのケースではなく、どれだけリトライしてもウォレットに残高は補充されません。どのステータスなら再試行に値し、どれが最終結果なのかは、エラーリファレンスを参照してください。