LLMを使ったアプリを書き始めると、最初は1つのファイルで収まる。しかしプロンプトが増え、APIが増え、機能が増えるにつれてコードは絡み合い、どこに何があるかわからなくなる。
この記事でわかること:
- LLMアプリのコードがどこで複雑化しやすいか
- 小〜中規模向けのシンプルな構成例
- 複数モデル・複数機能に対応した発展構成
- プロンプト・設定・APIクライアントを分離する具体的なやり方
1ファイルで書き続けると起きること
LLMを呼び出すコードを1ファイルに詰め込むと、次の問題が起きやすい。
プロンプトがコードの中に文字列としてハードコードされているため、変更するたびにロジックの近くを触ることになる。Claude用とGPT用でAPIの呼び出し方が違うにもかかわらず、同じ関数の中に分岐が積み重なっていく。設定値(APIキー、モデル名、temperature)が散在し、どこを変えればよいか一目でわからない。
これらは「関心の分離」ができていないことによる症状だ。LLMアプリには、API呼び出し・プロンプト管理・ビジネスロジック・データ処理・設定管理という5つの独立した責務がある。それぞれを別のファイルやモジュールに切り出すことで、見通しがよくなる。
基本構成:小〜中規模向け
スクリプト1〜3本程度の小さなアプリなら、以下のフラット構成が出発点になる。
my_llm_app/
├── app.py # メインのアプリロジック
├── config.py # 設定の読み込み(環境変数、モデル名など)
├── llm_client.py # LLM APIの呼び出しをまとめた関数・クラス
├── prompt_utils.py # プロンプトの読み込み・フォーマット処理
├── prompts/ # プロンプトテンプレートファイルを置くフォルダ
│ ├── summarize.txt
│ └── qa.txt
├── utils.py # 汎用ユーティリティ(出力パース等)
├── .env # 環境変数(.gitignoreに追加する)
└── requirements.txt
ポイントは llm_client.py と prompt_utils.py を分けることだ。APIの呼び出し方の詳細(認証、リトライ、パラメータ)を llm_client.py に集約し、プロンプトの中身には触れない設計にする。
発展構成:複数機能を持つアプリ向け
要約・Q&A・RAGなど複数の機能を持つアプリは、次のような階層構造にする。
advanced_llm_app/
├── main.py # エントリーポイント
├── core/ # 共通基盤コンポーネント
│ ├── config.py # 設定読み込み
│ ├── llm_interface.py # LLM呼び出しの抽象化
│ ├── prompt_manager.py # プロンプトの一元管理
│ └── output_parser.py # 出力パース
├── modules/ # 機能ごとのモジュール
│ ├── qa/ # Q&A機能
│ │ ├── chain.py
│ │ └── prompts/
│ │ └── retrieval_qa.yaml
│ └── summarization/ # 要約機能
│ ├── service.py
│ └── prompts/
│ └── condense.txt
├── shared/
│ └── data_models.py # Pydanticモデル
├── tests/
├── .env
└── requirements.txt
core/ に共通基盤を、modules/ に機能ごとの実装を置く。各モジュールが自分のプロンプトファイルを持つ設計にすると、機能追加・削除の影響範囲が明確になる。
LLMクライアントを分離する理由
ClaudeとGPTでは呼び出し方が違う。エンドポイント、パラメータ名、レスポンスの形式がそれぞれ異なる。これらの差異を llm_interface.py の中に閉じ込めておくことで、ビジネスロジック側は「どのモデルを使っているか」を意識せずに済む。
# core/llm_interface.py の例
class LLMClient:
def __init__(self, provider: str, model: str):
self.provider = provider
self.model = model
def complete(self, prompt: str, **kwargs) -> str:
if self.provider == "anthropic":
return self._call_claude(prompt, **kwargs)
elif self.provider == "openai":
return self._call_gpt(prompt, **kwargs)
def _call_claude(self, prompt, **kwargs): ...
def _call_gpt(self, prompt, **kwargs): ...
ここにレート制限のリトライ処理やリクエストのログ記録も加えると、デバッグが格段に楽になる。
プロンプトを外部ファイルに切り出す
プロンプトをPythonコードの中に直接書くと、プロンプトを変えるたびにコードのテストが必要になる。外部ファイル(.txt、.yaml、.json)に切り出しておくと、ロジックを変えずにプロンプトだけを変更・評価できる。
# core/prompt_manager.py の例
from pathlib import Path
from jinja2 import Template
class PromptManager:
def __init__(self, prompts_dir: str = "prompts"):
self.prompts_dir = Path(prompts_dir)
def load(self, name: str, **variables) -> str:
template_text = (self.prompts_dir / name).read_text()
return Template(template_text).render(**variables)
Jinja2を使うと、{{ topic }} のようなプレースホルダーで動的な値を差し込める。変数を受け取る箇所が明示されるため、プロンプトの仕様が読みやすくなる。
設定と環境変数の扱い
APIキーをコードに直接書くのは論外だが、モデル名やtemperatureなどの設定値もコードに散らばりやすい。config.py で一元管理する。
# config.py の例
import os
from dataclasses import dataclass
@dataclass
class Config:
anthropic_api_key: str = os.getenv("ANTHROPIC_API_KEY", "")
openai_api_key: str = os.getenv("OPENAI_API_KEY", "")
default_model: str = os.getenv("DEFAULT_MODEL", "claude-sonnet-4-6")
temperature: float = float(os.getenv("TEMPERATURE", "0.7"))
max_tokens: int = int(os.getenv("MAX_TOKENS", "2048"))
config = Config()
.env ファイルに値を書いて python-dotenv で読み込む方法が一般的だ。.env は必ず .gitignore に追加する。
まとめ
LLMアプリのコードが複雑になる前に構成を整えることで、後からの変更コストを大きく下げられる。最初から完璧な構成を目指す必要はない。
まずは llm_client.py(API呼び出し)と prompts/(プロンプトファイル)と config.py(設定)を分けるだけでも、コードの見通しは格段に改善する。アプリが成長するにつれて、core/・modules/ の階層へ移行していけばよい。