前回の続きから始める
前回の記事で、Claude Code SDK を使って 2 週間で社内エージェントを TypeScript で組んだ話を書いた。あの構成は今でも動いている。ただ、本番投入から約 6 週間が経った頃、開発チームの Slack に妙な書き込みが増え始めた。「また今日も止まってる」「なんか応答遅くない?」。原因はだいたい同じで、Anthropic 側の rate limit (429) か、特定地域での一時的な capacity 不足、あるいは数分の API 停止だった。
1 回あたりは数十秒〜数分の影響で済むのだが、業務時間中に「社内ボットがコケる」という事象が頻発し始めると、ユーザーの信頼は一瞬で削れる。正直に書くと、初期設計で「単一ベンダーで十分」と判断したのは筆者で、その判断の代償が現場で出てきた格好だ。
そこで、ボットの応答エンジン部分を n8n に寄せ、3-layer の Multi-LLM Gateway に組み直した。本記事はその実装記録になる。前回の延長線というよりは、「production でしばらく回した結果、構成を一段抽象化した話」と思って読んでほしい。前回記事は Claude Code SDK で社内エージェントを 2 週間で構築 に置いてある。
3-layer Gateway の全体像
結論から書くと、こういう構成に落ち着いた。
[ User / Slack / Web ]
│
▼
┌─────────────────────────────┐
│ L1: Capability Router │ ← 意図分類 → モデルクラス決定
│ (n8n Function Node) │
└──────────────┬──────────────┘
│
▼
┌─────────────────────────────┐
│ L2: Fallback Chain │ ← primary → secondary → tertiary
│ (n8n Switch + Try/Catch) │
└──────────────┬──────────────┘
│
▼
┌─────────────────────────────┐
│ L3: Knowledge Integration │ ← Notion を retriever として注入
│ (AI Agent + Vector Store) │
└─────────────────────────────┘
L1 で「この request はどのクラスのモデルで処理するべきか」を決める。L2 でそのクラスの primary が落ちていたら secondary・tertiary に逃がす。L3 は AI Agent ノードに Notion knowledge base を tool として接続するレイヤーで、ナレッジボットとしての本体になる。各層は責務が分かれているので、後から差し替えやテストもしやすい。

L1: Capability Router の実装
capability router は最初、LLM 自身に分類させていた。haiku に「分類だけしろ」と投げる方式だ。ただ実運用で測ってみると、「分類のためにモデル呼ぶ → 本番モデル呼ぶ」で 2 回コストがかかる上、分類自体に 600ms 前後の latency が乗る。社内チャットの応答としては、これは無視できない遅延だった。
結局、軽量分類はルール + 小さな keyword matching に置き換え、LLM 分類は「ルールで判定不能だった場合の fallback」に格下げした。n8n の Function ノードに置いた classifier の本体はざっくりこんな感じだ。
type ModelClass = "light" | "balanced" | "deep" | "code";
interface RouteDecision {
modelClass: ModelClass;
reason: string;
}
const KEYWORDS: Record<ModelClass, string[]> = {
code: ["コード", "TypeScript", "Python", "実装", "リファクタ", "bug"],
deep: ["設計", "アーキテクチャ", "比較", "trade-off", "提案"],
light: ["要約", "短く", "一言で", "tldr"],
balanced: [],
};
export function route(input: string, tokenEstimate: number): RouteDecision {
// 長すぎる入力は無条件で deep へ
if (tokenEstimate > 8000) {
return { modelClass: "deep", reason: "long-context" };
}
for (const [cls, words] of Object.entries(KEYWORDS) as [ModelClass, string[]][]) {
if (words.some((w) => input.includes(w))) {
return { modelClass: cls, reason: keyword:${cls} };
}
}
return { modelClass: "balanced", reason: "default" };
}
愚直で恥ずかしいが、これで 8 割の request はルールで決まる。残り 2 割は haiku に分類だけ任せる構造だが、その 2 割でも「ルールで決まらなかった時だけ」なので、コストとレイテンシのインパクトは小さい。最初から LLM 任せにしていた頃と比べ、平均応答時間が 0.5 秒ほど縮んだ。
L2: Fallback Chain
これが今回のメインの仕事だった。primary が落ちている時に、別ベンダーの同等クラスへ自動で逃がす。考え方は circuit breaker と weighted routing の組み合わせで、特殊なものではないが、n8n の Function + HTTP Request ノードだけで組めるところまで素朴に書いた。
type Provider = "anthropic" | "openai" | "google" | "groq";
interface Endpoint {
provider: Provider;
model: string;
call: (prompt: string) => Promise<string>;
}
const CHAIN: Record<ModelClass, Endpoint[]> = {
light: [endpoints.haiku, endpoints.gpt4oMini, endpoints.flash],
balanced: [endpoints.sonnet, endpoints.gpt4o, endpoints.gemini25Pro],
deep: [endpoints.opus, endpoints.gpt4o, endpoints.gemini25Pro],
code: [endpoints.sonnet, endpoints.gpt4o, endpoints.haiku],
};
const breaker = new Map<Provider, { openedAt: number }>();
const OPEN_MS = 60_000; // 1 分は再試行しない
async function callWithFallback(cls: ModelClass, prompt: string) {
const errors: string[] = [];
for (const ep of CHAIN[cls]) {
const b = breaker.get(ep.provider);
if (b && Date.now() - b.openedAt < OPEN_MS) continue;
try {
return await ep.call(prompt);
} catch (e: any) {
errors.push(${ep.provider}/${ep.model}: ${e.message});
if (e.status === 429 || e.status >= 500) {
breaker.set(ep.provider, { openedAt: Date.now() });
}
}
}
throw new Error("all providers failed: " + errors.join(" | "));
}
ハマりポイントが 2 つあった。1 つは、provider 切り替え時に system prompt や tool definition の形式が違うこと。Claude の tool use と OpenAI の function calling は表現が違うので、内部表現を一度中間 schema に正規化してから各 SDK の形に変換するレイヤーを噛ませた。これがないと、fallback 中に「tool が呼ばれない」「JSON が壊れる」が起きる。
もう 1 つは、ストリーミング時の chunk 形式の差だ。SSE の event 名・delta の構造がベンダーごとに違うので、Slack の typing indicator を出している以上、ここも統一しないと UX が崩れる。今回は最初 streaming を諦めて全件 non-stream に倒した。後から streaming を入れ直した時には、中間 chunk type を 1 種類に潰してから出している。順番を間違えると痛い目を見るので、ここは先に non-stream で安定させたのは正解だった。
L3: Notion を retriever として AI Agent に注入
ナレッジ統合は、n8n が公式に出している Notion Knowledge Base AI Assistant のテンプレートをほぼそのまま借りた。Notion DB を Document Loader で吸い、Text Splitter で割って Embeddings、Vector Store に積み、AI Agent に Retriever tool として渡す。
[Notion DB]
│ (n8n Notion Trigger / Scheduled)
▼
[Document Loader] → [Splitter] → [Embeddings] → [Vector Store]
│
▼
[AI Agent] ── tool ── [Retriever]
n8n の AI Agent ノードは、LLM の差し替えが ChatModel の入力ポートを繋ぎ替えるだけで済むので、L2 の出力を ChatModel の代わりに injection する設計と相性がいい。実際には L2 の callWithFallback を内包した「Custom LLM Wrapper」 ノードを 1 つ作って、AI Agent にそれを差している。
// AI Agent から呼ばれる Custom Chat Model の最小実装イメージ
export class GatewayChatModel {
constructor(private cls: ModelClass) {}
async invoke(messages: { role: string; content: string }[]) {
const prompt = renderPrompt(messages);
const text = await callWithFallback(this.cls, prompt);
return { role: "assistant", content: text };
}
}
1 点だけ注意したのは、retriever のヒット件数 (topK) を欲張らないことだ。最初 topK=8 で組んだら、コンテキストが膨らんで balanced クラスでも回答品質が落ちた。今は topK=4 + 軽量 reranker を間に挟んでいる。reranker は別の小さな model に投げているので、ここでも L2 の恩恵が効く。reranker が落ちても、検索結果の上位 4 件をそのまま渡すフォールバックを足してある。
運用 1 か月の metrics と気づき
ざっくりした数字だが、本番投入から 1 か月ほど経った時点の数字を載せる。社内ボットなので絶対値は小さいが、傾向は十分に見える。
request: 約 14,000 件 / 月、primary 成功率 96.4%、fallback 発火 3.2%、全失敗 0.4%
平均 latency: balanced クラスで 1.8 秒、deep クラスで 4.6 秒 (streaming 時は初回 token 0.7 秒前後)
1 req あたり平均コスト: 約 $0.0021 → 全体 70% を light クラス (haiku / gpt-4o-mini) に流せたのが大きい
業界事例として、model routing で 60〜80% のコスト削減、prompt cache で 40〜90% の追加削減という数字が出ているが、筆者の手元でも model routing 単体で 60% 前後の削減は再現できた。「30%」と書くと小さく見えるが、月 1 万件のうち 3 千件分のコストが消える、と言うと感覚が変わるはずだ。逆に「全 request を最上位モデルに流せばクオリティは最高」というのは分かっていて、コスト的にできない、というジレンマは確かに消えた。
意外だったのは、fallback の発火が「ベンダー障害」より「自分側の prompt 設計ミス」で起きる回数の方が多かったことだ。tool 定義の typo、context 超過、format 違反といった人間側のミスが、たまたま fallback chain で吸収されて表面化しなかった、というケースが 1 か月で 10 件以上あった。Gateway を入れると、これらのミスがログだけに残り、ユーザーに見えなくなる。便利だが、長期的には品質劣化に気付きにくくなるので、fallback 発火率は週次でレビューに上げるようにした。これは導入前には想定していなかった副作用で、書き残しておく価値があると思う。
応用とまとめ、そして次回
3-layer のうち、L1 と L2 はナレッジボット以外にもそのまま転用できる。実際、別件の請求書 OCR ワークフローでも L1+L2 だけを引っこ抜いて流用している。L3 の retriever 部分を「社内 CRM」「製品マニュアル」「契約書 DB」に差し替えれば、用途別ボットの量産も難しくない。LLM の選択肢が増えた 2026 年、production で「1 ベンダー固定」を貫くのはだんだん割に合わなくなってきている。Gateway 層を 1 枚噛ませるだけで、障害耐性とコストの両方に効く。まだ単一構成で運用している人は、L2 の薄い実装からでも試す価値はあると思う。
次回は趣向を変えて、Three.js + React Three Fiber でラグジュアリー D2C 向けの 3D プロダクトビジュアライザを作る記録を書く予定だ。同じく実装記録ベースで、ハマりどころとパフォーマンスチューニングを中心にまとめる。
筆者は 5years+ で韓国·日本の中小企業向け AI 案件を担当している。