T-CREATOR

LangChain を使わない判断基準:素の API/関数呼び出しで十分なケースと見極めポイント

LangChain を使わない判断基準:素の API/関数呼び出しで十分なケースと見極めポイント

LLM(大規模言語モデル)を活用したアプリケーション開発において、LangChain は非常に強力なフレームワークとして多くの開発者に利用されています。しかし、すべてのケースで LangChain が最適解とは限りません。

実は、シンプルなユースケースでは素の API 呼び出しの方が開発効率が高く、保守性も優れているケースが少なくないのです。本記事では、LangChain を使わない判断をする際の基準や見極めポイントを、具体的なコード例を交えながら詳しく解説していきますね。

背景

LLM アプリケーション開発の現状

近年、OpenAI の GPT シリーズや Anthropic の Claude など、高性能な LLM が次々と登場しています。これらを活用したアプリケーション開発では、API を直接呼び出す方法と、LangChain のようなフレームワークを利用する方法の 2 つの選択肢が存在するでしょう。

LangChain は、LLM を活用したアプリケーション開発を支援するフレームワークとして、チェーン機能、メモリ管理、エージェント機能など、豊富な機能を提供していますね。

LangChain が提供する主な機能

以下の表は、LangChain が提供する代表的な機能をまとめたものです。

#機能カテゴリ概要主な用途
1チェーン複数の処理を連鎖的に実行多段階の推論、データ変換
2メモリ会話履歴の管理と保持チャットボット、対話システム
3エージェントツールを使った自律的な問題解決動的なタスク実行
4ドキュメントローダー様々な形式のデータ読み込みRAG システム構築
5ベクトルストア連携埋め込みベクトルの保存・検索セマンティック検索

LangChain を使うことで、これらの機能を簡単に実装できる一方で、プロジェクトの複雑性が増すというトレードオフも存在します。

以下の図は、LLM アプリケーション開発における 2 つのアプローチの関係性を示しています。

mermaidflowchart TB
    start["LLM アプリ開発"]
    simple["シンプルな要件"]
    complex["複雑な要件"]
    direct["素の API 呼び出し"]
    framework["LangChain 利用"]

    start --> simple
    start --> complex
    simple --> direct
    complex --> framework

    direct --> benefit1["・コード量が少ない<br/>・学習コストが低い<br/>・デバッグが容易"]
    framework --> benefit2["・機能が豊富<br/>・開発が高速化<br/>・ベストプラクティス"]

この図からわかるように、要件の複雑さによって最適なアプローチが異なります。シンプルな要件であれば、素の API で十分な場合が多いのです。

課題

LangChain を安易に採用することのリスク

LangChain は便利なフレームワークですが、すべてのプロジェクトに適しているわけではありません。安易に採用すると、以下のような課題に直面する可能性があるでしょう。

学習コストの増加

LangChain は抽象化レイヤーが厚く、独自の概念や設計パターンを理解する必要があります。シンプルな LLM 呼び出しを実装するだけなのに、Chain、Prompt Template、Output Parser など、多くの概念を学ばなければなりません。

チームメンバー全員が LangChain に習熟していない場合、開発効率が低下するリスクがありますね。

依存関係の肥大化

LangChain をインストールすると、多数の依存パッケージが一緒にインストールされます。これにより、プロジェクトのバンドルサイズが増加し、デプロイ時間やコールドスタート時間に影響を与える可能性があるでしょう。

#項目素の APILangChain
1パッケージ数1〜2 個20 個以上
2バンドルサイズ数 KB数 MB
3インストール時間数秒数十秒〜数分
4更新頻度の影響小さい大きい

デバッグの複雑化

LangChain は内部で多くの処理を抽象化しているため、問題が発生した際のデバッグが困難になるケースがあります。エラーメッセージがフレームワーク内部のスタックトレースになり、本質的な問題を見つけにくくなることも。

mermaidsequenceDiagram
    participant Dev as 開発者
    participant App as アプリコード
    participant LC as LangChain
    participant API as LLM API

    Dev->>App: エラー調査
    App->>LC: 内部処理を追跡
    Note over LC: 複数の抽象化レイヤー
    LC->>API: 実際の API 呼び出し
    API-->>LC: エラーレスポンス
    LC-->>App: 抽象化されたエラー
    App-->>Dev: 元のエラーが不明瞭

この図が示すように、LangChain を経由することで、エラーの原因究明に時間がかかる場合があるのです。

バージョン管理の難しさ

LangChain は活発に開発されており、頻繁にバージョンアップが行われます。これは進化の証でもありますが、破壊的変更が含まれることも多く、アップデート対応に工数がかかる可能性があります。

過剰な抽象化による柔軟性の低下

フレームワークが提供する抽象化は便利ですが、特殊な要件に対応する際に制約となることがあるでしょう。カスタマイズしようとすると、かえって複雑になり、素の API を使った方が簡潔に実装できるケースも存在しますね。

解決策

LangChain を使わない判断基準

LangChain を使わずに素の API で実装すべきケースを見極めるための基準を、以下に整理しました。

基準 1:処理フローがシンプルである

単一の LLM 呼び出しで完結する処理や、2〜3 ステップ程度の簡単な処理フローの場合、LangChain は不要でしょう。

具体的には以下のようなケースです。

  • テキストの要約
  • 単純な質問応答
  • テキストの翻訳
  • 感情分析
  • カテゴリ分類

これらは OpenAI API や Claude API を直接呼び出すだけで十分に実装できますね。

基準 2:メモリや状態管理が不要である

会話履歴を保持する必要がない、または自前で簡単に管理できる場合は、LangChain のメモリ機能は不要です。

REST API のように、各リクエストが独立している場合や、状態をデータベースで管理する設計の場合は、素の API で十分でしょう。

基準 3:外部ツールとの連携が限定的である

LangChain のエージェント機能は、複数のツールを動的に使い分ける場合に威力を発揮します。しかし、連携するツールが固定されている、または単一のツールのみを使う場合は、自前で実装した方がシンプルになります。

基準 4:パフォーマンスが重要である

レスポンスタイムが厳しい要件や、コールドスタートを最小化したい場合、LangChain の抽象化レイヤーがオーバーヘッドになることがあるでしょう。

以下の表は、パフォーマンス面での比較です。

#指標素の APILangChain差分
1初期化時間10〜50ms100〜300ms5〜10 倍
2メモリ使用量5〜10MB30〜50MB3〜5 倍
3API 呼び出しオーバーヘッドほぼなし10〜30ms追加コスト

基準 5:チームの習熟度が低い

プロジェクトメンバーが LangChain に不慣れな場合、学習コストを考慮すると素の API から始める方が効率的です。必要になったタイミングで LangChain に移行する戦略も有効でしょう。

判断フローチャート

以下の図は、LangChain を使うべきか素の API を使うべきかを判断するためのフローチャートです。

mermaidflowchart TD
    start["LLM 機能を実装したい"]
    q1{"処理ステップは<br/>3 つ以下?"}
    q2{"会話履歴の<br/>管理が必要?"}
    q3{"複数ツールを<br/>動的に使う?"}
    q4{"RAG や<br/>ベクトル検索?"}
    q5{"パフォーマンス<br/>が最重要?"}

    useAPI["素の API を使う"]
    useLangChain["LangChain を使う"]

    start --> q1
    q1 -->|はい| q2
    q1 -->|いいえ| q3

    q2 -->|いいえ| q5
    q2 -->|はい| q3

    q3 -->|いいえ| q5
    q3 -->|はい| q4

    q4 -->|いいえ| useAPI
    q4 -->|はい| useLangChain

    q5 -->|はい| useAPI
    q5 -->|いいえ| useLangChain

この判断フローに従うことで、プロジェクトに最適な実装方法を選択できるでしょう。

見極めポイントの整理

判断基準をさらに整理すると、以下の 3 つの観点から評価すると良いですね。

機能面での見極め

  • 必要な機能が LangChain の提供機能と一致しているか
  • カスタマイズの必要性はどの程度か
  • 将来的な機能拡張の可能性

技術面での見極め

  • プロジェクトの技術スタックとの相性
  • パフォーマンス要件
  • デプロイ環境の制約(サーバーレス、エッジなど)

チーム面での見極め

  • チームの LangChain 習熟度
  • 学習に割ける時間
  • 保守・運用体制

具体例

ケーススタディ 1:テキスト要約機能

シンプルなテキスト要約機能を、素の API と LangChain の両方で実装し、比較してみましょう。

素の API を使った実装

まず、必要なパッケージをインストールします。

bashyarn add openai

次に、OpenAI API を直接呼び出す実装です。

typescript// パッケージのインポート
import OpenAI from 'openai';
typescript// OpenAI クライアントの初期化
const openai = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY,
});
typescript// テキスト要約関数の定義
async function summarizeText(
  text: string
): Promise<string> {
  // API リクエストの実行
  const response = await openai.chat.completions.create({
    model: 'gpt-4',
    messages: [
      {
        role: 'system',
        content:
          'あなたは要約の専門家です。与えられたテキストを簡潔に要約してください。',
      },
      {
        role: 'user',
        content: `以下のテキストを要約してください:\n\n${text}`,
      },
    ],
    temperature: 0.3, // 要約は一貫性を重視
    max_tokens: 500,
  });

  // レスポンスから要約テキストを抽出
  return response.choices[0].message.content || '';
}
typescript// 使用例
const longText = `
  LangChain は LLM を活用したアプリケーション開発のためのフレームワークです。
  チェーン機能により複数の処理を連鎖させることができ、
  メモリ機能で会話履歴を管理できます...
`;

const summary = await summarizeText(longText);
console.log(summary);

この実装は約 30 行で完結し、非常にシンプルですね。OpenAI のドキュメントを読めば誰でも理解できる内容になっています。

LangChain を使った実装

同じ機能を LangChain で実装してみましょう。

bashyarn add langchain @langchain/openai
typescript// 必要なモジュールのインポート
import { ChatOpenAI } from '@langchain/openai';
import { PromptTemplate } from 'langchain/prompts';
import { LLMChain } from 'langchain/chains';
typescript// LLM モデルの初期化
const model = new ChatOpenAI({
  modelName: 'gpt-4',
  temperature: 0.3,
  openAIApiKey: process.env.OPENAI_API_KEY,
});
typescript// プロンプトテンプレートの定義
const template = `
あなたは要約の専門家です。
与えられたテキストを簡潔に要約してください。

テキスト:
{text}

要約:
`;

const prompt = PromptTemplate.fromTemplate(template);
typescript// チェーンの作成と実行
const chain = new LLMChain({
  llm: model,
  prompt: prompt,
});
typescript// 要約関数の定義
async function summarizeTextWithLangChain(
  text: string
): Promise<string> {
  const result = await chain.call({ text });
  return result.text;
}
typescript// 使用例
const summary = await summarizeTextWithLangChain(longText);
console.log(summary);

LangChain を使った場合、約 40 行になり、やや複雑になりました。このシンプルなユースケースでは、LangChain の利点を活かせていないことがわかるでしょう。

比較結果

#項目素の APILangChain推奨
1コード行数約 30 行約 40 行素の API
2依存パッケージ1 個2 個以上素の API
3理解しやすさ★★★★★★★★☆☆素の API
4デバッグ容易性★★★★★★★★☆☆素の API
5拡張性★★★☆☆★★★★★LangChain

このケースでは、明らかに素の API の方が適していますね。

ケーススタディ 2:エラーハンドリングの実装

実際のプロダクション環境では、エラーハンドリングとリトライ処理が重要です。これも両方の方法で実装してみましょう。

素の API でのエラーハンドリング

typescript// エラータイプの定義
class LLMError extends Error {
  constructor(
    message: string,
    public code: string,
    public statusCode?: number
  ) {
    super(message);
    this.name = 'LLMError';
  }
}
typescript// リトライ処理を含む要約関数
async function summarizeWithRetry(
  text: string,
  maxRetries: number = 3
): Promise<string> {
  let lastError: Error | null = null;

  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      // API 呼び出しの実行
      const response = await openai.chat.completions.create(
        {
          model: 'gpt-4',
          messages: [
            {
              role: 'system',
              content: 'テキストを要約してください。',
            },
            { role: 'user', content: text },
          ],
        }
      );

      return response.choices[0].message.content || '';
    } catch (error: any) {
      lastError = error;

      // エラーコードの判定
      if (error.status === 429) {
        // Rate limit エラー:待機してリトライ
        const waitTime = attempt * 1000; // 1秒、2秒、3秒...
        await new Promise((resolve) =>
          setTimeout(resolve, waitTime)
        );
        continue;
      } else if (error.status >= 500) {
        // サーバーエラー:リトライ可能
        continue;
      } else {
        // その他のエラー:即座に失敗
        throw new LLMError(
          error.message,
          'API_ERROR',
          error.status
        );
      }
    }
  }

  // すべてのリトライが失敗
  throw new LLMError(
    `Failed after ${maxRetries} attempts: ${lastError?.message}`,
    'MAX_RETRIES_EXCEEDED'
  );
}

この実装では、エラーコードに応じた適切な処理を行っています。Error 429: Rate Limit Exceeded の場合は待機してリトライし、Error 500: Internal Server Error などのサーバーエラーもリトライ対象としていますね。

typescript// 使用例とエラーハンドリング
try {
  const result = await summarizeWithRetry(longText);
  console.log('要約成功:', result);
} catch (error) {
  if (error instanceof LLMError) {
    console.error(`エラーコード: ${error.code}`);
    console.error(`ステータス: ${error.statusCode}`);
    console.error(`メッセージ: ${error.message}`);

    // エラーコードに応じた処理
    switch (error.code) {
      case 'API_ERROR':
        // API エラー時の処理
        break;
      case 'MAX_RETRIES_EXCEEDED':
        // リトライ上限到達時の処理
        break;
    }
  }
}

エラー情報には具体的なエラーコードを含めることで、問題の切り分けとデバッグが容易になります。

ケーススタディ 3:ストリーミングレスポンス

リアルタイムで LLM の応答を表示したい場合、ストリーミング API を使用します。

素の API でのストリーミング実装

typescript// ストリーミング対応の要約関数
async function* streamingSummarize(
  text: string
): AsyncGenerator<string> {
  // ストリーミングモードで API を呼び出し
  const stream = await openai.chat.completions.create({
    model: 'gpt-4',
    messages: [
      {
        role: 'system',
        content: 'テキストを要約してください。',
      },
      { role: 'user', content: text },
    ],
    stream: true, // ストリーミングを有効化
  });

  // チャンクごとにデータを yield
  for await (const chunk of stream) {
    const content = chunk.choices[0]?.delta?.content;
    if (content) {
      yield content;
    }
  }
}
typescript// Next.js API Route での使用例
// app/api/summarize/route.ts
import { NextRequest } from 'next/server';

export async function POST(request: NextRequest) {
  const { text } = await request.json();

  // ストリーミングレスポンスの作成
  const encoder = new TextEncoder();
  const stream = new ReadableStream({
    async start(controller) {
      try {
        // ストリーミング要約を実行
        for await (const chunk of streamingSummarize(
          text
        )) {
          // クライアントにチャンクを送信
          controller.enqueue(encoder.encode(chunk));
        }
      } catch (error) {
        controller.error(error);
      } finally {
        controller.close();
      }
    },
  });

  return new Response(stream, {
    headers: {
      'Content-Type': 'text/plain; charset=utf-8',
    },
  });
}

この実装により、ユーザーはリアルタイムで要約結果を見ることができます。素の API を使うことで、ストリーミングの制御を細かく行えるのです。

ケーススタディ 4:関数呼び出し(Function Calling)

OpenAI の Function Calling 機能を使った実装例です。

typescript// 利用可能な関数の定義
const functions = [
  {
    name: 'get_weather',
    description: '指定された都市の天気情報を取得します',
    parameters: {
      type: 'object',
      properties: {
        city: {
          type: 'string',
          description: '都市名(例:東京、大阪)',
        },
        unit: {
          type: 'string',
          enum: ['celsius', 'fahrenheit'],
          description: '温度の単位',
        },
      },
      required: ['city'],
    },
  },
];
typescript// 実際に天気を取得する関数
async function getWeather(
  city: string,
  unit: string = 'celsius'
): Promise<string> {
  // 実際の API 呼び出しをシミュレート
  return `${city}の天気は晴れ、気温は25${
    unit === 'celsius' ? '℃' : '°F'
  }です。`;
}
typescript// Function Calling を使った会話
async function chatWithFunctionCalling(
  userMessage: string
): Promise<string> {
  // 最初の API 呼び出し
  const response = await openai.chat.completions.create({
    model: 'gpt-4',
    messages: [{ role: 'user', content: userMessage }],
    functions: functions,
    function_call: 'auto', // 自動的に関数を呼び出し
  });

  const message = response.choices[0].message;

  // 関数呼び出しが必要な場合
  if (message.function_call) {
    const functionName = message.function_call.name;
    const functionArgs = JSON.parse(
      message.function_call.arguments
    );

    // 関数を実行
    let functionResult = '';
    if (functionName === 'get_weather') {
      functionResult = await getWeather(
        functionArgs.city,
        functionArgs.unit
      );
    }

    // 関数の結果を含めて再度 API 呼び出し
    const secondResponse =
      await openai.chat.completions.create({
        model: 'gpt-4',
        messages: [
          { role: 'user', content: userMessage },
          message,
          {
            role: 'function',
            name: functionName,
            content: functionResult,
          },
        ],
      });

    return secondResponse.choices[0].message.content || '';
  }

  return message.content || '';
}
typescript// 使用例
const result = await chatWithFunctionCalling(
  '東京の天気を教えて'
);
console.log(result);
// 出力: 東京の天気は晴れで、気温は 25℃です。

このように、素の API でも Function Calling を使った高度な機能を実装できます。コードの流れが明確で、デバッグもしやすいですね。

実装パターンの整理

以下の図は、素の API を使った実装パターンの全体像を示しています。

mermaidflowchart LR
    subgraph "基本パターン"
        simple["シンプルな<br/>API 呼び出し"]
    end

    subgraph "高度なパターン"
        retry["リトライ処理"]
        stream["ストリーミング"]
        func["Function<br/>Calling"]
    end

    subgraph "共通機能"
        error["エラー<br/>ハンドリング"]
        log["ロギング"]
        cache["キャッシング"]
    end

    simple --> retry
    simple --> stream
    simple --> func

    retry --> error
    stream --> error
    func --> error

    error --> log
    error --> cache

図で理解できる要点:

  • 基本的なシンプルな呼び出しから始めて、必要に応じて高度な機能を追加できる
  • エラーハンドリングやロギングなどの共通機能は横断的に適用可能
  • 段階的に機能を拡張しやすい設計になっている

まとめ

本記事では、LangChain を使わない判断基準と、素の API で実装する際のポイントを詳しく解説しました。

重要なポイントを整理すると、以下のようになります。

素の API が適しているケース

  • 処理フローが 3 ステップ以下のシンプルな要件
  • 会話履歴の管理が不要、または自前で簡単に実装可能
  • 外部ツールとの連携が固定的で限定的
  • レスポンスタイムやコールドスタートなどパフォーマンスが重要
  • チームの LangChain 習熟度が低く、学習コストを避けたい

LangChain が適しているケース

  • 複雑な多段階処理が必要(5 ステップ以上)
  • エージェント機能で動的にツールを選択したい
  • RAG システムでベクトル検索と組み合わせる
  • 様々な LLM プロバイダーを切り替えて使いたい
  • チームが LangChain に習熟しており、生産性向上が見込める

実装のベストプラクティス

素の API で実装する際は、以下の点に注意すると良いでしょう。

  1. エラーハンドリングを適切に実装する - エラーコードを含めた詳細なエラー情報を記録し、検索しやすくする
  2. リトライロジックを組み込む - Rate Limit やサーバーエラーに対応できるようにする
  3. 型安全性を確保する - TypeScript の型定義を活用して、実行時エラーを防ぐ
  4. ストリーミングを活用する - ユーザー体験向上のため、長い応答はストリーミングで返す
  5. 段階的に機能を追加する - 最初はシンプルに実装し、必要に応じて機能を拡張していく

最終的に、LangChain を使うか素の API を使うかは、プロジェクトの要件、チームのスキルセット、パフォーマンス要求などを総合的に判断することが大切です。

「とりあえず LangChain」という選択ではなく、本当に必要な機能は何かを見極めることで、保守性が高く、パフォーマンスに優れたアプリケーションを構築できるでしょう。シンプルなケースでは、素の API の明快さと制御性の高さが大きな武器になりますね。

関連リンク