AIエージェントに必要なのはプロンプトではなくコントロールフローだ——決定論的設計でLLMの信頼性を高める「サンドイッチアーキテクチャ」入門
AIエージェントに必要なのはプロンプトではなくコントロールフローだ——決定論的設計でLLMの信頼性を高める「サンドイッチアーキテクチャ」入門
はじめに:「MANDATORY」と書いたその瞬間、キミは負けているヨ🦝
ねえねえ、キミ!! エージェントのプロンプトに MANDATORY とか DO NOT SKIP とか書いちゃったこと、ある?😅
僕はあるヨ。あります。ガッツリあります。
「必ず日本語で答えてください」「絶対にコードを省略しないでください」「スキップ厳禁!!」……書けば書くほど、なんか呪文みたいになってくるヤツ。ねえ、聞いてヨ。僕さ、昨日も、試したんだケド。どんなに強い言葉を並べても、LLMはぜんぜん気にせず好き勝手やるんだヨネ……😭
これ、ジツはコントロールフロー設計の失敗サインなんですヨ!! そういう話を、今日はしちゃいますネ♪
先日 Hacker News でバズった記事(bsuh.bearblog.dev)がズバリ言ってたんですヨ。「MANDATORY に頼り始めたら、プロンプトエンジニアリングの天井に達した証拠だ」って。キミ、センスあるネ!! きっとこの記事、刺さるはずデス😆
第1章:なぜプロンプトだけでは足りないのか——LLMエージェント設計の非決定論的特性🎲
温度=0でも消えない「ゆらぎ」
まず大前提の話をしちゃいますネ。LLMって、温度パラメータ(temperature)を0にすれば決定論的になるって思ってない?僕も最初そう思ってたんですヨ。ファミコンのカセット吹いてた頃からのクセで「設定さえ合えば必ず同じ動作」って信じてたんだヨネ📟
でもね、ちがうんですヨ……😭
2025年の arxiv論文(2502.04853) でも確認されてるんだケド、内部のfloating-point演算の丸め誤差や実行環境の差異で、temperature=0でも出力がブレることがあるんですネ💡 確率的サンプリングの基本メカニズム上、完全な再現性は保証されてないんですヨ。
コレがLLMエージェント設計に致命的な影響を与えるんですネ!!
「約30ファイル後に崩壊」の実録😭
ちょっと聞いてヨ……僕の知り合い(というかHNのコメント欄の話なんだケド)のやらかし報告。
プロンプトだけに依存したQAシステムを作ったら、最初の20〜30ファイルはぜんぜん問題なく動いてたのに、ある日突然、31ファイル目あたりから挙動がおかしくなってきたと。なんでかって言うとサ——
コンテキストウィンドウにエラーが積み重なっていくから、なんですネ。前のステップのエラー情報がどんどん汚染していって、LLMが「え、何をすればいいんだっけ?」みたいな状態に陥っちゃう。プロンプトの指示よりも、文脈のゴミの方が強くなるんですヨ!!
これが「高コストなデータ変換パイプライン」に成り下がる瞬間デス……😅
僕もまったく同じやらかしをしたコトあってサ、プロンプトを何度書き直しても直らなくて、結局コントロールフローの設計から見直したんだヨネ……あの徹夜は二度としたくナイです(笑)。まさに「やらかしちゃいましたヨ……😭」って感じデスネ。
LLMエージェント設計で信頼できるシステムに必要な3つのこと
HNの議論を見てると、コンセンサスはほぼこれで固まってたんですネ♪
- 明確な状態遷移の定義:今どのステップにいるかをコードで管理する
- 検証チェックポイントの設置:LLMの出力を毎回ちゃんと確認する
- LLMをコンポーネントとして扱う:ブラックボックスに「全部お任せ」しない
「盲目的な信頼」はシステム障害への片道切符なんですヨ🔥 キミ、覚えておいてネ!!
第2章:コントロールフローを「挟む」——サンドイッチアーキテクチャの全体像🥪
まずは全体像から行くヨ!!イメージできると一気に理解が深まるから、じっくり見ててネ👇
┌─────────────────────────────────────┐
│ 決定論的 前処理レイヤー │
│ ・入力バリデーション │
│ ・コンテキスト選択 │
│ ・タスク構造化 │
└──────────────┬──────────────────────┘
↓
┌─────────────────────────────────────┐
│ LLM 推論レイヤー │
│ ・判断・分類・生成 │
│ ← ここだけ非決定論的 → │
└──────────────┬──────────────────────┘
↓
┌─────────────────────────────────────┐
│ 決定論的 後処理レイヤー(品質ゲート)│
│ ・出力バリデーション │
│ ・構造チェック │
│ ・失敗検出・リトライ制御 │
└─────────────────────────────────────┘
これが「サンドイッチアーキテクチャ(Guardrail Sandwich)」デス!! LLMの非決定論的な判断を、決定論的なコードで前後から「挟む」コントロールフロー設計パターンなんですネ🎵
Stripeの Minions システムも、このパターンを採用してるんですヨ。週1,000件超のPRを処理する本番システムが、「LLMの作業ステップ間に決定論的ノードを明示的に挿入」することで安定稼働してるって言うんだから、コレ マジでオススメ!!
前処理:LLMエージェント設計で「渡す前」が9割🎯
前処理レイヤーでやることはシンプルですヨ。
- 入力バリデーション:おかしいデータをLLMに見せない
- コンテキストの明示的な選択:全部見せるんじゃなくて、必要な情報だけを選ぶ
- タスクの構造化:「なんかいい感じにして」じゃなく「このJSON形式で出力して」に変換する
コレ、地味に見えるケド、コントロールフローの安定性を左右する最重要ポイントなんですヨ!!😤
後処理:品質ゲートで「信頼できる出力」だけを通す🚦
後処理が本丸デスネ。LLMの出力をそのままパイプラインに流すの、怖くナイ!? ジツは品質ゲート、こういうコードで書けちゃうんですヨ!!めちゃシンプルでしょ!?😤
import json
from dataclasses import dataclass
from typing import Any
@dataclass
class ValidationResult:
is_valid: bool
errors: list[str]
def validate_agent_output(raw_output: str, schema: dict) -> ValidationResult:
"""
LLMの出力を決定論的に検証する品質ゲート
"""
errors = []
# Step 1: JSON構造チェック(決定論的)
try:
parsed = json.loads(raw_output)
except json.JSONDecodeError as e:
return ValidationResult(is_valid=False, errors=[f"JSON parse error: {e}"])
# Step 2: 必須フィールドチェック(決定論的)
required_fields = schema.get("required", [])
for field in required_fields:
if field not in parsed:
errors.append(f"Missing required field: {field}")
# Step 3: 型チェック(決定論的)
for field, expected_type in schema.get("types", {}).items():
if field in parsed and not isinstance(parsed[field], expected_type):
errors.append(f"Type mismatch: {field} should be {expected_type.__name__}")
return ValidationResult(is_valid=len(errors) == 0, errors=errors)動いた時サ、思わず声出ちゃいましたヨ!!😆 妻に怒られたケド!!(笑)
第3章:12-Factor Agentsが教えるコントロールフロー設計の原則📋
Twelve-Factor Appのエージェント版が登場!!
ちょっと待ってヨ、キミ!! その前に謎の近況報告を挟んでもいい?僕さ、先週からニンジンを毎朝齧る習慣を始めたんだヨネ。理由は特にナイのだケド、なんか続いてるんですヨ……。関係ないケド!😅
……閑話休題、本題に戻りますネ♪
HumanLayerが提唱した「12-Factor Agents」、キミ知ってる??
Herokuの「Twelve-Factor App」をエージェント設計に適用したヤツで、GitHub(humanlayer/12-factor-agents) で公開されてるんですヨ✨ ソフトウェアエンジニアリングの知見をそのままエージェントのコントロールフロー設計に持ち込むっていう発想が、マジでシブい!!
12個全部説明すると長くなるので、特に大事な原則を拾いますネ♪
Factor 8「Own Your Control Flow」——コントロールフロー設計の最重要原則🔥
百聞は一見に如かず!!悪いパターンと良いパターンを並べたよ。眺めるだけで「あ〜コレやってたわ……」ってなるはずデス😅👇
# ❌ LLMにコントロールフローを委ねるダメなパターン
async def bad_agent(task: str = "") -> str:
# LLMが「次に何をするか」を全部決めちゃう
response = await llm.complete(f"以下のタスクを完了してください: {task}")
return response # LLMが幻覚した実行パスをそのまま信用している
# ✅ コントロールフローを明示的に管理するGoodなパターン
async def good_agent(task: str = "") -> str:
# Step 1: タスク分類(LLMに判断させる)
task_type = await classify_task(task)
# Step 2: タスクタイプに応じて決定論的に分岐(コードが制御する)
if task_type == "code_generation":
return await handle_code_generation(task)
elif task_type == "data_analysis":
return await handle_data_analysis(task)
else:
return await handle_general_task(task)
# ← 分岐ロジックはLLMではなくコードが所有する!「決定ロジックの明示的管理」というのが Factor 8 の核心なんですネ。LLMが恣意的な実行パスを「幻覚」するのを防ぐために、コントロールフローの分岐はぜんぶコードで書く。シンプルだケド、奥が深いんですヨ💡
Factor 12「Stateless Reducer」——関数型の考え方デス
コレ、関数型プログラミング好きな人にはニヤリとする原則なんですヨ🎵 状態遷移コード、見てみて!!関数型っぽくてなかなかいい感じじゃナイ!?👇
from typing import TypedDict, Literal
class AgentState(TypedDict):
status: Literal["idle", "processing", "awaiting_human", "complete", "error"]
current_file: str | None
processed_count: int
errors: list[str]
class AgentEvent(TypedDict):
type: str
payload: dict
def agent_reducer(state: AgentState, event: AgentEvent) -> AgentState:
"""
(currentState, event) → newState
純粋関数として状態遷移を定義する(Factor 12)
"""
if event["type"] == "FILE_PROCESSED":
return {
**state,
"processed_count": state["processed_count"] + 1,
"current_file": None,
}
elif event["type"] == "ERROR_OCCURRED":
return {
**state,
"status": "error",
"errors": state["errors"] + [event["payload"]["message"]],
}
elif event["type"] == "HUMAN_APPROVAL_NEEDED":
return {**state, "status": "awaiting_human"}
else:
return state(currentState, event) → newState という純粋関数のパターンで状態管理すると、同じ入力からは必ず同じ出力が得られるんですネ。これがコントロールフロー設計における決定論的な再現性の確保につながるんですヨ!!😆
著者の本質的な洞察、僕めっちゃ好きなんですケド:
「"AIエージェント"を名乗る製品の多くは、実際にはほとんど決定論的コードであり、LLMステップは適切な箇所に散りばめられているに過ぎない」
これなんですヨ。これが全部デス🔥
第4章:LLMエージェント設計を完成させる品質ゲートとTDD実装💎
エージェント向けTDDとは何か🤔
ねえねえ、キミ!! エージェントにTDDって、難しくナイ?? 僕もサ、最初ハマったんだヨ……
従来のTDDって「期待値と実際の出力が一致したらOK」じゃないですか。でもLLMの出力、毎回ちょっとずつ違うじゃないですか。「正解が一つではない」んですヨネ😭
じゃあどうするか。コツはね、こうなんですヨ:
「構造的正しさ」と「意味的正しさ」を分けてテストする!!
テストコードはこんな感じにナルんですヨ!!あ、驚かないでネ、ちゃんと意図があるから!?👇
import pytest
import json
# ✅ 構造的正しさ(決定論的にテスト可能)
def test_agent_output_structure():
"""出力のJSON構造が仕様通りかをテストする"""
output = run_agent("コードレビューして")
parsed = json.loads(output)
assert "severity" in parsed, "severity フィールドが必要"
assert parsed["severity"] in ["low", "medium", "high", "critical"]
assert "issues" in parsed
assert isinstance(parsed["issues"], list)
# ✅ 確率的アサーション(複数回実行して合格率を確認)
@pytest.mark.parametrize("run", range(10))
def test_agent_semantic_correctness(run):
"""10回実行して8回以上正しい判断をするかをテスト"""
buggy_code = "for i in range(10): print(i" # 意図的なバグ
output = json.loads(run_agent(f"バグを見つけて: {buggy_code}"))
# 10回中8回以上はバグを検出してほしい(確率的アサーション)
assert output["severity"] in ["medium", "high", "critical"]確率的なシステムには確率的なアサーションで挑む、ってコトデスネ💡
TDD強制の品質ゲート——Probity設計から学ぶ✨
GitHub の nizos/probity というプロジェクト、知ってる?Claude Code のフックシステムを活用した品質ゲートの実装例なんですヨ!!
Probityパターンを実装するとこうなるんですケドサ!!見てみて!?ちょっと長いケド最後まで読んでほしいんですヨ!!🔥
def enforce_tdd(session_state: dict) -> tuple[bool, str]:
"""
テストなしのソースコード追加をブロックする品質ゲート
(Probityの enforceTdd パターン)
"""
recent_changes = session_state.get("recent_file_changes", [])
source_files_added = [
f for f in recent_changes
if f["type"] == "added" and _is_source_file(f["path"])
]
test_files_added = [
f for f in recent_changes
if f["type"] == "added" and _is_test_file(f["path"])
]
if source_files_added and not test_files_added:
filenames = [f["path"] for f in source_files_added]
return (
False,
f"テストファイルが見つかりません。"
f"以下のソースファイルに対応するテストを追加してください: {filenames}"
)
return (True, "TDDチェック通過")
def _is_source_file(path: str) -> bool:
return (
path.endswith(".py")
and not _is_test_file(path)
and "/migrations/" not in path
)
def _is_test_file(path: str) -> bool:
return "test_" in path or "_test.py" in path or "/tests/" in pathどう!?シンプルでしょ!!これをCIやエージェントのフックに仕込んでおくと、「テストなしのコード追加」を自動でブロックできるんですヨ😤
サンドイッチアーキテクチャの最小完全実装——MinimalSandwich🥪
さあ、今まで学んだ全部をひとつのクラスに詰め込むヨ!!これが「サンドイッチアーキテクチャ」の最小完全実装デス!!コードちょっと長いケド、コレがわかれば全部わかるから、ぜひ手元で動かしてみてネ!!😆
import json
import asyncio
from dataclasses import dataclass, field
@dataclass
class MinimalSandwich:
"""
サンドイッチアーキテクチャの最小完全実装
前処理(決定論的)→ LLM推論(非決定論的)→ 後処理(決定論的)
"""
llm_client: object
schema: dict
max_retries: int = 3
def preprocess(self, raw_input: str) -> dict:
"""前処理レイヤー:入力を正規化・構造化する(決定論的)"""
if not raw_input or not raw_input.strip():
raise ValueError("空のinputは受け付けないヨ!!")
return {
"task": raw_input.strip(),
"output_format": self.schema,
"previous_errors": [],
}
def postprocess(self, raw_output: str) -> ValidationResult:
"""後処理レイヤー:出力を検証する品質ゲート(決定論的)"""
return validate_agent_output(raw_output, self.schema)
def _build_prompt(self, preprocessed: dict) -> str:
"""構造化されたプロンプトを生成する"""
lines = [f"タスク: {preprocessed['task']}"]
if preprocessed["previous_errors"]:
lines.append(f"前回の失敗理由: {preprocessed['previous_errors']}")
lines.append(
f"以下のJSONスキーマに従って出力してください: "
f"{json.dumps(preprocessed['output_format'], ensure_ascii=False)}"
)
return "\n".join(lines)
async def run(self, raw_input: str) -> dict:
"""
メインのコントロールフロー。
前処理 → LLM → 後処理 → 失敗時リトライ を self.max_retries 回繰り返す。
"""
preprocessed = self.preprocess(raw_input)
for attempt in range(self.max_retries):
prompt = self._build_prompt(preprocessed)
raw_output = await self.llm_client.complete(prompt)
result = self.postprocess(raw_output)
if result.is_valid:
# 品質ゲート通過!決定論的に検証済みの出力だけを返す
return json.loads(raw_output)
# 失敗情報を付加してリトライ(ただしコンテキスト汚染に注意)
preprocessed["previous_errors"] = result.errors
if attempt < self.max_retries - 1:
await asyncio.sleep(0.5 * (attempt + 1)) # exponential backoff
raise RuntimeError(
f"最大リトライ回数({self.max_retries}回)を超過しました。"
f"最終エラー: {result.errors}"
)これで「前処理→LLM→後処理→リトライ」のフルサイクルが完結するんですヨ!!どこか一箇所でもコケたら、ちゃんとエラーとして上に伝播する。LLMの気まぐれがパイプラインを汚染しない設計デス🔥
実際に手元で試したら、僕もう「MANDATORYとか書かなくていいじゃん!!」ってなってサ……あの徹夜の苦労がやらかしちゃいましたヨ……😭 って感じで走馬灯のように蘇ったんだヨネ(笑)。
まとめ:プロンプトからアーキテクチャへ——キミの設計を次のレベルに上げるヨ!!🦝
今日の話をギュっとまとめるとコレデス!!
| やってはいけないコト | やるべきコト |
|---|---|
プロンプトに MANDATORY を書く |
コントロールフローをコードで定義する |
| LLMに「次に何をするか」を決めさせる | 分岐ロジックはコードが所有する(Factor 8) |
| LLMの出力をそのまま信用してパイプラインに流す | 品質ゲートで検証してから次ステップへ |
| 状態を文脈(コンテキスト)で管理する | ステートレスReducerで状態遷移を明示する(Factor 12) |
| エラーをコンテキストに積み続ける | チェックポイントでクリーンなコンテキストを維持する |
キミ、今日の3つの「持って帰るポイント」はコレだヨ!!😆
MANDATORYはシグナル——書きたくなった瞬間、設計を見直す時だヨ- LLMは「コンポーネント」として扱う——判断させていい範囲を明示的に決める
- 品質ゲートは後付けじゃなく設計の一部——最初からサンドイッチで組む
次のアクション
- 📖 参照記事:LLM Control Flow(bsuh.bearblog.dev)
- 🔧 12-Factor Agents:humanlayer/12-factor-agents(GitHub)
- 🛡️ 品質ゲート実装例:nizos/probity(GitHub)
- 📄 temperature=0の非決定論性:arxiv:2502.04853
僕も引き続き実装しながら学んでるとこなので、「ここ違うヨ!!」「もっとイイ方法あるヨ!!」ってのがあったら、ぜひ教えてネ♪
キミのエージェント設計が「プロンプトの呪文」から卒業できる日を、陰ながら応援してるヨ!!🦝✨
関連記事
GRUで仮想通貨価格を予測する方法|初心者からわかる実装ガイド2026
GRUを使った仮想通貨価格予測の実装方法を初心者向けに解説。RNN・LSTMとの違い、ゲート機構の仕組み、Pythonコード実装まで失敗談を交えて丁寧に紹介します。
機械学習による仮想通貨価格予測(第3部):予測モデルの検証と自動売買システムへの統合完全ガイド
機械学習×仮想通貨自動売買シリーズ第3部。ルックアヘッドバイアス・サバイバーシップバイアス・データリーケージの3大バイアスを解説し、ウォークフォワード検証と自動売買システムへの統合方法をコード付きで完全解説。
機械学習による仮想通貨価格予測(第2部):予測モデル実装と精度比較の完全ガイド
LSTM・Transformer・XGBoostを使った仮想通貨価格予測モデルの実装方法を徹底解説。各モデルの仕組みと精度をRMSE・MAPE・R²で比較し、どのモデルが最適かを明らかにする実践的ガイド。