LLMアプリ開発で死なないための設計思想 - Langfuse時代の実務ベストプラクティス
LLMアプリ開発で死なないための設計思想:Langfuse時代の実務ベストプラクティス
はじめに:LLMアプリ開発の「死の谷」
LLMアプリ開発は「動くものを作る」だけなら驚くほど簡単です。OpenAIのAPIキーを取得し、数行のPythonコードを書けば、誰でもチャットボットを作ることができます。
しかし、そこには巨大な「死の谷」が待っています。
PoC(概念実証)を終え、本番運用フェーズに入った途端、開発チームは以下の壁に激突します。
- モデル変更の壁: 「GPT-4oが高すぎるからGemini 2.5 Flashに変えて」と言われた時、コードの何箇所を修正する必要があるでしょうか?
- プロンプト改善の壁: プロンプトエンジニアやPMが「プロンプトを修正したい」と言った時、エンジニアがコードを書き換えてデプロイする必要があるでしょうか?
- 精度の壁: 「最近回答がおかしい」と言われた時、それがプロンプトのせいなのか、モデルの劣化なのか、RAGの検索精度の低下なのか、即座に特定できるでしょうか?
多くのプロジェクトが、これらの変更コストに耐えきれず破綻(PoC死)します。
結論から言いましょう。LLMアプリ開発における唯一の生存戦略は、徹底した 「疎結合」 です。
1. 「疎結合」が唯一の生存戦略 (Gateway Pattern)
最大のアンチパターンは、ビジネスロジック(UseCase/Service)の中に openai.chat.completions.create を直接記述することです。これは、DB操作をControllerに直書きするのと同罪です。
解決策:Gateway (Adapter) パターン
LLMへのアクセスは、必ず抽象化されたインターフェース(Gateway)を介して行います。
ビジネスロジックは「LLMを使っていること」を知っていますが、「OpenAIを使っているか、Anthropicを使っているか」は知るべきではありません。

この設計により、モデルの差し替えは Infrastructure 層の実装クラスを切り替える(DIする)だけで完了します。
2. プロンプトは「コード」ではなく「データ」
プロンプトを .py や .ts ファイルの中にハードコードしてはいけません。
f-stringで system_prompt = f"あなたは{role}です..." と書いた瞬間、そのプロジェクトから非エンジニアの協業の余地が消えます。また、Gitのdiffがプロンプトの修正で埋め尽くされ、ロジックの変更が追えなくなります。
ベストプラクティス:Prompt Management
プロンプトは外部の管理システム(Langfuse Prompt Managementなど)で管理し、バージョン付きのIDで呼び出すべきです。
# 悪い例
prompt = "あなたは親切なアシスタントです..."
# 良い例
prompt = langfuse.get_prompt("customer-support-bot", version="latest")
これにより、PMやドメインエキスパートがGUI上でプロンプトを修正・テストし、エンジニアはコードを変更することなく本番環境の振る舞いを更新(またはロールバック)できます。
3. Traceとアーキテクチャの一致
「良い設計」かどうかを判断する指標があります。それは、 「Langfuseなどのトレース画面を見た時に、そのままシステム構成図として読めるか」 です。
- Trace (Root): 1つのUseCase(ユーザーの1リクエスト)。
- Span (Child): 処理のステップ(Retrieval, Prompt Build, LLM Call, Parser)。
コードの関数構造と、可観測性ツールのスパン構造が一致している時、ボトルネックの特定は容易になります。「どこで遅いのか」「どこでエラーが出たのか」が一目でわかるからです。
4. 具体的なディレクトリ構成例 (Boilerplate)
実務でそのまま使える、Clean Architectureを意識した構成例を提示します。
src/
├── app/
│ └── usecases/ # ビジネスロジック。ここでTraceを開始する
│ └── summarize_doc.py
├── domain/ # 型定義。LLMのレスポンスに依存しない独自の型
│ └── models/
│ └── summary.py # Pydanticモデルなど
├── infrastructure/ # 外部接続
│ ├── llm/ # OpenAI/Claude等の具象実装
│ │ ├── gateway.py # Interfaceの実装
│ │ └── client.py
│ └── prompt/ # プロンプト取得ロジック
│ └── manager.py # Langfuse SDKのラッパー
└── interfaces/ # API / CLI
└── api/
└── routes.py # UseCaseを呼び出すだけ
ポイント
- domain/models: LLMからのレスポンス(JSON)を、ここで定義したPydanticモデルにマッピングします。OpenAIのレスポンス型をそのままアプリ内部に持ち込みません。
-
app/usecases: ここが「台本」になります。プロンプトを取得し、Gatewayに投げ、結果をParseする流れを記述します。Langfuseのデコレーター(
@observe())はここに付与します。 -
infrastructure/llm:
openaiライブラリを import して良いのはこのディレクトリ以下のファイルのみとします。
5. 評価(Evaluation)のための設計
「人間の目で見てなんとなく良い」を卒業するために、設計段階で評価の仕組みを組み込みましょう。
Output Structuring
LLMの出力を自由記述(String)にさせると、自動評価が困難になります。
OpenAIの Structured Outputs や、Pydanticによるバリデーションを強制し、出力を必ず構造化データ(JSON)にします。これにより、後続のプログラムでの処理や、正解データとの比較が可能になります。
Feedback Loop
Langfuseの score 機能をUseCaseの最後に配置し、ユーザーフィードバック(Good/Bad)や、LLM-as-a-Judgeの結果を蓄積するパイプラインを確保しておきましょう。
結び
アーキテクチャは「コードを綺麗に書くため」にあるのではありません。 「変更のコストを下げるため」 にあります。
モデルが週単位で進化し、ベストプラクティスが月単位で変わる今のLLM業界において、疎結合であることは、もはやマナーです。
スパゲッティコードと戦うのは今日で終わりにしましょう。
技術的な設計論は以上です。 なぜ私がここまで「設計」にこだわるようになったのか? その背景にある 「プロンプト直書きによるプロジェクト炎上(失敗談)」 と、そこから得たキャリア論については、ブログの方に赤裸々に書きました。
Discussion