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列を流し込めばいつでも同じ挙動を再現できる(リプレイ=デバッグ)。制御フロー(for・try・分岐)は決定論的な自前コード側にあり、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