コンテンツにスキップ

LLMプロダクトの「クリーンアーキテクチャ的」設計手法

作成日: 2026-06-08 きっかけ: 「LLMプロダクト作りでクリーンアーキテクチャみたいな設計手法ってあるの?」という問いを体系化 関連: ai_agent_9社_本番運用アーキテクチャcontext_engineering_ast活用claude_code_long_session_guide


0. 要点(3行)

  • 単一の決定版は無いが、発想は1つに収束している —「LLMを"詳細(detail)"として境界の外に追い出し、決定論的なコアを evals で囲う」。これはクリーンアーキテクチャ/ヘキサゴナル(Ports & Adapters)の原則そのもの。
  • その上に重ねる名前付きの実践論が 12-Factor Agents(HumanLayer)=12-Factor App のLLM版、Anthropic「Building Effective Agents」=workflows/agents の5構成パターン、Compound AI Systems(Berkeley BAIR)=「モデル単体でなくシステムとして設計せよ」。
  • 共通する勘所は ①確率的な縁と決定論的コアの分離 ②構造化I/O(型)で境界を固める ③LLMは差し替え可能なadapter ④evalsを一級市民に ⑤traceを最初から。「成功する"エージェント"は大半が普通の堅実なソフトで、LLMは要所に少量振りかけてあるだけ」。

1. 大前提:LLMは "detail"。ヘキサゴナルをそのまま適用

クリーンアーキテクチャの核は「フレームワーク・DB・外部サービスは detail であり、ドメインはそれらを知らない」。LLMはこの "外部サービス" の一種にすぎない。だから LLMプロバイダを Port & Adapter の外側に置くのが土台になる。

# ── domain(中心。LLMもSDKも知らない) ───────────────
from typing import Protocol
from dataclasses import dataclass

@dataclass(frozen=True)
class Draft:
    title: str
    body: str

class TextGenerator(Protocol):           # ← Port(抽象。ドメインが定義する境界)
    def generate(self, prompt: str) -> str: ...

class SummarizeArticle:                   # ← ユースケース(純粋なビジネスロジック)
    def __init__(self, gen: TextGenerator):
        self._gen = gen
    def __call__(self, article: str) -> Draft:
        out = self._gen.generate(f"次の記事を3行で要約:\n{article}")
        return Draft(title="要約", body=out)

# ── infrastructure(外側。差し替え可能なadapter) ──────
import anthropic

class ClaudeAdapter:                       # ← Adapter(Portの具体実装)
    def __init__(self, client: anthropic.Anthropic):
        self._c = client
    def generate(self, prompt: str) -> str:
        msg = self._c.messages.create(
            model="claude-opus-4-8",        # プロバイダ・モデルは"詳細"。ここだけに閉じる
            max_tokens=1024,
            messages=[{"role": "user", "content": prompt}],
        )
        return msg.content[0].text

# ── 組み立て(依存はすべて外→内、内は外を知らない) ──────
usecase = SummarizeArticle(gen=ClaudeAdapter(anthropic.Anthropic()))

効くポイント: - モデル乗り換え(Claude↔GPT↔ローカル)が Adapter 差し替えだけで済む - テスト時は FakeGenerator(決まった文字列を返す)を注入 → ドメインを LLM無しでユニットテストできる - プロンプトは "設定" として境界に寄り、ドメインロジックに混ざらない(後述 Factor 2)

逆アンチパターン: ユースケースの中で直接 anthropic.Anthropic().messages.create(...) を呼ぶ。ドメインがSDK・モデル名・プロンプト文字列に汚染され、テストもモデル乗り換えも困難になる。


2. 12-Factor Agents(HumanLayer / Dex Horthy, 2025)

「12-Factor App」のLLM版。AI Engineer World's Fair 2025 で発表。テーゼは 「本番で成功している"AIエージェント"の大半は、魔法の自律体ではなく、要所にLLMを少量振りかけた、よく設計された普通のソフトウェアだ」

2-1. 全13項目(原典の見出し)

# Factor 意味
1 Natural Language to Tool Calls 自然言語を構造化されたツール呼び出しに変換する
2 Own your prompts プロンプトをフレームワーク任せにせず自分で管理する
3 Own your context window 何をLLMに渡すか(コンテキスト窓)を自分で設計する
4 Tools are just structured outputs "ツール"の正体は schema 付き構造化出力にすぎない
5 Unify execution state and business state 実行状態とビジネス状態を1つに統一する
6 Launch/Pause/Resume with simple APIs 起動・一時停止・再開を素朴なAPIで扱えるようにする
7 Contact humans with tool calls 人間への問い合わせも"ツール呼び出し"として同じ仕組みで扱う
8 Own your control flow 制御フロー(分岐・ループ)を自分で持つ。frameworkに委ねない
9 Compact Errors into Context Window エラーを要約してコンテキストに戻し、自己回復させる
10 Small, Focused Agents 巨大単一でなく、小さく専門特化したagentに分ける
11 Trigger from anywhere Slack・cron・API等どこからでも起動でき、ユーザーのいる場所で動く
12 Make your agent a stateless reducer agentを f(state, event) → newState の純粋関数(reducer)にする
13 (番外)Pre-fetch context 必要になりそうな文脈は実行前に先回りで取得しておく

2-2. 具体:Factor 12「stateless reducer」+ Factor 8「制御フローを持つ」

最もクリーンアーキテクチャ的な項目。agentを純粋関数 f(events) → next_action にすると、テスト・リプレイ・デバッグが決定論的になる。

# Factor 12 + 8: agentは「これまでのイベント列」から「次の一手」を決める純粋関数
from dataclasses import dataclass

@dataclass(frozen=True)
class Event: ...
@dataclass(frozen=True)
class NextAction:               # Factor 1/4: 次の一手は構造化された型
    kind: str                   # "tool_call" | "ask_human" | "done"
    payload: dict

def decide(events: list[Event]) -> NextAction:   # ← 純粋関数。副作用なし
    prompt = render(events)                       # Factor 3: 文脈を自分で組む
    raw = llm.generate(prompt)                    # 唯一の外部呼び出し
    return parse_to_action(raw)                   # schema検証して構造化(失敗なら例外)

# 制御フローは"自分のコード"側に持つ(Factor 8)。LLMはあくまで decide の中だけ
def run(events: list[Event], max_steps: int = 20) -> list[Event]:   # Factor 10: 3〜20歩に制限
    for _ in range(max_steps):
        action = decide(events)
        if action.kind == "done":
            return events
        if action.kind == "ask_human":
            events = events + [contact_human(action.payload)]        # Factor 7
            continue
        try:
            result = run_tool(action.payload)
        except Exception as e:
            result = compact_error(e)                                # Factor 9: エラーを要約して戻す
        events = events + [result]                                   # Factor 5: 状態は単一のevent列
    return events

ここで decide が純粋関数なので、過去のevent列を流し込めばいつでも同じ挙動を再現できる(リプレイ=デバッグ)。制御フロー(fortry・分岐)は決定論的な自前コード側にあり、LLMは "次の一手の判断" にだけ閉じている。

2-3. 具体:Factor 1 / 4「自然言語→ツール=構造化出力」

# ツール定義は"出力スキーマ"でしかない(Factor 4)
TOOLS = [{
    "name": "search_orders",
    "description": "注文を検索する",
    "input_schema": {
        "type": "object",
        "properties": {"user_id": {"type": "string"}, "status": {"type": "string"}},
        "required": ["user_id"],
    },
}]
# LLMは自然言語「山田さんの未発送の注文は?」を上記スキーマの構造化出力に変換する(Factor 1)
# → {"name":"search_orders","input":{"user_id":"yamada","status":"unshipped"}}
# あとは普通の関数呼び出し。LLMの仕事は"NL→構造化"の翻訳に限定される

3. Anthropic「Building Effective Agents」(2024)

公式が「workflows と agents を明確に区別」している点が設計指針になる。

  • Workflow: LLMとツールを あらかじめ決めたコードパス(predefined code path) で編成する。予測可能・テストしやすい
  • Agent: LLMが 自分で経路とツール使用を動的に決める。ステップ数が読めない open-ended な問題向け
  • 原則: 「まず最小構成。必要になって初めて複雑にする」(=YAGNI)。多くのケースは1回のLLM呼び出し+retrieval/in-context examplesで足りる

3-1. 土台:Augmented LLM(拡張されたLLM)

すべての構成要素の最小単位。LLM + retrieval + tools + memory。これを Port として綺麗に包むのが §1 の話。

3-2. 5つのworkflowパターン(原典)

① Prompt Chaining(逐次分解)
   in →[LLM1]→[gate?]→[LLM2]→[LLM3]→ out
   各呼び出しが前の出力を受け取る。固定の小タスク列(例: 下書き→翻訳)

② Routing(分類して振り分け)
            ┌→[LLM: 返金処理用プロンプト]
   in →[分類]┼→[LLM: 技術質問用プロンプト]→ out
            └→[LLM: 一般問い合わせ用]
   入力を分類し、専門化したハンドラ/プロンプトへ。関心の分離

③ Parallelization(並列)
   - sectioning: 独立サブタスクを並列実行して結合
   - voting:     同一タスクをN回流して多数決/多様な出力を得る
   in →[LLM]×N(並列)→[集約]→ out

④ Orchestrator-Workers(動的委譲)
   in →[Orchestrator: タスク分解]→[Worker LLM]×動的個数→[統合]→ out
   サブタスク数が事前に読めない時(例: 複数ファイルにまたがるコード変更)

⑤ Evaluator-Optimizer(生成⇄評価ループ)
   in →[Generator LLM]→[Evaluator LLM]→(NG:フィードバック付きで戻す)↺→(OK)→ out
   明確な評価基準があり、反復で質が上がる時(翻訳推敲など)

①②③は workflow(決定論的な骨格)、④⑤は agentに近い(動的)。重要なのは「①〜③で済むなら④⑤を使わない」という altitude 判断。ai_agent_9社_本番運用アーキテクチャ の各社も「LLMの万能性に頼らず決定論ワークフローを骨格に」が共通していた。

3-3. 具体:② Routing の最小実装

def route(query: str) -> str:
    label = llm.generate(f"次を refund/tech/general のいずれかに分類:\n{query}").strip()
    return label

PROMPTS = {
    "refund":  "あなたは返金担当。返金ポリシーに沿って…",
    "tech":    "あなたは技術サポート。再現手順を聞き…",
    "general": "あなたは一般窓口。…",
}
def handle(query: str) -> str:
    label = route(query)                       # ← 制御フローは自前(Factor 8)
    return llm.generate(PROMPTS[label] + "\n\n" + query)

4. Compound AI Systems(Berkeley BAIR, 2024-02)

"The Shift from Models to Compound AI Systems"

「単一モデル」でなく「複数の相互作用するコンポーネント(複数のLLM呼び出し・retriever・外部ツール)から成るシステム」として設計せよという潮流。SOTAはモデルのスケールより 賢いシステム設計から生まれる(例: AlphaCode2 はLLMで大量生成→フィルタという"システム")。

クリーンアーキテクチャ的に言えば「ユースケース=複数の Port(LLM・Retriever・Tool)の編成」。RAGはその最小形:

class Retriever(Protocol):
    def search(self, q: str, k: int = 5) -> list[str]: ...

class AnswerWithRAG:                       # Compound: Retriever + LLM の合成ユースケース
    def __init__(self, retriever: Retriever, gen: TextGenerator):
        self._r, self._g = retriever, gen
    def __call__(self, question: str) -> str:
        ctx = "\n".join(self._r.search(question))    # 検索(別Port)
        return self._g.generate(f"文脈:\n{ctx}\n\n質問: {question}")  # 生成(別Port)

各コンポーネントが Port として独立しているので、retriever をベクトルDBに差し替える・LLMを変える・評価を挟む、が局所変更で済む(vectordb_recommendation_整理 の検索段がそのまま retriever になる)。


5. Eval駆動開発(evals-driven development)

LLMは出力が非決定的なので、従来のアサーション型テストが効きにくい。そこで TDDの代わりに "評価(evals)" を先に書き、CIで回す(Hamel Husain らが普及)。クリーンアーキテクチャで言う「テスト容易性」のLLM版。

# 評価データセット(入力と"期待される性質"の組)
CASES = [
    {"input": "1+1は?", "must_include": ["2"]},
    {"input": "退会したい", "must_route": "general"},
]

def test_summarizer_quality():
    gen = ClaudeAdapter(anthropic.Anthropic())
    usecase = SummarizeArticle(gen)
    scores = []
    for c in CASES:
        out = usecase(c["input"]).body
        # ルールベース、または LLM-as-a-judge で採点
        scores.append(all(s in out for s in c.get("must_include", [])))
    assert sum(scores) / len(scores) >= 0.8     # 合格ラインを閾値で持つ

ポイント: - 境界(Port)でモック可能だからこそ、決定論部分は通常のユニットテスト、確率的部分は evals、と分離できる - 評価は LLM-as-a-judge(別のLLMに採点させる)+ルールベースの併用が定番 - trace(LangSmith / Langfuse / OpenTelemetry)で「なぜその出力か」を後追いできる設計を最初から入れる


6. 全体を貫く設計原則 ↔ クリーンアーキテクチャ対応表

LLMプロダクトの原則 クリーンアーキテクチャでの対応 具体策
確率的な縁と決定論的コアの分離 同心円(内=純粋、外=詳細) LLM呼び出しはユースケースの末端。制御フローは自前(Factor 8/12)
構造化I/Oで境界を固める 境界をまたぐデータはDTO/型 入口=NL→schema(Factor 1)、出口=structured output(Factor 4)
LLMは差し替え可能なadapter Ports & Adapters TextGenerator Port+ClaudeAdapter(§1)
プロンプトは設定であり詳細 フレームワーク・設定は外側 プロンプトを自分で所有(Factor 2)、ドメインに混ぜない
evalsを一級市民に テスト容易性 eval-first、LLM-as-a-judge、閾値ゲート(§5)
可観測性を最初から ログ/監視は横断的関心事 trace(Langfuse/OTel)、event列を単一の真実源(Factor 5)
小さく専門特化 単一責任・高凝集 micro-agentをDAGで連鎖(Factor 10)、巨大プロンプト回避

7. 実プロダクトのディレクトリ構成例

src/
  domain/                 # ← 内側。LLM・SDK・HTTPを一切importしない
    entities.py           #   Draft, Order ... 値オブジェクト
    ports.py              #   TextGenerator, Retriever, HumanGateway (Protocol)
    usecases/
      summarize.py        #   SummarizeArticle
      answer_with_rag.py  #   AnswerWithRAG(compound)
  infrastructure/         # ← 外側。差し替え可能な詳細
    llm/
      claude_adapter.py   #   ClaudeAdapter(model="claude-opus-4-8"はここだけ)
      fake_generator.py   #   テスト用
    retrieval/
      qdrant_adapter.py
    prompts/              #   プロンプトを"設定"として外出し(Factor 2)
      summarize.md
  interface/              # ← 起動口(Factor 11: どこからでも)
    api.py  slack.py  cron.py
  evals/                  # ← 評価データセット+採点(Factor: eval駆動)
    cases.yaml  test_quality.py

依存の向きは常に interface → usecase → domain ← infrastructure(domainは何も知らない)。


8. まとめ — 何を選ぶか

状況 採るもの
まず土台を正しくしたい ヘキサゴナル:LLMをPort&Adapterで境界に追い出す(§1)
本番運用の信頼性を上げたい 12-Factor Agents:stateless reducer・制御フロー自前・小さく分割
構成の型が欲しい Anthropic 5パターン:①〜③で済むなら④⑤を使わない
モデル単体で精度が頭打ち Compound AI:retriever/tool/複数呼び出しのシステムにする
非決定的な品質を担保したい eval駆動:eval-first+LLM-as-a-judge+trace

結論: 「クリーンアーキテクチャみたいな手法」はある——というより、クリーンアーキテクチャ/ヘキサゴナルがそのまま土台になる。LLMを detail として境界に追い出し、その上に 12-Factor Agents(実践原則)+ Anthropic のworkflows/agentsパターン(構成テンプレ)+ evals(品質ゲート) を重ねるのが、現時点で最も "標準" に近い設計手法である。


参考リンク

  • 12-Factor Agents(HumanLayer / Dex Horthy): https://github.com/humanlayer/12-factor-agents / https://www.humanlayer.dev/12-factor-agents
  • Anthropic "Building Effective Agents": https://www.anthropic.com/engineering/building-effective-agents
  • Berkeley BAIR "The Shift from Models to Compound AI Systems"(2024-02-18): https://bair.berkeley.edu/blog/2024/02/18/compound-ai-systems/
  • Compound AI(Databricks): https://www.databricks.com/blog/what-are-compound-ai-systems
  • Eval駆動の実践(Hamel Husain): https://hamel.dev/
  • 用語の元: Clean Architecture(R.C. Martin)/Hexagonal Architecture = Ports & Adapters(Alistair Cockburn)

作成: 2026-06-08 / 最終更新: 2026-06-16