1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

n8n + Claude + Notion で 3-layer Multi-LLM Gateway を組む実装記録

1
Posted at

前回の続きから始める

前回の記事で、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 として接続するレイヤーで、ナレッジボットとしての本体になる。各層は責務が分かれているので、後から差し替えやテストもしやすい。

複数 LLM プロバイダを束ねる Gateway アーキテクチャのイメージ

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 案件を担当している。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?