powered by TechFeed
表示モード
Deep Dive

AIエージェントを「純粋関数」に封じ込める — Stack Overflowが提唱する本番投入のための決定論設計

10月7日、Stack Overflowが「Part 1: Make your AI agents boring: the determinism layer」と題した記事を公開した。この記事では、LLMエージェントを本番環境で安全に動かすために「決定論レイヤー」を設計し、モデルを単一ノードに封じ込める実装手法について詳しく紹介されている。

10月7日、Stack Overflowが「Part 1: Make your AI agents boring: the determinism layer」と題した記事を公開した。この記事では、LLMエージェントを本番環境で安全に動かすために「決定論レイヤー」を設計し、モデルを単一ノードに封じ込める実装手法について詳しく紹介されている。


「AIエージェントを退屈にせよ」という設計思想

AIエージェントのデモは派手だ。ツールを呼び出し、ループし、自律的に「なんとかする」。だが本番環境はそれを嫌う。

モデルの挙動がその日の推論パスに依存する限り、テストできず、監査できず、重要な処理には触れさせられない。金融、医療、インフラといった規制のある領域では「だいたい動く」は許容されない。

この記事はLLMシステムを本番投入するための6段階の成熟度モデルのLevel 1、すなわち「決定論レイヤー」を扱う。同モデルは以下の6段階で構成される:Level 1「決定論レイヤー」(本記事)、Level 2「評価(eval)レイヤー」、Level 3「信頼度ルーティング」、Level 4「フィードバックループ」、Level 5「監査・説明可能性」、Level 6「自律的な継続改善」。evalや信頼度ルーティングを実装する前に、まずこの土台を固める必要がある。

核心となるアイデアは一つ:モデルを単一ノードに封じ込め、それ以外をすべて通常のテスト可能なコードにする。


最重要ルール:エージェントは純粋関数である

記事が最初に提示するのは、エージェントの役割定義だ。

エージェントは「提案」を返す純粋関数であり、世界を変える権限を持たない。

@dataclass(frozen=True)
class Proposal:
    decision_id: str
    capability: str
    action: dict        # 提案された変更 — まだ適用されていない
    confidence: float
    routing: Literal["auto", "hitl_recommended", "hitl_required", "reject"]
    reasoning: list[str]
    evidence: list[dict]

class Agent(Protocol):
    def propose(self, ctx: "Context") -> Proposal: ...   # 副作用なし

class Substrate(Protocol):
    def apply(self, proposal: Proposal, approval: "Approval") -> "Effect": ...  # 唯一の変更者

propose()が純粋関数であることで、テストは単純になる:

def test_propose_is_pure():
    agent = ClassifyAgent(gateway=FakeGateway(scripted))
    assert agent.propose(ctx) == agent.propose(ctx)   # 同じコンテキスト → 同じ提案

この境界が三つの性質をもたらす:

  • テスト可能:propose()は純粋関数なので、世界をモックせずにロジックをテストできる
  • 安全:バグやジェイルブレイクされたエージェントは悪い「提案」を生むだけで、悪い「アクション」は起こせない。被害はガードレールか人間の拒否で止まる
  • 合成可能:エージェントが別のエージェントを呼ばない。処理はsubstrate(案件テーブル+スケジューラ等)を通して流れるので、隠れた副作用の連鎖が生まれない

これにより「AIが説明できない何かをした」が「AIが何かを提案し、誰がそれを承認したかが明確に残る」に変わる。


自由なReActループを固定グラフに置き換える

ReAct(Reasoning + Acting)とは、モデルが「思考→行動→観察」を繰り返しながら次のステップを自ら決定するエージェント設計パターンだ。プロトタイプや探索用途では強力だが、本番環境では「次に何が起きるか事前に分からない」という性質が致命的になる。実行パスがモデルの推論によって毎回変わるため、テストが書けず、レイテンシの上限も定まらず、監査証跡を事後に再構築しなければならない。規制当局や社内コンプライアンス部門が求める「なぜその決定をしたか」の説明が、構造上困難になるのだ。

代わりに、各ケイパビリティを固定ノード列としてモデル化する。

entry → load context → reason (LLM) → output guardrail → verify →
        judge (sampled) → compose confidence → route → prepare proposal → record → exit
自由形式のReActループ 固定グラフ(この手法)
制御フロー モデルが次のステップを決定 事前に既知
テスト性 困難(パスが変動) 各ノードを独立でテスト可能
レイテンシ/コスト 上限なし 有界・予測可能
監査 トレースから再構築が必要 毎回均一な行

グラフは12ノードで構成され、大半は全ケイパビリティで共有される。新しいケイパビリティを追加するには約4ノードを実装するだけでよい。

1  entry              decision_id生成、テナントとIDのバインド    [共有]
2  pre_check          入力検証、参照解決、早期リジェクト          [ケイパビリティ固有]
3  context_load       この決定に必要なコンテキストのみ取得         [共有]
4  llm_decision       推論ステップ — 構造化入力・構造化出力       [ケイパビリティ固有]
5  output_guardrail   モデル出力のPII/ポリシースクラブ            [共有]
6  verification       決定論的な正確性チェック                   [ケイパビリティ固有]
7  judge              高リスク時のサンプリング済み二番手レビュー   [共有]
8  confidence_compose シグナルからのスコア合成                   [共有]
9  routing            auto / hitl / reject の振り分け            [共有]
10 prepare_proposal   最終提案の成形                             [ケイパビリティ固有]
11 memory_write       エージェント自身の監査メモリへの書き込み     [共有]
12 exit               不変のレジャーエントリを追記                [共有]

モデルの出力を構造化する:自由記述を排除する

LLMの脆弱性の多くは一つの選択から来る。モデルに自由記述を返させ、それをパースしようとすることだ。

「了解です!おそらくApproveだと思いますが、Escalateの可能性も……」——こうなると、文字列からApproveを抽出するNLU問題を抱えることになり、明日のモデルの言い回し変化でregexが壊れる。

解決策は単純だ:モデルに検証済み構造体を出力させ、それ以外は失敗としてリトライする。

from pydantic import BaseModel
from typing import Literal

class Decision(BaseModel):
    decision: Literal["approve", "escalate", "reject"]   # enumそのものがガードレール
    confidence: float
    reasons: list[str]
def decide(base_prompt, model, max_retries=2) -> Decision:
    prompt = base_prompt
    for _ in range(max_retries + 1):
        raw = model.generate(prompt, schema=Decision.model_json_schema())
        try:
            return Decision.model_validate_json(raw)
        except ValidationError as e:
            prompt = f"{base_prompt}\n\nYour previous output was invalid: {e}. Return JSON only."
    raise NonConformingOutput()   # 失敗時はクローズ — 推測を下流に渡さない

ただし、構造化は形式を制約するものであり、正確性を保証しない点は明記されている。{"decision":"approve","confidence":0.99}が完全に間違っている可能性はある。構造化出力はパース失敗モードを除去するが、判断失敗モードは除去しない。その上にevalや検証レイヤーが必要になる。


ルーティングと不変の監査台帳

信頼度スコアは4つのパスに変換される:

def route(confidence: float, verified: bool, T: float = 0.85) -> str:
    if not verified:           return "reject"
    if confidence >= T:        return "auto"
    if confidence >= T - 0.2:  return "hitl_recommended"
    return "hitl_required"

閾値Tはケイパビリティごとの設定値であり、定数ではない。記事は「最初は保守的に——すべてを人間へ——設定し、データが安全性を証明した範囲でのみ下げよ」と推奨する。

すべての決定は不変の一行として監査台帳に記録される:

CREATE TABLE decision_ledger (
  decision_id     TEXT PRIMARY KEY,
  inputs_hash     TEXT NOT NULL,     -- 生の機密データではなくハッシュ
  outcome         TEXT,              -- 後から新しい行として追記、UPDATEしない
  supersedes      TEXT REFERENCES decision_ledger(decision_id),
  prev_hash       TEXT               -- 改ざん証跡のためのハッシュチェーン
);
-- UPDATE/DELETEの権限を剥奪。修正は上書きではなく新行で行う。

UPDATEできる台帳は監査証跡ではない。


この設計の要点は、モデルの「賢さ」を一ノードに閉じ込め、残りをすべて普通のソフトウェアとして扱うことだ。予測不能性を排除しつつ、判断が必要な箇所にのみモデルの能力を使う。

詳細はPart 1: Make your AI agents boring: the determinism layerを参照していただきたい。