AIエージェントに必要なのはプロンプトではなくコントロールフローだ——決定論的設計でLLMの信頼性を高める「サンドイッチアーキテクチャ」入門

約26分で読めます by ぽんたぬき
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の議論を見てると、コンセンサスはほぼこれで固まってたんですネ♪

  1. 明確な状態遷移の定義:今どのステップにいるかをコードで管理する
  2. 検証チェックポイントの設置:LLMの出力を毎回ちゃんと確認する
  3. 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つの「持って帰るポイント」はコレだヨ!!😆

  1. MANDATORY はシグナル——書きたくなった瞬間、設計を見直す時だヨ
  2. LLMは「コンポーネント」として扱う——判断させていい範囲を明示的に決める
  3. 品質ゲートは後付けじゃなく設計の一部——最初からサンドイッチで組む

次のアクション

僕も引き続き実装しながら学んでるとこなので、「ここ違うヨ!!」「もっとイイ方法あるヨ!!」ってのがあったら、ぜひ教えてネ♪

キミのエージェント設計が「プロンプトの呪文」から卒業できる日を、陰ながら応援してるヨ!!🦝✨

関連記事

機械学習による仮想通貨価格予測(第3部):予測モデルの検証と自動売買システムへの統合完全ガイド
AI・機械学習

機械学習による仮想通貨価格予測(第3部):予測モデルの検証と自動売買システムへの統合完全ガイド

機械学習×仮想通貨自動売買シリーズ第3部。ルックアヘッドバイアス・サバイバーシップバイアス・データリーケージの3大バイアスを解説し、ウォークフォワード検証と自動売買システムへの統合方法をコード付きで完全解説。

機械学習による仮想通貨価格予測(第2部):予測モデル実装と精度比較の完全ガイド
AI・機械学習

機械学習による仮想通貨価格予測(第2部):予測モデル実装と精度比較の完全ガイド

LSTM・Transformer・XGBoostを使った仮想通貨価格予測モデルの実装方法を徹底解説。各モデルの仕組みと精度をRMSE・MAPE・R²で比較し、どのモデルが最適かを明らかにする実践的ガイド。

コメント

0/2000