0
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?

Fable 5が3日で使えなくなった日に学ぶ、LLM依存を減らすアプリ設計

0
Posted at

株式会社Good Labでエンジニアをしている コータロー です。
日々、Java・SQL・Gitなどの技術情報や、新人エンジニア向けの学習ノウハウ、
AI活用についての情報を発信しています。

Good Labについて気になった方は、コーポレートサイトもぜひご覧ください。
コーポレートサイト

はじめに

2026年6月、Anthropic の最新モデル Fable 5・Mythos 5 が、リリースからわずか3日で、米政府の輸出管理に関する指令により「全外国籍ユーザーのアクセス停止」となりました。リアルタイムで外国籍ユーザーだけを切り分けることが難しいため、結果的に両モデルが事実上ほぼ全面停止という対応が取られています(他の Claude モデルは影響なし)。

ここで言いたいのは政治の話ではありません。エンジニアとして拾うべき教訓はシンプルです。

特定の1モデルは、技術的にもビジネス的にも問題がなくても、「規制・政治」という自分のコントロール外の理由で突然消えうる。

過去にも「料金改定」「レート制限の変更」「モデルの deprecated(提供終了)」「リージョン制限」などで、特定モデルが急に使えなくなる事態は何度も起きてきました。今回はそこに「輸出管理」という新しい変数が加わっただけです。

私は SES で4年目、副業で iOS アプリを複数リリースしつつ、Claude Code をヘビーに使って開発しています。そういう「LLM に依存したものを作って動かし続ける」立場から見ると、今回の件は「特定モデルにベタ書きで密結合する設計のリスク」を可視化した好例でした。

この記事では時事ネタの深掘りはしません。本題は 「LLM を使うアプリ/ツールを、特定モデルに縛られないように作るには具体的にどうコードを書くか」 です。TypeScript のサンプルで、すぐ真似できる4つの実装パターンを紹介します。

出典(事実確認用)

対象読者:

  • LLM を組み込んだアプリ/ツール/バッチを作っている人
  • 「とりあえず1つの SDK をベタ書き」で動かしていて、依存が気になり始めた人
  • 個人開発でコストと可用性の両方を気にしたい人

想定環境

本記事のコードは以下を前提に書いています。SDK のメジャーバージョン差で書き方(メソッド名・レスポンス構造)が変わることがあるため、お使いのバージョンに合わせて読み替えてください。

項目 想定バージョン
Node.js v20 以上(検証は v25 で実施)
@anthropic-ai/sdk 0.x 系(messages.create API)
openai 4.x 系(chat.completions.create API)
zod 3.x 系
モデル ID 例 Anthropic: claude-opus-4-8 / OpenAI: gpt 系の任意モデル

※モデル ID はコードに直書きせず、後述のとおり環境変数で渡す前提です(上記はあくまで設定値の例)。


何が「依存」なのか — まず問題を特定する

よくある「依存しすぎ」コードはこういう形です。

// 悪い例: SDK もモデル ID もプロンプトの癖も、ビジネスロジックに密結合
import Anthropic from "@anthropic-ai/sdk";

const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });

async function summarize(text: string): Promise<string> {
  const res = await client.messages.create({
    model: "claude-opus-4-8", // ← モデル ID 直書き
    max_tokens: 1024,
    messages: [{ role: "user", content: `次を3行で要約して:\n${text}` }],
  });
  // ↓ レスポンス構造が特定 SDK 専用
  return res.content[0].type === "text" ? res.content[0].text : "";
}

このコードの問題は3つです。

  1. summarize() がベンダー SDK を直接知っている … プロバイダを変えると全箇所を書き換える必要がある
  2. モデル ID がコードに埋まっている … 「別モデルで試す」が再デプロイ案件になる
  3. レスポンスの取り出し方が SDK 固有 … 戻り値の形が変わると呼び出し側も壊れる

これを順に剥がしていきます。


1. モデル抽象化レイヤー(プロバイダ非依存のインターフェース)

最初の一手は、「LLM を呼ぶ」という行為を1つのインターフェースに切り出すことです。ビジネスロジックはこのインターフェースだけを知り、具体的なベンダー SDK は一切知らない状態にします。

// llm/types.ts

/** プロバイダ非依存のリクエスト */
export interface LLMRequest {
  /** システム指示(任意) */
  system?: string;
  /** ユーザー入力本文 */
  prompt: string;
  /** 出力の最大トークン数の目安 */
  maxTokens?: number;
}

/** プロバイダ非依存のレスポンス */
export interface LLMResponse {
  /** 実際に応答したプロバイダ名(ログ・観測用) */
  provider: string;
  /** モデルが返した本文 */
  text: string;
}

/** すべてのプロバイダが満たす共通インターフェース */
export interface LLMProvider {
  readonly name: string;
  generate(req: LLMRequest): Promise<LLMResponse>;
}

呼び出し側(ビジネスロジック)はこうなります。SDK もモデル ID も登場しません。

// app/summarize.ts
import type { LLMProvider } from "../llm/types";

export async function summarize(
  llm: LLMProvider, // ← 注入される(依存性注入)
  text: string,
): Promise<string> {
  const res = await llm.generate({
    system: "あなたは日本語の要約アシスタントです。",
    prompt: `次のテキストを3行で要約してください:\n${text}`,
    maxTokens: 512,
  });
  return res.text;
}

具体プロバイダは「インターフェースの実装」として別ファイルに閉じ込めます。ここだけがベンダー SDK を知っています。

// llm/providers/anthropic.ts
import Anthropic from "@anthropic-ai/sdk";
import type { LLMProvider, LLMRequest, LLMResponse } from "../types";

export class AnthropicProvider implements LLMProvider {
  readonly name = "anthropic";
  private client: Anthropic;
  private model: string;

  constructor(apiKey: string, model: string) {
    this.client = new Anthropic({ apiKey });
    this.model = model; // ← モデル ID は外から渡す
  }

  async generate(req: LLMRequest): Promise<LLMResponse> {
    const res = await this.client.messages.create({
      model: this.model,
      max_tokens: req.maxTokens ?? 1024,
      system: req.system,
      messages: [{ role: "user", content: req.prompt }],
    });
    const text = res.content[0]?.type === "text" ? res.content[0].text : "";
    return { provider: this.name, text };
  }
}

OpenAI を使いたければ、同じインターフェースを満たす実装を増やすだけです(呼び出し側は一切変わりません)。

// llm/providers/openai.ts
import OpenAI from "openai";
import type { LLMProvider, LLMRequest, LLMResponse } from "../types";

export class OpenAIProvider implements LLMProvider {
  readonly name = "openai";
  private client: OpenAI;
  private model: string;

  constructor(apiKey: string, model: string) {
    this.client = new OpenAI({ apiKey });
    this.model = model;
  }

  async generate(req: LLMRequest): Promise<LLMResponse> {
    const messages: { role: "system" | "user"; content: string }[] = [];
    if (req.system) messages.push({ role: "system", content: req.system });
    messages.push({ role: "user", content: req.prompt });

    const res = await this.client.chat.completions.create({
      model: this.model,
      max_tokens: req.maxTokens ?? 1024,
      messages,
    });
    return { provider: this.name, text: res.choices[0]?.message?.content ?? "" };
  }
}

プロバイダとモデル ID は「設定値」で差し替える

どのプロバイダ・どのモデルを使うかは、コードではなく**環境変数(設定値)**で決めます。これで「モデルを変える」が再デプロイではなく設定変更で済みます。

// llm/factory.ts
import type { LLMProvider } from "./types";
import { AnthropicProvider } from "./providers/anthropic";
import { OpenAIProvider } from "./providers/openai";

/** 必須の環境変数を取得。未設定なら起動時に明確な例外を投げる */
function requireEnv(key: string): string {
  const v = process.env[key];
  if (!v) {
    throw new Error(`環境変数 ${key} が未設定です(起動を中止します)`);
  }
  return v;
}

/** 環境変数からプロバイダを1つ組み立てる */
export function createProvider(prefix: string): LLMProvider {
  // VENDOR / MODEL / API_KEY が欠けていたら、不正キーで実行する前に落とす
  const vendor = requireEnv(`${prefix}_VENDOR`); // 例: "anthropic"
  const model = requireEnv(`${prefix}_MODEL`);

  switch (vendor) {
    case "anthropic":
      return new AnthropicProvider(requireEnv("ANTHROPIC_API_KEY"), model);
    case "openai":
      return new OpenAIProvider(requireEnv("OPENAI_API_KEY"), model);
    default:
      throw new Error(`unknown vendor: ${vendor}`);
  }
}

セキュリティ注意:他のコード例で出てくる ?? "YOUR_API_KEY" は、あくまで説明用のプレースホルダーです。本番では上記のように API キー(および VENDOR/MODEL)が未設定なら起動時に例外を投げる実装にしてください。プレースホルダーをそのまま渡すと、不正なキーで API を叩いて 401 が返るまで設定漏れに気づけず、原因の切り分けが遅れます。

# .env (例)— 実際のキーをコミットしないこと(.gitignore 必須)
PRIMARY_VENDOR=anthropic
PRIMARY_MODEL=claude-opus-4-8
ANTHROPIC_API_KEY=YOUR_API_KEY
OPENAI_API_KEY=YOUR_API_KEY

これだけで、「Fable 5 が使えなくなった」レベルの事態でも、コードを触らず .env の1行を書き換えてデプロイし直すだけで逃げられる土台ができます。


2. フォールバック戦略(落ちたら別モデルへ)

抽象化しただけでは「主モデルが落ちたら止まる」のは変わりません。次は 主モデルがダメなときに代替モデルへ自動で切り替える実装です。

ポイントは2段構えです。

  • リトライ:一時的なエラー(タイムアウト・レート制限・5xx)は、同じプロバイダで少し待って再試行
  • フェイルオーバー:それでもダメなら、別プロバイダ(別モデル)へ切り替え

LLMProvider を複数受け取り、優先順位順に試すラッパーを作ります。これ自体も LLMProvider を実装するので、呼び出し側からは「ただの1つのプロバイダ」に見えます。

// llm/fallback.ts
import type { LLMProvider, LLMRequest, LLMResponse } from "./types";

export interface FallbackOptions {
  /** 各プロバイダごとのリトライ回数(初回を除く) */
  retries?: number;
  /** リトライ間隔のベース(ミリ秒) */
  baseDelayMs?: number;
  /** 全体の応答時間の上限(ミリ秒)。超えたら打ち切る */
  maxWaitMs?: number;
}

export class FallbackProvider implements LLMProvider {
  readonly name = "fallback";

  constructor(
    private providers: LLMProvider[], // 優先順位順
    private opts: FallbackOptions = {},
  ) {
    if (providers.length === 0) {
      throw new Error("at least one provider is required");
    }
  }

  async generate(req: LLMRequest): Promise<LLMResponse> {
    const retries = this.opts.retries ?? 1;
    const base = this.opts.baseDelayMs ?? 300;
    const maxWaitMs = this.opts.maxWaitMs ?? 10_000; // 既定はバッチ向けに 10 秒
    const startedAt = Date.now();
    let lastError: unknown;

    for (const provider of this.providers) {
      for (let attempt = 0; attempt <= retries; attempt++) {
        // 全体の上限を超えていたら、これ以上待たずに打ち切る
        if (Date.now() - startedAt >= maxWaitMs) {
          throw new Error(
            `fallback timed out after ${maxWaitMs}ms. ` +
              `last error: ${(lastError as Error)?.message ?? "none"}`,
          );
        }
        try {
          return await provider.generate(req);
        } catch (e) {
          lastError = e;
          // ログは「どのプロバイダで何回目に失敗したか」を残す
          console.warn(
            `[fallback] ${provider.name} failed (attempt ${attempt + 1}): ` +
              `${(e as Error).message}`,
          );
          // 最後の試行でなければ指数バックオフで待つ。
          // ただし全体上限の残り時間を超えて待たない
          if (attempt < retries) {
            const wait = base * 2 ** attempt;
            const remaining = maxWaitMs - (Date.now() - startedAt);
            if (remaining <= 0) break; // 次プロバイダへ(実質打ち切り)
            await sleep(Math.min(wait, remaining));
          }
        }
      }
    }
    throw new Error(
      `all providers failed. last error: ${(lastError as Error)?.message}`,
    );
  }
}

function sleep(ms: number): Promise<void> {
  return new Promise((r) => setTimeout(r, ms));
}

運用注記(タイムアウトの目安):指数バックオフ(baseDelayMs * 2 ** attempt)は、プロバイダ数 × リトライ回数が増えると最悪で 10 秒以上待ちうる構成です。ユーザー向け API(同期レスポンス)では、全体応答が 2〜3 秒に収まるよう maxWaitMs を明示的に短く指定してください。既定値の 10 秒は、多少待っても完走を優先したいバッチ処理向けの設定です。

組み立てはこうです。

// app/main.ts
import { createProvider } from "../llm/factory";
import { FallbackProvider } from "../llm/fallback";
import { summarize } from "./summarize";

const llm = new FallbackProvider(
  [
    createProvider("PRIMARY"),  // 例: anthropic / claude-opus-4-8
    createProvider("FALLBACK"), // 例: openai / gpt 系
  ],
  // ユーザー向け同期 API なので全体上限を短めに(2.5 秒)
  { retries: 2, baseDelayMs: 300, maxWaitMs: 2_500 },
);

// 呼び出し側は「1つの LLM」しか意識しない
const result = await summarize(llm, "長い議事録テキスト…");
console.log(result);

動作確認(モックで検証)

実際にネットワークを使わず、ロジックだけ検証したものがこちらです。主モデルを必ず失敗させ、代替へフェイルオーバーするか確認します。

// fallback ロジックの動作確認(モック)
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

class MockProvider {
  constructor(name, behavior) { this.name = name; this.behavior = behavior; }
  async generate(req) {
    if (this.behavior === "fail") throw new Error(`${this.name} unavailable`);
    return { provider: this.name, text: `「${req.prompt}」を要約しました` };
  }
}

class LLMClient {
  constructor(providers, { retries = 1, baseDelayMs = 300 } = {}) {
    this.providers = providers;
    this.retries = retries;
    this.baseDelayMs = baseDelayMs;
  }
  async generate(req) {
    let lastErr;
    for (const p of this.providers) {
      for (let i = 0; i <= this.retries; i++) {
        try {
          return await p.generate(req);
        } catch (e) {
          lastErr = e;
          // TS 版と同じく指数バックオフで待つ
          if (i < this.retries) await sleep(this.baseDelayMs * 2 ** i);
        }
      }
    }
    throw new Error(`all providers failed: ${lastErr?.message}`);
  }
}

const client = new LLMClient(
  [
    new MockProvider("primary", "fail"),  // 主モデルが落ちている
    new MockProvider("fallback", "ok"),   // 代替へフェイルオーバー
  ],
  { retries: 1, baseDelayMs: 50 },
);
const res = await client.generate({ prompt: "長い議事録テキスト" });
console.log(JSON.stringify(res));

実行結果(Node.js v25 で確認):

{"provider":"fallback","text":"「長い議事録テキスト」を要約しました"}

primary が落ちても fallback が応答を返しています。これが「片方が政治・規制・障害で止まっても、サービスは生き続ける」状態です。

注意:フェイルオーバー先のプロバイダにも同じデータを送ることになるため、送信先が増える=データの取り扱い範囲が広がる点は要確認です。個人情報や機密を扱う場合、フォールバック先の利用規約・データ保持ポリシーも主プロバイダと同じ基準で見ておく必要があります。


3. プロンプトのモデル非依存化(出力スキーマで差を吸収)

プロバイダを差し替え可能にしても、プロンプトが特定モデルの癖に最適化されていると、乗り換えた瞬間に出力品質が崩れます。ここで効くのが「出力を JSON スキーマで縛り、形を強制する」アプローチです。

自然文で受け取ると、モデルごとに「前置きを付ける/付けない」「箇条書きの記号が違う」といった差が出ます。これをそのままパースすると壊れやすい。そこで構造化出力を要求し、コード側でスキーマ検証することで、モデル差を吸収します。

// app/extract.ts
import type { LLMProvider } from "../llm/types";
import { z } from "zod";

// 受け取りたい構造をスキーマとして定義
const TodoSchema = z.object({
  tasks: z.array(
    z.object({
      title: z.string(),
      priority: z.enum(["high", "mid", "low"]),
    }),
  ),
});
export type Todo = z.infer<typeof TodoSchema>;

export async function extractTodos(
  llm: LLMProvider,
  text: string,
): Promise<Todo> {
  const res = await llm.generate({
    system:
      "あなたは抽出器です。出力は指定された JSON のみ。前置き・説明・コードブロックは禁止。",
    prompt: [
      "次の議事録から ToDo を抽出してください。",
      "出力フォーマット(この形以外を返さない):",
      '{"tasks":[{"title":"...", "priority":"high|mid|low"}]}',
      "---",
      text,
    ].join("\n"),
    maxTokens: 1024,
  });

  // モデルがうっかり前後にテキストを付けても拾えるよう JSON 部分を抜き出す
  const json = extractJson(res.text);
  // スキーマで検証 → ここを通れば、どのモデルでも同じ型で扱える
  return TodoSchema.parse(json);
}

/**
 * 文字列中の JSON ブロックを取り出す簡易ヘルパ。
 * まずオブジェクト `{...}`、見つからなければ配列 `[...]` を試す。
 * ([\s\S] で改行を含むネストにも対応。最初にマッチした塊を JSON.parse)
 */
function extractJson(s: string): unknown {
  const objMatch = s.match(/\{[\s\S]*\}/);
  const arrMatch = s.match(/\[[\s\S]*\]/);
  for (const m of [objMatch, arrMatch]) {
    if (!m) continue;
    try {
      return JSON.parse(m[0]);
    } catch {
      // パース失敗時は次の候補へフォールバック
    }
  }
  throw new Error("JSON not found in model output");
}

注意(簡易実装):上記は前後に説明文が付いた程度の出力を救うための簡易抽出です。greedy なマッチのため、出力中に複数の JSON 塊が混在するようなケースでは取りこぼし・誤抽出が起きえます。本番では json5 などの JSON ブロック抽出に対応したライブラリの利用を推奨します。

設計のコツは次の3点です。

  • モデル固有機能に寄りかかりすぎない:特定ベンダーだけの独自機能を中核に据えると、乗り換えコストが跳ね上がる。共通項(system 指示+JSON 強制)で組むと移植が楽
  • 出力契約をコード側で守るzod などでスキーマ検証し、ズレたら例外。これで「壊れた出力がそのまま下流に流れる」事故を防ぐ
  • 検証失敗もフォールバックの引き金にできる:スキーマ違反をエラーとして投げれば、FallbackProvider が別モデルで再試行する流れにつなげられる
  • コンテキスト長の上限に注意:モデルごとにコンテキスト長の上限が異なるため、prompt(入力)+ maxTokens(出力)が乗り換え先モデルの上限を超えないかは確認しておく

JSON という「共通言語」で受けることで、プロンプトもパーサも、特定モデルへの依存を最小化できます。


4. モデル選定の多段構成(コストと可用性の両取り)

最後は運用の話です。全処理を最上位モデル1本に寄せるのは、コスト面でも可用性面でも脆い設計です。タスクの重要度に応じてモデルを使い分ける多段構成にしておくと、片方が落ちてももう片方で回せます。

私自身、Claude Code を使った日々の開発では次のような使い分けをしています(これは一般化して書けます)。

階層 用途 特徴
最上位モデル 後戻りコストが大きい一点だけ(アーキテクチャ選定・重要な意思決定) 高価・高品質。呼ぶ回数を絞る
標準モデル コード生成・調査・大半の日常処理 コストと品質のバランス重視。ここが主戦場
軽量モデル 分類・要約・整形などの定型処理 安価・高速。量で効く

これを「タスクの種類」でルーティングする実装にします。

// llm/router.ts
import type { LLMProvider, LLMRequest, LLMResponse } from "./types";

export type TaskTier = "critical" | "standard" | "light";

export class TieredRouter implements LLMProvider {
  readonly name = "tiered-router";

  constructor(
    private tiers: Record<TaskTier, LLMProvider>,
    private defaultTier: TaskTier = "standard",
  ) {}

  /** tier を明示して呼ぶ(指定なしは default) */
  async generateAs(tier: TaskTier, req: LLMRequest): Promise<LLMResponse> {
    return this.tiers[tier].generate(req);
  }

  // LLMProvider 互換: default tier で動く
  async generate(req: LLMRequest): Promise<LLMResponse> {
    return this.tiers[this.defaultTier].generate(req);
  }
}

各 tier に先ほどの FallbackProvider をそのまま入れられるのがポイントです。インターフェースが揃っているので、組み合わせが効きます。

// app/wire.ts
import { createProvider } from "../llm/factory";
import { FallbackProvider } from "../llm/fallback";
import { TieredRouter } from "../llm/router";

const router = new TieredRouter({
  // 重い意思決定: 最上位 + フォールバック
  critical: new FallbackProvider([
    createProvider("CRITICAL_PRIMARY"),
    createProvider("CRITICAL_FALLBACK"),
  ]),
  // 日常処理: 標準モデル + フォールバック
  standard: new FallbackProvider([
    createProvider("STANDARD_PRIMARY"),
    createProvider("STANDARD_FALLBACK"),
  ]),
  // 定型処理: 軽量モデル + フォールバック
  light: new FallbackProvider([
    createProvider("LIGHT_PRIMARY"),
    createProvider("LIGHT_FALLBACK"),
  ]),
});

// 重要な判断だけ critical を明示。それ以外は default(standard)
await router.generateAs("critical", { prompt: "設計方針を比較検討して…" });
await router.generate({ prompt: "この文章を整形して…" }); // standard

この構成のうれしさは2つです。

  • コスト:高価なモデルを呼ぶ回数を構造的に絞れる。「全部最上位」より明確に安い
  • 可用性:ある tier のモデルが規制・障害で消えても、その tier の中で別プロバイダへフェイルオーバーできる。最悪、tier ごと別モデルに付け替えるのも設定変更で済む

「最上位モデルは後戻りコストの大きい一点だけ」という運用ルールは、コスト最適化のためだと思われがちですが、今回のような突然の停止に対する“依存の分散”としても効くのです。1つのモデルにすべての処理を集中させていないこと自体が、リスクヘッジになります。


まとめ

今回の Fable 5・Mythos 5 の件は、「最高性能のモデルでも、自分のコントロール外の理由で突然消えうる」ことを思い出させてくれました。とはいえ、慌てて何かを乗り換える必要はありません。やるべきは、最初から差し替え可能に作っておくことだけです。

この記事で紹介した4点を再掲します。

  1. 抽象化レイヤー:ビジネスロジックは共通インターフェースだけを知る。SDK・モデル ID は端に寄せ、設定値で差し替え可能にする
  2. フォールバック:リトライ+別プロバイダへのフェイルオーバーで、片方が止まっても生き残る
  3. プロンプトのモデル非依存化:JSON スキーマで出力を縛り、モデル差をコード側で吸収する
  4. 多段構成:重要度でモデルを使い分け、コストと可用性を同時に確保する

最強の1モデルに最適化しすぎない。差し替え可能にしておくことが、性能と同じくらい重要な“可用性の保険”になる。

性能を追うのは正しいことです。ただ、その性能を「いつでも引き出せる」ことを保証するのは、モデルそのものではなく、それを呼ぶ側の設計です。今のうちに、依存を端に寄せておきましょう。

参考


@kotaro_ai_lab
AI活用や開発効率化について発信しています。フォローお気軽にどうぞ!

0
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
0
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?