AIエージェントに必要なのはプロンプトではなくコントロールフローだ——決定論的設計でLLMの信頼性を高める「サンドイッチアーキテクチャ」入門
AIエージェントに必要なのはプロンプトではなくコントロールフローだ——決定論的設計でLLMの信頼性を高める「サンドイッチアーキテクチャ」入門
導入:なぜあなたのAIエージェントは「使えない」のか
「プロンプトをどれだけ丁寧に書いても、本番では思い通りに動かない」——AIエージェント開発に関わるエンジニアなら、一度はこの壁にぶつかったことがあるはずです。
試行錯誤の末にたどり着くのは、プロンプトの改善ではなくアーキテクチャの見直しです。
本記事では、LLMの信頼性を根本的に高める設計手法として注目されている「サンドイッチアーキテクチャ」を解説します。「賢いLLM」と「信頼できるシステム」は別物です。この区別を理解することが、プロダクションレベルのAIエージェント開発への第一歩となります。
第1章:LLMの信頼性問題——プロンプト主義の限界
1-1. プロンプトエンジニアリングが抱える構造的な欠陥
プロンプトエンジニアリングは強力なツールですが、システム設計の代替にはなりません。根本的な欠陥が3つあります。
出力の非決定性:同じプロンプトでも、temperature設定や内部のサンプリング処理によって毎回異なる結果が返ります。ステートレスなAPIを「状態を持つエージェント」として使おうとすること自体に無理があります。
コンテキストドリフト:会話が長くなるにつれて、初期の指示が薄まり、意図とは異なる方向に推論が流れていく現象です。「プロンプトに書いたはずなのに無視される」のはバグではなく、LLMの構造的特性です。
保守コストの爆発:プロンプトに条件分岐やルールを詰め込み始めると、変更の影響範囲が見えなくなります。コードと違い、バージョン管理・テスト・デバッグが極めて困難です。
1-2. エージェントの失敗パターン:現場で起きていること
実際の開発現場では、以下の三大障害が繰り返し報告されています。
- 無限ループ:タスク完了条件をLLMが自己判断できず、同じツールを何度も呼び出し続ける
- ハルシネーション連鎖:一度誤った情報を生成すると、後続のステップがその誤りを前提に推論を重ねる
- タスク脱線:複雑なマルチステップ処理の途中で、当初の目標と無関係な作業を始める
これらはすべて、「LLMに任せすぎている」設計から生じます。
1-3. 問題の根本:LLMは「推論器」であり「実行器」ではない
LLMが得意なのはテキストの理解・生成・推論です。苦手なのは状態管理・副作用の制御・冪等な処理の保証です。
実際の開発現場では、この役割分担を意識せずに設計してしまうことが失敗の主因です。LLMに「実行器」の役割まで担わせようとするから、システムが不安定になります。
第2章:コントロールフロー設計という発想の転換
2-1. LLMを「関数」として扱うアーキテクチャ思想
コントロールフロー設計の核心は、LLMを副作用のない純粋な変換関数として扱うことです。
f(入力コンテキスト) → 構造化出力
この考え方に立てば、LLMの呼び出しは通常のAPI呼び出しと何ら変わりません。入力を明確に定義し、出力を型安全に受け取り、エラーをハンドリングする——普通のソフトウェア設計の原則がそのまま適用できます。
2-2. 主要なコントロールフローパターン
エージェント設計で頻繁に登場するパターンを整理します。
| パターン | 用途 | 特徴 |
|---|---|---|
| Sequential Chain | 直列タスク処理 | シンプル・デバッグしやすい |
| Conditional Branching | 条件による分岐 | LLMの判断を分岐条件に使う |
| Loop with Exit Condition | 反復処理 | 終了条件を決定論的に定義する |
| Map-Reduce | 並列集約 | 大量データの並列処理 |
| Human-in-the-Loop | 人間介入 | 高リスク操作の承認フロー |
ポイントは、これらのパターンをコードで実装することです。「LLMに次のステップを決めさせる」設計は、制御の予測可能性を失います。
第3章:サンドイッチアーキテクチャの全体設計
3-1. 三層モデルの直感的理解
サンドイッチアーキテクチャは、LLMを「パン(決定論的レイヤー)」で挟む構造です。
┌─────────────────────────────────────────┐
│ 🍞 Pre-Processing Layer(上パン) │
│ 入力の正規化・バリデーション・コンテキスト構築 │
├─────────────────────────────────────────┤
│ 🥩 LLM Core Layer(フィリング) │
│ 推論・判断・構造化出力の生成 │
├─────────────────────────────────────────┤
│ 🍞 Post-Processing Layer(下パン) │
│ 出力パース・品質チェック・副作用の実行 │
└─────────────────────────────────────────┘
「サンドイッチ」という命名が本質を突いているのは、LLMを中身(具材)として扱い、前後の決定論的処理で包むという構造が直感的に伝わるからです。
3-2. 上パン:Pre-Processing Layer
入力レイヤーの責務は以下の通りです。
- 入力バリデーション:不正なリクエストをLLMに到達させる前にブロック
- コンテキスト注入:RAGによる関連情報の取得、ツール定義の選択的付与
- プロンプトテンプレートの静的管理:プロンプトをコードとして管理し、バージョン管理・レビューを可能にする
- トークン予算の管理:コンテキストウィンドウの使用量を予測・制御する
3-3. 中身:LLM Core Layer
LLMに委ねる判断を意図的に限定することが重要です。LLMが担うべきは:
- 自然言語の理解と構造化
- 複数の選択肢からの推論・判断
- 自然言語の生成
担わせるべきでないのは:
- 状態の追跡と管理
- ツールの実行と副作用
- ループの終了判定(終了条件は決定論的に定義する)
構造化出力(Structured Output)の強制は必須です。JSON Schemaを指定してLLMの出力を型安全に受け取ることで、後処理レイヤーの実装が劇的にシンプルになります。
3-4. 下パン:Post-Processing Layer
出力レイヤーは「LLMの結果を現実世界に接続する」責務を持ちます。
- 出力パースと型変換:LLMの出力を型安全なオブジェクトに変換
- 品質チェック:必須フィールドの存在確認、値の範囲検証、ガードレール
- フォールバック戦略:パース失敗時のリトライ、エスカレーション
- 可観測性の確保:LLM呼び出しのレイテンシ・コスト・品質をログ記録
第4章:実装パターンとコード例
4-1. 最小構成のサンドイッチ実装(Python)
実際の開発現場で使える骨格コードを示します。
import anthropic
from pydantic import BaseModel, ValidationError
from typing import Optional
import logging
# 構造化出力のスキーマ定義
class TaskAnalysis(BaseModel):
intent: str
required_tools: list[str]
confidence: float
reasoning: str
class SandwichAgent:
def __init__(self, client: anthropic.Anthropic):
self.client = client
self.logger = logging.getLogger(__name__)
# ─────────────────────────────────────
# 上パン:Pre-Processing Layer
# ─────────────────────────────────────
def preprocess(self, raw_input: str, context: dict) -> dict:
"""入力の正規化・バリデーション・コンテキスト構築"""
# バリデーション
if not raw_input or len(raw_input.strip()) == 0:
raise ValueError("入力が空です")
if len(raw_input) > 10000:
raise ValueError("入力が上限を超えています")
# コンテキスト構築
return {
"user_input": raw_input.strip(),
"available_tools": context.get("tools", []),
"user_id": context.get("user_id"),
"session_id": context.get("session_id"),
}
# ─────────────────────────────────────
# 中身:LLM Core Layer
# ─────────────────────────────────────
def call_llm(self, preprocessed: dict) -> dict:
"""LLMに推論を委ねる(構造化出力を強制)"""
tools_desc = ", ".join(preprocessed["available_tools"]) or "なし"
prompt = f"""以下のユーザー入力を分析し、JSON形式で回答してください。
ユーザー入力: {preprocessed["user_input"]}
利用可能なツール: {tools_desc}
必ず以下のJSONスキーマに従って出力してください:
{{
"intent": "ユーザーの意図(1文)",
"required_tools": ["必要なツール名のリスト"],
"confidence": 0.0〜1.0の確信度,
"reasoning": "判断の根拠"
}}"""
response = self.client.messages.create(
model="claude-opus-4-5",
max_tokens=1024,
messages=[{"role": "user", "content": prompt}]
)
return {
"raw_output": response.content[0].text,
"usage": {
"input_tokens": response.usage.input_tokens,
"output_tokens": response.usage.output_tokens,
}
}
# ─────────────────────────────────────
# 下パン:Post-Processing Layer
# ─────────────────────────────────────
def postprocess(self, llm_result: dict, max_retries: int = 2) -> Optional[TaskAnalysis]:
"""出力パース・バリデーション・フォールバック"""
import json
raw = llm_result["raw_output"]
# JSON抽出(LLMが余分なテキストを含む場合に対応)
try:
start = raw.index("{")
end = raw.rindex("}") + 1
json_str = raw[start:end]
data = json.loads(json_str)
except (ValueError, json.JSONDecodeError) as e:
self.logger.error(f"JSONパース失敗: {e}, raw: {raw[:200]}")
return None
# 型安全なバリデーション
try:
result = TaskAnalysis(**data)
except ValidationError as e:
self.logger.error(f"スキーマ検証失敗: {e}")
return None
# 品質チェック
if result.confidence < 0.3:
self.logger.warning(f"確信度が低すぎます: {result.confidence}")
# 低確信度はエスカレーション候補としてマーク
# 可観測性:メトリクス記録
self.logger.info(
"llm_call_completed",
extra={
"input_tokens": llm_result["usage"]["input_tokens"],
"output_tokens": llm_result["usage"]["output_tokens"],
"confidence": result.confidence,
}
)
return result
# ─────────────────────────────────────
# オーケストレーション
# ─────────────────────────────────────
def run(self, user_input: str, context: dict) -> Optional[TaskAnalysis]:
"""三層を順番に実行するコントロールフロー"""
try:
preprocessed = self.preprocess(user_input, context)
llm_result = self.call_llm(preprocessed)
return self.postprocess(llm_result)
except ValueError as e:
self.logger.error(f"前処理エラー(入力不正): {e}")
raise
except anthropic.APIError as e:
self.logger.error(f"LLM APIエラー: {e}")
raise
# 使用例
if __name__ == "__main__":
client = anthropic.Anthropic()
agent = SandwichAgent(client)
result = agent.run(
user_input="先月の売上レポートを作成して、CSVでダウンロードできるようにしてほしい",
context={"tools": ["database_query", "csv_export", "file_upload"], "user_id": "u-123"}
)
if result:
print(f"意図: {result.intent}")
print(f"必要なツール: {result.required_tools}")
print(f"確信度: {result.confidence:.2f}")このコードの重要なポイントは、各レイヤーが独立してテスト可能なことです。preprocess()はLLMなしでユニットテストでき、postprocess()はモックの出力文字列でテストできます。
4-2. 終了条件付きループの実装
無限ループを防ぐ、決定論的な終了条件の実装例です。
def run_with_loop(self, task: str, max_iterations: int = 5) -> str:
"""終了条件を決定論的に管理するループ"""
history = []
for iteration in range(max_iterations): # 最大反復数はコードで管理
result = self.run(task, context={"history": history})
if result is None:
return "エラー: 処理を中断しました"
history.append(result)
# 終了条件の判定はLLMではなくコードで行う
if result.confidence > 0.9 and "complete" in result.intent.lower():
self.logger.info(f"タスク完了: {iteration + 1}回目")
return result.reasoning
# 最大反復数に達した場合はエスカレーション
self.logger.warning("最大反復数に達しました。人間にエスカレーションします。")
return "ESCALATE_TO_HUMAN"4-3. 既存フレームワークとの対応関係
サンドイッチアーキテクチャの思想は、主要フレームワークにそのまま対応しています。
| フレームワーク | サンドイッチへの対応 |
|---|---|
| LangGraph | ノード(処理)とエッジ(条件)によるDAG管理。各ノードが前処理・推論・後処理の責務を持つ |
| LlamaIndex Workflows | イベント駆動アーキテクチャ。@stepデコレータで各レイヤーを明確に分離 |
| Anthropic Agent SDK | ツール定義・実行・結果処理の三層が標準設計として組み込まれている |
素のAPIで実装する場合も、上記のパターンをそのまま適用できます。フレームワークは「コントロールフローの記述を楽にするもの」であり、思想そのものをフレームワークに依存する必要はありません。
第5章:信頼性を高める設計原則
5-1. Fail-Fast原則——早期検出と早期停止
バリデーションは必ず入力時に行ってください。LLMの呼び出し後にエラーを検出するのは、時間・コスト・副作用のすべてで損失が大きくなります。
# ❌ 悪い例:LLM呼び出し後にバリデーション
result = call_llm(raw_input)
if not is_valid(result):
pass # トークンコストをかけた後で失敗
# ✅ 良い例:入力時にバリデーション
validated = validate_input(raw_input) # ここで失敗させる
result = call_llm(validated)5-2. テスト戦略:エージェントをテスタブルにする
サンドイッチアーキテクチャの最大のメリットはテスタビリティです。
import pytest
from unittest.mock import MagicMock
class TestSandwichAgent:
def test_preprocess_rejects_empty_input(self):
"""前処理レイヤーは単体でテスト可能"""
agent = SandwichAgent(client=MagicMock())
with pytest.raises(ValueError, match="入力が空"):
agent.preprocess("", {})
def test_postprocess_handles_malformed_json(self):
"""後処理レイヤーも単体でテスト可能"""
agent = SandwichAgent(client=MagicMock())
result = agent.postprocess({"raw_output": "これはJSONではありません", "usage": {}})
assert result is None # フォールバックが正しく機能する
def test_full_pipeline_with_mock_llm(self):
"""LLMをモックして統合テスト"""
mock_client = MagicMock()
mock_client.messages.create.return_value = MagicMock(
content=[MagicMock(text='{"intent": "売上レポート作成", "required_tools": ["db"], "confidence": 0.95, "reasoning": "test"}')],
usage=MagicMock(input_tokens=100, output_tokens=50)
)
agent = SandwichAgent(client=mock_client)
result = agent.run("売上レポートを作って", context={"tools": ["db"]})
assert result is not None
assert result.confidence == 0.95決定論的レイヤーはLLMなしでユニットテストでき、LLMレイヤーはモックで置き換えられます。これにより、CI/CDパイプラインでのテスト自動化が現実的になります。
5-3. 可観測性の組み込み
本番運用では、LLM呼び出しをブラックボックスにしないことが重要です。OpenTelemetryと連携したトレーシングを導入し、以下のメトリクスを計測してください。
- コスト:入力/出力トークン数(
input_tokens,output_tokens) - レイテンシ:各レイヤーの処理時間
- 品質:確信度スコア、フォールバック発生率、エスカレーション率
第6章:アンチパターンと落とし穴
6-1. LLM過信パターン
最も多いアンチパターンは「LLMが全部やってくれる」という設計思想です。具体的には:
- プロンプトで状態管理を代替する:「前のステップでは〜したので...」とプロンプトに書き続けることで、コンテキストウィンドウが汚染されます
- エージェント・マトリョーシカ:エージェントがエージェントを呼び出し、さらに別のエージェントを呼び出す多重構造。デバッグが不可能になります
6-2. 過度な決定論化の落とし穴
逆のアンチパターンとして、「すべてルールベースで書く」過剰制御もあります。LLMが価値を発揮するのは、ルールで書き切れない曖昧な判断の領域です。コントロールフローはLLMの判断を活かすための枠組みであり、LLMを排除するための手段ではありません。
6-3. ツール定義の曖昧さ
ツール名と説明が曖昧だと、LLMは誤ったツールを呼び出します。
# ❌ 悪い例
{"name": "get_data", "description": "データを取得する"}
# ✅ 良い例
{
"name": "get_sales_report",
"description": "指定期間の売上データをデータベースから取得する。期間はISO 8601形式(YYYY-MM-DD)で指定する。",
"input_schema": {
"type": "object",
"properties": {
"start_date": {"type": "string", "format": "date"},
"end_date": {"type": "string", "format": "date"}
},
"required": ["start_date", "end_date"]
}
}まとめ:プロンプトからアーキテクチャへ
サンドイッチアーキテクチャの要点を5つにまとめます。
- LLMは「推論器」であり「実行器」ではない:役割分担を明確にする
- コントロールフローはコードで書く:ループの終了条件・分岐・エラーハンドリングをLLMに任せない
- 三層構造で責務を分離する:前処理・推論・後処理を独立したレイヤーとして設計する
- 構造化出力を強制する:JSONスキーマでLLMの出力を型安全に受け取る
- 決定論的レイヤーをテストする:ユニットテストとモックでCI/CDに組み込む
「LLMを信頼する」から「LLMを設計する」への思考転換——これがプロダクションレベルのAIエージェント開発の本質です。
今日から適用できる第一歩:既存のエージェントコードを開き、LLMの呼び出し前後に明示的なバリデーション処理を追加してください。その小さな変更が、サンドイッチアーキテクチャへの移行の起点となります。
付録
参考資料・関連リンク
- Anthropic Agent SDK 公式ドキュメント
- LangGraph ドキュメント
- LlamaIndex Workflows
- 論文:ReAct: Synergizing Reasoning and Acting in Language Models
- 論文:Toolformer: Language Models Can Teach Themselves to Use Tools
用語集
| 用語 | 説明 |
|---|---|
| コントロールフロー | プログラムの実行順序・分岐・ループを決定する制御構造 |
| 決定論的レイヤー | 同じ入力に対して常に同じ出力を返す、予測可能な処理層 |
| 構造化出力 | JSON Schemaなどで型を定義し、LLMの出力を機械可読な形式に強制する手法 |
| コンテキストドリフト | 長い会話でLLMが初期指示から逸脱していく現象 |
| Evals | AIシステムの出力品質を自動・半自動で評価するフレームワーク |
| 冪等性 | 同じ操作を何度繰り返しても結果が変わらない性質 |
チェックリスト:サンドイッチアーキテクチャ導入確認項目
設計フェーズ
- LLMに委ねる判断の種類を明示的に定義したか
- 前処理・推論・後処理の責務を分離したか
- ループの終了条件をコードで実装したか
- ツール定義に明確な名前・説明・スキーマを記述したか
実装フェーズ
- 入力バリデーションを前処理レイヤーに実装したか
- 構造化出力(JSON Schema)を強制しているか
- 後処理でパース失敗時のフォールバックを実装したか
- LLM呼び出しのコスト・レイテンシをログに記録しているか
テストフェーズ
- 前処理・後処理レイヤーのユニットテストを作成したか
- LLMをモックした統合テストを実装したか
- 最大反復数・タイムアウトの動作をテストしたか
関連記事
Anthropicはオープンウェイトモデル禁止を求めていない──AI政策を動かす3つの提言を読み解く
AnthropicCEOダリオ・アモデイ氏がオープンウェイトモデル全面禁止を否定。チップ輸出規制強化・蒸留取り締まり・安全性テスト義務化の3つの政策提言を技術的・政策的観点から詳しく解説します。
Claudeのコメントが長すぎる問題──AIが書いたコメントは、AI自身の役に立っていなかった
Claude Codeが生成する過剰コメントの原因をRLHFの訓練特性から解説。「コードから復元できない情報のみ」ルールでコメント比率を21%→8.8%に改善した実践知。明日から使えるCLAUDE.md設定付き。
Cloudflare OSとは?オープンソースAIオペレーティングシステムの全貌をわかりやすく解説
Cloudflareが2026年8月に公開したオープンソースAI OS「Cloudflare OS」を徹底解説。Gatekeeperによるゼロトラスト設計、エージェントワークスペース、パーソナルアプリプラットフォームのアーキテクチャをわかりやすく紹介します。
4BパラメータのオープンモデルがGPT-5.6 Solに並ぶ日 ― Castform × Neon Lakebaseで実現するRAGコスト1/100
4BパラメータのオープンモデルにRL学習を施し、GPT-5.6 Sol同等の精度をコスト1/100で実現。CastformとNeon Lakebase Postgresを活用したRAGアーキテクチャの仕組みとコスト試算を徹底解説。